AltiTrak
trail-running analytics where elevation comes first
GPX and Strava activities turned into vertical speed, climb splits, run/hike ratio, training load and a "you vs the race" readiness score. FastAPI, PostGIS and TimescaleDB, React.
- Product, backend, data model, frontend, CI
- FastAPI · PostgreSQL · PostGIS · TimescaleDB · React · Playwright
- Shipped through V2, then archived; a microservices rewrite followed
The problem
Running apps are built for the road: pace per kilometre, weekly distance. On a trail, pace is meaningless and the climbing is the workout. I wanted an app that reads an activity the way a trail runner does: how much did I climb, how fast did I go up, how much of it did I walk, how hard was the descent, and that can answer one question before a race: am I ready for this one?
What I built
A monorepo with a React front end and a FastAPI back end in four layers, where the domain is pure functions with zero external imports: data quality flags, climb segmentation, vertical speed up and down, time per 100 m, run/hike split, a "destructive descent" index. Track points live in a TimescaleDB hypertable with a PostGIS geometry column, one instance for both time series and geography, a decision recorded as an ADR, chosen over a second database.
On top of it: a training load that weights each session by its elevation (D+ and D− count differently), a double performance-management chart (global with cycling as cross-training, trail-only without it), weekly alerts, and the "you vs the race" score: a race's distance and elevation against the last four weeks of volume, climbing, long runs and descent exposure, returned as a 0–100 readiness with three strengths, three gaps and three actions. An MCP server runs inside the same process so an assistant can query the data. CI runs lint, audits, unit tests and about two hundred Playwright end-to-end tests, smoke on every deployment and the full suite nightly.
What I learned
That the first version of the load model overestimated fatigue two to four times (a missing warm-up window, a terrain factor applied to the wrong input), and that comparing against an established tool is how you find out. That an app should work with only a type, a date and a duration, and treat every other field as optional. And that a monolith with pure domain functions is a fine place to be; the microservices rewrite that followed taught me the cost of the alternative.
“A trail runner's week is measured in metres climbed, not in minutes per kilometre.”A modded Minecraft server, in production