oyware [email protected]

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

2026-09-16TypeScript, React 19, Tailwind v4, shadcn/ui, Fastify 5, Prisma 6, PostgreSQL 16

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 = 7 does 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:

  1. Strength is the level's rank. Coach is 0. Ties are broken randomly.
  2. 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.
  3. 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.
  4. Teams: strongest + weakest vs the middle two (group 1 + 4 vs 2 + 3).
  5. 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:

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:


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

Two traps the script now checks for, loudly:

The manifest's MIME type. Nginx does not know .webmanifest. Served as application/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 is application/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's try_files … /index.html answers 200 for any path, so "200" proves nothing; a real leftover file comes back as text/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.

Until they answer, it stays what it says on oyware.com: a pitch, not a commission.