What Triple Hit is built from
A badminton night on one phone — a whole-session snapshot with a version number, a shuffle that lives in the browser, and a clock that is never ticked
Triple Hit is a badminton club in Amman. They play most evenings at two venues, and a night runs like this: people turn up, a coach splits them into doubles across six or so courts, a round runs for fifteen minutes, and then everyone is reshuffled so nobody plays with the same partner all night. Today that happens on paper and from memory.
Be clear about the status first. This is a pitch, not a commission. I built it to show the club what it could look like; they did not ask for it and are not paying for it. The questions only they can answer — are the session times on their Instagram still current, and do my shuffle rules match how they actually balance a court — are listed at the end, open.
On 5 September there was already a first version: one HTML file that kept everything
in localStorage. It worked on one phone and nowhere else. On 16 September it was
replaced, in a single day, by a real app on the stack from
What the gym tracker is built from. This guide is about the parts
that are different — the gym tracker has one person on one phone; a badminton night
has one screen that a second phone might also be editing, in a sports hall with bad
signal.
| JSON API endpoints | 30 |
| Database tables | 9 |
| Screens | 8 (landing, log in, Today/Day, Calendar, Session, People, player page, Settings) |
| Lines of my code | ~8,900 (tests and the copied UI kit not counted) |
| Tests | 44 API, 118 web, plus 5 browser flows |
Two halves: a public page and an app behind a login
triplehit.oyware.com
│
┌──────────────────┴──────────────────┐
▼ ▼
/ (logged out) /app/… (logged in)
Landing page Today · Calendar · Session
club, venues, weekly slots, People · Settings
phone numbers, Instagram │
│ │
└──── GET /api/public/schedule ◀──────┤ edits venues + slots
(no login) │
▼
Fastify :3003 → Postgres "triplehit"
The landing page and Settings read the same rows. A venue's weekly slots are edited
in Settings and served, without a login, by GET /api/public/schedule, so changing a
slot changes the public page on the next load. There is no second copy of the
schedule to forget to update.
That public route is the only door in without a cookie, so it is written to leak nothing: it selects the venue name and each slot's weekday and times, from active venues only, and passes them through a DTO. A test asserts exactly that — no ids, no inactive venues, no counts. Nobody learns who plays from the public page.
There is no signup and no password reset. Logins are made on the server with a
script (create-user), and changing your own password is a permission on the account
(canChangePassword, off by default) rather than something everyone sees. For a club
where two or three people run the nights, a signup form is attack surface with no user.
The stack, layer by layer
Same choices as the gym tracker, deliberately — the second app on a stack is where the first app's lessons pay off.
| Layer | Technology | What it does here |
|---|---|---|
| UI | React 19 + TypeScript | Eight screens, phone-first: it is held in one hand at the side of a court. |
| Components | shadcn/ui (radix-nova) | 13 primitives copied into components/ui/, restyled square. |
| Styling | Tailwind v4 | Tokens only; the design system below is the source of truth. |
| Server state | TanStack Query 5 | Everything except the open session, which has its own store. |
| Session state | IndexedDB (idb-keyval) |
The open session lives on the phone first. |
| Installable app | vite-plugin-pwa, own sw.ts |
Network-first navigations, precached shell for offline. |
| Server | Fastify 5 | /api/* only. Nginx serves the files. |
| Validation | zod 4 | Every body, including the whole session snapshot. |
| Database | Prisma 6.19 + PostgreSQL 16 | Its own database and user on the same droplet. |
| Auth | argon2 + cookie sessions | The cookie token is stored as an HMAC, logins last 90 days, rate-limited. |
The API has 7 runtime dependencies; the web app has 19. One file,
api/src/contract.ts, holds every JSON shape, and the web app imports it type-only —
if the API changes a field, the web build fails, not the phone.
Nine tables, and the two that are JSON
| Table | What it holds |
|---|---|
User |
a login: username, argon2 hash, whether it may change its own password |
AuthSession |
one row per logged-in device; the id is the HMAC of the cookie |
ClubSettings |
one row, id club: default courts and round length |
Level |
the level list, rank 0 = strongest, unique so the order is never ambiguous; isCoach |
Venue |
a place the club plays, with a sort order and an active flag |
VenueSlot |
a weekly slot: weekday (0 = Sunday), start and end as "19:00" strings |
Player |
a person: name, level, archived, and guest for someone who came once |
Session |
one night at one venue: date, times, courts, round length, status, version, and two JSON columns |
SessionPlayer |
who came to a session, with their level that day |
Round |
a finished round: its number, start, end, and the line-up as JSON |
Two decisions are worth pulling out.
The level is copied onto the attendance row. If a player moves up a level in March,
their January sessions must still say what they were in January. SessionPlayer.levelId
is the level that day; Player.levelId is the level now.
The line-up is JSON, not a table of seats. A court is { court, teamA: [id, id],
teamB: [id, id] }, and a round's line-up is a list of those. I nearly modelled
Court and Seat tables. But nothing ever queries "who sat in seat 3 of court 2" —
the only reads are "show this line-up" and "who has this person played with", and the
second one is answered by walking the rounds in domain/history.ts. A table per seat
would have been four inserts per court per round for no query that needs it.
Dates are club-local strings — "YYYY-MM-DD" and "HH:mm" — never toISOString().
The gym tracker taught that one the hard way (a flow that failed every night between
midnight and 3 a.m. Amman time, because UTC was still on yesterday).
The session is one snapshot, saved whole
This is the core of the app, and the thing that is most different from the gym tracker.
In the gym tracker, every tick of a set is its own small write. A badminton night is not like that. In the middle of a round a coach might add a late arrival, swap two people across courts, shuffle, start the clock and pause it — six changes in twenty seconds, several of which rewrite the whole line-up. Sending each as its own request, in the right order, over hall Wi-Fi, is a sync engine. I did not want to write a sync engine.
So a session is one snapshot: the people, the current line-up, the timer, the
finished rounds, and a version. The phone owns it while it is open.
tap ──▶ update the snapshot in memory ──▶ write it to IndexedDB (the screen is done)
│
└─ ~1 s after the last change
PUT /api/sessions/:id { …snapshot, version: 7 }
│
┌────────────────────┼─────────────────────────┐
▼ ▼ ▼
200 { version: 8 } 409 { current: … } no answer / 5xx
→ saved → another phone won; → keep it, retry on
show theirs reconnect / every 8 s
The screen never waits on the network. Every change lands in memory and in IndexedDB at once, and a debounced queue sends only the newest snapshot — there is no point sending version 7's intermediate states when 7's final state replaces them. The header badge says which of four states it is in: Saved · Saving… · Offline, will sync · Not saved (tap to retry).
The 409 is the whole conflict strategy
What if a second phone edits the same night? The server keeps a version on the
session and only accepts a save built on the current one:
// api/src/domain/snapshot.ts (trimmed)
// Claim the version before any write. A single UPDATE … WHERE id AND version takes the
// row lock, so a concurrent PUT on the same version waits here, then matches no row once
// this transaction commits: exactly one of them wins.
const claimed = await tx.session.updateMany({
where: { id: snap.id, version: snap.version },
data: { /* … */ version: { increment: 1 } },
});
if (claimed.count === 0) throw new ConflictError();
// People and rounds are replaced wholesale; the version lock above makes that safe.
await tx.sessionPlayer.deleteMany({ where: { sessionId: snap.id } });
The loser gets 409 with the server's copy in the body, and the app shows it: "This session was changed on another phone. Showing the latest." Last writer does not win; first writer wins and the second is told.
That sounds harsh, and for a shared document it would be. Here it is right, because one admin runs a night at a time — a real conflict means two people are both steering the same courts, and the honest thing is to show them one truth, not a merge nobody asked for. The whole conflict strategy is about fifteen lines.
The check has to be the write. Reading the version, comparing it, and then writing is not enough: two saves on the same version can both read "7", both pass, and both write.
UPDATE … WHERE version = 7does the compare and the claim in one statement, under the row lock — the second one waits, then matches zero rows. Validation that needs no lock (unknown venue, unknown level, a court number the session does not have) runs before the claim, so it answers 400 with a reason instead of a foreign-key error.
Players added offline
A coach can add a new person with no signal. That person needs an id now, because the
line-up refers to them. So the phone makes one — a cuid — and the snapshot carries the
new player alongside. The server creates any it has not seen (skipDuplicates, so a
retried save is harmless) and never renames or re-levels an existing one from a
snapshot.
The same trick exists as an ordinary route: POST /players with a client id is
idempotent. Two concurrent posts with the same unseen id could both pass the
"does it exist" check and race to create; the loser's unique-violation surfaced as a
500 instead of a 200. The fix is to catch the violation and re-fetch — the id
being the client's is exactly what makes that safe.
The store's hard part is not the network
web/src/lib/sessionStore.ts is 183 lines, and most of them are about one question:
what happens when a stale copy arrives late? Opening a session fetches it from the
server — but by the time that answer comes back, the coach may already have tapped
something. The store refuses to let a reopen roll anything back:
// Never let a reopen roll back or clobber what's already live in memory: a
// stale/late open (older server version), an unsent edit (dirty), or a save
// that's mid-flight must all just return what's there, untouched.
if (before && (server.version < before.snapshot.version || before.dirty || before.inFlight)) {
return before.snapshot;
}
And one line that is easy to miss: a captive portal — the hall Wi-Fi's login page —
can answer a PUT with 200 and an HTML page. The store checks that the reply is a
session with the same id and a numeric version before it believes it was saved;
anything else counts as offline. Otherwise a login page would mark a night "Saved"
that never reached the server.
It has 16 tests of its own, including the late open, the edit made while a save is in flight, IndexedDB being unavailable (private mode), and the captive portal.
The shuffle is pure, and it lives in the browser
The API never shuffles. web/src/lib/shuffle.ts is a pure function — people with
their level rank, the court count, and the earlier rounds in, a line-up out — and at
78 lines it is the whole of the club's rules as I understood them:
- Strength is the level's rank. Coach is 0. Ties are broken randomly.
- Courts used = people ÷ 4. It refuses if that does not divide, or needs more courts than the night has — and says how many people or courts are missing.
- Mix: sort by strength, cut into four equal groups; every court takes exactly one person from each group. With 24 people on 6 courts, every court gets one from the strongest quarter, one from each middle quarter, one from the weakest.
- Teams: strongest + weakest vs the middle two (group 1 + 4 vs 2 + 3).
- Variety: try 300 random candidates and keep the one that repeats least against this night's earlier rounds.
const PARTNER = 10, OPPONENT = 3, SAME_COURT = 1;
// score = 10 × repeated partners + 3 × repeated opponents + 1 × repeated court-mates
Playing with the same person again is what people notice, so it costs ten times what sharing a court does. The loop stops early on a perfect zero.
Why in the browser? Because it has to work with no signal — a reshuffle between rounds
cannot wait for the hall Wi-Fi. And because the rules are the club's, not mine:
when they tell me I have the balance wrong, the change is one file and its tests, and
nothing on the server moves. The random source is a seeded mulberry32, injectable,
so a test can say "same people, same seed, same courts" and mean it.
The app never reshuffles by itself. A shuffle replaces the whole line-up — including any hand swaps — so it only happens when someone taps Shuffle or Next round. Hand swaps are tap one person, tap another, anywhere on the sheet.
A shuffle with no levels is just random. If nobody has a level yet, every rank is equal and "balanced" means nothing. The final review caught that Next round would happily deal it anyway. It is now disabled with the reason on screen, instead of greyed out silently.
The clock is stored, never ticked
A round timer that counts down in setInterval dies the moment the phone locks, the
tab reloads, or the snapshot is sent to another phone. So the timer is state, not a
process:
// web/src/lib/timer.ts
// The round clock is stored, never ticked: a start instant plus a length, or the
// seconds left while paused.
{ roundNumber, durationSec, startedAt, roundStartedAt, pausedRemainingSec }
Running: remaining = duration − (now − startedAt). Paused: pausedRemainingSec.
Resume back-dates startedAt so the same formula gives what was left. The screen
redraws from that every second, but nothing is lost if it does not — the phone can be
in a pocket for ten minutes and the clock is right when it comes out. At zero there is
a sound, a vibration and an orange bar, all local; the screen holds a wake lock while
a round runs.
The bug that took two rounds of fixes
startedAt looked like "when the round started". It is not. It is the countdown
anchor: back-dated on every resume and cleared whenever the clock stops. When a
round was filed into history, it used startedAt — and got nonsense:
- End round cleared
startedAt, so the round was filed as having run its full length backwards from now. A two-minute round became 15:47–16:02, sometimes starting before the session did. - The first fix kept the instant for End round — but pause clears it too. A two-minute round, paused for an hour, then ended, was filed as a full 900 seconds beginning 47 minutes late, before the previous round had finished.
The real fix was to stop asking one field to mean two things. roundStartedAt is set
once, on the first start, and carried untouched through pause, resume and end early.
History uses that; the countdown uses startedAt. It is optional on the API, so a
snapshot queued on a phone running the older build still saves. There are now eight
archive paths under test and a browser flow step that ends a round from paused.
If a field has two jobs, it has a bug. Every one of these was a correct piece of code reading a value that meant something slightly different from its name.
History is only as good as the rounds that get filed
The last review found two more ways a night could lose its record:
- Reopening a finished night restarted round numbering at 1. The API refuses two
rounds numbered 1 in one session — a 400. The store only retries on 5xx, so the save
parked, and nothing else that night ever saved. One function,
nextRoundNumber, is now the only source of the next number. - Removing someone who was on court deleted the round in play, taking fifteen minutes out of eight people's histories. Removal now files that round first — its real start, ended at the removal, the line-up as it stood — then clears the courts, and the confirmation says so.
Scoreboard: the design system, from Claude Design
The screens were designed before a line of UI was written. I briefed Claude Design
with every screen and state — landing, log in, Today, Calendar, the open-a-session
sheet, the session screen with swap selection and every timer state including time-up,
People, the player page, Settings — in dark and light. It came back with two
directions; I picked A, "Scoreboard", over B, "Courtside", and then every app screen
was drawn in it. DESIGN.md was written from the export, and it is the rule for every
screen since.
The idea is the scoreboard in a sports hall:
| Rule | Why |
|---|---|
Near-black ground, off-white text, one accent: the club's orange #FA6E00, from its logo |
Readable in a bright hall or a dim one |
| Square corners, 1px hairlines, condensed uppercase headings, no shadows or gradients | It is a board, not a card game |
| Numbers are the content — timer, court numbers, times, counts are big and tabular | The thing you read from two metres away |
| Coaches are always orange, everywhere a person appears | You find the coach on a court at a glance |
| One motif: a 3px orange rule on the left of a record card; gold on a warning | Only one decoration, so it means something |
| Main action and timer at the bottom, every target ≥ 44px, nothing on hover | One hand, the other holding a racket |
The export's colours did not all survive. It used the brand orange unchanged in light
mode, and orange on white is 2.88:1 — under the 3:1 minimum even for a button
edge, and far under 4.5:1 for small text. So the light theme
has an adjusted --primary (#EB6700, 3.24:1 as an edge) and a separate, darker
--accent-ink (#B34D00) for small orange text. Every such change is marked
"(adjusted)" in the token table, with the reason.
theme.ts mirrors every hex value in index.css, and theme.test.ts fails if the two
drift or if any pairing in the contrast table drops below its WCAG threshold. The same
pattern as the gym tracker's accent colours: the palette is data, and the test holds it
to account.
All user-facing text lives in one module, web/src/lib/copy.ts. Arabic is not in v1,
but when it comes it is one file, not a hunt through every screen.
Built by three agents at once
The spec and a six-phase, sixteen-task plan were written first. After the scaffold, the plan split into three lanes that did not touch each other, each run by an agent in its own git worktree:
Task 1 → Task 2 ─┬─ Lane A (API): auth → settings/venues → players → sessions
├─ Lane B (logic): shuffle, timer, offline session store
└─ Lane C (design): Claude Design → DESIGN.md → tokens
│
▼
Phase 5: screens → Phase 6: ship
That split is why it fit in one day. The shuffle and the timer are pure functions with no UI; the API is a contract; the design system is tokens. None needed another to exist before it could be written and tested. Every task had a review after it, and two whole-branch reviews at the end — the eight must-fix items from those are the bugs in the sections above.
Testing it
| Kind | Count | What it proves |
|---|---|---|
| API integration | 44 | Real Postgres. Auth and rate limit, every CRUD route, the 409 on a stale version, history queries, that the public schedule exposes nothing private, that the seed is idempotent |
| Web unit | 118 | Session actions (42 — add, remove, swap, shuffle, next round, reopen), the store (16), the timer (10), the shuffle (8), dates, theme contrast, the install guide |
| Browser flows | 5 | Headless Chrome at phone width: landing, settings-people, day-calendar, session, pwa |
The API tests refuse to run unless the database name contains test, because every
test truncates every table. The shuffle tests assert the rules, not the output:
one per quarter on every court, 1+4 vs 2+3, the refusals, deterministic with a seed,
and fewer repeats over many rounds than a plain random deal.
session.mjs is the one that matters. It opens a night from a slot, adds people,
shuffles, swaps across courts, runs and ends a round, cuts the network mid-round and
reconnects, reloads mid-round to check the clock, and then saves from a second
"phone" to force the 409 and check the first phone shows the winner's copy. pwa.mjs
needs the built app — a dev server registers no service worker, so there is nothing
to answer a navigation offline.
Flows that clean up by archiving still leave a mess. The first production runs archived the people they created, and archived people still sit in the club's People list: 33 of them had to be cleared by hand. Cleanup now deletes, and the API refuses (409) to delete anyone who is in a session or a round — in which case the flow archives instead. Cleanup can never destroy real history, and normally leaves nothing at all.
The other flow traps, from the flows' README: a sheet that has just closed still
matches [role="dialog"] input during its exit animation, so wait for exactly one
open dialog; and never hard-code a count, because another agent's run is writing to
the same dev database.
Shipping it
./scripts/deploy.sh, from the Mac:
tests → build on the Mac → back up the DB → ship API → install + migrate + seed
→ ship web → restart → verify on the box, then from outside
- Built on the Mac.
tscand Vite are exactly the memory spikes a 1 GB server shared with two other apps should never take. The droplet only ever runsnode dist/…, capped withMemoryMax=256Min the systemd unit. - Backup before migrate. A migration is the one step that redeploying old code cannot undo. There is deliberately no auto-rollback: the script prints the rollback command and the backup's location instead.
- The seed runs every deploy. It only fills what is missing — levels, the two venues and their slots, the settings row — so a rebuilt database is usable at once.
- No
--deletewhen publishing the web app. A phone that opened the app before the deploy must still be able to lazy-load its screens from the old hashed chunks. Chunks older than 30 days are pruned.
Two traps the script now checks for, loudly:
The manifest's MIME type. Nginx does not know
.webmanifest. Served asapplication/octet-stream, the site loads and works — and nothing can be installed to a home screen, and nothing warns you. The deploy fetches it and shouts unless it isapplication/manifest+json.The old app had published the whole repo. The one-file version's deploy untarred the entire repository into the web root — so the specs and plans were publicly readable at
/docs/…. The new deploy never deletes (see above), so nothing would ever have removed them. It was cleaned by hand once, and the deploy now checks that path on every run. It judges by content type, not status: the app'stry_files … /index.htmlanswers 200 for any path, so "200" proves nothing; a real leftover file comes back astext/markdown.
Terms you will hit in this codebase
| Term | Meaning here |
|---|---|
| Snapshot | The whole open session as one JSON object, saved with PUT |
| Version | A counter on the session; a save must be built on the current one |
| 409 Conflict | "Your copy is stale." The body carries the server's copy |
| Optimistic concurrency | Assume no conflict, detect it on write, instead of locking up front |
| Debounce | Wait until changes stop (≈1 s) before sending, so only the last one goes |
| Idempotent | Sending the same request twice has the same effect as once — why client ids exist |
| cuid | A collision-resistant id a phone can make offline |
| Pure function | Output depends only on input — the shuffle, the timer maths |
| Seeded random | A random generator that gives the same sequence for the same seed, for tests |
| Captive portal | A Wi-Fi login page that answers every request, often with 200 |
| Guest | Someone who came to one session: a name and level, never listed in People |
Open with the club
Nothing below blocks the app from working. All of it blocks it from being theirs.
- Are the schedule and venues current? The seed data came from the club's pinned Instagram posts from March.
- Do the shuffle rules match how they balance a court? Strongest + weakest vs the
middle two is my reading. If it is wrong, it is
shuffle.tsand its tests, nothing else. - Prices, subscriptions, Arabic — all deliberately out of v1, and all shaped for:
prices would be a field, Arabic is
copy.ts.
Until they answer, it stays what it says on oyware.com: a pitch, not a commission.