Skip to content

fix bugs - #172

Merged
abdulsaheel merged 15 commits into
mainfrom
fix/bugs
Aug 2, 2026
Merged

fix bugs#172
abdulsaheel merged 15 commits into
mainfrom
fix/bugs

Conversation

@abdulsaheel

@abdulsaheel abdulsaheel commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

User description

  • stop dropping naps that straddle midnight
  • unfreeze live step count on today
  • don't cache a morning briefing off an unsettled overnight
  • hold off deriving days next to finalized ones after a tz jump
  • don't feed today's unfinalized night into the illness alert
  • don't let a partial derive pass write into the baseline series
  • hold steps display through a disconnect, checkpoint against app kill
  • notify per workout bout, not once per day
  • auto-retry an unconfirmed alarm once before warning

PR Type

Bug fix


Description

  • Fix naps straddling midnight being silently dropped by introducing a napSub buffer window

  • Fix live step count freezing/dropping on disconnect with a grace cushion and orphan-session recovery

  • Fix false illness alerts, stale morning briefings, and partial-derive baseline pollution

  • Fix alarm confirmation retry, per-bout workout notifications, and timezone-travel derive guard


Diagram Walkthrough

flowchart LR
  napSub["napSub buffer\n(+3h past midnight)"]
  napDetect["Nap detection\n(_sleepPeriods / _attachNaps)"]
  napSub -- "attributionEndSec guard" --> napDetect

  liveSteps["liveSteps getter"]
  cushion["_sessionStepsCushion\n(20s grace)"]
  checkpoint["_checkpointLiveSession\n(SharedPrefs, per-minute)"]
  orphan["_recoverOrphanedLiveSession\n(on connect)"]
  liveSteps -- "max(raw, cushion)" --> cushion
  liveSteps -- "checkpoint on commit" --> checkpoint
  checkpoint -- "recovered on next connect" --> orphan

  briefing["Morning briefing\ngeneration"]
  overnightState["overnight_state == 'ready'\ncheck"]
  briefing -- "gate on" --> overnightState

  tzGuard["_timezoneTravelSuspected\n(>=3h jump)"]
  deriveScope["_buildDeriveScope\npending days"]
  tzGuard -- "hold adjacent days" --> deriveScope

  partialDerive["partial derive pass"]
  metricSeries["metric_series write"]
  partialDerive -- "skipped when partial=true" --> metricSeries

  illnessAlert["Illness/anomaly CUSUM"]
  todayRow["today's unfinalized row"]
  todayRow -- "excluded from" --> illnessAlert

  alarmRetry["_onAlarmGraceElapsed\nauto-retry once"]
  alarmConfirm["AlarmConfirmation\nevent 56"]
  alarmRetry -- "silent re-arm before warning" --> alarmConfirm

  workoutNotif["auto_workout notification"]
  boutKey["dedupeKey: date:auto_workout:endSec"]
  workoutNotif -- "per-bout key" --> boutKey
Loading

File Walkthrough

Relevant files
Bug fix
derive_prepare.dart
Add napSub buffer slice to fix midnight-straddling nap bisection

lib/compute/derive_prepare.dart

  • Adds napSub field to PreparedDerivationDay: a substrate slice extended
    3 hours past the calendar day end so naps straddling midnight are seen
    whole
  • Introduces napBoundaryBufferSec constant (3 h) documenting the buffer
  • Serializes/deserializes napSub in toJson/fromJson; falls back to
    daySub for old rows
  • Passes napSub = sub.slice(day.startSec, day.endSec +
    napBoundaryBufferSec) in prepareDerivationPayload
+27/-4   
derivation_engine.dart
Fix nap double-count, TZ guard, illness alert, and per-bout workout
notify

lib/compute/derivation_engine.dart

  • Threads napSub through _DayBlocksInput and uses it (with
    attributionEndSec) in _sleepPeriods and _attachNaps so naps starting
    in the buffer but belonging to tomorrow are excluded
  • Adds _timezoneTravelSuspected() (>=3 h offset jump) and holds pending
    days adjacent to finalized ones until a full restage; resets guard on
    force-restage
  • Excludes today's unfinalized day_result row from the cross-day
    illness/anomaly CUSUM feed
  • Changes workout notification dedupeKey from date:auto_workout to
    date:auto_workout:endSec so a second real workout the same day is not
    silently swallowed
  • _adjacentDayIds helper uses DST-safe DateTime arithmetic
+120/-10
db.dart
Skip metric_series writes for partial derive passes           

lib/data/db.dart

  • Wraps metric_series inserts in upsertDayResult with if (!partial)
    guard so a failed/timed-out second-half derive pass cannot overwrite
    or seed baseline series values
+14/-6   
app_state.dart
Fix step freeze, step checkpoint, briefing cache, and alarm auto-retry

lib/state/app_state.dart

  • Adds _sessionStepsCushion / _sessionCushionSetAtMs (20 s grace) to
    liveSteps so the displayed count never regresses on disconnect before
    derivation catches up
  • Adds _checkpointLiveSession (writes committed steps to SharedPrefs
    once per minute) and _recoverOrphanedLiveSession (replays checkpoint
    on next connect) to survive process kills
  • Gates morning briefing generation on overnight_state == 'ready' to
    avoid caching a briefing off a still-syncing overnight
  • Adds _alarmAutoRetried flag and _onAlarmGraceElapsed to silently
    re-arm the alarm once before surfacing the unconfirmed warning
  • Adds liveSteps to the context.select tuple in TodayScreen so the steps
    tile updates during a live walk
  • Calls _recoverOrphanedLiveSession() before _resetLivePedometer() on
    connect; clears checkpoint after clean session end
+168/-4 
briefing_engine.dart
Remove stale time-of-day greeting from cached briefing prompt

lib/ai/briefing_engine.dart

  • Removes time-of-day greeting from the system prompt entirely;
    instructs the model to start with substance so a cached briefing read
    hours later doesn't open with a stale greeting
  • Updates partOfDay doc comment to clarify it is generation-time context
    only, not a greeting directive
+11/-6   
today_screen.dart
Include liveSteps in Today screen select to unfreeze step tile

lib/ui/today/today_screen.dart

  • Adds a.liveSteps to the context.select tuple so the Today screen
    rebuilds on live step changes (~1/s) without waiting for an unrelated
    dbCounts change
+10/-10 

Summary by CodeRabbit

  • New Features

    • Live step counts now update more reliably on the Today screen.
    • Interrupted step-tracking sessions can recover automatically.
    • Alarm confirmation includes an automatic silent re-arm.
    • Nap detection better handles periods crossing calendar-day boundaries.
    • AI briefings display greetings separately and begin directly with briefing content.
  • Bug Fixes

    • Prevented incomplete daily results from overwriting baseline metrics.
    • Improved duplicate workout notifications and timezone-change handling.
    • Excluded unsettled daily data from illness and anomaly alerts until finalized.
    • Improved handling of daylight-saving transitions and cross-day calculations.
    • Improved reliability when processing sleep and activity data.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR updates derivation boundaries, unsettled-day analytics, partial-result persistence, briefing prompts, live-step recovery, alarm confirmation, calibration, and TodayScreen live-step updates.

Changes

Core behavior updates

Layer / File(s) Summary
Derivation boundaries and nap substrate
lib/compute/derive_prepare.dart, lib/compute/derivation_engine.dart
Prepared days include buffered nap data. Derivation tracks failures, applies timezone guards and attribution boundaries, and deduplicates workout notifications by bout end time.
Unsettled-day analytics and partial persistence
lib/compute/derivation_engine.dart, lib/compute/crossday_pipeline.dart, lib/data/db.dart, test/crossday_pipeline_test.dart
Current non-finalized results remain in shared inputs but are excluded from illness and anomaly calculations. Partial results no longer update metric series. Regression tests cover unsettled and settled inputs.
Briefing readiness and prompt contract
lib/state/app_state.dart, lib/ai/briefing_engine.dart, test/ai_briefing_test.dart
Morning briefings wait for ready overnight data. Time of day remains user-prompt context. System prompts prohibit greetings and time-of-day references.
Live session recovery and alarm retry
lib/state/app_state.dart, lib/ui/today/today_screen.dart
Live sessions checkpoint and recover orphaned data, preserve displayed steps during transitions, use raw totals for persistence and calibration, and retry alarm confirmation once. TodayScreen observes liveSteps.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: localhoop, dannymcc

Sequence Diagram(s)

sequenceDiagram
  participant AppState
  participant SharedPreferences
  participant LocalStorage
  participant TodayScreen
  AppState->>SharedPreferences: checkpoint committed live-step data
  AppState->>SharedPreferences: recover orphaned checkpoint
  AppState->>LocalStorage: record recovered steps
  AppState->>TodayScreen: update liveSteps
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title is generic and does not identify the nine bug fixes or the primary change in the pull request. Replace “fix bugs” with a concise title that identifies the main fix, such as preserving live steps and correcting derivation and alert handling.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 43d7613)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Boolean latch without reset

_alarmAutoRetried is set to true before the engine.setAlarm(when) call, and only reset to false if !rearmed. However, if _disposed is true after the await, the method returns early without resetting _alarmAutoRetried. If the app is not actually disposed but _disposed was transiently true (or if the logic is extended), the latch stays set permanently for this alarm epoch, preventing any future retry. More concretely: if rearmed is true but _savedAlarm != epoch (a newer alarm was set during the await), the method falls through to notifyListeners() without resetting _alarmAutoRetried, which is correct — but if _disposed is true after a successful re-arm, the timer is never re-armed and _alarmAutoRetried stays latched. This matches the recurring "sticky boolean latch" pattern (§4.3).

_alarmAutoRetried = true;
var rearmed = false;
try {
  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();
Orphan recovery ordering

In openSession, the PR reorders to await _recoverOrphanedLiveSession() then _resetLivePedometer() then await engine.enableLiveStreams(). However, in the background cold-launch branch (_ensureForegroundLease path), the order is await _recoverOrphanedLiveSession() then _resetLivePedometer() — but enableLiveStreams() was already called earlier via engine.connectToRemoteId. This means frames can arrive on the live stream between connectToRemoteId returning and _resetLivePedometer() being called in the background path, which the PR comment says it was trying to fix. The fix is complete for openSession but the background path still has the same race.

await _recoverOrphanedLiveSession();
_resetLivePedometer();
_maybeDowngradeLiveForBackground();
kAlgoVersion not bumped

This PR changes analytics output in multiple ways: nap detection now uses a buffered napSub window (fixing midnight-straddling naps), metric_series is now skipped for partial derive passes, unsettled days are nulled out of illness/anomaly CUSUMs, and the per-bout workout notification dedupeKey changes. All of these alter what gets written to day_result / metric_series / notifications for the same underlying data. Per §3 invariant 4, kAlgoVersion must be bumped whenever analytics output changes — without a bump, existing finalized rows are never recomputed and users with already-derived days will not see the nap fix or the partial-derive baseline fix applied to their history.

// re-derive without a same-day, no-sleep readiness/stress score.
TZ guard uses wall-clock offset

_timezoneTravelSuspected() compares DateTime.now().timeZoneOffset.inMinutes against a persisted baseline. The persisted value is updated at the end of run() only when failures == 0. However, DateTime.now().timeZoneOffset reflects the device's current UTC offset, which changes by ±1h on DST transitions. The threshold is 180 minutes, so DST alone won't trigger it — but a user who travels two time zones (e.g. UTC+1 → UTC+3, a 120-minute jump) will NOT trigger the guard (120 < 180), while the comment says ">=3h jump". The 180-minute threshold may be too conservative for real travel scenarios that are less than 3 hours but still cause day-label mismatches (e.g. UTC-5 → UTC-7 is only 120 min). This is a design choice but worth flagging since the stated intent ("real cross-timezone travel") and the threshold don't fully align.

static const int _tzJumpThresholdMin = 180;
Future<bool> _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
    }
  }
  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;
}

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 43d7613

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Non-idempotent timezone guard baseline update

_timezoneTravelSuspected() unconditionally updates the persisted baseline offset
when no jump is detected, meaning it is called during normal (non-force) derive runs
and silently advances the stored offset. If a timezone jump happens between two
normal derive runs, the second normal run will see lastOffset == nowOffset (because
the first run already updated it) and return false, dropping the guard before any
full restage has run. The baseline should only be updated at the end of a successful
full restage (in run()), not inside _timezoneTravelSuspected() on the non-jump path.

lib/compute/derivation_engine.dart [1508-1514]

-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,
-        }),
-      );
+Future<bool> _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
+    }
+  }
+  if (lastOffsetMin == null) {
+    await LocalDb.putBaseline(
+      'tz_travel_guard',
+      jsonEncode({'offset_min': nowOffsetMin}),
+    );
+    return false;
+  }
+  final jumped = (nowOffsetMin - lastOffsetMin).abs() >= _tzJumpThresholdMin;
+  // Do NOT update the stored offset here on the non-jump path — doing so
+  // would advance the baseline on every normal derive run, so a jump
+  // detected on run N would be silently cleared by run N+1 before any
+  // full restage has resolved it. The baseline is only advanced by a
+  // successful full restage (run() / runDays() with force+full-coverage).
+  return jumped;
+}
Suggestion importance[1-10]: 7

__

Why: This is a valid and important concern: _timezoneTravelSuspected() updates the persisted baseline on every non-jump call, which means a timezone jump detected on one derive run could be silently cleared by the very next normal derive run before a full restage resolves it. Removing the baseline update from the non-jump path in _timezoneTravelSuspected() and relying solely on the end-of-restage update in run()/runDays() would make the guard more robust.

Medium
Stale metric values persist after partial re-derive

Skipping metric_series writes for a partial row means that if a day was previously
fully derived (with valid metric_series rows) and then re-derived as partial (e.g.
second-half compute timed out), the old valid metric values remain in metric_series
and will be read as if they are current. This is actually worse than writing the
partial values: the baseline reads stale data from a prior algo version or prior
data window, silently. The intent should be to not SEED new metric_series rows for a
brand-new partial day, but to still overwrite if rows already exist — or
alternatively, to delete existing rows for this day when partial, so the baseline
abstains rather than reading stale data.

lib/data/db.dart [2737-2745]

 if (!partial) {
   for (final e in series.entries) {
     await txn.insert('metric_series', {
       'date': dayId,
       'key': e.key,
       'value': e.value,
     }, conflictAlgorithm: ConflictAlgorithm.replace);
   }
+} else {
+  // Partial row: remove any previously-written metric_series rows for
+  // this day so the baseline reads null/abstains rather than stale data
+  // from a prior successful pass.
+  await txn.delete(
+    'metric_series',
+    where: 'date = ?',
+    whereArgs: [dayId],
+  );
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion raises a valid edge case: if a day was previously fully derived and then re-derived as partial, the old metric_series rows remain and will be read as current data. Deleting existing rows for a partial day would ensure the baseline abstains rather than reading stale values, which is safer behavior. However, this scenario (a previously successful day re-deriving as partial) may be uncommon in practice.

Low
Reset boolean latch on disposed path

If engine.setAlarm(when) throws an exception, rearmed stays false but
_alarmAutoRetried is reset to false in the if (!rearmed) check below — so the retry
IS correctly given back on exception. However, if _disposed is true when we enter
_onAlarmGraceElapsed, we return early before resetting _alarmAutoRetried, leaving it
permanently true for any subsequent alarm set on a re-initialized state. The
_alarmAutoRetried = false reset in setAlarm() only fires for a fresh user-initiated
set, but if _disposed was briefly true and then the object is reused (or a new alarm
is set before _disposed is checked), the latch stays stuck. The _alarmAutoRetried
flag should be reset on the failure/disposed path at the top of
_onAlarmGraceElapsed.

lib/state/app_state.dart [2606-2616]

-_alarmAutoRetried = true;
-var rearmed = false;
-try {
-  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;
+Future<void> _onAlarmGraceElapsed(DateTime when) async {
+  if (_disposed || _alarm.confirmed) return;
+  final epoch = when.millisecondsSinceEpoch ~/ 1000;
+  if (_savedAlarm != epoch) return;
+  if (_alarmAutoRetried || !isConnected) {
+    notifyListeners();
+    return;
+  }
+  _alarmAutoRetried = true;
+  var rearmed = false;
+  try {
+    rearmed = await engine.setAlarm(when);
+  } catch (e) {
+    _log('[alarm] auto-retry re-arm failed: $e');
+  }
+  if (!rearmed) _alarmAutoRetried = false;
+  if (_disposed) {
+    _alarmAutoRetried = false; // reset latch — object may be reused
+    return;
+  }
Suggestion importance[1-10]: 3

__

Why: The concern about _disposed leaving _alarmAutoRetried stuck is largely theoretical — AppState is a ChangeNotifier that is not typically reused after disposal, and setAlarm() already resets _alarmAutoRetried = false on any fresh user-initiated set. The scenario described (object reused after disposal) is not a realistic code path in this codebase.

Low

Previous suggestions

Suggestions up to commit bd4c59d
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix durable-first ordering in orphan checkpoint recovery

The checkpoint is removed from prefs (await prefs.remove(...)) before the coverage
window is written to the DB. If the process is killed in the gap between those two
operations, the checkpoint is gone and the steps are lost — the exact scenario this
recovery is meant to prevent. Remove the checkpoint only after addLiveCoverage
succeeds (durable-first ordering, same as the commit-before-ACK invariant the
codebase enforces elsewhere).

lib/state/app_state.dart [2071-2111]

 Future<void> _recoverOrphanedLiveSessionOnce() 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;
     ...
     if (await LocalDb.hasLiveCoverageWindow(window.startTs, window.endTs)) {
       _log('[steps] orphan checkpoint already banked — not re-adding');
+      await prefs.remove(_kLiveSessionCheckpoint);
       return;
     }
     final day = dayLabelOf(
       DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000),
     );
     await LocalDb.addLiveCoverage(window.startTs, window.endTs, steps, day);
+    await prefs.remove(_kLiveSessionCheckpoint);
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that removing the checkpoint from prefs before writing to the DB violates the durable-first ordering principle. If the process is killed between prefs.remove and addLiveCoverage, the steps are permanently lost — the exact scenario the recovery mechanism is designed to prevent. This is a real correctness issue, though the window is narrow.

Medium
Guard notifyListeners against disposed state on retry failure

When rearmed is false (the write failed), _alarmAutoRetried is reset to false so the
retry slot is returned. However, the code then falls through to notifyListeners()
without first checking _disposed, meaning it can call notifyListeners() on a
disposed ChangeNotifier and throw. The _disposed guard that exists for the rearmed
== true branch is missing for the failure path.

lib/state/app_state.dart [2607-2619]

 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();
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that when rearmed is false, the _disposed check at line 2612 is skipped because it comes after the if (!rearmed) _alarmAutoRetried = false block but the code falls through to notifyListeners() without re-checking _disposed. Moving the _disposed guard before the notifyListeners() call on the failure path prevents calling notifyListeners() on a disposed ChangeNotifier.

Low
General
Remove stale metric_series rows when a day re-derives as partial

Skipping metric_series writes for a partial day means that if a previously
successful (non-partial) day later re-derives as partial (e.g. due to a timeout on
the second-half compute), the old correct values remain in metric_series and are
silently used by tomorrow's readiness/illness baseline — which is the right
behavior. However, if a day's FIRST-EVER derivation is partial, metric_series gets
no row at all, so the baseline reads a gap instead of abstaining cleanly. This is
acceptable per the codebase's "never fabricate" rule, but the existing rows from a
prior non-partial pass are also never cleaned up on a partial re-derive, which is
the intended behavior and is correct as written. No change needed here — this note
is informational only. Actually, the real issue is that a partial re-derive of a
previously non-partial day leaves stale metric_series rows that may now be wrong
(e.g. the second-half compute that failed is exactly what produced those values).
Those stale rows should be deleted when a partial result replaces a non-partial one.

lib/data/db.dart [2737-2745]

 if (!partial) {
   for (final e in series.entries) {
     await txn.insert('metric_series', {
       'date': dayId,
       'key': e.key,
       'value': e.value,
     }, conflictAlgorithm: ConflictAlgorithm.replace);
   }
+} else {
+  // A partial re-derive must not leave stale metric_series rows from a
+  // prior successful pass — those values came from a compute that is now
+  // known to have failed/timed out, so they are no longer trustworthy.
+  await txn.delete('metric_series', where: 'date = ?', whereArgs: [dayId]);
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion is self-contradictory — it first argues no change is needed, then proposes deleting metric_series rows on partial re-derives. The PR's intent is explicitly to avoid writing partial data to metric_series, and leaving prior non-partial values intact is the correct behavior (they came from a successful pass). Deleting them on a partial re-derive would make the baseline read a gap instead of the last known good value, which is worse.

Low
Suggestions up to commit 03a9980
CategorySuggestion                                                                                                                                    Impact
Possible issue
Timezone guard baseline reset too early defeats the hold

When jumped is true the baseline is intentionally NOT updated (so repeated calls
keep returning true until a force restage resets it). However, the force path in
_deriveScope writes the baseline unconditionally with
DateTime.now().timeZoneOffset.inMinutes BEFORE calling _timezoneTravelSuspected,
meaning the very next non-force derive call will see lastOffset == nowOffset, jumped
will be false, and the guard silently clears itself without a full restage having
actually run yet — defeating the hold. The baseline update in the force branch
should be moved to AFTER the restage completes (i.e. after _derivePreparedDay
finishes), not at scope-selection time.

lib/compute/derivation_engine.dart [1454-1468]

-if (lastOffsetMin == null) {
-  await LocalDb.putBaseline(
-    'tz_travel_guard',
-    jsonEncode({'offset_min': nowOffsetMin}),
-  );
-  return false;
+// Move this putBaseline call to after derivation completes, not at scope
+// selection time — writing it here means the very next non-force call sees
+// lastOffset == nowOffset and clears the guard before restage finishes.
+//
+// In _deriveScope, remove the putBaseline from the force branch:
+if (force) {
+  return _scopeForDays(rawDays, reason: 'full-history', fullHistory: true);
 }
-final jumped = (nowOffsetMin - lastOffsetMin).abs() >= _tzJumpThresholdMin;
-if (!jumped) {
-  await LocalDb.putBaseline(
-    'tz_travel_guard',
-    jsonEncode({'offset_min': nowOffsetMin}),
-  );
-}
-return jumped;
+// Then after _derivePreparedDay / the full restage loop completes, write:
+// await LocalDb.putBaseline(
+//   'tz_travel_guard',
+//   jsonEncode({'offset_min': DateTime.now().timeZoneOffset.inMinutes}),
+// );
Suggestion importance[1-10]: 7

__

Why: This is a valid logical issue: the force branch in _deriveScope writes the tz_travel_guard baseline at scope-selection time (before derivation runs), so the very next non-force call would see lastOffset == nowOffset and clear the guard prematurely. The suggestion correctly identifies the race condition, though the improved_code is more of a comment/pseudocode than a concrete fix.

Medium
Guard disposed state after async throw on alarm retry

When engine.setAlarm(when) throws, rearmed stays false and _alarmAutoRetried is
reset to false — but the exception path does not call notifyListeners() before
returning, so the UI never updates to show the unconfirmed warning. After the failed
retry the method falls through to the final notifyListeners() only if rearmed is
true and the re-arm branch is not taken, but on the exception path execution
continues past the if (!rearmed) reset and reaches the bottom notifyListeners()
however if _disposed is true at that point the guard above already returned. Verify
the _disposed check after the await covers the exception path correctly; currently a
throw leaves _alarmAutoRetried = true momentarily before the reset, which is fine,
but the _disposed guard after the await is only reached on the non-throw path, so a
throw while _disposed is true will still call notifyListeners() on a torn-down
object.

lib/state/app_state.dart [2574-2587]

 if (_alarmAutoRetried || !isConnected) {
   notifyListeners();
   return;
 }
 _alarmAutoRetried = true;
 var rearmed = false;
 try {
   rearmed = await engine.setAlarm(when);
 } catch (e) {
   _log('[alarm] auto-retry re-arm failed: $e');
 }
 if (!rearmed) _alarmAutoRetried = false;
+if (_disposed) return;
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that after a catch block, _disposed is not checked before continuing execution. However, looking at the actual code flow, after the catch block the code does reach the if (_disposed) return; guard at line 2591 on both throw and non-throw paths (since try/catch doesn't exit the function), so the concern about the throw path missing the _disposed guard is partially mitigated. Still, the suggestion to add an early _disposed guard after the await is a valid defensive improvement.

Low
General
Prevent double-reset of live pedometer on BLE restore

_recoverOrphanedLiveSession() removes the checkpoint from prefs and writes a
live_coverage row, then _resetLivePedometer() zeroes all counters — this ordering is
correct for the openSession path. However, in the background-restore path added
above (IosBleRestore branch), _recoverOrphanedLiveSession() and
_resetLivePedometer() are called BEFORE enableLiveStreams(), which is correct, but
_resetLivePedometer() is called a second time later via
_maybeDowngradeLiveForBackground() only if that method internally resets — verify
_maybeDowngradeLiveForBackground does not call _resetLivePedometer itself, which
would wipe the freshly-started counters and lose the first partial minute of the new
session.

lib/state/app_state.dart [2833-2835]

 await _recoverOrphanedLiveSession();
 _resetLivePedometer(); // fresh live step count for this connected session
 await engine.enableLiveStreams();
+// NOTE: _maybeDowngradeLiveForBackground must NOT call _resetLivePedometer
+// internally — counters are already zeroed for this session above.
Suggestion importance[1-10]: 2

__

Why: The suggestion asks to verify that _maybeDowngradeLiveForBackground doesn't call _resetLivePedometer internally, but the improved_code is essentially the same as existing_code with just a comment added. This is a verification request rather than a concrete fix, and the improved_code doesn't reflect a real code change.

Low
Suggestions up to commit ffea4a0
CategorySuggestion                                                                                                                                    Impact
Possible issue
Prevent re-derivation of finalized days under timezone guard

When _timezoneTravelSuspected() returns true and all pending days are adjacent to
finalized data, pending becomes empty and the code falls through to
_scopeForDays([rawDays.last], reason: 'latest-finalized-check'), which re-derives
the most recent finalized day. This is exactly the non-idempotent re-derivation the
timezone guard is meant to prevent — a finalized day gets re-derived against a
shifted timezone baseline on every pass until the user manually triggers a full
restage. After filtering, if pending is empty the function should return a no-op
scope rather than falling through.

lib/compute/derivation_engine.dart [1378-1390]

 if (await _timezoneTravelSuspected()) {
   final adjacent = <String>{
     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 const _DeriveScope(
+    fullHistory: false,
+    targetDays: [],
+    reason: 'tz-guard-held',
+  );
+}
Suggestion importance[1-10]: 7

__

Why: When the timezone guard filters all pending days, the existing if (pending.isEmpty) check at line 1391 already handles the empty case by returning _scopeForDays([rawDays.last], reason: 'latest-finalized-check'). The suggestion correctly identifies that this fallthrough re-derives the last finalized day, which is exactly what the guard is meant to prevent. Adding an early return with a no-op scope after the guard block would fix this behavioral gap.

Medium
Reset boolean latch on auto-retry failure path

If engine.setAlarm(when) returns false (the strap rejected the write), the code
falls through to notifyListeners() without resetting _alarmAutoRetried. This is the
sticky-boolean-latch pattern (AGENTS.md §4.3): _alarmAutoRetried stays true, so if
the user manually re-sends the alarm and the grace window elapses again,
_onAlarmGraceElapsed skips the retry entirely and goes straight to the warning — the
one retry the user's fresh set was supposed to get is silently consumed by the
failed auto-retry. Reset _alarmAutoRetried = false on the failure path so the next
user-initiated set gets its own retry.

lib/state/app_state.dart [2548-2566]

 _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');
 }
+_alarmAutoRetried = false;
 notifyListeners();
Suggestion importance[1-10]: 6

__

Why: The analysis is logically sound: if setAlarm returns false or throws, _alarmAutoRetried stays true, consuming the retry budget for the next user-initiated alarm set. However, the comment at line 2525 says _alarmAutoRetried = false is reset on each fresh user-initiated set (_alarmAutoRetried = false; // fresh user-initiated set gets its one retry), which partially mitigates the issue — the latch is only sticky within a single alarm-set lifecycle, not across user re-sends.

Low
Use day-label helper instead of manual UTC string

The orphaned session recovery computes the day label using raw DateTime string
arithmetic instead of dayLabelOf() from data/day_label.dart. Per AGENTS.md §3
invariant 7, day labels must always use todayLabel() / dayLabelOf() — the manual
string construction is the exact pattern flagged as a recurring UTC-vs-local bug
(§4.8), and will mis-date sessions that cross midnight or occur in non-UTC
timezones. Replace the manual construction with dayLabelOf(d).

lib/state/app_state.dart [2060-2066]

-final day =
-    '${d.year.toString().padLeft(4, '0')}-'
-    '${d.month.toString().padLeft(2, '0')}-'
-    '${d.day.toString().padLeft(2, '0')}';
+final day = dayLabelOf(d);
 await LocalDb.addLiveCoverage(window.startTs, window.endTs, steps, day);
 _log('[steps] recovered $steps orphaned step(s) from a killed session');
Suggestion importance[1-10]: 5

__

Why: The manual string construction for day in _recoverOrphanedLiveSession mirrors the same pattern used elsewhere in the file (e.g., _finalizeLivePedometer), so it's not uniquely wrong here. However, if a dayLabelOf() helper exists and is the canonical approach per project conventions, using it would be more consistent and safer. The impact is moderate since timezone-adjacent midnight sessions could be mislabeled.

Low
Suggestions up to commit 487194d
CategorySuggestion                                                                                                                                    Impact
Possible issue
Timezone hold bypassed when all pending days are held

When _timezoneTravelSuspected() returns true and ALL pending days are adjacent to
finalized data (i.e., held equals pending), pending becomes empty and the method
falls through to _scopeForDays([rawDays.last], reason: 'latest-finalized-check')
which re-derives the last raw day anyway, defeating the hold. The guard should also
return early (or return an empty scope) when all pending days are held, not just
when pending was already empty before the guard ran.

lib/compute/derivation_engine.dart [1378-1390]

 if (await _timezoneTravelSuspected()) {
       final adjacent = <String>{
         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 const _DeriveScope(
+        fullHistory: false,
+        targetDays: [],
+        reason: 'tz-travel-hold',
+      );
+    }
Suggestion importance[1-10]: 7

__

Why: This is a valid logic bug — when all pending days are held due to timezone travel suspicion, pending becomes empty and falls through to _scopeForDays([rawDays.last], ...) which re-derives the last raw day, defeating the hold. The fix adds a proper early return with a dedicated reason, though the improved_code duplicates the existing pending.isEmpty check rather than replacing it.

Medium
Day label uses raw formatting instead of helper

The orphaned session recovery in _recoverOrphanedLiveSession constructs the day
label manually from a DateTime using raw string interpolation, violating the
invariant that day labels must always use dayLabelOf() from data/day_label.dart.
This is the same UTC-vs-local bug pattern documented in §4.8 —
DateTime.fromMillisecondsSinceEpoch is local by default but the manual formatting is
fragile and inconsistent with the rest of the codebase.

lib/state/app_state.dart [2060-2066]

 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(d);
       await LocalDb.addLiveCoverage(window.startTs, window.endTs, steps, day);
       _log('[steps] recovered $steps orphaned step(s) from a killed session');
Suggestion importance[1-10]: 4

__

Why: The suggestion raises a valid consistency concern about using raw string formatting for day labels instead of a helper function. However, the same manual formatting pattern is used in _finalizeLivePedometer (line 1962-1966) in the same PR without being flagged, and it's unclear if dayLabelOf() exists in the codebase as described.

Low
Boolean latch not reset on failed retry path

When engine.setAlarm(when) returns false (not an exception), _alarmAutoRetried is
set to true but notifyListeners() is never called — the UI stays stuck showing the
old state with no warning. The notifyListeners() at the bottom of the method is only
reached after the catch block, not after a false return from inside the try. Add a
notifyListeners() call (or a return that falls through to it) on the !ok path inside
the try block.

lib/state/app_state.dart [2542-2567]

+Future<void> _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();
+  }
 
-
Suggestion importance[1-10]: 3

__

Why: The suggestion claims notifyListeners() is never called when setAlarm returns false, but looking at the code, the notifyListeners() at the bottom of the method IS reached after a false return from inside the try block (since there's no return on the !ok path). The improved_code is identical to the existing_code, making this suggestion incorrect.

Low

@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Went through the bot findings:

  • TZ guard resetting itself every call (real bug — pushed a fix, only bootstraps/updates the baseline when NOT jumped, so the hold actually persists until `force`/Re-analyze runs).
  • Orphan recovery raced against pedometer reset (checked — the recovery path only reads the SharedPreferences checkpoint, not the in-memory counters, so no double-count was possible, but awaited it anyway instead of unawaited for clarity).
  • Alarm retry latch not reset on failure — checked every call site that touches `_alarmGraceTimer`; nothing re-arms it except `setAlarm()` itself (which already resets the latch) and this function's own success branch. The described "second grace-elapsed path" isn't reachable as written, so left as-is.
  • Day-label helper in orphan recovery — same inline pattern already used in `_finalizeLivePedometer` right above it; not touching that in this PR.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 487194d

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/data/db.dart (1)

2678-2731: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Narrow the partial gate to only the metric_series keys the second half actually produces.

partial is set when only the offloaded second-half compute (naps/workouts/HRR/wear/curves/wake-features) failed or timed out. The first-isolate scalars — rhr, rmssd, sdnn, readiness, ln_rmssd, resp_rate, skin_temp_z, skin_temp_adc, dip_pct, trimp, strain_effort, odi_per_hour, cpc_ratio, stress, spo2 — are computed before the second half even runs and are never touched by it (see _computeDayBlocks in derivation_engine.dart, which only patches wake features, steps/energy, naps, sleep periods, workouts, wrist orientation, restlessness, and fit quality).

Gating the whole series map on !partial withholds these already-correct values from metric_series — the table trend charts and trailingSeriesValues (the rolling readiness/illness baseline input, see derivation_engine.dart) read from — for as long as the day stays partial. A recurring transient failure (e.g., a consistently slow device) can keep a day partial across multiple passes, creating real gaps in trend history and in the baseline other days' readiness/illness scores are computed against, even though the metrics being dropped were never incomplete.

♻️ Proposed fix: only withhold second-half-dependent keys when partial
       }, 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) {
+      // Second-half-dependent keys only: a `partial` row's headline scalars
+      // (rhr/rmssd/readiness/etc.) are already correct — only the values the
+      // offloaded second half produces (naps/workouts/wear/steps/curves) are
+      // suspect when partial, so only those are withheld.
+      const secondHalfOnlyKeys = {
+        'nap_min', 'hrr_bpm', 'steps', 'active_min', 'strain', 'calories',
+        'calories_total', 'worn_min', 'rem_min', 'deep_min', 'light_min',
+        'tst_min', 'efficiency', 'lf_hf', 'hrv_cv', 'irregular_rhythm_flag',
+        'brv_cv', 'dyn_p90',
+      };
+      for (final e in series.entries) {
+        if (!partial || !secondHalfOnlyKeys.contains(e.key)) {
           await txn.insert('metric_series', {
             'date': dayId,
             'key': e.key,
             'value': e.value,
           }, conflictAlgorithm: ConflictAlgorithm.replace);
         }
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/data/db.dart` around lines 2678 - 2731, Update putDayResult so partial
only suppresses metric_series entries produced by the second-half computation,
while still persisting first-isolate scalar keys such as rhr, rmssd, readiness,
and the other listed baseline metrics. Filter series entries by the
second-half-dependent key set before inserting into metric_series; retain normal
insertion for all keys when partial is false.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/compute/derivation_engine.dart`:
- Around line 1407-1446: Update _timezoneTravelSuspected so it does not
overwrite the persisted tz_travel_guard baseline when the offset change meets
_tzJumpThresholdMin; retain the prior baseline while returning true, and refresh
it only when no jump is detected. Preserve the existing first-observation
behavior and ensure subsequent derive passes continue reporting the travel
suspicion until the baseline is explicitly refreshed.
- Around line 2353-2364: Update buildCrossDayBundle so today’s unfinalized row
remains available to days/recent and readiness_glassbox processing, while
excluding or replacing only its input to the illness and anomaly CUSUM
calculations. Adjust the shared filtering around _runIsolateCancellable and the
downstream illness/anomaly builders without changing finalized-day behavior.

In `@lib/state/app_state.dart`:
- Around line 2059-2065: Replace the manually constructed day string in the live
coverage block with the existing dayLabelOf() helper, passing the appropriate
window timestamp. Remove the local DateTime and string-building logic while
preserving the existing LocalDb.addLiveCoverage call and its timestamp
arguments.
- Around line 2798-2800: Ensure _recoverOrphanedLiveSession() runs during the
background cold-launch path in _init() after a successful
engine.connectToRemoteId call and before starting backfill work, while
preserving the existing full-connect recovery. Also invoke recovery before
openSession() returns through its fast-reclaim path so orphaned checkpoints are
folded into live_coverage for every launch route.
- Around line 2538-2568: Update _onAlarmGraceElapsed after await
engine.setAlarm(when) to return immediately when _disposed is true or when the
current alarm no longer matches the retry’s when value. Perform both guards
before calling _alarm.set, cancelling or scheduling _alarmGraceTimer, or
notifying listeners, so an in-flight retry cannot recreate timers or overwrite
newer alarm state.
- Around line 1830-1839: Add a raw, uncushioned step-count accessor alongside
liveSteps, preserving the existing _liveRaw gain-and-rounding calculation.
Update _finalizeLivePedometer and finishStepCalibration to use this raw accessor
for LocalDb.addLiveCoverage persistence and ana.calibrateCadence input, while
retaining liveSteps only for display-related reads.
- Around line 2525-2568: Extract the duplicated grace-timer construction into a
helper named _armGraceTimer(DateTime when), preserving the existing duration and
unawaited _onAlarmGraceElapsed(when) callback. Replace the inline Timer blocks
in setAlarm and _onAlarmGraceElapsed with calls to _armGraceTimer(when).
- Around line 1953-1956: Update the cushion assignment in the session-end logic
so a new positive steps value cannot replace an existing active cushion with a
smaller value; retain the larger cushion and its original timestamp, while still
initializing or updating the cushion when the new value is greater or no active
cushion exists.

---

Outside diff comments:
In `@lib/data/db.dart`:
- Around line 2678-2731: Update putDayResult so partial only suppresses
metric_series entries produced by the second-half computation, while still
persisting first-isolate scalar keys such as rhr, rmssd, readiness, and the
other listed baseline metrics. Filter series entries by the
second-half-dependent key set before inserting into metric_series; retain normal
insertion for all keys when partial is false.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3584f50b-de2a-48a4-9fa1-8c7600aa9848

📥 Commits

Reviewing files that changed from the base of the PR and between 5faa4b0 and 6930a3a.

📒 Files selected for processing (6)
  • lib/ai/briefing_engine.dart
  • lib/compute/derivation_engine.dart
  • lib/compute/derive_prepare.dart
  • lib/data/db.dart
  • lib/state/app_state.dart
  • lib/ui/today/today_screen.dart

Comment thread lib/compute/derivation_engine.dart
Comment thread lib/compute/derivation_engine.dart
Comment thread lib/state/app_state.dart
Comment thread lib/state/app_state.dart
Comment thread lib/state/app_state.dart Outdated
Comment thread lib/state/app_state.dart
Comment thread lib/state/app_state.dart
Comment thread lib/state/app_state.dart Outdated
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit ffea4a0

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
lib/compute/derivation_engine.dart (3)

1407-1419: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use dayLabelOf() for adjacent local-day labels.

The local label() function duplicates local day-label formatting. Use dayLabelOf() for both adjacent dates.

Proposed fix
-    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)),
+      dayLabelOf(DateTime(d.year, d.month, d.day - 1)),
+      dayLabelOf(DateTime(d.year, d.month, d.day + 1)),
     ];

As per coding guidelines, lib/**/*.dart: “Use todayLabel() or dayLabelOf() from data/day_label.dart for local day labels; do not derive labels from UTC strings.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/derivation_engine.dart` around lines 1407 - 1419, Update
_adjacentDayIds to use the existing dayLabelOf() helper for both DateTime values
instead of the local label() formatter, while preserving the current DST-safe
adjacent-date calculation and invalid-input behavior.

Source: Coding guidelines


3721-3729: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Bump the analytics version for the changed nap attribution.

This changes persisted sleep_periods, naps, and nap_min. Existing finalized rows remain on the old attribution logic because they are keyed by the unchanged kAlgoVersion. Bump kAlgoVersion, add its changelog entry, and add a regression test for a nap that starts before midnight and ends after midnight.

As per coding guidelines, lib/compute/derivation_engine.dart: “Bump kAlgoVersion and add a changelog entry whenever analytics output changes.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/derivation_engine.dart` around lines 3721 - 3729, The nap
attribution change in the derivation flow alters persisted sleep analytics, so
update kAlgoVersion in derivation_engine.dart and add the corresponding
changelog entry. Add a regression test covering a nap that starts before
midnight and ends after midnight, verifying the updated sleep_periods, naps, and
nap_min attribution.

Source: Coding guidelines


3721-3729: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Use one nap-segmentation result for both outputs.

_sleepPeriods() and _attachNaps() classify naps with different algorithms. They can produce conflicting sleep_periods, naps, and nap_min values for the same buffered substrate. Derive one attributed nap set, then use it for both output blocks.

As per coding guidelines, lib/**/*.dart: “Maintain one source per concern: one raw decode point, sleep segmentation path, readiness path, frame-ingest path, and notification emitter.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/derivation_engine.dart` around lines 3721 - 3729, Update the nap
handling around _sleepPeriods and _attachNaps to derive a single attributed
nap-segmentation result from inp.napSub, onset, offset, and attributionEndSec,
then reuse that result for both bundlePatch['sleep_periods'] and the nap
attachment output. Remove the competing classification path so sleep_periods,
naps, and nap_min consistently reflect the same attributed nap set.

Source: Coding guidelines

lib/state/app_state.dart (1)

2042-2065: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Keep valid orphan checkpoints until durable recovery succeeds.

Live frames can repeat the same recTs, so endTs == startTs is valid input for deriveLiveCoverageWindow(). The current endTs <= startTs check drops these recoverable sessions before that function can use the ingest-time bounds.

The code also removes the checkpoint before LocalDb.addLiveCoverage() completes. If that write fails, the catch block logs the error and permanently loses the steps. Accept equal timestamps, persist with an idempotent recovery key, and clear the checkpoint only after successful persistence. Add tests for equal timestamps and a failed persistence retry.

As per coding guidelines, test/**/*.dart: “Behavior changes, especially regressions involving readiness, abstention, idempotence, synchronization, migrations, and lifecycle safety, must include regression tests.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 2042 - 2065, Update the checkpoint
recovery flow around deriveLiveCoverageWindow and LocalDb.addLiveCoverage to
allow endTs == startTs, preserving only the invalid endTs < startTs case so
ingest-time bounds can recover repeated recTs sessions. Persist recovery
idempotently using a stable checkpoint/recovery key, and move removal of
_kLiveSessionCheckpoint until after addLiveCoverage completes successfully so
failures retain the checkpoint for retry. Add regression tests covering equal
timestamps and persistence failure followed by successful retry.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/state/app_state.dart`:
- Around line 2797-2803: Reorder the live-session initialization so
_recoverOrphanedLiveSession() and _resetLivePedometer() complete before
engine.enableLiveStreams() begins delivering frames, or otherwise gate ingestion
until initialization finishes. Add a regression test that injects a live frame
while recovery is pending and verifies the frame is not cleared or
misattributed.

---

Outside diff comments:
In `@lib/compute/derivation_engine.dart`:
- Around line 1407-1419: Update _adjacentDayIds to use the existing dayLabelOf()
helper for both DateTime values instead of the local label() formatter, while
preserving the current DST-safe adjacent-date calculation and invalid-input
behavior.
- Around line 3721-3729: The nap attribution change in the derivation flow
alters persisted sleep analytics, so update kAlgoVersion in
derivation_engine.dart and add the corresponding changelog entry. Add a
regression test covering a nap that starts before midnight and ends after
midnight, verifying the updated sleep_periods, naps, and nap_min attribution.
- Around line 3721-3729: Update the nap handling around _sleepPeriods and
_attachNaps to derive a single attributed nap-segmentation result from
inp.napSub, onset, offset, and attributionEndSec, then reuse that result for
both bundlePatch['sleep_periods'] and the nap attachment output. Remove the
competing classification path so sleep_periods, naps, and nap_min consistently
reflect the same attributed nap set.

In `@lib/state/app_state.dart`:
- Around line 2042-2065: Update the checkpoint recovery flow around
deriveLiveCoverageWindow and LocalDb.addLiveCoverage to allow endTs == startTs,
preserving only the invalid endTs < startTs case so ingest-time bounds can
recover repeated recTs sessions. Persist recovery idempotently using a stable
checkpoint/recovery key, and move removal of _kLiveSessionCheckpoint until after
addLiveCoverage completes successfully so failures retain the checkpoint for
retry. Add regression tests covering equal timestamps and persistence failure
followed by successful retry.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1de7ab69-01f7-4eb6-b7e6-2372664cc95f

📥 Commits

Reviewing files that changed from the base of the PR and between 6930a3a and ffea4a0.

📒 Files selected for processing (4)
  • lib/ai/briefing_engine.dart
  • lib/compute/derivation_engine.dart
  • lib/state/app_state.dart
  • test/ai_briefing_test.dart

Comment thread lib/state/app_state.dart Outdated
…lert scoping

- 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.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Went through the remaining bot findings against current main+branch code — here's what was still open and is now fixed in 03a9980, plus what I'm declining and why.

Fixed

  • liveSteps cushion leaked into persistence/calibration (CodeRabbit, Critical) — confirmed real. _finalizeLivePedometer and finishStepCalibration both read the cushioned getter, so a short session ending inside the prior session's 20 s grace window persisted the prior total again → double-counted live_coverage + a nonsense cadence into the calibration model. Added _rawSessionSteps and used it at both persistence sites; liveSteps is now display-only.
  • Cushion overwrite could regress the display — same block, fixed: the cushion never moves down.
  • Orphan recovery never ran on a headless cold launch — confirmed. A process kill + iOS BLE-restore relaunch takes _init()'s _background branch, not openSession(), so checkpointed steps sat in prefs forever — the exact scenario the checkpoint exists for. Recovery now runs there too.
  • Recover before enabling live streams — reordered: recovery + _resetLivePedometer() now run before enableLiveStreams(), closing the window where a frame ingested during the awaited recovery was wiped by the reset behind it.
  • Alarm retry hardening — added the _disposed guard after the await (it was creating a timer dispose() could no longer cancel), a staleness check so a retry in flight can't clobber a newly-armed alarm's confirmation state, gave the retry back when the write itself never landed, and extracted the duplicated grace-timer wiring into _armAlarmGraceTimer.
  • TZ hold defeated when all pending days are held — confirmed: pending emptied and fell straight through to latest-finalized-check, which re-derives rawDays.last — one of the days just held. Returns an empty tz-travel-hold scope now.
  • Today's exclusion was too broad (CodeRabbit, Major) — confirmed, and worse than described. days is the single input list for the whole bundle, so dropping today also removed it from readiness_glassbox, the resting-HR trend-shift CUSUM, CTL/ATL/TSB, sleep debt, _crossDaySri, todayStrain, and recent — whose last row is what _runNotifications dates every notification from. Today's row is now flagged unsettled rather than dropped, and buildCrossDayBundle nulls only the illness / anomaly / skin-temp-illness inputs for a flagged day. Three regression tests added in crossday_pipeline_test.dart.
  • dayLabelOf() used at both live-coverage write sites (the pre-existing _finalizeLivePedometer one too, not just the new code).

Already fixed on the branch, no action

  • Sticky TZ guard (487194d), and kAlgoVersion — already bumped to 51, which covers this round's output changes too.

Declined

  • Orphan recovery on openSession()'s fast-reclaim path — deliberately not added. Fast reclaim is a foreground resume of a live process with an ongoing session; the checkpoint there belongs to the session still accumulating in memory. Recovering it would double-count against the in-flight counters, which is the opposite of the bug. Recovery is correct only where counters start fresh (full connect, background cold launch) — both now covered.
  • PR Agent's "notifyListeners() never called on !ok" — incorrect; there's no return on that path, so it already fell through. Its own scoring notes the suggested code is identical to the existing code.
  • PR Agent's "TZ guard writes inside an isolate"_deriveScope runs on the main isolate; LocalDb calls there are correct and consistent with the rest of the file.

flutter analyze clean, flutter test 1058 passing (was 1055).

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 03a9980

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/state/app_state.dart (1)

1961-1994: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Clear the checkpoint before, not after, the durable write — avoid double-counting on a kill.

_finalizeLivePedometer writes to LocalDb.addLiveCoverage and only clears _kLiveSessionCheckpoint afterward (Line 1985-1994). If the process is killed between the addLiveCoverage write completing and _clearLiveSessionCheckpoint finishing, the stale checkpoint survives. On the next launch, _recoverOrphanedLiveSession (Line 2059-2090) reads that same checkpoint and calls addLiveCoverage again with the same window and step count — double-counting those steps into the day's total and into the cadence-calibration input.

_recoverOrphanedLiveSession already uses the safer order (clears the checkpoint, Line 2064, before calling addLiveCoverage, Line 2085) — a kill in that window loses a few steps rather than duplicating them. Make _finalizeLivePedometer consistent with that ordering so a kill in either function fails toward "lose a little" rather than "double-count."

🐛 Proposed fix
     _resetLivePedometer();
+    // The session ended cleanly — drop the checkpoint BEFORE writing
+    // live_coverage. A kill in this narrow window then loses this bout's
+    // steps rather than double-counting them on the next recovery pass.
+    await _clearLiveSessionCheckpoint();
     // 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
     // 100 Hz always wins and a minute is never counted twice.
     if (window != null) {
       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
-    // that would otherwise let a killed-process session recover is no longer
-    // needed.
-    await _clearLiveSessionCheckpoint();
     if (steps <= 0 || durS < 20) return;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 1961 - 1994, Update
_finalizeLivePedometer to await _clearLiveSessionCheckpoint immediately before
the durable LocalDb.addLiveCoverage call, while preserving the existing window
calculation and reset flow. Remove the later checkpoint-clear call so recovery
cannot replay an already-written coverage window.
lib/compute/derivation_engine.dart (1)

1687-1735: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pass the buffered nap slice through CSV/WHOOP import days.

PreparedDerivationDay.napSub defaults to daySub, but derive_prepare.dart uses sub.slice(day.startSec, day.endSec + napBoundaryBufferSec) for nap detection. lib/compute/derivation_engine.dart:1718 passes Substrate.empty from day napSub, so imported days cannot span midnight naps into the next calendar day. Add the buffered napSub slice here or through the imported PhysioDay/DerivationDay path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/derivation_engine.dart` around lines 1687 - 1735, Update
_deriveDay to populate PreparedDerivationDay.napSub with a substrate slice
extending day.endSec by napBoundaryBufferSec, matching the buffered range used
by derive_prepare.dart. Ensure CSV/WHOOP-imported days pass this buffered nap
data through instead of the default or Substrate.empty, while preserving the
existing daySub and sleepSub behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@lib/compute/derivation_engine.dart`:
- Around line 1687-1735: Update _deriveDay to populate
PreparedDerivationDay.napSub with a substrate slice extending day.endSec by
napBoundaryBufferSec, matching the buffered range used by derive_prepare.dart.
Ensure CSV/WHOOP-imported days pass this buffered nap data through instead of
the default or Substrate.empty, while preserving the existing daySub and
sleepSub behavior.

In `@lib/state/app_state.dart`:
- Around line 1961-1994: Update _finalizeLivePedometer to await
_clearLiveSessionCheckpoint immediately before the durable
LocalDb.addLiveCoverage call, while preserving the existing window calculation
and reset flow. Remove the later checkpoint-clear call so recovery cannot replay
an already-written coverage window.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 65806142-5934-450c-ad42-c26816178f07

📥 Commits

Reviewing files that changed from the base of the PR and between ffea4a0 and 03a9980.

📒 Files selected for processing (4)
  • lib/compute/crossday_pipeline.dart
  • lib/compute/derivation_engine.dart
  • lib/state/app_state.dart
  • test/crossday_pipeline_test.dart

…replay-safe recovery

- _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.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Second round of bot findings, checked against current code — three were real and are fixed in cc126ee.

Fixed

  • napSub never reaches the import path (CodeRabbit, Major) — real. _deriveDay (CSV/WHOOP import) didn't pass napSub, so it fell back to the daySub default and bisected midnight-straddling naps again — on exactly the path this PR set out to fix. Now passes the same buffered slice prepareDerivationPayload uses.
  • Orphan recovery could replay an already-banked bout (CodeRabbit, Major) — real. _finalizeLivePedometer writes live_coverage before clearing the checkpoint, and a kill in that gap leaves a checkpoint whose steps are already durable; live_coverage has no uniqueness on the window (it's a plain SUM), so recovery re-added them.
    I did not take the proposed fix of clearing the checkpoint before the write — that just trades a double-count for a guaranteed loss, and inverts the durable-first ordering this codebase uses everywhere else (same rule as commit-before-ACK). Instead recovery is now idempotent: LocalDb.hasLiveCoverageWindow(start, end) and skip on a hit. Safe ordering kept, no data lost either way.
  • TZ guard baseline reset too early (PR Agent, Medium) — real. The force branch re-baselined the guard at scope-selection time, before the restage had actually run, so an interrupted restage dropped the hold anyway. Moved to after the full-history pass completes.

Declined

  • PR Agent: "_disposed guard only reached on the non-throw path" — incorrect. try/catch doesn't exit the function; both paths fall through to the if (_disposed) return; below. Its own scoring notes this.
  • PR Agent: "_maybeDowngradeLiveForBackground may reset the pedometer" — verified it doesn't; it only calls engine.enableHrOnlyLive(). No change needed (the suggestion was a verification request, not a diff).

flutter analyze clean, flutter test 1058 passing.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit cc126ee

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

No code suggestions found for the PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (10)
lib/compute/derivation_engine.dart (3)

1422-1434: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use dayLabelOf() for adjacent local labels.

_adjacentDayIds performs safe date arithmetic, but its label function creates a second local day-label implementation. Use dayLabelOf(DateTime(...)) for both adjacent dates.

As per coding guidelines, lib/**/*.dart must use todayLabel() or dayLabelOf() for local day labels.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/derivation_engine.dart` around lines 1422 - 1434, Update
_adjacentDayIds to use the existing dayLabelOf function for both DateTime values
instead of defining the local label formatter. Preserve the current DST-safe
date arithmetic and empty-list behavior when dayId cannot be parsed.

Source: Coding guidelines


1985-1989: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Emit one notification for each eligible workout bout.

The dedupe key is per bout, but _computeWorkouts still returns only the newest notifBout. _derivePreparedDay emits one event. If two bouts end within the two-hour window, only the newest bout generates a notification.

Return all eligible bout descriptors and call the existing notification emitter once per bout. Keep ${day.date}:auto_workout:${bout.endSec} as the per-bout dedupe key.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/derivation_engine.dart` around lines 1985 - 1989, Update
_computeWorkouts to return all eligible workout-bout descriptors instead of only
the newest notifBout, then update _derivePreparedDay to invoke the existing
notification emitter once for each returned bout. Preserve eligibility filtering
and use `${day.date}:auto_workout:${bout.endSec}` for each bout’s dedupe key.

3394-3399: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Document the nap attribution change in the v51 release notes.

kAlgoVersion is already 51, so this version bump is present. Add an explicit derivation changelog entry for the sleep_periods, naps, and nap_min output changes so the bumped version matches the changed analytics output.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/derivation_engine.dart` around lines 3394 - 3399, Update the v51
release notes or derivation changelog to explicitly document the nap attribution
changes affecting the sleep_periods, naps, and nap_min outputs, ensuring the
entry corresponds to the existing kAlgoVersion 51 bump.

Source: Coding guidelines

lib/state/app_state.dart (7)

2067-2074: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Allow equal band timestamps during orphan recovery.

Live frames commonly repeat the same recTs, so startTs == endTs is valid. deriveLiveCoverageWindow() reconstructs duration from the phone-clock ingest bounds.

The endTs <= startTs guard returns before that helper runs. Normal checkpoints are therefore discarded, and their steps are never banked. Reject only endTs < startTs.

Minimal fix
-      if (steps <= 0 || startTs == null || endTs == null || endTs <= startTs) {
+      if (steps <= 0 || startTs == null || endTs == null || endTs < startTs) {

Add a regression test with repeated recTs values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 2067 - 2074, Update the validation
guard in the orphan-recovery flow around deriveLiveCoverageWindow to reject only
endTs < startTs, allowing equal timestamps to reach the helper and bank their
steps. Add a regression test covering repeated recTs values and confirming the
resulting steps are preserved.

1922-1925: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Serialize checkpoint writes with session finalization.

_checkpointLiveSession() reads session fields after await SharedPreferences.getInstance(). A disconnect can reset those fields before the continuation runs. The checkpoint can then contain data from a different session.

The reverse race can also occur. _finalizeLivePedometer() can clear a newer session's checkpoint after await LocalDb.addLiveCoverage() completes.

Capture an immutable snapshot before the first await. Associate it with a session generation. Serialize checkpoint writes and finalization. Clear only the checkpoint for the finalized generation.

Also applies to: 1991-1994, 2025-2039

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 1922 - 1925, Serialize
_checkpointLiveSession() and _finalizeLivePedometer() so checkpoint writes and
finalization cannot overlap. Capture all session fields needed by
_checkpointLiveSession before its first await, associate the snapshot and
checkpoint with a monotonically tracked session generation, and ignore stale
continuations. During finalization, clear only the checkpoint belonging to the
finalized generation, preserving newer session data.

2061-2064: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Keep the checkpoint until coverage is durable.

For a valid checkpoint, _recoverOrphanedLiveSession() removes the preference before LocalDb.addLiveCoverage(). A process kill or database failure in that gap loses the only recovery record.

The deduplication check is also not atomic. Concurrent recovery or finalization can both observe no row and insert duplicate coverage because live_coverage has no unique window constraint.

Remove the checkpoint only after a successful insert or a confirmed existing row. Make the check-and-insert atomic with a database transaction or a unique idempotency key.

Also applies to: 2082-2094

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 2061 - 2064, Update
_recoverOrphanedLiveSession() to retain _kLiveSessionCheckpoint until
LocalDb.addLiveCoverage() succeeds or an existing matching coverage row is
confirmed. Make the deduplication check and insert atomic using a database
transaction or unique idempotency key, ensuring concurrent recovery/finalization
cannot create duplicates; remove the checkpoint only after that durable outcome.

2535-2559: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Invalidate stale alarm operations before a new write.

setAlarm() does not cancel or invalidate the previous grace timer before awaiting engine.setAlarm(when). If the old timer expires during a slow new write, _onAlarmGraceElapsed() can send the old alarm time to the strap.

The post-await _savedAlarm check prevents stale local state updates. It cannot undo a stale hardware write. Serialize alarm operations or use an operation generation. Invalidate the previous generation before awaiting a new write, and verify the generation before retrying or applying the result.

Also applies to: 2577-2604

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 2535 - 2559, Update setAlarm and the
related _onAlarmGraceElapsed retry flow to invalidate the previous alarm
operation before awaiting engine.setAlarm(when), using an operation generation
or equivalent serialization. Ensure the grace timer and any retry verify the
current generation before writing to the strap, and verify it again before
applying post-await state or persistence so stale operations cannot affect
hardware or local alarm state.

2542-2560: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard setAlarm() continuations after disposal.

setAlarm() can resume after dispose() from engine.setAlarm() or SharedPreferences. It can then mutate alarm state and create _alarmGraceTimer after dispose() has canceled owned timers.

The notifyListeners() guard does not prevent the new timer from being created. Check _disposed after each await and before arming the timer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 2542 - 2560, Guard the asynchronous
continuation in the alarm-setting method after each await, including
engine.setAlarm and SharedPreferences.getInstance/setInt, by returning when
_disposed is true before mutating alarm state or persisting values. Also check
_disposed immediately before _armAlarmGraceTimer so disposal cannot create a new
timer, while preserving the existing failure handling and notification behavior.

2838-2844: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Complete checkpoint handling on every live-session path.

openSession() now recovers before resetting counters. _reconnect() at Lines 2946-2956 still enables live streams and calls _resetLivePedometer() without recovery. A checkpoint left by a failed finalization can be overwritten by the next session.

finishStepCalibration() and cancelStepCalibration() also reset the shared counters without clearing or finalizing the checkpoint. Calibration data can later be replayed as orphaned live_coverage, or be overwritten by a later checkpoint.

Add recovery before every new-session reset. Add explicit finalize-or-clear behavior for calibration, including pending checkpoint writes.

As per coding guidelines, lib/**/*.dart: “When adding or changing a capability, cover every call path, including all raw decode paths and all relevant export/session triggers.”

Also applies to: 3421-3421

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 2838 - 2844, Complete checkpoint
handling across all live-session entry paths: update _reconnect() to recover
orphaned sessions before enabling streams or calling _resetLivePedometer().
Update finishStepCalibration() and cancelStepCalibration() so calibration resets
explicitly finalize or clear the checkpoint, including awaiting any pending
checkpoint writes. Preserve the existing openSession() ordering and ensure no
session path can overwrite an unresolved checkpoint.

Source: Coding guidelines


2032-2038: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Keep the recovered window aligned with committed steps.

The checkpoint stores committed steps, but it stores total _liveSamples and the latest ingest bounds. Those fields include the uncommitted partial minute.

_recoverOrphanedLiveSession() passes the mixed values to deriveLiveCoverageWindow(). The resulting coverage row can exclude the partial minute from the 1 Hz estimator without adding its steps.

Store the committed sample and time frontier, or persist enough state to recover the partial minute.

Also applies to: 2073-2079

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 2032 - 2038, Align checkpointed
coverage data with the committed steps in the live-session state serialization.
Update the fields written near `steps` and the corresponding handling in
`_recoverOrphanedLiveSession()` so `deriveLiveCoverageWindow()` receives
committed samples and time frontiers, or enough partial-minute state to restore
them consistently; do not pass total `_liveSamples` or latest ingest bounds when
they include uncommitted data.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/compute/derivation_engine.dart`:
- Around line 881-888: Track timezone-travel hold state explicitly instead of
clearing it when the current offset matches the baseline. Update
_timezoneTravelSuspected to preserve the hold until a successful full restage
completes, despite skipped or failed processDay calls. Ensure both the current
full-restage flow and runDays(force: true) reset the hold only after every
targeted day succeeds; interrupted or partially failed restages must leave it
held.
- Around line 1700-1703: Update _prepareTargetDay to load a buffered nap
substrate spanning day.startSec through day.endSec + napBoundaryBufferSec, and
add an explicit napSub field and constructor parameter to PreparedDerivationDay.
Pass this napSub through _derivePreparedDay into candidate.toPreparedDay instead
of relying on the daySub fallback, while keeping import-data verification
separate and requiring callers to provide post-day-end samples.

In `@lib/data/db.dart`:
- Around line 793-808: Make live-coverage recovery atomic by replacing the
separate hasLiveCoverageWindow check and addLiveCoverage call with a
transactionally deduplicated insert for the same start/end window. Update the
recovery caller to use the insert result and only treat the checkpoint as newly
recovered when the insert succeeds; preserve liveStepsForDay’s existing
aggregation behavior.

---

Outside diff comments:
In `@lib/compute/derivation_engine.dart`:
- Around line 1422-1434: Update _adjacentDayIds to use the existing dayLabelOf
function for both DateTime values instead of defining the local label formatter.
Preserve the current DST-safe date arithmetic and empty-list behavior when dayId
cannot be parsed.
- Around line 1985-1989: Update _computeWorkouts to return all eligible
workout-bout descriptors instead of only the newest notifBout, then update
_derivePreparedDay to invoke the existing notification emitter once for each
returned bout. Preserve eligibility filtering and use
`${day.date}:auto_workout:${bout.endSec}` for each bout’s dedupe key.
- Around line 3394-3399: Update the v51 release notes or derivation changelog to
explicitly document the nap attribution changes affecting the sleep_periods,
naps, and nap_min outputs, ensuring the entry corresponds to the existing
kAlgoVersion 51 bump.

In `@lib/state/app_state.dart`:
- Around line 2067-2074: Update the validation guard in the orphan-recovery flow
around deriveLiveCoverageWindow to reject only endTs < startTs, allowing equal
timestamps to reach the helper and bank their steps. Add a regression test
covering repeated recTs values and confirming the resulting steps are preserved.
- Around line 1922-1925: Serialize _checkpointLiveSession() and
_finalizeLivePedometer() so checkpoint writes and finalization cannot overlap.
Capture all session fields needed by _checkpointLiveSession before its first
await, associate the snapshot and checkpoint with a monotonically tracked
session generation, and ignore stale continuations. During finalization, clear
only the checkpoint belonging to the finalized generation, preserving newer
session data.
- Around line 2061-2064: Update _recoverOrphanedLiveSession() to retain
_kLiveSessionCheckpoint until LocalDb.addLiveCoverage() succeeds or an existing
matching coverage row is confirmed. Make the deduplication check and insert
atomic using a database transaction or unique idempotency key, ensuring
concurrent recovery/finalization cannot create duplicates; remove the checkpoint
only after that durable outcome.
- Around line 2535-2559: Update setAlarm and the related _onAlarmGraceElapsed
retry flow to invalidate the previous alarm operation before awaiting
engine.setAlarm(when), using an operation generation or equivalent
serialization. Ensure the grace timer and any retry verify the current
generation before writing to the strap, and verify it again before applying
post-await state or persistence so stale operations cannot affect hardware or
local alarm state.
- Around line 2542-2560: Guard the asynchronous continuation in the
alarm-setting method after each await, including engine.setAlarm and
SharedPreferences.getInstance/setInt, by returning when _disposed is true before
mutating alarm state or persisting values. Also check _disposed immediately
before _armAlarmGraceTimer so disposal cannot create a new timer, while
preserving the existing failure handling and notification behavior.
- Around line 2838-2844: Complete checkpoint handling across all live-session
entry paths: update _reconnect() to recover orphaned sessions before enabling
streams or calling _resetLivePedometer(). Update finishStepCalibration() and
cancelStepCalibration() so calibration resets explicitly finalize or clear the
checkpoint, including awaiting any pending checkpoint writes. Preserve the
existing openSession() ordering and ensure no session path can overwrite an
unresolved checkpoint.
- Around line 2032-2038: Align checkpointed coverage data with the committed
steps in the live-session state serialization. Update the fields written near
`steps` and the corresponding handling in `_recoverOrphanedLiveSession()` so
`deriveLiveCoverageWindow()` receives committed samples and time frontiers, or
enough partial-minute state to restore them consistently; do not pass total
`_liveSamples` or latest ingest bounds when they include uncommitted data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 475617c1-7852-4335-b10a-70d40fe085cf

📥 Commits

Reviewing files that changed from the base of the PR and between 03a9980 and cc126ee.

📒 Files selected for processing (3)
  • lib/compute/derivation_engine.dart
  • lib/data/db.dart
  • lib/state/app_state.dart

Comment thread lib/compute/derivation_engine.dart Outdated
Comment thread lib/compute/derivation_engine.dart
Comment thread lib/data/db.dart
Comment on lines +793 to +808
/// 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<bool> 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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make live-coverage recovery atomic.

hasLiveCoverageWindow only reads. The caller checks it and then calls addLiveCoverage in a separate operation. live_coverage has no unique constraint on (start_ts, end_ts). Two recovery paths can both observe no row and insert the same checkpoint. liveStepsForDay then counts the steps twice.

Combine the check and insert in one transaction, or add a unique window key with a deduplicating migration. Make recovery use the atomic insert result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/data/db.dart` around lines 793 - 808, Make live-coverage recovery atomic
by replacing the separate hasLiveCoverageWindow check and addLiveCoverage call
with a transactionally deduplicated insert for the same start/end window. Update
the recovery caller to use the insert result and only treat the checkpoint as
newly recovered when the insert succeeds; preserve liveStepsForDay’s existing
aggregation behavior.

…lure, single-flight recovery

- 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.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Third round — all three CodeRabbit findings on cc126ee were real. Fixed in bd4c59d. PR Agent reported "No code suggestions found" for this commit.

Fixed

  • napSub never reached the LIVE decoded path (Major) — the important one. My previous commit only fixed _deriveDay (import). _prepareTargetDay — used by run() / runDays() / rescanRecent(), i.e. every non-imported day — still called toPreparedDay(daySub:, sleepSub:) with no napSub, so it fell back to daySub and kept bisecting cross-midnight naps. In other words the headline fix of this PR was inert on the real path. It now loads [dayStart, dayEnd + napBoundaryBufferSec) in one substrate pass and slices the calendar day back out of it — each _loadSubstrateRange spawns its own isolate, so a second load would have doubled the per-day prepare cost; the widened range is ~+12% rows and stays far under _maxDayRawRows/_maxDayRawPages.
  • TZ hold cleared after skipped/failed days (Major) — real. processDay catches per-day errors and marks the day skipped, so a restage could "finish" with days unresolved and still drop the hold. Now gated on every targeted day actually deriving (a deliberately-kept pruned override day is not counted as a failure).
  • Recovery check-then-insert not atomic (Major) — real in principle now that two entry points can call it. Made _recoverOrphanedLiveSession single-flight, which closes the interleaving window at the source. I did not add a unique window key + dedup migration: live_coverage may already hold legitimate duplicate-looking rows on existing installs, so creating a UNIQUE index inside openDatabase risks throwing on the iOS CPU watchdog path for a race that only one process can now reach.

Partially declined

  • "Apply the same rule to runDays(force: true)"runDays is the selected-days path, not the full-restage entry point; "Re-analyze data" (reanalyzeAll) calls run(profile, heavy: true, force: true), which is where the reset lives. Clearing the hold from an arbitrary selected-day subset would be the same premature-clear bug in a different place.

flutter analyze clean, flutter test 1058 passing.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit bd4c59d

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
lib/state/app_state.dart (2)

2071-2106: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Persist recovered coverage before clearing the checkpoint.

Line 2076 removes the checkpoint before validation and before LocalDb.addLiveCoverage. Repeated live-frame recTs values make endTs == startTs normal, but Lines 2080-2083 reject that checkpoint before deriveLiveCoverageWindow can use the ingest-time bounds. A database failure after removal also loses the recovery data permanently.

Derive the coverage window first. Accept equal band timestamps when the helper derives a valid window. Remove the checkpoint only after existing coverage is detected or the new coverage write succeeds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 2071 - 2106, The
_recoverOrphanedLiveSessionOnce method clears _kLiveSessionCheckpoint too early
and rejects equal band timestamps before deriveLiveCoverageWindow can recover
using ingest bounds. Derive the window first, allow startTs == endTs when the
helper returns a valid window, then remove the checkpoint only after existing
coverage is detected or LocalDb.addLiveCoverage succeeds; retain the checkpoint
if validation fails or persistence errors.

2850-2856: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Apply the frame-ingest ordering to reconnects.

This path recovers and resets before enableLiveStreams, but _reconnect() still enables streams before calling _resetLivePedometer. A frame received during that await is added to the new session and then erased.

Reset the pedometer before enabling either live-stream mode in _reconnect().

As per coding guidelines, lib/**/*.dart: “When adding or changing a capability, cover every call path, including all raw decode paths and all relevant export/session triggers.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 2850 - 2856, Update _reconnect() so
_resetLivePedometer() runs before enabling live streams, matching the ordering
in the shown recovery path. Ensure both reconnect stream-enable modes reset
first, preventing frames received during the awaited enableLiveStreams() call
from being erased.

Source: Coding guidelines

lib/compute/derivation_engine.dart (1)

3770-3778: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Prevent cross-midnight naps from being attributed twice.

The forward-only buffer lets the prior day detect a nap that crosses midnight, but the next day sees the suffix as a new nap. Filter detected runs by both calendar boundaries after providing prior-boundary context.

  • lib/compute/derivation_engine.dart#L3770-L3778: pass a lower and upper attribution boundary. Keep only naps whose start is in [dayStartSec, dayEndSec).
  • lib/compute/derive_prepare.dart#L313-L313: include the preceding nap buffer as well as the following buffer.
  • lib/compute/derivation_engine.dart#L1039-L1051: load the preceding nap buffer for live derivation.
  • lib/compute/derivation_engine.dart#L1721-L1725: include the preceding nap buffer for imported derivation.

As per coding guidelines, lib/**/*.dart: “When adding or changing a capability, cover every call path, including all raw decode paths and all relevant export/session triggers.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/derivation_engine.dart` around lines 3770 - 3778, Update the
sleep attribution flow so detected naps are restricted to starts in
[dayStartSec, dayEndSec): at lib/compute/derivation_engine.dart lines 3770-3778,
pass both lower and upper attribution boundaries to _sleepPeriods and
_attachNaps. At lib/compute/derive_prepare.dart lines 313-313, include the
preceding nap buffer alongside the following buffer. At
lib/compute/derivation_engine.dart lines 1039-1051 and 1721-1725, load/include
the preceding nap buffer for live and imported derivation paths respectively.

Source: Coding guidelines

♻️ Duplicate comments (1)
lib/state/app_state.dart (1)

2589-2619: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not let a stale retry consume the newer alarm retry.

A retry for alarm A sets _alarmAutoRetried = true before awaiting engine.setAlarm. If the user sets alarm B during that await, setAlarm resets the flag for B. When A then succeeds, this method leaves the flag true because its stale check fails at Line 2614. Alarm B then skips its one automatic retry.

After the await, return when _savedAlarm != epoch before changing _alarmAutoRetried.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 2589 - 2619, Update
_onAlarmGraceElapsed so that after awaiting engine.setAlarm(when), it checks
whether _savedAlarm still equals epoch before modifying _alarmAutoRetried.
Return immediately for a stale retry, preserving the newer alarm’s retry state;
keep the existing dispose and rearm handling for the current alarm unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/compute/derivation_engine.dart`:
- Around line 884-899: Update runDays to track unresolved target days and reset
the tz_travel_guard baseline only after a forced selected restage whose selected
set covers the full raw history and has no unresolved days. Ensure
AppState.reanalyzeDays() via runDays(..., force: true) clears the hold on
complete success, while partial selections or failed/skipped targets preserve
the existing baseline.

---

Outside diff comments:
In `@lib/compute/derivation_engine.dart`:
- Around line 3770-3778: Update the sleep attribution flow so detected naps are
restricted to starts in [dayStartSec, dayEndSec): at
lib/compute/derivation_engine.dart lines 3770-3778, pass both lower and upper
attribution boundaries to _sleepPeriods and _attachNaps. At
lib/compute/derive_prepare.dart lines 313-313, include the preceding nap buffer
alongside the following buffer. At lib/compute/derivation_engine.dart lines
1039-1051 and 1721-1725, load/include the preceding nap buffer for live and
imported derivation paths respectively.

In `@lib/state/app_state.dart`:
- Around line 2071-2106: The _recoverOrphanedLiveSessionOnce method clears
_kLiveSessionCheckpoint too early and rejects equal band timestamps before
deriveLiveCoverageWindow can recover using ingest bounds. Derive the window
first, allow startTs == endTs when the helper returns a valid window, then
remove the checkpoint only after existing coverage is detected or
LocalDb.addLiveCoverage succeeds; retain the checkpoint if validation fails or
persistence errors.
- Around line 2850-2856: Update _reconnect() so _resetLivePedometer() runs
before enabling live streams, matching the ordering in the shown recovery path.
Ensure both reconnect stream-enable modes reset first, preventing frames
received during the awaited enableLiveStreams() call from being erased.

---

Duplicate comments:
In `@lib/state/app_state.dart`:
- Around line 2589-2619: Update _onAlarmGraceElapsed so that after awaiting
engine.setAlarm(when), it checks whether _savedAlarm still equals epoch before
modifying _alarmAutoRetried. Return immediately for a stale retry, preserving
the newer alarm’s retry state; keep the existing dispose and rearm handling for
the current alarm unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 998167d9-dee4-4357-aec7-06109208463f

📥 Commits

Reviewing files that changed from the base of the PR and between cc126ee and bd4c59d.

📒 Files selected for processing (3)
  • lib/compute/derivation_engine.dart
  • lib/compute/derive_prepare.dart
  • lib/state/app_state.dart

Comment thread lib/compute/derivation_engine.dart
…tz hold, durable-first recovery

- 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.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Fourth round on bd4c59d — two real, two declined. Fixed in 43d7613.

Fixed

  • CodeRabbit: reset the travel guard after a successful selected full restage (Major) — fair pushback on my partial decline last round, and the refined condition resolves my objection. reanalyzeDays() (Advanced → select days) goes through runDays(force: true), so a user who selects everything could complete a restage and stay blocked by tz-travel-hold forever. Now cleared from that path too — but only when the selection actually covers the whole raw history and every target resolved. A partial selection still leaves the hold up, since those days say nothing about the ones being held.
  • PR Agent: durable-first ordering in orphan recovery (7/10) — correct, and it's a regression I introduced. Removing the checkpoint before writing coverage meant a kill in that gap lost the bout — exactly what the checkpoint exists to prevent, and backwards from the commit-before-ACK rule used everywhere else here. Flipped to write-then-drop. This is only safe because of the two things added in the last two commits: the coverage-window check makes replay idempotent, and the method is single-flight. A checkpoint that can never be recovered (malformed JSON, or a window the sanitizer rejects) is still dropped immediately so it can't be retried on every connect forever.

Declined

  • PR Agent: "_disposed guard missing on the retry-failure path" — factually wrong; if (_disposed) return; sits above notifyListeners() and is reached on both the throw and the !rearmed paths. Its own proposed diff is a no-op that only deletes comments. This is the third variant of this same claim across rounds.
  • PR Agent: "delete metric_series rows when a day re-derives as partial" (3/10) — declined. The suggestion argues against itself mid-paragraph, and its own scoring says so. Deleting the last known-good values on a partial re-derive would make the baseline read a gap instead of the last good day — that is precisely the blank-readiness bug class fixed in edge#108, and upsertDayResult skipping the write is what keeps a timed-out second-half pass from overwriting good values in the first place.

flutter analyze clean, flutter test 1058 passing.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 43d7613

@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Fifth round on 43d7613: CodeRabbit posted no new findings (status: "Review completed"). PR Agent raised one, which I'm declining as factually incorrect.

Declined — "Non-idempotent timezone guard baseline update"

The claim is that "if a timezone jump happens between two normal derive runs, the second normal run will see lastOffset == nowOffset (because the first run already updated it) and return false."

That is not what the code does. _timezoneTravelSuspected only writes the baseline on the non-jump path:

final jumped = (nowOffsetMin - lastOffsetMin).abs() >= _tzJumpThresholdMin;
if (!jumped) {
  await LocalDb.putBaseline('tz_travel_guard', ...);
}
return jumped;

When a jump is detected the stored offset is deliberately left alone, so every subsequent pass compares against the same pre-travel baseline and keeps returning true until a full restage clears it. That was exactly the fix applied in 487194d, and the suggestion would re-open the hole it closed — with the non-jump write removed, an ordinary DST shift (60 min, well under the 180 min threshold) would leave the baseline permanently stale, and a later legitimate 2 h move measured against a months-old offset would read as travel and hold days for no reason.

The one genuine residual is sub-threshold staircase drift (two separate <3 h offset changes summing to more than 3 h are each absorbed). The proposed change doesn't address that either, and a single discrete offset change is what actual travel looks like. Leaving as-is.

Merging — CI green, flutter analyze clean, flutter test 1058 passing, kAlgoVersion at 51.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant