StatsHub Docs

Conventions

The rules for writing code in the product site — where a filter goes, where state lives, what a new board must reuse, and how strings get translated.

Rules that are enforced by review rather than by a compiler, each with the failure it exists to prevent.

Filters live in the filter panel

Every filter a board has belongs in its panel, in a src/components/filters/<board>-filters.tsx that renders FilterGroups. That file is the complete set, and it is what FullToolFilters mounts.

The top bar is a mirror, never the only home. Put a filter there when it is changed between one look at the results and the next — a date, a league, which fixtures are in scope — and bind it to the same state the panel control uses, so the two can never disagree.

Settings chosen once and left alone stay in the panel, where they do not compete for the bar's width.

Two bugs follow from breaking this

A filter reachable only from the top bar is a bug — the panel is where someone goes to find out what can be narrowed, and a control that is not there does not exist. So is state that filters the request with no control bound to it: the results are wrong and nothing on screen explains why.

A new board reuses three things

Before writing a board, know that these already exist:

  1. FeatureTemplate — the page shape. Do not assemble the filter bar, panel and body by hand; see Architecture.
  2. src/components/filters/<board>-filters.tsx — the panel contents.
  3. src/composed/<board>/ — the board's own parts. Anything a second board would want goes to src/components or, if a second app would want it, to @statshub/ui-web.

Filter state goes in the URL

nuqs, in about forty files. A board's state is its query string, which makes a filtered view a link — someone can send you exactly what they are looking at, and the back button steps through filter changes because they are history entries.

That is also why the mirror rule above works: the bar control and the panel control both read and write the same nuqs key rather than two pieces of state someone has to synchronise.

For the wider rule about which cache owns what, see Where state lives.

Server components fetch; client components filter

A page's data is read in its page.tsx and handed down as props. The client component below it owns the address bar and the table, and does not fetch. Reach for SWR when the answer has to change without a navigation — see Data.

Strings go through the catalog

Text visible to a reader comes from statshub-i18n, never from a literal in JSX:

const t = translateCurrent;
<span>{t("filters.allFilters")}</span>

The catalogs are en, es and pt. translateCurrent and the useLocale hook are re-exported from src/contexts/locale-context.tsx — which is a re-export on purpose, because the implementation is shared with the native app so the two cannot drift.

The layout reads the statshub_locale cookie and passes the initial catalog down, so the first paint is in the reader's language rather than English replaced a frame later.

Fonts are self-hosted

Through next/font, not a <link> to Google. A blocking stylesheet request to a third party on every page is one problem; the one that actually bit was quieter — --font-bebas is the heading font in 30 components and nothing was loading it, so every font-bebas heading rendered in the fallback sans for months.

display: "swap" keeps text visible while the file arrives. The default hides headings for up to three seconds on a cold load.

What the gates do not catch

bun run lint and bun run typecheck will not tell you that a filter has no control, that a portal found no target, or that a board renders below the fold. Open the route. bun run test is vitest, and covers the pure helpers — odds-normalizer, tools, shared-config — not the pages.

On this page