@hadoku/game-stats-ui
v0.1.12
Published
React screens for the hadoku game-stats store: record, ratings, match history, attr breakdowns and leaderboards for any game, drawn from @wolffm/prefs-client/games.
Maintainers
Readme
@wolffm/game-stats-ui
The stats screens every hadoku game shares: a player's record and rating, their
recent matches, what wins (grouped by any attr), lifetime counter totals, and
the friend group's leaderboards. Data comes from the game-stats store in
prefs-api through @wolffm/prefs-client/games; colours are
@wolffm/themes tokens. Design: docs/planning/game-stats-2026-10-09.md.
Nothing in the package knows which game it is drawing. A game supplies what is particular to it — which attrs to break down, how to draw an attr value (a colour pip, a sprite), what its counters are called — through props.
pnpm add @wolffm/game-stats-uiPeers: react 18/19, @wolffm/prefs-client 1.x, @wolffm/themes 5.x.
A stats screen in one component
import { useMemo } from 'react';
import { createGameStatsClient } from '@wolffm/prefs-client/games';
import { GameStats, formatAttr, type RenderAttr } from '@wolffm/game-stats-ui';
import '@wolffm/game-stats-ui/style.css';
const renderAttr: RenderAttr = (key, value) =>
key === 'sprite' ? <SpriteIcon sprite={String(value)} /> : formatAttr(value);
export function StatsScreen({ player }: { player?: string }) {
const client = useMemo(
() => createGameStatsClient({ gameId: 'bomberman' }),
[]
);
return (
<main>
<h1>{player ?? 'Stats'}</h1>
<GameStats
client={client}
mode="ffa"
player={player}
seatAttrs={['sprite']}
matchCounters={['rounds']}
profileCounters={['kills', 'chainKills', 'deaths']}
counterLabels={{
kills: 'Kills',
chainKills: 'Chain kills',
deaths: 'Deaths',
rounds: 'Rounds',
}}
breakdowns={[{ attr: 'sprite', title: 'Sprites', label: 'Sprite' }]}
leaderboards={[
{ by: 'rating', title: 'Ratings', valueLabel: 'Rating' },
{ by: 'counter:kills', title: 'Most kills', valueLabel: 'Kills' },
]}
renderAttr={renderAttr}
renderName={(name) => (
<a href={`#/stats/${encodeURIComponent(name)}`}>{name}</a>
)}
/>
</main>
);
}Each section loads on its own: it is busy until its read lands, says No games yet when there is nothing to draw, and a failed read fails that section only — a signed-out or forbidden read says Sign in to see stats. rather than a status code.
A breakdown's React key and testid suffix is <attr> (or <attr>-everyone).
Two breakdowns of the same attr and scope must set id to tell them apart.
ratedOnly reads only the matches that ranked, in the record, the history and
every breakdown — the boards never count anything else. A game reports a match
unrated when a bot, a guest or a test account sat in it, so this leaves them off
the screen while the store keeps them.
A breakdown can be narrowed and dressed by the game: match keeps the matches
whose scalar match attr is one of the values ({ format: ['standard'] }), with
carries seat attrs on each row off its latest seat (renderAttr's third
argument — a deck's colours), filterRows narrows the answered rows without a
read (a name search), and toolbar is drawn under the heading in every state,
so a filter that empties the table stays reachable.
GameStats reloads a section when mode, ratedOnly, player or a
breakdown's match / with changes. It does not watch
the client's identity, so create the client once (useMemo, module scope).
Opening instantly: cache and prefetch
Each section reads on mount, so a cold screen shows busy cards for as long as the store takes. Give the screen a cache and warm it when the page loads:
import { createStatsCache, prefetchGameStats } from '@wolffm/game-stats-ui';
const client = createGameStatsClient({ gameId: 'cardtable' });
const cache = createStatsCache(); // one per client; values reused for 30 s
const props = { client, mode: 'constructed', breakdowns: BREAKDOWNS };
prefetchGameStats({ ...props, cache }); // at page load: the screen's own reads
<GameStats {...props} cache={cache} />; // later: draws at onceprefetchGameStats warms exactly the reads GameStats makes for the same
props (one read plan serves both). A value older than maxAgeMs is drawn at
once and refreshed behind it; a failed refresh keeps the old value on screen.
Errors are never cached. cache.clear() drops everything, e.g. after a match.
The pieces
When a game wants its own layout, compose the parts directly:
| Export | Draws |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| RecordSummary({ profile, mode? }) | games, W–L, win rate, rating in one pool |
| CounterSummary({ counters, keys?, flags?, … }) | each counter's lifetime total and single-match best; flags (0/1 per match) show no best |
| LeaderboardTable({ rows, valueLabel, … }) | a ranked board; the viewer's row carries data-me |
| BreakdownTable({ rows, attr, label, renderAttr? }) | W–L grouped by one attr, most played first |
| MatchList({ matches, player?, seatAttrs?, … }) | recent matches, the player's seat first, result from that seat |
| StatsSection({ title, load, loadKey, … }) | a titled card that runs its own read: busy, content, empty line or error |
| useStatsLoad(load, loadKey) | the hook under StatsSection |
renderAttr(key, value) receives the attr's name and its value (a string,
number, boolean, or — for a set-valued attr like a colour list — a string
array); the default draws plain text. renderName(name) draws a registry name;
the default is plain text. A loadKey must name everything its load reads.
The stats pages
The redesigned leaderboard and player pages are built from blocks that each
load their own reads, and share them: blocks given the same client (and the
same cache, or none) read a thing once. Every block has a pure twin that
takes data instead of a client.
| Block (loads) | Pure twin | Draws |
| ------------------------------------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Rankings({ client, mode, by?, sorts?, … }) | RankingsTable({ rows, changeLabel }) | rank badge, avatar + name, You, rating, 30-day move, W–L, win-rate bar, games; sort chips |
| MatchFeed({ client, player, match?, filters? }) | MatchFeedList({ matches, player, now }) | player: '*': each seat with its result and move; a name: that seat vs the rest; Older pages |
| ProfileHeader({ client, player, mode }) | ProfileSummary({ profile, place, change }) | avatar, name, #n of N, last played, rating with its move, W–L, win rate, games |
| RatingHistory({ client, player, count? }) | RatingChart({ series, now }) | the rating over the last count (30) rated matches, ticks only at values it reaches |
| HeadToHead({ client, player, limit? }) | HeadToHeadList({ rows }) | W–L against each opponent, by placement, with a split bar |
const renderAttr: RenderAttr = (key, value) =>
key === 'colors' ? <Pips colors={value} /> : formatAttr(value);
<Rankings client={client} mode="constructed" renderName={link} />
<MatchFeed
client={client}
mode="constructed"
ratedOnly
player="*"
seatAttrs={['colors', 'deck']}
renderAttr={renderAttr}
meta={['attr:format', 'counter:turns', 'attr:endReason']}
counterLabels={{ turns: 'turns' }}
match={format === 'all' ? undefined : { format: [format] }}
filters={<Chips label="Show" options={FORMATS} value={format} onChange={setFormat} />}
/>- The 30-day move is each player's summed
ratingAfter − ratingBeforeovermatches({ player: '*', rated: true }), paged back 50 at a time until the window is covered, at mostchangePages(6) pages. When the cap runs out first, the column saysSince <date>instead of30 days. A player with no rated match in the window shows—, not0. - A player's history (header move, chart, head-to-head) is one read of
their matches, at most
historyPages(4) pages of 50. - Prefetch:
prefetchRankings,prefetchMatchFeed,prefetchPlayerwarm exactly what the blocks read, so a page opens drawn. - Avatars are an initial on a tint chosen by the name; pass
renderAvatar(name, size)to draw a game's own art. - Results are tint chips (
--color-<f>-bgunder--color-on-<f>-bg), the pair every theme holds to 4.5:1; bars and the chart line carry colour beside a number, never instead of one. Wide tables scroll inside their own box.
Styles
@wolffm/game-stats-ui/style.css is a plain file, not imported by the JS, so a
game that mounts inside a shadow root can place it in that root (?inline in
Vite). Every selector is a gs- class — nothing targets :root, html or
body — and every colour is a theme token, so the host's theme applies.
