Clutch Darts
Backend for an international dart training and game tracking application based in Germany — handling real-time game sessions, personalized training plans, and player statistics.
I own the backend solo. That covers 65 route handlers across 17 API groups, the 26-table Postgres schema and its 34 migrations, and every query behind them: email/Google/Apple auth, match ingest for four game modes, versioned training plans, streaks, the statistics module, and push notification scheduling. 351 commits on the current branch are mine. The iOS app is built by someone else, so every request and response shape in here is a contract I agreed with a client developer and then had to keep working while the data model changed underneath it.
// the problem
What was broken
A darts player practising alone has no way to tell whether they are actually improving. A three-dart average written in a notebook does not answer whether your checkout percentage is better than last month, or whether you only ever win against the bot. Players were coping with paper and spreadsheets, which nobody sustains long enough to see a trend. On top of that, "I'll practise three times a week" fails silently — there was nothing tracking whether you actually showed up.
// constraints
What made it hard
The app is offline-first: a player throws in a garage with no signal, and sessions sync later, sometimes twice, sometimes out of order — so idempotency is a requirement on every write endpoint, not a nicety. Everything a player sees is in their own timezone, so day boundaries cannot be computed in UTC and cannot be trusted from the client either. Training reminders need minute-level accuracy, which on Vercel means a cron that runs every minute and finishes inside its budget. And the hardest one: training plans are editable, but streaks are historical — changing a plan today must not rewrite whether you kept your streak last Tuesday.
// architecture
How it fits together
This is a Next.js App Router API on Vercel with Neon Postgres over Drizzle,
deployed through a debug → dev → staging → main branch flow with a separate
database per environment. It is deliberately one deployment rather than a service
split: the difficulty here is data modelling and query correctness, not
throughput, and splitting would have added network boundaries between things that
need to be consistent with each other. Routes are kept thin — authenticate,
validate with zod, shape errors — and the work lives in lib/, grouped by domain
(statistics, streaks, notifications, auth, recommendations). Rate
limiting sits in lib/utils/rate-limit.ts on Upstash Redis with an in-memory
fallback for local dev, because Vercel functions do not share process memory and
a per-instance counter would not have limited anything.
Two decisions shape most of the code. First, aggregation stays in SQL. A year
view is thousands of throws, and bucketing plus averaging them in Postgres — with
AT TIME ZONE applied inside the query so the day boundaries are the player's —
keeps the endpoint fast on a cold function; the controller only fills gaps, so a
week with no darts still returns seven labelled points with null values and the
client never reasons about missing days. The cost is long SQL that was duplicated
per period and per game mode, which I paid down by extracting shared fragments
into constants rather than by moving the work into TypeScript. Second, training
plan items are versioned rather than mutated: valid_from and deactivated_at
on training_plan_items, deactivated_at on training_plans. Editing a plan
retires the old row and writes a new one, so "was this a planned training day?"
stays answerable for any past date. Hard updates would have been far simpler and
would have silently corrupted every historical streak.
iOS client
Queues completed matches offline, syncs in batches when a connection returns
POST /api/game/around-the-clock/sessions
JWT auth, zod validation, dedupe on clientGameId before any insert
game_sessions + darts_throws
The match summary and every individual dart, Postgres on Neon
training_sessions
The same match when played from a plan — separate legacy shape, different JSON keys
GET /api/statistics/summary
Validates period and IANA timezone, then delegates immediately
lib/statistics/stats-queries.ts
One pass per game mode, UNIONs both session tables, buckets in the player's timezone
lib/statistics/stats-controller.ts
Fills empty buckets, weights averages, builds per-variant summaries and charts
// challenges
Where I got stuck
Streaks against an editable plan
This is the most-rewritten code in the repo — roughly nineteen fix commits and one outright revert. Each failure was specific: streak days counted per session instead of per local day, active plans truncated by a sibling plan's created_at, the year summary leaving planned days out of the denominator, the plan's own creation day excluded from missed-training math. What finally worked was adding an explicit validity window (deactivated_at) to plans and treating "was today a training day" as a pure function of plan state as-of that date, rather than as of now.
Plan edits that quietly deleted history
Four consecutive commits fight over the same semantics — whether creating a plan replaces existing ones. It started as replace-all, which wiped user plans whenever the app generated a recommended one. It became additive, then additive-but-retire-only-the app_recommended plan, then additive with day-scoping preserved on update. The root cause was that one endpoint was serving two different intents, app-generated and user-authored, distinguished only by a plan_source column added later.
One match, two tables
The same game lands in game_sessions if played freely and in training_sessions if played from a plan, with different JSON shapes for identical numbers. Every stats query has to UNION ALL both and normalise as it reads. I considered migrating to one table and rejected it: the write paths are shared with a shipped iOS client I do not control, so the normalisation had to live in the read layer instead.
Opponent classification was silently wrong
The training-session branch treated "an opponent object exists" as proof the opponent was the Kaspa bot — but those routes write that object for solo games too. So every human match and every solo run counted as a bot match; win-rate-vs-human returned null and the bot win rate was computed over solo games. I found it by classifying every production row under both the old and new rule and diffing, which also proved the fix changed nothing for genuine bot matches.
No test suite
There are no automated tests in this repo. For a correctness-heavy read API that is a real gap, and it is why verification here means writing throwaway harnesses that post every game mode × variant × opponent type through the live endpoints, assert the response, then delete what they created. It works, but it is a slow substitute for tests and it does not protect the next change.
// new ground
What I hadn't used before
valid_from / deactivated_at so a mutable plan can still answer questions about the past.(completion_time AT TIME ZONE $tz)::date rather than trusting UTC or the client's own dates.// takeaways
What I took away
- Anything a user can edit needs a validity window from day one if anything else derives history from it — retrofitting
deactivated_atonto training plans cost more than designing it in would have. - A summary endpoint returning
nulllooks identical whether the metric is genuinely empty or the rows were misclassified, so every "no data" path needs to be provable from the raw rows before you believe it. - Reading the write path before changing a read query would have saved me a wrong fix — I had already reordered a classification
CASEbefore noticing the column it read was itself resolved by the route that writes it. - One endpoint serving two intents will grow a boolean, then a column, then four fix commits; splitting app-generated and user-authored plan creation earlier would have avoided all of them.
- Diffing old versus new logic across every production row is a faster correctness argument than any unit test I could write for SQL this shaped.
// the journey
How it actually went
I started this expecting a CRUD backend — record matches, return them — and assumed statistics would be the easy part. Statistics and streaks between them account for most of the rework in the repo.
The first real mistake was writing the stats SQL inline in route files. It
worked, so I did it again in a second route, and the two copies drifted almost
immediately: one read the three-dart average from one JSON path and the other
from a different one, so the dashboard and the statistics screen disagreed for a
while before anyone noticed. Pulling it into lib/statistics and splitting
queries from calculations from pure helpers was the change that made the module
maintainable, and I should have done it at the second copy rather than the tenth.
Streaks were worse, and the git history does not hide it — there is a commit
literally called streak revert, another called fix all identified 14 issues,
and a run of narrowly-scoped fixes after each of those. Every time I fixed the
streak calculation, the next plan-editing feature broke it again. The moment it
clicked was realising I was not fixing a streak bug at all: I was repeatedly
discovering that a mutable training plan cannot answer questions about the past.
Once plans and plan items carried their own validity windows, most of that class
of bug stopped being possible.
The most recent one taught me a different lesson. A user reported that win rate versus human never appeared for one game mode, and my instinct was that the aggregation was wrong. It was not — the classification feeding it was, and it had been wrong for every game mode. It only got reported for one because that mode's human matches happened to arrive through the other table.
What I would do differently: normalise the two session tables at the write layer on day one, even at the cost of an awkward early migration. Every read query in this repo pays a tax for that split, and the tax grows with each new game mode.
// outcome
What it changed
Shipped and running in production across three environments (dev, staging, production), each with its own Neon database, deployed from a debug → dev → staging → main branch flow.
Provable from the repo: 65 API route handlers across 17 groups; 26 tables and 34 migrations; four game modes recorded, three of them surfaced in statistics (X01, Around the Clock, Bob's 27) across nine variants; rate limiting at 1000 requests per hour per client with a 5-per-hour limit on OTP; a notifications cron running every minute with a 300-second function budget; a GitHub Actions job backing up all three databases to Vercel Blob every Sunday at 02:00 UTC; 351 commits between July 2025 and July 2026.
Ten TODO markers remain, all of them the same unfinished thread: subscription
status is hardcoded at the auth endpoints pending the RevenueCat integration.
// overview
In more detail
Clutch Dart is an international dart training and game tracking application based in Germany. The backend powers a full-featured mobile app experience — handling real-time game sessions, personalized training plans, player statistics, and a subscription-based ecosystem.
Built with Next.js App Router and TypeScript, using PostgreSQL on Neon with Drizzle ORM. The system includes JWT dual-token authentication with Google/Apple OAuth, a full X01 game engine, weekly training plans with timezone-aware scheduling, and RevenueCat-integrated subscriptions.
// features
What it does
- Authentication System — JWT dual-token auth, Google/Apple OAuth, OTP-based passwordless login
- Game Engine (X01) — Full game session lifecycle with throw recording and live state tracking
- Training Module — Permanent weekly training plans with daily status tracking
- Recommendations Engine — Score-based X01 checkout recommendations
- Dashboard & Analytics — Aggregated player stats and historical chart data
- Referral & Subscription System — RevenueCat-integrated subscriptions with referral extensions
- Content System — Articles with ratings, comments, and social sharing
- Offline-First Support — Client-side UUID tracking for reliable game sync
- 31 REST API endpoints organized by domain across 30 database tables
// stack