docs: correct the false Wi-Fi premise; add UI and live-tracking phases
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 14s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 53s

The plan's headline section claimed the Rider 650 has on-device Wi-Fi and a
Data Sync menu that uploads to Bryton's cloud with no phone involved. It
does not. The unit has ANT+ and Bluetooth only; its sole sync route is BLE
to the Bryton Active app. Confirmed on the physical device, corroborated by
BikeRadar's hands-on. Likely origin: conflation with the Rider 750 / S800,
which do have Wi-Fi.

Impact is narrower than it first appears and no built code is invalidated:
everything downstream of Bryton's cloud never depended on how a ride got
into that cloud, so the poller, ingestion, schema, wear engine and all of
Phase 0 stand. What was invalidated is the product promise — Phase 1 was
called "Zero-touch ride history" and claimed to fix the original complaint
(having to remember to open the Active app). It does not; it is one-tap.
Renamed accordingly rather than leaving the doc overclaiming.

Corrections propagated everywhere the premise had spread: PLAN.md's opening
sections, Phase 1, top risks (the chain is now longer and has a human link
that fails silently — earns a "nothing ingested in N days" nudge), and the
verification checklist; DECISIONS.md D3's justification; README.md, which
was additionally stale on nearly every other point (claimed no code written,
Postgres, compose, four containers); and RESEARCH.md, where the claim
originated under a "verified" header it had not earned. RESEARCH.md is
annotated rather than rewritten — it is a record of what was found, and the
correction is part of that record. USB path facts are marked unverified too,
since they came from the same unverified batch.

D20 records the process lesson: the plan contained the right check ("first
action before writing any code"), it was never run, and nothing downstream
required it to have been. Device capabilities get confirmed on the device
before being written as fact.

Also adds the two phases requested before this came up, both grounded in
feasibility research rather than assumption:
- Phase 1A, an open-ended UI pass done together, including the verbose field
  surface driven off activity_field_inventory.
- Phase 1B, live tracking. Constrained hard by reality: iOS suspends
  backgrounded PWAs and implements no Web Bluetooth, and Bryton's own Live
  Track needs the phone relaying over BLE, so the tracking client cannot be
  our PWA. Shape that works is OwnTracks POSTing to our API for position,
  with the server deriving distance/pace/elevation; HR and power need BLE and
  are explicitly a second-class opt-in, not a blocker. Two rules written in:
  live positions must never become activities (invariant #6), and "no privacy
  zones, ever" does not extend to a public live link.

Inserted as 1A/1B rather than renumbering Phases 2-5, whose numbers are
referenced from DECISIONS.md, deploy/README.md and code comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
This commit is contained in:
2026-09-21 23:20:37 -04:00
co-authored by Claude Opus 5
parent 244fe525dd
commit 7f33cb1593
4 changed files with 345 additions and 78 deletions
+39 -24
View File
@@ -1,48 +1,63 @@
# bike-app
A self-hosted cycling app: 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.
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: planning complete, no code written yet.**
**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
Today the Rider 650 syncs over Bluetooth to the Bryton Active phone app, which forwards to Strava —
but Active has no background sync, so you have to remember to open the app. Research found a better
path that removes the phone entirely:
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 -> Rider 650 joins home Wi-Fi (Main Menu -> Data Sync)
-> uploads to Bryton cloud
ride ends -> BLE -> Bryton Active app -> Bryton cloud
-> this app's poller fetches the ORIGINAL FIT file
-> rides, wear tracking, and push reminders
```
That's also *higher fidelity* than the current route — Strava's API can only ever return smoothed
streams, never the original file.
**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/RESEARCH.md`](docs/RESEARCH.md) | Raw findings: the Bryton cloud protocol (endpoints, headers, auth), FIT library comparisons, maintenance interval tables, self-hostable geo services, Gitea Actions gotchas |
| [`docs/DECISIONS.md`](docs/DECISIONS.md) | Every decision taken, what was rejected, and why |
| [`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 |
## Planned stack
## Stack as built
Python 3.12 / FastAPI / SQLAlchemy async / PostgreSQL 16 + PostGIS, `procrastinate` for jobs,
SvelteKit static SPA as an installable PWA, MapLibre GL, all behind Caddy in Docker Compose.
Source control and CI in self-hosted Gitea with an act_runner on the same box.
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.
Four containers in v1, under 2GB RAM.
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. **Verify on the Rider 650:** does `Main Menu -> Data Sync` upload *automatically* on joining
Wi-Fi, or only on manual trigger? This determines how completely the phone leaves the loop.
2. **Plug the 650 in over USB** and `ls -R` the mounted volume to confirm the real `.fit` path
(documented as `Bryton/Activities/`, but worth confirming).
3. **Grab a real `.fit` file** from it 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 0: scaffolding and CI (see the roadmap in `docs/PLAN.md`).
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`).