1The problem
A recreational sports league was running entirely on spreadsheets, paper score sheets and group texts. There was no live way to see a tournament bracket update, no shared source of truth for standings, and worst of all, referees on the field had no reliable way to score a game and have it show up anywhere else. Anything that needed to be "live" wasn't.
2The approach
Rather than one monolithic app, I split the problem by who uses it and how they read/write data, and built four independently deployable apps sharing one data layer:
- Referee scoring PWA: the single-writer, real-time core. Installable and offline-capable: scoring state is written to the device on every action, so a dropped signal or a closed tab never loses a game in progress. Submitting the final result needs a connection, and says so rather than failing quietly.
- Tournament site: bracket generation and display, PNG export, multiple divisions.
- Schedule / standings: a read-heavy app computing points-based standings with tiebreakers, consuming what the scoring app writes.
- Captain lineup tool: fully client-side and offline-first by design, no backend dependency at all.
The tournament site doubles as the organiser console. An organiser PIN unlocks roster management, referee assignment and the switches the other apps read, and the organiser tab is filtered out of the navigation for everyone else rather than shown and disabled. The PIN is a server-side secret, exchanged once for a session token by an edge function; an earlier version had the client send it with every write, so every organiser's browser held it. Building this as a role-gated mode rather than a separate admin app means the league learns one interface, not two, and it is how they change a season without going through the database, or through me.
All four sit in a pnpm monorepo sharing a UI kit, with a typed Supabase client factory shared by the three that talk to the database. The Postgres schema (internally called GRIMMERIE) is documented with a DBML/Mermaid ERD and versioned through eleven hand-written migrations, with deliberately no ORM, so every change is a reviewable SQL file rather than a generated diff.
CI is deploy-gating, not deploy-owning: GitHub Actions runs typecheck, lint and 25 Vitest test files across three of the four apps on every push to main, and Cloudflare Pages (four separate projects off the same repo, one per app) owns the actual deploy. That split kept each app independently shippable without a shared release train.
3The outcome
apps live in production, three of them on one shared Postgres schema.
The schema has taken eleven migrations since June while serving three live apps with different read/write patterns, with no ORM to obscure what each change did. The lineup tool is deliberately client-only, so it never touches the schema at all. All four apps are live and field-validated: the scoring PWA has been used by real referees to score real games under real field conditions, not demoed once and shelved.
The tournament site's config-flag pattern let a second tournament format ship from the same codebase without a fork. And season setup, divisions and rules are now the league's to change rather than a request that has to come to me.
4The backlog
Top of the backlog is instrumentation. I built four apps and instrumented none of them: no games scored, no active users, no usage by app. It's the exact thing I'd have insisted on before launch if this were work, and building it for myself is where I noticed I hadn't. Below it, the lineup tool: it runs entirely on the device and doesn't touch the shared schema yet, which was the right call to get it shipped and is the next thing I'd revisit.
This project is also where most of my current front-end fluency comes from: I lean on dbt/SQL for the pipeline side day to day, but this is where I keep the React/TypeScript muscle live.