Reviewed-on: #13
bike-app
A self-hosted cycling app ("Velodrome"): syncs rides from a Bryton Rider 650, tracks mileage like Strava, and adds a spare-parts inventory and a maintenance record with mileage-milestone reminders.
Status: Phase 0 complete and deployed. Auth, the single-container image, CI/CD into a
self-hosted Gitea registry, and a real HTTPS deployment are live and verified. No ride ingestion
yet — that's Phase 1. See the roadmap in docs/PLAN.md.
Why
The Rider 650 syncs over Bluetooth to the Bryton Active phone app, which forwards to Bryton's cloud and on to Strava. Two problems: Active has no background sync, so you have to remember to open the app; and Strava's API can only ever hand back decoded, smoothed streams — never the original file.
This app polls Bryton's cloud directly and takes the original, unmodified FIT bytes:
ride ends -> BLE -> Bryton Active app -> Bryton cloud
-> this app's poller fetches the ORIGINAL FIT file
-> rides, wear tracking, and push reminders
What that does and doesn't fix. It does not remove the phone: you still open Active once after a
ride, and nothing in this app can reach across that gap (the Rider 650 has no Wi-Fi, and its BLE
sync protocol is undocumented — see docs/PLAN.md, "How rides actually reach the app"). What it
fixes is everything after that tap — full-resolution original bytes in your own database, every
field the head unit recorded, wear recalculated, reminders armed, and no third party able to change
the terms later.
Docs
| File | What's in it |
|---|---|
docs/PLAN.md |
The full implementation plan: stack, schema, ingestion pipeline, auth, notifications, phased roadmap, CI/CD, risks, verification |
docs/DECISIONS.md |
Every decision taken, what was rejected, and why — including the ones later reversed, with the reasoning intact |
docs/RESEARCH.md |
Raw findings: the Bryton cloud protocol, FIT library comparisons, maintenance interval tables, self-hostable geo services, Gitea Actions gotchas |
CLAUDE.md |
Conventions, non-negotiable invariants, branching and PR workflow |
deploy/README.md |
How to build, run, and bootstrap the deployed container |
Stack as built
Python 3.12 / FastAPI / SQLAlchemy 2.0 async / SQLite (D15 — reversed the original Postgres+PostGIS choice mid-Phase-0), SvelteKit static SPA as an installable PWA, MapLibre GL to come in Phase 1, all served by Caddy from a single container (D16). Source control and CI in self-hosted Gitea with act_runner on the same host.
The two consequences of the SQLite decision worth knowing before reading any code: user isolation is
enforced entirely in the repository layer (apps/api/velodrome/db.py's Scope), with no database
RLS behind it; and the original job-queue choice (procrastinate, Postgres-only) needs a
replacement before Phase 1's ingestion pipeline can be built.
Next steps
- Pick the two Phase 1 blockers deferred by D15: the background job queue, and how to store ride tracks without PostGIS.
- Verify against the physical device before building on it (the lesson of D20): the USB
.fitpath layout, and that theintervalssyncprotocol still retrieves activities from a current Bryton account. - Grab a real
.fitfile and run it throughfitdecode— Bryton's encoder is not Garmin's, and the schema should be checked against reality before it's written. - Then Phase 1: ingestion (see the roadmap in
docs/PLAN.md).