Back to work

Live platform, solo-built

From schema to sideline: referees score real games on what I designed and built.

Four apps, four different ways of handling data. One Postgres schema serves three of them, and the fourth runs entirely on the device with no backend at all. A score entered by a referee reaches the standings without anyone retyping it, and the league configures the whole thing itself.

Role
Sole developer & architect
Timeframe
Since June 2026, ongoing
Scale
~28,000 lines ยท 4 apps
Status
Live, field-validated

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.

Constraint Whatever scored a live game had to work for non-technical volunteer referees, standing on a field, sometimes with patchy signal. Reliability and offline support weren't optional.

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:

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.

The tournament site in organiser mode: the same header and navigation as the public view, with an ORGANISER badge in place of the sign-in control and an extra tab that is hidden for everyone else. The panel below manages a roster of officials, marks who is eligible to lead a game, and sets a per-person PIN.
Signed in as an organiser: same app, same shell, one extra tab and a roster to manage. Signed out, that tab is not in the navigation at all. Demo data, seeded for these screenshots: the live apps show real people's names. Scroll the image sideways to read it all.

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

Four apps, four access patterns. Three share one Postgres schema, the fourth never touches it, and the tournament site's organiser mode configures what they all read. Scroll the diagram sideways to read it all.

The outcome

4

apps live in production, three of them on one shared Postgres schema.

11tracked migrations, no ORM
~28Klines of TypeScript
The tournament site's bracket view: two divisions on separate tabs, quarter finals through to the final, each fixture showing its time, its field, both teams with their colours, the resolved score and the officials assigned to it. A control in the corner exports the bracket as an image.
The bracket the tournament runs from. Winners resolve forward as scores land, and the whole thing exports to an image for printing. Demo data, seeded for these screenshots: the live apps show real people's names. Scroll the image sideways to read it all.

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 scoring app on a phone, mid-game: the current period and both scores across the top, the live count beneath, then six large tap targets for each thing an official records, with undo and redo along the bottom.
The scoring app at the width it is actually used, one-handed, on a field. Every action is a single large tap, and every one of them is undoable. Demo data, seeded for these screenshots: the live apps show real people's names.
A standings table for two divisions. Each row carries a team, its won, drawn and lost counts and its runs scored, ordered by wins first, then draws, then runs. Two teams level on every column share a rank rather than being split arbitrarily.
Downstream of the scoring app, with nobody retyping anything in between. The order resolves on wins, then draws, then runs scored, and two teams level on all three share a rank. Demo data, seeded for these screenshots: the live apps show real people's names. Scroll the image sideways to read it all.

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.

TypeScriptReactVite PWA / offline-firstSupabase / Postgres pnpm workspacesGitHub ActionsCloudflare Pages