M
Back to client projects
clientElement 2.0158 UG & Co KGJul 2025 – Jul 2026 (ongoing)
featured

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

Bitemporal-ish plan versioningKeeping valid_from / deactivated_at so a mutable plan can still answer questions about the past.
Timezone-correct SQL aggregationBucketing on (completion_time AT TIME ZONE $tz)::date rather than trusting UTC or the client's own dates.
Distributed rate limiting on serverlessUpstash Redis with an in-memory local fallback, because per-instance counters do not limit anything across Vercel functions.

// 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_at onto training plans cost more than designing it in would have.
  • A summary endpoint returning null looks 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 CASE before 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

Built with

Next.js
TypeScript
PostgreSQL (Neon)
Drizzle ORM
JWT
OAuth 2.0
Zod
Nodemailer
RevenueCat
Firebase FCM
Questions about this build? Get in touch