Track record

It started with a goal song.

A team management platform for my son's junior football team. It was not planned, it was not scoped, and nobody asked for most of it. It began with one comment on the boundary line and got out of hand from there.

Next.js 15React 19TypeScriptTailwind Drizzle ORMSupabasednd-kitVitestVercel
The app on a phone: a player profile showing the jersey and number, games, goals and behinds for the season, and the player's goal song with a play button beside it
Role
Sole developer
Status
In production, every match day
Unit tests
223 passing
Running cost
Nil

One comment on the boundary line

A parent at training said it would be great if the boys could have a goal song play over a speaker when they scored, the way it happens at a professional game. That was the entire brief.

1feature, to begin with

So I built that, and only that

A player profile, a song attached to it, and a phone on the boundary paired to a Bluetooth speaker. Somebody taps the goal, the kid's song plays. That was the whole thing, and for about a week it was enough.

Then I could not help myself. If the app knows who scored, it knows the score. If it knows the score, it can keep the quarter-by-quarter. If it knows who is on the field, it can tell you who has not had a turn in the midfield yet. Each answer was one honest step from the last, and a season later it runs the team.

The field position creator: an AFL oval with players placed in position slots, a bench row underneath, and a strip of coaching roles across the top
The field position creator. Coaches place players by dragging them onto the oval, several people can be looking at the same lineup, and nothing is official until someone publishes it.

What it does on a match day

Before the siren

Lineups by dragging players onto the field

A real AFL oval drawn in SVG, with position slots on it. Drag a player in and anyone already there is bumped rather than overwritten, because a coach mid-thought should not lose a placement to a mis-drop. The swap and bump behaviour is a pure function with its own tests, kept separate from the interface.

During the game

Live scoring several people can touch at once

More than one volunteer keeps stats, so the app hands each of them the categories they claimed and locks the claims at the first siren. Every tap is reflected instantly and reconciled against the server afterwards, because the person recording it is watching the play, not the screen.

Across the season

Analytics that answer coaching arguments

Margin trend, shot accuracy, whether scoring first actually predicts the result, home against away, and per-quarter strength. Built to settle the questions that otherwise get settled by whoever remembers last week most confidently.

Through the week

Training, attendance and a drill library

Sessions, an attendance grid, and a library of drills that accumulates over a season. Rosters, player profiles, injury logs and a fixture ladder sit alongside it.

A player profile screen showing season counts, preferred positions, a goal song with the track attached, and a notes field
A player profile, with the goal song attached to it. The feature the whole thing started from is still sitting there in the middle of the form. Names shown are placeholder data, not real players.
The analytics screen showing win rate, margin trend, shot accuracy, home against away and per-quarter strength
Season analytics, at the width it was designed for. Everyone using this is holding a phone.
The drill library, showing coaching drills as cards, each with a diagram of player movements on the field
The drill library. Coaches build it up across a season and reach for the same handful most weeks, so favourites and run counts sit on the card rather than behind a menu.

The part nobody asked for that gets used most

The match feed draws the margin as it moves, with every goal marked and named. It was built so the people at the ground could see the shape of a game they were already watching. That is not what it ended up being for.

A finished match screen: Hawks 9.5 (59) beat Blue 8.8 (56) by three points, with a quarter-by-quarter table and a graph of the margin across the match, each goal labelled with the scorer
A three-point win, drawn as it happened. Down by 23 in the first quarter, in front by 33 in the third.

Families away on holiday watch it live

When a player is away, their family opens the app and follows the match from wherever they are. The worm moves, the goals appear with the scorer's name on them, and the clock ticks.

Nobody specified that. It came out of building the thing properly and then finding out what people did with it, which is the argument for doing it properly in the first place.

Doing it properly, on a volunteer club's budget

Every developer's portfolio says the work is careful. These are the entries from this project's log that show what careful actually cost. All four are in production.

$0a month to run

Live, secure, and free to keep running

A junior club has no software budget and no appetite for a subscription that renews forever. The constraint was that this had to be genuinely live, properly access-controlled, and cost nothing to keep running once I stopped paying attention to it.

Sign-in is by emailed magic link, so there are no passwords to store, reset or breach, and public sign-up is switched off entirely. Hosting and the database both sit inside free allowances at this size. The club can keep using it whether or not I am still involved, which is the same test every Leapfrog build has to pass.

Every table

Row-level security on the whole schema, not the tables you remember

A hosted database of this kind hands the browser a public key, and the only thing standing between that key and a row is the security policy on its table. The trap is that switching security on without attaching a policy looks like protection in the dashboard and is not, so a schema can never be trusted table by table. It gets audited as a whole.

Every table carries row-level security with a policy attached to it, and the schema audits clean. The durable part is the standing rule rather than the audit: a new table ships with its policy file, or it does not ship. On a platform holding children's names and parents' phone numbers, that is not a preference.

Connection pooling

The stall that no server-side timeout can see

A database of this shape sits behind a connection pooler, and a pooler will retire a pooled connection without telling the client holding it. The driver then dispatches its next query onto a socket that is already gone and waits client-side, which is the part that matters: no server-side timeout is watching, because as far as the server is concerned nothing was ever asked.

So the timeout is put where the wait actually happens. The two methods the ORM calls are wrapped with a client-side deadline, the pool is rebuilt the moment one fires, and the query that triggered it is retried once underneath. A burst of twelve concurrent requests comes back with twelve results.

Optimistic recording

Making undo safe when the taps come faster than the network

Recording a stat had to feel instant, which means showing the result before the server confirms it. Doing that naively lets an undo overtake the record it is undoing, and the count ends up wrong in a way nobody can reproduce afterwards.

Each tap now updates an optimistic layer immediately while the server call runs behind a serialised queue, so an undo can never be dispatched ahead of its own record. The display reconciles against the server on the next read, with a short expiry so the optimistic layer can never get stuck holding a wrong number.

Why this one is on the site

Nobody paid for this. It is my son's team, it started as a favour, and it turned into the thing I point at when someone asks what I actually do. The people using it never see the security audit or the connection pool or any of the rest of it, which is the entire point of having done them. What they see is a song playing when their kid kicks a goal.