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)', () {