Add planning docs for self-hosted cycling app

Planning output only; no application code yet.

Key findings driving the design:

- The Bryton Rider 650 has on-device Wi-Fi (Main Menu -> Data Sync) and
  uploads to Bryton's cloud with no phone and no Bryton Active app. Paired
  with the reverse-engineered Bryton cloud API — which returns the original
  unmodified FIT bytes — this makes ride sync fully hands-off, and higher
  fidelity than the current Strava route (Strava's API cannot return the
  original file, only smoothed streams).
- Build fresh rather than forking Endurain or FitTrackee; borrow Endurain's
  gear/component structure and strava-gear's retroactive time-ranged wear
  computation.
- PWA rather than a native iOS app: iOS 16.4+ gives home-screen PWAs real
  push notifications, which was the only thing that used to force native.

Docs:
  docs/PLAN.md       stack, schema, ingestion, auth, notifications, roadmap,
                     CI/CD, risks, verification
  docs/RESEARCH.md   Bryton cloud protocol, FIT library comparison,
                     maintenance intervals, geo services, Gitea gotchas
  docs/DECISIONS.md  decisions taken, alternatives rejected, rationale

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 20:55:30 -04:00
co-authored by Claude Opus 5
commit d3f5ed7e7b
5 changed files with 1239 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
# Decisions
Settled during the planning session on 2026-09-20. Each entry records what was chosen, what was
rejected, and why — so we don't relitigate them.
---
### D1 — Build fresh, don't fork
**Chosen:** greenfield, borrowing data models only.
**Rejected:** forking Endurain (AGPL, FastAPI+Vue, already has gear/component tracking, but in a
feature freeze with a thin maps story) or FitTrackee (AGPL, mature heatmaps, but its "equipment" is
one flat tag per workout with no wear intervals — the entire parts/maintenance system would be
bolted on anyway).
**Why:** nothing existing covers rides + real parts inventory + maintenance + the self-host
features. Take Endurain's gear/component table structure and strava-gear's retroactive wear
computation as references; own every line; avoid AGPL entanglement.
### D2 — PWA, not a native iOS app
**Chosen:** SvelteKit static SPA, installed to the iPhone home screen.
**Rejected:** a native SwiftUI app.
**Why:** a home-screen PWA gets the icon, standalone display, offline caching, and — since iOS 16.4
— real push notifications, which was the only thing that used to force native. Native would cost
$99/yr for an Apple Developer account, TestFlight/sideloading to reach family phones, and a second
codebase forever. The two real PWA gaps on iOS (Web Bluetooth, Background Sync) are irrelevant here:
Bryton BLE is a dead end regardless, and the *server* does all syncing.
**Kept as insurance:** the backend stays strictly API-first with a CI-enforced OpenAPI contract, so
if Apple ever makes the PWA route untenable, a native client is a code-generation exercise.
### D3 — Bryton cloud poller is the primary ingestion path
**Chosen:** server-side poller against the reverse-engineered Bryton Active API, every 15-20 min.
**Rejected:** Strava as a source (no `export_original` — decoded smoothed streams only; plus the
June 2026 tier restructure caps new apps at 10 users and requires a paid dev subscription);
BLE/ANT-FS direct (nobody has reverse-engineered Bryton's BLE — weeks of work, breaks on firmware
updates); depending on the Bryton Active phone app (the original complaint).
**Why:** the Rider 650 has on-device Wi-Fi (`Main Menu -> Data Sync`) and uploads to Bryton's cloud
with no phone involved, and the cloud API returns the **original unmodified FIT bytes**. That's both
zero-touch *and* higher fidelity than the current Strava route.
**Fallbacks, both built:** USB watch folder (also the historical-backfill mechanism, so it stays
exercised rather than bit-rotting) and manual upload.
### D4 — Python / FastAPI / Postgres+PostGIS
**Chosen:** Python 3.12, FastAPI, Pydantic v2, SQLAlchemy 2.0 async, Alembic, PostgreSQL 16 + PostGIS 3.4.
**Rejected:** TypeScript full-stack, Go.
**Why:** `fitdecode` is Python-only, and the Bryton poller reference implementation is Python
(~200 lines of Meteor DDP to vendor rather than reimplement — DDP over SockJS is fiddlier than it
looks in JS). FastAPI emits OpenAPI 3.1 for free. PostGIS is needed for heatmaps, bbox queries, and
self-segment matching. Go has the best raw performance and a fine FIT library but the weakest
data-analysis ecosystem for the later analytics work.
### D5 — procrastinate for background jobs
**Chosen:** `procrastinate` (Postgres-backed queue).
**Rejected:** Celery (needs Redis/RabbitMQ, heavyweight, weak async, second-class Postgres broker),
arq (Redis-only — a whole container for ~50 tasks/day), APScheduler (a scheduler, not a durable
queue: no retries, no dead-lettering, no multi-worker coordination).
**Why:** the decisive property is **transactional enqueue** — the `raw_files` INSERT and the parse-job
enqueue commit atomically on one connection, so there are no orphaned blobs and no jobs pointing at
rolled-back rows. That's impossible with a Redis broker without inventing an outbox. It also has
built-in cron, which removes the scheduler container, and keeps job state inside the same `pg_dump`.
### D6 — Opaque bearer tokens, no JWT
**Chosen:** Argon2id passwords + opaque tokens in a `sessions` table, HttpOnly cookie for the PWA.
**Rejected:** JWT.
**Why:** at 5-15 users, verification is one indexed PK lookup (~0.1ms), and you get instant
revocation, a real device list, and no key-rotation or clock-skew bug class. JWT's only advantage is
stateless horizontal scale, which will never arrive — choosing it would be a permanent complexity
tax against a benefit that never materialises.
### D7 — Raw bytes are the only truth
**Chosen:** every ingested file is written to a content-addressed blob store *before* parsing, and is
never mutated or deleted. All tables are rebuildable projections.
**Why:** this converts "a parser bug wrote wrong elevation to 4,000 rides" and "reprocess a decade of
history against a better DEM" from incidents/migrations into routine batch jobs (`parser_version`
bump + requeue). It is the single most load-bearing rule in the design, and it's also the primary
data-loss control.
### D8 — No odometer column anywhere; wear is derived
**Chosen:** `component_installs` as a time-ranged association (strava-gear's model, made relational
with a GIST `EXCLUDE` constraint), with wear computed by replaying the activity stream.
**Rejected:** a stored running odometer per component.
**Why:** correcting "I actually swapped that chain a week earlier" becomes one `UPDATE` and every
downstream number self-corrects. Parts moving between bikes is two rows. A stored counter can do
neither without a reconciliation nightmare — which is exactly where FitTrackee's flat equipment tag
falls over in year two.
### D9 — Streams as columnar int32 arrays
**Chosen:** one row per channel per activity, `values_i32[]` with a scale factor.
**Rejected:** a normalized per-sample table (~5x larger with index, and every real query wants the
whole stream anyway), JSONB (untyped, 3-5x larger, slow to deserialise), TimescaleDB (solves
cross-entity firehose scans; we do per-entity blob reads — and it would mean abandoning the
`postgis/postgis` base image and taking on extension-version coupling at every Postgres upgrade).
**Bonus:** FIT stores position as int32 semicircles, so lat/lon are lossless and free in this encoding.
Aggregates are precomputed at ingest into `activity_stats`, never scanned from streams.
### D10 — Imperial display units
**Chosen:** `users.unit_system` defaults to imperial. Storage stays SI integers throughout
(metres, seconds, mm/s, minor currency units); units are strictly a presentation concern.
### D11 — Notification dedupe by cycle sequence
**Chosen:** `notification_log` with `UNIQUE (user_id, dedupe_key)` where the key is
`service_due:<rule_id>:<component_id>:<cycle_seq>:<threshold>` and `cycle_seq` counts service
events logged against that (component, rule).
**Why:** a naive nightly evaluator nags you about the same chain every night until you fix it, and
you learn to ignore it. This fires exactly once at 80%, once at 100%, then goes quiet; logging the
service increments the cycle and re-arms the next 200 miles. It's how recurrence works without a
cron-style recurrence engine.
### D12 — ntfy first, Web Push second
**Chosen:** `apprise` -> ntfy as the primary notification channel; Web Push (VAPID/`pywebpush`) as
the nicer layer on top; every notification is also an in-app inbox row.
**Why:** `apprise` is already in the stack for poller alerts and works on iOS with no PWA-install
requirement, so notifications can ship early. Apple's `web.push.apple.com` does speak standard RFC
8291 (no Apple Developer account needed), but only for home-screen-installed PWAs, and iOS silently
drops subscriptions after OS updates. Push must never be the only path to the information.
### D13 — Monorepo
**Chosen:** one repo for API + web + deploy + workflows.
**Why:** one maintainer, and API and client change together constantly. D2 removed the only real
argument for splitting (a native app would have needed macOS runners that a Linux act_runner can't
provide) — now every artefact builds on the same runner.
### D14 — Research agent models
**Chosen:** Opus for the Bryton protocol research and the architecture design (ambiguous,
reverse-engineering, synthesis-heavy); Sonnet for the two breadth surveys (existing self-hosted
apps, Gitea CI patterns) where material is well-documented and the work is volume.
---
## Deliberately deferred
- **Routing** (Valhalla/Photon/Overpass) — Phase 5, optional. Several GB of RAM for something
Komoot already does well.
- **Local LLM ride summaries** (Ollama) — Phase 4, behind a compose profile.
- **Friends/family cross-visibility** — Phase 4, as an *additive* widened RLS policy, never as
removal of the default scope.
- **Legacy Bryton format support** — out of scope entirely. The Rider 650 writes `.fit`.
- **Reverse-engineering Bryton's BLE** — explicitly rejected. See RESEARCH.md §1.
+674
View File
@@ -0,0 +1,674 @@
# Self-Hosted Cycling App — Implementation Plan
## Context
You want a self-hosted Strava replacement that syncs rides from your **Bryton Rider 650**, tracks
your cumulative mileage, and adds two things Strava does badly or not at all: a **spare-parts
inventory** and a **maintenance record**. It runs on your own Linux server, with source control in
self-hosted Gitea and an act_runner on the same box for automated builds.
The specific pain driving this: today your 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 turned up a better path that removes the phone from the loop entirely
(see "The sync breakthrough" below).
You also want **mileage-milestone notifications** — "every 200 miles, clean and lube the drivetrain" —
which makes the maintenance side push-based rather than something you have to remember to go look at.
Directory is empty; this is greenfield. Decisions already made:
- **Build fresh** (borrow data models from Endurain and strava-gear, don't fork either)
- **Multi-user** — you plus friends/family, invite-only
- **PWA, not a native iOS app** — installed to the iPhone home screen
- **Imperial units by default** — you think in miles; storage stays SI, display is miles
- **Phased roadmap** with the self-hosting-only features staged in deliberately
---
## The sync breakthrough
Two facts verified during research change the design:
1. **Your Rider 650 has on-device Wi-Fi.** Its main menu has a `Data Sync` entry where the head unit
itself joins a Wi-Fi hotspot and uploads tracks to Bryton's cloud — **no phone, no Active app**.
2. **Bryton's cloud API is fully reverse-engineered** and returns the **original, unmodified FIT
bytes**. Working MIT reference implementation: `github.com/jorge-huxley/intervalssync`
(Python, updated 2026-09-17).
So the pipeline becomes fully hands-off:
```
ride ends → Rider 650 joins home Wi-Fi → uploads to Bryton cloud
→ your server's poller fetches the original FIT → app
```
**First action before writing any code:** on the Rider 650, go to `Main Menu → Data Sync`, join your
home Wi-Fi, and confirm a test ride uploads without the phone. If it only syncs on manual menu
trigger rather than automatically, the fallback is still good (USB, below) — but verify this, because
it determines whether Phase 2 fully solves your complaint.
**Why not Strava as the source:** Strava's API has no `export_original` endpoint — you get decoded,
smoothed streams, never the original file. Its June 2026 tier restructure also caps new apps at 10
users and requires the *developer* to hold a paid Strava subscription. Dead end; skip it.
**Why not Bluetooth direct:** Bryton's BLE sync protocol is not reverse-engineered by anyone —
no Gadgetbridge support, no ANT-FS, no published UUIDs. No third-party app, native or otherwise,
can pull rides off the head unit. Explicitly out of scope.
---
## Stack
| Layer | Choice | Why |
|---|---|---|
| API | Python 3.12, FastAPI, Pydantic v2, SQLAlchemy 2.0 async, Alembic | `fitdecode` is Python-only, so Python is forced; FastAPI emits OpenAPI 3.1 free |
| DB | PostgreSQL 16 + PostGIS 3.4 (`postgis/postgis:16-3.4`) | Needed for heatmaps, bbox queries, self-segment matching |
| Jobs | **procrastinate** (Postgres-backed queue) | Transactional enqueue; no Redis; built-in cron and retries |
| Frontend | SvelteKit `adapter-static` **SPA + PWA** — no Node process in prod | Static files served by Caddy; enforces API-first by construction |
| Maps | MapLibre GL JS from day one | Renders raster tiles now, self-hosted PMTiles vector later — a config change, not a rewrite |
| FIT parsing | `fitdecode` | Thread-safe, preserves header+CRC, correct developer-field handling; `python-fitparse`'s own maintainers point here |
| Auth | Argon2id + **opaque bearer tokens, no JWT** | Instant revocation, device list, no key rotation. Statelessness buys nothing at 15 users |
| Notifications | `apprise` library (ntfy default) | A library, not another container |
**Rejected:** Redis (nothing needs it), TimescaleDB (see streams decision), SSR, Celery, native iOS.
### The PWA decision
A home-screen-installed PWA gets you the icon, full-screen chrome-free display, offline caching, and
— since iOS 16.4 — **real push notifications**, which is the only thing that used to force native.
Going native would cost $99/yr for an Apple Developer account, TestFlight or sideloading to get it
onto family phones, and a second codebase forever. The two real PWA gaps on iOS (Web Bluetooth,
Background Sync) don't matter here: Bryton BLE is a dead end anyway, and the *server* does all syncing.
Design consequences — the iOS-specific traps, all of which have cheap answers if you know them upfront:
| Limitation | Design response |
|---|---|
| **Push requires "Add to Home Screen"** | Silently fails from a plain Safari tab. Treat install as a mandatory onboarding flow with a persistent card (iOS has no `beforeinstallprompt`, so there's no programmatic install). Only offer "Enable reminders" once `display-mode: standalone` is true. |
| **A denied notification permission is sticky** | The user must delete and reinstall the PWA to be asked again. So: request only from a direct user gesture, only in standalone, only after an explanatory screen. Never burn the prompt. |
| **An installed PWA has its own storage partition, separate from Safari** | The user *will* be logged in in Safari and logged out in the installed app and think it's broken. Document it in onboarding; 90-day cookie; "keep me signed in" on by default. |
| No Web NFC | Works anyway with **no app**: an NTAG sticker encoding `https://host/b/<tag_uid>` — iOS background tag reading fires from the lock screen and opens the URL, and the PWA's scope claims it so it opens *in* the app. Server-side that's one route. |
| No `BarcodeDetector` | `getUserMedia`**`zxing-wasm`**, lazy-loaded so the ~300KB WASM isn't in the shell bundle. |
| No Background Sync | Irrelevant — all syncing is server-side. Offline writes go to a small IndexedDB outbox flushed on `online`/`visibilitychange`, keyed by the **client-generated UUIDv7 that is already the row's PK**, so replay is idempotent with no server-side idempotency table. |
| Storage eviction | Bounded caches with explicit `ExpirationPlugin` limits (unbounded caches are what trigger eviction of the *whole origin*). Never treat client storage as durable; mark unsaved outbox items visibly rather than optimistically pretending they saved. |
| Aggressive service-worker termination | The SW does two things only: caching and displaying pushes. Nothing load-bearing. |
Service worker via `@vite-pwa/sveltekit` in `injectManifest` mode. Caching: precache the hashed shell;
cache-first on `index.html` for navigations (so it opens instantly and offline); **NetworkFirst with a
3s timeout on `/garage` and `/maintenance/due`** so the garage with no signal still shows "chain: 4,100
/ 4,800 mi" — the highest-value offline surface in the app; CacheFirst on tiles; **network-only on all
mutations**, never silently cached.
Keep the backend API-first anyway (versioned `/api/v1`, bearer tokens, committed OpenAPI schema).
It costs almost nothing, and if Apple ever makes the PWA route untenable, a native client becomes a
code-generation exercise against a stable contract rather than a rewrite.
---
## Service topology
**v1 — 4 containers, under 2GB RAM total:**
- `caddy` — serves the static SPA, proxies `/api/*`. Same-origin, so no CORS. Your existing reverse proxy terminates TLS in front.
- `api` — uvicorn/FastAPI. Runs Alembic on entrypoint.
- `worker` — same image, `procrastinate worker`. Owns periodic tasks too, so no separate scheduler.
- `db` — postgis/postgis:16-3.4.
Volumes: `pgdata`, `blobstore` (content-addressed raw FIT + attachments), `import_inbox` (USB watch folder bind-mount).
**Phase 2 adds zero containers** (poller is a periodic task; Open-Meteo is a public API).
**Phase 3 adds two:** `tileserver` (tileserver-gl-light + regional PMTiles, ~200MB1GB) and `topodata` (Open Topo Data + region-clipped SRTM).
**Phase 4+ behind opt-in compose profiles:** `ollama`, `grafana`, and optionally `valhalla`/`photon`/`overpass`.
**Explicitly NOT on day one:** Valhalla, Photon, Overpass, Nominatim (>1TB/128GB RAM — never), Ollama, Redis, MinIO, Grafana, a separate scheduler.
---
## Schema
Conventions: UUIDv7 PKs (time-ordered, URL-safe, client-generatable). All `timestamptz` UTC. **All
physical quantities as SI integers** — distance in metres, time in seconds, speed in mm/s, altitude
in cm, money in minor units. Units are a presentation concern.
### Two architectural rules that everything else depends on
**Rule 1 — raw bytes are the only truth.** Every ingested file is written to a content-addressed blob
store *before* parsing and is never mutated or deleted (`ON DELETE RESTRICT`). Every table below is a
**rebuildable projection**: delete the projection, re-parse, and you must get the same result. This is
what turns "parser bug corrupted 4,000 elevations" and "retroactively reprocess all history against a
better DEM" from disasters/migrations into routine batch jobs. It is the single most important rule here.
**Rule 2 — there is no odometer column anywhere.** Component wear is *derived* by replaying the
activity stream against time-ranged install records (the strava-gear insight, made relational).
Correcting "I actually swapped that chain a week earlier" is one `UPDATE`, and every downstream number
self-corrects. A stored counter cannot do that, nor handle parts moving between bikes, without a
reconciliation nightmare.
### Tables
**Identity:** `users` (carries `timezone` and `unit_system`, **defaulting to imperial** for you — it
drives display only, never storage), `invites` (only `sha256(code)` stored), `sessions` (opaque token
hashes), `api_tokens` (scoped, for Home Assistant/Grafana).
**Raw storage:** `raw_files``content_sha256`, `storage_path` (`blobstore/ab/cd/<sha>.fit`),
`source` (`upload|usb|bryton_cloud|gpx_import`), `source_ref`, `parse_state`
(`pending|parsed|failed|quarantined|not_an_activity`), `parser_version`, `fit_type`.
`UNIQUE (user_id, content_sha256)`.
**Activities:** `activities` (+ `activity_laps`, `activity_stats`). Notable columns:
- `ascent_device_m` **and** `ascent_dem_m` kept separately with an `elevation_source` flag, so Phase 3
DEM reprocessing never destroys the barometric original.
- `track geometry(LineStringZM, 4326)` full-res, plus `track_simplified` (~10m) for list maps, plus a
generated `bbox`. GIST index on the simplified track.
- `wet_fraction` (denormalised from weather, so the wear query needs no join), `is_indoor`,
`counts_for_wear` (user override).
- `fit_time_created` + `device_serial` → partial unique index. This is the natural dedupe key.
**Streams — columnar arrays, one row per channel** (`activity_streams`: `channel`, `n`, `scale`,
`values_i32[]`). Decision and justification:
- **Size.** A 3h ride at 1Hz × ~9 channels: normalized per-sample rows ≈ 1.2MB + 0.3MB index;
scaled-int arrays ≈ 150250KB after TOAST/LZ4, zero index cost. ~5× multiplier, compounding forever
under a full-retention requirement.
- **Read pattern.** Every real query is "give me the whole stream to draw a chart" — one TOAST fetch,
and it maps 1:1 onto the JSON the client wants with no row-to-column pivot.
- **Lat/lon are free.** FIT stores position as int32 semicircles; `values_i32` holds them losslessly.
- **Not JSONB** (untyped, 35× larger, slow to deserialise). **Not TimescaleDB** — it solves
cross-entity scans over a firehose; we do per-entity blob reads. Adopting it means abandoning the
`postgis/postgis` base image and taking on extension-version coupling at every Postgres upgrade.
- Aggregates (HR zone totals, power curve) are **precomputed at ingest** into `activity_stats`, never
scanned from streams.
**Bikes and parts** — the part of the schema most people get wrong:
- `bikes` — includes `initial_distance_m` (km ridden before this system existed) and `nfc_tag_uid`.
- `component_models` — shared catalogue (kind, manufacturer, model, `spec jsonb`, `gtin` barcode).
- `components` — a **tracked individual physical object** with identity and history. Has
`purchase_cost_minor`, `initial_distance_m`, `inventory_item_id` provenance.
- `component_installs`**time-ranged association**, mounted to a bike XOR a parent component
(cassette → wheelset → bike). A GIST `EXCLUDE` constraint enforces that a component can only be in
one place at a time. Moving a wheelset between bikes is two rows.
- `inventory_items`**fungible shelf stock** with a `quantity`, `min_quantity` low-stock threshold,
`location`, `gtin`. Plus `inventory_transactions`, an append-only ledger whose running sum *is* the
quantity.
**Why inventory and components are separate tables:** a spare chain on the shelf has no identity worth
tracking — you own "3 × Shimano CN-M8100", not three named chains. A *fitted* chain has identity,
install history, accrued km, and a cost-per-km. "Install from inventory" is the state transition:
decrement quantity, create a `components` row carrying the cost and a provenance link, create an
install row. Modelling stock as components-without-installs forces fake identities onto consumables
(sealant, cables, bar tape) and makes "how many chains do I have left?" a COUNT over a table that also
contains every chain you retired since 2019.
**Maintenance:** `service_rules` (scoped to a component XOR bike XOR kind), `service_events`,
`service_event_components`, `attachments` (receipts, photos, manuals — polymorphic on entity_type/id).
**Notifications:** `push_subscriptions`, `notification_log`, `notification_prefs`,
`odometer_milestones` — defined in the milestones section below.
**Integrations:** `integration_credentials` (AES-GCM encrypted, key from the compose `.env`),
`integration_health` (consecutive failures + error taxonomy, drives alerting).
**Weather:** `weather_observations` keyed on a **0.05° grid cell + UTC hour**, so nearby rides reuse
cached data, plus a per-activity `activity_weather` rollup.
### The wear engine
`service_rules` carries `metric` (`distance | ride_time | calendar`), `threshold`, `basis`
(`since_install | since_last_service`), `wet_multiplier`, and `include_indoor`. Three SQL layers:
1. `v_component_activity` — joins installs to activities on the time range.
2. `component_usage(component, since, wet_mult, include_indoor)` — sums
`distance_m * (1 + (wet_mult - 1) * wet_fraction)`, so a fully-wet ride on rim pads
(`wet_multiplier = 4.0`) counts 4×, a dry ride 1×, a half-wet ride 2.5×. Raw distance is retained
separately so the UI can show *"480 km ridden / 1,150 km effective wear"*.
3. `v_component_due` — percentage used, remaining, and a **projected due date** from your trailing
90-day rate.
One view answers every maintenance question in the product:
- chain @ 4,800km — `distance`, `since_install`, wet 2.0
- chain wax @ 350km — `distance`, `since_last_service`, wet 3.0
- fork lowers @ 50 ride **hours**`ride_time`, `since_last_service`, `include_indoor=false`
- sealant @ 90 **days**`calendar`, `since_last_service`
- BB @ 6 months **OR** 4,800km — two rules on one component; whichever hits 100% first wins the badge
`component_usage_cache` is refreshed by a debounced job after ingest, install edits, and service events.
The view stays authoritative; the dashboard reads the cache.
**Cost-per-km falls straight out** of `purchase_cost_minor / raw_distance_m` — a genuinely
differentiating number no cloud service gives you, for zero incremental work once these tables exist.
---
## Mileage milestones and notifications
This is the feature that makes the maintenance side push-based instead of something you have to
remember to go and check. Two distinct kinds of milestone, sharing one delivery system.
### Kind 1 — recurring maintenance intervals
"Every 200 miles, clean and lube the drivetrain" is already expressible: a `service_rule` with
`metric='distance'`, `threshold=321869` (200 mi in metres), `basis='since_last_service'`. The
`since_last_service` basis is what makes it **recurring** — log the service, the basis moves forward,
and the counter re-arms automatically. No separate "repeating rule" concept is needed.
**Seed catalogue, shipped as defaults** so the app is useful the moment you add a bike, with every
rule editable and dismissible. Intervals below are the consensus from mainstream cycling maintenance
guides; stored in metres/seconds/days, displayed in miles.
*Recurring tasks (`basis='since_last_service'`):*
| Task | Interval | Metric | Wet × | Notes |
|---|---|---|---|---|
| **Clean & lube drivetrain** | **200 mi** | distance | 2.5 | your example; the headline default |
| Wipe & re-lube chain (dry lube) | 150 mi | distance | 3.0 | dusty conditions shorten this |
| Wipe & re-lube chain (wet lube) | 250 mi | distance | 3.0 | |
| Re-wax chain (if waxing) | 200 mi | distance | 4.0 | |
| Measure chain wear with a gauge | 500 mi | distance | 1.0 | replace at 0.5% for 11/12-speed |
| Inspect brake pads | 500 mi | distance | 2.0 | discs: replace under 1.5mm |
| Check BB & headset for play | 500 mi | distance | 1.0 | |
| Check tyre pressure | 7 days | calendar | — | |
| Inspect / top up tubeless sealant | 90 days | calendar | 1.0 | it evaporates |
| Inspect cables & housing | 1,000 mi | distance | 1.5 | |
| Bolt torque check | 90 days | calendar | — | |
| Service hub/BB/headset bearings | 2,000 mi **or** 180 days | two rules | 2.0 | whichever first |
| Replace cables & housing | 2,500 mi | distance | 1.5 | |
| Full annual service | 365 days | calendar | — | |
| Fork lowers service | 50 ride hours | ride_time | 1.0 | MTB; `include_indoor=false` |
| Fork/shock full service | 150 ride hours | ride_time | 1.0 | |
*Replacement rules (`basis='since_install'`, `is_replacement=true`, retires the component):*
| Part | Interval | Wet × |
|---|---|---|
| Chain | 2,000 mi | 2.0 |
| Cassette | 6,000 mi | 2.0 |
| Chainrings | 15,000 mi | 2.0 |
| Rear tyre | 2,500 mi | 1.5 |
| Front tyre | 5,000 mi | 1.5 |
| Disc brake pads | 1,200 mi | 4.0 |
| Rim brake pads | 1,500 mi | 4.0 |
| Bar tape | 365 days | — |
Note the rear tyre wearing 23× faster than the front is why `tyre_front` and `tyre_rear` are separate
`kind` values rather than one "tyre" kind with a shared interval.
### Kind 2 — odometer achievement milestones
The other reading of "mileage milestones": *"the Ribble just passed 5,000 miles"*, *"you've done 1,000
miles this year."* Cheap to add and satisfying, which is the whole point of a mileage tracker.
```sql
CREATE TABLE odometer_milestones (
id uuid PRIMARY KEY, user_id uuid NOT NULL,
scope text NOT NULL, -- 'user' | 'bike' | 'component'
scope_id uuid,
period text NOT NULL, -- 'lifetime' | 'year' | 'month'
period_key text, -- '2026' for yearly
threshold_m bigint NOT NULL,
reached_at timestamptz NOT NULL,
activity_id uuid REFERENCES activities(id), -- the ride that crossed it
UNIQUE (user_id, scope, scope_id, period, period_key, threshold_m)
);
```
Ladders (all configurable): bikes every 500 mi lifetime; you every 1,000 mi lifetime; round numbers
100/250/500/1,000/2,500/5,000/10,000; annual goal progress at 25/50/75/100%. The `UNIQUE` constraint
means a milestone fires exactly once, ever — and the `activity_id` link lets the notification say
*"your 5,000th mile on the Ribble was on this morning's ride."*
### Delivery
```sql
CREATE TABLE push_subscriptions (
id uuid PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint text NOT NULL UNIQUE, -- e.g. https://web.push.apple.com/...
p256dh text NOT NULL, auth text NOT NULL,
user_agent text, is_standalone boolean,
created_at timestamptz NOT NULL DEFAULT now(),
last_success_at timestamptz, failure_count int NOT NULL DEFAULT 0
);
CREATE TABLE notification_log ( -- idempotency AND the in-app inbox
id uuid PRIMARY KEY, user_id uuid NOT NULL,
kind text NOT NULL, -- 'service_due'|'milestone'|'low_stock'|'sync_failed'|'weekly_summary'
dedupe_key text NOT NULL,
title text, body text, url text,
created_at timestamptz NOT NULL DEFAULT now(),
pushed_at timestamptz, read_at timestamptz,
UNIQUE (user_id, dedupe_key)
);
CREATE TABLE notification_prefs (
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
service_warn boolean DEFAULT true, -- fire at warn_at_pct (80%)
service_due boolean DEFAULT true, -- fire at 100%
milestones boolean DEFAULT true,
low_stock boolean DEFAULT true,
sync_health boolean DEFAULT true,
digest_mode text DEFAULT 'immediate', -- 'immediate' | 'daily' | 'weekly'
digest_hour smallint DEFAULT 18,
quiet_hours_start time, quiet_hours_end time -- interpreted in users.timezone
);
```
**The dedupe key is what stops this becoming spam**, and it's the one part that's easy to get wrong.
Key format: `service_due:<rule_id>:<component_id>:<cycle_seq>:<threshold>`, where `cycle_seq` is the
count of service events logged against that (component, rule) so far. Consequences:
- Within one cycle, each threshold fires **exactly once** — one nudge at 80%, one at 100%, then silence.
It does not re-notify nightly about the same chain.
- Logging the service increments `cycle_seq`, so the *next* 200-mile crossing is a new key and fires
again. That's how "every 200 miles" repeats forever without a cron-style recurrence engine.
- Milestones use `milestone:<scope>:<scope_id>:<period_key>:<threshold_m>` and are naturally once-ever.
**Evaluation job** `evaluate_notifications` runs (a) after every ingest, so a milestone or a newly-due
service arrives within minutes of the ride landing, and (b) nightly, to catch calendar-based rules that
no ride triggers. It reads `component_usage_cache` against `v_component_due`, inserts `notification_log`
rows, and enqueues sends on the `notify` queue. Quiet hours defer rather than drop.
**Channels, in order of reliability:**
1. **ntfy via `apprise`** — the primary. `apprise` is already in the stack for poller alerts, works on
iOS through the ntfy app with no PWA-install requirement, and is the most robust option. Roughly an
hour of work, which is why notifications can ship in Phase 1 rather than waiting for the PWA plumbing.
2. **Web Push (VAPID)** — the nicer experience. Apple's `web.push.apple.com` endpoint speaks standard
RFC 8291, so `pywebpush` reaches an iPhone with **no Apple Developer account and no APNs
certificate** — the only requirement is that the PWA is installed to the home screen. `410 Gone`/`404`
prunes the subscription row; `429` backs off. `navigator.setAppBadge(n)` puts a count of outstanding
due items on the home-screen icon for free.
3. **Email** via apprise, for weekly digests.
**Push is never the only path.** Every notification is a `notification_log` row rendered as an in-app
inbox, so a dead push channel degrades the experience without breaking the feature. That matters
because iOS silently drops push subscriptions after OS updates and long idle periods.
---
## Ingestion pipeline
**One canonical path.** Every source funnels through
`ingest_bytes(user_id, data, source, source_ref) -> RawFile` before any parsing. One parser, one
dedupe implementation, one set of side effects.
```
manual upload ─┐
USB watcher ─┼→ ingest_bytes() → raw_files row + blob (same txn) → enqueue parse_raw_file
Bryton poller ─┘ │
fitdecode → discriminate → rebuild projection in ONE txn
→ enqueue compute_stats, enrich_weather, recompute_wear
```
Idempotent by construction: `INSERT ... ON CONFLICT (user_id, content_sha256) DO NOTHING RETURNING id`.
Nothing returned ⇒ already have it ⇒ enqueue nothing. Uploading the same file 100 times costs 100
hashes and zero rows.
**Dedupe, three layers in order:**
1. **Content hash** — catches re-uploads and USB rescans. **Filename is never consulted** (Bryton reuses names).
2. **FIT natural key** (`device_serial` + `file_id.time_created`) — catches the same ride arriving via
USB *and* the cloud where the bytes differ. This is what makes dual-source operation safe.
3. **Temporal overlap heuristic** — same user, start within 90s, duration within 5%, distance within 2%
⇒ flag `duplicate_of_id`, keep both, offer a UI action. **Never auto-delete, only auto-hide.**
**Activity vs. course discrimination — a real bug waiting to happen.** Bryton writes routes as `.fit`
too. Check `file_id.type` (4 = activity, 6 = course, 32 = monitoring). Bryton's encoder is not
Garmin's, so if the type is absent or nonstandard, fall back to message-shape inspection: ≥1 `session`
**and** ≥1 timestamped `record` ⇒ activity; `course`/`course_point` messages or positions without
timestamps ⇒ course. Anything unclassifiable ⇒ `quarantined`, blob retained, one alert, surfaced in an
admin list. **Never silently drop.**
Other Bryton hardening: map nonstandard manufacturer/product IDs via serial prefix and store the raw
values; store laps verbatim but **never derive session totals by summing laps** (use the `session`
message); compute `moving_time` from records with speed > 0.5 m/s if absent.
**Sources** implement a common `ActivitySource` protocol:
- **Upload** — `POST /api/v1/uploads`, multipart, 50MB cap, accepts `.fit`, `.fit.gz`, `.gpx`, `.tcx`.
- **USB watcher** — 60s periodic scan of `/import/inbox/**/*.fit`. A host udev rule on volume label
`Bryton` mounts the device **read-only** and rsyncs into the inbox. **Discover the subfolder at
runtime by recursive glob** — sources disagree on whether it's `Activities/`, `Actives/`, or root, so
don't hardcode it (your 650 is documented as `Bryton/Activities/`, but verify).
- **Bryton cloud (Phase 2)** — Meteor DDP over SockJS to `m3.brytonactive.com`: `login` with the
SHA-256 digest → `subscribe("activityList")` → read `userActivities` (**filter `_deleted`
tombstones**) → diff against `raw_files.source_ref``GET /api/activity?id=<id>` with `X-User-Id`,
`X-Auth-Token`, `x-api-key`, `User-Agent: okhttp/4.12.0` → raw original FIT bytes. Poll every 20 min,
jittered. **Vendor** the `intervalssync` protocol logic with the upstream commit SHA in a header
comment rather than taking a runtime dependency on a reverse-engineering project.
**Credential warning:** the Bryton SHA-256 digest **is** the credential — it replays as a password.
Store it AES-GCM encrypted with a key from the compose `.env` (not in the DB), never return it from any
endpoint (the Pydantic response model simply doesn't contain the field), and redact it in logs. Say so
plainly in the setup UI.
**Job runner — procrastinate**, chosen over Celery (needs Redis, poor async, Postgres broker is
second-class), arq (Redis-only — a whole container for ~50 tasks/day), and APScheduler (a scheduler,
not a durable queue — no retries, no dead-lettering). The decisive property is **transactional
enqueue**: the `raw_files` INSERT and the parse-job enqueue commit atomically on one connection. No
orphaned blobs, no jobs referencing rolled-back rows. Impossible with a Redis broker without inventing
an outbox. Queues: `ingest` (2), `enrich` (4), `maintenance` (1).
**Poller failure alerting**`integration_health` tracks `consecutive_failures` and an error taxonomy
(`auth|protocol|network|ratelimit`). Via apprise/ntfy, max once per 24h: 3 consecutive failures; no
success in 48h while enabled; **`auth` or `protocol` errors alert immediately on the first failure** —
`protocol` is the API-changed-under-us signal. A **nightly canary** fetches the activity list only and
asserts it parses, so breakage surfaces on rest days rather than three weeks later. Persistent UI
banner while unhealthy; `/api/v1/health/integrations` feeds Home Assistant in Phase 4.
---
## Auth
**Opaque bearer tokens against a `sessions` table. No JWT.** At your scale, verification is one indexed
PK lookup (~0.1ms), and you get instant revocation, an "active devices" list, and no key-rotation or
clock-skew bugs. JWT's only advantage is stateless horizontal scale, which will never arrive here.
One token, two transports, one verification path: web gets `Set-Cookie: HttpOnly; Secure; SameSite=Lax`,
any future non-browser client gets `Authorization: Bearer`. A single FastAPI dependency reads bearer
first, then cookie, then sets `app.user_id` for RLS. **The web client gets no capability another client
lacks** — the cookie is transport convenience only.
- **CSRF:** cookie-authenticated mutations require `Origin` to match the configured public URL; bearer
requests skip it (attackers can't set that header). SameSite=Lax as defence in depth.
- **Isolation, enforced twice:** a repository layer where every query starts from a `scoped(User)` base,
**and Postgres RLS enabled from day one** with `SET LOCAL app.user_id` per request transaction.
Migrations run as a `BYPASSRLS` owner; the app connects as a non-owner. RLS is cheap now and means
re-auditing every query later. Friends/family visibility in Phase 2 is an *additive* widened policy,
never removal of the default scope.
- **Invites:** open signup does not exist as a setting. Admin generates a code; only `sha256(code)` is
stored; registration validates and increments `used_count` in the same transaction with
`SELECT ... FOR UPDATE` so a shared link can't be used twice concurrently. First user is bootstrapped
by CLI (`docker compose run api velodrome create-admin`), not a web setup wizard a scanner could race.
- Argon2id `t=3, m=64MiB, p=4`; login rate-limited per-IP and per-account in Postgres.
---
## Unique self-hosting features (staged by value/effort)
These are the payoff for self-hosting — things Strava structurally cannot do.
**Free, because they're schema properties:**
- **No privacy zones, ever.** No third party holds your data, so show real door-to-door routes.
- **Unlimited full-resolution retention**, forever, of the original files.
- **Cost-per-km on every component**, and per-kind averages ("my chains cost £0.019/km").
- **Receipts and photos attached** to parts, service events, and bikes.
- **Wet-weighted wear** — rim pads genuinely wear ~4× faster in the rain, and you have the weather data.
**Cheap and high value:**
- **Mileage-milestone and maintenance pushes** straight to your phone — the reason the garage data is
worth keeping. Strava's gear tracking can't do interval reminders at all.
- **Grafana pointed straight at Postgres** — roughly an afternoon, the cheapest analytics in the plan.
- **Barcode-scan parts** into inventory at purchase/install time (`zxing-wasm`).
- **Low-stock alerts** — "you're down to your last chain and the fitted one is at 87%".
- **Bulk-import your entire GPX archive** regardless of file count — no API quotas.
**Phase 34, genuinely differentiated:**
- **Retroactive elevation reprocessing** of every historical ride against a better DEM — the direct
payoff for the immutable-bytes rule.
- **Personal segment matching** against your own ride archive — your own PRs, no third-party segment
database, nothing made public.
- **Overnight batch compute** on idle server time: heatmap tiles, segment PRs, power curves.
- **Home Assistant entities** — "km until chain due", "days since last ride", "spare chains in stock".
- **NFC tag per bike** — tap the frame, its maintenance page opens (iOS Shortcuts, no app needed).
- **Local LLM ride summaries** via Ollama, behind a compose profile. Nothing leaves the house.
---
## Roadmap
Estimates assume one developer working evenings and weekends.
**Phase 0 — Scaffolding (2 weeks).** Monorepo, `uv`/`ruff`/`mypy --strict`, FastAPI skeleton with
`/healthz` and OpenAPI, Alembic baseline (users/invites/sessions **with RLS policies from the first
migration**), SvelteKit static SPA shell with login, manifest + service worker + precache passing
Lighthouse installability, VAPID keypair, Caddy, compose, Gitea Actions green, image in the registry,
deployed.
*Done when:* you log in at the real URL, add it to your iPhone home screen, it launches standalone —
and a push to `main` rebuilds and redeploys it.
**Phase 1 — Zero-touch ride history (68 weeks).** Ingestion core (all three dedupe layers, course
discrimination, quarantine); **the Bryton cloud poller as the primary path**, polling every 15 min with
the full `integration_health` alerting stack; USB watcher for historical backfill and as the break-glass
path; manual upload; activity list/detail with MapLibre and stream charts; totals and trends by
week/month/year and per bike, in miles; bikes CRUD; **odometer milestone notifications** (they only need
activities, so they ship here); invites; nightly `pg_dump -Fc` + restic.
*Done when:* you finish a ride, tap Data Sync on the 650, put the bike away, and it's on your phone
within 15 minutes with **zero further interaction** — and you stop opening Strava to look at your own
data. **This phase fixes your original complaint; protect it from scope creep.**
**Phase 2 — The garage (57 weeks).** Components and time-ranged installs; inventory with the stock
ledger and install-from-stock; service events with photo/receipt attachments; **the seeded service-rule
catalogue** across all three metrics with due/warn badges and projected-due dates;
`component_usage_cache` and its recompute job; **maintenance notifications end-to-end** (ntfy first,
Web Push second, in-app inbox always) with the cycle-seq dedupe; low-stock alerts; Open-Meteo weather
enrichment; wet-ride weighting switched on.
*Done when:* you get a push saying *"Drivetrain clean & lube due on the Ribble — 205 miles since last
time. You have 2 chains on shelf B,"* and can log the job from the garage floor.
**Phase 3 — Own the map + cheap differentiators (46 weeks).** Regional PMTiles + tileserver-gl, Open
Topo Data with retroactive re-elevation of the whole archive, overnight heatmap generation, personal
segment matching, cost-per-km dashboards, barcode scanning, NFC deep-link routes, nested installs
(wheelsets).
*Done when:* no third-party network request is needed in normal use.
**Phase 4 — Home and household (35 weeks).** Offline write outbox, friends/family visibility and a
household feed, Home Assistant entities via scoped API tokens, Grafana on the `obs` profile, Ollama ride
summaries on the `ai` profile, weekly summary push.
*Done when:* a display in the hallway shows the next thing that needs doing to a bike.
**Phase 5 — Routing (optional).** Valhalla, Photon, Overpass surface tags, route planning with `.fit`
course export back to the Rider 650. Several GB of RAM for something Komoot already does well — build
it only if Phase 4 leaves appetite.
---
## Repo layout and Gitea CI/CD
**Monorepo** — one maintainer, and API and client change together constantly.
```
apps/api/ pyproject.toml, velodrome/{api,models,ingest,sources,jobs,wear}/, alembic/, tests/
apps/web/ SvelteKit PWA, src/lib/api/ (generated types)
packages/openapi/ openapi.json ← COMMITTED; the contract artefact
deploy/ docker-compose.yml, .prod.yml, Caddyfile, .env.example,
systemd/velodrome-backup.{service,timer}, scripts/restore-drill.sh
.gitea/workflows/ ci.yml release.yml deploy.yml renovate.yml nightly.yml
```
**`ci.yml`** — push/PR with `paths:` filters so a web change doesn't run pytest.
`actions/cache@v4` works (act_runner has a built-in cache server). API job runs lint/mypy/pytest against
a **`postgis/postgis:16-3.4` service container** (service containers work in Docker mode) with real
Alembic migrations and **a corpus of ~20 real Rider 650 `.fit` files as golden parser fixtures**. An
`openapi-drift` job regenerates the schema and `git diff --exit-code`s it. A `migrations` job asserts
`upgrade → downgrade -1 → upgrade` succeeds and `alembic check` finds no model drift.
**`release.yml`** — on `v*` tags, buildx multi-stage, push to the Gitea registry.
**Authenticate with a PAT secret (`package:write` scope) — `secrets.GITEA_TOKEN` *cannot* push to the
Gitea container registry.** This is a documented Gitea limitation and will waste an hour if forgotten.
**`deploy.yml`** — `workflow_dispatch` + on release. Runs on a `[self-hosted, host]`-labelled runner in
host mode so it can reach the Docker socket: `compose pull``run --rm api alembic upgrade head`
`compose up -d``curl -f /healthz`. Migrations run as an explicit step **before** `up -d`, never from
the container entrypoint, so failures fail the deploy visibly. Note **`jobs.*.environment` is silently
ignored by Gitea** — there are no environment protection rules, so the gate is manual dispatch plus a
namespaced `PROD_*` secret.
**`renovate.yml`** — self-hosted Renovate (Dependabot is GitHub-only), weekly cron **plus
`workflow_dispatch`**, because Gitea's cron scheduler has shipped flaky releases and you need a manual
trigger. Exclude `fitdecode` and anything Bryton-adjacent from auto-merge.
**`nightly.yml`** — Bryton canary, projection-integrity check, `restic check --read-data-subset=5%`,
heatmap/segment recompute.
**Security note:** the runner holds the Docker socket, which is root-equivalent on the host. Acceptable
for a private single-maintainer instance — but never make this repo public or add untrusted
collaborators without disabling Actions on fork PRs.
**Backups are a systemd timer on the host, not a Gitea Action**`pg_dump -Fc` + restic to B2/S3 with
**append-only repo credentials** (so a compromised app host can't delete history), plus a restic
snapshot of `blobstore/`. Backups must not depend on CI, because CI is the thing most likely to be
broken when you need a restore. Quarterly `restore-drill.sh` restores into a throwaway stack and
asserts activity counts match.
---
## Top risks
**1. The Bryton private API breaks silently.** Hardcoded API key, undocumented protocol, zero
stability guarantee — and the failure mode is *silence*. Mitigations: the poller is one
`ActivitySource` among several, and the USB path ships first in Phase 1 so the system is never
*dependent* on it; nightly canary; immediate alerting on `auth`/`protocol` errors; vendored protocol
pinned to an upstream SHA so fixes are a diff, not a re-derivation; dual-source dedupe on the FIT
natural key means you can fall back to USB mid-week and lose nothing and duplicate nothing.
**2. Losing or corrupting years of ride history.** The realistic threats are mundane — a parser bug
writes wrong elevation to 4,000 rides, a migration drops a column, a disk dies. The raw-bytes-are-truth
rule is the control: every derived table rebuilds from the blob store, so a parser bug is a
`parser_version` bump and a requeue, not data loss. Plus offsite append-only backups independent of CI,
migration up/down/up testing in CI, golden FIT fixtures, and quarterly restore drills — an untested
backup is a hypothesis.
**3. Never shipping.** This scope is multiple person-years if attacked at once, and the failure mode is
a half-built system where you're *still* syncing manually. Mitigations: every phase has a *behavioural*
done criterion, not a feature list; v1 is capped at four containers; the heavy geo/AI services are
behind opt-in profiles in Phases 35; the highest-value differentiators (cost-per-km, wet-weighted
wear) are schema properties that cost nothing; and **Phase 2 is scheduled early and protected**,
because if the project stalls right after it, it has still succeeded.
---
## Verification
**Before coding:** on the Rider 650, `Main Menu → Data Sync` → join home Wi-Fi → ride → confirm the
activity reaches Bryton's cloud with the phone switched off. Then plug it in over USB and `ls -R` the
mounted volume to confirm the actual `.fit` path.
**Phase 0:** `curl https://host/healthz` returns 200; push to `main` produces a new registry image and
a redeployed container; `docker compose logs` shows migrations applied.
**Phase 1 — ingestion:**
- Upload a real Rider 650 `.fit` → activity appears with correct distance, elevation, and map track.
- Upload the **same file twice** → exactly one activity, one `raw_files` row.
- Upload a Bryton **route/course** `.fit` → does *not* become an activity; classified correctly.
- Run the poller against your real Bryton account → new rides ingest with byte-identical content to the
USB copy (compare `content_sha256`).
- Import the same ride via **both** USB and cloud → one activity; dedupe layer 2 catches it.
- Break the credential deliberately → an `auth` alert fires immediately and the UI banner appears.
- Confirm the stored Bryton credential appears in **no** API response and **no** log line.
- `pytest` green against the golden-fixture corpus in CI.
- Log in as a second invited user → sees none of your data. Verify RLS directly: `SET app.user_id` to
user B, `SELECT * FROM activities`, expect zero of user A's rows.
- Cross 1,000 miles on a bike → exactly one milestone notification, naming the ride that crossed it.
**Phase 2 — garage and notifications:**
- Create a bike, install a chain, import 3 rides → chain shows summed distance. Edit the install date
backwards → the number self-corrects with no manual recomputation.
- 90-day sealant rule → due badge at the right date. 50-ride-hour fork rule → tracks hours not miles,
and ignores indoor rides.
- Install a part from inventory → quantity decrements, a `components` row appears carrying the cost,
ledger balances.
- **The recurrence test, which is the one that matters:** set the 200-mile drivetrain rule, ride past
160 mi → one "80%" notification. Ride past 200 mi → one "due" notification. **Run the evaluator ten
more times → no further notifications.** Log the service → ride another 200 mi → it fires again.
- Import a wet ride (`wet_fraction` near 1.0) on rim pads → effective wear advances ~4× raw distance,
and the UI shows both numbers.
- Install the PWA on a second family member's iPhone, enable reminders, trigger a due rule → push
arrives on the lock screen and the app badge shows the count.
- Disable push and re-run → the notification still appears in the in-app inbox and via ntfy.
**End-to-end, the real test:** finish a ride, put the bike away, don't touch your phone. Within 15
minutes the ride is in the app with weather, every fitted component's wear has moved, and if the
drivetrain crossed 200 miles your phone has already told you.
+342
View File
@@ -0,0 +1,342 @@
# Research Findings
Compiled 2026-09-20 by four research agents (Opus for the Bryton protocol work and the
architecture design; Sonnet for the two breadth surveys). Confidence is flagged where it matters.
**Re-verify anything marked unverified before building on it.**
---
## 1. Getting data off the Bryton Rider 650
### Device facts (verified 2026-09-20)
The Rider 650 is **modern generation**. Two independent extraction paths, both good:
1. **USB mass storage.** Mounts as a plain FAT volume labelled `Bryton`; activities are native
Garmin-format `.fit` files in `Bryton/Activities/`. No driver, no udev rule needed beyond
convenience — it is plain `usb-storage`. Filter on `ID_FS_LABEL=Bryton`.
*Caveat:* sources disagree across models about whether the folder is `Activities/`, `Actives/`,
or the volume root. Discover it at runtime with a recursive glob; don't hardcode.
2. **On-device Wi-Fi.** `Main Menu -> Data Sync` lets the head unit join a Wi-Fi hotspot directly
and upload tracks to Bryton's cloud **with no phone and no Bryton Active app**. This is the
key finding — it removes the phone from the pipeline entirely.
*Still to verify:* whether it uploads automatically on joining Wi-Fi, or only when you
manually trigger Data Sync from the menu. Two-minute test; do it first.
### Bryton Active cloud API (reverse-engineered, working)
There is **no official/public Bryton API** — no developer portal, no OAuth. But the private API is
fully reverse-engineered and current.
Reference implementation: **`github.com/jorge-huxley/intervalssync`** (MIT, Python, 21 stars, last
push 2026-09-17). Also on PyPI as `intervalssync` with a headless CLI that emits JSON and proper
exit codes. The protocol was read directly from `src/intervalssync/bryton/{ddp,api}.py`:
**Transport — Meteor DDP over SockJS/WebSocket.** Bryton's backend is a Meteor app.
```
Hosts: m3.brytonactive.com (app/API host, DEFAULT_HOST)
active.brytonsport.com (web host)
Handshake: GET https://m3.brytonactive.com/sockjs/info
wss://m3.brytonactive.com/sockjs/<3-digit>/<8-char>/websocket
-> {"msg":"connect","version":"1","support":["1","pre2","pre1"]}
```
**Auth — standard Meteor `login` method, SHA-256 password digest. No OAuth, no client secret.**
```python
digest = hashlib.sha256(password.encode()).hexdigest()
call("login", [{"user": {"email": email},
"password": {"digest": digest, "algorithm": "sha-256"}}])
# -> {"id": <userId>, "token": <resume token>, ...}
# session resume: call("login", [{"resume": auth_token}])
```
**Listing**`subscribe("activityList", [])` populates the `userActivities` collection.
Documents carry `_id`, `name`/`title`, `local_start_time` (epoch seconds), and a `_deleted` flag /
literal `name == "_deleted"` tombstone **which you must filter out**.
**Download — plain REST, returns raw original FIT bytes:**
```
GET https://m3.brytonactive.com/api/activity?id=<activity_id>
Headers:
X-User-Id: <userId>
X-Auth-Token: <token>
x-api-key: UHIJdntFZFkZaVJsInRHfRcYES08Fwp0Bwkf # hardcoded app key
User-Agent: okhttp/4.12.0
```
The reference client validates the response by checking for the `.FIT` magic at bytes 8-13,
confirming you receive a **genuine, unmodified FIT file** — not a re-encode.
**Risk assessment.** Reliability: *medium*. Private API, hardcoded app key, `okhttp` UA spoof. A
Meteor method rename, key rotation, or UA check breaks it silently. But Meteor DDP collection names
are load-bearing for Bryton's own app, so they change slowly, and a small project has kept this
working. Rate limits are undocumented — poll conservatively (15-20 min).
**Security note that matters:** the SHA-256 digest **is** the credential — it replays as a password.
Leaking your DB leaks Bryton account access. Encrypt at rest, never return it from an API, redact in logs.
Third-party corroboration: Terra (tryterra.co/integrations/brytonsport) sells a Bryton integration
described as a "web API integration" with "no provider credentials required", polling every 5-10
min — i.e. the same unofficial route. Terra lists support for Rider S800, S500, 750, **650**, 550,
460, 320.
### Why NOT Strava as the data source
- **Strava API v3 has no way to retrieve the original file.** `export_original`/`export_tcx` exist
only on the website, not the API. You get `/activities/{id}` + `/streams` — decoded arrays with
Strava's own smoothing applied, no developer fields, no device/session metadata, no
laps-as-recorded fidelity.
- **June 2026 developer-program restructure:** new apps default to single-athlete; Standard Tier
caps at 10 users **and requires the developer to hold an active Strava subscription**; Extended
Access needs an application. Strava also explicitly blocked apps reaching the API via
middleware/aggregator layers.
- Queued breaking changes: 2026-09-01 retired Club Activities/Admins/Members and gated Segments
Explore; **June 2027** forces header-only tokens and moves `api.strava.com` ->
`www.api-v3.strava.com`. *(single secondary source — verify at developers.strava.com)*
Verdict: use Strava only as a redundancy signal ("did a ride happen?"), never as data of record.
### Why BLE / ANT+ direct is out of scope
- **ANT-FS: not supported by Bryton.** Bryton units are ANT+ *sensor consumers* and BLE peripherals
to the Active app. No evidence of an ANT-FS server on any Bryton model. *(negative claim —
exhaustive search found no counterexample)*
- **`Tigge/openant`** (v1.3.2) implements ANT-FS but its working-device list is **Garmin-only**.
- **Gadgetbridge has zero Bryton support** — grepped the supported-devices page, no matches, no
device-request issue, no protocol wiki page.
- **Bryton's proprietary BLE sync is not publicly reverse-engineered.** No service/characteristic
UUIDs, no protocol notes anywhere. The only artefact in existence is a single commit
*"Attempt to copnnect via BT"* (sic) from 2026-05-02 in a 3-commit fork.
- Reverse-engineering it would mean btsnoop_hci captures off an Android phone running Active, and
you would be the first person publicly to do it. **Weeks, not hours, and it breaks on firmware
updates.** Skip.
### Legacy Bryton tooling (NOT needed for the 650 — recorded for completeness)
`bryton-gps-linux` (`github.com/Pitmairen/bryton-gps-linux`) is **effectively abandoned**: last real
commit 2016-06-04, Python 2.7, GPL-3.0, 9 unanswered open issues. Supports Rider 20/20+/21/30/35/40/50
via raw SCSI reads (`py_sg`) for the proprietary pre-FIT binary format. Emits GPX/TCX only, never FIT.
Of its 13 forks, 12 are dead mirrors; `penseleit/bryton-gps-linux` branch `python3` has 3 commits from
2026-05-02. **Irrelevant to the Rider 650** — that writes `.fit` directly.
### FIT parsing libraries (verified against GitHub/npm/PyPI APIs 2026-09-20)
| Language | Recommendation | Rationale |
|---|---|---|
| **Python** | **`fitdecode`** (polyvertex/fitdecode, last push 2025-08-06, 218*) | Clean-room rewrite of fitparse. **Thread-safe** (matters for concurrent upload parsing), preserves FIT header + CRC footer instead of discarding them, optional CRC checking (match/compute/ignore) for speed, fixed developer-field decoding. `python-fitparse`'s own maintainers point users here; its 821 stars are legacy inertia. |
| JS/TS | `@garmin/fitsdk` 21.214.0 (official, ~monthly releases) — but **license is Garmin's own, not OSI**. OSS alternative: `fit-file-parser` 5.2.1 (MIT-ish, pins to the Garmin profile) | n/a — we chose Python |
| Go | `muktihari/fit` (**not** `tormoder/fit`, which declared itself unmaintained Sept 2024 and points at muktihari) | Preserves message ordering and surfaces unknown messages instead of dropping them — matters for a non-Garmin encoder |
| Rust | crate `fitparser` (repo `stadelmanma/fitparse-rs`) | Note the naming trap: repo is `fitparse-rs`, crate is `fitparser`, and a separate stale `fitparse` crate exists |
**Cross-cutting warning:** Bryton's FIT encoder is not Garmin's. Expect nonstandard
`manufacturer`/`product` IDs, missing or minimal `device_info`, unusual lap/session boundaries, and
possibly zero developer fields. **Test against a real Rider 650 file before designing the schema.**
### Format fidelity: FIT >> TCX > GPX
- **FIT -> TCX loses:** advanced dynamics, developer fields, most session/device metadata.
*Retains* GPS, time, distance, per-lap structure, HR, cadence, **power**.
- **FIT -> GPX loses far more:** lap markers, session totals, device settings, developer fields,
R-R intervals, L/R power balance, and **power itself** (no standard extension). Only HR, cadence
and temperature survive, via the non-standard `TrackPointExtension` namespace that many apps ignore.
Design rule: model the DB on FIT's structure (session -> laps -> records + events), treat TCX/GPX as
degraded inputs that null columns. **Never convert FIT->GPX for storage.** Keep original bytes forever.
---
## 2. Prior art
### Self-hosted fitness trackers surveyed
| Project | Stack | License | Parts/maintenance? | Status |
|---|---|---|---|---|
| **Endurain** | FastAPI + Vue 3 + Postgres | AGPL-3.0 | **Yes** — gear tracking *and* gear-component tracking (chain replacement etc.) | 2.2k*, active but in a **feature freeze** to harden the core |
| **FitTrackee** | Flask + Vue 3 + Postgres/PostGIS | AGPL | Weak — one flat "equipment" tag per workout, accrues totals, **no service intervals** | v1.3.5, 146 releases, active (primary dev on Codeberg) |
| Dawarich | Rails + PostGIS + Sidekiq | AGPL-3.0 | No (it's a Google Timeline replacement) | Very active, "breaking changes expected" |
| Wanderer | SvelteKit + PocketBase | AGPLv3 | No | 3.9k*, active, hiking/trail-catalogue oriented |
| Traccar | Java, 200+ device protocols | Apache 2.0 | No | Mature but it's a live fleet tracker — wrong tool |
| GoldenCheetah | C++/Qt **desktop** | GPLv2 | No | v3.8 Sep 2025. Best-in-class CP/W'bal/TSB modelling *reference* |
| **strava-gear** | Python CLI + SQLite | MIT | **Yes, dedicated** — YAML rule-based wear, parts move between bikes, hashtag temp swaps | 36*, small but the best data-model reference |
| LubeLogger | .NET + LiteDB/Postgres | MIT | Vehicle-oriented (cars) | 2.8k*, very active. Good UX reference for service records + receipts |
| Wrench Turn | Go + Node | GPL-3.0 | Generic multi-equipment | Alpha, too immature to fork |
| Intervals.icu | SaaS, closed | — | Some gear tracking | Feature reference only: 140+ metrics, multi-model power curve |
**Decision taken: build fresh.** No project covers rides + real parts inventory + maintenance +
the self-host features. Borrow Endurain's gear/component table structure and strava-gear's
retroactive time-ranged wear computation, but own the code and avoid AGPL entanglement.
### Component wear intervals (the seed catalogue source data)
| Component | Interval | Trigger metric |
|---|---|---|
| Chain | 2,000-3,000 mi (3,200-4,800 km); replace at 0.75% stretch, **0.5% for 11/12-speed** | distance + gauge |
| Cassette | 1,500-5,000 mi (2,400-8,000 km); survives 2-3 chains if chain replaced on time | distance |
| Chainrings | up to 25,000 km road; most replace every 2-3 yrs | distance/time |
| BB / headset / hub bearings | every 6 months or ~3,000 mi (4,800 km) | time or distance |
| Brake pads (rim) | highly variable — **a single wet century ~= 500 dry miles of wear** | distance, wet-weighted |
| Brake pads (disc) | ~1,500-3,000 km; replace under 1.5mm material | distance |
| Tyres | **rear wears 2-3x faster than front**; 2,500-5,000+ km | distance |
| Cables/housing | inspect 1,000 mi, replace 2,500 mi or annually | distance/time |
| Tubeless sealant | every 2-3 months (evaporates) | **calendar** |
| Suspension lower service | ~50 hours | **ride hours** |
| Suspension full service | ~125-200 hours | ride hours |
| Chain wax | every 300-400 km | distance |
| Clean & lube drivetrain | every 100-200 mi (dry lube 100-200, wet lube 200-300) | distance |
| Chain wear check | every 500 mi / monthly | distance |
The wet-ride multiplier in the schema exists because of the rim-pad line above: it is a real,
large effect, and weather data makes it computable.
Sources: road.cc, BikeRadar, The Service Course, Canadian Cycling Magazine, TBS Bike Parts,
ProBikeGarage, Bike New York, bikegremlin.com, Roadman Cycling.
### Canonical parts/maintenance data model (synthesized)
ProBikeGarage (commercial) is the fullest feature reference: register components -> assign to
bike(s) -> auto-accrue distance from linked activities -> interval alerts -> log service events.
Supports **grouping sub-components** (wheel + its bearings) and **swapping gear per ride type**
(race wheels, trainer tyre).
strava-gear's model is the cleanest open-source one and the one we adopt: components have a
`since:` timestamped install/removal rule, and wear is **computed retroactively from the activity
stream** rather than stored as a running odometer — which elegantly handles retroactive corrections.
---
## 3. Self-hostable enrichment services
**Rule: scope everything to your riding region, never the planet.**
| Service | Purpose | Footprint | Home-server viable? |
|---|---|---|---|
| **Open Topo Data** | Elevation (SRTM/Copernicus DEM) | ~20GB+ disk global, low RAM (memory-mapped) | **Yes** — the practical choice, fine on a Pi-class box |
| open-elevation | Elevation, older alternative | whole dataset must fit in RAM unless chunked | Marginal; Open Topo Data is better maintained |
| **PMTiles / Protomaps** | Vector tiles, single file + HTTP range requests | planet ~120GB, but **regional extract = a few hundred MB to a few GB** | **Yes — best fit.** No tile server needed, any static host with range requests |
| **TileServer-GL** | Serves vector/raster tiles + MapLibre style | low; disk for the tile source | **Yes** |
| OpenFreeMap self-host | Pre-built OSM vector tiles | similar to PMTiles | Yes |
| Photon (komoot) | Geocoding, Elasticsearch-based | planet ~95GB disk / 64GB RAM recommended; **country extract runs in 8-16GB** | Regional yes, planet no |
| **Nominatim** | Geocoding | **>1TB fast disk, ~128GB RAM** for planet | **NO — never at home.** Region extract only |
| Pelias | Geocoding | similar to Photon, more ops complexity | Overkill |
| **Valhalla** | Routing | ~100GB disk planet but **only 4-8GB RAM at serve time** (tiles load on demand) | **Best routing fit** |
| GraphHopper | Routing, Java | 8-16GB heap to build planet; 6-10GB serve | Good middle ground |
| OSRM | Routing | ~55GB RAM / 50GB disk planet | Regional only |
| **Overpass API** | POI / road-surface tags | dev/small extract 1-2GB RAM; personal instance 4-8GB, 20-30GB storage. `OVERPASS_META=no` saves ~30% disk | **Yes, easily** |
**Weather — do NOT self-host.** [Open-Meteo Historical Weather API](https://open-meteo.com/en/docs/historical-weather-api)
is ERA5 reanalysis, **hourly back to January 1940**, globally complete, 9-25km resolution, free JSON
over HTTP, **no API key**, non-commercial use. Its
[Air Quality API](https://open-meteo.com/en/docs/air-quality-api) (CAMS-sourced) gives PM2.5, PM10,
NO2, O3, SO2, CO, dust, UV, pollen, EU+US AQI, with historical coverage — also free and keyless.
The server is open source (AGPLv3) and data is mirrored on AWS Open Data if you ever need offline
operation, but for one cyclist's enrichment volume the public API is plenty. **Cache per ride.**
---
## 4. Gitea Actions + act_runner (2026)
Gitea Actions left experimental status in ~1.21 (enabled by default from 1.21.0+). Current stable
line is 1.26 (adds `concurrency:`, reusable workflows from private repos, configurable Actions-token
permissions, per-runner pause/disable, non-zipped artifacts, re-run-failed-jobs-only). 1.24 added
the artifact-download API, runner-registration API, and `workflow_dispatch` via API.
### Gotchas that will cost you time if you don't know them
1. **`secrets.GITEA_TOKEN` CANNOT push to the Gitea container registry.** Gitea's own comparison doc
lists this under unsupported: *"Package repository authorization: GITEA_TOKEN cannot publish to
package repositories."* Users hit `unauthorized: reqPackageAccess`. **Use a PAT** (Settings ->
Applications -> Manage Access Tokens) scoped `package:write` (+ `read:package` for pulls), stored
as a repo secret.
2. **`jobs.<id>.environment` is silently ignored** — no environment protection rules, no approvals.
Don't design a deploy gate around it.
3. **Marketplace actions need github.com reachability.** `[actions] DEFAULT_ACTIONS_URL = github` is
the default, so unqualified `uses: actions/checkout@v4` resolves to github.com. Set
`DEFAULT_ACTIONS_URL = self` and mirror actions into your own instance if you lock down runner
egress. You can always bypass per-step with a fully-qualified URL
(`uses: https://github.com/docker/build-push-action@v6`). Gitea mirrors popular actions at
`gitea.com/actions/*` but "it's impossible to mirror all of them."
4. **Scheduled workflows have shipped flaky.** Cron triggers firing at wrong times or not at all
(issues #26571, #34138). **Always pair `schedule:` with `workflow_dispatch:`.**
5. **`runs-on` only supports a plain label or `[a, b]`**, not GitHub's runner-group matrix syntax.
Most step-level **expression functions beyond `always()` are unimplemented**. Problem matchers
and error annotations are no-ops.
6. **Org/user-level secrets have had visibility bugs** (issue #30361). Prefer repo-level secrets.
7. **`docker.sock` in the runner = root on the host** (gitea/runner issue #167). Acceptable for a
private single-maintainer instance; never with untrusted collaborators or fork PRs.
### What works well
- **`actions/cache@v4` is built in** — act_runner ships a cache server (v2 backend, Feb 2025). No
extra service. Works for npm/pnpm/Go/pip/uv caches.
- **Service containers work** in Docker mode — standard GitHub `services:` syntax, since it's a
plain Docker feature. Postgres/PostGIS test containers are fine. Only rough edge: the UI doesn't
render a dedicated log section for them.
- **Multi-arch buildx works** (`setup-qemu-action` + `setup-buildx-action` + `build-push-action`).
QEMU arm64 emulation is slow. For DinD runners you must install binfmt *inside* the DinD container:
`docker run --privileged --rm tonistiigi/binfmt --install all`.
- **Registry cleanup rules** exist per package owner: Settings -> Packages -> Cleanup Rules, with
"keep last N versions" plus a regex, previewable before running.
### Runner setup
```yaml
act_runner:
image: gitea/act_runner:latest
environment:
GITEA_INSTANCE_URL: "https://git.example.com"
GITEA_RUNNER_REGISTRATION_TOKEN: "${RUNNER_TOKEN}"
GITEA_RUNNER_LABELS: "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
volumes:
- ./data:/data
- /var/run/docker.sock:/var/run/docker.sock # DooD — root-equivalent, see gotcha 7
```
Label format `<name>[:<schema>[:<args>]]`, schema is `docker` or `host`. Token from
Instance/Org/Repo -> Actions -> Runners -> "Create new runner", or `gitea actions generate-runner-token`.
Label precedence: `--labels`/env > `runner.labels` in config > labels baked into `.runner`.
### Deploy patterns, ranked for one home server
1. **Runner does `docker compose pull && up -d` on the host** — simplest, no SSH key, no extra
service, fastest. Chosen. Cost: runner needs docker socket access (= root).
2. SSH deploy from a separate runner — decouples build from deploy, but same blast radius relocated.
3. Watchtower / diun polling the registry — zero CI deploy logic, but Watchtower can't validate an
image is good before swapping it in. Use only for third-party containers you don't build.
4. Komodo / Dockge / Portainer webhooks — nice UI, but another daemon with socket access.
5. Pull-based GitOps agent — best security posture (host never listens), but DIY; there's no
Flux/Argo equivalent for plain compose.
### Renovate (Dependabot is GitHub-only)
Runs as a scheduled Gitea Action using `ghcr.io/renovatebot/renovate`, with
`RENOVATE_PLATFORM: gitea`, `RENOVATE_ENDPOINT: https://git.example.com/api/v1/`, and a **PAT** with
repo write (the automatic token is workflow-run-scoped and can't open PRs). Update rules go in
`renovate.json` in the target repo. Gitea publishes a shared config at `gitea.com/gitea/renovate-config`.
### Backups
`pg_dump -Fc` (custom format, parallel restore) piped into **restic** -> B2/S3. restic over borg here
because of native object-storage backends, content-defined chunking that dedupes well against a
slowly-growing FIT archive, and a single static binary. **Litestream does not apply** — it's SQLite
WAL streaming only. Orchestrate with a **systemd timer, not a Gitea Action** (`Persistent=true`
catches missed runs; and backups must not depend on CI being healthy — CI is the most likely thing
to be broken when you need a restore). Use **append-only repo credentials** so a compromised app host
can't delete history. 3-2-1. **Test restores** — an untested backup is a hypothesis.
---
## 5. Open questions to resolve before/while building
1. **Does Rider 650 Data Sync upload automatically on joining Wi-Fi, or only on manual trigger?**
Determines how completely the phone is removed from the loop. Two-minute test.
2. **Exact on-device `.fit` path** — documented as `Bryton/Activities/` for the 650, but sources
disagree across models. One `ls -R` settles it.
3. **Bryton's FIT encoder quirks** — untested against `fitdecode`. Get one real 650 file and run it
through before finalising the schema. Check: `file_id.type` present? manufacturer ID sane?
lap boundaries sensible? `moving_time` populated?
4. **Strava's June-2027 base-URL change** — single secondary source; verify at developers.strava.com
if you ever care (we don't, since Strava isn't a source).
5. **Bryton cloud rate limits** — undocumented. Start at 15-20 min polling and watch for 429s.