Files
rippr/docs/BACKLOG.md
uhryniuk b781ee36a8 Rename v4 to ROADMAP
v4 was always just the server-dependent programme (group rides, accounts, paid backup) plus V3-17. Renaming it to ROADMAP since it's about to become the home for an incoming list of bugs and features that need triage ahead of the next v3-style ticket batch -- that triage takes priority over the existing server-dependent items, none of which are blocking.
2026-08-23 11:32:29 -05:00

361 lines
17 KiB
Markdown

> **Carried forward from the native repo** (`~/dojo/rippr/docs/v3/BACKLOG.md`) when the
> Flutter port completed. Two items below have changed status since it was written:
>
> - **Compose UI tests** — no longer a gap. The port has 15 widget tests plus 4
> integration tests; see `docs/port/PARITY-AUDIT.md`.
> - **Elevation gain accuracy** — the algorithm is now proven bit-identical across Kotlin
> and Dart (`tool/parity/run.sh`), so any future tuning can be checked against the
> original rather than guessed at. The instruction below still stands: **do not tune it
> blind.**
>
> Everything else carries over unchanged, including the v3 ideas and the
> "must not regress" list.
---
# Backlog — v3 and the ROADMAP
Everything known-outstanding, with enough context to pick up cold. Nothing here is
committed to — it is a menu, roughly ordered by value.
**v3 is now broken out into tickets: [v3/README.md](v3/README.md).** This document stays
as the reasoning; the tickets are the executable form.
**v3 is everything that can be built with no server. The ROADMAP is everything that
cannot, plus whatever bugs and features come up that need triage before the next v3-style
ticket batch.** The no-server split is still the most useful structural fact in this
document: it means v3 can proceed indefinitely without anyone deciding to run
infrastructure or hold other people's location data. The ROADMAP is where everything else
gets managed first.
**Before planning anything: run the real-ride checklist in
[the real-ride checklist](port/REAL-RIDE-CHECKLIST.md).** Several items below may turn out to be non-issues, and
others may appear that nobody has thought of.
---
## 1. Carried over from v2 — the honest debt
### Elevation gain accuracy · *needs real data first*
~30 m of phantom gain per ten stationary minutes against synthetic ±8 m uniform noise. Real
GPS altitude error is *correlated* rather than uniform, so the true behaviour is unknown.
The current implementation is a 15-sample moving average plus reversal hysteresis (see
[ARCHITECTURE.md](ARCHITECTURE.md)). A naive version reported 1498 m over a parked
bike, so the guard rails matter.
**Do not tune this blind.** Record a flat ride, check whether the reported gain is
plausible, and only then adjust. If it needs work, options are a longer smoothing window, a
larger threshold, or using barometric pressure where available (much more accurate than GPS
altitude, and most phones have the sensor).
### Compose UI tests · *the largest coverage gap*
Zero UI tests across six screens. Everything was verified by manual screenshot. Worth
covering: navigation record→trips→detail→back, rotation/state retention, empty states,
`NotFound`, chart degradation below two points, selection mode enabling Merge only at two.
### Unmeasured, and probably should be
- **Battery drain** over a multi-hour ride — never measured, and it is the thing most likely
to make the app unusable in practice
- **Map memory across repeated navigation** — the osmdroid lifecycle is a known hazard and
the wiring was never leak-tested
- **GPX import into Strava/Garmin** — validated against an XML parser, but schema validity
does not guarantee a consumer accepts it
---
## 2. Live map on the recording screen — **decision reversed**
v2 deliberately shipped no live map, on the reasoning that the phone rides in a pocket.
Dylan has since asked for one — for visual appeal, and because **people may mount the phone
on the handlebars** to watch the route live. Treat the v2 stance as superseded.
**The handlebar case changes the premise, not just the feature.** v1 and v2 were both built
around "start it, pocket it, stop it". A mounted phone is a different product with different
constraints, and it is worth deciding explicitly whether that becomes a first-class mode:
- **Screen on for the whole ride** — battery goes from "a background service" to "a
service plus a lit screen plus continuous map rendering". Measure before committing.
- **Sunlight legibility** — the current dark theme is chosen for glanceability, but daylight
behind a visor is a different problem.
- **Glove-sized targets** — already partly handled (72dp buttons); a map needs the same care.
- **Keep-screen-awake** handling, and what happens on a call or notification.
What still holds regardless:
- **`TrackingService` must never reference a map.** Rendering belongs to the Compose
lifecycle of a visible screen, not the service.
- **No tile fetch or redraw while backgrounded**, even in mounted mode.
---
## 3. Activity type per ride
Promoted out of the ideas list, because it turned out to be a **positioning decision**
rather than a feature. See [LAUNCH.md](LAUNCH.md).
Everything user-facing says motorcycle, but the recording pipeline never did: it records
positions, speeds and altitudes, and nothing in it cares what you are sitting on. Dylan
already rides both bikes and motorcycles.
**Why it matters beyond the feature:** it decides the store category, the screenshots and
who ever finds the app. Cyclists are a far larger audience than motorcyclists and are
already used to paying for ride apps. That question is much cheaper to settle before a
store listing exists than after.
### Schema
An `activity` column on `Trip`, stored as a text enum like `TripState`:
```
motorcycle · bicycle · scooter · skateboard · running · walking · other
```
This is the **first real migration** the port will ship. The destructive fallback is gone
for good, so it needs `m.addColumn(trips, trips.activity)` with a default of `motorcycle`
for existing rows, `schemaVersion` bumped to 2, and a migration test that opens a v1
database and asserts the rides survive. Getting that path right once matters more than the
feature does — every later schema change depends on it.
### Type should drive defaults, not just labels
This is where the real value is, and it is easy to miss:
| Setting | Why it differs |
|---|---|
| Speed noise floor (1.5 km/h) | Fine for a motorcycle; wrong for walking, where real movement lives near it |
| Speed histogram bucket (10 km/h) | Useless for running — everything lands in one bucket. Wants ~1 km/h. |
| Accuracy gate (50 m) | A motorcycle at speed can tolerate looser fixes than a walker |
| Elevation smoothing window | Tuned at 15 samples for 2 Hz road speed; a slower activity covers less ground per sample |
| Map fit zoom | A 2 km walk and a 200 km ride want different defaults |
Treat these as a per-activity profile rather than scattering `if (activity == …)` through
the code.
### Do not put a picker in front of Start
The founding premise is press-and-go with gloves on. A modal asking "what are you doing?"
before recording begins would undo that.
Better: **default to the last activity used**, and make it editable on the trip detail
screen afterwards, next to rename. Most people do the same thing most days, and the one
time they don't, they can fix it after.
### Follow-ons, once the column exists
- **Filter and group the trips list** by activity
- **GPX `<type>` on `<trk>`** — Strava and Garmin read it, so an exported ride imports as
the right activity instead of defaulting to something wrong
- Per-activity totals, if a stats screen ever appears
---
## 4. Route drawing and planning
Drop pins on a map, have the **shortest path between them resolved along real roads**, and
get distance and estimated ride time back before setting off. Save it, ride it later.
This is *pre*-ride planning: a genuinely new mode alongside recording, not an extension of
it. It needs no ride in progress, no server of its own, and could be built entirely
standalone — which makes it the largest thing in v3 that carries no dependency on anything
else.
### It needs a routing engine, and that is the whole decision
Straight lines between pins are easy and reuse `haversineMeters`. **Road-snapped shortest
path is not** — it needs a routing service over OpenStreetMap data:
| Option | Trade-off |
|---|---|
| **Public OSRM demo server** | Free, zero setup, **not for production use** and rate-limited. Fine for prototyping only. |
| **Self-hosted OSRM** | Fast, well-understood. Needs a machine and a regional OSM extract (a province is a few GB). |
| **GraphHopper** | Self-hostable, good cycling and motorcycle profiles, friendlier ETAs |
| **Valhalla** | Best multi-modal profiles, heavier to run |
| **Commercial (Mapbox, Google)** | No ops, per-request billing, an API key in the app |
**Profiles matter here more than usual.** A motorcycle route and a bicycle route between
the same two pins are genuinely different, and cycling engines avoid motorways while
motorcycle riders often want the twisty road rather than the fast one. This is where
[section 3](#3-activity-type-per-ride) pays off — the activity picks the routing profile.
### Estimated time is a promise, and an easy one to get wrong
Routing engines return a duration based on posted speed limits. That is not how long *you*
take. Once there is real ride history, a far better estimate comes from the rider's own
average moving speed for that activity — which the app already stores on every `Trip`.
Show the engine's estimate first, and replace it with a personal one when there is enough
history to justify it.
### Data model
`Route` + `Waypoint`, **separate from `Trip`** — a plan is not a recording, and conflating
them would put unridden kilometres into ride totals. The resolved geometry (the polyline
the engine returns) should be cached on the `Route` so a saved plan opens offline and does
not re-bill a routing request every time it is viewed.
### Follow-ons
- Follow a planned route on the live map while riding (needs section 2)
- Compare a recorded ride against the plan afterwards — where you deviated, how the real
time compared to the estimate
- Export a plan as GPX so it loads into a dedicated sat-nav
---
## 5. Smaller items
| Item | Notes |
|---|---|
| **Trip splitting** | Merge exists; split does not. The natural counterpart. |
| **SAF export** | Dropped in T16 as unnecessary — share sheet covers it. Add if a real need appears. |
| **Auto-pause** | Detect a stop and pause automatically. Rejected in v2 as unreliable in traffic; revisit only with real ride data showing it would help. |
| **Distance units** | Metric only, hardcoded. Trivial to add a preference. |
| **Settings screen** | None exists. `Config` has endpoint, deviceId, mapEnabled — the map toggle currently lives on trip detail because one switch did not justify a screen. |
| **Offline tile pre-download** | osmdroid caches what it renders; a mountain ride with no signal shows blank tiles. Respect OSM's usage policy — no bulk prefetch of their public servers. |
| **Notification live stats** | Show distance/duration in the ongoing notification, readable without unlocking. |
| **Crash reporting** | None. A recorder that dies mid-ride currently leaves no trace beyond logcat. |
---
## 6. Ideas
Terse on purpose. Unshaped, to be consolidated later.
- **Live map while recording.** More visually appealing than a numbers screen. Reverses the
v2 decision — see section 2 for the constraints that survive it.
- **Pick a real theme.** The current look is functional dark + safety orange, chosen to
match the icon. Decide on an actual visual identity and push the UI toward something
polished rather than merely clean.
### Threads running through these
Sign-up, cloud backup and group ride are one programme, not three: they all need a server,
identity, and a privacy stance. They have therefore been moved out to the **ROADMAP**
below, and should be scoped together or not at all.
Activity type (now section 4) and theming are independent and much cheaper — either could
ship alone, and activity type is the natural first v3 task because it forces the migration
path to be proven while the stakes are still low.
Live map, handlebar mounting and route *following* cluster: all three assume a visible
screen during the ride, and all three want the same map component. Route *planning*
(section 4) is the odd one out — it needs no ride in progress at all and could be built
entirely standalone.
---
---
# ROADMAP
Formerly "v4." Renamed because it now holds more than the server-dependent programme
below: it's also where Dylan's running list of bugs and features gets triaged before
becoming its own ticket batch, the way v3 was broken out. That triage takes priority over
the items below — nothing here is blocking, all of it has been waiting since before v3
started.
## The server-dependent programme
Split out from v3 deliberately. These three are **one programme, not three items**: each
needs a server, an identity system, and a privacy stance, and none of them is worth
building alone. Nothing in v3 depends on any of them.
**A fourth item that doesn't fit that programme but shares its precondition:**
self-hosted road-snapped routing (v3's V3-08/V3-09, gated on
[V3-17](v3/V3-17-osrm-hosting.md)). It needs a server the same way this section's three
items do, but nothing about identity, privacy, or the group-ride use case — it's routing
infrastructure, not a rider-facing programme. Filed under `docs/v3/` for now since that's
where the tickets it unblocks already live; flagged here because "v3 is everything
buildable with no server" stops being strictly true the moment V3-17 is picked up.
## Live group rides
Invite someone to a ride and see both of you on the map, live.
This was in the **v1 brief's goal statement**, so it has been the intended destination all
along — it was deferred from v2 as "needs real server work", and that is still the honest
summary.
### Already in place, from v1
- `TelemetryUploader` — batched POST, retry, offline-safe, and structurally unable to stall
recording
- The `synced` column and backlog semantics
- `trip_id` / `segment_id` carried **per point**, so a server can reconstruct rides *and*
their pauses
- `Config.deviceId` — a stable per-install id, enough to distinguish riders
### Missing
- **A server. Nothing exists.** This is the actual work.
- UI for the endpoint — still only reachable via `Config.setUploadEndpoint()`
- Auth, rider identity, group membership, invitations
- Other riders drawn on the map, which also needs the live map from v3 section 2
- **Live** delivery. The current uploader is a batched backlog drain on a 30 s timer, which
is right for archiving and useless for watching someone move. Live positions want a
websocket or similar, running *alongside* the existing uploader rather than replacing it —
the batch path is what guarantees no fix is ever lost.
### The decisions that are not technical
- **Location sharing is consent, not a feature.** Who can see you, for how long, and how
does it stop? Sharing that outlives the ride is a privacy incident waiting to happen.
- Ride invitations mean handling someone declining, leaving mid-ride, or losing signal for
twenty minutes — the map has to say "last seen 8 minutes ago", not silently freeze them
in place.
- This is the point where Rippr stops being a local-only app and starts holding other
people's location data.
## Accounts and sign-up
Prerequisite for everything else here. Note that Apple **requires in-app account deletion**
for any app offering account creation, and that a location app with accounts inherits real
obligations — see [LAUNCH.md](LAUNCH.md).
## Paid cloud backup
Ongoing storage of rides over time, and the only monetization model that justifies
recurring money — because it is the only one with recurring costs. Needs accounts first,
plus decisions on hosting, pricing, and what happens to someone's history when they stop
paying.
---
# Things that must not regress · all versions
Hard-won and easy to undo by accident. Each has a comment in the code explaining why.
1. **The unbounded `Channel` + single batched writer.** Do not write to the database from
the location callback.
2. **Points stamped with `tripId`/`segmentId` at creation.** Refactoring this into a
write-time lookup breaks the pause guarantee silently.
3. **Recording state derived from the database.** Never reintroduce an in-memory flag.
4. **`fallbackToDestructiveMigration()` stays removed.** Any schema change ships a
`Migration` against `app/schemas/com.rippr.data.AppDatabase/2.json`.
5. **Decimation is render-only.** It must never reach storage or export.
6. **`RipprTheme`'s `Surface`.** It sets `LocalContentColor`; without it, text without an
explicit colour renders black-on-black and disappears.
7. **The map zoom clamp.** `zoomToBoundingBox` ignores `maxZoomLevel`; a short ride will
render an empty grid without it.
8. **Instrumented tests use in-memory databases.** One previously wiped the real device
database in `setUp`.
---
# Reading order for picking this up cold
1. [../README.md](../README.md) — what the app is and its current state
2. [ARCHITECTURE.md](ARCHITECTURE.md) — why it is built this way
3. `~/dojo/rippr/docs/v2/PROGRESS.md` (native repo) — every bug found during v2 and how
4. [the real-ride checklist](port/REAL-RIDE-CHECKLIST.md) — **especially "What the emulator cannot verify"**
5. `~/dojo/rippr/docs/DEVELOPMENT.md` (native repo) — when you actually need to build something
The v2 planning approach worked well and is worth repeating: one document per task with
goal, context, design, acceptance criteria and risks, written *before* implementing, plus a
running progress log recording what actually went wrong. Several bugs were caught precisely
because the risk had been written down first — and one (the osmdroid lifecycle) was written
down and then walked into anyway, which is its own lesson.