BBergle b467465f75
CI / Repo hygiene (push) Successful in 2s
CI / Web (lint, typecheck, build) (push) Successful in 13s
CI / Migrations reversible (push) Successful in 5s
Release image / Build and push single-container image (push) Successful in 7s
CI / API (lint, types, tests) (push) Successful in 55s
Merge pull request 'docs: correct the false Wi-Fi premise; add UI and live-tracking phases' (#13) from docs/roadmap-live-tracking into main
Reviewed-on: #13
2026-09-21 23:22:44 -04:00

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

  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).
S
Description
No description provided
Readme
742 KiB
Languages
Python 70.1%
TypeScript 8.4%
Shell 8.2%
Svelte 7.6%
Dockerfile 2.6%
Other 3.1%