Files
factorio-expcluster/exp_player_statistics/web/components/StatisticsPage.tsx
T
bbassieandClaude Opus 5.5 ad1c83def8 Address review on the statistics plugin
The plugin is now exp_player_statistics (@expcluster/player-statistics),
with the module, global, permission, route and database directory
renamed to match. The exp-statistics locale section keeps its name.

Every statistic is tracked unconditionally, so the enable flags are gone
from config.lua. Playtime and JoinCount are no longer counted in game:
clusterio already keeps online time and joins for each user, so the
controller fills them in from the user when a player's totals are
requested, and the web page does the same from the user list. They are
not stored or adjusted by the plugin.

The web table has a column picker, a multiple select with check boxes,
Select all and Reset buttons, showing "N statistics selected". The
choice is kept in local storage. The user page shows the statistics of
the player in a grid like the core player stats.

The readme statistics table drops its second title and uses the short
time format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:36:00 +00:00

115 lines
3.9 KiB
TypeScript

import React, { useMemo, useState } from "react";
import { Button, Checkbox, Divider, Select, Space, Table } from "antd";
import type { ColumnsType } from "antd/es/table";
import { PageHeader, PageLayout, useColumnSearch, useTableQueryState, useUsers } from "@clusterio/web_ui";
import { PlayerStatistics, userStatistics } from "../../messages.js";
import { usePlayerStatistics } from "..";
import { formatValue, statistics } from "../statistics";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
const storageKey = "exp_player_statistics.columns";
const defaultColumns = [
"Playtime", "AfkTime", "MapsPlayed", "JoinCount", "ChatMessages",
"RocketsLaunched", "ResearchCompleted", "MachinesBuilt", "Kills", "Deaths",
];
function loadColumns() {
try {
const stored = JSON.parse(localStorage.getItem(storageKey) ?? "null");
if (Array.isArray(stored)) {
return stored.filter(name => statistics.some(([statistic]) => statistic === name));
}
} catch { /* fall back to the defaults */ }
return defaultColumns;
}
export default function StatisticsPage() {
const [players, synced] = usePlayerStatistics();
const [users] = useUsers();
const [columns, setColumnsState] = useState<string[]>(loadColumns);
const tableState = useTableQueryState<PlayerStatistics>({
namespace: "statistics",
defaultSortKey: "Playtime",
defaultSortOrder: "descend",
pagination: { defaultPageSize: 50 },
});
const playerSearch = useColumnSearch<PlayerStatistics>(tableState, "player", player => player.id, "Search players");
function setColumns(names: string[]) {
// Keep the display order whatever order they were picked in
const ordered = statistics.map(([name]) => name).filter(name => names.includes(name));
setColumnsState(ordered);
localStorage.setItem(storageKey, JSON.stringify(ordered));
}
const rows = useMemo(() => [...players.values()].map(player => new PlayerStatistics(
player.id,
{ ...player.stats, ...userStatistics(users.get(player.id.toLowerCase())) },
player.updatedAtMs,
)), [players, users]);
const tableColumns: ColumnsType<PlayerStatistics> = [
{
title: "Player",
key: "player",
fixed: "left",
render: (_, player) => player.id,
sorter: (a, b) => strcmp(a.id, b.id),
sortOrder: tableState.sortOrder("player"),
filteredValue: tableState.filteredValue("player"),
...playerSearch,
},
...statistics.filter(([name]) => columns.includes(name)).map(
([name, title]): ColumnsType<PlayerStatistics>[number] => ({
title,
key: name,
align: "right",
render: (_, player) => formatValue(name, player.stats[name] ?? 0),
sorter: (a, b) => (a.stats[name] ?? 0) - (b.stats[name] ?? 0),
sortDirections: ["descend", "ascend"],
sortOrder: tableState.sortOrder(name),
})
),
];
const columnSelect = <Select
mode="multiple"
style={{ minWidth: 220 }}
value={columns}
onChange={setColumns}
options={statistics.map(([value, label]) => ({ value, label }))}
maxTagCount={0}
maxTagPlaceholder={() => `${columns.length} statistics selected`}
placeholder="No statistics selected"
menuItemSelectedIcon={null}
optionRender={option => <Checkbox checked={columns.includes(option.value as string)}>
{option.label}
</Checkbox>}
popupRender={menu => <>
{menu}
<Divider style={{ margin: "4px 0" }} />
<Space style={{ padding: "0 8px 4px" }}>
<Button size="small" onClick={() => setColumns(statistics.map(([name]) => name))}>Select all</Button>
<Button size="small" onClick={() => setColumns(defaultColumns)}>Reset</Button>
</Space>
</>}
/>;
return <PageLayout nav={[{ name: "Statistics" }]}>
<PageHeader title="Statistics" extra={columnSelect} />
<Table
size="small"
columns={tableColumns}
dataSource={rows}
loading={!synced}
rowKey={player => player.id}
pagination={tableState.pagination}
onChange={tableState.onChange}
scroll={{ x: "max-content" }}
/>
</PageLayout>;
}