How I Built a Real-Time Training Dashboard on My Personal Website

March 10, 2026

I wanted a page on my website that shows my training data. A heatmap like GitHub’s contribution graph, but for workouts. Live data from my Garmin watch, my own design.


Garmin has no public API

That’s the main problem. You can’t just grab a token and start fetching. You need to authenticate with your credentials, handle MFA, manage session tokens, and deal with their internal endpoints.

I don’t want any of that running on a frontend. So I set up a Python proxy on my VPS that handles all the Garmin auth stuff and exposes clean REST endpoints.

The website fetches from that proxy, server-side, and the API key never reaches the browser.

The Garmin proxy

I’m using python-garminconnect, a reverse-engineered Python library that wraps Garmin’s internal APIs.

The cool part: the FastAPI server auto-discovers every get_* method on the Garmin client and exposes them as REST endpoints. If the library has get_activities_by_date(), I automatically get a /activities_by_date endpoint. No manual wiring.

If I want any sort of data, like /sleep_data. /body_composition, I can add any Garmin data to the frontend without touching the backend and creating new endpoints.

Garmin uses OAuth tokens managed by the garth library under the hood. First setup requires MFA, if you have that enabled, but after that tokens persist to disk and refresh automatically


Astro SSR on Cloudflare

The training page is server-side rendered. On each request, Astro fetches 4 endpoints in parallel: activities (the full year), training status (VO2 max), sleep data, and body composition (weight, body fat, muscle mass).

I use cf.cacheTtl so Cloudflare’s edge caches the responses too. Combined with the VPS cache and browser Cache-Control headers, that’s three layers of caching. Data is always fresh enough without hitting Garmin on every page load.

I chose SSR over static builds because I’m not rebuilding the site every time I go for a run.


The frontend

One big React component handles everything. The heatmap is a full-year grid, Monday-first weeks, cells colored by training volume. Click any day to see that day’s activities. There’s a monthly bar chart view you can toggle to.

Stats at the top show VO2 max, resting heart rate, distance, and body composition (30-day averages). A “Today” card has sleep score, training time, and calories.

Every cardio activity shows its dominant HR zone, using Garmin’s native zone data (hrTimeInZone_1 through hrTimeInZone_5). The zone where you spent the most time wins. Way better than guessing zones from average HR.

One thing I had to deal with: Garmin auto-generates activity names like “Running in Vale de Cambra” and I don’t want my location on a public page. So the component replaces known location strings with zone-based labels. “Running in Vale de Cambra” becomes “Zone 2 Run”.


Withings scale

I use a Withings Body+ for weight, body fat, and muscle mass. It doesn’t sync to Garmin natively, but withings-sync handles that. It pulls from the Withings API and pushes to Garmin Connect. Runs on a cron job on the same VPS.

Every morning after I weigh in, the data shows up in Garmin, and then my training page picks it up through the body composition endpoint. I show 30-day rolling averages instead of the last reading because daily weight is noisy.


What I’d change

Honestly not much.

The generic proxy design was the best decision. This whole thing only took 30-1h to build (thank you Claude for handling the boring setups) and now I can pull any Garmin data without deploying anything. If I want stress levels or body battery tomorrow, it’s just a frontend change.


Stack

LayerTech
WatchGarmin Fenix 7 Pro
ScaleWithings Body+ > withings-sync > Garmin
APIPython FastAPI + python-garminconnect on VPS
WebAstro SSR on Cloudflare Workers
UIReact 19 + Tailwind + Framer Motion
CacheVPS (1h) > Cloudflare edge > browser

Check it out at araujo.zip/training.