Screenshot of the tracker's year-over-year view: heart rate zones by year above a workout-versus-commute breakdown
· Updated

Building my own coach

A local-LLM fitness tracker I built on Garmin data for the numbers I wanted: route rankings across years, cardiac drift and a coach I can talk to.

Contents

    A note from July 2026: since this first ran, Strava locked its API behind a paid subscription. That forced a rebuild around Garmin and the update you're reading now.

    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, but the metrics I cared about moved behind a paywall.

    I let the subscription lapse and built my own. It runs as a LaunchAgent on a Mac in my office, pulls new rides on a 30-minute timer and narrates my training day with a local LLM. It started as a month of evenings and has grown into a full coach. It doesn't just narrate; it talks back.

    I have sixteen years of riding data. Each app I was using told me a third of what I wanted to know.

    01

    What I came for

    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 by shape so years of repeat efforts line up against each other. That idea became its own page.

    Screenshot of the Lakeshore route page: a map of the loop, lifetime totals and every effort ranked by speed
    Route ranking. The Lakeshore loop, 192 efforts, ranked across the years.

    Cardiac drift over 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.

    Screenshot of a ride page headlining cardiac drift, alongside normalized power, average speed and a full metric grid
    Cardiac drift, surfaced on every ride. +2.8% aerobic decoupling here, low for a hard effort.

    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. The chart shows the ratio moving. Most apps optimize for the last thirty days. Improvement in economy is a slow signal; you see it year over year.

    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. A sidecar SQLite from a separate Garmin extraction tool feeds it, so no Garmin credentials touch my app. Ollama runs locally for the language model that writes the coach's notes.

    Garmin is the spine. It's the source of truth for the activity feed and for everything that matters on a ride: heart rate, power, training-effect, recovery and the route line, which I build from Garmin's own GPS trace at ingest. Strava sits on top as optional garnish. Kudos, descriptions, the occasional ride my Garmin never recorded. When Strava moved API access behind a paid subscription this year, I rebuilt so the app runs fully without it, and by default it does. There's a path that reads a Strava bulk export with no API. Letting that subscription lapse turned out to be load-bearing, not a preference.

    Screenshot of the Setup page: the detected Garmin database with its path and last extract, and the activity sync panel
    Setup. The app points at a read-only Garmin database and syncs from it. No Garmin login lives here.

    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 by shape, not by segment. 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 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

    Route matching earned its own page. A canonical clustering step folds every GPS trace into the route it belongs to, so a loop I've ridden a hundred times collapses into one entry with a hundred efforts under it, ranked. A corridor-coverage matcher catches the near-misses that never quite overlap. Reverse-geocoded labels name each one, and a lifetime-kilometer tile totals every time I've ridden it. It's the first thing I came for, grown up.

    Screenshot of the Routes page listing clustered routes with silhouettes, distances and personal records
    Routes as a first-class page. 223 loops ridden more than once, 3,340 efforts matched, filtered and ranked.

    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.

    Screenshot of the dashboard masthead: the day's readiness and the coach's greeting on the left, the live weather strip on the right
    The masthead. Today's readiness and the coach's read, with the live weather strip and air quality alongside.

    Weather decides whether I ride outside and which bike I take. Happy training is good training.

    Screenshot of a ride-conditions-by-year table counting clear, cloud, drizzle, rain and snow rides for each year
    Every ride's weather, counted. Years of clear, cloud, rain and snow, side by side.

    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 against my four-week baseline, today's weather and now how recent rides felt and what I ate on the bike. It returns a verdict (workout, maintenance or rest), a discipline from a constrained palette (road, gravel, mountain bike, e-MTB, 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. I never see a broken state.

    For a while that was all it was: a paragraph I read in the morning and a debrief I read after a ride. Useful, but one-directional. So I gave it a way to answer. The coach's note is now the opening line of a conversation. On the dashboard I can ask why it wants me to rest today. On any ride I can ask what the cardiac drift means or whether I under-fueled. Both threads are seeded with the note the coach already wrote, so the conversation picks up mid-thought instead of starting cold. Both stay grounded in the same signals the note came from. It can't invent a number it wasn't given.

    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 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.

    The debrief works the same way. Every completed ride gets its own note, written from that activity's full record, and every note is a door into the same back-and-forth. It's the difference between a coach who leaves a sticky note and one who's still in the room.

    Screenshot of the coach chat: a follow-up question about fueling answered with the ride's own carb, power and temperature numbers
    The back-and-forth in practice. Ask how to fuel better and the coach answers from this ride's carbs, power and heat.

    05

    Fuel and feel

    Two things the tracker never used to know: how a ride felt and what I put in my body while it happened. Both matter to a coach. Neither shows up in a power file. So I taught it both.

    Feel is a one-tap rating. Green, amber, red, tapped once when I'm done. It sounds trivial. It is the most useful field I've added, because the numbers can call a ride easy while my legs disagree, and the coach should hear both. Every coaching surface now reads it.

    Fuel took more. It starts with a pantry: every bottle, gel, drink mix and, yes, can of Coke I use, each with its carbs, sodium, caffeine and fluid straight off the packet. Plain water is a product too, so logging a ride is always product times servings, never guesswork.

    Screenshot of the fuel pantry: a table of products with per-serving carbs, sodium, fluid and calories
    The pantry. Every product's per-serving numbers straight off the packet, water included.

    On top of the pantry sits a planner. I give it a duration, an effort and the day's conditions, and it works out the carb and fluid targets. Then it packs a loadout from what's in the pantry: two bottle cages, a plan for water-or-coke refills, one plain bottle held back per carry. It reads sodium in milligrams per litre and raises a quiet flag when a hot forecast meets a low-sodium plan. It is the bookkeeping that decides whether the back half of a long ride falls apart.

    Screenshot of the fuel planner turning duration, effort and conditions into a packed bottle-and-food loadout
    The planner. Duration, effort and conditions in; carb and fluid targets and a loadout packed from the pantry out.

    Both feed the coach, and together they feed a slower question. Once enough rides are logged, a running tally lines fuel up against feel and starts to show which rides I under-fueled and what they cost me. It stays quiet until it has a floor of samples, because two data points is a rumor, not a pattern. So far, the days I fueled properly are the days I rated green.

    06

    The ledger

    Above the coach's greeting sits a readiness strip. It's the morning's recovery snapshot pulled straight from Garmin: readiness, sleep, HRV, training load, each with a small day-over-day arrow so I can see which way the trend is pointing before I read a word. It's the same data the coach reads, surfaced so I can sanity-check the advice against it.

    Under that runs The Ledger, a band of milestones that don't reset. My Eddington number for the year, which is the highest N where I've ridden at least N kilometers on N separate days, so nudging it up is properly hard and properly satisfying. The current week streak. Distance odometers ticking toward the next round number. The Ledger is the long memory, the part that rewards showing up across years, and it's the most motivating screen in the app.

    Screenshot of a cumulative-distance chart plotting this year's running kilometer total against every prior year on the same calendar
    Pace across the years. This year's running total against every year before it, on one calendar.

    07

    The design

    The UI is editorial, not athletic. Tailwind throughout, custom design tokens, a restrained chart theme. Charts via Recharts, maps via React-Leaflet and Mapbox polyline. Activity types get discipline-aware icons and colour treatments. A new chart 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.

    Recently it took the resemblance literally. The tracker now wears the same two typefaces as this site, DM Serif Display over Inter, and the same warm cream surfaces. If the screenshots here look like they belong on gluzman.com, that's because the app and the page you're reading it on now draw from the same brand.

    Screenshot of the stats page: year-to-date distance, time, sessions and elevation against the same window last year
    The stats page. This year against the same days last year.
    Screenshot of the threshold-zones page: suggested FTP and LTHR with heart rate and power zone-distribution histograms
    Zones. Thresholds suggested from the data, and where the hours fall.

    08

    Running it

    It lives as a macOS LaunchAgent on a Mac in my office. RunAtLoad and KeepAlive, 30-second restart throttle. Every 30 minutes it runs a bounded Garmin extract and folds anything new into the database, then runs the optional Strava pass if it's connected and a weather backfill on top. A Strava failure never fails the sync. It downgrades to a warning and the Garmin data lands regardless.

    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 sixteen years runs past 2 gigabytes.

    09

    What I'm learning

    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 and settled on qwen3:14b, the best balance of speed and accuracy I've found on my hardware.

    Garmin over Strava was the right call, and not a close one. Strava's distance and heart rate always felt slightly off to me, and the gap between what Strava recorded and what Garmin recorded on the same ride is real. The tracker only feels true once it's reading from Garmin. Making Garmin the spine, rather than a second opinion, is the change that made everything downstream trustworthy.

    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.

    The softest signal is the one I underrated. A one-tap sense of how a ride felt has changed more of the coach's advice than any metric I bolted on after it. The body keeps notes the power meter doesn't.

    10

    What's next

    A way to share a single ride. Probably an HTML snapshot rendered server-side, so I can text a non-Strava friend "look what I rode" without dragging them onto a platform. Still the thing I reach for and don't have.

    A coach with a longer memory. It reads today well now, and it reads feel and fuel, but each conversation still starts fresh. I want it to know I usually take Mondays off, that I was sick three weeks ago, that I'm two weeks out from a trip. The per-day context is rich. The across-time context is the next hard part.

    A planner that looks forward, not just at today. The fuel model already packs a bottle load for a ride I describe. The next step is a week that plans itself around the events on it, the weather coming and the recovery I'm carrying.

    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.

    Subscribe

    Get the next essay by email, in full, the day it goes up. Subscribe here. Or take the feed: RSS or JSON Feed.