Screenshot of the fitness tracker dashboard

Building my own coach

A Strava + Garmin + local LLM personal fitness tracker I built over a month for the data I actually wanted to see about my training.

Garmin's web app has every metric you can think of and a UI that makes it nearly impossible to find any of them. Strava is the inverse: a beautiful UI built around what other people did this week, with the metrics I cared about gradually moving behind a paywall. The thing Strava was selling me, segment competition, wasn't the thing I wanted.

So I let the subscription lapse and built my own. A month later it's running as a LaunchAgent on a Mac in my office, pulling new rides on a 30-minute timer, narrating my training day with a local LLM and surfacing the stats I actually care about. It's the thing I always wanted to exist and never could quite buy.

I've been riding forever, and in the past sixteen years I've collected more data than I knew what to do with. A gold mine to learn from. The apps I was using were each only telling me a third of what I wanted to know.

01

What I came for

Three things specifically.

How any ride stacks up against the same ride I've done dozens of times before. Strava ranks segments. I wanted to rank routes, with overlap matched via polyline similarity so years of repeat efforts line up against each other.

Screenshot of the route-matching view ranking similar efforts across years
Route ranking. Same ride, every time I've done it, ranked across years.

Cardiac drift over the course of a ride. Heart rate climbing while power stays flat is the cost the body is paying to keep the bike moving. Both apps record the signal. Neither charts it. Watching that gap close as fitness comes back is one of the most direct training feedback signals I know.

Screenshot of the cardiac drift chart for a single ride
Cardiac drift on a single ride. Heart rate climbing while power stays flat is the cost the body is paying.

Heart rate over power across years. The same ride at the same wattage at a lower heart rate is fitness. The same ride at the same heart rate at higher wattage is also fitness. Either way the ratio moves, and the chart shows it. Most apps optimize for the last thirty days. Improvement in economy is a slow signal you can't really see ride by ride. You can see it year over year, especially as you get older.

Screenshot of the year-over-year fitness view tracking heart rate over power trends
Year-over-year fitness. The economy ratio, season by season.

02

What it is

The stack is small. Express backend, React and Vite frontend, SQLite via Prisma for storage. One process, one URL, one machine. Strava OAuth on top, a sidecar SQLite from a separate Garmin extraction tool (no Garmin credentials touch my app) and Ollama running locally for the language model that writes the coach's notes.

The two halves: Strava is the source of truth for social and routes (kudos, descriptions, polyline). Garmin is the source of truth for metrics (heart rate, power, training-effect, recovery). My ingest layer enforces that precedence so I get the best of both without merge conflicts.

03

The data layer

Most of what makes a fitness tracker useful sits in date math. Garmin auto-resume vs forced-window extracts. Comparable-as-of-date calculations so a year-to-date this year compares to the same calendar day last year. Routes matched across years via polyline similarity. Commutes auto-tagged from the route signature, no manual labels.

None of that is glamorous. All of it is what makes "check today's ride against the last 90 days" work. There's no AI for that. There's just careful UTC date handling, a few hundred lines of comparable-period math and a willingness to write the boring parts.

Screenshot of the per-ride FIT-file detail view, exposing low-level Garmin metrics

FIT-file detail

Screenshot of the year-over-year monthly distance breakdown

Monthly distance, year over year

Weather is the third data source. Every ride gets historical weather attached at ingest: temperature, wind, precipitation and humidity, pulled from Open-Meteo's archive for past rides and from the ECCC GEM model forecast for today and tomorrow.

The masthead carries a live weather strip tied to my location. A rolling snapshot that refreshes on every load, shows the day's trend at a glance, and lets me hover any hour to see how the forecast shapes up. Air quality sits next to it. A rideability ribbon underneath flags the best window to be outside.

The masthead weather strip showing the day's temperature and conditions trend with a hoverable hourly forecast and a rideability ribbon
The weather strip in the masthead, location-specific and hoverable across the day.

Weather is critical for planning rides outside and for picking which bike to take. Happy training is good training.

04

The coach

The dashboard greeting is a paragraph generated by a local LLM. The LLM gets a full briefing: yesterday's activities with all metrics, Garmin recovery signals (readiness, sleep, HRV, training load), this week's volume vs my four-week baseline, and today's weather. It returns a verdict of workout, maintenance or rest, a discipline from a constrained palette of road, gravel, mountain bike, fatbike, trainer, weights and yoga, and a prose sentence that varies with time of day.

If Ollama is down or the LLM returns garbage, a deterministic template takes over. The user never sees a broken state.

The constraints matter as much as the prompt. Weather doesn't pick the discipline; it sets a mode (open, indoor, timing, forced or rest) and the model picks from the palette inside that. Benign weather leaves the choice open and the LLM has to make an interesting one, not reflexively default to gravel because the prompt mentioned roads.

Screenshot of the per-ride coach debrief generated by the local LLM
The per-ride debrief, generated by the same LLM with the full activity record as context.

05

The design

The UI is editorial, not athletic. Tailwind throughout, custom design tokens, restrained chart theme. Charts via Recharts, maps via React-Leaflet and Mapbox polyline. Activity types get discipline-aware icons and color treatments. Everything respects a single chart theme so a new chart automatically inherits the palette.

I made the call early that the tracker should read like a magazine spread, not like Garmin Connect. Wide gutters. Serif eyebrows. Restraint over information density. The data still gets there. It just gets there in a voice I want to read.

Screenshot of the year-over-year stats page in the fitness tracker
The year-over-year stats page. Editorial, not athletic.
Screenshot of the year-over-year heart rate zone distribution chart
Time in each heart rate zone, stacked across years.

06

Running it

It lives as a macOS LaunchAgent on a Mac in my office. RunAtLoad and KeepAlive, 30-second restart throttle. Auto-syncs Strava every 30 minutes, runs a cheap Garmin auto-resume every two hours for overnight wellness data, and triggers an expensive forced-window Garmin extract only when a Strava sync actually surfaced a new ride.

It's a personal product running on a personal computer for a personal use case. There's no auth, no users table, no billing, no notifications. There's me and my data.

The Garmin data over 16 years runs past 2 gigabytes.

07

What I'm learning

A month in, the things I've learned:

The cost of LLM features is mostly in the constraints, not the prompt. The prompt is two pages. The validation that makes the output safe to ship is another two. The cached-output keying that makes the note regenerate when the underlying signals shift is a third. I've tried several local models but have settled on qwen3:14b, which has been the best balance of speed and accuracy.

Garmin metric precedence over Strava is non-obvious and load-bearing. Strava's distance and heart rate have always felt slightly off to me; the actual delta between Strava-recorded and Garmin-recorded metrics on the same ride is real. The tracker only feels true once it's reading from Garmin.

Editorial styling on data tools is rare for a reason: most data tools serve dashboards and dashboards reward density. But I'm an audience of one and I want to read my own numbers. The tradeoff between magazine and dashboard isn't free, but at this scale I'd pick magazine again.

Screenshot of the per-ride summary view

Ride snapshot

Screenshot of the ride comparison view, side-by-side metrics from two rides

Ride comparison

Screenshot of personal records linked back to Strava

Personal records, linked to Strava

08

What's next

The next things I'm building toward are unsexy.

A proper yearly review page, mostly done, that puts the year's volume, intensity, zone distribution and FTP / LTHR progression on one canvas.

A history-aware coach. Right now the LLM gets yesterday and a recovery baseline. I want it to know that I usually take Mondays off, that I was sick three weeks ago, that I'm two weeks out from a trip. Context the current setup doesn't track.

A way to share a single ride summary with someone. Probably just an HTML snapshot rendered server-side, so I can text my non-Strava friends "look what I rode" without dragging them into the platform.

It's the best training tool I've ever had.

I wish I'd had it when I was actually racing.

If you're building something in the same vein, or you just want to compare training notes, my inbox is open.