# T07 — Live aggregate accumulation **Phase** 3 · **Depends on** T04, T06 · **Status** Done ## Goal Keep distance, moving time, elevation gain, and max speed current on the active `Trip` row while recording, so the Record screen shows live numbers without rescanning the point table. Done when those values update during a ride and are reconciled exactly on completion. ## Context v1's `observeStats()` recomputes aggregates in SQL on every emission. That worked for `MAX`, `AVG`, and `COUNT` over one table, but v2 needs **distance** and **elevation gain**, which require consecutive-row differences. **SQLite 3.18 on minSdk 26 has no window functions** — no `LAG`, no `OVER`. There is no SQL expression for "distance from the previous point". So the maths moves into Kotlin, into the writer loop that is already touching every point as it lands. `TrackingService` already batches writes every ~2 s (`FLUSH_INTERVAL_MS = 2000`, `FLUSH_SIZE = 25`), so one extra `UPDATE trips …` per flush is negligible — roughly one write every two seconds against a table with a single active row. ## Design The writer loop keeps the **last point of the previous batch** in memory as the anchor for cross-batch distance. Without it, distance resets at every flush boundary and under-reports by roughly one inter-point hop per 25 points. **Reuse `ElevationAccumulator` from T05 verbatim.** It is already streaming (fed one altitude at a time) precisely so the recorder can share it. Do **not** reimplement the hysteresis here — a naive version measured 1498 m of phantom climbing on a parked bike. Remember to call `finish()` before persisting final values. Implemented as `com.rippr.Accumulator` in `RideAccumulator.kt` rather than a private class inside the service, so it is unit-testable without a device. ```kotlin private data class Accumulator( var lastPoint: TrackPoint? = null, var distanceM: Double = 0.0, var movingMillis: Long = 0, var maxSpeedKmh: Float = 0f, var elevationGainM: Double = 0.0, var climbBuffer: Double = 0.0, // hysteresis state, see T05 var pointCount: Int = 0, ) ``` Rules, matching T05 exactly: - Distance accumulates only **within** a segment. On resume, `lastPoint` resets to null so the pause gap contributes nothing. - Moving time accumulates `dt` only where `speedKmh >= Telemetry.SPEED_NOISE_FLOOR_KMH`, with `dt` capped (10 s) so a tunnel dropout cannot inject phantom movement. - Elevation gain uses the same 3 m hysteresis. ### Reconciliation on completion Incremental accumulation can drift — a process kill mid-ride loses the in-memory accumulator, and the restarted service resumes from zero while the DB row holds a stale partial. So on `ACTION_STOP`, after the final drain, **recompute authoritatively** with `RideStatistics.compute()` over all stored points and overwrite the row. That is why T05 and T07 must agree: the live number is an estimate, the finished number is computed by T05's code. Using the same functions for both keeps them consistent by construction rather than by discipline. ## Implementation 1. Add `Accumulator` to `TrackingService`, owned by the writer coroutine. 2. After each batch insert, fold the batch into the accumulator and `UPDATE` the trip row in the same transaction as the point insert — so a crash cannot commit points without their aggregate contribution. 3. Reset `lastPoint` on segment change (pause/resume). 4. On restart-with-active-trip, seed the accumulator from the persisted trip row and set `lastPoint` to the last stored point of the open segment. 5. On stop, recompute with `RideStatistics.compute()` and overwrite. ## Acceptance criteria - [x] Distance updates live during recording - [x] Distance does not jump across a pause - [x] Distance is correct across flush boundaries (no per-batch reset) - [x] Stationary noisy-altitude recording yields ~0 m elevation gain - [x] Post-stop values exactly match `RideStatistics.compute()` over the stored points - [x] Restart mid-ride resumes accumulation instead of restarting from zero ## Tests Instrumented: - Insert 100 synthetic points across four flushes; assert accumulated distance equals the batch computation over the same points (the cross-batch anchor regression test) - Pause/resume with a 5 km jump between segments; assert the gap is excluded - Simulate restart: seed accumulator from DB, continue, assert continuity Emulator: feed a synthetic ride and compare the live displayed distance against the post-stop recomputation — they should differ by nothing. ## Risks / gotchas - **The cross-batch anchor is the subtle bug here.** Losing `lastPoint` between flushes silently under-reports distance by a few percent, which is exactly the kind of error nobody notices until they compare against a bike odometer. - **Same transaction as the insert.** Points and their aggregate contribution must commit atomically or a crash leaves them disagreeing. - **Do not let the accumulator's definitions drift from T05.** Share constants; do not copy magic numbers. ## Out of scope Displaying any of this (T09, T11).