The 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.
The 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.
How the four apps share one schema.
Four apps, four different access patterns. Three read or write the same schema, the fourth never touches it, and the tournament site's organiser mode configures what they all read.
1. Apps
four clients, four patterns
- Tournament site brackets, divisions, PNG export writes results
- Referee scoring PWA single writer, offline-capable, in-field writes live scores
- Schedule & standings read-heavy, computes tiebreakers reads only
- Captain lineup tool fully client-side, on device no backend, by design
2. Shared data layer
one schema, no ORM
- Postgres schema 11 tracked migrations, hand-written no ORM: every change a reviewable SQL file documented ERD (DBML + Mermaid)
- @emerald/db typed Supabase client, generated types
- @emerald/ui shared header and nav, as TS source
3. Ship
CI gates, Cloudflare deploys
- GitHub Actions typecheck, lint, 25 Vitest files on every push to main
- Cloudflare Pages 5 projects: one per app, plus a tournament-config variant
CI gates code quality. Cloudflare owns the deploy, so every app ships on its own, with no shared release train.
Organiser mode, tournament site
Season setup, divisions, rules
run by the league, not by me
writes the configuration the apps read
The 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.
The 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.