StatsHub Docs

Conventions

How code is written in this app — comment density, naming, imports, and the testing policy.

Comments

This codebase is heavily commented on purpose. Match it.

Comments explain why, not what. If the code says what it does, the comment says why it is that way: what was tried before, what breaks if you change it, which of two reasonable options this is and what the other one cost.

The test for whether a comment earns its place is whether it would stop someone from making a change that looks obviously correct and is not. A comment saying "loop over the players" fails that test. A comment saying "this list cannot be virtualised because the parent is already a scroll view, and nesting them collapses it to zero height" passes.

Numbers picked by eye get a sentence about why that number. Almost every hardcoded dimension in the UI has one, and it usually records something that was tried at two other values first.

Use // MARK: to divide a long file into sections.

Naming

Files are kebab-case. Components are PascalCase. Everything else is camelCase, except constants that are genuinely constant, which are SCREAMING_SNAKE.

A block file is named after its screen and ends in -block. A composed screen is named after its screen. A route is named by its URL. The three files for one screen should be findable by typing the screen's name once.

Types

Strict TypeScript, and it is expected to pass before you commit.

Prefer making a bad state unrepresentable over checking for it. The tournament ID branding in the data layer is the model: three types instead of three comments saying "don't mix these up".

Props interfaces are exported when a block is genuinely reused. Do not export one just because it exists.

Defining a component

Where a component goes is decided in architecture.md. This is what the file looks like once you are there.

One component per concern, in a kebab-case file named after it. A file may hold several when they only make sense together — a section and the row that goes inside it — but not because two unrelated pieces happened to be written on the same afternoon.

Export by name. Two files still have a default export as well, text.tsx and button.tsx, and that is history rather than a pattern to copy.

The file opens with a doc comment saying what the component is for and why it is built this way.

Props are typed inline. Sixty files do this and fifteen declare a named interface; declare one when the shape is genuinely reused or when the component is generic enough that callers need to name it, and not otherwise. Document individual props with a comment above them when the name does not carry it.

Nullable inputs are drawn as absent, never invented. Most of this app's data arrives half-filled, and the difference between "no odds priced" and "odds of 0.00" is the sort of thing that makes a screen lie. A player with no position gets a blank second line, not the word "Player".

Numbers picked by eye get a sentence about why that number, as everywhere else.

Navigation is driven from wherever the press actually fires, and there are only two shapes in this app.

The default is <Link asChild> around a Pressable. asChild does not make its child tappable — it clones an onPress onto whatever element it is given and trusts that element to fire it. A Pressable does. A plain View accepts the prop and silently drops it, and nothing fails loudly: the screen renders, the press feedback may even run, and the tap goes nowhere. That is exactly how Home's tool launchers shipped dead — a Link around a View around a glass button, every layer individually reasonable and none of them navigating. Link.Trigger is the same contract with a menu attached; the thing inside it is still a Pressable.

The exception is a control whose press is native — a SwiftUI button inside a Host, which is what GlassCircleButton is on iOS 26. No cloned prop reaches a hosted control's handler, so a Link around one is inert by construction. Such a control owns the route along with the press: router.push goes in the same onPress as the haptic tick — see ActionButton and the onboarding hive's cells. Do not add a Link on top for semantics; it is the dead layer described above.

So the rule when wiring a press: find the element that actually receives the touch. If it is an RN Pressable, the Link above it navigates and the callback is side effects only. If it is hosted native, navigate in its handler and there is no Link. A Link whose subtree contains no RN pressable is decoration, and decoration that looks like the thing that navigates is worse than none.

Nothing pushes the screen it is already on

Neither shape has to check where it is going. Every Stack in app/ carries UNSTABLE_router={dropDuplicatePush}, so a PUSH whose route and params match the screen already on top is dropped by the navigator before it becomes state — <Link>, router.push, a deep link and a restored session all take the same road, so guarding a push() helper would have covered one of them.

That means a row linking to the fixture you are reading is not a bug to fix at the call site, and a double-tap during the push animation cannot land twice. It also means the rule is invisible where it applies: bun run check:navigation fails when a new _layout declares a Stack without it. The reasoning, and what counts as the same route, is in lib/core/duplicate-push.ts.

Formatting and linting

Biome handles both.

bun run lint      check
bun run format    fix

Both should be clean before you commit. Import ordering is part of this and is fixed automatically, so do not hand-sort imports.

Type

The system font, in its default design, everywhere.

No custom typeface, no fontFamily, no expo-font — and no SF Rounded either, which is the one that gets in by not looking like a custom font: it is still the system face, so it costs no asset and passes every check, and it is still a different typeface from the one the rest of the app and every piece of system chrome is set in. font({ design: "rounded" }) on a SwiftUI text is the form it takes here; the fixture hero carried it on the competition, the date, both club names and the score.

Size and weight are the only two dials. A heading that needs to feel different from the line under it differs by those, which is how the platform's own headers do it.

Commits

One idea per commit. If the message needs the word "and", it should have been two commits.

Present tense, lower case, no trailing full stop. drop unused expo-font dependency, not Dropped unused expo-font dependency.

Run the type check before committing.

Vendored code

There is none, and that is worth keeping.

src/ui was an external design system inlined into the tree — ninety-odd files and eleven thousand lines, exempted from the house rules on the grounds that it might one day be re-synced with upstream. It never was. What the app imported from it, in the end, was Text, Button and a provider: about three hundred lines of real behaviour behind a toast system, a portal host, a colour kit and a press-feedback engine that nothing rendered.

Those three are written here now, in components/, in the house style, and the folder is gone. The lesson to carry: a dependency you have copied into the repo is code you own whether or not you are allowed to touch it, and "we might re-sync it" is a bet that ages badly. Vendor a library and you keep all of it; write the part you use and you keep that.

Dead code

Delete it. Do not comment it out and do not leave it behind a flag that is always false. Git remembers.

An export that nothing imports is a maintenance cost with no benefit. If something is only used inside its own file, it should not be exported at all.

On this page