← Back to work

Case study 01 · 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

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.

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.

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:

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. 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.

Architecture diagram: four apps on the left with their access patterns, a shared data layer in the middle holding the typed Supabase client and the Postgres schema, and the CI and deployment path on the right. The tournament site and the referee scoring PWA write to the schema, the schedule and standings app reads from it, and the captain lineup tool connects to nothing because it runs entirely on the device. Below the data layer, the tournament site's organiser mode writes the configuration the apps read.
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.

3The 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. 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.
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. 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.

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.

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