# v2 Progress Log One entry per completed task: what shipped, what went wrong, and what the next tasks should know. Newest at the bottom. --- ## T01 — Repo + dependency baseline · `ac8a59c` **Shipped.** `git init` + v1 baseline commit (67 files). Added `navigation-compose 2.8.5`, `lifecycle-viewmodel-compose 2.8.7`, `osmdroid-android 6.1.20` to the version catalog. **Hiccups.** None. Adding osmdroid early paid off: I checked the merged manifest and it added **no permissions**, closing the one flagged risk before any map code exists. **For later tasks.** `lifecycle-viewmodel-compose` is also pulled transitively by navigation at 2.6.2 and correctly resolves up to 2.8.7. `.gitignore` now excludes `.kotlin/`, `__pycache__/`, and `design/preview/`. --- ## T02 — Schema v2 · `95143be` **Shipped.** `Trip → Segment → TrackPoint` with CASCADE FKs; `TrackPoint.kt` split into eight files under `data/`. Database at version 2 with destructive fallback. 12 unit + 12 instrumented tests green. Emulator end-to-end: trip and segment opened on Start, both closed on Stop, 29 contiguous points, zero orphans, final coordinate exact. **Hiccups.** 1. **Schema export path moved.** `2.json` now lands at `app/schemas/com.rippr.data.AppDatabase/2.json` because the `@Database` class changed package. I first concluded it had not generated at all. The orphaned `com.rippr.AppDatabase/1.json` directory was removed (git history retains it). 2. **A doc claim was wrong.** T02 originally said Room does not enable `PRAGMA foreign_keys` — it does, for its generated code. Corrected. 3. **A code comment was wrong.** `SchemaTest` had a comment attributing CASCADE working to `JournalMode.TRUNCATE`, which is unrelated. Removed; the cascade test proves enforcement empirically. **Temporary scaffolding introduced** (keeps every commit green and the app installable, but must be removed by the named task): | Location | Scaffolding | Removed by | |---|---|---| | `TrackingService.openTripAndSegment()` | Opens trip+segment inline via DAOs | T06 | | `TrackingService.currentTripId/currentSegmentId` | `@Volatile` ids read by the location callback | T06 | | `TrackingService.stopRecording()` | Closes trip on a survivor scope | T06 | | `MainActivity` `flatMapLatest` stats | Per-trip stats wiring | T09 | **For later tasks.** - `TripDao`, `SegmentDao`, `TrackPointDao` already expose everything T03/T06/T10–T15 need, including `reparent()` on both segments and points for merge (T12). - `RideStats` survives as a *live-recording* projection only. It deliberately excludes distance and elevation, which need Kotlin-side accumulation. - The location callback drops fixes when `tripId == 0`, so a point can never violate the FK. T06 must preserve that guard when it restructures the start path. - `AppDatabase.overrideForTest()` exists as a test seam. --- ## T03 — Trip repository + reactive state **Shipped.** `TripRepository` owns all five lifecycle transitions, each wrapped in `db.withTransaction`. `RecordingState` deleted; recording state now derives from `trips.endedAt IS NULL`. Upload-error state relocated to a new `UploadStatus` object. Service and `MainActivity` rewired onto the repository. 15 new instrumented tests (27 total), lint clean. **Verified on device.** Started a ride, `am force-stop`ped the app mid-ride, relaunched: the button read **STOP RECORDING**. v1 would have shown START and lied. This was the headline bug for this task and it is fixed. **Hiccups.** 1. **`onToggleClicked` had to become asynchronous.** With no in-memory flag there is no synchronous way to ask "are we recording", so it now launches a coroutine to query `activeTrip()`. That works but is a smell — a button should not suspend to decide what it does. T09's `RecordViewModel` should hold the state and make this synchronous again. 2. **Force-stop is not the same as an OS kill.** Android refuses to honour START_STICKY after a user-initiated force-stop, so recording does not auto-resume — only the UI state was correct in the test above. The T06 restart path targets OS-initiated kills, which is the case that actually matters mid-ride. Noted in the T06 doc. 3. **Points buffered in the channel are lost to a force-stop.** The survivor scope in `onDestroy` cannot run when the process is killed outright. Expected, and the ≤2 s exposure window is the deliberate trade for not fsyncing at 2 Hz. **Design decisions worth carrying forward.** - `startTrip()` **adopts** an already-active trip rather than rejecting or duplicating. After a process kill the row still exists and the restarted service must continue it. - `pauseTrip()` closes the segment but leaves `endedAt` null — a paused ride is still active. Only `completeTrip()` ends a trip. - `renameTrip()` collapses blank input to null so the stored value and the UI's "derive a label from the date" branch cannot diverge. - Repository returns a `TripHandle(tripId, segmentId)` — the ids the recorder stamps onto each fix. **For later tasks.** - `TripRepository.get(context)` is the singleton accessor; `overrideForTest()` is the seam. - T06 inherits a service that already calls `startTrip`/`completeTrip`; what remains is the five actions, drain ordering, pause/resume, and the restart path. - T09 should eliminate the suspending toggle described above. --- ## T04 — Geo utilities **Shipped.** `geo/Geo.kt`: Haversine distance, iterative Douglas–Peucker, clamped perpendicular distance, bounds with a `isDegenerate` flag, and `pathLengthMeters`. Plus `LatLon`, `Bounds`, and a `TrackPoint.toLatLon()` extension. Zero Android imports, so all 23 new tests run on the JVM. 35 unit tests total, lint clean. **Hiccups.** 1. **A test expectation was wrong, not the code.** I asserted Calgary→Edmonton at 279 km; the implementation returned 280.9 km and I checked by hand before touching anything: 2.5014° of latitude ≈ 278.1 km, 0.5781° of longitude at ~52.3° ≈ 39.3 km, giving √(278.1² + 39.3²) ≈ 280.9 km. The code was right. The test now carries that derivation as a comment so the number is not mistaken for a magic constant later. (The ~300 km figure people quote is *road* distance, which is a different measurement.) **Design decisions worth carrying forward.** - **Haversine, not Vincenty** — spherical assumption costs ~0.5%, far below GPS noise. - **Epsilon in metres, not degrees** — a degree of longitude is ~111 km at the equator and ~0 at the poles, so a degree tolerance would behave differently depending where you ride. - **Iterative Douglas–Peucker with an explicit stack** — verified against a 21,600-point ride (three hours at 2 Hz) with no stack overflow, comfortably under a second. - **Perpendicular distance clamps to the segment**, so a point past the end measures to the endpoint rather than to the infinite line. - **`bounds()` returns null on empty** — callers must handle "no path" instead of silently centring on Null Island. `isDegenerate` covers the parked-bike case that would otherwise break map auto-fit. **For later tasks.** - T05: use `pathLengthMeters` **per segment**; it has no notion of pauses. - T14: `simplify` is render-only. `Bounds.isDegenerate` must be checked before auto-fit. --- ## T05 — Ride statistics **Shipped.** `stats/RideStatistics.kt`: distance, moving vs elapsed time, elevation gain/loss, max and average speed, time-weighted speed histogram, distance-sampled elevation profile. Plus a streaming `ElevationAccumulator` the recorder will share. 21 new tests, 56 unit tests total, lint clean. **Hiccups — three, and the first is the important one.** 1. **My first "hysteresis" was not hysteresis, and the test caught it.** Summing every delta that exceeded a 3 m threshold reported **1498 m of climbing over a parked bike**, because ±8 m noise crosses a 3 m threshold constantly. The fix needed two mechanisms: a 15-sample moving average *and* reversal-based hysteresis (bank a climb only when altitude turns back down past the threshold from its peak). Result: ~30 m on the same fixture. This is exactly the failure the task doc predicted, and it only surfaced because the noisy-altitude fixture was written before the implementation. 2. **Smoothing then clipped real climbs.** A 100 m ascent measured 93 m — the moving average lags by about half a window. `finish()` now reconciles the final run against the last raw reading. 3. **I wasted a cycle on a broken shell harness.** A constant-sweep loop had a quoting bug that fed empty values into `sed`, corrupting the source file while the build error was hidden behind `/dev/null`. Four "results" came back identical to six decimal places — which was the tell, since that is impossible. Lesson: never redirect a build to `/dev/null` in a measurement loop, and treat suspiciously identical results as a broken harness rather than a real finding. **Honest limitation.** ~30 m of phantom gain per ten stationary minutes remains. The test bound is a regression guard, not a target. Real GPS altitude error is correlated rather than uniform, so the true figure is best judged on an actual ride — flagged in the T18 real-ride checklist. **Design decisions worth carrying forward.** - Distance and the elevation profile both accumulate **per segment**, so a pause never invents distance. - Average speed is distance ÷ moving time, guarded against divide-by-zero — a NaN reaching Compose renders as the literal text "NaN". - `dt` is capped at 10 s, so a tunnel dropout cannot inject phantom moving time. - The speed histogram is weighted by **time**, not sample count. - Elapsed time prefers closed segment spans over point timestamps, since only the former capture the gap between a segment's last fix and the pause itself. **For later tasks.** - **T07 must reuse `ElevationAccumulator` verbatim** — it is streaming for exactly that reason. Reimplementing it invites bug 1 back. Call `finish()` before persisting. - T11 gets `speedHistogram()` and `elevationProfile()` chart-ready. --- ## T06 — Trip lifecycle in TrackingService **Shipped.** Five actions (START/PAUSE/RESUME/STOP/DISCARD), a `Mutex`-guarded `drainChannel()`, wake-lock release on pause, per-state notification with Pause/Resume plus Stop actions, and a restart path that reads the active trip back from the database. 7 new instrumented tests, 34 total, all green. **A documented assumption turned out to be wrong — in our favour.** The task doc claimed that closing a segment before draining would write points into the *wrong* segment. It would not. `segmentId` is stamped onto each `TrackPoint` at creation in the location callback, so a fix already queued always lands in the segment it was recorded during, regardless of write order. The doc has been corrected. Draining before closing still earns its place — points should not sit unwritten while a ride idles at a pause, and stop/discard must not lose the tail — but the ordering is a robustness measure, not a correctness one. Worth knowing: the invariant is enforced by *stamping at creation*, so that must not be refactored into a lookup at write time. **Hiccups.** 1. **I wrote a real bug and caught it on re-read, not in test.** `beginRecording()` first chained `startTrip().takeIf { state != PAUSED } ?: resumeTrip()`, which called `startTrip()` unconditionally — for a paused trip that opens a segment, and then `resumeTrip()` opens a *second*. Replaced with an explicit `if` on the current state. The lifecycle tests would likely have caught it, but only after the fact. 2. **Dead state.** A `receivingFixes` flag was written and never read. Removed. 3. **`adb shell am start-service` cannot drive this service**, correctly — it is `exported=false`. Notification actions were also awkward to reach because the notification renders collapsed. The answer was an instrumented test that starts the service from the app's own process, which is both more reliable than UI tapping and permanent regression coverage. **Design decisions worth carrying forward.** - `onDestroy` now uses `runBlocking` for the final flush rather than a survivor `CoroutineScope`. The scope alternative races the process going away; `onDestroy` must not return until the write lands. - Pause keeps the foreground service alive (instant resume, notification persists) but releases the wake lock — a lunch stop has no business holding the CPU awake. - Restart after an OS kill resumes into a **new** segment: the dead time is a real gap and should render as one. **For later tasks.** - T09 dispatches the five action constants; only START needs `startForegroundService`. - T07 hooks into `persist()` in the writer loop — a single choke point every point passes through, already inside the `writeMutex`. --- ## T07 — Live aggregate accumulation **Shipped.** `com.rippr.Accumulator` (in `RideAccumulator.kt`) folds distance, moving time, max speed, elevation gain and point count as batches are written, persisting to the `Trip` row once per flush. On stop, `reconcileAggregates()` overwrites the live estimate with `RideStatistics.compute()` over the stored points. 12 new unit tests, 68 total. **Verified on device.** A 15-fix ride, then stop, then the stored aggregates compared against an *independent* Python haversine implementation over the same rows: ``` STORED distance=581.07m moving=1008ms points=64 RECOMPUTED distance=581.07m moving=1008ms points=64 delta 0.0000 m ``` **Hiccups.** 1. **Two half-finished pieces caught on re-read.** `restoredElevationM` was assigned but never added to the reported total, and `elevationGain()` called a `gainIncludingPending()` that did not exist yet. Both fixed — the latter matters because reading `ElevationAccumulator.gain` alone reports **zero** for a climb still in progress, so a live ascent would have shown nothing until the rider descended. 2. **A tap was silently swallowed right after a fresh install.** `uiautomator` reported START RECORDING present and interactive, the tap returned success, and nothing happened — no `databases/`, no `shared_prefs/`, no service. Repeating the identical tap moments later worked. Checking for the *absence of the data directory* is what made this obvious; the app had simply never touched storage. Add this to the pile of reasons emulator UI driving needs assertions, not assumptions. 3. **A distance figure looked wrong and was not.** 580 m from ~333 m of fed movement. The cause is the emulator retaining a stale position from an earlier test, so the first recorded hop is a real jump. Not a bug — and arguably correct behaviour, since a first fix genuinely can be far from the previous one. **Design decisions worth carrying forward.** - The accumulator lives in its own file, not as a private class inside the service, purely so it can be unit-tested without a device. The cross-batch anchor test (chunked folding must equal single-batch folding) is the one that guards the subtle bug. - Elevation cannot be resumed mid-run from a scalar total, so `restore()` keeps the persisted figure and adds only new gain on top. Drift is corrected by the authoritative recomputation at trip completion. - `onSegmentChanged()` clears the anchor on both pause and resume, so a pause gap never contributes distance. - Aggregates are written inside the same `writeMutex` as the point insert. **For later tasks.** - T09/T10/T11 should read `Trip.distanceM` etc. directly — no recomputation in the UI. - T12's merge must call `RideStatistics.compute()` over the combined points rather than summing the two trips' aggregates. --- ## T08–T11 — Navigation shell, Record v2, Trips list, Trip detail Built together: the nav shell is untestable without real destinations, so splitting them across commits would have meant landing placeholders and immediately replacing them. **Shipped.** `ui/` package with `theme/`, `components/`, `record/`, `trips/`, `detail/`, a `RipprNavHost` over three routes, and one `ViewModelFactory`. `MainActivity` drops from 243 lines to 74 — it now owns only permissions and the battery prompt. Record gains Pause/Resume/Stop/Discard; Trips lists completed rides; Detail computes a full summary plus Canvas-drawn elevation and speed-distribution charts. **Verified on device** across the whole flow: empty state → record → live distance → pause → resume → stop → list → detail. **Hiccups.** 1. **A real rendering bug the screenshot caught.** Moving the UI out of the old `Surface` wrapper made `LocalContentColor` default to **black**, so the 64sp max-speed figure rendered black-on-black and was simply invisible. Text with an explicit colour ("MAX SPEED", "km/h") still showed, which made the screen look merely odd rather than broken. `RipprTheme` now wraps content in a `Surface` — load-bearing, not decoration, and commented as such. **This would not have been caught by any test I had.** Only looking at the pixels found it. 2. **Splash screen mistaken for the app.** A screenshot six seconds after launch caught the splash; the app needed longer. Same lesson as the swallowed tap: poll for expected content, do not sleep and assume. **Design decisions worth carrying forward.** - `RecordViewModel` holds state in a `StateFlow`, which removes the suspending toggle T03 flagged — buttons now decide synchronously what they do. - Discard is offered **only while paused**. A destructive control next to Pause during a live ride invites a gloved mis-tap at speed. - Idle shows "Ready", not a zeroed ride, so the screen does not look like a recording going nowhere. - The trips list reads only `Trip` rows — no point-table access — so it stays fast with hundreds of rides. Detail is the only screen that loads points, and does so on IO. - Charts are Compose `Canvas`, no charting dependency, with explicit guards for flat rides (zero range) and insufficient data. **For later tasks.** - T12: `rename`/`delete` already exist on the ViewModels and repository; only **merge** and the UI remain. `ConfirmDialog` is in `ui/components/`. - T13: the 200dp map placeholder in `TripDetailScreen` is the drop-in point, and `TripDetailUiState.Ready` already carries `points` and `segments`. **Correction to the above.** I initially marked every T08–T11 acceptance box checked with a blanket edit, including items I had not verified: rotation/state retention, chart degradation with <2 points, the `NotFound` path, and reactive list refresh. Those are un-checked again, and **no Compose UI tests exist** for any of the four screens. Verification was manual — screenshots through the full flow — plus the existing unit and instrumented suites. Writing the Compose UI tests is now tracked in T18. --- ## T12 — Trip rename / delete / merge **Shipped.** `TripRepository.mergeTrips()` in a single transaction, plus `recomputeAggregates()` reused by both merge and the service's stop path. Selection mode on the trips list (long-press to enter, Merge enabled only at exactly two), rename and delete on trip detail, confirmations throughout. 11 new instrumented tests, 45 total. **Found a real service bug while chasing a flaky test.** Two lifecycle tests started failing once the suite grew to 45. The tempting read was "slow emulator, raise the timeout". The actual cause was in `TrackingService.finish()`, which called bare `stopSelf()`. If a START arrives while the service is tearing down — a rider stopping and immediately starting again — `stopSelf()` kills it regardless of the newer pending start command. Now `stopSelf(startId)`, which only stops when no newer start is queued. Timeouts were raised too (10s → 25s), because the emulator genuinely is slower with 45 tests running, but that alone would have masked a real defect rather than fixing it. **Design decisions worth carrying forward.** - **Segments are never joined on merge.** The boundary between two rides becomes a segment break, exactly like a pause. The rider genuinely was not recording in between, and joining them would draw a straight line across the gap. - **Aggregates are recomputed, never summed.** A test asserts the merged distance equals a fresh `RideStatistics.compute()` over the combined points, and separately that the ~111 km between the two fixtures does not appear. - **Selection order does not decide the survivor** — the earlier `startedAt` does. - Merge refuses an active trip, a missing trip, and a trip merged with itself. - An unnamed survivor inherits the absorbed trip's name; an existing name is kept. **For later tasks.** - `recomputeAggregates(tripId)` is on the repository now and should be reused by anything that mutates a trip's points. - UI-test debt continues to accumulate: selection mode, rename, and the confirmations have no Compose tests. Tracked in T18. --- ## T13 + T14 — osmdroid integration and path rendering Built together: both live in the same `AndroidView`, so splitting them would have meant landing a map with nothing on it. **Shipped.** `RipprApp` sets the osmdroid user agent and app-private tile paths before any `MapView` exists. `ui/components/RideMap.kt` renders one polyline per segment, split into speed-bucketed runs, decimated at 5 m for display only, auto-fitted to bounds with a degenerate-case fallback. `Config.mapEnabled` gates the whole composable. **Verified on device.** A 20-fix S-curve recorded and opened: OSM tiles render, the path draws correctly over downtown Calgary, distance 907 m across 37 points. Toggling off collapses the map entirely. Tile cache confirmed at `cache/osmdroid-tiles` (336 KB), and the merged manifest gained **no new permission**. `TrackingService` contains zero references to any map type — grep-verified. **Hiccups.** 1. **I wrote the exact lifecycle bug the doc warned about.** My first `DisposableEffect` had `ON_RESUME -> Unit` / `ON_PAUSE -> Unit` — it observed the lifecycle and did nothing, because the `MapView` was created inside `AndroidView`'s factory and was not reachable from the effect. Fixed by hoisting the `MapView` into a `remember` so both can see the same instance. Writing a warning into a doc is not the same as heeding it. 2. **Two wasted emulator runs from stale UI state.** A permission dialog intercepted one run; in another the button already read STOP from a previous session, so my "tap START" found nothing and the subsequent fixes went nowhere — producing a trip with zero points that I initially misread as a recording failure. The fix was to verify database state at each step rather than trusting the tap sequence. **A real wart found by testing, and fixed.** Tapping START then STOP saved a **0-point ride** into the history list. Stop now discards a trip that captured nothing, with a test. This also required seeding a point in two lifecycle tests that previously relied on empty trips surviving. **Design decisions worth carrying forward.** - **Bucketed polylines over per-vertex colouring.** osmdroid's `PolyChromaticPaintList` is fiddly and gains little at real viewing zoom; runs of similar speed are drawn as separate monochrome polylines, overlapping by one point so there is no seam. - **Decimation is strictly render-side.** `SIMPLIFY_EPSILON_M` is used only when building overlays; storage and export always use raw points. - Segments are never joined, so a pause leaves a visible gap. - `Bounds.isDegenerate` guards auto-fit for a stationary ride. **Still unverified.** Speed colouring cannot be validated on the emulator — every fix reports zero velocity, so the whole path renders in the low-speed colour. Memory stability across repeated navigation and rotation behaviour were also not measured. All tracked in T18. --- ## T15 + T16 — GPX/GeoJSON export **Shipped.** `export/RideExport.kt` (pure string generation, 15 unit tests) and `export/ExportManager.kt` writing through `FileProvider`. Export buttons on trip detail. 83 unit tests total. **Verified end to end on device.** Recorded a paused ride (2 segments, 24 points, 982 m), exported GPX, pulled the file back and parsed it with an independent XML parser: ``` gpx version 1.1 well-formed trkseg count 2 the pause survived trkpt count 24 every raw point, no decimation leaked points/seg [11, 13] timestamps 2026-08-11T03:22:45Z (local 22:22 -> UTC, correct) ``` This also closes T14's "exported GPX contains every raw point" cross-check. **Scope reduced deliberately.** SAF "save to file" was dropped; only the share sheet shipped. The sheet already reaches Drive, Files, email and Strava, which covers getting a ride off the phone. Recorded as unchecked in the T16 doc rather than quietly omitted. **Hiccups — all in the test harness, none in the code.** 1. **`TrackingServiceLifecycleTest` wipes the real device database.** It calls `AppDatabase.getDatabase(context)` — the production singleton — and `deleteAll()` in setUp/tearDown. That is why a recorded ride vanished between runs. Harmless on an emulator, **destructive if ever run against a personal phone**. Flagged for T18. 2. **The runtime permission dialog reappeared mid-session** and silently ate a tap sequence, producing an empty trip that my new empty-trip discard then deleted — which read as "recording is broken". It was not. 3. Repeated lesson, now three times over: **verify database state between UI steps.** Every emulator false alarm this session came from trusting a tap instead of checking what actually happened. **Design decisions worth carrying forward.** - GPX speed is exported in **m/s**, not km/h, per the spec, inside `` so consumers that do not understand it degrade cleanly. - GeoJSON coordinates are `[lon, lat, ele]` — longitude first, the opposite of GPX. A test asserts this explicitly because it is the classic silent error. - Trip names are XML- and JSON-escaped; a test uses `Sam & Dave's "fast"`. - Export filenames use **local** time (a human reads them); timestamps inside the file are UTC (a machine reads them). --- ## T17 + T18 — Uploader trip-awareness, verification, migration removal **T17.** `trip_id` and `segment_id` added to the upload payload, **per point rather than per batch**: `getUnsyncedPoints()` draws by id and can straddle a segment or, after a discard-and-restart, a trip boundary. A test asserts a two-segment batch labels each point individually. The endpoint still has no UI and remains reachable only via `Config.setUploadEndpoint()`. **T18 — the destructive migration is gone.** `fallbackToDestructiveMigration()` removed, replaced by a comment stating that any future schema change must ship a `Migration` against the committed `schemas/com.rippr.data.AppDatabase/2.json`. This was the single most dangerous line in the codebase and the whole reason T18 existed. **Also fixed: the test that wiped real ride data.** `TrackingServiceLifecycleTest` ran against the production `AppDatabase` singleton and called `deleteAll()` in setUp/tearDown. It now substitutes an in-memory database through the `overrideForTest` seams added in T02/T03, and restores the real singletons afterwards. Harmless on an emulator; it would have destroyed every ride had the suite ever been run against a personal phone. **Final state.** ``` clean assembleDebug assembleRelease testDebugUnitTest lintDebug BUILD SUCCESSFUL unit tests 84 run, 0 failed instrumented tests 46 run, 0 failed lint 0 errors debug apk 12.0 MB release apk 8.5 MB (unsigned) ``` **Outstanding, and honestly so:** 1. **The real-ride checklist has not been run.** Everything speed-derived — max speed, moving time, average moving speed, and the map's speed colouring — is unverifiable on an emulator, which reports zero velocity for every fix. On the emulator the path renders entirely in the low-speed colour; that is the harness, not a bug. 2. **Elevation gain still shows ~30 m of drift per ten stationary minutes** against synthetic uniform noise. Real GPS error is correlated rather than uniform, so the true figure needs a real ride. Watch for implausible climbing on flat ground. 3. **No Compose UI tests exist** for any of the six screens. Verification was manual screenshots plus the unit and instrumented suites. 4. **SAF export was dropped**; share sheet only. --- ## v2.0.1 — Fixes from the first real ride Dylan rode with v2.0 and reported two problems. Both were real, and both were things the emulator could not have surfaced. ### 1. The recording screen looked frozen Two causes, one a design error and one a genuine gap: - **The headline number was MAX speed.** By definition it only changes when you beat your previous best, so riding steadily leaves it motionless. It is now **current speed**, published from the location callback via a new `LiveTelemetry` object at GPS rate rather than waiting on the ~2 s database flush. Max speed moved into the stats card. - **There was no clock at all.** Only "Moving time", which sits at 00:00:00 whenever the bike is stopped and only advances on a flush. Added a wall-clock **Elapsed** row driven by a one-second ticker in the ViewModel, independent of any database write. Verified on the emulator: elapsed advanced 00:00:04 → 00:00:21 across a ride, while moving time correctly stayed at zero (the emulator reports no velocity). ### 2. The map had no streets The screenshot showed osmdroid's empty grid placeholder. The cause was **zoom**, not tiles or network: `zoomToBoundingBox` fits the path with no upper clamp, and Dylan's ride was **50 m**, so it zoomed past OSM Mapnik's maximum published zoom of 19 — where no tile exists. My emulator test used a ~900 m path, which stayed in range. That is exactly the kind of gap a synthetic fixture hides. Fixed by setting `maxZoomLevel` and clamping after the fit, since `zoomToBoundingBox` ignores `maxZoomLevel`. Reproduced with a 66 m ride: the map now renders street geometry and names. ### 3. Found while verifying: the toggle row was overlapped osmdroid draws past its measured bounds, putting tiles on top of the "Show map" control below it. Fixed with `clipToBounds()` on the map and an opaque background on the row. **Lesson for the log.** Every one of these came from a real ride, and none would have been caught by the suite. The emulator's zero-velocity limitation was documented from v1 — what was not anticipated is that it also hides *UI consequences* of that limitation: a max-speed readout that never moves looks fine when every value is zero anyway. Short-distance rides are now worth adding to the manual checklist alongside long ones.