IEM

Strava/Garmin Training Assistant

Private code
  • Public landing page of the console, with no athlete data at all.
    Public landing page, deliberately data-free
  • Dashboard with volume, weekly TRIMP, CTL, ATL, TSB, ACWR, monotony and active days, each with its trend sparkline.
    Load and form dashboard, with each metric's trend
  • Performance tab: readiness for the target race, predictions and personal bests.
    Performance: readiness, predictions and personal bests
  • Load tab with acute and chronic training load over the year.
    Acute and chronic load over time
  • Weekly training calendar with each day's session, planned against actual, and the activity linked from Strava or Garmin.
    Training calendar: planned versus completed
  • Operations view with the job queue, errors, log, runs and received webhooks.
    Operations: queue, errors, log and webhooks

A Cloudflare Worker that receives Strava webhooks, compares the day's main activity with the training plan, and adds weather and race data. It then uses AI to generate the title and summary published to Strava.

  • Queues with retries and a dead-letter queue, plus an hourly cron and a daily purge.
  • Strava tokens encrypted with AES-GCM before being stored in D1.
  • React admin dashboard served by the Worker itself.
  • Garmin FIT file import and activity streams stored in R2.

The problem

Strava titles every workout «Afternoon Run» and leaves the description empty, so a year of history says nothing about how any of those days went. The data to tell it does exist: the training plan, the weather at that hour and, if the activity was a race, its official page. What was missing was something to put them together on its own, every day, without having to remember.

My role

Everything: the Worker, the D1 schema, the queues, the React admin dashboard and the local tools that import Garmin files.

Decisions

  1. The webhook enqueues and answers; the work happens elsewhere

    Strava gives two seconds to answer a webhook, and the work — read the activity, match it against the plan, fetch the weather, generate the copy and publish it back — does not fit. The webhook drops a job on a Cloudflare queue and answers; the queue processes it with retries, and whatever exhausts them lands in a dead-letter queue instead of vanishing. Without that second queue, a third-party API failure swallows the day without a trace.

  2. The model is not told where you live

    The start point of a run that leaves from home is the home address, and that coordinate went whole into a third party's prompt and was stored in cleartext in the races table. It is now rounded to two decimals, about 1.1 km: enough to tell two races with the same name in different cities apart, which is all it was there for, and not enough to point at a doorway. The Strava tokens are also encrypted with AES-GCM in the database.

  3. The model writes the prose; the code writes the numbers

    A model generates the title and the opening line, but the metrics block is deterministic and computed separately. A model that writes well also rounds paces, invents a plausible elevation gain, and nobody notices until someone checks against the watch. Keeping them apart lets each side do what it is good at, and means swapping the model moves no number.

  4. Garmin is imported from outside, not from the Worker

    Garmin Connect has no open API like Strava's: you have to log in, pass the second factor and decode FIT files. That lives in local tools that read and push to the Worker, rather than putting Garmin credentials and a binary decoder inside something that runs permanently and faces the internet. The exposed surface stays limited to what actually has to be exposed.

Outcome

  • 197 commits between April and September 2026, 387 TypeScript files and 36 D1 migrations: a service that has been publishing on its own for months.
  • 40 test files with nearly 11,000 lines, running inside the Workers runtime itself rather than a simulated Node.
  • Two data sources — Strava by webhook and Garmin by FIT file — land in the same history, with weather and the official race when they exist.

What I would change

The home coordinate did travel to a third party before anyone asked what was being sent, and it was fixed afterwards, by rereading the code with fresh eyes. That question — what leaves here, and where does it go — cannot wait for an audit: it belongs at the moment you write the first call. And the project grew to 62,000 lines for something one person uses; half the dashboard's features I have opened twice.

  • Cloudflare Workers
  • D1
  • Queues
  • R2
  • React
  • TypeScript
  • Hono
  • Vitest