Your Strava and Wahoo rides in one place, with the analysis both of them leave out.
Pulls every ride from Strava and Wahoo (and raw .fit files straight off a head unit)
into a single local SQLite database, then builds the reporting on top: maps, elevation
and power breakdowns, time-range and segment comparison, long-run progression, and a
training view that says what the numbers actually mean.
Everything runs on your machine. No account, no cloud, no telemetry — the database is one file you can copy, back up, or delete.
Ride analysis
- Route map, shaded by elevation, power, heart rate or speed
- Power, heart rate, cadence, speed and elevation on one shared time-or-distance axis
- Drag any chart to select a range — every metric is recomputed for that section (normalized power, IF, TSS, VAM, average grade), and the section highlights on the map
- Laps, segment efforts, time in zone, and a per-ride power curve
- "Same route, other days" — the rides worth comparing today's against
Connections
- Link Strava and Wahoo from the Settings page — stepped setup, credentials stored
locally, no
.envediting or restart - Automatic sync polls linked accounts in the background on an interval you choose
- Direct
.fitupload and a batch importer for a whole Strava archive
Training
- Fitness / fatigue / form (CTL, ATL, TSB) with the standard 42- and 7-day models
- Ramp rate and acute:chronic workload ratio, with the thresholds that matter flagged
- Intensity distribution named against the polarized / pyramidal / threshold models
- Rider profile across four energy systems, so you can see which one is your limiter
- Concrete session prescriptions derived from your current form, distribution and limiter
Progression
- Weekly, monthly and yearly rollups of volume, load, climbing and average power
- All-time mean-maximal power curve, with 42/90/365-day curves layered on it
- Critical power and W′ fitted from your own bests, plus FTP estimates and how much to trust them
- Efficiency factor over time — the cleanest single marker of aerobic progress
- Segment effort history with a running personal best
Comparison
- Two to four rides overlaid on a normalised progress axis, so rides of different lengths line up
- Side-by-side summary and power curves
git clone https://github.com/rompasaurus/cycling-training.git
cd cycling-training
npm install
cp .env.example .env
npm run db:migrate
npm run devThen open http://localhost:5173.
The API runs on :4000, the web app on :5173, and Vite proxies /api across so
there is one origin in development.
npm -w server run seedGenerates a synthetic 400-day season — realistic power, heart-rate lag, cardiac drift,
a GPS track and a progressive build — so every screen is populated before you connect
a real account. Deterministic, so it produces the same season each time. Wipe it with
npm run db:reset.
Settings → Connections does this in the app — a stepped setup guide with the exact values to copy, a credentials form, and the authorise button. No file editing, no restart.
- Create an app at https://www.strava.com/settings/api.
- Set Authorization Callback Domain to
localhost— just the domain, no scheme or port. - Paste the Client ID and Secret into Settings and save.
- Press Connect Strava and approve.
STRAVA_CLIENT_ID / STRAVA_CLIENT_SECRET in .env still work and take precedence.
Strava allows 200 requests per 15 minutes and 2,000 per day. A full sync costs two calls per ride (detail + streams), so importing a long history happens in batches — run the sync again after the window resets and it picks up where it stopped. Segment efforts come along with the ride detail automatically.
Wahoo rejects http:// redirect URIs, even on localhost, so this one needs a
certificate first. One command, once:
npm run cert # issues certs/localhost.pem via mkcert, or openssl as a fallbackRestart the server and it serves HTTPS on :4443 alongside HTTP on :4000. Then, under
Settings → Connections → Wahoo:
- Register at https://developers.wahooligan.com.
- Set the redirect URI to
https://localhost:4443/api/auth/wahoo/callback— the whole URL, path and scheme included, unlike Strava's bare domain. Copy it from the Settings page so it matches exactly. - Paste the Client ID and Secret into Settings, save, and connect.
If Wahoo answers "The requested redirect uri is malformed or doesn't match client
redirect URI", the URL registered with Wahoo differs from the one being sent — usually
http vs https, or a wrong port. Settings shows the exact string to register.
With mkcert installed the certificate is issued by a CA in your system trust store, so browsers accept it silently. Without it the script falls back to a self-signed certificate that works but prompts a warning on first visit.
Wahoo does not expose per-second streams over its API — the workout detail carries a signed URL to the original FIT file, so the sync downloads and parses that. If the file is missing the ride still imports from the summary, just without streams.
Settings → Import .fit files takes files straight off a head unit. No account needed, and a FIT file carries the full per-second record — more than either API returns.
For a bulk Strava export (thousands of .fit.gz files, more than the uploader takes at
once), use the batch importer:
./scripts/import-fit.sh ~/Downloads/strava_export/activitiesIt gunzips, uploads in batches, and reports per-file failures. Re-running is safe.
→ Full export and import guide — how to get the archive out of Strava, how to pull files off an ELEMNT, which formats import and which do not, and what to set up afterwards.
The same ride usually lands twice: your head unit pushes to Wahoo and Strava. Rides that start within 10 minutes of each other, from different sources, with distances within 2%, are linked as duplicates. The richer copy (more stream channels, power, GPS) is kept as primary and the other is excluded from every total — but not deleted, because Strava carries segment efforts the FIT file does not.
Settings → Athlete profile. Three values do most of the work:
| Value | What it drives |
|---|---|
| FTP | TSS, intensity factor, every power zone, and therefore CTL/ATL/TSB |
| Threshold HR (LTHR) | Heart-rate zones, and a load score for rides with no power meter |
| Weight | W/kg and the rider-profile comparison |
Changing any of them re-analyses every stored ride automatically.
FTP history matters if you have more than a season of data: a ride from last winter should be scored against the FTP you had then, not the one you have now. Add entries under Settings → FTP history and historical TSS is recalculated against the right value.
Everything is derived locally from your streams; nothing is taken on trust from a provider.
- Normalized power — Coggan: the fourth-power mean of the 30-second rolling average. Requires 1 Hz data, which is why every stream is resampled to 1 Hz on import.
- TSS —
seconds × NP × IF ÷ (FTP × 3600) × 100. One hour at FTP is exactly 100. - hrTSS — for rides without power, the same identity using average HR against LTHR.
- CTL / ATL — 42- and 7-day exponentially weighted averages of daily TSS. TSB is yesterday's CTL − ATL, which is what training platforms display.
- Mean-maximal curve — the best rolling average at 44 durations from 1 s to 5 h, computed per ride via prefix sums and rolled up across your whole history.
- Critical power — the two-parameter model
P(t) = W′/t + CP, least-squares fitted on 2–20 minute bests. Only reported when the fit clears r² ≥ 0.9. - Aerobic decoupling (Pw:Hr) — output per heartbeat in the first half of a ride against the second. Under about 5% is the usual marker of aerobic durability.
- Elevation gain — hysteresis threshold of 3 m, without which barometer jitter invents hundreds of metres of climbing on a flat road.
- Rider profile — 5 s / 1 min / 5 min / 20 min W/kg placed on the Coggan power-profile ladder. Read the shape, not the absolute score: the gap between your best and worst system is where the training time should go.
The maths has a test suite, as does the FIT ingest path — including a miniature FIT
encoder, so parsing is tested against real binary rather than a mock: npm test.
cycling-training/
├── server/ Node + Express + better-sqlite3 (TypeScript, ESM)
│ ├── src/analytics/ Pure functions — power, zones, load, coaching
│ ├── src/ingest/ Resampling, FIT parsing, storage, sync orchestration
│ ├── src/providers/ Strava and Wahoo clients, OAuth token handling
│ ├── src/routes/ HTTP API
│ ├── src/db/schema.sql The whole data model
│ └── test/ Analytics test suite
└── web/ React 18 + TypeScript + Vite
├── src/components/charts/ Purpose-built SVG charts (no charting library)
├── src/pages/ One file per screen
└── src/styles/tokens.css Design tokens, light and dark
Why hand-written SVG charts. Ride streams are tens of thousands of points and a general charting library either chokes on that or forces its own visual language. These charts down-sample server-side, share one crosshair across lanes, and follow a single data-visualisation contract — including a colourblind-validated palette and a table view behind every chart.
Why SQLite. One file, no server to run, fast enough for a lifetime of riding (a 400-ride season with full streams is about 40 MB), and it ports directly to Android via Room when the mobile app happens.
| Command | What it does |
|---|---|
npm run dev |
API and web app together, both watching |
npm run build |
Type-check and build both |
npm start |
Run the built API |
npm test |
Analytics test suite |
npm run typecheck |
Type-check both workspaces |
npm run db:migrate |
Create or update the database |
npm run db:reset |
Delete the database and start over |
npm -w server run seed |
Generate a synthetic season |
npm -w server run db:unseed |
Remove seeded data only, keeping real rides |
./scripts/import-fit.sh <dir> |
Bulk-import a folder of .fit / .fit.gz files |
npm run cert |
Issue a local TLS certificate (needed for Wahoo OAuth) |
| Endpoint | Purpose |
|---|---|
GET /api/activities |
Filtered, sorted, paginated ride list with totals |
GET /api/activities/:id |
Ride detail, laps, curve, zones, segment efforts, similar rides |
GET /api/activities/:id/streams?points=N |
Down-sampled streams |
GET /api/activities/compare/streams?ids=1,2 |
Aligned multi-ride comparison |
GET /api/analytics/summary|load|zones|progression|power-curve|records|calendar|coach |
Analysis |
GET /api/segments, GET /api/segments/:id |
Segments and effort history |
GET|PUT /api/settings/profile |
Athlete profile (re-analyses on change) |
POST /api/sync/strava|wahoo|all |
Run a sync |
POST /api/sync/upload |
Multipart .fit upload |
GET|PUT|DELETE /api/settings/providers/:p |
API credentials, entered in the UI |
GET|PUT /api/settings/autosync |
Background sync schedule |
- Android app over the same SQLite schema
- Weather and wind on ride detail
- Route-matched comparison rather than start-point proximity
- Planned workouts and a calendar you can put sessions into
- Gear and maintenance tracking against distance
MIT
