From 9b073d58606bdc93af4a0ea10029f3e60dfaa6af Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Tue, 25 Aug 2026 18:24:34 +0200 Subject: [PATCH] fix(derive): cap the live-workout derive hold so a forgotten session cannot blank Home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting a workout holds all derivation (DeriveScheduler.setWorkoutActive) on the stated assumption that "a workout is minutes long and its own results are derived at the end anyway". Forget to finish the session and that assumption inverts: capture keeps landing records, every queued derive job stays parked, today's day_result is never built, and Home spends the day on "Nothing recorded for today — Sync the band" while the strap is connected and syncing fine. The sync CTA is a false answer — the sync completes and its derive jobs are parked too. Two changes, one per half of the symptom: - DeriveScheduler gains a workoutHoldCap (6 h, matching AppState._kMaxLiveWorkoutAgeMs, the ceiling past which a live session row from a previous run is already judged "almost certainly not something the user is still in"). Past the cap the session is treated as forgotten and held work drains even though it is still live; ending the workout re-arms the cap for the next session. Inside the cap the hold is unchanged. - Home's bare-day card branches on the live workout: while a session holds derivation it says "A workout is still running" and points at the session bar pinned below, instead of claiming nothing was recorded and offering a sync that cannot help. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015TxisrChNFxtVMXFFNRGtV --- lib/compute/derive_scheduler.dart | 45 +++++++++++++++++-- lib/ui2/screens/home_screen.dart | 61 ++++++++++++++++++++------ test/ui2_wiring_r2_test.dart | 19 ++++++++ test/workout_reliability_test.dart | 69 ++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 17 deletions(-) diff --git a/lib/compute/derive_scheduler.dart b/lib/compute/derive_scheduler.dart index 6984ad6d..8e0adc63 100644 --- a/lib/compute/derive_scheduler.dart +++ b/lib/compute/derive_scheduler.dart @@ -17,6 +17,7 @@ class DeriveScheduler { required this.onChanged, this.lightSettle = const Duration(seconds: 8), this.heavySettle = const Duration(seconds: 2), + this.workoutHoldCap = const Duration(hours: 6), }); final Future Function({required DeriveJobKind kind}) run; @@ -25,6 +26,19 @@ class DeriveScheduler { final Duration lightSettle; final Duration heavySettle; + /// Ceiling on the live-workout hold. The hold's whole justification is "a + /// workout is minutes long and its own results are derived at the end + /// anyway, so deferring costs nothing" — which inverts the moment the user + /// forgets to finish the session: capture keeps landing records, every + /// queued job stays parked, today's `day_result` is never built, and Home + /// spends the day on "Nothing recorded for today — Sync the band" while the + /// strap is connected and syncing fine. Past the cap the session is treated + /// as forgotten and held work drains even though it is still live. Six + /// hours matches `AppState._kMaxLiveWorkoutAgeMs`, the ceiling past which a + /// live session row from a previous run is already judged "almost certainly + /// not something the user is still in". + final Duration workoutHoldCap; + bool _offloadActive = false; /// True while a live workout is running. Held exactly like [_offloadActive]. @@ -36,9 +50,18 @@ class DeriveScheduler { /// foregrounded, so derives ran at their most expensive possible moment, /// competing with the GPS stream, the live map and the BLE drain. A workout /// is minutes long and its own results are derived at the end anyway, so - /// deferring costs nothing. + /// deferring costs nothing — as long as the session actually ends; a + /// forgotten one is bounded by [workoutHoldCap]. bool _workoutActive = false; + /// True once [workoutHoldCap] has elapsed on the CURRENT session's hold. + /// Cleared on release, so the next session holds again from scratch. + bool _workoutHoldExpired = false; + Timer? _workoutCapTimer; + + /// The gate the drain actually checks: live workout, cap not yet blown. + bool get _workoutHeld => _workoutActive && !_workoutHoldExpired; + // While the app is backgrounded we must NOT run derivation: a derive pass // decodes the retained substrate + runs the metric compute, and doing // that on a short background BLE wake trips iOS's CPU watchdog @@ -71,6 +94,7 @@ class DeriveScheduler { Map snapshot() => { 'offload_active': _offloadActive, 'workout_active': _workoutActive, + 'workout_hold_expired': _workoutHoldExpired, 'background': _background, 'running': _running, 'pending_light': _pendingLight, @@ -93,10 +117,21 @@ class DeriveScheduler { if (active) { _timer?.cancel(); _timer = null; + _workoutCapTimer?.cancel(); + _workoutCapTimer = Timer(workoutHoldCap, () { + _workoutHoldExpired = true; + log('[derive-scheduler] workout still live past the hold cap — ' + 'treating it as forgotten; derive may run'); + onChanged(); + _arm(); + }); log('[derive-scheduler] workout live — holding derive work'); onChanged(); return; } + _workoutCapTimer?.cancel(); + _workoutCapTimer = null; + _workoutHoldExpired = false; log('[derive-scheduler] workout ended — derive may run'); onChanged(); _arm(); @@ -140,6 +175,8 @@ class DeriveScheduler { void dispose() { _timer?.cancel(); _timer = null; + _workoutCapTimer?.cancel(); + _workoutCapTimer = null; } Future _enqueue({ @@ -152,7 +189,7 @@ class DeriveScheduler { } void _arm() { - if (_running || _offloadActive || _background || _workoutActive) return; + if (_running || _offloadActive || _background || _workoutHeld) return; if (!_pendingLight && !_pendingHeavy) { unawaited(_refreshSnapshot()); return; @@ -165,7 +202,7 @@ class DeriveScheduler { } Future _drain() async { - if (_running || _offloadActive || _background || _workoutActive) return; + if (_running || _offloadActive || _background || _workoutHeld) return; _timer?.cancel(); _timer = null; final job = await LocalDb.takeNextComputeJob(); @@ -179,7 +216,7 @@ class DeriveScheduler { // land) inside it — at which point running the pass is exactly what the // gate exists to prevent. The job is already marked `running` by // takeNextComputeJob, so hand it back rather than leaving it claimed. - if (_offloadActive || _background || _workoutActive) { + if (_offloadActive || _background || _workoutHeld) { if (id != null && id.isNotEmpty) { await LocalDb.requeueComputeJob(id); } diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 4b9f114b..bfbe6a42 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -114,6 +114,18 @@ DbRebuild? dbRebuildOf(BuildContext c) { } } +/// Whether a live workout is open, or false in a golden. `select`, not +/// `watch`: AppState ticks at ~1 Hz while a session is live, and this screen +/// only cares about the bool flipping. The bare-day card branches on it — see +/// [workoutHoldCard]. +bool workoutLiveOf(BuildContext c) { + try { + return c.select((a) => a.activeWorkout != null); + } catch (_) { + return false; + } +} + /// Read a metric envelope. `_scalarMetric` writes the literal string `'—'` for /// an absent value, so this must never be replaced by `map['value'] as num`. Metric metricOf(Object? raw) => Metric.parse(raw); @@ -361,6 +373,21 @@ StatusCard? dbRebuiltCard(DbRebuild? r) { ); } +/// The bare day during a live workout — missing COMPUTE, not data. A live +/// session holds derivation (`DeriveScheduler.setWorkoutActive`), so nothing +/// lands in `day_result` until it ends: the band keeps recording, the sync +/// keeps landing records, and "Sync the band" is a false answer — the sync +/// completes and changes nothing on this screen. The true remedy is finishing +/// the session, and its bar is pinned right below this card, so the card +/// points there rather than duplicating the door. +StatusCard workoutHoldCard() => const StatusCard( + 'A workout is still running', + 'Today is on hold while a workout is live: the band keeps recording, ' + 'but the numbers are computed once the session ends. Finish the workout ' + 'from the bar below and today fills in — syncing will not.', + icon: LucideIcons.timer, + ); + StatusCard? staleInsightsCard( Map? reason, VoidCallback? onSync) { final s = reason; @@ -1071,7 +1098,11 @@ class HomeScreen extends StatefulWidget { /// noise that trains you to regenerate without looking. final int? hour; - const HomeScreen({super.key, this.data, this.hour}); + /// Whether a workout is live, injected only by tests/goldens — production + /// reads it off AppState via [workoutLiveOf]. + final bool? workoutLive; + + const HomeScreen({super.key, this.data, this.hour, this.workoutLive}); @override State createState() => _HomeScreenState(); @@ -1270,18 +1301,22 @@ class _HomeScreenState extends State with RevisionReload { ), if (bare) - StatusCard( - d.heldOverNight == null - ? 'Nothing derived yet' - : 'Nothing recorded for today', - d.heldOverNight == null - ? 'No band recordings processed yet.' - : 'The last night this app scored was ' - '${prettyDay(d.heldOverNight)}. Nothing has reached it since.', - fix: syncOf(c) == null ? '' : 'Sync the band', - icon: LucideIcons.watch, - onFix: syncOf(c), - ) + // A live workout holds derivation, so a bare day with a session open + // is the hold at work, not a sync problem — see [workoutHoldCard]. + (widget.workoutLive ?? workoutLiveOf(c)) + ? workoutHoldCard() + : StatusCard( + d.heldOverNight == null + ? 'Nothing derived yet' + : 'Nothing recorded for today', + d.heldOverNight == null + ? 'No band recordings processed yet.' + : 'The last night this app scored was ' + '${prettyDay(d.heldOverNight)}. Nothing has reached it since.', + fix: syncOf(c) == null ? '' : 'Sync the band', + icon: LucideIcons.watch, + onFix: syncOf(c), + ) else ...[ // ── the three rings ── // diff --git a/test/ui2_wiring_r2_test.dart b/test/ui2_wiring_r2_test.dart index 921e8ab2..47467227 100644 --- a/test/ui2_wiring_r2_test.dart +++ b/test/ui2_wiring_r2_test.dart @@ -384,6 +384,25 @@ void main() { await t.pumpWidget(frame(const HomeData(dayId: '2026-05-20'))); expect(find.text('Nothing derived yet'), findsOneWidget); }); + + // A bare day during a live workout is missing COMPUTE, not data: the + // session holds derivation (DeriveScheduler.setWorkoutActive), so "sync + // the band" is a false answer — the sync completes and changes nothing. + // The card must name the workout instead. + testWidgets('a bare day during a live workout blames the workout, not sync', + (t) async { + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: const Scaffold( + body: HomeScreen( + data: HomeData( + dayId: '2026-05-20', heldOverNight: '2026-05-16'), + hour: 20, + workoutLive: true)))); + expect(find.text('A workout is still running'), findsOneWidget); + expect(find.text('Nothing recorded for today'), findsNothing); + expect(find.text('Sync the band'), findsNothing); + }); }); // ── the one observation Home is allowed to make ── diff --git a/test/workout_reliability_test.dart b/test/workout_reliability_test.dart index e819be13..e36235a6 100644 --- a/test/workout_reliability_test.dart +++ b/test/workout_reliability_test.dart @@ -129,6 +129,75 @@ void main() { reason: 'and it must drain once the session ends, not be dropped'); }, ); + + test( + 'the hold is time-capped — a forgotten workout cannot park work forever', + () async { + // The reported bug: start a workout, forget it, and Home spends the + // rest of the day on "Nothing recorded for today — Sync the band" + // while the strap is connected and syncing fine. The sync was never + // the problem: every derive job it queued was parked behind a hold + // whose design assumed "a workout is minutes long". + var cappedRuns = 0; + final capped = DeriveScheduler( + run: ({required DeriveJobKind kind}) async => cappedRuns++, + log: logs.add, + onChanged: () {}, + lightSettle: const Duration(milliseconds: 10), + heavySettle: const Duration(milliseconds: 10), + workoutHoldCap: const Duration(milliseconds: 150), + ); + addTearDown(capped.dispose); + + capped.setWorkoutActive(true); + capped.markStoredData(); + await _until(() => capped.snapshot()['pending_light'] == true); + expect(cappedRuns, 0, + reason: 'inside the cap the hold works exactly as before'); + + // The workout is never ended. The cap alone must release the work. + await _until(() => cappedRuns == 1); + expect(cappedRuns, 1, + reason: 'past the cap the queued job must run — the workout is ' + 'forgotten, not in progress'); + + // And work arriving AFTER expiry runs too: the pipeline is unwedged + // for the rest of the session, not for one job. + capped.markStoredData(); + await _until(() => cappedRuns == 2); + expect(cappedRuns, 2); + }, + ); + + test('ending a workout re-arms the cap for the next session', () async { + var cappedRuns = 0; + final capped = DeriveScheduler( + run: ({required DeriveJobKind kind}) async => cappedRuns++, + log: logs.add, + onChanged: () {}, + lightSettle: const Duration(milliseconds: 10), + heavySettle: const Duration(milliseconds: 10), + workoutHoldCap: const Duration(milliseconds: 150), + ); + addTearDown(capped.dispose); + + // Let one session expire its cap… + capped.setWorkoutActive(true); + capped.markStoredData(); + await _until(() => cappedRuns == 1); + capped.setWorkoutActive(false); + + // …then a NEW session must hold again from scratch. An expiry that + // survived the release would make the gate one-shot per launch. + capped.setWorkoutActive(true); + capped.markStoredData(); + await _until(() => capped.snapshot()['pending_light'] == true); + expect(cappedRuns, 1, + reason: 'a fresh session holds again — the expiry must not stick'); + capped.setWorkoutActive(false); + await _until(() => cappedRuns == 2); + expect(cappedRuns, 2); + }); }); group('requeueComputeJob (the post-claim gate race)', () {