Two copies for two jobs. rippr-src/ is a browsable git archive export of the tracked tree at 2f76983 - no build outputs, no local.properties, no nested .git - which is convenient to read in gitea but carries no history and will drift. rippr-full-history.bundle is the real backup: all 18 commits, verified as "records a complete history" and test-cloned before committing. This matters because ~/dojo/rippr has no git remote and otherwise exists only on one machine. rippr-src/SNAPSHOT.md explains the difference and how to restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5.0 KiB
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.
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,
lastPointresets to null so the pause gap contributes nothing. - Moving time accumulates
dtonly wherespeedKmh >= Telemetry.SPEED_NOISE_FLOOR_KMH, withdtcapped (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
- Add
AccumulatortoTrackingService, owned by the writer coroutine. - After each batch insert, fold the batch into the accumulator and
UPDATEthe trip row in the same transaction as the point insert — so a crash cannot commit points without their aggregate contribution. - Reset
lastPointon segment change (pause/resume). - On restart-with-active-trip, seed the accumulator from the persisted trip row and set
lastPointto the last stored point of the open segment. - On stop, recompute with
RideStatistics.compute()and overwrite.
Acceptance criteria
- Distance updates live during recording
- Distance does not jump across a pause
- Distance is correct across flush boundaries (no per-batch reset)
- Stationary noisy-altitude recording yields ~0 m elevation gain
- Post-stop values exactly match
RideStatistics.compute()over the stored points - 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
lastPointbetween 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).