October 1, 2026

I Built Myself a Race Director

I run a Suunto watch that quietly logs every run as a .fit file, and I plan my training by arguing with an AI in a chat window until we land on something resembling a sensible block.

I Built Myself a Race Director

Here's the setup: I run a Suunto watch that quietly logs every run as a .fit file, and I plan my training by arguing with an AI in a chat window until we land on something resembling a sensible block. Neither of those things has ever spoken to the other. For a while, "checking my progress" meant opening a spreadsheet with a tab per race and
eyeballing whether this week's long run looked like the one three weeks ago. This is not a system. This is the training equivalent of navigating by vibes.

So, obviously, the correct response to "I have two files that don't talk to each other" was to build a FastAPI backend, put it on a DigitalOcean droplet, and give it its own subdomain. Because why do anything by halves.

The subdomain thing bit me almost immediately, in a way that felt like a metaphor for something. matthewdavis.uk, the bare domain, 301-redirects into this very blog, which has strong opinions about 404ing anything that looks like an API route. So the first genuinely alarming moment of the project was curling the wrong host mid-deploy and concluding, with real conviction, that I'd just taken the whole thing down. I hadn't. I'd just asked the wrong website a question it was never going to answer. runlog.matthewdavis.uk is the actual app. Lesson: check which website you're panicking at before you panic.

The bigger rebuild was getting rid of the manual export. Originally, "syncing" meant plugging in, exporting to Drive, and waiting for a cron job to notice. Suunto doesn't have an official API for this, so I ended up porting the auth scheme from someone's unofficial TypeScript wrapper, HOTP-over-PBKDF2, an x-totp header, and cross-checking my Python version against the original byte-for-byte before I'd trust it with my own login. It worked on the first real attempt, which should have been satisfying. Instead, Suunto's backend decided a fresh login every thirty minutes constituted suspicious activity and started emailing me a "new device signed in" warning every half hour, forever, which is a genuinely unhinged way to find out your own script is working. Caching the session token fixed it. My inbox has never recovered its trust in me.

The part that actually matters, though, the bit that isn't just infrastructure I built to feel busy, is the pacing plan. Upload a race's GPX, and it doesn't just draw you an elevation profile and call it a day. It runs a grade-adjusted cost model against the real checkpoints on the actual route and hands you arrival and dwell times for a given goal pace, because "just run 9-minute miles" means something completely different on flat tarmac than it does on the sixth climb of the day. I built this specifically because the Ochil Ultra has a soft cutoff, forty miles inside twelve hours, with most of the climbing loaded into the first stretch of the course. Knowing, in advance, exactly where I need to be and when, rather than finding out in real time that I've quietly fallen apart, is the entire point of the tool. Everything else is scaffolding around that one number.

Not every fix was that dignified. I shipped a mobile update for the race tabs, wrapped the wide tables in scrollable containers, looked correct, read fine in the CSS, and got back, essentially, "this doesn't look like an improvement." Turned out a CSS grid item without min-width: 0 was letting one table's natural width blow the entire page past the phone's viewport, and a nav bar with a long date label was quietly doing the same thing somewhere else. Neither was visible from reading the rules in isolation. Both were obvious the second I actually rendered the page at the size a phone renders it. There's a pattern here I keep re-learning: the fix that looks right on paper and the fix that's actually right are, infuriatingly, not always the same fix.

It's password-gated now too, because at some point I noticed I'd built a fully public write API for a training log that exactly one person on Earth has any reason to edit, and decided that was a bit much even for a project with this little regard for scope.

None of this, to be clear, has anything to do with whether I can actually finish the Winter Spine (My actual end goal). RunLog can tell me precisely what pace I need out of the first checkpoint on the Ochil Ultra. It has no opinion on three days without proper sleep, which is the part of a 431km winter race that actually decides whether you finish it, and which, deliberately, for now, isn't tracked anywhere in the app. That's the next problem. This one just had to stop living in a spreadsheet first.