A personal running performance dashboard built entirely with Claude Code.
Tracks training over time, syncs with Garmin Connect, and predicts race finish times — built to target a sub-85-minute half-marathon.
- Garmin sync — pulls running activities from Garmin Connect via
python-garminconnect; tokens cached after first login - Activity import — drag-and-drop FIT, GPX, or Garmin's CSV export (
Activities.csv) as an alternative to live sync - Dashboard — pace trends, weekly mileage, personal bests
- Race goal tracker — predicts finish time using the Riegel formula based on recent runs
- 5-week training plan — auto-generated from your race date: Base → Build → Peak → Taper → Race week, with knee stretch reminders and checkboxes
- Training load — weekly volume chart with spike warnings (>10% week-on-week flags injury risk)
- Pace calculator — per-km split breakdown for any target time
| Layer | Tech |
|---|---|
| Frontend | React + TypeScript + Vite + Tailwind + Recharts |
| Backend | FastAPI + SQLite (SQLModel) |
| Garmin | python-garminconnect (unofficial API) |
cd backend
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env # add your Garmin credentialscd frontend
npm install./start.sh # starts both servers; Ctrl+C to stopOpen http://localhost:5173.
Add your Garmin Connect email and password to backend/.env (gitignored):
GARMIN_EMAIL=you@example.com
GARMIN_PASSWORD=yourpassword
OAuth tokens are cached in backend/.garmin_tokens/ after the first successful login — also gitignored.
This app was built in a single Claude Code session. Here's a condensed version of the process:
Claude used its interactive question tool to clarify app type (web), data source (Garmin API), and desired features before writing a single line of code.
Claude generated the entire project structure in parallel — FastAPI backend, React frontend, all components — without switching back and forth.
Blank page on first load
The tsconfig.json was missing. Without it Vite's TypeScript compilation silently failed, leaving a blank screen. Claude spotted it from the browser console error.
Object.keys(null) crash
When no runs exist yet, the backend returns a stats object without a personal_bests key. The frontend crashed trying to call Object.keys() on it. One-line null-guard fix.
Garmin 429 rate limit
python-garminconnect hits Garmin's SSO on every call by default. Garmin rate-limits this aggressively. Fix: cache OAuth tokens to disk on first login (client.garth.dump(TOKEN_STORE)) and reuse them on subsequent syncs — SSO is only hit once.
Token store FileNotFoundError
The library expected the token directory to already exist before writing to it. Fix: os.makedirs(TOKEN_STORE, exist_ok=True) before login, plus a guard to only pass tokenstore to login() when the file already exists.
Silent token cache failure
garth.dump() was wrapped in a bare except: pass, meaning a failed save went unnoticed and every future sync would hit SSO again. Removed the silent catch so failures surface immediately.
CSV import as Garmin sync fallback
Garmin's SSO rate-limits aggressively — after repeated 429 errors with no resolution, a CSV/FIT/GPX file import was added. Garmin Connect lets you export all activities as a single Activities.csv instantly, bypassing the API entirely.
TypeScript tuple type error
type DayTemplate = [WorkoutType, number?, string?, string, string, boolean?] — TypeScript does not allow required elements after optional ones in a tuple. Fixed by using number | undefined instead of number? for mid-tuple elements.
- The Garmin integration uses an unofficial API — it may break if Garmin changes their auth flow.
- First Garmin sync can be slow (~200 activities). Subsequent syncs skip already-imported runs.
- As a reliable alternative, export
Activities.csvfrom Garmin Connect and use the Import button. - Race time prediction uses the Riegel formula:
T2 = T1 × (D2/D1)^1.06 - The 5-week training plan is generated from your race date. Update the goal if the plan dates look off.