Skip to content

correlations that read numbers, blood work in your profile, backups, naps you can correct, and breathing you can do with your eyes shut - #221

Merged
abdulsaheel merged 7 commits into
mainfrom
feat/journal-insights-backup-naps-breathing
Aug 9, 2026
Merged

correlations that read numbers, blood work in your profile, backups, naps you can correct, and breathing you can do with your eyes shut#221
abdulsaheel merged 7 commits into
mainfrom
feat/journal-insights-backup-naps-breathing

Conversation

@abdulsaheel

@abdulsaheel abdulsaheel commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

User description

Five of the six remaining items from the discussions triage. Per-app vibration patterns is parked.

Journal correlations. The typed fields shipped with nowhere to go — recorded, displayed, correlated with nothing. This moves the analytics pin to pick up journalNumericCorrelations and wires it in. Findings lead with a slope in the outcome's own units ("about 4 ms less HRV per extra coffee") rather than a coefficient, with rho as supporting detail. Deliberately a separate pass from the tag correlations rather than more rows in the same list: a tag answers "were those days different", a dose answers "does more of this go with less of that", and they carry different evidence — a difference of means with a Cohen's d versus a rank correlation with a confidence interval. Its date axis is the days a number was recorded, which is not the same set as the days a tag was.

Profile from the health store. Weight, height, and on Apple sex and date of birth. Weight and height are adopted because they drift and three things the app computes depend on them; age and sex only fill a gap, because neither changes and another app's record should not overrule something you set deliberately. Sex and DOB are requested only on Apple — Health Connect has no characteristic record for either, and requesting them there is issue #184's shape again. Android's manifest gains READ_WEIGHT/READ_HEIGHT with no write counterparts; without them Health Connect silently returns nothing, which is indistinguishable from an empty store.

Automatic backup. Daily or weekly, into an OpenStrap Backups folder in the app's Documents directory, reachable from Files (UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace) and from the Android file manager. Point iCloud Drive, Synology or Nextcloud at it. Not a user-picked folder: that needs a persisted SAF tree URI or a security-scoped bookmark, both of which expire silently, and a backup that quietly stopped working is worse than one that lives somewhere slightly less convenient. It runs on foreground when due rather than pretending to a background schedule neither platform will honour, and the sheet says so — along with the fact that it is not encrypted.

Naps you can correct. Three people asked, from two directions: "it tracked sleep when I was awake, let me delete it" and "I napped for two hours and it wasn't counted". Same feature — the detector's answer is a proposal and the person who was there gets the last word.

Two decisions worth calling out. Edits are stored separately from the detector's output and replayed over it on every derivation, rather than written into the result: the detector improves, and a day re-derived under a better stager should still respect "there was no nap here" instead of freezing the old detection alongside the edit. And a rejection matches by overlap, not exact bounds — detected boundaries shift between runs, and an edit that stopped applying when one moved by a minute would let a deleted nap quietly come back.

This bumps kAlgoVersion to 61, and that is the point rather than a side effect: logged naps count toward sleep totals, sleep debt and readiness exactly as detected ones do. Days carrying an edit are force-derived alongside sleep-override days so an edit to a finalized day takes effect.

Breathing. The screen already had real cardiac coherence from live RR; it was missing the other three things. Four patterns instead of one fixed pace. A session that ends when the button said it would — it used to say "2-Minute Session" and run until you stopped it. And the strap buzzes each phase change, which was always the point of an exercise you are not supposed to be watching a phone during. Sessions are kept now, so the score is a trend rather than a number that vanished on stop. Coherence stays on resonance only: it measures oscillation at the paced frequency, and scoring box breathing against it grades it on an exam it is not sitting.

Interval timer rides the same phase engine — rounds and breaths are the same problem. It records nothing and starts no session; a workout already covers that, and a timer that quietly created sessions would double-count every round. Reachable from the start-workout sheet, which is only safe because that sheet became scrollable earlier.

Schema 29 → 31 across three additive rungs, with a migration test that walks v27 all the way up and writes to every new table — a CREATE with a typo still leaves a table nothing can write to.


PR Type

Enhancement, Bug fix, Tests


Description

  • Five new user-facing features: journal numeric correlations, health-store profile import, automatic backup, correctable naps, and improved guided breathing

  • kAlgoVersion bumped 60→61 and schemaVersion bumped 29→31; two new tables (sleep_nap, breathing_session) added with migrations

  • Breathing screen gains four patterns, real session timer, per-phase strap buzz, and session history; new interval timer screen shares the same phase engine

  • Nap edits (add/reject) stored separately from detector output and replayed on every derivation; rejection matches by overlap, not exact bounds


Diagram Walkthrough

flowchart LR
  A["Health store\n(HealthKit / Health Connect)"]
  B["HealthProfileImporter\n(health_profile_import.dart)"]
  C["AppState.updateProfile()"]
  D["Local profile map"]

  E["User nap edit\n(add / reject)"]
  F["LocalDb.putNapEdit()\nsleep_nap table (schema v31)"]
  G["DerivationEngine\napplyNapEdits()"]
  H["day_result\n(kAlgoVersion 61)"]

  I["Breathing session\n(CalmBreathingScreen)"]
  J["LocalDb.putBreathingSession()\nbreathing_session table (schema v30)"]
  K["AppState.buzzBreathPhase()"]
  L["BLE strap buzz"]

  M["Auto backup\n(auto_backup.dart)"]
  N["Documents/OpenStrap Backups/\n*.db files"]

  A -- "read" --> B
  B -- "mergeHealthProfile()" --> C
  C --> D

  E --> F
  F -- "napEditDays() force-derive" --> G
  G --> H

  I -- "≥60 s session" --> J
  I -- "phase boundary" --> K
  K --> L

  M -- "daily / weekly" --> N
Loading

File Walkthrough

Relevant files
Enhancement
11 files
derivation_engine.dart
Bump kAlgoVersion 60→61; replay nap edits over detector output
+78/-27 
db.dart
Schema v29→31; add sleep_nap and breathing_session tables
+144/-1 
nap_edits.dart
Pure nap-edit merge logic: applyNapEdits, overlap rejection
+129/-0 
health_profile_import.dart
Read weight/height/sex/DOB from platform health store       
+226/-0 
auto_backup.dart
Automatic daily/weekly database backup to Documents folder
+176/-0 
breath_phases.dart
Shared phase engine for breathing and interval timer         
+200/-0 
calm_breathing_screen.dart
Four patterns, real timer, per-phase strap buzz, session history
+261/-113
interval_timer_screen.dart
New interval timer screen using shared breath phase engine
+253/-0 
sleep_periods_screen.dart
Add/remove nap UI; detected naps suppressed by overlap match
+156/-3 
profile_screen.dart
Health-store import row and automatic backup row in profile
+121/-1 
app_state.dart
Wire backup, nap-edit reanalysis, breathing pattern/history/buzz
+131/-3 
Tests
4 files
nap_edits_test.dart
Tests for nap edit merge, overlap rejection, and validation
+209/-0 
auto_backup_test.dart
Tests for backup scheduling, filename ordering, and pruning
+228/-0 
health_profile_import_test.dart
Tests for health-store snapshot, merge policy, and permissions
+239/-0 
breath_phases_test.dart
Tests for phase engine timing and interval pattern helpers
+183/-0 
Configuration changes
3 files
prefs.dart
Add backup cadence and last-run timestamp preference keys
+4/-0     
AndroidManifest.xml
Add READ_WEIGHT and READ_HEIGHT Health Connect permissions
+7/-0     
Info.plist
Add UIFileSharingEnabled and LSSupportsOpeningDocumentsInPlace
+9/-0     
Dependencies
1 files
pubspec.yaml
Update analytics sibling pin for journal numeric correlations
+10/-1   
Additional files
7 files
app.dart +5/-0     
local_repository_impl.dart +108/-3 
journal_screen.dart +95/-3   
workout_types.dart +17/-2   
workouts_screen.dart +31/-1   
calm_breathing_view_test.dart +1/-1     
db_migration_ladder_test.dart +47/-0   

Summary by CodeRabbit

  • New Features
    • Added manual nap logging, editing, removal, and sleep re-analysis.
    • Added scheduled or on-demand local database backups with retention.
    • Added health-store profile import for weight, height, age, and sex.
    • Added selectable guided-breathing patterns, session history, phase cues, and an interval timer.
    • Added numeric journal insights and correlation summaries.
    • Enabled iOS Files access and in-place document opening.
    • Added Health Connect access for reading weight and height.
  • Bug Fixes
    • Due backups now run when the app returns to the foreground.

…naps you

can correct, and breathing you can do with your eyes shut

Five things, in the order they became possible.

The typed journal fields shipped with nowhere to go — recorded, displayed,
correlated with nothing. They correlate now, on a rank statistic with a slope
in the outcome's own units, so the finding reads "about 4 ms less HRV per
extra coffee" rather than a coefficient nobody can act on. Kept apart from the
tag findings: a tag says those days were different, a dose says more of this
goes with less of that, and they carry different evidence.

Weight, height and — on Apple, where the records exist — sex and date of birth
can be pulled from the health store. Weight is the one that drifts, and three
things the app computes depend on it. Weight and height are adopted because
they change; age and sex only fill a gap, because they do not, and another
app's record does not get to overrule something you set on purpose.

Automatic backup, into a folder you can reach from Files and point iCloud or
Nextcloud at. It runs when you open the app rather than claiming a schedule
the OS will not honour, and it says so.

Naps you can correct. The detector's answer is a proposal now: log one it
missed, delete one it invented. Edits are stored apart from the detection and
replayed over it, so a better detector later still respects "there was no nap
here", and a deletion matches by overlap rather than exact bounds, because
those bounds move between runs. Logged naps count toward sleep totals, sleep
debt and readiness exactly as detected ones do, which is why this bumps the
algorithm version.

Breathing gained the three things it was missing. Four patterns instead of
one, a session that ends when the button said it would, and the strap buzzing
each phase change so you can close your eyes — which was always the point of
an exercise you are not supposed to be staring at a phone during. Sessions are
kept now, so the coherence score is a trend rather than a number that
vanished. Coherence stays on resonance breathing only; scoring box breathing
against it would grade it on an exam it is not sitting.

The interval timer rides the same phase engine, because rounds and breaths are
the same problem. It records nothing — a workout already covers that.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@abdulsaheel, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: affcf137-d346-4d5f-a4e4-8dc9dcf493cc

📥 Commits

Reviewing files that changed from the base of the PR and between a5032f3 and baab763.

⛔ Files ignored due to path filters (1)
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (26)
  • android/app/src/main/AndroidManifest.xml
  • ios/Runner/Info.plist
  • lib/app.dart
  • lib/compute/derivation_engine.dart
  • lib/compute/nap_edits.dart
  • lib/data/auto_backup.dart
  • lib/data/db.dart
  • lib/data/local_repository_impl.dart
  • lib/health/health_profile_import.dart
  • lib/state/app_state.dart
  • lib/state/prefs.dart
  • lib/ui/journal/journal_screen.dart
  • lib/ui/profile/profile_screen.dart
  • lib/ui/sleep/sleep_periods_screen.dart
  • lib/ui/stress/breath_phases.dart
  • lib/ui/stress/calm_breathing_screen.dart
  • lib/ui/stress/interval_timer_screen.dart
  • lib/ui/workouts/workout_types.dart
  • lib/ui/workouts/workouts_screen.dart
  • pubspec.yaml
  • test/auto_backup_test.dart
  • test/breath_phases_test.dart
  • test/calm_breathing_view_test.dart
  • test/db_migration_ladder_test.dart
  • test/health_profile_import_test.dart
  • test/nap_edits_test.dart

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 adds persisted nap editing, scheduled local backups, health-profile import, numeric journal insights, configurable paced breathing, and an interval timer. It updates database schemas, platform permissions, profile and sleep screens, workout navigation, and related tests.

Changes

Sleep nap editing

Layer / File(s) Summary
Nap edit model and validation
lib/compute/nap_edits.dart, test/nap_edits_test.dart
Adds manual and rejected nap models, duration limits, overlap handling, ordering, metadata, and nap-minute aggregation.
Nap persistence and derivation
lib/data/db.dart, lib/compute/derivation_engine.dart, test/db_migration_ladder_test.dart
Adds the sleep_nap schema and APIs. Derivation replays persisted edits over detected naps and updates sleep totals and periods.
Nap editing interface
lib/ui/sleep/sleep_periods_screen.dart, lib/state/app_state.dart
Adds nap logging, removal, validation, persistence, reanalysis, and updated sleep-period controls.

Profile, backup, and journal data

Layer / File(s) Summary
Automatic local backups
lib/data/auto_backup.dart, lib/state/app_state.dart, lib/state/prefs.dart, lib/app.dart, lib/ui/profile/profile_screen.dart, ios/Runner/Info.plist, test/auto_backup_test.dart
Adds daily or weekly local backups, retention, manual execution, due checks on resume, preference tracking, and profile controls.
Health-profile import
lib/health/health_profile_import.dart, lib/ui/profile/profile_screen.dart, android/app/src/main/AndroidManifest.xml, test/health_profile_import_test.dart
Reads and validates weight, height, age, and sex data from platform health stores. Profile flows merge changed values. Android declares read-only weight and height permissions.
Numeric journal insights
lib/data/local_repository_impl.dart, lib/ui/journal/journal_screen.dart, pubspec.yaml
Computes numeric journal correlations and renders slope, direction, units, sample count, and rank correlation alongside tag insights.

Breathing and interval timing

Layer / File(s) Summary
Breathing phase engine
lib/ui/stress/breath_phases.dart, test/breath_phases_test.dart
Defines breathing patterns, phase progression, interval construction, presentation metadata, and finite-session deadlines.
Configurable paced breathing
lib/ui/stress/calm_breathing_screen.dart, lib/state/app_state.dart, lib/data/db.dart, test/calm_breathing_view_test.dart
Adds selectable patterns and durations, elapsed-time phase updates, phase buzzes, automatic stopping, pattern-based coherence, and completed-session history.
Interval timer and workout entry
lib/ui/stress/interval_timer_screen.dart, lib/ui/workouts/workout_types.dart, lib/ui/workouts/workouts_screen.dart
Adds configurable work/rest rounds and exposes the interval timer from the workout picker without recording a workout session.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AppState
  participant AutoBackup
  participant LocalDb
  participant BackupDirectory

  AppState->>AutoBackup: runBackupIfDue()
  AutoBackup->>LocalDb: exportCopy()
  LocalDb-->>AutoBackup: database copy
  AutoBackup->>BackupDirectory: store and prune backup
  AutoBackup-->>AppState: backup outcome
  AppState->>AppState: persist successful run timestamp
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title describes the pull request's major feature areas, but it is long, informal, and includes the inaccurate phrase "blood work."
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 9, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit baab763)

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

Schema version skip

schemaVersion jumps from 29 to 31, but the migration ladder only adds if (oldV < 30) and if (oldV < 31) rungs. A device currently on schema 29 will run both rungs correctly, but a device on schema 30 (e.g. a build that shipped v30 internally) will skip the breathing-sessions table creation entirely because _createBreathingSessions is only called in the oldV < 30 branch. If any internal/beta build ever wrote schema 30, those users get no breathing_session table and every putBreathingSession call crashes. The _repairOpenSchema path calls _createBreathingSessions only inside _createUserDataStore, which is not called from _repairOpenSchema directly — confirm the repair path covers this table.

static const int schemaVersion = 31;
Boolean latch not reset on failure

In startBreathingSession, breathingActive is set to true before the try block. If the BLE stream subscription or any subsequent setup inside the try throws, breathingActive is never reset to false (there is no finally or catch that clears it). The guard if (breathingActive) return; at the top means every subsequent call to startBreathingSession silently returns, wedging the session state until force-close. This matches the recurring §4.3 pattern exactly.

  breathingActive = true;
  breathingResult = null;
  breathingError = null;
  _breathingFrames.clear();
  _breathingStartedAt = DateTime.now();
  notifyListeners();
  unawaited(BreathingLiveActivity.start(startedAt: DateTime.now()));
  try {
    // OWNERSHIP: same rule as spot-check — only claim "we enabled it" when
    // live was actually OFF, so ending the session can never turn off
    // streams the open session still expects on.
    if (!engine.liveEnabled) {
      await engine.enableLiveStreams();
      _breathingEnabledStreams = true;
    } else if (engine.liveHrOnly) {
      await engine.enableLiveStreams();
    }
  } catch (_) {
    /* best-effort; we still collect whatever arrives */
  }
  _breathingRecomputeTimer?.cancel();
  _breathingRecomputeTimer = Timer.periodic(_breathingRecomputeInterval, (_) {
    unawaited(_recomputeBreathingCoherence());
  });
}
Local-time nap timestamp

_addNap constructs startTs and endTs using DateTime(day.year, day.month, day.day, start.hour, start.minute) where day = DateTime.parse(widget.date). DateTime.parse on a plain date string (e.g. "2026-08-09") returns a LOCAL DateTime, so the arithmetic is local — which is correct. However, if widget.date ever arrives as a UTC ISO string (e.g. "2026-08-09T00:00:00.000Z"), DateTime.parse returns UTC and the subsequent field construction is still treated as local, producing an epoch that is off by the UTC offset. The day-label invariant (§3.7) requires that day labels come from day_label.dart helpers; verify widget.date is always a plain local label, not a UTC ISO string, before this path is considered safe.

final day = DateTime.parse(widget.date);
final startTs =
    DateTime(day.year, day.month, day.day, start.hour, start.minute)
        .millisecondsSinceEpoch ~/
    1000;
var endTs =
    DateTime(day.year, day.month, day.day, end.hour, end.minute)
        .millisecondsSinceEpoch ~/
    1000;
// An end before the start means it ran past midnight.
if (endTs <= startTs) endTs += 24 * 3600;
Nap-edit idempotence on rerun

napEditDays() returns every day that has ANY row in sleep_nap, including days whose only edit is a rejected suppression. Every such day is force-included in todoDays on every derivation run, even after the day is finalized and its raw data pruned. The comment in the changelog acknowledges this for added naps, but a rejected-nap row on a pruned day will cause the per-day loop to attempt re-derivation, hit the "no raw data" guard, and skip — but the day stays in napEditDays() forever, so it is re-evaluated on every single BLE drain for the lifetime of the app. This is not data-loss but it is unbounded extra work per drain proportional to the number of ever-rejected naps, and it conflicts with §3.9 (never prune a day that is not fully derived — the inverse concern is that a finalized+pruned day with a rejection edit will never successfully re-derive, yet keeps being scheduled).

final overrideDays = {
  ...await LocalDb.sleepOverrideDays(),
  // A nap edit on a finalized day has to take effect too — same reason.
  ...await LocalDb.napEditDays(),
};

⚠️ Review coverage: The following files were not included in this review because of the token budget:

  • test/health_profile_import_test.dart
  • lib/data/auto_backup.dart
  • lib/health/health_profile_import.dart
  • test/nap_edits_test.dart
  • lib/ui/journal/journal_screen.dart
  • lib/ui/stress/interval_timer_screen.dart
  • lib/data/local_repository_impl.dart
  • test/breath_phases_test.dart
  • lib/ui/stress/breath_phases.dart
  • lib/compute/nap_edits.dart
  • lib/ui/profile/profile_screen.dart
  • test/calm_breathing_view_test.dart
  • test/db_migration_ladder_test.dart
  • lib/ui/workouts/workouts_screen.dart
  • lib/app.dart
  • lib/state/prefs.dart
  • pubspec.yaml
  • android/app/src/main/AndroidManifest.xml
  • ios/Runner/Info.plist

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to baab763

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard context access after widget disposal

_onTick is an AnimationController listener that fires on every animation frame.
After _stop() sets _running = false, the AnimationController is stopped but its
listener is not removed until dispose. If the controller fires one more frame
between _stop() returning and the listener being removed, _running is false so the
guard at the top returns early — that is fine. However context.read() is called
unconditionally before _stop() without a mounted check; if the widget is being
disposed concurrently (e.g. the user pops the screen at the exact moment the session
ends) this will throw. Add a mounted guard before the context.read calls inside
_onTick.

lib/ui/stress/interval_timer_screen.dart [100-122]

 void _onTick() {
-  if (!_running) return;
+  if (!_running || !mounted) return;
   final elapsed = _clock.elapsed;
   final end = _sessionEnd;
   if (end != null && elapsed >= end) {
     context.read<AppState>().buzzSessionComplete();
     _stop();
     return;
   }
   final at = phaseAt(_pattern, elapsed);
   if (at != null &&
       (at.phase.kind != _lastPhase || at.cycle != _lastCycle)) {
     _lastPhase = at.phase.kind;
     _lastCycle = at.cycle;
     context.read<AppState>().buzzBreathPhase(at.phase.kind);
   }
   setState(() {});
 }
Suggestion importance[1-10]: 7

__

Why: Adding a mounted check before context.read calls in _onTick is a valid defensive measure to prevent potential crashes when the widget is disposed while the animation controller fires. The improved code accurately adds !mounted to the early return guard.

Medium
Prevent null-assertion crash on missing outcome map

maps[od['key']]![d] uses a non-null assertion on the inner map lookup but then
subscripts with [d], which returns null for a date that has no outcome value — this
is expected and fine. However the outer ! will throw if od['key'] is not present in
maps at all (e.g. a new outcome def whose series query returned nothing and was
never inserted). Use a null-safe fallback for the outer lookup to avoid a crash that
silently kills the entire insights pass.

lib/data/local_repository_impl.dart [2918-2921]

 final outcomes = <String, List<double?>>{
   for (final od in outcomeDefs)
-    (od['key'] as String): [for (final d in dates) maps[od['key']]![d]],
+    (od['key'] as String): [
+      for (final d in dates) maps[od['key']]?[d],
+    ],
 };
Suggestion importance[1-10]: 7

__

Why: The non-null assertion maps[od['key']]! could throw if an outcome key is missing from maps, which would crash the entire insights computation silently. Replacing ! with ? is a minimal, correct fix that makes the code more robust against unexpected data states.

Medium
Snapshot mutable state before async gap

app.user is read before the await importer.read() call to compute changed, but the
actual merge passed to updateProfile also calls app.user after the await. If
app.user mutates between the two reads (e.g. another concurrent profile update),
healthProfileChanges and mergeHealthProfile operate on different snapshots, so the
reported changes and the written values can diverge. Capture app.user once before
the first await and use that snapshot for both calls.

lib/ui/profile/profile_screen.dart [119-138]

+final existingUser = app.user;
 final snap = await importer.read();
 if (!ctx.mounted) return;
-...
-await app.updateProfile(mergeHealthProfile(app.user, snap));
+if (snap.isEmpty) {
+  messenger.showSnackBar(
+    SnackBar(content: Text('Nothing to read from $store')),
+  );
+  return;
+}
+final changed = healthProfileChanges(existingUser, snap);
+if (changed.isEmpty) {
+  messenger.showSnackBar(
+    const SnackBar(content: Text('Your profile already matches')),
+  );
+  return;
+}
+await app.updateProfile(mergeHealthProfile(existingUser, snap));
 if (!ctx.mounted) return;
 messenger.showSnackBar(
   SnackBar(content: Text('Updated ${changed.join(', ')}')),
 );
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that app.user is read twice across an async gap, which could lead to inconsistency if the profile is mutated concurrently. However, this is a relatively rare race condition in a mobile app context, and the improved code accurately reflects the fix by capturing existingUser before the first await.

Low
Clear session state before flipping active flag

breathingActive is set to false before _breathingStartedAt and _breathingTarget are
cleared. If notifyListeners() triggers a rebuild that calls startBreathingSession
before the nulling lines execute (e.g. from a widget reacting to breathingActive
becoming false), the new session would inherit the stale _breathingStartedAt and
_breathingTarget from the previous one. Clear _breathingStartedAt and
_breathingTarget before setting breathingActive = false and calling
notifyListeners().

lib/state/app_state.dart [3933-3973]

 Future<void> stopBreathingSession() async {
     if (!breathingActive) return;
     _breathingRecomputeTimer?.cancel();
     _breathingRecomputeTimer = null;
-    breathingActive = false;
-    _stopBreathingStreams();
-    unawaited(BreathingLiveActivity.end());
 
     final started = _breathingStartedAt;
     final target = _breathingTarget;
     _breathingStartedAt = null;
     _breathingTarget = null;
+
+    breathingActive = false;
+    _stopBreathingStreams();
+    unawaited(BreathingLiveActivity.end());
+
     if (started != null) {
-      ...
+      final ended = DateTime.now();
+      var seconds = ended.difference(started).inSeconds;
+      if (target != null && seconds > target.inSeconds) {
+        seconds = target.inSeconds;
+      }
+      if (seconds >= 60) {
+        final res = breathingResult;
+        final scored = res != null && res['ok'] == true;
+        final rated = breathingPattern.coherenceRated && scored;
+        unawaited(
+          LocalDb.putBreathingSession(
+            startedAt: started.millisecondsSinceEpoch,
+            endedAt: ended.millisecondsSinceEpoch,
+            pattern: breathingPattern.key,
+            seconds: seconds,
+            coherence: rated ? (res['score'] as num?)?.toDouble() : null,
+            confidence: rated ? (res['confidence'] as num?)?.toDouble() : null,
+          ),
+        );
+      }
     }
     notifyListeners();
   }
Suggestion importance[1-10]: 4

__

Why: The concern about startBreathingSession being called between breathingActive = false and the nulling of _breathingStartedAt/_breathingTarget is theoretically valid, but in practice notifyListeners() is synchronous and Dart is single-threaded — no widget rebuild can interleave between these lines. The suggestion is logically sound but the race condition it describes cannot actually occur in Dart's event loop model.

Low
General
Add breathing_session table to repair-open-schema path

The migration for schema version 30 (breathing_session) is missing from the
_repairOpenSchema path, but _createBreathingSessions is only called from
_createUserDataStore (for fresh installs) and the v30 migration ladder. The
_repairOpenSchema function already has _createSleepNap added for self-healing merged
builds, but _createBreathingSessions was not added there. Users on the same schema
version from a merged build will be missing the breathing_session table. Add await
_createBreathingSessions(db) to _repairOpenSchema alongside the other CREATE TABLE
IF NOT EXISTS calls already there.

lib/data/db.dart [420-428]

+if (oldV < 30) {
+      // Paced-breathing history. New table only.
+      await _createBreathingSessions(db);
+    }
+    if (oldV < 31) {
+      // User edits to a day's naps. New table only — the detector's own
+      // output is untouched and the edits replay over it.
+      await _createSleepNap(db);
+    }
 
-
Suggestion importance[1-10]: 3

__

Why: The suggestion points out that _createBreathingSessions may be missing from _repairOpenSchema, but the existing_code and improved_code are identical — no actual fix is shown. The concern may be valid but the suggestion fails to demonstrate the fix, making it difficult to evaluate and act on.

Low
Prevent double-stop from expiry latch on re-mount

_finished is reset to false in _begin(), but _begin() is also called from initState
when widget.active is already true (re-mount mid-session). If stopBreathingSession
is called and the parent flips active to false, didUpdateWidget calls _ticker.stop()
and sets _localStart = null, but _finished is NOT reset there. If the session is
then restarted (active goes true again), _begin() correctly resets _finished.
However, if the view is disposed and remounted while active is still true after a
natural expiry (the _finished = true path called onStop but the parent hasn't
flipped active yet), _begin() in initState resets _finished to false and the expiry
check in _onTick will fire onStop again — a second stop call on an already-stopping
session. The _finished reset in _begin should be guarded to only apply when starting
fresh, not on re-mount of an already-active session.

lib/ui/stress/calm_breathing_screen.dart [187-195]

 void _begin() {
     _lastPhase = null;
     _lastCycle = -1;
+    // Only reset _finished when we are starting fresh (no authoritative
+    // start time yet). On a re-mount mid-session the parent has not yet
+    // flipped active=false, so we must not re-arm the expiry latch.
+    if (widget.startedAt == null && _localStart == null) {
+      _finished = false;
+    } else {
+      _finished = false; // safe: a re-mount means the session is still live
+    }
     _finished = false;
     // Only when the session cannot tell us — otherwise a remount would reset
     // the clock and restart the session's timing from zero.
     _localStart ??= DateTime.now();
     _ticker.repeat();
   }
Suggestion importance[1-10]: 1

__

Why: The improved_code is self-contradictory — it contains two separate _finished = false assignments with a comment implying they differ, but both branches do the same thing, making the suggestion logically incoherent. The scenario described (dispose and remount while _finished=true but active still true) is also an extremely narrow edge case that the existing code handles adequately via the _finished guard in _onTick.

Low

Previous suggestions

Suggestions up to commit db30204
CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard context access after dispose in ticker callback

_onTick calls context.read() directly on the UI isolate inside an
AnimationController listener. Per AGENTS.md §4.5, context.read after an async gap or
in a listener that fires after dispose is a recurring crash source. If the widget is
disposed while the ticker is still running (e.g. the OS kills the screen),
context.read will throw. The dispose method does call _stop() only when _running is
true, but the AnimationController listener fires before dispose completes. Guard
with mounted before every context.read call in _onTick.

lib/ui/stress/interval_timer_screen.dart [100-122]

 void _onTick() {
-  if (!_running) return;
+  if (!_running || !mounted) return;
   final elapsed = _clock.elapsed;
   final end = _sessionEnd;
   if (end != null && elapsed >= end) {
     context.read<AppState>().buzzSessionComplete();
     _stop();
     return;
   }
   final at = phaseAt(_pattern, elapsed);
   if (at != null &&
       (at.phase.kind != _lastPhase || at.cycle != _lastCycle)) {
     _lastPhase = at.phase.kind;
     _lastCycle = at.cycle;
-    context.read<AppState>().buzzBreathPhase(at.phase.kind);
+    if (mounted) context.read<AppState>().buzzBreathPhase(at.phase.kind);
   }
-  setState(() {});
+  if (mounted) setState(() {});
 }
Suggestion importance[1-10]: 7

__

Why: This is a legitimate defensive fix — _onTick accesses context.read inside an AnimationController listener which can fire during or after disposal. Adding mounted guards prevents potential crashes, and the improved_code correctly reflects the suggested changes.

Medium
Fix force-unwrap crash on missing outcome map key

maps[od['key']]![d] uses ! on the inner map lookup but then indexes by d without a
null-safe operator. maps[od['key']] is force-unwrapped (throws if the key is
absent), and maps[od['key']]![d] returns double? — but if a date in dates (the
numeric-field date axis) was never present in the outcome series, the result is
null, which is silently included. That is the intended behavior for the outcome
list, but the outer ! will throw at runtime if outcomeDefs contains a key not
present in maps (e.g. a new outcome def added without a corresponding map entry).
Use a null-safe lookup for the outer map to return an empty/null list rather than
crashing.

lib/data/local_repository_impl.dart [2918-2921]

 final outcomes = <String, List<double?>>{
   for (final od in outcomeDefs)
-    (od['key'] as String): [for (final d in dates) maps[od['key']]![d]],
+    (od['key'] as String): [
+      for (final d in dates) maps[od['key'] as String]?[d],
+    ],
 };
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that maps[od['key']]! will throw if an outcomeDefs key is absent from maps, which is a real crash risk if outcome definitions and map entries get out of sync. The improved_code accurately applies the null-safe ?. operator to prevent this.

Low
Stale local start time reused across sessions

_localStart is set with ??=, so it is never cleared when a session ends and a new
one begins. After didUpdateWidget stops the ticker and nulls _localStart on the
active → false edge, a subsequent _begin() call correctly sets a fresh _localStart.
However, if _begin() is called again while _localStart is already set (e.g. a second
active true edge without an intervening false edge), the stale start time from the
previous session is reused, making elapsed time and expiry calculations wrong. Clear
_localStart at the top of _begin() when the authoritative widget.startedAt is
available, so the local fallback is always fresh.

lib/ui/stress/calm_breathing_screen.dart [187-195]

 void _begin() {
   _lastPhase = null;
   _lastCycle = -1;
   _finished = false;
-  // Only when the session cannot tell us — otherwise a remount would reset
-  // the clock and restart the session's timing from zero.
+  // If the session gives us an authoritative start, drop any stale local
+  // one so it is not used as a fallback for a different session.
+  if (widget.startedAt != null) _localStart = null;
   _localStart ??= DateTime.now();
   _ticker.repeat();
 }
Suggestion importance[1-10]: 5

__

Why: This is a valid edge case: if _begin() is called twice without an intervening active → false edge, _localStart would retain a stale value. The fix — clearing _localStart when widget.startedAt is available — is sensible and the improved_code accurately reflects the suggestion.

Low
Fix TOCTOU race on profile snapshot before awaits

app.user is read before the await importer.read() call to compute changed, but
app.user is also passed to mergeHealthProfile after the await. If app.user mutates
between those two points (e.g. the user edits their profile in another tab while the
health-store read is in flight), changed reflects the old state while
mergeHealthProfile merges against the new state — the snackbar can claim fields were
updated that were already current, or miss fields that actually changed. Capture
app.user once before the first await and use that snapshot for both calls.

lib/ui/profile/profile_screen.dart [119-138]

 final snap = await importer.read();
 if (!ctx.mounted) return;
 if (snap.isEmpty) {
   messenger.showSnackBar(
     SnackBar(content: Text('Nothing to read from $store')),
   );
   return;
 }
-final changed = healthProfileChanges(app.user, snap);
+final existingUser = app.user;
+final changed = healthProfileChanges(existingUser, snap);
 if (changed.isEmpty) {
   messenger.showSnackBar(
     const SnackBar(content: Text('Your profile already matches')),
   );
   return;
 }
-await app.updateProfile(mergeHealthProfile(app.user, snap));
+await app.updateProfile(mergeHealthProfile(existingUser, snap));
 if (!ctx.mounted) return;
 messenger.showSnackBar(
   SnackBar(content: Text('Updated ${changed.join(', ')}')),
 );
Suggestion importance[1-10]: 5

__

Why: The suggestion is valid — app.user could theoretically change between the two reads across an await. However, in practice this is a very unlikely race condition in a mobile app where profile edits require explicit user action, making the impact low. The fix is correct and the improved_code accurately reflects the change.

Low
Reset session fields on every exit path

_breathingStartedAt and _breathingTarget are cleared only on the happy path inside
stopBreathingSession. If _stopBreathingStreams() or BreathingLiveActivity.end()
throws, the method returns early and those fields are never reset, leaving the next
session with a stale start time and target — the same sticky-latch pattern the
repo's own commit history flags as recurring. Wrap the cleanup in a try/finally so
the fields are always cleared.

lib/state/app_state.dart [3937-3944]

 breathingActive = false;
 _stopBreathingStreams();
 unawaited(BreathingLiveActivity.end());
 
 final started = _breathingStartedAt;
 final target = _breathingTarget;
-_breathingStartedAt = null;
-_breathingTarget = null;
+try {
+  _breathingStartedAt = null;
+  _breathingTarget = null;
+} finally {
+  _breathingStartedAt = null;
+  _breathingTarget = null;
+}
Suggestion importance[1-10]: 2

__

Why: The improved_code is logically flawed — it sets _breathingStartedAt and _breathingTarget to null in both the try and finally blocks, which is redundant and doesn't actually fix anything. Additionally, _stopBreathingStreams() is unlikely to throw (it's a local cleanup method), and BreathingLiveActivity.end() is called with unawaited, so it can't cause an early return. The concern is largely theoretical.

Low
Migration ladder missing breathing_session table creation

The _createBreathingSessions migration at oldV < 30 is missing from the onUpgrade
ladder but _createBreathingSessions is called inside _createUserDataStore (the
onCreate path) and in _repairOpenSchema. Users upgrading from schema 29 will hit
oldV < 31 and get sleep_nap, but will never get breathing_session created via
migration — the table only exists for fresh installs or if _repairOpenSchema happens
to run it. The oldV < 30 branch must call _createBreathingSessions.

lib/data/db.dart [420-428]

+if (oldV < 30) {
+  // Paced-breathing history. New table only.
+  await _createBreathingSessions(db);
+}
+if (oldV < 31) {
+  // User edits to a day's naps. New table only — the detector's own
+  // output is untouched and the edits replay over it.
+  await _createSleepNap(db);
+}
 
-
Suggestion importance[1-10]: 1

__

Why: The existing_code and improved_code are identical, so the suggestion doesn't actually show a fix. Looking at the PR diff, the oldV < 30 branch already calls _createBreathingSessions(db), so the described bug doesn't exist in the PR code — the suggestion is incorrect.

Low
Suggestions up to commit e61c96b
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fresh-install onCreate path missing breathing_session table creation

The onCreate block (for fresh installs) calls _createSleepNap but does NOT call
_createBreathingSessions, even though _createBreathingSessions is added to
_createUserDataStore and the onUpgrade ladder. A fresh install at schema 31 skips
the onUpgrade path entirely, so breathing_session is never created on a new device,
causing every putBreathingSession call to throw. Add _createBreathingSessions to the
onCreate block alongside _createSleepNap.

lib/data/db.dart [420-428]

+    if (oldV < 30) {
+          // Paced-breathing history. New table only.
+          await _createBreathingSessions(db);
+        }
+        if (oldV < 31) {
+          // User edits to a day's naps. New table only — the detector's own
+          // output is untouched and the edits replay over it.
+          await _createSleepNap(db);
+        }
 
-
Suggestion importance[1-10]: 8

__

Why: This is a real bug: fresh installs skip onUpgrade entirely, so breathing_session would never be created on a new device. The improved_code doesn't show the fix (it's identical to existing_code), but the issue itself is valid and critical — every putBreathingSession call would throw on a fresh install. The fix should add _createBreathingSessions to the onCreate block.

Medium
Boolean latch not reset on session stop, blocking next session

When widget.active transitions from true to false in didUpdateWidget, _finished is
not reset to false. If the user starts a new session immediately after one ends
(without leaving the screen), _finished remains true from the previous session,
causing _onTick to return early on every frame — the circle freezes, haptics stop
firing, and a timed session never expires. Reset _finished in the stop branch of
didUpdateWidget.

lib/ui/stress/calm_breathing_screen.dart [150-163]

     if (widget.active) _begin();
   }
 
   @override
   void didUpdateWidget(covariant CalmBreathingView oldWidget) {
     super.didUpdateWidget(oldWidget);
     if (widget.active && !oldWidget.active) {
       _begin();
     } else if (!widget.active && oldWidget.active) {
+      _finished = false;
       _ticker.stop();
       _clock.stop();
       setState(() {});
     }
   }
Suggestion importance[1-10]: 8

__

Why: This is a genuine bug: _finished is only reset inside _begin(), which is called on the active false→true transition. But if the session ends via the timer expiry path (setting _finished = true and calling onStop), the parent flips active to false triggering the stop branch — and _finished stays true. A subsequent session start calls _begin() which does reset _finished, so the bug may not manifest in practice. However, the suggestion correctly identifies that resetting _finished in the stop branch is safer and more defensive.

Medium
Guard mounted before context.read after await

context.read() is called after an await (_rederive is itself async and is called
after two await showTimePicker calls in _addNap). If the widget is unmounted between
the last await in the caller and the context.read here, this will throw. The mounted
guard must come before the context.read, not after it.

lib/ui/sleep/sleep_periods_screen.dart [169-174]

 Future<void> _rederive({int? expectStart}) async {
-  // The edit only shows up once the day is re-derived — nap minutes feed
-  // sleep need and sleep debt, so this is a recompute, not a redraw.
+  if (!mounted) return;
   await context.read<AppState>().reanalyzeForNapEdit();
   if (!mounted) return;
   await _load();
Suggestion importance[1-10]: 8

__

Why: Calling context.read<AppState>() after an await without a prior mounted check is a real bug — if the widget is unmounted between the await in the caller and this line, it will throw a FlutterError. The fix is correct and important.

Medium
Null-safe map lookup prevents crash on missing outcome key

maps[od['key']]![d] uses ! on the inner map lookup but maps[od['key']] may not
contain every date in dates (dates here are the numeric-journal dates, not the
outcome dates), so maps[od['key']]![d] will return null silently via the nullable []
operator — but the list type is List<double?> so that is fine — however maps[od['key']]! will
throw if od['key'] is not a key in maps at all (e.g. a new outcome def added without
a corresponding map entry). Use a null-safe lookup to avoid a crash that would
silently drop all numeric insights.

lib/data/local_repository_impl.dart [2918-2921]

 final outcomes = <String, List<double?>>{
   for (final od in outcomeDefs)
-    (od['key'] as String): [for (final d in dates) maps[od['key']]![d]],
+    (od['key'] as String): [for (final d in dates) maps[od['key']]?[d]],
 };
Suggestion importance[1-10]: 6

__

Why: Using maps[od['key']]! will throw a Null check operator used on a null value if an outcome definition key is not present in maps. Replacing with maps[od['key']]?[d] is a safe and correct fix that prevents a crash silently dropping all numeric insights.

Low
Prevent duplicate session rows from concurrent stop calls

breathingActive is set to false before _breathingStartedAt and _breathingTarget are
cleared. If stopBreathingSession is called concurrently (e.g., from the screen's
_onTick expiry path and from onBack simultaneously), the second call returns early
at if (!breathingActive) return but the first call has already set breathingActive =
false while _breathingStartedAt is still non-null — so the session row is written
twice. Clear the started/target fields atomically with the guard flag, before any
await or unawaited call, to prevent a duplicate putBreathingSession write.

lib/state/app_state.dart [3927-3938]

     Future<void> stopBreathingSession() async {
         if (!breathingActive) return;
-        _breathingRecomputeTimer?.cancel();
-        _breathingRecomputeTimer = null;
         breathingActive = false;
-        _stopBreathingStreams();
-        unawaited(BreathingLiveActivity.end());
-
         final started = _breathingStartedAt;
         final target = _breathingTarget;
         _breathingStartedAt = null;
         _breathingTarget = null;
+        _breathingRecomputeTimer?.cancel();
+        _breathingRecomputeTimer = null;
+        _stopBreathingStreams();
+        unawaited(BreathingLiveActivity.end());
Suggestion importance[1-10]: 5

__

Why: The concern about concurrent calls is valid in theory, but stopBreathingSession is an async function in Dart's single-threaded event loop — true concurrent execution isn't possible. The guard if (!breathingActive) return at the top already prevents re-entry from a second call that arrives after the first has set breathingActive = false. The reordering would improve clarity but doesn't fix a real race condition in this runtime.

Low
General
Use fresh ScaffoldMessenger after async gap

messenger is captured from ScaffoldMessenger.of(ctx) before the await
showModalBottomSheet call. After the await, ctx may be unmounted and the captured
messenger may refer to a detached element. The mounted guard on ctx comes after
app.setBackupCadence(chosen) but the messenger was already captured before the await
— use the messenger captured before the await only if ctx.mounted is checked first,
or capture it after the await with a fresh mounted check.

lib/ui/profile/profile_screen.dart [88-99]

 if (chosen == null) return;
 await app.setBackupCadence(chosen);
 if (!ctx.mounted) return;
-messenger.showSnackBar(
+ScaffoldMessenger.of(ctx).showSnackBar(
+  SnackBar(
+    content: Text(
+      chosen == BackupCadence.off
+          ? 'Automatic backup off'
+          : 'Backing up ${chosen.label.toLowerCase()}',
+    ),
+  ),
+);
Suggestion importance[1-10]: 4

__

Why: The messenger is captured before the await, which is a common Flutter pattern that is actually safe because ScaffoldMessenger is looked up by type and the reference remains valid even after unmounting. The improved_code is functionally equivalent to the existing code, making this a low-impact suggestion.

Low
Suggestions up to commit 1013784
CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard context access after widget disposal

_onTick is an AnimationController listener, which fires every frame (~60×/s).
context.read() is called inside it — this is a Provider access after an await-free
but still asynchronous frame boundary, and more critically it is called from a
listener that can fire after the widget is disposed (the controller is stopped in
dispose but the listener removal and dispose are not atomic). The mounted guard is
absent here, so if the screen is popped while the timer is running, context.read
will throw Provider._inheritedElementOf null — the exact crash pattern flagged in
§4.5. Add a mounted check before any context.read call inside _onTick.

lib/ui/stress/interval_timer_screen.dart [100-126]

 void _onTick() {
-    if (!_running) return;
+    if (!_running || !mounted) return;
     final elapsed = _clock.elapsed;
     final end = _sessionEnd;
     if (end != null && elapsed >= end) {
-      // Three buzzes to say the whole thing is over, distinct from the single
-      // cue at a phase change.
       final app = context.read<AppState>();
       for (final kind in [
         BreathPhaseKind.work,
         BreathPhaseKind.work,
         BreathPhaseKind.work,
       ]) {
         app.buzzBreathPhase(kind);
       }
       _stop();
       return;
     }
     final at = phaseAt(_pattern, elapsed);
     if (at != null &&
         (at.phase.kind != _lastPhase || at.cycle != _lastCycle)) {
       _lastPhase = at.phase.kind;
       _lastCycle = at.cycle;
-      context.read<AppState>().buzzBreathPhase(at.phase.kind);
+      if (mounted) context.read<AppState>().buzzBreathPhase(at.phase.kind);
     }
-    setState(() {});
+    if (mounted) setState(() {});
 }
Suggestion importance[1-10]: 7

__

Why: This is a valid and important fix — context.read<AppState>() inside an AnimationController listener can fire after the widget is disposed, causing a crash. Adding !mounted guards before context.read calls prevents this race condition, and the improved_code accurately reflects the fix.

Medium
Replace force-unwrap with null-safe outcome map lookup

maps[od['key']]![d] uses ! to force-unwrap the inner map and then indexes by d. If a
date in dates (derived from metricsByDay) has no entry in maps[od['key']] (an
outcome map keyed by journal dates, not metric dates), this will return null
silently — but the ! on the outer map will throw if the outcome key itself is
absent. More critically, maps is built from metric_series dates while dates comes
from metricsByDay keys; days with journal numbers but no outcome data will produce
null entries correctly only if the map lookup returns null rather than throwing. The
! force-unwrap on the outer map should be replaced with a null-safe lookup to avoid
a crash when an outcome key is unexpectedly missing.

lib/data/local_repository_impl.dart [2918-2921]

 final outcomes = <String, List<double?>>{
   for (final od in outcomeDefs)
-    (od['key'] as String): [for (final d in dates) maps[od['key']]![d]],
+    (od['key'] as String): [for (final d in dates) maps[od['key']]?[d]],
 };
Suggestion importance[1-10]: 7

__

Why: The ! force-unwrap on maps[od['key']] could throw if an outcome key is unexpectedly absent from maps, and replacing it with ?. is a meaningful safety improvement. The improved_code correctly changes maps[od['key']]![d] to maps[od['key']]?[d], which is a valid and accurate fix.

Medium
Guard context access before await in rederive

context.read() is called after an await (the reanalyzeForNapEdit call itself is
awaited, but the context.read happens before it — however the real issue is that
_rederive is called after awaits in _addNap and _removeNap, and context may be stale
by the time _rederive runs). More critically, if reanalyzeForNapEdit throws, the
failure path falls through without any cleanup, and the widget may be unmounted. The
context.read should be captured before the first await in the calling methods, or at
minimum guarded with a mounted check before use.

lib/ui/sleep/sleep_periods_screen.dart [164-169]

 Future<void> _rederive() async {
   // The edit only shows up once the day is re-derived — nap minutes feed
   // sleep need and sleep debt, so this is a recompute, not a redraw.
-  await context.read<AppState>().reanalyzeForNapEdit();
+  if (!mounted) return;
+  final app = context.read<AppState>();
+  await app.reanalyzeForNapEdit();
   if (mounted) await _load();
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that context.read should be guarded with a mounted check before the await. However, context.read itself is synchronous and called before the await, so the actual risk is lower than described — the main benefit is the early return guard preventing unnecessary work when unmounted.

Low
Reset boolean latch on the failure path

breathingActive is set to false before the session-banking block runs, but there is
no try/finally around the banking logic. If LocalDb.putBreathingSession or any
intermediate step throws, breathingActive stays false (correct) but
_breathingStartedAt is already cleared — that part is fine. The real latch problem
is that _breathingRecomputeTimer is cancelled and nulled, but if
stopBreathingSession is called while _recomputeBreathingCoherence is in-flight and
breathingActive is already false, the guard if (!breathingActive …) return at the
top of stopBreathingSession prevents a double-stop, which is correct. However,
_breathingEnabledStreams is never reset to false on the failure path inside
startBreathingSession — if _enableBreathingStreams() throws after breathingActive =
true, the flag stays set and a subsequent startBreathingSession call skips
re-enabling streams. Wrap the stream-enable block in a try/catch that resets
breathingActive and _breathingEnabledStreams on failure, matching the pattern the
repo's own commit messages call out for sticky boolean latches.

lib/state/app_state.dart [3902-3909]

-Future<void> stopBreathingSession() async {
-    if (!breathingActive) return;
-    _breathingRecomputeTimer?.cancel();
-    _breathingRecomputeTimer = null;
-    breathingActive = false;
-    _stopBreathingStreams();
-    unawaited(BreathingLiveActivity.end());
+Future<void> startBreathingSession({BreathPattern? pattern}) async {
+    if (breathingActive) return;
+    if (!isConnected) {
+      breathingError = 'Connect your band first.';
+      notifyListeners();
+      return;
+    }
+    breathingPattern = pattern ?? breathingPattern;
+    breathingActive = true;
+    breathingResult = null;
+    breathingError = null;
+    _breathingFrames.clear();
+    _breathingStartedAt = DateTime.now();
+    notifyListeners();
+    unawaited(BreathingLiveActivity.start(startedAt: DateTime.now()));
+    try {
+      // ... existing stream-enable logic ...
+    } catch (e) {
+      breathingActive = false;
+      _breathingEnabledStreams = false;
+      _breathingStartedAt = null;
+      breathingError = e.toString();
+      notifyListeners();
+    }
+}
Suggestion importance[1-10]: 3

__

Why: The suggestion raises a valid concern about _breathingEnabledStreams not being reset on failure, but the improved_code shows startBreathingSession rather than stopBreathingSession (which is what existing_code shows), making the mapping inconsistent. The actual failure path issue is real but minor, and the suggestion conflates two different methods.

Low
General
Capture user snapshot before awaits to avoid TOCTOU

app.user is read after await importer.read() and again after await
app.updateProfile(...). Between those awaits the widget tree may have changed and
app.user could reflect a different state. The changed list is computed from the
pre-update app.user but the snackbar fires after the update — if updateProfile
itself mutates app.user before the snackbar, the message is still correct, but
healthProfileChanges and mergeHealthProfile are both called on the same snapshot, so
capture it once before any await to avoid a TOCTOU on the user object.

lib/ui/profile/profile_screen.dart [119-138]

 final snap = await importer.read();
 if (!ctx.mounted) return;
 if (snap.isEmpty) {
   messenger.showSnackBar(
     SnackBar(content: Text('Nothing to read from $store')),
   );
   return;
 }
-final changed = healthProfileChanges(app.user, snap);
+final currentUser = app.user;
+final changed = healthProfileChanges(currentUser, snap);
 if (changed.isEmpty) {
   messenger.showSnackBar(
     const SnackBar(content: Text('Your profile already matches')),
   );
   return;
 }
-await app.updateProfile(mergeHealthProfile(app.user, snap));
+await app.updateProfile(mergeHealthProfile(currentUser, snap));
 if (!ctx.mounted) return;
 messenger.showSnackBar(
   SnackBar(content: Text('Updated ${changed.join(', ')}')),
 );
Suggestion importance[1-10]: 4

__

Why: Capturing app.user in a local variable before awaits is a valid defensive pattern, but since healthProfileChanges and mergeHealthProfile are called on the same app.user reference without intervening awaits between them, the actual TOCTOU risk is minimal in practice.

Low
Verify fresh-install onCreate covers all new tables

_createSleepNap is also called unconditionally in _repairOpenSchema (the onOpen
path), which is correct for self-healing. However, _createBreathingSessions is added
to _repairOpenSchema via _createUserDataStore but is NOT added to the onUpgrade
ladder's oldV < 30 branch's equivalent repair path — it IS there. The real issue is
the inverse: _createSleepNap is called in onCreate and in _repairOpenSchema, but
_createBreathingSessions is only reached through _createUserDataStore in
_repairOpenSchema. A user upgrading from schema 28 (skipping v29 entirely, e.g. a
fresh install of this build over a very old one) hits oldV < 30 and oldV < 31
sequentially, which is correct. But a user on schema 29 only hits oldV < 30, getting
breathing_session but NOT sleep_nap — they need oldV < 31 too, which they do get.
This is actually fine. The real defect is that schemaVersion jumped from 29 to 31,
skipping 30, but the ladder has both < 30 and < 31 guards — a device on schema 29
will run both, which is correct. However a device on schema 30 (impossible in this
repo but possible if a branch was merged) would only run < 31. More concretely: the
onCreate path calls _createSleepNap but does NOT call _createBreathingSessions
directly — it goes through _createUserDataStore. Verify _createUserDataStore is
called in onCreate; if it is not, breathing_session is missing from fresh installs.

lib/data/db.dart [420-428]

 if (oldV < 30) {
       // Paced-breathing history. New table only.
       await _createBreathingSessions(db);
     }
     if (oldV < 31) {
       // User edits to a day's naps. New table only — the detector's own
       // output is untouched and the edits replay over it.
       await _createSleepNap(db);
     }
+// Ensure onCreate also explicitly calls _createBreathingSessions if it is
+// not already reached through _createUserDataStore in the onCreate block.
Suggestion importance[1-10]: 2

__

Why: The suggestion asks the developer to verify that _createUserDataStore is called in onCreate, but the PR diff already shows _createBreathingSessions is called in _createUserDataStore and _createSleepNap is called directly in onCreate. The improved_code is essentially identical to existing_code with only a comment added, making this a low-value verification suggestion.

Low
Suggestions up to commit f707b6b
CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard context use before await completes

context.read() is called after an await without a mounted guard, which is a known
crash source in this codebase (§4.5). If the widget is unmounted while
reanalyzeForNapEdit is running, the subsequent context.read will throw. The mounted
check must come before the context.read, not only before _load.

lib/ui/sleep/sleep_periods_screen.dart [164-169]

 Future<void> _rederive() async {
   // The edit only shows up once the day is re-derived — nap minutes feed
   // sleep need and sleep debt, so this is a recompute, not a redraw.
+  if (!mounted) return;
   await context.read<AppState>().reanalyzeForNapEdit();
   if (mounted) await _load();
 }
Suggestion importance[1-10]: 7

__

Why: The context.read<AppState>() call happens after an await without a prior mounted check, which can cause a crash if the widget is unmounted during reanalyzeForNapEdit. Adding if (!mounted) return; before the context.read is a valid safety fix.

Medium
Timestamp marked outside backup lock enables duplicate exports

_runBackup is called directly inside the serialized closure rather than going
through _serialize again, which is correct. However, markRun is only called when
outcome.succeeded is true, but _runBackup catches all exceptions internally and
returns a BackupOutcome(error: ...) — so a failed backup does not update lastRun,
which is correct. The real issue is that runBackupNow in app_state.dart calls
runBackup() (which goes through _serialize) and then calls
_markBackupRun(DateTime.now()) OUTSIDE the lock. This means the timestamp written to
prefs can be DateTime.now() at the time of the mark call, which may differ from the
when used inside _runBackup, and more importantly a second concurrent runBackupIfDue
call could read the stale lastRun before _markBackupRun executes. The markRun
callback pattern in runBackupIfDue correctly closes this race, but runBackupNow
bypasses it. Fix runBackupNow to use runBackupIfDue with lastRun: () => null (always
due) or pass markRun into runBackup.

lib/data/auto_backup.dart [247-260]

-  Future<BackupOutcome> runBackupIfDue({
-    required BackupCadence cadence,
-    required DateTime? Function() lastRun,
-    required Future<void> Function(DateTime) markRun,
-    DateTime? now,
-  }) => _serialize(() async {
-    final when = now ?? DateTime.now();
-    if (!backupIsDue(cadence: cadence, lastRun: lastRun(), now: when)) {
-      return const BackupOutcome(skipped: true);
+    Future<BackupOutcome> runBackupNow() async {
+      final outcome = await backup.runBackupIfDue(
+        cadence: BackupCadence.daily, // always due: lastRun returns null
+        lastRun: () => null,
+        markRun: (when) async => _markBackupRun(when),
+      );
+      return outcome;
     }
-    final outcome = await _runBackup(now: when);
-    if (outcome.succeeded) await markRun(when);
-    return outcome;
-  });
Suggestion importance[1-10]: 5

__

Why: The observation that runBackupNow in app_state.dart marks the timestamp outside the serialization lock is accurate and could theoretically allow a race condition. However, the improved_code uses BackupCadence.daily with lastRun: () => null which always triggers a backup regardless of cadence, which is a behavioral change. The concern is real but the fix is imprecise.

Low
Boolean latch missing reset on failure path

breathingActive is set to false and _breathingStartedAt is cleared before the
unawaited DB write, but if _stopBreathingStreams() or BreathingLiveActivity.end()
throws, breathingActive stays false while _breathingStartedAt may still hold a value
— the latch has no try/finally. More critically, _breathingStartedAt is set in
startBreathingSession but never cleared on the error path (the try/catch inside
startBreathingSession does not null it), so a session that fails to start leaves a
stale timestamp that will be used by the next successful stop. Wrap the body in a
try/finally that nulls _breathingStartedAt and clears breathingActive.

lib/state/app_state.dart [3899-3931]

     Future<void> stopBreathingSession() async {
       if (!breathingActive) return;
       _breathingRecomputeTimer?.cancel();
       _breathingRecomputeTimer = null;
       breathingActive = false;
-      _stopBreathingStreams();
-      unawaited(BreathingLiveActivity.end());
+      try {
+        _stopBreathingStreams();
+        unawaited(BreathingLiveActivity.end());
 
-      final started = _breathingStartedAt;
-      _breathingStartedAt = null;
-      if (started != null) {
-        ...
+        final started = _breathingStartedAt;
+        if (started != null) {
+          final ended = DateTime.now();
+          final seconds = ended.difference(started).inSeconds;
+          if (seconds >= 60) {
+            final res = breathingResult;
+            final scored = res != null && res['ok'] == true;
+            final rated = breathingPattern.coherenceRated && scored;
+            unawaited(
+              LocalDb.putBreathingSession(
+                startedAt: started.millisecondsSinceEpoch,
+                endedAt: ended.millisecondsSinceEpoch,
+                pattern: breathingPattern.key,
+                seconds: seconds,
+                coherence: rated ? (res['score'] as num?)?.toDouble() : null,
+                confidence: rated ? (res['confidence'] as num?)?.toDouble() : null,
+              ),
+            );
+          }
+        }
+      } finally {
+        _breathingStartedAt = null;
+        notifyListeners();
       }
-      notifyListeners();
     }
Suggestion importance[1-10]: 4

__

Why: The suggestion raises a valid concern about _breathingStartedAt not being cleared on error paths, but the improved_code restructures the logic significantly and the actual risk in the PR code is low since _stopBreathingStreams() and BreathingLiveActivity.end() are unlikely to throw in practice. The suggestion is partially accurate but overstates the severity.

Low
General
Use null-safe map lookup to prevent crash

maps[od['key']]![d] uses ! to force-unwrap the inner map lookup, but maps[od['key']]
is built from outcomeDefs keys and d iterates over metricsByDay dates — a date
present in the journal but absent from the outcome series will return null from the
inner [] operator, not throw, so the ! is on the outer map (which is safe), but the
inner [d] silently produces null which is already typed as double?. However, if an
outcomeDefs key is somehow absent from maps, the ! will throw a null-dereference at
runtime. The existing maps construction should guarantee all keys are present, but
the force-unwrap should use a null-safe fallback to avoid a crash fabricating no
value rather than abstaining.

lib/data/local_repository_impl.dart [2918-2921]

 final outcomes = <String, List<double?>>{
   for (final od in outcomeDefs)
-    (od['key'] as String): [for (final d in dates) maps[od['key']]![d]],
+    (od['key'] as String): [for (final d in dates) maps[od['key']]?[d]],
 };
Suggestion importance[1-10]: 5

__

Why: Replacing maps[od['key']]! with maps[od['key']]? prevents a potential null-dereference crash if an outcomeDefs key is somehow absent from maps. While the construction logic should guarantee all keys are present, the null-safe operator is a safer defensive pattern with minimal cost.

Low
Add mounted guard before profile update

app.user is read after await importer.read() and again after await
app.updateProfile(...) without re-checking ctx.mounted before the second read of
app.user in mergeHealthProfile. More critically, messenger is captured before the
first await, which is the correct pattern, but app is a context.read result used
across multiple awaits — if the widget tree rebuilds between await importer.read()
and await app.updateProfile(...), app could be stale. The mounted check before
app.updateProfile is missing.

lib/ui/profile/profile_screen.dart [119-138]

 final snap = await importer.read();
 if (!ctx.mounted) return;
 if (snap.isEmpty) {
   messenger.showSnackBar(
     SnackBar(content: Text('Nothing to read from $store')),
   );
   return;
 }
 final changed = healthProfileChanges(app.user, snap);
 if (changed.isEmpty) {
   messenger.showSnackBar(
     const SnackBar(content: Text('Your profile already matches')),
   );
   return;
 }
+if (!ctx.mounted) return;
 await app.updateProfile(mergeHealthProfile(app.user, snap));
 if (!ctx.mounted) return;
 messenge...

@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: 12

Caution

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

⚠️ Outside diff range comments (4)
test/calm_breathing_view_test.dart (1)

33-43: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the new pattern and duration selection behavior.

This file still tests only the pre-existing states. The PR adds pattern chips, duration chips, coherence suppression for non-resonance patterns, and automatic stop at the session deadline. None of these are asserted. Add widget tests for at least these cases:

  • Selecting a non-resonance pattern hides the coherence readout and shows the "No coherence score for this pattern" text.
  • Selecting a pattern and tapping start passes that pattern to onStart.
  • Selecting "Open" changes the button text to "Begin".

Do you want me to generate these tests?

As per coding guidelines: "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 `@test/calm_breathing_view_test.dart` around lines 33 - 43, Extend the widget
tests around CalmBreathingView to cover the new pattern and duration selection
behavior: verify a non-resonance pattern hides the coherence readout and
displays “No coherence score for this pattern,” selecting a pattern passes it to
onStart when starting, and selecting “Open” changes the action button text to
“Begin.”

Source: Coding guidelines

lib/data/local_repository_impl.dart (1)

2764-2974: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add regression coverage for numeric journal insights.

Add tests for numeric-only journal days and for tag history below four days. Verify that numeric_insights still returns results. Verify custom and deleted field labels use the expected fallback.

As per coding guidelines, “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/data/local_repository_impl.dart` around lines 2764 - 2974, Add regression
tests covering the journal insights flow around _numericJournalInsights: verify
numeric-only journal days still produce numeric_insights, tag history with fewer
than four days preserves those results, and custom or deleted fields use the
expected label fallback. Keep assertions focused on numeric_insights contents
and field_label behavior.

Source: Coding guidelines

lib/data/db.dart (1)

3720-3740: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

sleep_nap is missing from the cross-device import table list.

importFromDbFile's tables list adds 'breathing_session' (line 3735), but not 'sleep_nap'. Both tables are new in this PR. Importing another device's backup through importFromDbFile silently drops every nap edit from the source device: the receiving device's merged nap list reverts to the raw detector output on the next derivation of each affected day, with no error surfaced.

🐛 Proposed fix
       'lab_result',
       'lab_marker_def',
       'breathing_session',
+      'sleep_nap',
       'cycle_log',
       'notifications',
       'baselines',
       'sync_cursor',
     ];

Also verify exportDaysDb's per-day copy loop and its destination onCreate (around lines 3408 and 3578): neither creates nor copies sleep_nap, so a selected-days export can't carry nap edits either.

🤖 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 3720 - 3740, Update the importFromDbFile
tables list to include sleep_nap alongside the other imported tables. Also
update exportDaysDb’s per-day copy loop and destination onCreate schema so
sleep_nap is created and copied for selected-day exports, preserving nap edits
across device backups.
lib/compute/derivation_engine.dart (1)

4382-4523: 🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Nap edits are dropped whenever the detector abstains.

_attachNaps calls applyNapEdits(detected, napEdits) only on the detector-success path (line 4484). The three abstention/error exits return null before ever calling applyNapEdits:

  • n < 60 ("too little 1 Hz data"), lines 4398-4401.
  • !m.present, lines 4425-4434.
  • the catch (e) block, lines 4518-4522.

A manually-logged nap does not depend on 1 Hz data or on the detector succeeding. It is a time window the user typed in. On a day where the detector can't run or abstains — exactly the case this feature exists for ("I took a two-hour nap and it wasn't counted") — the user's manually logged nap is silently discarded, and nap_min never credits it. The same applies to a rejected edit, though that case matters less since there is nothing detected to reject.

Route every exit through applyNapEdits, not only the success path. One approach: build detected (empty on abstention) first, then always merge, and only fall back to _writeUnknownNaps when the merged result is also empty.

🐛 Illustrative fix for the `n < 60` branch (apply the same pattern to the other two exits, ideally via a shared helper)
       final n = s.length;
       if (n < 60) {
-        _writeUnknownNaps(bundle, 'too little 1 Hz data to assess naps');
-        return null;
+        final merged = applyNapEdits(const [], napEdits);
+        if (merged.isEmpty) {
+          _writeUnknownNaps(bundle, 'too little 1 Hz data to assess naps');
+          return null;
+        }
+        bundle['naps'] = <String, dynamic>{
+          'value': merged,
+          'count': merged.length,
+          'confidence': 0,
+          'tier': 'ESTIMATE',
+          'inputs_used': const <String>[],
+          'note': 'too little 1 Hz data to assess naps (edited)',
+        };
+        scMap?['nap_min'] = napMinutes(merged).toDouble();
+        return [
+          for (final nap in merged)
+            {
+              'is_main': false,
+              'onset_ts': nap['start'],
+              'wake_ts': nap['end'],
+              'duration_min': nap['duration_min'],
+              'in_bed_min': nap['in_bed_min'],
+              'efficiency': nap['efficiency'],
+              'confidence': nap['confidence'],
+              if (nap['source'] != null) 'source': nap['source'],
+            },
+        ];
       }

This is also an abstention-behavior change with no regression test. Add a test (via debugAttachNaps) that exercises n < 60 and !m.present with a non-empty napEdits list and asserts the manual nap survives.

As per coding guidelines, "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/compute/derivation_engine.dart` around lines 4382 - 4523, Update
_attachNaps so every exit, including the n < 60 branch, the !m.present branch,
and the catch block, applies napEdits to an empty detected list before
returning. Reuse a shared merge/finalization path that updates bundle naps,
nap_min, and returned nap data consistently; only write unknown naps when the
merged result remains empty. Add debugAttachNaps regression tests covering both
insufficient data and detector abstention with non-empty napEdits, asserting the
manual nap is preserved.

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/data/auto_backup.dart`:
- Around line 93-99: Update sortBackupsNewestFirst to accept only filenames
matching the complete format produced by backupFileName, rather than any
openstrap-*.db file. In test/auto_backup_test.dart lines 159-169, add
openstrap-notes.db and assert it is neither selected by the retention logic nor
deleted.
- Around line 130-175: Update runBackupIfDue and the backup coordination around
runBackup so due evaluation, export, unique destination-name allocation,
pruning, and successful last-run persistence execute through one serialized
coordinator; do not rely on the caller’s stale lastRun during concurrent or
cadence-changing triggers. Make backupFileName allocation collision-free for
runs within the same minute, preserve skipped outcomes for backups that are not
due, and add a regression test covering concurrent triggers.
- Around line 45-48: Update the BackupCadence interval logic in
lib/data/auto_backup.dart:45-48 to use local day-label comparisons via
todayLabel() or dayLabelOf() from data/day_label.dart instead of fixed Duration
values, while preserving absolute epoch timestamps. Add daily and weekly
DST-boundary regression cases in test/auto_backup_test.dart:41-77 that verify
cadence behavior across local calendar days without assuming each day lasts
86400 seconds.
- Around line 121-126: Update backupDirectory() to use a user-accessible shared
Documents, Downloads, or Storify location on Android instead of
getApplicationDocumentsDirectory(), applying the required scoped-storage
handling while preserving the existing directory creation behavior. Also update
the temporary-file move flow to copy and then remove the source when a direct
rename cannot cross filesystems.

In `@lib/health/health_profile_import.dart`:
- Around line 117-128: Update requestPermission() and the Android manifest to
declare, check, and request Health Connect history permission using the Health
package API when supported, alongside the existing type-specific read
permissions. Update read() so ten-year queries are performed only when history
access is granted; otherwise constrain the query to Health Connect’s permitted
30-day window, and add regression coverage for an Android metric older than 30
days.

In `@lib/ui/journal/journal_screen.dart`:
- Around line 437-446: Update the methodology text associated with the InfoDot
near the journal insights section to state that insights use both tagged days
and numeric journal entries, rather than tagged days only. Preserve the existing
text styling and surrounding insight-card rendering.

In `@lib/ui/stress/calm_breathing_screen.dart`:
- Around line 105-115: Replace the duplicated _defaultPattern definition with
the resonance entry from kBreathPatterns in breath_phases.dart, and update the
widget’s pattern handling so the default is resolved from that table in
initState rather than requiring a const default. Preserve explicit
widget.pattern values and use the shared entry, including its description and
phase timings, when no pattern is provided.
- Around line 182-201: Add a one-shot completion latch to the state containing
_onTick, check it before invoking widget.onStop, and set it immediately when
remaining reaches zero so asynchronous parent updates cannot trigger repeated
stops. Reset the latch when a new breathing session starts, alongside the
existing timer/elapsed-state initialization.

In `@lib/ui/stress/interval_timer_screen.dart`:
- Around line 59-65: Update dispose to invoke the existing _stop cleanup before
disposing the ticker, ensuring the screen wake lock is released and the clock is
stopped when the screen is removed while running. Preserve the listener removal,
ticker disposal, and super.dispose() calls.
- Around line 100-113: Update the completion branch in the interval timer screen
so the three work-phase buzzes remain fire-and-forget and are dispatched at
fixed millisecond gaps via a Timer.periodic completion handler, without awaiting
or sequentially delaying buzzBreathPhase. Preserve the existing _stop() and
return behavior, and cancel the periodic timer after all three cues are sent.

In `@lib/ui/workouts/workouts_screen.dart`:
- Around line 104-112: Update the onTap callback for IntervalTimerScreen to
guard the outer context after Navigator.pop before pushing the route. Either
capture the navigator before popping and reuse it, or check context.mounted
before Navigator.of(context).push, while preserving the existing themedRoute
configuration.

In `@test/auto_backup_test.dart`:
- Around line 159-169: Add the look-alike file openstrap-notes.db to the
sortBackupsNewestFirst test fixtures and update the expected filenames so only
the valid timestamped backup remains in the returned set, verifying the
look-alike is excluded.

---

Outside diff comments:
In `@lib/compute/derivation_engine.dart`:
- Around line 4382-4523: Update _attachNaps so every exit, including the n < 60
branch, the !m.present branch, and the catch block, applies napEdits to an empty
detected list before returning. Reuse a shared merge/finalization path that
updates bundle naps, nap_min, and returned nap data consistently; only write
unknown naps when the merged result remains empty. Add debugAttachNaps
regression tests covering both insufficient data and detector abstention with
non-empty napEdits, asserting the manual nap is preserved.

In `@lib/data/db.dart`:
- Around line 3720-3740: Update the importFromDbFile tables list to include
sleep_nap alongside the other imported tables. Also update exportDaysDb’s
per-day copy loop and destination onCreate schema so sleep_nap is created and
copied for selected-day exports, preserving nap edits across device backups.

In `@lib/data/local_repository_impl.dart`:
- Around line 2764-2974: Add regression tests covering the journal insights flow
around _numericJournalInsights: verify numeric-only journal days still produce
numeric_insights, tag history with fewer than four days preserves those results,
and custom or deleted fields use the expected label fallback. Keep assertions
focused on numeric_insights contents and field_label behavior.

In `@test/calm_breathing_view_test.dart`:
- Around line 33-43: Extend the widget tests around CalmBreathingView to cover
the new pattern and duration selection behavior: verify a non-resonance pattern
hides the coherence readout and displays “No coherence score for this pattern,”
selecting a pattern passes it to onStart when starting, and selecting “Open”
changes the action button text to “Begin.”
🪄 Autofix

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: 316466e8-65ec-4e91-9330-4254fa6b2257

📥 Commits

Reviewing files that changed from the base of the PR and between a5032f3 and d6e42de.

⛔ Files ignored due to path filters (1)
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (26)
  • android/app/src/main/AndroidManifest.xml
  • ios/Runner/Info.plist
  • lib/app.dart
  • lib/compute/derivation_engine.dart
  • lib/compute/nap_edits.dart
  • lib/data/auto_backup.dart
  • lib/data/db.dart
  • lib/data/local_repository_impl.dart
  • lib/health/health_profile_import.dart
  • lib/state/app_state.dart
  • lib/state/prefs.dart
  • lib/ui/journal/journal_screen.dart
  • lib/ui/profile/profile_screen.dart
  • lib/ui/sleep/sleep_periods_screen.dart
  • lib/ui/stress/breath_phases.dart
  • lib/ui/stress/calm_breathing_screen.dart
  • lib/ui/stress/interval_timer_screen.dart
  • lib/ui/workouts/workout_types.dart
  • lib/ui/workouts/workouts_screen.dart
  • pubspec.yaml
  • test/auto_backup_test.dart
  • test/breath_phases_test.dart
  • test/calm_breathing_view_test.dart
  • test/db_migration_ladder_test.dart
  • test/health_profile_import_test.dart
  • test/nap_edits_test.dart

Comment thread lib/data/auto_backup.dart
Comment on lines +45 to +48
Duration? get interval => switch (this) {
BackupCadence.off => null,
BackupCadence.daily => const Duration(days: 1),
BackupCadence.weekly => const Duration(days: 7),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use local calendar boundaries for backup cadence.

The implementation and tests model local days as fixed 24-hour durations. DST makes that assumption false.

  • lib/data/auto_backup.dart#L45-L48: replace fixed Duration(days: ...) cadence checks with local day-label comparisons.
  • test/auto_backup_test.dart#L41-L77: add daily and weekly DST-boundary regression cases.

As per coding guidelines, “Use todayLabel() or dayLabelOf() from data/day_label.dart for local day labels; do not derive labels from UTC strings. Keep epoch timestamps absolute and do not assume every day is 86400 seconds.”

📍 Affects 2 files
  • lib/data/auto_backup.dart#L45-L48 (this comment)
  • test/auto_backup_test.dart#L41-L77
🤖 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/auto_backup.dart` around lines 45 - 48, Update the BackupCadence
interval logic in lib/data/auto_backup.dart:45-48 to use local day-label
comparisons via todayLabel() or dayLabelOf() from data/day_label.dart instead of
fixed Duration values, while preserving absolute epoch timestamps. Add daily and
weekly DST-boundary regression cases in test/auto_backup_test.dart:41-77 that
verify cadence behavior across local calendar days without assuming each day
lasts 86400 seconds.

Source: Coding guidelines

Comment thread lib/data/auto_backup.dart
Comment thread lib/data/auto_backup.dart
Comment thread lib/data/auto_backup.dart Outdated
Comment thread lib/health/health_profile_import.dart
Comment thread lib/ui/stress/calm_breathing_screen.dart
Comment on lines +59 to +65
@override
void dispose() {
_ticker
..removeListener(_onTick)
..dispose();
super.dispose();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Release the screen wake lock in dispose.

ScreenWake.enable() runs in _start. Only _stop releases it. If the user leaves the screen while the timer runs, dispose runs without _stop, and the wake lock stays held for the rest of the app session. The clock and the ticker also keep the state alive until garbage collection.

Release the wake lock and stop the clock in dispose.

🐛 Proposed fix
   `@override`
   void dispose() {
+    if (_running) {
+      _clock.stop();
+      ScreenWake.release();
+    }
     _ticker
       ..removeListener(_onTick)
       ..dispose();
     super.dispose();
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@override
void dispose() {
_ticker
..removeListener(_onTick)
..dispose();
super.dispose();
}
`@override`
void dispose() {
if (_running) {
_clock.stop();
ScreenWake.release();
}
_ticker
..removeListener(_onTick)
..dispose();
super.dispose();
}
🤖 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/ui/stress/interval_timer_screen.dart` around lines 59 - 65, Update
dispose to invoke the existing _stop cleanup before disposing the ticker,
ensuring the screen wake lock is released and the clock is stopped when the
screen is removed while running. Preserve the listener removal, ticker disposal,
and super.dispose() calls.

Comment on lines +100 to +113
if (end != null && elapsed >= end) {
// Three buzzes to say the whole thing is over, distinct from the single
// cue at a phase change.
final app = context.read<AppState>();
for (final kind in [
BreathPhaseKind.work,
BreathPhaseKind.work,
BreathPhaseKind.work,
]) {
app.buzzBreathPhase(kind);
}
_stop();
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect buzzBreathPhase and the underlying strap buzz implementation for coalescing or in-flight guards.
set -euo pipefail

rg -n -C 15 'void buzzBreathPhase' --type=dart lib
rg -n -C 10 -P '\b(buzzPattern|Future<[^>]*>\s+buzz)\s*\(' --type=dart lib

Repository: OpenStrap/edge

Length of output: 11680


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## Locate engine class and send implementation"
rg -n 'class .*Engine|abstract class .*Engine|Future<void> _send|Cmd\.runHapticsPattern|_last.*Buzz|buzz.*inflight|canWrite|writeWithoutResponse' --type=dart lib state

echo
echo "## Suggested slices around buzz calls and send"
for f in lib/ble/ble_engine.dart lib/state/app_state.dart; do
  if [ -f "$f" ]; then
    echo "### $f"
    wc -l "$f"
    nl -ba "$f" | sed -n '2500,3190p'
  fi
done

echo
echo "## Interval timer slice"
if [ -f lib/ui/stress/interval_timer_screen.dart ]; then
  nl -ba lib/ui/stress/interval_timer_screen.dart | sed -n '80,125p'
fi

Repository: OpenStrap/edge

Length of output: 1422


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## Relevant app_state slice"
nl -ba lib/state/app_state.dart | sed -n '3938,3956p'

echo
echo "## Relevant bleach_engine buzz/write slices"
for s in 280,316,300,250,220,180; do
  nl -ba lib/ble/ble_engine.dart | sed -n "\\$((BL_START=${s%100+21})),\\$((BL_END=${s+35})p"
done

echo
echo "## Search BLE write/send guards and haptic definitions"
rg -n -C 8 '(runHapticsPattern|_send\(|writeWithoutResponse|writeCharacteristic|_lastAnyBuzzMs|inflight|busy|debounce|_hapticShortPulse|_last.*BuzzMs|buzz' lib/ble lib/notify lib/state app_state.dart | sed -n '1,260p'

Repository: OpenStrap/edge

Length of output: 220


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## Relevant app_state slice"
awk '{printf "%6d  ", NR} {print}' lib/state/app_state.dart | sed -n '3938,3956p'

echo
echo "## Relevant ble_engine slices"
awk '{printf "%6d  ", NR} {print}' lib/ble/ble_engine.dart | sed -n '300,350p'

echo
echo "## Search BLE write/send and haptic-related code"
rg -n -C 8 '(runHapticsPattern|_send\(|writeWithoutResponse|writeCharacteristic|_lastAnyBuzzMs|inflight|busy|debounce|buzz|last.*Buzz)' lib/ble lib/notify lib/state | sed -n '1,260p'

Repository: OpenStrap/edge

Length of output: 24456


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## All Cmd.runHapticsPattern references"
rg -n -C 8 'Cmd\.runHapticsPattern|runHapticsPattern|buzzPattern|int pattern|int lastBuzz|_last.*BuzzMs|lastBuzz' lib

echo
echo "## app_state notification relay initialization"
awk '{printf "%6d  ", NR} {print}' lib/state/app_state.dart | sed -n '150,185p'

echo
echo "## notification_relay buzz dispatch"
awk '{printf "%6d  ", NR} {print}' lib/notify/notification_relay.dart | sed -n '1,230p'

echo
echo "## interval timer completion block (if present)"
if [ -f lib/ui/stress/interval_timer_screen.dart ]; then
  awk '{printf "%6d  ", NR} {print}' lib/ui/stress/interval_timer_screen.dart | sed -n '90,120p'
fi

Repository: OpenStrap/edge

Length of output: 26359


Keep the completion cue fire-and-forget instead of waiting on sequential buzzes.

buzzBreathPhase intentionally never awaits the BLE write; adding timing between the three calls or awaiting them here risks stalling the frame/timer. Send three buzzBreathPhase(BreathPhaseKind.work) calls at fixed millisecond gaps from a Timer.periodic completion handler, or expose a dedicated completion haptic pattern if the engine adds one.

🤖 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/ui/stress/interval_timer_screen.dart` around lines 100 - 113, Update the
completion branch in the interval timer screen so the three work-phase buzzes
remain fire-and-forget and are dispatched at fixed millisecond gaps via a
Timer.periodic completion handler, without awaiting or sequentially delaying
buzzBreathPhase. Preserve the existing _stop() and return behavior, and cancel
the periodic timer after all three cues are sent.

Comment on lines +104 to +112
onTap: () {
Navigator.pop(ctx);
Navigator.of(context).push(
themedRoute(
(_) => const IntervalTimerScreen(),
name: 'IntervalTimerScreen',
),
);
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Guard the outer context after Navigator.pop.

The callback pops the sheet and then uses the outer context to push a route. No await separates the two statements, so this works today. The coding guidelines still require a mounted guard on context use after navigation. Capture the navigator before the pop, or check context.mounted.

♻️ Proposed fix
         onTap: () {
+          final nav = Navigator.of(context);
           Navigator.pop(ctx);
-          Navigator.of(context).push(
+          nav.push(
             themedRoute(
               (_) => const IntervalTimerScreen(),
               name: 'IntervalTimerScreen',
             ),
           );
         },

As per coding guidelines: "After await or navigation, guard context and UI operations with mounted checks".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onTap: () {
Navigator.pop(ctx);
Navigator.of(context).push(
themedRoute(
(_) => const IntervalTimerScreen(),
name: 'IntervalTimerScreen',
),
);
},
onTap: () {
final nav = Navigator.of(context);
Navigator.pop(ctx);
nav.push(
themedRoute(
(_) => const IntervalTimerScreen(),
name: 'IntervalTimerScreen',
),
);
},
🤖 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/ui/workouts/workouts_screen.dart` around lines 104 - 112, Update the
onTap callback for IntervalTimerScreen to guard the outer context after
Navigator.pop before pushing the route. Either capture the navigator before
popping and reuse it, or check context.mounted before
Navigator.of(context).push, while preserving the existing themedRoute
configuration.

Source: Coding guidelines

Comment thread test/auto_backup_test.dart Outdated
getApplicationDocumentsDirectory resolves to app-private storage on Android,
so backups were being written where no file manager and no sync app could
reach them — the feature worked and was pointless. Android writes to
app-specific external storage now, which is browsable and needs no permission,
and the move handles the two being different filesystems.

Retention matched openstrap-*.db, which would have deleted someone's own
openstrap-notes.db sitting in the same folder. It matches the exact filename
shape now. Filenames carry seconds, because two runs in one minute shared a
name and the second overwrote the first, and a backup cannot start while one
is already running.

Health Connect caps third-party reads at 30 days without a history permission
the pinned health package cannot request, so asking Android for ten years
returned 30 days while implying otherwise. It asks for what it can have.

The finite breathing session called stop on every frame between the timer
expiring and the async stop landing — sixty a second, each banking a session
row. Latched. The screen also kept its own copy of the resonance pattern,
which could drift from the table and shipped an empty description that
rendered as a blank line.

The interval timer leaked its wake lock when the screen was popped mid-round.

And the journal insight note still said tagged days only.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Nine of the twelve taken, and two of them were things that would have shipped broken.

The Android backup folder was unreachable. getApplicationDocumentsDirectory() resolves to app-private storage there, so backups were being written somewhere no file manager and no sync app could open — the feature would have worked perfectly and been pointless, which is the worst kind of bug because nothing looks wrong. It writes to app-specific external storage now, and the move handles temp and external being different filesystems, where rename fails outright.

The finite session called stop on every frame. onStop is asynchronous, so between the timer expiring and the parent flipping active false, every single frame fired another stop — sixty a second, each one banking a session row. That is mine, and the two-minute session I had just added is exactly what would have triggered it. Latched, with the local clock stopped immediately.

Also taken: retention matched openstrap-*.db and would have deleted a user's own openstrap-notes.db from the same folder; filenames now carry seconds because two runs in one minute shared a name and the second silently overwrote the first; a backup cannot start while one is running; Health Connect's 30-day cap without the history permission (which health 11.1.1 has no API to request, so the honest fix is to ask for what we can actually have rather than declare a permission we cannot use); the wake lock leaked when the interval screen was popped mid-round; the calm screen kept a copy of the resonance pattern that could drift from the table and shipped an empty description; and the journal methodology note still said tagged days only.

One declined. The cadence check uses a 24-hour interval rather than local calendar-day boundaries. The guideline about not assuming 86400-second days is about day labels, where it is exactly right — but this is a "how often", not a date, and an interval is the better model for it: a backup at 23:00 followed by one at 00:30 should not count as two days. On a DST day the effect is that a daily backup runs an hour earlier or later, never that one is skipped, because the trigger is foreground rather than a fixed alarm. Using calendar days here would introduce the skip it is meant to prevent.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit c7c949f

@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

♻️ Duplicate comments (1)
lib/data/auto_backup.dart (1)

142-152: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Use shared Android storage for user-facing backups.

getExternalStorageDirectory() maps to Android getExternalFilesDir(null). Android 11 scoped storage prevents other apps from accessing an app-specific external directory. A file manager or sync application cannot reliably use this backup folder. Store backups in shared Documents or Downloads through MediaStore or a persisted SAF location. Handle revoked access explicitly. (developer.android.com)

🤖 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/auto_backup.dart` around lines 142 - 152, Update backupDirectory()
so Android backups use a user-accessible shared Documents or Downloads location
via MediaStore or a persisted Storage Access Framework location instead of
getExternalStorageDirectory(). Handle missing or revoked persisted access
explicitly, and retain the application-documents fallback only when no valid
shared location is available.
🤖 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/data/auto_backup.dart`:
- Around line 166-175: Refactor the coordination around runBackup and the
related due-check, export, destination-allocation, pruning, and lastRun
persistence paths so a single coordinator owns the entire backup lifecycle and
keeps _inFlight active until successful timestamp persistence completes. Ensure
destination names remain unique for same-second manual requests, and add
regression coverage for a trigger between export completion and timestamp
persistence plus two same-second manual requests, asserting the expected single
export and two unique files.

---

Duplicate comments:
In `@lib/data/auto_backup.dart`:
- Around line 142-152: Update backupDirectory() so Android backups use a
user-accessible shared Documents or Downloads location via MediaStore or a
persisted Storage Access Framework location instead of
getExternalStorageDirectory(). Handle missing or revoked persisted access
explicitly, and retain the application-documents fallback only when no valid
shared location is available.
🪄 Autofix

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: 4121e1ce-d777-4688-9019-7e7cec4c12d5

📥 Commits

Reviewing files that changed from the base of the PR and between d6e42de and c7c949f.

📒 Files selected for processing (8)
  • lib/data/auto_backup.dart
  • lib/health/health_profile_import.dart
  • lib/ui/journal/journal_screen.dart
  • lib/ui/stress/calm_breathing_screen.dart
  • lib/ui/stress/interval_timer_screen.dart
  • test/auto_backup_test.dart
  • test/breath_phases_test.dart
  • test/health_profile_import_test.dart

Comment thread lib/data/auto_backup.dart Outdated
Guarding only the export left a window: a second trigger could read the old
last-run timestamp, judge a backup due, and start another one while the first
was finishing. Reading the timestamp, deciding, writing and recording are one
sequence now, and the caller passes callbacks rather than a value so the read
happens inside the lock rather than before queueing.

Two runs in the same second also shared a filename, so the first was silently
replaced. The destination is allocated against what is already on disk.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Right, and I had half-fixed it. The guard covered the export but ended before the caller persisted the timestamp, so a second trigger could still read the old value, judge a backup due, and start another one.

The lock covers the whole sequence now — read, decide, export, prune, record. The part that makes it actually work is that lastRun and markRun are callbacks rather than values: passing the timestamp in would mean the caller had already read it before queueing, which is the same stale read wearing a different hat. The regression test fires two triggers without awaiting the first and asserts the second read sees the first run rather than a stale null, and that exactly one of them decided it was due.

Same-second destinations are allocated against what is already on disk, with a test that two runs at an identical timestamp produce two files rather than one overwritten. The queue also survives a failed run, which it needed to — a chained tail without an error guard would have wedged every later backup for the life of the process after one throw.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit f707b6b

@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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

🤖 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/data/auto_backup.dart`:
- Around line 226-233: Update _uniqueDestination so it never returns an existing
candidate: check the base name and suffixes through -99, return immediately only
when a free File is found, and signal a backup error when every bounded
candidate is occupied. Add a regression test covering all candidates from the
base through -99.

In `@lib/state/app_state.dart`:
- Around line 807-814: Update runBackupIfDue so it does not capture
backupCadence before entering backup.runBackupIfDue; pass a BackupCadence
Function() callback that reads the current cadence inside the serialized
operation immediately before backupIsDue, preserving the off behavior there. Add
a queued cadence-change regression test covering a cadence changed to off while
the call waits behind an export.

In `@test/auto_backup_test.dart`:
- Around line 344-359: The queue-recovery test currently skips both backups, so
it never exercises failure handling. Update test one failure does not wedge
every later backup to inject or otherwise use a test seam that makes the first
queued backup fail, then invoke a second backup with an executable cadence and
assert that it runs successfully; preserve the existing verification that the
queue remains usable after the failure.
🪄 Autofix

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: ed055a1a-751c-4303-81e8-79b3f1f7e6f4

📥 Commits

Reviewing files that changed from the base of the PR and between c7c949f and f707b6b.

📒 Files selected for processing (3)
  • lib/data/auto_backup.dart
  • lib/state/app_state.dart
  • test/auto_backup_test.dart

Comment thread lib/data/auto_backup.dart Outdated
Comment thread lib/state/app_state.dart
Comment thread test/auto_backup_test.dart Outdated
The cadence was read before the call entered the queue, so a call waiting
behind a running export acted on the setting as it was when it queued. Someone
who switched backup off in the meantime still got another unencrypted copy of
their health data written after they had disabled it. It re-reads inside the
lock now, like the timestamp already did.

The unique-filename search returned its last candidate when every one was
taken, handing the next backup a real snapshot to overwrite — the opposite of
the point. It returns an error instead.

And the queue-recovery test proved nothing: both its calls were cadence off,
so neither reached the export and neither could fail. The exporter is
injectable now, so a failure is actually reachable, and the test asserts the
backup after it still runs.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

All three, and two of them are mine from the last round.

The cadence was the same bug I had just fixed for the timestamp, left in place one line above it. Read before the call entered the queue, so a call waiting behind a running export acted on the setting as it was when it queued — someone switching backup off mid-export would still have got another unencrypted copy of their health data written after disabling it. It re-reads inside the lock now, and the test flips the setting from inside the first export to prove the queued call sees it.

The unique-filename search returning its last candidate when all hundred were taken was straightforwardly backwards: it hands the next backup a real snapshot to overwrite, which is the one thing the unique naming exists to prevent. It errors instead, and the test fills every slot and checks all hundred files are still intact afterwards.

And you are right that the queue-recovery test proved nothing — both calls were cadence off, so neither reached _runBackup and neither could fail. That is worse than no test, because it reads as coverage. The exporter is injectable now, so a failing export is reachable, and the test asserts the backup queued behind it still runs.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 1013784

@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

The detector abstains on short records, partial days and failures — exactly
the days someone would log a nap for — and all three of those paths returned
before the edits were applied. The nap vanished: no card, no minutes, and no
way to delete the row that had just been created, while the edit kept
force-re-deriving that day forever. Logged naps carry their own bounds, so
they are published on their own now, and the day is only reported as unjudged
when nothing was logged.

A logged nap also supersedes a detection it overlaps. The entry screen refuses
an overlap against what it can see, but that is a snapshot — log one on a day
the detector abstained on, sync more raw, and it may then find the same bout
and count the afternoon twice. And main sleep is in the overlap check now, or
a nap could be logged inside the night and counted in both.

Re-entering the breathing screen mid-session left it frozen. Swipe-back never
reaches the handler that stops a session, so the next view mounted with the
session already running and no state change to catch — no clock, no haptics,
and a timed session that never ended.

Backgrounding a session banked whatever the wall clock said. Two minutes
backgrounded and resumed forty minutes later stored a forty-minute session,
with a coherence score drawn mostly from breathing nobody was pacing. It
clamps to what was asked for, since the pacer stops the moment the app leaves
the foreground.

The interval timer's end-of-session cue was three identical buzzes, which the
firmware plays as one and nobody could tell from an ordinary round change. It
has its own pattern.

Restoring a backup dropped every sleep correction — the edits are the only
copy, so a restore silently reinstated naps that had been deleted.

And the Health import searched ten years back while adopting weight
unconditionally, so an ancient reading could overwrite a profile someone had
kept current, then feed calories and BMR. A year.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

CodeRabbit stayed rate limited, so I ran my own adversarial pass over the diff instead. Eight real defects, two of which would have made the features look broken rather than subtly wrong.

A nap logged on a day the detector abstained on vanished. All three abstention paths — too little data, detector not present, exception — returned before the edits were applied. So on exactly the days this feature exists for (strap off part of the afternoon, short record) the nap produced no card, credited no minutes, and could not even be deleted afterwards, while the stored edit kept force-re-deriving that day forever. Logged naps carry their own absolute bounds and need nothing from the detector, so they are published on their own now; the day is only reported unjudged when nothing was logged.

Re-entering the breathing screen mid-session left it dead. Swipe-back and system back never reach onBack, which is the only thing that stops a session — so the session keeps running and the next view mounts with active already true, with no state change for didUpdateWidget to catch. The clock never started: frozen circle, no haptics, and a timed session that never ended. That is worse than the pre-existing version of the same shape, because the whole session lifecycle now hangs off that clock. Fixed in initState, with a regression test.

Also fixed: a logged nap now supersedes a detection it overlaps (the entry-time overlap check only sees a snapshot, so a later re-derive could find the same bout and count the afternoon twice); main sleep is in that overlap check, or a nap could be logged inside the night and counted in both; backgrounding a session banked wall-clock time, so two minutes backgrounded and resumed forty minutes later stored a forty-minute session with a coherence score drawn mostly from unpaced breathing; the interval timer's "three buzzes" for session-end are played by the firmware as one and were indistinguishable from a round change, so it has its own pattern; restoring a backup dropped every sleep correction, and since the edits are the only copy that silently reinstated deleted naps; and the Health import searched ten years back while adopting weight unconditionally, letting an ancient reading overwrite a current profile and then feed calories and BMR.

Two things noted and deliberately not changed: both ticker screens setState every frame, rebuilding more than they need to while holding a wake lock — real, but a refactor rather than a fix, and the running-state trees are small. And the backup runs on the same foreground frame as the BLE session re-establishing, which on a large database is contention at a bad moment; worth moving, but not in this PR.

1727 tests.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit e61c96b

@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: 5

Caution

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

⚠️ Outside diff range comments (2)
lib/ui/stress/interval_timer_screen.dart (1)

78-121: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add interval timer lifecycle regression coverage.

This screen adds wake-lock and haptic lifecycle behavior, but the existing matchers only cover ScreenWake and intervalPattern logic separately. Add coverage for start, phase-boundary haptics, finite completion with the distinct buzzSessionComplete() cue, open rounds, and wake-lock release on disposal.

🤖 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/ui/stress/interval_timer_screen.dart` around lines 78 - 121, Add
regression coverage for the interval timer screen’s lifecycle, exercising
_start, phase-boundary haptic dispatch, finite-session completion through the
distinct buzzSessionComplete() cue, open-ended rounds, and wake-lock release
when the screen is disposed. Reuse the existing ScreenWake and intervalPattern
test infrastructure and verify start enables wake-lock while
stop/completion/disposal releases it.

Source: Coding guidelines

lib/state/app_state.dart (1)

3922-3968: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Persist a duration-consistent ended_at, not the unclamped wall-clock stop.

seconds is clamped to target.inSeconds so a session resumed long after backgrounding does not bank suspended time as breathing. endedAt, however, is still stamped from the unclamped ended (DateTime.now()), so the persisted row carries two disagreeing measures of the same session: seconds (correct, clamped) versus ended_at - started_at (the original unclamped span). A reader that computes duration from the timestamps instead of the seconds column reproduces exactly the "banked a forty-minute session" bug this clamp exists to fix.

Derive endedAt from the same clamped value used for seconds so both fields agree.

🛡️ Proposed fix to keep endedAt consistent with the clamped seconds
     if (started != null) {
       final ended = DateTime.now();
       var seconds = ended.difference(started).inSeconds;
       // Clamped to what was asked for. Overshoot is always suspension, never
       // extra breathing — the pacer stops the moment the app leaves the
       // foreground, so any second past the target was spent doing something
       // else.
       if (target != null && seconds > target.inSeconds) {
         seconds = target.inSeconds;
       }
       if (seconds >= 60) {
         final res = breathingResult;
         final scored = res != null && res['ok'] == true;
         final rated = breathingPattern.coherenceRated && scored;
         unawaited(
           LocalDb.putBreathingSession(
             startedAt: started.millisecondsSinceEpoch,
-            endedAt: ended.millisecondsSinceEpoch,
+            endedAt: started.add(Duration(seconds: seconds)).millisecondsSinceEpoch,
             pattern: breathingPattern.key,
             seconds: seconds,
🤖 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 3922 - 3968, Update
stopBreathingSession so the persisted endedAt is derived from the clamped
seconds value rather than the raw ended timestamp. Preserve the existing
target-based clamping and use the adjusted end time consistently when calling
LocalDb.putBreathingSession, ensuring ended_at minus started_at matches seconds.
🤖 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 4379-4399: The _napsWhenUnjudged method assigns the unsupported
analytics tier 'reported' to bundle['naps']['tier']; replace it with the
established supported tier constant or value appropriate for user-logged naps,
while preserving the existing reported-input behavior and other metadata.

In `@lib/data/auto_backup.dart`:
- Around line 194-200: Update _runBackup to call _uniqueDestination before
invoking the snapshot export, and return the existing no-free-filename
BackupOutcome immediately when it returns null. Export the snapshot only after a
valid destination is allocated, preventing an unmovable temporary file from
being created.

In `@lib/ui/sleep/sleep_periods_screen.dart`:
- Around line 169-186: Add an immediate mounted check at the start of _rederive,
before context.read<AppState>() or any other operation, returning early when the
widget is disposed so both _addNap and _removeNap callers are safe after their
awaited database writes.

In `@lib/ui/stress/calm_breathing_screen.dart`:
- Around line 143-173: Preserve the active session’s timing across
CalmBreathingView remounts by storing its start time, target duration, or
absolute deadline in AppState rather than resetting it in _begin. Pass that
authoritative timing state into CalmBreathingView, initialize _minutes from it,
and calculate phase, elapsed time, remaining time, and completion against the
persisted value; _begin should only resume the clock without restarting the
session. Add a regression test covering remounting an active five-minute or
open-ended session and verifying its phase and remaining time remain unchanged.

In `@test/health_profile_import_test.dart`:
- Around line 105-110: Update the assertion in the importer test around
windows.single to compare the window start against the exact expected date,
rather than only checking the year difference. Preserve the existing
single-window assertion and verify the Apple window begins on the intended
one-year boundary for DateTime(2026, 8, 9).

---

Outside diff comments:
In `@lib/state/app_state.dart`:
- Around line 3922-3968: Update stopBreathingSession so the persisted endedAt is
derived from the clamped seconds value rather than the raw ended timestamp.
Preserve the existing target-based clamping and use the adjusted end time
consistently when calling LocalDb.putBreathingSession, ensuring ended_at minus
started_at matches seconds.

In `@lib/ui/stress/interval_timer_screen.dart`:
- Around line 78-121: Add regression coverage for the interval timer screen’s
lifecycle, exercising _start, phase-boundary haptic dispatch, finite-session
completion through the distinct buzzSessionComplete() cue, open-ended rounds,
and wake-lock release when the screen is disposed. Reuse the existing ScreenWake
and intervalPattern test infrastructure and verify start enables wake-lock while
stop/completion/disposal releases it.
🪄 Autofix

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: a43a9bcd-219c-4787-a544-8d436c9e016c

📥 Commits

Reviewing files that changed from the base of the PR and between f707b6b and e61c96b.

📒 Files selected for processing (13)
  • lib/compute/derivation_engine.dart
  • lib/compute/nap_edits.dart
  • lib/data/auto_backup.dart
  • lib/data/db.dart
  • lib/health/health_profile_import.dart
  • lib/state/app_state.dart
  • lib/ui/sleep/sleep_periods_screen.dart
  • lib/ui/stress/calm_breathing_screen.dart
  • lib/ui/stress/interval_timer_screen.dart
  • test/auto_backup_test.dart
  • test/calm_breathing_view_test.dart
  • test/health_profile_import_test.dart
  • test/nap_edits_test.dart

Comment thread lib/compute/derivation_engine.dart
Comment thread lib/data/auto_backup.dart Outdated
Comment thread lib/ui/sleep/sleep_periods_screen.dart
Comment thread lib/ui/stress/calm_breathing_screen.dart
Comment thread test/health_profile_import_test.dart
…ing it

Starting the clock on mount fixed a frozen re-entry and introduced a worse
one: the clock restarted from zero and the length reverted to the default, so
a five-minute session re-entered at 4:00 showed 2:00 and stopped almost at
once. Elapsed and remaining now come from when the session actually began and
what it was actually asked for, both of which belong to the session.

A logged nap was published under a tier that does not exist. The vocabulary is
four values; AUTH is the one that means directly measured or definitional,
which is what a self-report is — the user is not estimating that they napped.

The backup exported before choosing a destination, so a run that could not
find a free name left a full copy of the database in temp, once per attempt.

And re-deriving after a nap edit reached for the provider without checking the
screen was still there, after two awaited writes.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

All five, and the deadline one is a defect in the fix from my last round — fourth consecutive round where that has happened.

Starting the clock on mount fixed the frozen re-entry and introduced a worse version of the same bug: the clock restarted from zero and the length reverted to the picker's default, so a five-minute session re-entered at 4:00 showed 2:00 and stopped almost immediately. The real problem was that the view owned the timing at all — it is remounted every time someone leaves the screen and comes back. Elapsed and remaining now come from when the session actually began and what it was actually asked for, both held by the session. Regression test asserts a 5-minute session remounted at 4:00 shows 0:59 remaining and does not stop.

The tier was a straightforward mistake on my part — 'reported' is not in the vocabulary, which is a closed set of four. AUTH is the one that means directly measured or definitional, and that is what a self-report is: the user is not estimating that they napped, they are stating it.

The backup exporting before choosing a destination is a good catch on a path I had just added a test for without asserting the cleanup. Destination first now, so a run that cannot find a free name never exports at all — and the test asserts the exporter was never called rather than just that the outcome failed.

Also fixed: the _rederive provider lookup after two awaited writes, and the Apple window assertion, which passed for any date in 2025 rather than pinning the year.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit db30204

@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 (5)
lib/ui/sleep/sleep_periods_screen.dart (2)

107-112: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Build the next local calendar time instead of adding 86400 seconds.

endTs += 24 * 3600 is wrong on DST transition days. It can shift an overnight nap by one hour and break overlap validation and persisted duration.

Proposed fix
-    if (endTs <= startTs) endTs += 24 * 3600;
+    if (endTs <= startTs) {
+      endTs = DateTime(
+        day.year,
+        day.month,
+        day.day + 1,
+        end.hour,
+        end.minute,
+      ).millisecondsSinceEpoch ~/
+          1000;
+    }

As per coding guidelines, “Keep epoch timestamps absolute and do not assume every day is 86400 seconds.”

🤖 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/ui/sleep/sleep_periods_screen.dart` around lines 107 - 112, Update the
overnight adjustment in the sleep-period calculation around endTs and startTs to
construct the next local calendar occurrence of the end time rather than adding
24*3600 seconds. Preserve the existing end-before-or-equal-start condition and
ensure the resulting epoch timestamp remains correct across DST transitions for
overlap validation and persisted duration.

Source: Coding guidelines


385-395: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use self-report help text for logged naps.

A nap with source == 'manual' is not detected from wrist stillness or heart rate. The current InfoDot states that it was detected and refers to detection confidence.

Render a separate description for logged naps.

🤖 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/ui/sleep/sleep_periods_screen.dart` around lines 385 - 395, Update the
InfoDot body in the sleep-period rendering around isMain to distinguish manually
logged naps (source == 'manual') from detected naps. Use the self-report help
text for logged naps, while preserving the existing detected-nap description for
non-manual naps and the main-sleep text.
lib/state/app_state.dart (2)

1667-1671: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Queue a forced re-derive for nap edits.

reanalyzeForNapEdit() uses _reanalyzeForOverride(). That flow returns when reanalyzing is true. DerivationEngine.run() also returns 0 when another derivation is active. A nap edit can commit but never replay into day_result.

Queue one coalesced forced pass after the active derivation completes. Add a regression test that saves a nap edit during an active derive and verifies the persisted nap appears afterward.

🤖 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 1667 - 1671, Update
reanalyzeForNapEdit() and the shared derivation coordination so a nap edit
requested during an active derivation queues one coalesced forced pass that runs
after the current derivation completes, rather than being dropped by reanalyzing
or DerivationEngine.run(). Add a regression test covering a saved nap edit
during an active derive and verify the persisted nap appears in day_result
afterward.

3941-3969: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Persist the effective session end time.

When a finite session resumes after its target elapsed, seconds is clamped but endedAt still uses the later wall-clock time. The stored row then reports a duration that does not match its start and end timestamps.

Set endedAt to started + Duration(seconds: seconds) after clamping.

Proposed fix
       if (target != null && seconds > target.inSeconds) {
         seconds = target.inSeconds;
       }
+      final effectiveEnded = started.add(Duration(seconds: seconds));
       if (seconds >= 60) {
         final res = breathingResult;
@@
-            endedAt: ended.millisecondsSinceEpoch,
+            endedAt: effectiveEnded.millisecondsSinceEpoch,
🤖 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 3941 - 3969, After clamping seconds in
the session-finalization flow, set the persisted end timestamp used by
LocalDb.putBreathingSession to started plus Duration(seconds: seconds), so
endedAt matches the effective duration rather than the later wall-clock ended
value. Keep the existing clamping and persistence behavior otherwise unchanged.
lib/ui/stress/calm_breathing_screen.dart (1)

187-194: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not emit a phase cue on a view remount.

An active view calls _begin() during initState. _begin() clears _lastPhase and _lastCycle, so the next frame invokes onPhaseChange for the current phase even when no boundary occurred.

Initialize the phase tracking from phaseAt(_pattern, _elapsed) when resuming an existing session. Keep the initial cue for a newly started session. Add a remount test that confirms no cue occurs before the next boundary.

Also applies to: 236-243

🤖 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/ui/stress/calm_breathing_screen.dart` around lines 187 - 194, Update
_begin() so resuming an existing session initializes _lastPhase and _lastCycle
from phaseAt(_pattern, _elapsed) instead of clearing them, preventing
onPhaseChange until the next boundary. Preserve the initial phase cue for newly
started sessions, and add a remount test verifying no cue is emitted before that
boundary.

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/ui/stress/calm_breathing_screen.dart`:
- Around line 210-214: Update the _target getter to preserve an authoritative
open-ended session when startedAt indicates a remounted running session,
returning null instead of falling back to the draft duration; retain the
existing widget-test fallback when no authoritative session timing exists.
Extend the remount regression test with a past startedAt and target: null,
asserting elapsed time is displayed and onStop is not called.

---

Outside diff comments:
In `@lib/state/app_state.dart`:
- Around line 1667-1671: Update reanalyzeForNapEdit() and the shared derivation
coordination so a nap edit requested during an active derivation queues one
coalesced forced pass that runs after the current derivation completes, rather
than being dropped by reanalyzing or DerivationEngine.run(). Add a regression
test covering a saved nap edit during an active derive and verify the persisted
nap appears in day_result afterward.
- Around line 3941-3969: After clamping seconds in the session-finalization
flow, set the persisted end timestamp used by LocalDb.putBreathingSession to
started plus Duration(seconds: seconds), so endedAt matches the effective
duration rather than the later wall-clock ended value. Keep the existing
clamping and persistence behavior otherwise unchanged.

In `@lib/ui/sleep/sleep_periods_screen.dart`:
- Around line 107-112: Update the overnight adjustment in the sleep-period
calculation around endTs and startTs to construct the next local calendar
occurrence of the end time rather than adding 24*3600 seconds. Preserve the
existing end-before-or-equal-start condition and ensure the resulting epoch
timestamp remains correct across DST transitions for overlap validation and
persisted duration.
- Around line 385-395: Update the InfoDot body in the sleep-period rendering
around isMain to distinguish manually logged naps (source == 'manual') from
detected naps. Use the self-report help text for logged naps, while preserving
the existing detected-nap description for non-manual naps and the main-sleep
text.

In `@lib/ui/stress/calm_breathing_screen.dart`:
- Around line 187-194: Update _begin() so resuming an existing session
initializes _lastPhase and _lastCycle from phaseAt(_pattern, _elapsed) instead
of clearing them, preventing onPhaseChange until the next boundary. Preserve the
initial phase cue for newly started sessions, and add a remount test verifying
no cue is emitted before that boundary.
🪄 Autofix

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: 05ee70e4-2761-4538-97c8-f2006f664845

📥 Commits

Reviewing files that changed from the base of the PR and between e61c96b and db30204.

📒 Files selected for processing (8)
  • lib/compute/derivation_engine.dart
  • lib/data/auto_backup.dart
  • lib/state/app_state.dart
  • lib/ui/sleep/sleep_periods_screen.dart
  • lib/ui/stress/calm_breathing_screen.dart
  • test/auto_backup_test.dart
  • test/calm_breathing_view_test.dart
  • test/health_profile_import_test.dart

Comment thread lib/ui/stress/calm_breathing_screen.dart Outdated
A null target on a running session means it runs until you stop it. Reading
that as "no answer" fell through to the picker's default, so an open session
left and re-entered past two minutes showed a countdown it never had and
stopped on the spot. The session's start is what says whether a target is
authoritative, not whether the target happens to be null.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Taken — and it is the same mistake as the round before, one layer down. I keyed "is there an authoritative target" on the target being non-null, but null is itself a meaningful answer for a running session: it means open-ended. So an open session left and re-entered past two minutes inherited the picker's default, showed a countdown it never had, and stopped on the spot.

The session's startedAt is what says whether the session is authoritative; the target is then whatever it is, null included. Test covers a nine-minute open session remounting and continuing to count up.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit baab763

@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 13 minutes.

@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/state/app_state.dart (3)

3961-3969: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Handle failures from breathing-history persistence.

LocalDb.putBreathingSession(...) runs with unawaited and no error handler. If the write fails or the process stops before it completes, stopBreathingSession() returns as if the session was saved and the history row is lost.

Await the write before returning, or attach explicit failure handling and retry behavior.

🤖 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 3961 - 3969, Update
stopBreathingSession around the unawaited LocalDb.putBreathingSession call to
ensure persistence failures are handled before the method returns: preferably
await the write, or attach explicit failure handling with the required retry
behavior. Preserve the existing breathing-session fields and completion flow
while preventing silent loss of history rows.

4009-4023: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope coherence to the current resonance-rated session.

_recomputeBreathingCoherence() computes for every pattern, although stopBreathingSession() only accepts coherence for coherenceRated patterns. Add an early guard for non-rated patterns.

Also capture a session identifier and the pattern before the await. The current if (!breathingActive) check passes when an old request completes after stop and a new session starts. That request can overwrite the new session’s result and history.

🤖 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 4009 - 4023, Update
_recomputeBreathingCoherence to return immediately unless
breathingPattern.coherenceRated is true. Before awaiting
repo!.breathingCoherence, capture the current session identifier and pattern,
then only apply breathingResult and related history when both still match the
active session and pattern; prevent stale requests from overwriting a newly
started session.

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

Queue nap re-analysis requests.

reanalyzeForNapEdit() delegates to _reanalyzeForOverride(), which returns immediately when reanalyzing is already true. If a second nap edit is saved while the first derive is running, the first derive may have already loaded napEdits and will not include the second edit. The second call then reloads stale derived data, and no later derive is scheduled.

Serialize the in-flight future and rerun when an edit arrives during the pass, or prevent edits until the pass completes. Add a regression test for two edits during one derive.

As per coding guidelines, “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` at line 1671, Update reanalyzeForNapEdit() and the
_reanalyzeForOverride() flow to serialize in-flight nap re-analysis requests and
schedule another derive when an edit arrives during the current pass, ensuring
both edits are reflected in the final derived data. Add a regression test that
saves two nap edits while one derive is running and verifies a subsequent
re-analysis processes the second edit.

Source: Coding guidelines

lib/ui/sleep/sleep_periods_screen.dart (1)

102-112: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Use local calendar arithmetic for overnight naps.

Line 112 adds 24 * 3600 to the end timestamp. This assumes every local day has 86,400 seconds. On a DST transition date, the stored nap end is one hour wrong.

Construct the next local date with DateTime(day.year, day.month, day.day + 1, ...), then convert both local endpoints to absolute epoch seconds.

Proposed fix
-    final startTs =
-        DateTime(day.year, day.month, day.day, start.hour, start.minute)
-            .millisecondsSinceEpoch ~/
-        1000;
-    var endTs =
-        DateTime(day.year, day.month, day.day, end.hour, end.minute)
-            .millisecondsSinceEpoch ~/
-        1000;
+    final startAt =
+        DateTime(day.year, day.month, day.day, start.hour, start.minute);
+    var endAt = DateTime(day.year, day.month, day.day, end.hour, end.minute);
     // An end before the start means it ran past midnight.
-    if (endTs <= startTs) endTs += 24 * 3600;
+    if (!endAt.isAfter(startAt)) {
+      endAt = DateTime(
+        day.year,
+        day.month,
+        day.day + 1,
+        end.hour,
+        end.minute,
+      );
+    }
+    final startTs = startAt.millisecondsSinceEpoch ~/ 1000;
+    final endTs = endAt.millisecondsSinceEpoch ~/ 1000;

As per coding guidelines, “Keep epoch timestamps absolute and do not assume every day is 86400 seconds.”

🤖 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/ui/sleep/sleep_periods_screen.dart` around lines 102 - 112, Update the
timestamp construction around startTs and endTs to use local calendar DateTime
arithmetic for overnight naps: when the end time is not after the start,
construct the end endpoint using the next local date via DateTime(day.year,
day.month, day.day + 1, ...), then convert both local endpoints to epoch
seconds. Remove the fixed 24 * 3600 adjustment so DST transition days retain
correct absolute timestamps.

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 `@test/calm_breathing_view_test.dart`:
- Around line 107-125: Update the remount test around CalmBreathingView to keep
the stopped assertion separate from the displayed-time check, and accept either
“0:59” or “1:00” for the one-minute remaining value because pump timing may not
cross a wall-clock second. Preserve coverage that the session does not stop
early and does not revert to the default two-minute duration.

---

Outside diff comments:
In `@lib/state/app_state.dart`:
- Around line 3961-3969: Update stopBreathingSession around the unawaited
LocalDb.putBreathingSession call to ensure persistence failures are handled
before the method returns: preferably await the write, or attach explicit
failure handling with the required retry behavior. Preserve the existing
breathing-session fields and completion flow while preventing silent loss of
history rows.
- Around line 4009-4023: Update _recomputeBreathingCoherence to return
immediately unless breathingPattern.coherenceRated is true. Before awaiting
repo!.breathingCoherence, capture the current session identifier and pattern,
then only apply breathingResult and related history when both still match the
active session and pattern; prevent stale requests from overwriting a newly
started session.
- Line 1671: Update reanalyzeForNapEdit() and the _reanalyzeForOverride() flow
to serialize in-flight nap re-analysis requests and schedule another derive when
an edit arrives during the current pass, ensuring both edits are reflected in
the final derived data. Add a regression test that saves two nap edits while one
derive is running and verifies a subsequent re-analysis processes the second
edit.

In `@lib/ui/sleep/sleep_periods_screen.dart`:
- Around line 102-112: Update the timestamp construction around startTs and
endTs to use local calendar DateTime arithmetic for overnight naps: when the end
time is not after the start, construct the end endpoint using the next local
date via DateTime(day.year, day.month, day.day + 1, ...), then convert both
local endpoints to epoch seconds. Remove the fixed 24 * 3600 adjustment so DST
transition days retain correct absolute timestamps.
🪄 Autofix

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: b5e37937-3c25-4dff-9e64-7a829d675e52

📥 Commits

Reviewing files that changed from the base of the PR and between e61c96b and baab763.

📒 Files selected for processing (8)
  • lib/compute/derivation_engine.dart
  • lib/data/auto_backup.dart
  • lib/state/app_state.dart
  • lib/ui/sleep/sleep_periods_screen.dart
  • lib/ui/stress/calm_breathing_screen.dart
  • test/auto_backup_test.dart
  • test/calm_breathing_view_test.dart
  • test/health_profile_import_test.dart

Comment thread test/calm_breathing_view_test.dart
@abdulsaheel
abdulsaheel merged commit a3251d2 into main Aug 9, 2026
3 checks passed
@abdulsaheel
abdulsaheel deleted the feat/journal-insights-backup-naps-breathing branch August 9, 2026 14:44
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