A personal training dashboard for your rides: a static site built from your Strava data that stays on your machine.
Casquette turns your rides into a fast, fully static website. Every ride becomes a Markdown file with all of its data in the frontmatter. Astro builds the pages from those files, so nothing runs on a server. A fresh clone ships with a synthetic demo dataset, so you can look around before you connect anything.
Every page shares the same frame: a collapsible sidebar with all pages, a slim header with breadcrumbs and, on Dashboard and Rides, the period (7 days, 14 days, 30 days, 3 months, 1 year, all time) and the zone basis (power or heart rate). Your choice is kept across pages. Calm grey-and-pink cards and charts (zones keep their traditional colours) with exact values on hover, sortable tables, light, dark or system theme.
| Page | What you get |
|---|---|
Dashboard / |
Key figure cards with the change against the previous period, fitness, fatigue and form chart, 52-week training calendar heatmap, form today, 80/20 check, power profile, training load, goals, zones and intensity, power and heart rate, efficiency, power curve, ride analysis, history, records, fuelling, recent rides |
Rides /rides |
The ride log for the chosen period as a table: sort any column, search by name or place, show or hide columns, click a row to open the ride |
Ride /rides/[id] |
Route map coloured by power zone, key data, power and heart-rate streams coloured by zone plus cadence, speed and elevation, with one shared hover that the map follows, time in power and heart rate zones, power curve vs. year and all-time, performance analysis and fuelling, laps, segments, kilometre splits, raw data |
Years /years |
All years compared, and a "wrapped"-style review per year: key figures vs. the previous year, months, at a glance, calendar, months in detail, intensity, time in zones, power curve, bikes, highlights, top 10, all rides |
Map /map |
Heatmap of all outdoor routes, filter by year, bike or type, colour by year or bike. Browse places by country, region and town, or click the map to see every ride that passed there |
Segments /segments |
All segments in a searchable table (all, ≥ 2 attempts, climbs, virtual). Per segment: attempts, PR history, watts vs. time, segment map and elevation profile |
Performance /performance |
FTP history, estimated FTP, best efforts, W/kg and weight, long-term fitness, power curve, power profile per year |
Gear /gear |
All bikes with distance per year, kilometres per bike and year. Per bike: key figures, setup, distance per month, power curve, records, cost per km, all rides |
|
|
|
|
|
|
You need Node.js 22.18 or newer (it runs the TypeScript scripts directly). To sync your own rides you also need a Strava subscription: since June 2026 Strava only lets subscribers create the personal API application the sync uses (one per Strava account). The demo runs without any account.
Clone this repository, then:
npm ci
npm run dev # http://localhost:4321Without any synced rides, Casquette shows about three years of generated rides, bikes, goals and FTP history. The data is synthetic: a made-up "travelling rider" who rides real roads in Munich, London, Paris, New York and Vancouver, with real place names and well-known segments such as Box Hill or Swain's Lane. Nothing comes from a real person's rides. A "Demo data" entry in the sidebar marks it and links to the setup steps. As soon as your first ride is synced, the whole site switches to your data and the entry disappears. Demo and real data are never mixed.
- Create a Strava API application at https://www.strava.com/settings/api. Pick any name and website (e.g.
http://localhost), and set Authorization Callback Domain tolocalhost. - Create
.envfrom the template and fill in the Client ID and Client Secret of your app:cp .env.example .env
- Authorize once. This opens Strava in your browser and stores a token locally (
.strava-token.json, refreshed automatically):npm run strava:auth
- Sync your history. Strava's rate limits allow only a limited number of requests per 15 minutes and per day.
sync:allwaits them out and keeps going until your complete history is downloaded, newest rides first. At the end it looks up the place names of all your routes via OpenStreetMap Nominatim (one request per second, so a large history adds about an hour). You can stop it at any time; the next run continues where it left off.npm run sync:all
- Start the dashboard. If
npm run devwas already running during your first sync, restart it.npm run dev
From then on, run npm run sync whenever you want new rides; it looks up the few new places itself. How the sync works in detail (rate limits, cache and offline rebuild, power and HR backfill, geocoding): docs/sync.md.
Your rides contain heart rate, power and GPS tracks. The start and end of most rides give away your home address. That is why none of your data is ever committed: everything the sync writes is ignored by Git (see .gitignore).
| Ignored | Contents |
|---|---|
.env, .strava-token.json |
Your API credentials and access token |
.strava-cache/ |
API cache (summaries, details, derived stream data) for offline rebuilds |
src/content/rides/ |
One Markdown file per ride |
src/content/gear/ |
One Markdown file per bike or pair of shoes |
public/streams/ |
Second-by-second data (including GPS) for charts and maps |
src/data/athlete.json |
Profile, zones, FTP, gear |
src/data/fitness-history.json |
FTP and weight history |
src/data/goals.json |
Your goals |
src/data/zones.json |
Your own heart rate and power zones (optional) |
src/data/geo-cells.json |
Place cache, which reveals every place you have ridden through |
Only the empty folders and the example files src/data/*.example.json are tracked. Never force-add these files, and keep a backup of them yourself: they are not in the repository, so Git won't restore them.
Goals: copy the example and edit it. Without src/data/goals.json, Casquette uses the example.
cp src/data/goals.example.json src/data/goals.json{ "id": "year-km", "label": "Distance per year", "period": "year", "metric": "distance", "target": 5000 }
{ "id": "week-z2", "label": "Zone 2 per week", "period": "week", "metric": "zone_time", "zone": 2, "basis": "power", "target": 3 }
{ "id": "month-long", "label": "Long rides", "period": "month", "metric": "long_rides", "min_km": 80, "target": 2 }period: year, month, week. metric: distance (km), time (h), elevation (m), count, tss, zone_time (h), long_rides.
FTP and weight history: Strava only provides your current values. The sync appends every change to src/data/fitness-history.json (created on the first sync). Add older FTP tests by hand; IF, TSS and W/kg of each ride are then calculated with the values valid on the ride date. npm run build applies changes, no new sync needed.
{ "id": "2024-03-01", "date": "2024-03-01", "ftp": 230, "weight": 78, "source": "manual", "note": "Ramp test" }Zones: by default, heart rate zones come from Strava and power zones are Coggan, from the FTP on the ride date. To use your own, copy the example and edit it. Unlike the goals, the example is never used on its own: without src/data/zones.json nothing changes. npm run build applies changes, no new sync needed; the Zones card on the dashboard shows where your zones come from.
cp src/data/zones.example.json src/data/zones.json{ "heart_rate": [{ "from": "2023-01-01", "bpm": [0, 125, 145, 160, 175] }, { "from": "2025-03-01", "bpm": [0, 130, 150, 165, 180] }],
"power": { "ftp_percent": [0, 55, 75, 90, 105, 120, 150] } }heart_rate: the lower bounds of Z1–Z5 in bpm, starting at 0. Each entry applies from its from date to the next one; leave from out for zones that never changed. power: the lower bounds of Z1–Z7 in % of FTP, starting at 0; leave it out to keep Coggan. Either part is optional.
Heart rate time in zones is computed from an HR distribution the sync stores per ride. Rides synced before this existed keep the zone times stored at sync until you run npm run sync -- --backfill-hr once (details).
Gear details: in src/content/gear/<id>.md, the custom: block and the text below the frontmatter belong to you. The sync never touches them.
custom:
purchase_date: "2024-03-15"
price_eur: 4200 # enables cost per km on the gear page
components: ["Shimano GRX 2x12", "Zipp 303 S"]
color: "Sand"
notes: "Favourite bike for long gravel days"Environment variables (in .env):
| Variable | Purpose |
|---|---|
STRAVA_CLIENT_ID, STRAVA_CLIENT_SECRET |
Your Strava API application (required for syncing) |
FTP, WEIGHT |
Fallbacks if Strava provides no values |
STRAVA_AUTH_PORT |
Local port for the OAuth callback (default 8723) |
GEO_BUDGET_MIN |
Minutes per sync spent on looking up new places (default 15; sync:all has no limit unless this is set) |
PUBLIC_LANG |
UI language (default en) |
Geocoding settings (GEO_LANG, NOMINATIM_URL, NOMINATIM_UA) are described in docs/sync.md.
Language: all UI text lives in src/i18n/en.ts. To add a language, copy that file, translate it, register it in src/i18n/index.ts and set PUBLIC_LANG.
| Command | What it does |
|---|---|
npm run dev |
Start the dev server at http://localhost:4321 |
npm run build |
Build the static site into dist/ |
npm run preview |
Serve the built site locally |
npm run strava:auth |
Connect to Strava once (OAuth) |
npm run sync |
Fetch new rides plus as much history as today's rate limit allows |
npm run sync:all |
Like sync, but waits out rate limits until the full history is there |
npm run sync:rebuild |
Rewrite all ride files from the local cache, offline |
npm run geo |
Look up all missing places without touching Strava (resumable), e.g. after changing GEO_LANG |
npm test / npm run test:watch |
Run the tests once / in watch mode |
npm run typecheck |
Type-check everything (astro check, strict) |
npm run test:e2e |
Build the demo site and run the browser tests (details) |
npm run test:website |
Browser test of the promo website in website/ |
npm run demo:generate |
Regenerate the demo dataset in demo/ (deterministic, offline) |
npm run demo:fetch-routes |
Refresh the cached road geometry of the demo routes (uses the BRouter web service; details) |
npm run screenshots |
Retake the README screenshots from the demo build (details) |
npm run demo:check -- <dir> [--base /demo/] |
Fail unless a demo build contains only demo data and works under its base path |
Sync flags such as --limit, --no-geo and --backfill-power are described in docs/sync.md.
npm run build # static site in dist/dist/ is plain HTML, CSS, JS and JSON. Host it wherever you like, or just run it locally. Syncing is always manual: sync, then build again.
Warning
The built site contains your health and location data, including GPS tracks. If you put it on the public internet, protect it (e.g. with HTTP basic auth). Don't publish it on open static hosting.
The promo website and the public demo (synthetic data only) are built separately: npm run site:build fills the website's legal pages from SITE_LEGAL_* variables and can add Cabin analytics. With PUBLIC_LEGAL_URL set at build time, the app links to those legal pages from its sidebar. See website/README.md.
The live website and demo deploy themselves: on every push to main, .github/workflows/pages.yml publishes the website at / and the demo (built from the demo dataset, checked by npm run demo:check) at /demo/ on GitHub Pages.
It reads the legal details (SITE_LEGAL_*) from repository secrets and the custom domain (PAGES_DOMAIN, optional SITE_CABIN_HOST) from repository variables; your own copy of Casquette needs none of this (details).
- Astro 7 with content collections: fully static
- React islands with shadcn/ui and Tailwind CSS v4, charts with Recharts (shadcn charts), tables with TanStack Table
- Leaflet with OpenStreetMap tiles for maps (no API key)
- OpenStreetMap Nominatim for place names
- TypeScript throughout; the sync and site scripts run directly on Node.js (type stripping), without a build step or extra dependencies
- Vitest for unit tests, Playwright with axe for browser tests
Design system: DESIGN.md · Domain terms: GLOSSARY.md · Architecture decisions: docs/adr/
npm test
npm run typecheck # astro check: .ts, .tsx and .astro, strictTests live next to the code (*.test.ts) under src/ and scripts/. The build reads all its data through one build dataset (src/lib/build-dataset.ts, docs/ride.md); tests give it an in-memory data source and a fixed clock instead of files. Scripts are split by data side: scripts/sync/ talks to Strava and Nominatim and writes your own data, scripts/site/ works on the demo dataset, the website and the brand, and scripts/shared/ holds the ride file, metrics and helpers both use. The sync side never imports the site side and vice versa (scripts/demo-isolation.test.ts).
npm run test:e2eBrowser tests in e2e/ run against a production build of the demo dataset (DATA_SOURCE=demo, built into dist-e2e/), so they never touch your own rides. They use a locally installed Chromium or Chrome (CHROME_PATH to pick one); without one, run npx playwright install chromium once.
Specs import test and expect from e2e/fixtures.ts: page.goto there waits until every island has hydrated (<html data-hydrated>, set by useIslandReady() in each client:load component), and motion is reduced. After navigating by a click, call hydrated(page) before interacting. Keep one page per test for heavy checks such as axe; e2e/demo.ts lists the demo years and bikes for that.
npm run test:websiteThe promo website (website/, plain HTML without a build) has its own browser test in e2e-website/ (playwright.website.config.ts): it serves the folder as is and checks it loads without errors and passes axe in light and dark. It needs no build and doesn't run with test:e2e.
The demo dataset in demo/ is generated by npm run demo:generate (seeded, byte-for-byte reproducible, no network). Its routes are hand-picked waypoints per city (scripts/site/lib/demo-cities.ts). npm run demo:fetch-routes asks the public BRouter cycling router once for a road-following track through them and stores the simplified result, with elevation, in demo/source/routes/. Run it only after changing the waypoints, then regenerate the demo data.
Route geometry in demo/source/routes/ and the demo rides derived from it: © OpenStreetMap contributors, available under the Open Database License (ODbL). Routed with BRouter; elevation from SRTM via BRouter.
Compatible with Strava: Casquette uses the Strava API to read your own activities. Links back to Strava read “View on Strava”, as the Strava API Brand Guidelines ask. This project is independent and is not developed, sponsored or endorsed by Strava. Map data and demo route geometry © OpenStreetMap contributors (ODbL).
Casquette follows semantic versioning. What counts is everything you keep or run on your side: the ride files and the other files in src/data/ and .strava-cache/, the zones and goals file formats, the npm run commands, the .env settings and the required Node.js version. A change that breaks one of them is a new major version and comes with a migration or clear upgrade steps. New features are minor versions, fixes are patch versions. Changes are listed in CHANGELOG.md.