Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions lib/compute/derive_scheduler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> Function({required DeriveJobKind kind}) run;
Expand All @@ -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].
Expand All @@ -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
Expand Down Expand Up @@ -71,6 +94,7 @@ class DeriveScheduler {
Map<String, dynamic> snapshot() => {
'offload_active': _offloadActive,
'workout_active': _workoutActive,
'workout_hold_expired': _workoutHoldExpired,
'background': _background,
'running': _running,
'pending_light': _pendingLight,
Expand All @@ -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();
Expand Down Expand Up @@ -140,6 +175,8 @@ class DeriveScheduler {
void dispose() {
_timer?.cancel();
_timer = null;
_workoutCapTimer?.cancel();
_workoutCapTimer = null;
}

Future<void> _enqueue({
Expand All @@ -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;
Expand All @@ -165,7 +202,7 @@ class DeriveScheduler {
}

Future<void> _drain() async {
if (_running || _offloadActive || _background || _workoutActive) return;
if (_running || _offloadActive || _background || _workoutHeld) return;
_timer?.cancel();
_timer = null;
final job = await LocalDb.takeNextComputeJob();
Expand All @@ -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);
}
Expand Down
61 changes: 48 additions & 13 deletions lib/ui2/screens/home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState, bool>((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);
Expand Down Expand Up @@ -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<String, dynamic>? reason, VoidCallback? onSync) {
final s = reason;
Expand Down Expand Up @@ -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<HomeScreen> createState() => _HomeScreenState();
Expand Down Expand Up @@ -1270,18 +1301,22 @@ class _HomeScreenState extends State<HomeScreen> 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 ──
//
Expand Down
19 changes: 19 additions & 0 deletions test/ui2_wiring_r2_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down
69 changes: 69 additions & 0 deletions test/workout_reliability_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () {
Expand Down
Loading