Road to Ragnarök
A local-first app that turns my real life into an RPG status screen: Garmin runs, gym sessions, PlayStation trophies, books, manga and exams all feed one engine that computes stats, a daily "train hard or rest" call, quests and 112 trophies — styled like a NieR:Automata YoRHa terminal. Everything runs on my own machine: no cloud, no account, no subscription.
- Role
- Solo — designed and built all of it
- Context
- Personal project, in daily use and still growing
- Stack
- TypeScript monorepo, React + Vite, SQLite, Vitest, the Anthropic SDK plus an optional Gemini layer, Garmin and PlayStation Network connectors, Open-Meteo for weather
- Tests
- 489, all green
// Problem
I wanted one place that treats real-life progress like a game: training, studying, reading and gaming all shown as stats and quests. The fun part is the game layer, but the part I cared most about is that the numbers are honest. If a stat can drift or change randomly, the whole thing is pointless. So the hard requirement was determinism: every stat is calculated the same way every time from my real data, and every calculation is testable.
// What I built
All of it, solo. A TypeScript monorepo where a pure calculation core sits behind a repository interface, with adapters around it for the messy outside world: a Garmin connector that pulls new runs and recovery data with one button, a PlayStation Network sync that keeps my trophy list and playtime honest, importers for gym sessions, weigh-ins, books, manga and exams, and a weather backfill via Open-Meteo. Every sync is idempotent — re-running an import can never duplicate data. On top of that core: three training stats, a daily readiness call, a study planner for my exam resits, a Ragnarök quest log and 112 trophies. A React dashboard renders everything from one read-only JSON contract; a CLI covers it all headless.
// Some decisions I made and why
01I split the app into a thinking part and a messy part
One package only does calculations — pure functions, no database, no network, not even a system clock (time is injected). Everything messy lives in adapters around it: file parsing, SQLite, HTTP. It's a ports-and-adapters setup in plain terms, and it's why 489 tests run fast and never flake: I feed the core data and check the answer with nothing in the way.
02I made the app refuse to mix kilograms and pounds
Early on I was worried about accidentally treating a weight in pounds as if it were kilograms, which would quietly wreck a stat. So units are branded types: the compiler refuses to mix the two instead of letting it slip through silently.
03I gave the screen one clear thing to show
Instead of the dashboard reaching into all the calculations itself, the app builds one read-only summary object and the screen just renders that. When I change how a stat is calculated, the UI doesn't break — it only ever reads that one contract.
04I put all the unit conversions in one place
Rounding and converting can introduce small errors, so I kept that logic in a single spot rather than scattered around. That way if a number looks off, I know where to look.
05I used the real max heart rate instead of the common shortcut
A lot of apps use "220 minus your age" to set heart-rate zones, but that is just a rough rule of thumb and it was wrong for me. I used my actual tested max, so the zones are right.
06Derived, never stored
A rule I kept re-applying: anything that can be computed from the data never gets its own column. Whether an exam is passed derives from the grade, whether a manga series is finished derives from the volumes I've read, whether a game counts as synced derives from the sync data itself. Stored flags drift out of sync with reality; derived values can't lie.
07AI is in there, but fenced in
The stats never touch AI — they stay deterministic. AI does the narrow jobs it's genuinely good at: parsing a pasted coaching schedule into dated sessions, writing a short weekly coach's read. Every AI feature is opt-in behind an API key and silently skips without one, responses are cached so unchanged data costs zero tokens, and a budget guard rejects a truncated answer instead of caching half a sentence — a bug I actually shipped twice before building the guard.
// Outcome and status
In daily use — it's literally how I decide whether to train hard today. 489 tests across 55 files, all green; new Garmin and PlayStation data lands with one button each; dropping an export file anywhere on the dashboard imports it. The game layer is what makes it fun; the determinism is what makes it true.
// What I'd change next
Strength sessions still come in by hand — wiring them in from Garmin's strength tracking is next. The audit trail that makes every corrected number traceable covers strength today but not yet every conversion; I want to finish that. And it's tied to one machine by design — at some point I'll want it to sync between two.
// Links
It runs locally, so there is no hosted demo. A short screen-recording of the dashboard plus this writeup does the job.
AI usage: AI parses pasted schedules and writes a short weekly summary — opt-in, cached and budget-guarded. Every stat and every coaching number is hand-written, deterministic code.