From d46721297707e4ea7e20fa5f829e974a1cfee03d Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 10:12:01 +0530 Subject: [PATCH 01/15] stop dropping naps that straddle midnight --- lib/compute/derivation_engine.dart | 43 ++++++++++++++++++++++++------ lib/compute/derive_prepare.dart | 31 ++++++++++++++++++--- 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 61b0d0b..bdd6193 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1836,6 +1836,7 @@ class DerivationEngine { // sendable object (never `this`, `day`, or `bundle`). final blocksInput = _DayBlocksInput( daySub: daySub, + napSub: day.napSub, sleepSub: sleepSub, profile: profile, onsetSec: day.sleepOnsetSec, @@ -3267,7 +3268,12 @@ class DerivationEngine { /// Sleep periods: the main sleep + any NAPS (still, on-wrist minute-runs ≥20 /// min OUTSIDE the main window). Conservative — naps need sustained stillness. - static Map _sleepPeriods(Substrate s, int onsetSec, int offsetSec) { + static Map _sleepPeriods( + Substrate s, + int onsetSec, + int offsetSec, { + int? attributionEndSec, + }) { final periods = >[]; var totalAsleep = 0; if (offsetSec > onsetSec) { @@ -3317,8 +3323,13 @@ class DerivationEngine { j++; } final lenMin = j - i; - if (lenMin >= 20) { - final start = keys[i] * 60, end = keys[j - 1] * 60 + 60; + final start = keys[i] * 60; + // A run STARTING at/after the real day boundary belongs to tomorrow's + // own (unbuffered) window — only count runs that started today. + final startsToday = + attributionEndSec == null || start < attributionEndSec; + if (lenMin >= 20 && startsToday) { + final end = keys[j - 1] * 60 + 60; periods.add({ 'is_main': false, 'start': start, @@ -3342,8 +3353,9 @@ class DerivationEngine { Map? scMap, Substrate s, int onsetSec, - int offsetSec, - ) { + int offsetSec, { + int? attributionEndSec, + }) { try { final n = s.length; if (n < 60) return; @@ -3363,8 +3375,14 @@ class DerivationEngine { if (lo >= 0 && hi > lo) main = ana.SleepWindowSpan(lo, hi); } final m = ana.detectNaps(accel, hr, mainSleep: main); - final naps = m.value ?? const []; final t0 = s.tsSec.first; + // A nap STARTING at/after the real day boundary is tomorrow's — its own + // (unbuffered) window finds it independently, so keeping it here too + // would double-count it. + final naps = (m.value ?? const []).where((nap) { + if (attributionEndSec == null) return true; + return t0 + nap.startSec < attributionEndSec; + }).toList(); bundle['naps'] = { 'value': [ for (final nap in naps) @@ -3604,8 +3622,15 @@ class DerivationEngine { seriesPatch['resp_day'] = _dayRespCurve(daySub); seriesPatch['skin_temp_day'] = _daySkinTempCurve(daySub); bundlePatch['restlessness'] = _restlessness(sleepSub); - bundlePatch['sleep_periods'] = _sleepPeriods(daySub, onset, offset); - _attachNaps(bundlePatch, scMap, daySub, onset, offset); + // napSub extends a few hours past this day's calendar end so a nap/ + // secondary-sleep block spanning midnight isn't bisected — but a run that + // actually STARTS in that borrowed buffer belongs to tomorrow (which sees + // it in its own regular window), so both helpers drop anything starting + // at/after dayEndSec to avoid double-counting. + bundlePatch['sleep_periods'] = + _sleepPeriods(inp.napSub, onset, offset, attributionEndSec: inp.dayEndSec); + _attachNaps(bundlePatch, scMap, inp.napSub, onset, offset, + attributionEndSec: inp.dayEndSec); // Overrides wake's activity_curve (same value, computed once here). bundlePatch['activity_curve'] = _activityCurve(daySub); bundlePatch['detected_workouts'] = const >[]; @@ -3974,6 +3999,7 @@ Future runCancellableIsolate( /// pure compute needs are performed by the caller and passed in here. class _DayBlocksInput { final Substrate daySub; + final Substrate napSub; final Substrate sleepSub; final Profile profile; final int onsetSec; @@ -3998,6 +4024,7 @@ class _DayBlocksInput { final int dataNowSec; const _DayBlocksInput({ required this.daySub, + required this.napSub, required this.sleepSub, required this.profile, required this.onsetSec, diff --git a/lib/compute/derive_prepare.dart b/lib/compute/derive_prepare.dart index d172128..803726b 100644 --- a/lib/compute/derive_prepare.dart +++ b/lib/compute/derive_prepare.dart @@ -18,6 +18,14 @@ class PreparedDerivationDay { final Substrate daySub; final Substrate sleepSub; + /// Same as [daySub] but extended [napBoundaryBufferSec] past the calendar + /// end — a nap/secondary-sleep block that starts before midnight and runs + /// past it was being silently bisected at the exact day boundary (each half + /// falling under the 20-min floor and vanishing from BOTH days). Only nap + /// detection reads this wider slice; every other day-scoped calc keeps using + /// the strict [daySub] so steps/wear/activity are never double-counted. + final Substrate napSub; + const PreparedDerivationDay({ required this.date, required this.endSec, @@ -29,8 +37,9 @@ class PreparedDerivationDay { required this.sleepOffsetSec, required this.daySub, required this.sleepSub, + Substrate? napSub, this.sleepSource = 'auto', - }); + }) : napSub = napSub ?? daySub; Map toJson() => { 'date': date, @@ -44,11 +53,15 @@ class PreparedDerivationDay { 'sleep_source': sleepSource, 'day_sub': daySub.toJson(), 'sleep_sub': sleepSub.toJson(), + 'nap_sub': napSub.toJson(), }; static PreparedDerivationDay fromJson(Map m) { List strs(String k) => ((m[k] as List?) ?? const []).map((e) => e.toString()).toList(); + final daySub = Substrate.fromJson( + ((m['day_sub'] as Map?) ?? const {}).cast(), + ); return PreparedDerivationDay( date: m['date'] as String? ?? '', endSec: (m['end_sec'] as num?)?.toInt() ?? 0, @@ -60,16 +73,24 @@ class PreparedDerivationDay { sleepOnsetSec: (m['sleep_onset_sec'] as num?)?.toInt() ?? 0, sleepOffsetSec: (m['sleep_offset_sec'] as num?)?.toInt() ?? 0, sleepSource: m['sleep_source'] as String? ?? 'auto', - daySub: Substrate.fromJson( - ((m['day_sub'] as Map?) ?? const {}).cast(), - ), + daySub: daySub, sleepSub: Substrate.fromJson( ((m['sleep_sub'] as Map?) ?? const {}).cast(), ), + napSub: m['nap_sub'] is Map + ? Substrate.fromJson((m['nap_sub'] as Map).cast()) + : daySub, ); } } +/// How far past a day's calendar end nap detection is allowed to look, so a +/// nap/secondary-sleep block spanning midnight is seen whole by the day it +/// started on. Naps starting inside this buffer belong to tomorrow, which +/// sees them anyway in its own regular (unbuffered) window — so this can't +/// double-count. +const int napBoundaryBufferSec = 3 * 3600; + class PreparedDerivationPayload { final int dataNowSec; final List days; @@ -287,6 +308,7 @@ PreparedDerivationPayload prepareDerivationPayload( for (final day in calendarDays(sub, override: override)) { if (targetDay != null && day.date != targetDay) continue; final daySub = sub.slice(day.startSec, day.endSec); + final napSub = sub.slice(day.startSec, day.endSec + napBoundaryBufferSec); final sleepSub = day.hasSleep ? sub.sliceIdx(day.sleepLoIdx, day.sleepHiIdx) : Substrate.empty; @@ -322,6 +344,7 @@ PreparedDerivationPayload prepareDerivationPayload( sleepSource: day.sleepSource, daySub: daySub, sleepSub: sleepSub, + napSub: napSub, ), ); } From d45f352e2d51aa6856d89346cdf19232e8001f3b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 10:12:27 +0530 Subject: [PATCH 02/15] unfreeze live step count on today --- lib/ui/today/today_screen.dart | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index 7d450e0..3ddd18b 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -178,16 +178,16 @@ class _TodayScreenState extends State @override Widget build(BuildContext context) { - // SELECT the 3 fields _emptyOrProcessing actually reads, not the whole - // AppState — this screen used to fully rebuild on EVERY notifyListeners() - // (67 call sites incl. per-second timers and every derive-day callback), - // which is exactly what made a multi-day backfill/reanalyze visibly - // freeze this screen (several notifications in quick succession, each one - // forcing a full ListView rebuild while a screen switch might also be - // in flight). `app` itself is still the live, same-instance object (read, - // not watch) — only the REBUILD TRIGGER is now scoped. - context.select, bool, String)>( - (a) => (a.dbCounts, a.reanalyzing, a.reanalyzeProgress), + // SELECT the fields this screen actually reads, not the whole AppState — + // it used to fully rebuild on EVERY notifyListeners() (67 call sites incl. + // per-second timers and every derive-day callback), which is exactly what + // made a multi-day backfill/reanalyze visibly freeze this screen. `app` + // itself is still the live, same-instance object (read, not watch) — only + // the REBUILD TRIGGER is scoped. liveSteps is included (already rate- + // limited to ~1/s at the source) so the steps tile doesn't freeze mid-walk + // while waiting on an unrelated dbCounts change. + context.select, bool, String, int)>( + (a) => (a.dbCounts, a.reanalyzing, a.reanalyzeProgress, a.liveSteps), ); final app = context.read(); final t = TodayData.fromJson(data); From 142087aa30c4e331bbea86e77e16522a5e952c9b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 10:13:54 +0530 Subject: [PATCH 03/15] don't cache a morning briefing off an unsettled overnight --- lib/ai/briefing_engine.dart | 17 +++++++++++------ lib/state/app_state.dart | 11 +++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/ai/briefing_engine.dart b/lib/ai/briefing_engine.dart index ff49e9a..45183e0 100644 --- a/lib/ai/briefing_engine.dart +++ b/lib/ai/briefing_engine.dart @@ -134,10 +134,12 @@ Future> collectBriefingInputs( // ── prompt building (PURE — unit-tested on sample data) ─────────────────────── -/// The reader's local time of day — used only for the briefing's greeting, and -/// deliberately distinct from [BriefingPeriod]: the morning briefing (last -/// night's sleep + recovery) is shown right up to 17:00, so a reader opening it -/// in the afternoon must be greeted for the afternoon, never the "morning". +/// The reader's local time of day at GENERATION time — passed into the prompt +/// as context only (the model is told not to write a greeting off it; the app +/// renders its own greeting fresh at read time, since a cached briefing can be +/// read hours after it was generated). Deliberately distinct from +/// [BriefingPeriod]: the morning briefing (last night's sleep + recovery) is +/// shown right up to 17:00. String partOfDay(DateTime now) { final h = now.hour; if (h < 12) return 'morning'; @@ -169,10 +171,13 @@ String briefingSystemPrompt(BriefingPeriod period, String timeOfDay) { ? 'last night\'s sleep and recovery, and what they mean for the day ahead' : 'today\'s activity, strain and stress, and how the day landed'; return 'You write a health briefing for a local-first fitness band app. ' - 'It is currently $timeOfDay for the reader — if you open with a greeting, ' - 'greet for the $timeOfDay and never assume a different time of day. ' 'Summarize $scope.\n' 'HARD RULES:\n' + '- Do NOT open with a greeting or any time-of-day reference ("good ' + 'morning", "this evening", etc.) — the app shows its own greeting ' + 'separately, computed at the moment the reader actually opens it, and ' + 'this text may be read hours after it was written. Start straight with ' + 'the substance.\n' '- Use ONLY the numbers provided. Never invent, estimate or mention a ' 'metric that is not in the data. No medical advice or diagnosis.\n' '- If a "readiness" value is given, its parenthesized band label ' diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index d4465cd..78ae6ad 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1085,6 +1085,17 @@ class AppState extends ChangeNotifier { want = BriefingPeriod.morning; } if (want == null || BriefingStore.read(want) != null) return; + if (want == BriefingPeriod.morning) { + // Don't opportunistically write (and permanently cache) a morning + // briefing off a still-syncing/truncated overnight — e.g. the band + // disconnected mid-sleep and the app is only foregrounded at 5am, so + // "overnight_state" is still 'building'. Wait for it to genuinely + // settle; the 10-min rate limit above already caps how often we check. + final today = await r.getToday(); + final status = (today['status'] as Map?)?.cast(); + final overnightState = status?['overnight_state']?.toString(); + if (overnightState != 'ready') return; + } _lastBriefingAttemptMs = at.millisecondsSinceEpoch; await BriefingEngine(config: cfg, repo: r).generate(want, now: at); _log('[ai] ${want.id} briefing generated'); From 4315b34bd3d8ffd73c58cdb6df3168b803170d19 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 10:15:49 +0530 Subject: [PATCH 04/15] hold off deriving days next to finalized ones after a tz jump --- lib/compute/derivation_engine.dart | 72 +++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index bdd6193..4763e92 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1351,14 +1351,43 @@ class DerivationEngine { } final rawDays = rawByDay.keys.toList()..sort(); if (force) { + // A full restage resolves any held-back timezone-adjacent days on its + // own — reset the guard's baseline offset so it doesn't fire again + // until a genuinely new jump happens. + await LocalDb.putBaseline( + 'tz_travel_guard', + jsonEncode({'offset_min': DateTime.now().timeZoneOffset.inMinutes}), + ); return _scopeForDays(rawDays, reason: 'full-history', fullHistory: true); } final finalized = await LocalDb.finalizedDayIds(kAlgoVersion); - final pending = [ + var pending = [ for (final day in rawDays) if (!finalized.contains(day)) day, ]; + + // decodedRecTsMaxByDay() buckets by the CURRENT device timezone, but + // `finalized` was frozen under whatever timezone was active when each day + // was derived. A real cross-timezone trip (not an ~1h DST shift) can make + // the SAME rec_ts rows relabel to a day adjacent to one already finalized + // — looking like a brand-new "pending" night that's really a duplicate, or + // silently landing on an already-finalized day_id and looking lost. Once + // that's detected, hold off auto-deriving anything adjacent to finalized + // data until "Re-analyze data" (full restage) resolves it properly. + if (await _timezoneTravelSuspected()) { + final adjacent = { + for (final day in finalized) ..._adjacentDayIds(day), + }; + final held = pending.where(adjacent.contains).toList(); + if (held.isNotEmpty) { + _log( + 'derive: possible timezone change — holding ${held.length} day(s) ' + 'adjacent to finalized data until Re-analyze data runs: $held', + ); + pending = pending.where((d) => !adjacent.contains(d)).toList(); + } + } if (pending.isEmpty) { return _scopeForDays([rawDays.last], reason: 'latest-finalized-check'); } @@ -1375,6 +1404,47 @@ class DerivationEngine { return _scopeForDays(light.days, reason: light.reason); } + /// The day before and after [dayId] ('YYYY-MM-DD'), DST-safe (goes through + /// real DateTime arithmetic, not a raw ±86400s offset). + static List _adjacentDayIds(String dayId) { + final d = DateTime.tryParse(dayId); + if (d == null) return const []; + String label(DateTime x) => + '${x.year.toString().padLeft(4, '0')}-' + '${x.month.toString().padLeft(2, '0')}-' + '${x.day.toString().padLeft(2, '0')}'; + return [ + label(DateTime(d.year, d.month, d.day - 1)), + label(DateTime(d.year, d.month, d.day + 1)), + ]; + } + + /// True once, right after the device timezone jumps by more than a real DST + /// shift ever would (>=3h) — a strong signal of actual cross-timezone + /// travel rather than a seasonal clock change. Persists the new offset so + /// this only fires on the transition itself, not every subsequent call. + static const int _tzJumpThresholdMin = 180; + Future _timezoneTravelSuspected() async { + final nowOffsetMin = DateTime.now().timeZoneOffset.inMinutes; + final row = await LocalDb.baseline('tz_travel_guard'); + final raw = row?['payload_json']; + int? lastOffsetMin; + if (raw is String && raw.isNotEmpty) { + try { + final d = jsonDecode(raw); + if (d is Map) lastOffsetMin = (d['offset_min'] as num?)?.toInt(); + } catch (_) { + // fall through — treat as unknown + } + } + await LocalDb.putBaseline( + 'tz_travel_guard', + jsonEncode({'offset_min': nowOffsetMin}), + ); + if (lastOffsetMin == null) return false; + return (nowOffsetMin - lastOffsetMin).abs() >= _tzJumpThresholdMin; + } + _DeriveScope _scopeForDays( List days, { required String reason, From f2feb5b494281d7e3d76d59c8323b5db18f92916 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 10:17:06 +0530 Subject: [PATCH 05/15] don't feed today's unfinalized night into the illness alert --- lib/compute/derivation_engine.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 4763e92..bc92db2 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -2346,9 +2346,18 @@ class DerivationEngine { // unconditionally on every heavy pass. _decodeBundle/_crossDayRecord are // both static, so this whole transform+encode step is isolate-safe. final rows = await LocalDb.recentDayResults(_crossDayWindow); + final today = LocalDb.localDayLabelNow(); final (days, json) = await _runIsolateCancellable(() { final days = >[]; for (final row in rows.reversed) { + // Today's own row updates on every derive pass while the night is + // still syncing/settling — feeding that partial reading into the + // illness/anomaly CUSUM can fire a false "possible illness onset" on + // data that's really just a truncated/mid-drain night. Only exclude + // TODAY specifically; older days already had their 48h to settle. + if (row['day_id'] == today && (row['finalized'] as num?) != 1) { + continue; + } final payload = _decodeBundle(row['payload_json']); if (payload == null) continue; if (payload['skipped'] == true) continue; From c63c22e1e0922e8784a53b23c289bfc129a6dc51 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 10:17:42 +0530 Subject: [PATCH 06/15] don't let a partial derive pass write into the baseline series --- lib/data/db.dart | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 33d11f7..bf6abe5 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -2712,12 +2712,20 @@ class LocalDb { 'rmssd': rmssd, 'readiness': readiness, }, conflictAlgorithm: ConflictAlgorithm.replace); - for (final e in series.entries) { - await txn.insert('metric_series', { - 'date': dayId, - 'key': e.key, - 'value': e.value, - }, conflictAlgorithm: ConflictAlgorithm.replace); + // A `partial` row already doesn't count as "derived" for the raw-pruning + // guard (see above) — extend the same caution to the rolling baselines: + // don't let a day whose second-half compute failed/timed out overwrite + // (or seed, for a brand-new day) the value tomorrow's readiness/illness + // baseline reads via metric_series. The next successful (non-partial) + // pass writes the real value once it lands. + if (!partial) { + for (final e in series.entries) { + await txn.insert('metric_series', { + 'date': dayId, + 'key': e.key, + 'value': e.value, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } } }); } From 7c73453e9c838ca6a534cf648b85de6c59bff503 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 10:20:54 +0530 Subject: [PATCH 07/15] hold steps display through a disconnect, checkpoint against app kill --- lib/state/app_state.dart | 117 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 78ae6ad..17b4e65 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1816,7 +1816,27 @@ class AppState extends ChangeNotifier { /// gain-applied). Used for cadence calibration. 0 when not streaming. int get _liveRaw => _committedRaw + (_magMin.isEmpty ? 0 : ana.pedometer(_magMin)); - int get liveSteps => (_liveRaw * ana.StepParams.gain).round(); + + // A routine BLE disconnect zeroes the live counter for a fresh session + // (`_resetLivePedometer`) before the derived day_result has had a chance to + // fold the just-ended session's steps in — the Today tile would otherwise + // visibly drop then jump back once derivation catches up. Hold the ended + // session's total on display for a short grace window so the number never + // regresses; the derived total supersedes it well within that window. + int _sessionStepsCushion = 0; + int _sessionCushionSetAtMs = 0; + static const int _sessionCushionGraceMs = 20 * 1000; + + int get liveSteps { + final raw = (_liveRaw * ana.StepParams.gain).round(); + if (_sessionStepsCushion <= 0) return raw; + if (DateTime.now().millisecondsSinceEpoch - _sessionCushionSetAtMs >= + _sessionCushionGraceMs) { + _sessionStepsCushion = 0; + return raw; + } + return math.max(raw, _sessionStepsCushion); + } // Snapshot of the RAW session total at the moment a manual workout started, so // the live-session screen shows steps FOR THIS WORKOUT (not since connection). @@ -1876,13 +1896,19 @@ class AppState extends ChangeNotifier { // Commit each completed minute into the raw total (matches the gain's // per-minute calibration), then keep counting the next partial minute. + var committedThisTick = false; while (_magMin.length >= _minuteSamples) { final minute = _magMin.sublist(0, _minuteSamples); _magMin.removeRange(0, _minuteSamples); final before = _committedRaw; _committedRaw += ana.pedometer(minute); if (_committedRaw > before) _lastWalkMs = nowMs; + committedThisTick = true; } + // Checkpoint once a minute (only on an actual commit, not every frame) so + // a killed process doesn't lose the whole session — only whatever hasn't + // completed a minute yet. See _recoverOrphanedLiveSession. + if (committedThisTick) unawaited(_checkpointLiveSession()); if (nowMs - _lastLiveUiNotifyMs >= 1000) { _lastLiveUiNotifyMs = nowMs; notifyListeners(); // live readout re-counts the partial minute on read @@ -1924,6 +1950,10 @@ class AppState extends ChangeNotifier { final enmo = _liveEnmoN > 0 ? _liveEnmoSum / _liveEnmoN : 0.0; // Derive the coverage window BEFORE resetting (it reads session counters). final window = _liveCoverageWindow(steps); + if (steps > 0) { + _sessionStepsCushion = steps; + _sessionCushionSetAtMs = DateTime.now().millisecondsSinceEpoch; + } _resetLivePedometer(); // Record the REAL 100 Hz step window (device time). The derivation pass adds // it to the day's steps AND excludes those minutes from the 1 Hz estimate, so @@ -1936,6 +1966,10 @@ class AppState extends ChangeNotifier { '${d.day.toString().padLeft(2, '0')}'; await LocalDb.addLiveCoverage(window.startTs, window.endTs, steps, day); } + // The session ended cleanly and is now durably recorded — the checkpoint + // that would otherwise let a killed-process session recover is no longer + // needed. + await _clearLiveSessionCheckpoint(); if (steps <= 0 || durS < 20) return; final cadence = steps / (durS / 60.0); // Any nonzero AN-2554 count is CONFIRM-gated gait; confidence is high when @@ -1957,6 +1991,84 @@ class AppState extends ChangeNotifier { } } + // Whatever accrued via _committedRaw/_magMin between minute-commits is + // in-memory ONLY — if the OS kills the app (backgrounded walk, phone + // reboot) mid-session, none of it was ever going to reach + // _finalizeLivePedometer, so it just vanished with no trace and no + // fallback (the 1 Hz estimator only backfills minutes a coverage row + // says are UNCOVERED). Checkpoint the committed total once a minute so + // the next session start can recover it instead of losing it outright. + static const String _kLiveSessionCheckpoint = 'live_session_checkpoint'; + + Future _checkpointLiveSession() async { + if (_committedRaw <= 0 || _liveCoverStartTs == null) return; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _kLiveSessionCheckpoint, + jsonEncode({ + 'steps': (_committedRaw * ana.StepParams.gain).round(), + 'samples': _liveSamples, + 'cover_start_ts': _liveCoverStartTs, + 'cover_end_ts': _liveCoverEndTs, + 'first_ingest_ms': _liveFirstIngestMs, + 'last_ingest_ms': _liveLastIngestMs, + }), + ); + } catch (e) { + _log('[steps] checkpoint skipped: $e'); + } + } + + Future _clearLiveSessionCheckpoint() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kLiveSessionCheckpoint); + } catch (_) { + // best-effort + } + } + + /// Recover a checkpoint left behind by a session that never reached + /// [_finalizeLivePedometer] (the process was killed, not a clean + /// disconnect) — folds the committed steps into `live_coverage` just like + /// a normal session end, so a killed background walk doesn't just vanish. + /// Call this BEFORE starting a fresh session ([_resetLivePedometer]). + Future _recoverOrphanedLiveSession() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_kLiveSessionCheckpoint); + if (raw == null || raw.isEmpty) return; + await prefs.remove(_kLiveSessionCheckpoint); + final m = jsonDecode(raw); + if (m is! Map) return; + final steps = (m['steps'] as num?)?.toInt() ?? 0; + final startTs = (m['cover_start_ts'] as num?)?.toInt(); + final endTs = (m['cover_end_ts'] as num?)?.toInt(); + if (steps <= 0 || startTs == null || endTs == null || endTs <= startTs) { + return; + } + final window = deriveLiveCoverageWindow( + steps: steps, + samples100Hz: (m['samples'] as num?)?.toInt() ?? 0, + bandStartTs: startTs, + bandEndTs: endTs, + firstIngestMs: (m['first_ingest_ms'] as num?)?.toInt(), + lastIngestMs: (m['last_ingest_ms'] as num?)?.toInt(), + ); + if (window == null) return; + final d = DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000); + final day = + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + await LocalDb.addLiveCoverage(window.startTs, window.endTs, steps, day); + _log('[steps] recovered $steps orphaned step(s) from a killed session'); + } catch (e) { + _log('[steps] orphan recovery skipped: $e'); + } + } + /// "Charge it now or lose tonight" — the one battery warning that is about /// DATA rather than about the battery. /// @@ -2645,6 +2757,9 @@ class AppState extends ChangeNotifier { // untouched: commit-before-ACK and the HISTORY_COMPLETE bookkeeping all // live inside the engine regardless of who awaits the report. await engine.enableLiveStreams(); + // Recover any steps orphaned by a killed process BEFORE wiping the + // counters for this fresh session. + unawaited(_recoverOrphanedLiveSession()); _resetLivePedometer(); // fresh live step count for this connected session dbCounts = await LocalDb.counts(); unawaited( From d36ca2cdab8f00a64af3cdd34d5e87856daf28fd Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 10:21:21 +0530 Subject: [PATCH 08/15] notify per workout bout, not once per day --- lib/compute/derivation_engine.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index bc92db2..ac3eeae 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1949,7 +1949,11 @@ class DerivationEngine { if (nb != null) { await NotificationCenter.instance.emit( NotificationEvent( - dedupeKey: '${day.date}:auto_workout', + // Per-bout, not per-day — a per-day key silently swallowed the + // notification for a second real workout later the same day + // (fire-once-per-key by design). endSec is stable across re-derive + // passes re-detecting the SAME bout, so that case still dedupes. + dedupeKey: '${day.date}:auto_workout:${nb.endSec}', category: NotifCategory.recovery, priority: NotifPriority.normal, title: 'Did you work out?', From 6930a3a5c36e8f785c57e296b3926b889c3ebf73 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 10:22:07 +0530 Subject: [PATCH 09/15] auto-retry an unconfirmed alarm once before warning --- lib/state/app_state.dart | 44 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 17b4e65..abb09ea 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2478,6 +2478,14 @@ class AppState extends ChangeNotifier { // just wires the strap event stream + persistence + the fired notification. final AlarmConfirmation _alarm = AlarmConfirmation(); Timer? _alarmGraceTimer; + // Event 56 is a one-shot BLE notification — if that single packet gets + // dropped by an ordinary momentary disconnect right after the write (the + // band DID latch the alarm), there is no retry/re-poll for it and the + // GET_ALARM readback fallback is parked (unconfirmed format), so the app had + // no way to ever clear the "unconfirmed" warning short of the user + // re-sending the whole alarm. One silent, automatic re-arm covers that + // common case; only a still-unconfirmed retry falls through to the warning. + bool _alarmAutoRetried = false; /// The strap emitted ALARM_SET (event 56) — the alarm is confirmed armed. bool get alarmConfirmed => _alarm.confirmed; @@ -2514,6 +2522,7 @@ class AppState extends ChangeNotifier { _savedAlarm = epoch; device.alarmEpoch = epoch; // optimistic display _alarm.set(epoch, DateTime.now().millisecondsSinceEpoch); // await event 56 + _alarmAutoRetried = false; // fresh user-initiated set gets its one retry final prefs = await SharedPreferences.getInstance(); await prefs.setInt('alarm_epoch', epoch); // Nudge the UI once the grace window elapses so an unconfirmed alarm flips to @@ -2521,13 +2530,42 @@ class AppState extends ChangeNotifier { _alarmGraceTimer?.cancel(); _alarmGraceTimer = Timer( Duration(milliseconds: _alarm.graceMs + 250), - () { - if (!_alarm.confirmed) notifyListeners(); - }, + () => unawaited(_onAlarmGraceElapsed(when)), ); notifyListeners(); } + /// Grace window elapsed with no event 56. Before showing the soft warning, + /// try ONE silent re-arm — if the strap really did latch it and only the + /// confirmation notification was dropped, this re-send gives it a second + /// chance to confirm without the user having to notice or do anything. + Future _onAlarmGraceElapsed(DateTime when) async { + if (_alarm.confirmed) return; + if (_alarmAutoRetried || !isConnected) { + notifyListeners(); + return; + } + _alarmAutoRetried = true; + try { + final ok = await engine.setAlarm(when); + if (ok) { + _alarm.set( + when.millisecondsSinceEpoch ~/ 1000, + DateTime.now().millisecondsSinceEpoch, + ); + _alarmGraceTimer?.cancel(); + _alarmGraceTimer = Timer( + Duration(milliseconds: _alarm.graceMs + 250), + () => unawaited(_onAlarmGraceElapsed(when)), + ); + return; + } + } catch (e) { + _log('[alarm] auto-retry re-arm failed: $e'); + } + notifyListeners(); + } + /// Fire the strap's alarm haptics immediately — a "test buzz" so the user can /// confirm the band actually fires before trusting the scheduled wake. Future testAlarmBuzz() async { From 487194d282910c21d3fa3def90ef2c0abe4356df Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 10:47:59 +0530 Subject: [PATCH 10/15] fix bot findings: sticky tz guard, await orphan recovery --- lib/compute/derivation_engine.dart | 33 +++++++++++++++++++++--------- lib/state/app_state.dart | 6 ++++-- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index ac3eeae..7615eae 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1419,10 +1419,14 @@ class DerivationEngine { ]; } - /// True once, right after the device timezone jumps by more than a real DST - /// shift ever would (>=3h) — a strong signal of actual cross-timezone - /// travel rather than a seasonal clock change. Persists the new offset so - /// this only fires on the transition itself, not every subsequent call. + /// True right after the device timezone jumps by more than a real DST shift + /// ever would (>=3h) — a strong signal of actual cross-timezone travel + /// rather than a seasonal clock change. Stays true across repeated calls + /// (derive runs many times a day) by only ever updating the persisted + /// baseline offset when NO jump is detected — updating it unconditionally + /// would make the very next call see lastOffset == nowOffset and silently + /// drop the guard after a single pass. `force` (full restage) is what + /// resets it, per _deriveScope. static const int _tzJumpThresholdMin = 180; Future _timezoneTravelSuspected() async { final nowOffsetMin = DateTime.now().timeZoneOffset.inMinutes; @@ -1437,12 +1441,21 @@ class DerivationEngine { // fall through — treat as unknown } } - await LocalDb.putBaseline( - 'tz_travel_guard', - jsonEncode({'offset_min': nowOffsetMin}), - ); - if (lastOffsetMin == null) return false; - return (nowOffsetMin - lastOffsetMin).abs() >= _tzJumpThresholdMin; + if (lastOffsetMin == null) { + await LocalDb.putBaseline( + 'tz_travel_guard', + jsonEncode({'offset_min': nowOffsetMin}), + ); + return false; + } + final jumped = (nowOffsetMin - lastOffsetMin).abs() >= _tzJumpThresholdMin; + if (!jumped) { + await LocalDb.putBaseline( + 'tz_travel_guard', + jsonEncode({'offset_min': nowOffsetMin}), + ); + } + return jumped; } _DeriveScope _scopeForDays( diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index abb09ea..ede30a5 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2796,8 +2796,10 @@ class AppState extends ChangeNotifier { // live inside the engine regardless of who awaits the report. await engine.enableLiveStreams(); // Recover any steps orphaned by a killed process BEFORE wiping the - // counters for this fresh session. - unawaited(_recoverOrphanedLiveSession()); + // counters for this fresh session. Awaited (not unawaited) so there's + // no window where a fresh session could start accumulating before the + // old checkpoint is read and cleared. + await _recoverOrphanedLiveSession(); _resetLivePedometer(); // fresh live step count for this connected session dbCounts = await LocalDb.counts(); unawaited( From ffea4a06676adb8d8ca679f3b730acf52e84cd4b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 10:56:05 +0530 Subject: [PATCH 11/15] drop unused timeOfDay from system prompt, fix greeting test --- lib/ai/briefing_engine.dart | 13 ++++++------- test/ai_briefing_test.dart | 29 +++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/lib/ai/briefing_engine.dart b/lib/ai/briefing_engine.dart index 45183e0..3c5a63c 100644 --- a/lib/ai/briefing_engine.dart +++ b/lib/ai/briefing_engine.dart @@ -166,18 +166,17 @@ String readinessBand(num v) { return 'good'; } -String briefingSystemPrompt(BriefingPeriod period, String timeOfDay) { +String briefingSystemPrompt(BriefingPeriod period) { final scope = period == BriefingPeriod.morning ? 'last night\'s sleep and recovery, and what they mean for the day ahead' : 'today\'s activity, strain and stress, and how the day landed'; return 'You write a health briefing for a local-first fitness band app. ' 'Summarize $scope.\n' 'HARD RULES:\n' - '- Do NOT open with a greeting or any time-of-day reference ("good ' - 'morning", "this evening", etc.) — the app shows its own greeting ' - 'separately, computed at the moment the reader actually opens it, and ' - 'this text may be read hours after it was written. Start straight with ' - 'the substance.\n' + '- Do NOT open with a greeting or any reference to the time of day — ' + 'the app shows its own greeting separately, computed at the moment the ' + 'reader actually opens it, and this text may be read hours after it was ' + 'written. Start straight with the substance.\n' '- Use ONLY the numbers provided. Never invent, estimate or mention a ' 'metric that is not in the data. No medical advice or diagnosis.\n' '- If a "readiness" value is given, its parenthesized band label ' @@ -291,7 +290,7 @@ class BriefingEngine { (({required String system, required String user}) => CoachEngine.completeText( config: config, system: system, user: user)))( - system: briefingSystemPrompt(period, tod), + system: briefingSystemPrompt(period), user: buildBriefingUserPrompt(period, day, inputs, tod), ); if (raw.trim().isEmpty) { diff --git a/test/ai_briefing_test.dart b/test/ai_briefing_test.dart index 2eee28d..9d4bd26 100644 --- a/test/ai_briefing_test.dart +++ b/test/ai_briefing_test.dart @@ -114,23 +114,36 @@ void main() { group('prompt building (pure)', () { test('system prompt scopes by period and forbids invention', () { - final m = briefingSystemPrompt(BriefingPeriod.morning, 'morning'); + final m = briefingSystemPrompt(BriefingPeriod.morning); expect(m, contains('ONLY the numbers provided')); expect(m.toLowerCase(), contains('sleep')); - final e = briefingSystemPrompt(BriefingPeriod.evening, 'evening'); + final e = briefingSystemPrompt(BriefingPeriod.evening); expect(e.toLowerCase(), contains('strain')); }); - test('greeting follows the clock, not the period (issue #134)', () { + test('greeting comes from the app at read time, never baked into the ' + 'model text (issue #134)', () { expect(partOfDay(DateTime(2026, 7, 22, 9)), 'morning'); expect(partOfDay(DateTime(2026, 7, 22, 15)), 'afternoon'); expect(partOfDay(DateTime(2026, 7, 22, 19)), 'evening'); expect(partOfDay(DateTime(2026, 7, 22, 23)), 'night'); - // The morning briefing is shown until 17:00, so an afternoon reader must - // be told it's afternoon and the prompt must never say "morning". - final sys = briefingSystemPrompt(BriefingPeriod.morning, 'afternoon'); - expect(sys, contains('currently afternoon')); - expect(sys.toLowerCase(), isNot(contains('morning'))); + // The morning briefing is shown until 17:00, so it can be generated at + // 5am and read at 4pm — the model must never be told to write a + // greeting/time-of-day reference at all, since that word gets cached + // and can't track the actual read time. This must hold regardless of + // which period is requested. + for (final period in BriefingPeriod.values) { + final sys = briefingSystemPrompt(period).toLowerCase(); + expect(sys, contains('do not open with a greeting')); + // No "currently " framing — that's the exact pattern + // that got baked into the cached text and read stale hours later. + expect(sys, isNot(contains('currently'))); + expect(sys, isNot(contains('good morning'))); + expect(sys, isNot(contains('good afternoon'))); + expect(sys, isNot(contains('good evening'))); + } + // The USER prompt still carries the real read-time context for the + // model to reason with, distinct from the system prompt's rules. final usr = buildBriefingUserPrompt( BriefingPeriod.morning, '2026-07-22', {'readiness': 74}, 'afternoon'); expect(usr, contains('afternoon')); From 03a9980f683068dd894d6b7617ad6be26da3205e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 23:09:03 +0530 Subject: [PATCH 12/15] fix remaining bot findings: step cushion, orphan recovery, tz hold, alert scoping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - liveSteps' display cushion leaked into persistence and calibration: _finalizeLivePedometer and finishStepCalibration read the same getter, so a short session ending inside the prior session's 20s grace window persisted the PRIOR total again (double-counted live_coverage + a bogus cadence). Added _rawSessionSteps and used it wherever the true count is needed. - The cushion could also be lowered by that follow-up session, reintroducing the very regression it exists to prevent — it now never moves down. - Orphan step recovery never ran on a headless cold launch: a process kill plus iOS BLE-restore relaunch takes _init()'s background branch, not openSession(), so checkpointed steps sat in prefs forever. Also moved recovery + reset to BEFORE enableLiveStreams so a frame arriving during the awaited recovery isn't wiped by the reset that followed it. - Alarm auto-retry: guard on _disposed after the await (it was creating a timer dispose() could no longer cancel), bail if a newer alarm was armed while the retry was in flight (it could clobber the fresh one's confirmation), give the retry back when the write itself never landed, and extract the duplicated grace-timer wiring into one helper. - Timezone-travel hold was defeated when EVERY pending day was held: `pending` emptied and fell through to 'latest-finalized-check', which re-derived rawDays.last — one of the days just held. Returns an empty scope now. - Today's unfinalized row is FLAGGED, not dropped, from the cross-day input. Dropping it kept it out of the illness CUSUM but also removed it from readiness/glass-box, the resting-HR trend-shift CUSUM, load, sleep debt and `recent` (whose last row dates every notification). buildCrossDayBundle now nulls only the illness/anomaly/temp inputs for a flagged day. - Day labels go through dayLabelOf() at both live-coverage write sites. Tests 1055 -> 1058, analyze clean. --- lib/compute/crossday_pipeline.dart | 26 ++++++-- lib/compute/derivation_engine.dart | 29 +++++++-- lib/state/app_state.dart | 100 +++++++++++++++++++---------- test/crossday_pipeline_test.dart | 42 ++++++++++++ 4 files changed, 150 insertions(+), 47 deletions(-) diff --git a/lib/compute/crossday_pipeline.dart b/lib/compute/crossday_pipeline.dart index c4b08c9..e2d874d 100644 --- a/lib/compute/crossday_pipeline.dart +++ b/lib/compute/crossday_pipeline.dart @@ -47,20 +47,30 @@ Map buildCrossDayBundle( for (final d in days) _numOrNull(d['skin_temp_z']), ]; + // A day flagged `unsettled` (today, still syncing / not finalized) is a + // truncated reading, not a physiological signal — it must not drive an + // illness/anomaly/temperature ALERT. It stays in `days` for everything else + // (readiness, RHR trend, load, sleep debt, `recent`), which is why this is a + // per-input null rather than dropping the row from the list. + final unsettled = [for (final d in days) d['unsettled'] == true]; + T? settled(int i, T? v) => unsettled[i] ? null : v; + // ── illness CUSUM (NightSignal) on nightly RHR ───────────────────────────── - final illness = ana.illnessCusum(dates, rhrList); + final illness = ana.illnessCusum(dates, [ + for (var i = 0; i < n; i++) settled(i, rhrList[i]), + ]); // ── multivariate anomaly {RHR↑,HRV↓,temp↑,resp↑} ─────────────────────────── // Build one AnomalyFeatures per day (same length as dates). Days with all // features null still occupy a slot — the detector handles the nulls // internally (needs ≥2 present features tonight to compute a distance). final feats = [ - for (final d in days) + for (var i = 0; i < n; i++) ana.AnomalyFeatures( - rhr: _numOrNull(d['rhr']), - hrv: _numOrNull(d['rmssd']), - temp: _numOrNull(d['skin_temp_z']), - resp: _numOrNull(d['resp_rate']), + rhr: settled(i, rhrList[i]), + hrv: settled(i, rmssdList[i]), + temp: settled(i, tempList[i]), + resp: settled(i, respList[i]), ), ]; final anomaly = ana.multivariateAnomaly(dates, feats); @@ -81,7 +91,9 @@ Map buildCrossDayBundle( final load = ana.ctlAtlTsb(dailyTrimp); // ── skin-temp illness flag (Smarr, cycle-aware) ──────────────────────────── - final tempIllness = ana.tempIllnessFlag(dates, tempList); + final tempIllness = ana.tempIllnessFlag(dates, [ + for (var i = 0; i < n; i++) settled(i, tempList[i]), + ]); // ── circadian: mid-sleep, free/work split, jetlag, chronotype, sleep debt ── // mid-sleep epoch = (onset+wake)/2; local clock-hours in [0,24) via mod-day. diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 7615eae..e743eaf 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1386,6 +1386,16 @@ class DerivationEngine { 'adjacent to finalized data until Re-analyze data runs: $held', ); pending = pending.where((d) => !adjacent.contains(d)).toList(); + if (pending.isEmpty) { + // Everything pending was held. Falling through to the + // 'latest-finalized-check' below would re-derive rawDays.last — + // one of the very days just held — defeating the hold entirely. + return const _DeriveScope( + fullHistory: false, + targetDays: [], + reason: 'tz-travel-hold', + ); + } } } if (pending.isEmpty) { @@ -2367,19 +2377,26 @@ class DerivationEngine { final (days, json) = await _runIsolateCancellable(() { final days = >[]; for (final row in rows.reversed) { + final payload = _decodeBundle(row['payload_json']); + if (payload == null) continue; + if (payload['skipped'] == true) continue; + final rec = _crossDayRecord(row, payload); + if (rec == null) continue; // Today's own row updates on every derive pass while the night is // still syncing/settling — feeding that partial reading into the // illness/anomaly CUSUM can fire a false "possible illness onset" on // data that's really just a truncated/mid-drain night. Only exclude // TODAY specifically; older days already had their 48h to settle. + // + // FLAG it rather than DROP it: `days` is the single input list for the + // whole cross-day bundle, so dropping today also silently removed it + // from readiness/glass-box, the resting-HR trend-shift CUSUM, load, + // sleep debt and `recent` (whose last row dates every notification). + // buildCrossDayBundle nulls only the alert inputs for a flagged day. if (row['day_id'] == today && (row['finalized'] as num?) != 1) { - continue; + rec['unsettled'] = true; } - final payload = _decodeBundle(row['payload_json']); - if (payload == null) continue; - if (payload['skipped'] == true) continue; - final rec = _crossDayRecord(row, payload); - if (rec != null) days.add(rec); + days.add(rec); } return (days, jsonEncode({'algo_version': kAlgoVersion, 'days': days})); }, _crossDayTimeout, label: 'crossday-input'); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index ede30a5..988c73f 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1473,6 +1473,13 @@ class AppState extends ChangeNotifier { try { await _ensureForegroundLease(); if (await engine.connectToRemoteId(paired!.remoteId)) { + // A process kill followed by an iOS BLE-restore relaunch lands + // HERE, not in openSession() — this is the primary case the live + // step checkpoint exists for, so recovery has to run on this path + // too or those steps sit in prefs forever. Counters are fresh on a + // cold launch, so there is nothing to double-count. + await _recoverOrphanedLiveSession(); + _resetLivePedometer(); _maybeDowngradeLiveForBackground(); _startBackfillTimer(); } else { @@ -1827,8 +1834,15 @@ class AppState extends ChangeNotifier { int _sessionCushionSetAtMs = 0; static const int _sessionCushionGraceMs = 20 * 1000; + /// The TRUE gain-applied step count for THIS connected session, with no + /// display cushion. Everything that PERSISTS or CALIBRATES must read this — + /// [liveSteps] is display-only and can deliberately report a just-ended + /// PRIOR session's larger total during its grace window, which would + /// double-count into `live_coverage` and poison the cadence model. + int get _rawSessionSteps => (_liveRaw * ana.StepParams.gain).round(); + int get liveSteps { - final raw = (_liveRaw * ana.StepParams.gain).round(); + final raw = _rawSessionSteps; if (_sessionStepsCushion <= 0) return raw; if (DateTime.now().millisecondsSinceEpoch - _sessionCushionSetAtMs >= _sessionCushionGraceMs) { @@ -1945,13 +1959,23 @@ class AppState extends ChangeNotifier { /// End-of-session: if the bout is credible walking, fold it into the personal /// cadence calibration (persisted) so the 24/7 estimate gets more accurate. Future _finalizeLivePedometer() async { - final steps = liveSteps; // gain-applied + // RAW, never the cushioned display value: a second short session ending + // inside the first one's grace window would otherwise persist the FIRST + // session's total again (double-counted coverage + a nonsense cadence). + final steps = _rawSessionSteps; // gain-applied final durS = _liveSamples / 100.0; final enmo = _liveEnmoN > 0 ? _liveEnmoSum / _liveEnmoN : 0.0; // Derive the coverage window BEFORE resetting (it reads session counters). final window = _liveCoverageWindow(steps); if (steps > 0) { - _sessionStepsCushion = steps; + // Never LOWER a still-active cushion — a small follow-up session ending + // inside the grace window must not reintroduce the visible regression + // the cushion exists to prevent. + final active = liveSteps; // expires the cushion if it is already stale + _sessionStepsCushion = math.max( + steps, + math.max(active, _sessionStepsCushion), + ); _sessionCushionSetAtMs = DateTime.now().millisecondsSinceEpoch; } _resetLivePedometer(); @@ -1959,11 +1983,9 @@ class AppState extends ChangeNotifier { // it to the day's steps AND excludes those minutes from the 1 Hz estimate, so // 100 Hz always wins and a minute is never counted twice. if (window != null) { - final d = DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000); - final day = - '${d.year.toString().padLeft(4, '0')}-' - '${d.month.toString().padLeft(2, '0')}-' - '${d.day.toString().padLeft(2, '0')}'; + final day = dayLabelOf( + DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000), + ); await LocalDb.addLiveCoverage(window.startTs, window.endTs, steps, day); } // The session ended cleanly and is now durably recorded — the checkpoint @@ -2057,11 +2079,9 @@ class AppState extends ChangeNotifier { lastIngestMs: (m['last_ingest_ms'] as num?)?.toInt(), ); if (window == null) return; - final d = DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000); - final day = - '${d.year.toString().padLeft(4, '0')}-' - '${d.month.toString().padLeft(2, '0')}-' - '${d.day.toString().padLeft(2, '0')}'; + final day = dayLabelOf( + DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000), + ); await LocalDb.addLiveCoverage(window.startTs, window.endTs, steps, day); _log('[steps] recovered $steps orphaned step(s) from a killed session'); } catch (e) { @@ -2527,12 +2547,18 @@ class AppState extends ChangeNotifier { await prefs.setInt('alarm_epoch', epoch); // Nudge the UI once the grace window elapses so an unconfirmed alarm flips to // its soft warning even if no event ever arrives. + _armAlarmGraceTimer(when); + notifyListeners(); + } + + /// (Re)arm the "grace window elapsed" timer. One helper so the grace + /// duration and the retry wiring can't drift between the two call sites. + void _armAlarmGraceTimer(DateTime when) { _alarmGraceTimer?.cancel(); _alarmGraceTimer = Timer( Duration(milliseconds: _alarm.graceMs + 250), () => unawaited(_onAlarmGraceElapsed(when)), ); - notifyListeners(); } /// Grace window elapsed with no event 56. Before showing the soft warning, @@ -2540,29 +2566,35 @@ class AppState extends ChangeNotifier { /// confirmation notification was dropped, this re-send gives it a second /// chance to confirm without the user having to notice or do anything. Future _onAlarmGraceElapsed(DateTime when) async { - if (_alarm.confirmed) return; + if (_disposed || _alarm.confirmed) return; + final epoch = when.millisecondsSinceEpoch ~/ 1000; + // A newer alarm was armed while this timer was pending — that set owns the + // confirmation machine now; retrying the stale time would clobber it. + if (_savedAlarm != epoch) return; if (_alarmAutoRetried || !isConnected) { notifyListeners(); return; } _alarmAutoRetried = true; + var rearmed = false; try { - final ok = await engine.setAlarm(when); - if (ok) { - _alarm.set( - when.millisecondsSinceEpoch ~/ 1000, - DateTime.now().millisecondsSinceEpoch, - ); - _alarmGraceTimer?.cancel(); - _alarmGraceTimer = Timer( - Duration(milliseconds: _alarm.graceMs + 250), - () => unawaited(_onAlarmGraceElapsed(when)), - ); - return; - } + rearmed = await engine.setAlarm(when); } catch (e) { _log('[alarm] auto-retry re-arm failed: $e'); } + // The write itself never landed, so the one retry was not actually spent — + // give it back rather than latching this alarm out of any future retry. + if (!rearmed) _alarmAutoRetried = false; + // dispose() ran while the write was in flight — do NOT create a timer it + // no longer has any chance to cancel (it would keep poking a torn-down + // engine on every fire). + if (_disposed) return; + // Re-check staleness after the await for the same reason as above. + if (rearmed && _savedAlarm == epoch && !_alarm.confirmed) { + _alarm.set(epoch, DateTime.now().millisecondsSinceEpoch); + _armAlarmGraceTimer(when); + return; + } notifyListeners(); } @@ -2794,13 +2826,13 @@ class AppState extends ChangeNotifier { // no-progress timer, so bursts ran long). The drain's correctness is // untouched: commit-before-ACK and the HISTORY_COMPLETE bookkeeping all // live inside the engine regardless of who awaits the report. - await engine.enableLiveStreams(); - // Recover any steps orphaned by a killed process BEFORE wiping the - // counters for this fresh session. Awaited (not unawaited) so there's - // no window where a fresh session could start accumulating before the - // old checkpoint is read and cleared. + // Recover any steps orphaned by a killed process, and zero the counters + // for this session, BEFORE live delivery starts. Doing it after + // enableLiveStreams() left a window where frames ingested during the + // (awaited, I/O-bound) recovery were then wiped by _resetLivePedometer. await _recoverOrphanedLiveSession(); _resetLivePedometer(); // fresh live step count for this connected session + await engine.enableLiveStreams(); dbCounts = await LocalDb.counts(); unawaited( _kickSyncBurst(kickFirst: false).then((report) async { @@ -3377,7 +3409,7 @@ class AppState extends ChangeNotifier { /// model (refEnmo + cadence). Returns the learned cadence (spm), or null if the /// walk wasn't credible. Stops the stream we turned on. Future finishStepCalibration() async { - final steps = liveSteps; + final steps = _rawSessionSteps; // raw, never the display cushion final durS = _liveSamples / 100.0; final enmo = _liveEnmoN > 0 ? _liveEnmoSum / _liveEnmoN : 0.0; double? learned; diff --git a/test/crossday_pipeline_test.dart b/test/crossday_pipeline_test.dart index 22834cb..bb8361d 100644 --- a/test/crossday_pipeline_test.dart +++ b/test/crossday_pipeline_test.dart @@ -306,4 +306,46 @@ void main() { expect((load['atl'] as num).toDouble(), greaterThan(50.0)); }); }); + group('unsettled (today, not finalized) day scoping', () { + // Regression: today's unfinalized row used to be DROPPED from the input + // list entirely to keep it out of the illness CUSUM. That also removed it + // from readiness/glass-box, the resting-HR trend-shift CUSUM feed, load, + // sleep debt and `recent` — whose last row dates every notification. It + // must now stay in the series and only be nulled out of the alert inputs. + test('stays in `recent` (so notifications date to today)', () { + final days = _synthDays(30); + final lastDate = days.last['date'] as String; + days.last['unsettled'] = true; + + final bundle = buildCrossDayBundle(days, const {}); + final recent = bundle['recent'] as List; + + expect(recent.length, days.length); + expect((recent.last as Map)['date'], lastDate); + // The resting-HR trend-shift CUSUM reads `rhr` back off these rows. + expect((recent.last as Map)['rhr'], isNotNull); + }); + + test('does not drive the illness/anomaly alert', () { + // A sustained spike on the final days trips the flag when settled... + final settled = _synthDays(30, rhrSpikeLast: true); + expect(buildCrossDayBundle(settled, const {})['illness'], isNotNull); + + // ...and the SAME spike on a still-syncing today must not, because its + // inputs are withheld from the CUSUMs. + final unsettled = _synthDays(30, rhrSpikeLast: true); + unsettled.last['unsettled'] = true; + final bundle = buildCrossDayBundle(unsettled, const {}); + + final last = (bundle['recent'] as List).last as Map; + expect(last['illness'], isFalse); + expect(last['anomaly'], isFalse); + }); + + test('an all-settled series is unaffected by the flag plumbing', () { + final bundle = buildCrossDayBundle(_synthDays(30), const {}); + expect((bundle['recent'] as List).length, 30); + expect(bundle['n_days'], 30); + }); + }); } From cc126eeae4a98928bcf88a361ee40a0aef1cfdc1 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 1 Aug 2026 23:29:26 +0530 Subject: [PATCH 13/15] fix second-round bot findings: import napSub, tz guard reset timing, replay-safe recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _deriveDay (the CSV/WHOOP import path) never passed napSub, so it fell back to daySub and bisected midnight-straddling naps again — on exactly the path this PR set out to fix. Passes the same buffered slice prepareDerivationPayload uses. - The tz-travel guard's baseline was re-written in _deriveScope's force branch, i.e. at scope-SELECTION time, before the restage had actually run. An interrupted restage therefore dropped the hold anyway. Moved to after the full-history pass completes. - Orphan step recovery is now replay-safe. _finalizeLivePedometer writes live_coverage before clearing the checkpoint (durable-first, deliberately); a kill in that gap left a checkpoint whose bout was already banked, and live_coverage has no uniqueness on the window, so recovery would inflate the day's real steps. Added LocalDb.hasLiveCoverageWindow and skip on a hit — keeps the safe write ordering instead of trading a double-count for a loss. Tests 1058 passing, analyze clean. --- lib/compute/derivation_engine.dart | 22 ++++++++++++++++------ lib/data/db.dart | 16 ++++++++++++++++ lib/state/app_state.dart | 9 +++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index e743eaf..c6d324e 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -878,6 +878,14 @@ class DerivationEngine { if (scope.fullHistory) { _diag['stage'] = 'prune'; await _pruneOldDecoded(todoDays, dataNowSec); + // The full restage COMPLETED — every day was re-derived under the + // current timezone, so any held-back adjacency is resolved. Re-baseline + // the travel guard now (not when the scope was chosen), so an + // interrupted restage leaves the hold in place. + await LocalDb.putBaseline( + 'tz_travel_guard', + jsonEncode({'offset_min': DateTime.now().timeZoneOffset.inMinutes}), + ); } return done; } catch (e, st) { @@ -1352,12 +1360,9 @@ class DerivationEngine { final rawDays = rawByDay.keys.toList()..sort(); if (force) { // A full restage resolves any held-back timezone-adjacent days on its - // own — reset the guard's baseline offset so it doesn't fire again - // until a genuinely new jump happens. - await LocalDb.putBaseline( - 'tz_travel_guard', - jsonEncode({'offset_min': DateTime.now().timeZoneOffset.inMinutes}), - ); + // own, so it clears the guard — but only once it has actually RUN. See + // the reset at the end of run(): clearing it here, at scope-selection + // time, meant an interrupted restage still dropped the hold. return _scopeForDays(rawDays, reason: 'full-history', fullHistory: true); } @@ -1692,6 +1697,10 @@ class DerivationEngine { bool forceFinalize = false, }) async { final daySub = sub.slice(day.startSec, day.endSec); + // Same buffered slice prepareDerivationPayload uses — without it, imported + // days fall back to daySub and a nap straddling midnight is bisected again + // on exactly the path this PR set out to fix. + final napSub = sub.slice(day.startSec, day.endSec + napBoundaryBufferSec); final sleepSub = day.hasSleep ? sub.sliceIdx(day.sleepLoIdx, day.sleepHiIdx) : Substrate.empty; @@ -1725,6 +1734,7 @@ class DerivationEngine { sleepOnsetSec: onsetSec, sleepOffsetSec: offsetSec, daySub: daySub, + napSub: napSub, sleepSub: sleepSub, ), profile, diff --git a/lib/data/db.dart b/lib/data/db.dart index bf6abe5..ea66fbf 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -790,6 +790,22 @@ class LocalDb { }); } + /// True when a coverage row for exactly this window already exists. + /// + /// `live_coverage` is an append-only SUM (no uniqueness on the window), so a + /// replayed write double-counts the day's real steps. The orphaned-session + /// recovery uses this to stay idempotent: a process killed AFTER + /// `_finalizeLivePedometer` wrote coverage but BEFORE it cleared the + /// checkpoint would otherwise re-add the same bout on the next launch. + static Future hasLiveCoverageWindow(int startTs, int endTs) async { + final db = await instance; + final r = await db.rawQuery( + 'SELECT 1 FROM live_coverage WHERE start_ts = ? AND end_ts = ? LIMIT 1', + [startTs, endTs], + ); + return r.isNotEmpty; + } + /// Real (100 Hz) steps attributed to [day]. static Future liveStepsForDay(String day) async { final db = await instance; diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 988c73f..c943453 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2079,6 +2079,15 @@ class AppState extends ChangeNotifier { lastIngestMs: (m['last_ingest_ms'] as num?)?.toInt(), ); if (window == null) return; + // The clean-shutdown path writes coverage BEFORE clearing the checkpoint + // (durable-first, same ordering rule as commit-before-ACK). A kill in + // that narrow gap leaves a checkpoint whose bout is already banked — + // `live_coverage` has no uniqueness on the window, so replaying it would + // silently inflate the day. Skip anything already recorded. + if (await LocalDb.hasLiveCoverageWindow(window.startTs, window.endTs)) { + _log('[steps] orphan checkpoint already banked — not re-adding'); + return; + } final day = dayLabelOf( DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000), ); From bd4c59dfbe9ceaaf1582d7a997a642617bcd4e48 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 2 Aug 2026 07:55:00 +0530 Subject: [PATCH 14/15] fix third-round bot findings: napSub on the live path, tz hold on failure, single-flight recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The napSub fix only ever reached the import path. _prepareTargetDay (used by run()/runDays()/rescanRecent() — i.e. EVERY non-imported day) still called toPreparedDay without napSub, so it fell back to daySub and kept bisecting cross-midnight naps. It now loads [dayStart, dayEnd + napBoundaryBufferSec) in ONE substrate pass (each load spawns its own isolate, so a second load would double the cost) and slices the calendar day back out of it. - The tz-travel guard was re-baselined after ANY full restage, but processDay swallows per-day errors and marks the day skipped — a restage could 'finish' with days still unresolved and drop the hold anyway. Only clears now when every targeted day actually derived. Not applied to runDays(): that is the selected-days path, not the full-restage entry point (reanalyzeAll calls run(force: true)). - Orphan step recovery is single-flight. Two entry points can call it now, and interleaving would let both read the checkpoint before either removed it; live_coverage is an append-only SUM with no window uniqueness, so the duplicate would inflate the day. Tests 1058 passing, analyze clean. --- lib/compute/derivation_engine.dart | 44 ++++++++++++++++++++++-------- lib/compute/derive_prepare.dart | 2 ++ lib/state/app_state.dart | 14 +++++++++- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index c6d324e..cd2c59f 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -803,6 +803,7 @@ class DerivationEngine { var done = 0; var completed = 0; + var failures = 0; final activeDays = {}; _diag['stage'] = 'per_day'; _diag['active_days'] = const []; @@ -844,6 +845,7 @@ class DerivationEngine { ); _diag['skipped_days'] = (_diag['skipped_days'] as int) + 1; _diag['last_error'] = 'no_bounded_window_payload day=$dayId'; + failures++; } } catch (e) { _log('derive day $dayId FAILED/skipped: $e'); @@ -856,6 +858,7 @@ class DerivationEngine { ); _diag['skipped_days'] = (_diag['skipped_days'] as int) + 1; _diag['last_error'] = '$e'; + failures++; } activeDays.remove(dayId); _diag['active_days'] = activeDays.toList(); @@ -878,14 +881,22 @@ class DerivationEngine { if (scope.fullHistory) { _diag['stage'] = 'prune'; await _pruneOldDecoded(todoDays, dataNowSec); - // The full restage COMPLETED — every day was re-derived under the - // current timezone, so any held-back adjacency is resolved. Re-baseline - // the travel guard now (not when the scope was chosen), so an - // interrupted restage leaves the hold in place. - await LocalDb.putBaseline( - 'tz_travel_guard', - jsonEncode({'offset_min': DateTime.now().timeZoneOffset.inMinutes}), - ); + // Re-baseline the travel guard — but ONLY if every targeted day + // actually got re-derived under the current timezone. processDay + // swallows per-day errors and marks the day skipped, so a restage can + // "finish" with days still unresolved; clearing the hold then would + // drop it without the adjacency ever having been fixed. (A day kept + // deliberately — a pruned override day — is not a failure.) + if (failures == 0) { + await LocalDb.putBaseline( + 'tz_travel_guard', + jsonEncode({'offset_min': DateTime.now().timeZoneOffset.inMinutes}), + ); + } else { + _log( + 'derive: $failures day(s) unresolved — keeping the timezone hold', + ); + } } return done; } catch (e, st) { @@ -1025,12 +1036,19 @@ class DerivationEngine { final candidate = await _sleepCandidateForDay(dayId, stats: stats); final dayStart = _localDayLabelToSec(dayId); final dayEnd = _localNextDayLabelToSec(dayId); - final daySub = await _loadSubstrateRange( + // Load the day PLUS the nap boundary buffer in ONE pass (each + // _loadSubstrateRange spawns its own isolate, so a second load would + // double that cost) and slice the calendar day back out of it. Without + // this, the live decoded path — run()/runDays()/rescanRecent(), i.e. every + // non-import day — fell back to napSub == daySub and went on bisecting + // naps at midnight. + final napSub = await _loadSubstrateRange( dayStart, - dayEnd - 1, + dayEnd - 1 + napBoundaryBufferSec, dayId: dayId, stats: stats, ); + final daySub = napSub.slice(dayStart, dayEnd); Substrate sleepSub = Substrate.empty; if (candidate.present && candidate.sleepOffsetSec > candidate.sleepOnsetSec) { @@ -1050,7 +1068,11 @@ class DerivationEngine { if (stats.rows > (_diag['max_day_raw_rows'] as int)) { _diag['max_day_raw_rows'] = stats.rows; } - return candidate.toPreparedDay(daySub: daySub, sleepSub: sleepSub); + return candidate.toPreparedDay( + daySub: daySub, + napSub: napSub, + sleepSub: sleepSub, + ); } Future _sleepCandidateForDay( diff --git a/lib/compute/derive_prepare.dart b/lib/compute/derive_prepare.dart index 803726b..7892a9d 100644 --- a/lib/compute/derive_prepare.dart +++ b/lib/compute/derive_prepare.dart @@ -182,6 +182,7 @@ class SleepSessionCandidate { PreparedDerivationDay toPreparedDay({ required Substrate daySub, required Substrate sleepSub, + Substrate? napSub, }) => PreparedDerivationDay( date: dayId, // `endSec` is what the engine anchors FINALIZATION on @@ -202,6 +203,7 @@ class SleepSessionCandidate { sleepOffsetSec: sleepOffsetSec, sleepSource: sleepSource, daySub: daySub, + napSub: napSub, sleepSub: sleepSub, ); } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index c943453..e8ec69d 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2056,7 +2056,19 @@ class AppState extends ChangeNotifier { /// disconnect) — folds the committed steps into `live_coverage` just like /// a normal session end, so a killed background walk doesn't just vanish. /// Call this BEFORE starting a fresh session ([_resetLivePedometer]). - Future _recoverOrphanedLiveSession() async { + /// Single-flight: two entry points can now call this (openSession's full + /// connect and the background cold-launch branch). Interleaving them would + /// let both read the checkpoint before either removed it, and + /// `live_coverage` is an append-only SUM with no window uniqueness — the + /// duplicate would silently inflate the day's real steps. + Future? _orphanRecovery; + + Future _recoverOrphanedLiveSession() => + _orphanRecovery ??= _recoverOrphanedLiveSessionOnce().whenComplete(() { + _orphanRecovery = null; + }); + + Future _recoverOrphanedLiveSessionOnce() async { try { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_kLiveSessionCheckpoint); From 43d7613ddd4e73756f59fc87a9d58849139976aa Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 2 Aug 2026 08:26:24 +0530 Subject: [PATCH 15/15] fix fourth-round bot findings: full-coverage selected restage clears tz hold, durable-first recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runDays(force: true) — the Advanced > select-days re-analyze path — now also clears the timezone hold, but only when the selection actually covers the whole raw history AND every target resolved. A partial selection still says nothing about the days being held, so it deliberately leaves the hold up. - Orphan step recovery is durable-first again (write coverage, then drop the checkpoint) — the same rule as commit-before-ACK. Removing the checkpoint first meant a kill in that gap lost the bout, which is what the checkpoint exists to prevent. Replay is safe now: the coverage-window check makes it idempotent and the method is single-flight. A checkpoint that can never be recovered (malformed, or a window the sanitizer rejects) is still dropped immediately so it can't be retried on every connect forever. Tests 1058 passing, analyze clean. --- lib/compute/derivation_engine.dart | 20 ++++++++++++++++++++ lib/state/app_state.dart | 27 +++++++++++++++++---------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index cd2c59f..1afb400 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -972,6 +972,7 @@ class DerivationEngine { final orderedDays = todoDays.reversed.toList(); var done = 0; var completed = 0; + var failures = 0; final activeDays = {}; Future processDay(String dayId) async { @@ -987,11 +988,13 @@ class DerivationEngine { } else { _diag['skipped_days'] = (_diag['skipped_days'] as int) + 1; _diag['last_error'] = 'no_bounded_window_payload day=$dayId'; + failures++; } } catch (e) { _log('derive selected day $dayId FAILED/skipped: $e'); _diag['skipped_days'] = (_diag['skipped_days'] as int) + 1; _diag['last_error'] = '$e'; + failures++; } activeDays.remove(dayId); _diag['active_days'] = activeDays.toList(); @@ -1000,6 +1003,23 @@ class DerivationEngine { } await runWithConcurrency(orderedDays, _deriveConcurrency, processDay); + // A SELECTED re-analyze that happens to cover the whole raw history, with + // every day resolved, is a full restage by any other name — it re-derived + // every day under the current timezone, so it clears the travel hold too. + // A partial selection deliberately does not: those days say nothing about + // the ones still held. + if (force && failures == 0) { + final rawDays = (await LocalDb.decodedRecTsMaxByDay()).keys.toSet(); + if (rawDays.isNotEmpty && rawDays.difference(days).isEmpty) { + await LocalDb.putBaseline( + 'tz_travel_guard', + jsonEncode({ + 'offset_min': DateTime.now().timeZoneOffset.inMinutes, + }), + ); + _log('derive selected: full-coverage restage — timezone hold cleared'); + } + } if (done > 0) { await _refreshBaselines(); await _runCrossDay(profile); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index e8ec69d..88e5cbf 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2073,14 +2073,21 @@ class AppState extends ChangeNotifier { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_kLiveSessionCheckpoint); if (raw == null || raw.isEmpty) return; - await prefs.remove(_kLiveSessionCheckpoint); + // Durable-FIRST, like everywhere else in this codebase (commit-before-ACK + // is the same rule): the checkpoint is only dropped once its steps are + // banked, so a kill anywhere in here re-runs the recovery rather than + // losing the bout. Replay is safe because of the coverage-window check + // below and because this method is single-flight. The one exception is a + // checkpoint that can never be recovered — dropped immediately so it + // can't be retried on every single connect forever. + Future drop() => prefs.remove(_kLiveSessionCheckpoint); final m = jsonDecode(raw); - if (m is! Map) return; + if (m is! Map) return await drop(); final steps = (m['steps'] as num?)?.toInt() ?? 0; final startTs = (m['cover_start_ts'] as num?)?.toInt(); final endTs = (m['cover_end_ts'] as num?)?.toInt(); if (steps <= 0 || startTs == null || endTs == null || endTs <= startTs) { - return; + return await drop(); } final window = deriveLiveCoverageWindow( steps: steps, @@ -2090,20 +2097,20 @@ class AppState extends ChangeNotifier { firstIngestMs: (m['first_ingest_ms'] as num?)?.toInt(), lastIngestMs: (m['last_ingest_ms'] as num?)?.toInt(), ); - if (window == null) return; - // The clean-shutdown path writes coverage BEFORE clearing the checkpoint - // (durable-first, same ordering rule as commit-before-ACK). A kill in - // that narrow gap leaves a checkpoint whose bout is already banked — - // `live_coverage` has no uniqueness on the window, so replaying it would - // silently inflate the day. Skip anything already recorded. + if (window == null) return await drop(); + // The clean-shutdown path writes coverage BEFORE clearing the checkpoint, + // so a kill in that gap leaves a checkpoint whose bout is already banked — + // and `live_coverage` has no uniqueness on the window, so replaying it + // would silently inflate the day. Skip anything already recorded. if (await LocalDb.hasLiveCoverageWindow(window.startTs, window.endTs)) { _log('[steps] orphan checkpoint already banked — not re-adding'); - return; + return await drop(); } final day = dayLabelOf( DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000), ); await LocalDb.addLiveCoverage(window.startTs, window.endTs, steps, day); + await drop(); _log('[steps] recovered $steps orphaned step(s) from a killed session'); } catch (e) { _log('[steps] orphan recovery skipped: $e');