npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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-ui

Peers: 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 once

prefetchGameStats 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 − ratingBefore over matches({ player: '*', rated: true }), paged back 50 at a time until the window is covered, at most changePages (6) pages. When the cap runs out first, the column says Since <date> instead of 30 days. A player with no rated match in the window shows —, not 0.
  • 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, prefetchPlayer warm 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>-bg under --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.