# 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`](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`](docs/PLAN.md) | The full implementation plan: stack, schema, ingestion pipeline, auth, notifications, phased roadmap, CI/CD, risks, verification | | [`docs/DECISIONS.md`](docs/DECISIONS.md) | Every decision taken, what was rejected, and why — including the ones later reversed, with the reasoning intact | | [`docs/RESEARCH.md`](docs/RESEARCH.md) | Raw findings: the Bryton cloud protocol, FIT library comparisons, maintenance interval tables, self-hostable geo services, Gitea Actions gotchas | | [`CLAUDE.md`](CLAUDE.md) | Conventions, non-negotiable invariants, branching and PR workflow | | [`deploy/README.md`](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 1. **Pick the two Phase 1 blockers** deferred by D15: the background job queue, and how to store ride tracks without PostGIS. 2. **Verify against the physical device before building on it** (the lesson of D20): the USB `.fit` path layout, and that the `intervalssync` protocol still retrieves activities from a current Bryton account. 3. **Grab a real `.fit` file** and run it through `fitdecode` — Bryton's encoder is not Garmin's, and the schema should be checked against reality before it's written. 4. Then Phase 1: ingestion (see the roadmap in `docs/PLAN.md`).