From 1740630dcb0e7f58b9068967b7278b3683f25df9 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:33:24 +0530 Subject: [PATCH 1/9] fix strain footnote to match how it actually behaves --- lib/l10n/app_en.arb | 4 ++-- lib/ui2/activity/day_strain.dart | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9db068866..df6c14335 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -9274,9 +9274,9 @@ "@dayStrainChartTitle": { "description": "Chart title above the day's strain-over-time curve" }, - "dayStrainChartFootnote": "Accumulated, so it only ever climbs — the STEEP parts are where the effort was. Built from {drawn} recorded waking minutes.", + "dayStrainChartFootnote": "Effort banked above your usual waking pace — it can ease later in the day if intensity drops back toward that pace, even though the STEEP parts already happened. Built from {drawn} recorded waking minutes.", "@dayStrainChartFootnote": { - "description": "Footnote under the strain curve chart explaining it is cumulative", + "description": "Footnote under the strain curve chart explaining it tracks effort above baseline pace, not a raw accumulator, so it can dip", "placeholders": { "drawn": { "type": "int" diff --git a/lib/ui2/activity/day_strain.dart b/lib/ui2/activity/day_strain.dart index 5e1551947..031a1be29 100644 --- a/lib/ui2/activity/day_strain.dart +++ b/lib/ui2/activity/day_strain.dart @@ -280,9 +280,10 @@ class _DayStrainDetailState extends State { xLabels: const ['00:00', '12:00', '24:00'], series: d.curve, footnote: l?.dayStrainChartFootnote(drawn) ?? - 'Accumulated, so it only ever climbs — the STEEP parts ' - 'are where the effort was. Built from $drawn recorded waking ' - 'minutes.', + 'Effort banked above your usual waking pace — it can ease ' + 'later in the day if intensity drops back toward that ' + 'pace, even though the STEEP parts already happened. ' + 'Built from $drawn recorded waking minutes.', child: CustomPaint( size: Size.infinite, painter: LineChart(d.curve, p.on(C.purple), From 0a52f3e7547dfc27bdead5c2195da86dd1e0119c Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:33:31 +0530 Subject: [PATCH 2/9] close a db-handle leak that flaked workout tests --- test/phone_pedometer_hour_walk_test.dart | 6 ++++++ test/workout_reliability_test.dart | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/test/phone_pedometer_hour_walk_test.dart b/test/phone_pedometer_hour_walk_test.dart index 7e2d16eeb..423322f65 100644 --- a/test/phone_pedometer_hour_walk_test.dart +++ b/test/phone_pedometer_hour_walk_test.dart @@ -31,6 +31,12 @@ void main() { await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); }); + // LocalDb.instance caches its open Database keyed only on `db.isOpen`, not + // on `dbName` — leaving it open here hands the next suite this file's db + // instead of its own. This was the actual source of + // workout_reliability_test.dart's order-dependent flake (edge#259). + tearDownAll(() => LocalDb.close()); + setUp(() async { final db = await LocalDb.instance; await db.delete('live_coverage'); diff --git a/test/workout_reliability_test.dart b/test/workout_reliability_test.dart index e36235a63..7eb904ec3 100644 --- a/test/workout_reliability_test.dart +++ b/test/workout_reliability_test.dart @@ -44,6 +44,13 @@ void main() { await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); }); + // LocalDb.instance caches its open Database keyed only on `db.isOpen`, not + // on `dbName` — a still-open handle left by an earlier suite is handed back + // as-is, ignoring the dbName set above. Close ours so whichever suite runs + // next doesn't inherit it (see phone_pedometer_hour_walk_test.dart, which + // was the actual source of this file's order-dependent flake: edge#259). + tearDownAll(() => LocalDb.close()); + group('DeriveScheduler — live-workout gate', () { late List logs; late int runs; From 8e8f788dafa063462803c2aa25da0d929ff9c727 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:33:40 +0530 Subject: [PATCH 3/9] stop truncated derives from clobbering good readiness data --- lib/compute/derivation_engine.dart | 72 ++++++++++++++++++++++- lib/compute/onehz_pipeline.dart | 45 +++++++++++++++ test/derive_result_protection_test.dart | 76 +++++++++++++++++++++++++ test/readiness_saturation_test.dart | 31 ++++++++++ 4 files changed, 223 insertions(+), 1 deletion(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 50945e783..d04f89ebe 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1528,7 +1528,17 @@ import 'substrate.dart'; // zone anchor. Two different derivations cannot share one number — that is // the whole contract of this constant, and main's 81 is the one already on // main, so it keeps it. Nothing about the zone change itself moved. -const int kAlgoVersion = 82; +// +// 82 → 83 (edge#305): a re-derive at the retention-cutoff edge could produce +// null rhr/rmssd/readiness for a day whose sleep-window substrate had aged +// out while daytime HR survived, and — because `metric_series` is unversioned +// — silently clobber a good historical baseline point, collapsing the +// readiness baseline out from under later nights. That re-derive is now +// declined (see the guard in `_derivePreparedDay` right after `producedNothing`) +// so the older, complete row keeps being served. Also: the readiness z-cap +// abstention now populates `readiness_absent_diag` with its own +// `unstable_baseline:` reason instead of leaving it null (onehz_pipeline.dart). +const int kAlgoVersion = 83; /// The sibling SHAs this version was derived against, asserted against /// pubspec.yaml in test/db_serve_version_and_reads_test.dart. /// @@ -3684,6 +3694,50 @@ class DerivationEngine { } } + // ── NEVER WRITE NIGHT-NULL OVER NIGHT-REAL (edge#305) ─────────────────── + // The guard above only catches a day with literally NOTHING. A day at the + // retention-cutoff EDGE can have plenty of daytime HR (daySub non-empty) + // while its sleep window's own RR/HR substrate has aged out from under it + // (sleepSub empty) — sleep STAGING survives regardless (it replays the + // durable `sleep_session_candidates` row, not raw), so `daySub`/`sleepSub`/ + // `scMap` all look like "something" and `producedNothing` is false — but + // every night-physiology scalar (rhr/rmssd/readiness) comes back null. + // `putDayResult` replaces `metric_series` wholesale (it's UNVERSIONED), so + // this null silently overwrites a good historical baseline point and + // collapses every later night's readiness baseline out from under it. + // + // Detect the same shape the guard above already reasons about — a known + // sleep window whose substrate is now entirely gone, re-deriving over a + // day that already had a real result — and decline the SAME way: keep + // the existing (older-version) row being served (`_servedDayJoin` already + // serves the highest version <= ceiling, so nothing needs to change there) + // rather than writing a new, worse row. A `partial` row was considered + // instead, but `partial` still gets written to `day_result` and would + // still shadow the better older row for day-detail reads; declining is + // what actually protects it. + if (!producedNothing && + nightSubstrateRegressed( + hasSleepWindow: day.sleepOffsetSec > day.sleepOnsetSec, + sleepSubEmpty: sleepSub.isEmpty, + nightScalarsNull: scMap == null || + (scMap['rhr'] == null && + scMap['rmssd'] == null && + scMap['readiness'] == null), + )) { + final existingNight = await LocalDb.dayResult(day.date); + final existingHadNight = existingNight != null && + (existingNight['rhr'] != null || + existingNight['rmssd'] != null || + existingNight['readiness'] != null); + if (existingHadNight) { + _log('derive ${day.date}: sleep-window substrate pruned out from ' + "under a day that already had real night scalars — kept the " + 'existing result rather than nulling the readiness baseline ' + '(edge#305)'); + return; + } + } + // ── SECOND HALF — OFFLOADED to a background isolate ────────────────────── // Everything that turns the isolate-1 bundle into the full day result (wake // features, hybrid steps + TDEE, all-day HRV/RSA/skin-temp Timeline lines, @@ -4141,6 +4195,22 @@ class DerivationEngine { /// finalizing locks the day out of every future pass at this algo version. static const Set _transientSkipReasons = {'timeout', 'error'}; + /// edge#305 — whether THIS derive's night-physiology substrate has + /// regressed to nothing even though [producedNothing] (in + /// `_derivePreparedDay`) is false: plenty of daytime HR survives + /// ([hasSleepWindow] true, i.e. sleep STAGING still has a window — it + /// replays the durable `sleep_session_candidates` row, not raw — but + /// [sleepSubEmpty], the night's own RR/HR substrate, has aged out from + /// under it) and every night-physiology scalar ([nightScalarsNull]) came + /// back null. Pure so the shape is unit-testable without the full pipeline; + /// the caller still has to confirm an EXISTING result actually had real + /// night scalars before declining to write over it. + static bool nightSubstrateRegressed({ + required bool hasSleepWindow, + required bool sleepSubEmpty, + required bool nightScalarsNull, + }) => hasSleepWindow && sleepSubEmpty && nightScalarsNull; + /// Whether [row] is a REAL derived day result worth protecting — i.e. not a /// skip marker and not an all-absent shell. static bool _isRealDayResult(Map? row) { diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 0eb773ab9..ec0bbaa9f 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -104,6 +104,25 @@ double? headlineReadinessScalar(Metric composite) { return r.score; } +/// The `readiness_absent_diag` note for a composite that computed (unlike the +/// `!composite.present` cold-start case) but got withheld by [kReadinessZCap] +/// — a saturated, degenerate-baseline artefact. Null whenever +/// [headlineReadinessScalar] would have returned a value, i.e. there is +/// nothing to explain. +/// +/// Deliberately NOT the `need_baseline:` note (edge#305): reaching a present +/// composite already means every input cleared `readinessCompositeMinBaseline`, +/// so "Need N more nights" would be a proven-false cause once this fires — the +/// UI's baseline-note convention exists precisely to prevent stating a wrong +/// cause. Pure so the gate + note format are unit-testable without the full +/// pipeline. +String? zCapAbsentNote(Metric composite) { + if (!composite.present) return null; + if (headlineReadinessScalar(composite) != null) return null; + final z = composite.value!.compositeZ; + return 'unstable_baseline:z=${z.toStringAsFixed(3)},cap=$kReadinessZCap'; +} + /// Serializable input to the isolate: one physiological day's decoded 1 Hz /// substrate (the day slice), the PRECOMPUTED single-source sleep segmentation, /// the profile, and trailing baseline history for the readiness pass. @@ -1042,6 +1061,32 @@ Map deriveDayBundle(Map inputJson) { // Abstain from a saturated, degenerate-baseline readiness rather than persist a // bogus ~100 that a cleaner re-derive would then snap back down (#117 bounce). final readinessScalar = headlineReadinessScalar(composite); + // The z-cap can abstain even when the composite itself is `present` — a + // saturated, degenerate-baseline artefact, not a cold-start — and that used + // to leave `readinessAbsentDiag` null (only the `!composite.present` branch + // above sets it), so the UI fell through to the composite's own prose, which + // names whichever driver its renormalisation happened to refuse (e.g. temp) + // rather than the real reason the headline is blank (edge#305). Do NOT reuse + // `need_baseline:` here: on main an input needs >=`readinessCompositeMinBaseline` + // points before the composite is even present, so by the time THIS branch can + // fire "Need N more nights" is a proven-false cause. Give it its own reason, + // carrying the z that tripped the cap plus the same per-input counts the + // cold-start diag carries, so it renders with an actual explanation instead + // of a bare blank. + final zCapNote = zCapAbsentNote(composite); + if (readinessAbsentDiag == null && zCapNote != null) { + readinessAbsentDiag = { + 'hrv': {'value': lnToday != null, 'baseline_n': d.lnRmssdHistory.length}, + 'rhr': {'value': rhrToday != null, 'baseline_n': d.rhrHistory.length}, + 'resp': {'value': respToday != null, 'baseline_n': d.respHistory.length}, + 'temp': { + 'value': skinTempAdc != null, + 'baseline_n': d.skinTempAdcHistory.length, + 'settled_frac': skinTempSettledFrac, + }, + 'note': zCapNote, + }; + } final strainScalar = strainMetric.present ? strainMetric.value : null; // ── STRESS: Baevsky SI → a transparent 0–100 score (log-mapped over the diff --git a/test/derive_result_protection_test.dart b/test/derive_result_protection_test.dart index de32bdd8a..19d4a4ad5 100644 --- a/test/derive_result_protection_test.dart +++ b/test/derive_result_protection_test.dart @@ -381,4 +381,80 @@ void main() { expect(DerivationEngine.isRicherSleep(night(null), night(null)), isFalse); }); }); + + // 4. A re-derive at the retention-cutoff edge (edge#305): daytime HR + // survives (so `producedNothing` is false) while the sleep window's own + // RR/HR substrate has aged out from under it — sleep STAGING survives + // regardless (it replays the durable `sleep_session_candidates` row, not + // raw), so every night-physiology scalar comes back null and, because + // `metric_series` is UNVERSIONED, silently clobbers a good historical + // baseline point and collapses the readiness baseline out from under + // every later night. + group('nightSubstrateRegressed (edge#305)', () { + test('a known sleep window with no surviving substrate and null night ' + 'scalars is a regression', () { + expect( + DerivationEngine.nightSubstrateRegressed( + hasSleepWindow: true, + sleepSubEmpty: true, + nightScalarsNull: true, + ), + isTrue, + ); + }); + + test('no known sleep window at all is not a regression (honest no-sleep ' + 'day)', () { + expect( + DerivationEngine.nightSubstrateRegressed( + hasSleepWindow: false, + sleepSubEmpty: true, + nightScalarsNull: true, + ), + isFalse, + ); + }); + + test('sleep substrate still present is not a regression, even with null ' + 'scalars (a genuine can\'t-compute night)', () { + expect( + DerivationEngine.nightSubstrateRegressed( + hasSleepWindow: true, + sleepSubEmpty: false, + nightScalarsNull: true, + ), + isFalse, + ); + }); + + test('real night scalars computed is not a regression', () { + expect( + DerivationEngine.nightSubstrateRegressed( + hasSleepWindow: true, + sleepSubEmpty: true, + nightScalarsNull: false, + ), + isFalse, + ); + }); + }); + + // The full guard also requires an EXISTING result with real night scalars + // before it declines to write — a fresh day with nothing on disk yet must + // still get its (all-null) first result, same principle as producedNothing. + test('a night-substrate regression with NO existing result still writes', + () async { + const day = '2026-08-23'; + expect(await LocalDb.dayResult(day), isNull, reason: 'precondition'); + expect( + DerivationEngine.nightSubstrateRegressed( + hasSleepWindow: true, + sleepSubEmpty: true, + nightScalarsNull: true, + ), + isTrue, + reason: 'the shape alone says regression; the caller still checks for ' + 'an existing real result before declining to persist', + ); + }); } diff --git a/test/readiness_saturation_test.dart b/test/readiness_saturation_test.dart index 7a4ada878..2a92d5d7a 100644 --- a/test/readiness_saturation_test.dart +++ b/test/readiness_saturation_test.dart @@ -98,4 +98,35 @@ void main() { expect(headlineReadinessScalar(flat), isNull); }); }); + + // edge#305 — the z-cap abstention used to leave `readiness_absent_diag` null + // (only the `!composite.present` cold-start branch set it), so the UI fell + // through to the composite's own prose, which names whichever driver its + // renormalisation happened to refuse rather than the real reason the + // headline is blank. `zCapAbsentNote` gives it its own reason. + group('zCapAbsentNote', () { + test('a saturated composite gets its own note, not need_baseline', () { + final sat = composite(61.0, 59.0, degenerate); + final note = zCapAbsentNote(sat); + expect(note, isNotNull); + expect(note, startsWith('unstable_baseline:')); + expect(note, isNot(contains('need_baseline'))); + expect(note, contains('cap=$kReadinessZCap')); + }); + + test('a clean composite (real score) has nothing to explain', () { + final ok = composite(70.0, 60.0, clean); + expect(zCapAbsentNote(ok), isNull); + }); + + test('an absent composite (cold-start) is the OTHER branch\'s job', () { + // The z-cap note only fires once the composite is already `present` — + // the `!composite.present` cold-start case has its own `need_baseline:` + // note built elsewhere and must not collide with this one. + final flat = composite(61.0, 59.0, List.filled(28, 60.0), + rhrBase: List.filled(28, 60.0)); + expect(flat.present, isFalse); + expect(zCapAbsentNote(flat), isNull); + }); + }); } From 10a6ce32d44477bff71e5430fcad3ce442712519 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:34:21 +0530 Subject: [PATCH 4/9] pass real timestamps to gen5 rr consumers --- lib/data/local_repository_impl.dart | 40 ++++++++++++++++++++++++----- lib/state/app_state.dart | 17 +++++++++++- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index bc72f5144..ca36be5fc 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -4083,13 +4083,29 @@ Map? stressSummaryForToday( Map _spotCheckCompute(List records) { // Decode RR from the live RR-bearing frames (0x28 / R10), clean, compute HRV. + // rrTsMs carries each beat's packet timestamp (ms) alongside it — same seam + // getNightBeats uses (line ~867) — so correctRr can re-anchor at a dropout + // and hrvTime's seam detection can refuse to diff across it (edge#286: a + // flat rrMs-only list let a real packet gap read as two successive beats). final rrMs = []; + final rrTsMs = []; final hrs = []; + int? lastPacketTs; for (final hex in records) { final rr = proto.realtimeRr(hex); if (rr != null) { - for (final v in rr.rrMs) { - if (v > 0) rrMs.add(v.toDouble()); + // A duplicate or out-of-order packet timestamp cannot prove adjacency + // to whatever came before it — drop the whole packet rather than let + // it splice onto the series at the wrong place. + if (lastPacketTs == null || rr.ts > lastPacketTs) { + lastPacketTs = rr.ts; + final ts = rr.ts.toDouble() * 1000; + for (final v in rr.rrMs) { + if (v > 0) { + rrMs.add(v.toDouble()); + rrTsMs.add(ts); + } + } } } try { @@ -4100,7 +4116,7 @@ Map _spotCheckCompute(List records) { if (rrMs.length < 20) { return {'ok': false, 'n_beats': rrMs.length}; } - final cleaned = ana.correctRr(rrMs); + final cleaned = ana.correctRr(rrMs, rrTsMs: rrTsMs); final hrv = ana.hrvTime(cleaned.nn, nnTimesMs: cleaned.nnTimesMs); if (!hrv.present) return {'ok': false, 'n_beats': cleaned.nn.length}; final meanHr = hrs.isEmpty ? null : hrs.reduce((a, b) => a + b) / hrs.length; @@ -4120,19 +4136,31 @@ Map _breathingCoherenceCompute( ) { // Decode RR from the live RR-bearing frames (0x28 / R10) — same seam // spotCheck uses — then run McCraty & Zayas 2014 cardiac coherence. + // rrTsMs (see _spotCheckCompute above) lets correctRr re-anchor at a real + // packet gap so the coherence time axis is the real wall clock, not a + // gap-free cumsum (edge#286). final rrMs = []; + final rrTsMs = []; + int? lastPacketTs; for (final hex in records) { final rr = proto.realtimeRr(hex); if (rr != null) { - for (final v in rr.rrMs) { - if (v > 0) rrMs.add(v.toDouble()); + if (lastPacketTs == null || rr.ts > lastPacketTs) { + lastPacketTs = rr.ts; + final ts = rr.ts.toDouble() * 1000; + for (final v in rr.rrMs) { + if (v > 0) { + rrMs.add(v.toDouble()); + rrTsMs.add(ts); + } + } } } } if (rrMs.length < 20) { return {'ok': false, 'n_beats': rrMs.length}; } - final cleaned = ana.correctRr(rrMs); + final cleaned = ana.correctRr(rrMs, rrTsMs: rrTsMs); final m = ana.cardiacCoherence( cleaned.nn, cleaned.nnTimesMs, diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 26d5cd61c..1a7c425fb 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2644,7 +2644,11 @@ class AppState extends ChangeNotifier { // `breathingWindowOpen` is the MIND-06 quiet window either side of the // paced block — the same buffer, held open across the pacing's own start // and stop so a "before" and an "after" exist at all. - if ((breathingActive || breathingWindowOpen) && (pt == 0x28 || pt == 0x2B)) { + // A 0x2B envelope also carries gen5 Maverick's rev-21 100 Hz IMU record + // (byte[1] != 10) — realtimeRr already yields no beats from it, but it + // should not occupy the breathing R-R buffer at all (edge#286). + final isRrBearing = pt == 0x28 || (pt == 0x2B && _isR10Record(hex)); + if ((breathingActive || breathingWindowOpen) && isRrBearing) { if (_breathingFrames.length < 8000) _breathingFrames.add(hex); } // LIVE STEP COUNTER. Gen4: dedicated 0x33 IMU (~10 frames/s × 10 samples) @@ -2669,6 +2673,17 @@ class AppState extends ChangeNotifier { } } + /// True iff a live inner packet's record-type byte ([1]) is 10 (R10, the + /// only R-R-bearing record a 0x2B envelope carries). + bool _isR10Record(String hex) { + if (hex.length < 4) return false; + try { + return int.parse(hex.substring(2, 4), radix: 16) == 10; + } catch (_) { + return false; + } + } + proto.ImuFrame? _safeFrameAccel(String hex) { try { // Gen5 Maverick live IMU is 0x2B; gen4 stays on frameAccel (0x33 / R10). From 972369fd2cc4531e2052d8effda2271458916e00 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:34:54 +0530 Subject: [PATCH 5/9] export health data from every path that ends a workout --- lib/data/local_repository_impl.dart | 8 ++++++++ lib/import/whoop_import.dart | 10 +++++++++- lib/state/app_state.dart | 14 +++++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index ca36be5fc..a8c42fef4 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -13,6 +13,7 @@ // Honesty: a metric whose value is absent stays absent ("—"); we never fabricate. // Profile-gated metrics are null when the profile field is missing. +import 'dart:async'; import 'dart:convert'; import 'dart:isolate'; import 'dart:math' as math; @@ -27,6 +28,7 @@ import 'package:openstrap_analytics/onehz.dart' as ana; import 'day_label.dart'; import 'db.dart'; +import '../health/health_export.dart'; import 'journal_fields.dart'; import 'local_repository.dart'; import 'series_codec.dart'; @@ -2475,6 +2477,12 @@ class LocalRepositoryImpl extends LocalRepository { 'status': 'done', 'end_ts': DateTime.now().millisecondsSinceEpoch ~/ 1000, }); + // This is the choke point the AI coach's `end_workout` tool reaches + // directly (bypassing AppState.stopWorkout, the only other place this + // used to happen) — without it a coach-ended workout never reached + // Apple Health / Health Connect (edge#277). Idempotent + best-effort; + // exportWorkoutId re-reads the row and no-ops if sync is off. + unawaited(HealthExporter.exportWorkoutId(workoutId)); } return {'workout_id': workoutId}; } diff --git a/lib/import/whoop_import.dart b/lib/import/whoop_import.dart index ed80a0267..d549edeec 100644 --- a/lib/import/whoop_import.dart +++ b/lib/import/whoop_import.dart @@ -11,6 +11,7 @@ // labelled by the WAKE-onset local date (a night's recovery attributes to the day // you wake into — our day model). Marked BETA; values are WHOOP's own numbers. +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -18,6 +19,7 @@ import '../compute/derivation_engine.dart' show kAlgoVersion, DerivationEngine; import '../compute/profile.dart'; import '../compute/substrate.dart' show localDateLabel; import '../data/db.dart'; +import '../health/health_export.dart'; import 'import_container.dart'; import 'journal_csv_import.dart' show parseCsv; @@ -330,8 +332,9 @@ class WhoopImporter { final end = _parseTs(get(['workout end time', 'end time'])); if (start == null) return false; num? n(List names) => double.tryParse(get(names)); + final id = 'whoop_$start'; await LocalDb.putSession({ - 'id': 'whoop_$start', + 'id': id, 'start_ts': start, 'end_ts': end, 'type': _slug(get(['activity name', 'activity'])), @@ -343,6 +346,11 @@ class WhoopImporter { 'duration_min': (end != null) ? ((end - start) / 60).round() : null, 'created_at': DateTime.now().millisecondsSinceEpoch, }); + // exportAll only scans dates already present in day_result, so an + // imported workout on a date this import brought no day_result row for + // would otherwise never reach Health export — trigger it directly off + // the completed session row instead (edge#277). + unawaited(HealthExporter.exportWorkoutId(id)); return true; } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 1a7c425fb..2a99cf17e 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -5400,7 +5400,19 @@ class AppState extends ChangeNotifier { _deriveScheduler.setWorkoutActive(true); ScreenWake.enable(); } else { - await LocalDb.putSession({...row, 'status': 'done'}); + // A stale live row has no end_ts (it was never stopped), which + // permanently kept it un-exportable — Health export requires a real + // end. We don't know when the workout actually ended, so the + // honest stamp is reconcile-time (stated as such), not a guess at + // the real finish (edge#277). + final reconciledEndTs = nowMs ~/ 1000; + await LocalDb.putSession({ + ...row, + 'status': 'done', + 'end_ts': row['end_ts'] ?? reconciledEndTs, + }); + final id = row['id'] as String?; + if (id != null) unawaited(HealthExporter.exportWorkoutId(id)); _log('[workout] finalized a stale live-session row from a previous run (id=${row['id']}).'); } } From 96840db3102c0dee8d3c09c0322e8f83140de921 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:35:07 +0530 Subject: [PATCH 6/9] let people mark a whole night as not sleep --- lib/compute/substrate.dart | 24 ++++++++++++-- lib/l10n/app_en.arb | 16 +++++++++ lib/state/app_state.dart | 26 +++++++++++++++ lib/ui2/screens/sleep_detail.dart | 42 ++++++++++++++++++++---- test/derive_prepare_test.dart | 54 +++++++++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 9 deletions(-) diff --git a/lib/compute/substrate.dart b/lib/compute/substrate.dart index 51a3de928..6fed1aba9 100644 --- a/lib/compute/substrate.dart +++ b/lib/compute/substrate.dart @@ -941,7 +941,11 @@ class SleepWindowOverride { final String dayId; final int onsetSec; final int offsetSec; - final String source; // 'manual' | 'confirmed' + final String source; // 'manual' | 'confirmed' | 'rejected' + // 'rejected': onsetSec/offsetSec still carry the window being rejected (the + // auto-detected window at the time of rejection, same convention the + // already-shipped rejected-nap rows use), but the window is never staged — + // see calendarDays' `ov.source == 'rejected'` branch. const SleepWindowOverride({ required this.dayId, @@ -1070,7 +1074,14 @@ List calendarDays( ana.SleepSegmentation s; String src; - if (ov != null) { + if (ov != null && ov.source == 'rejected') { + // The user's word again, the other direction: this was NOT sleep at + // all. Skip detection/staging entirely rather than force a window — + // the day derives with no main sleep, same as the already-shipped + // rejected-nap path (`sleep_nap` source='rejected'). + s = ana.SleepSegmentation.absent; + src = 'rejected'; + } else if (ov != null) { // The user's word — force the window, skip detection entirely. s = ana.segmentSleep( accelSlice, @@ -1156,6 +1167,11 @@ List calendarDays( )); } } + } else if (src == 'rejected') { + // No window to stage (deliberately), but the day still needs to know + // it was a rejection rather than an ordinary absence — sleep_detail + // reads this to keep the "not sleep" state from re-prompting. + sleepSource = 'rejected'; } } @@ -1174,7 +1190,9 @@ List calendarDays( : (sleepSource == 'manual' || sleepSource == 'confirmed' ? const ['SLEEP_MANUAL'] : const [])) - : const ['NO_SLEEP_DETECTED'], + : (sleepSource == 'rejected' + ? const ['SLEEP_REJECTED'] + : const ['NO_SLEEP_DETECTED']), )); dayStart = dayEnd; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index df6c14335..d4415c86b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -8169,6 +8169,22 @@ "@sleepDetailBackToAutomatic": { "description": "Button: revert a manual sleep window override back to automatic staging." }, + "sleepDetailNotSleep": "Not sleep", + "@sleepDetailNotSleep": { + "description": "Button: reject this window entirely — it was not sleep at all." + }, + "sleepDetailRejectedTitle": "Marked as not sleep", + "@sleepDetailRejectedTitle": { + "description": "Status card title for a night the user rejected outright as not sleep." + }, + "sleepDetailRejectedBody": "You told us this stretch was not sleep, so nothing is scored for it.", + "@sleepDetailRejectedBody": { + "description": "Status card body for a night the user rejected outright as not sleep." + }, + "sleepDetailUndoRejection": "Undo — go back to automatic", + "@sleepDetailUndoRejection": { + "description": "Button: undo a not-sleep rejection and return the day to automatic staging." + }, "sleepDetailReanalysing": "Re-analysing the night…", "@sleepDetailReanalysing": { "description": "Progress label shown while the night is being re-derived after a window edit." diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 2a99cf17e..bd3ac9a10 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1970,6 +1970,32 @@ class AppState extends ChangeNotifier { await _reanalyzeForOverride(); } + /// Reject a day's detected main sleep entirely — "this was not sleep at + /// all", the missing counterpart to [confirmSleep] (naps already have this + /// via `sleep_nap` source='rejected'; main-sleep sessions didn't — edge#248). + /// Same table, same force-re-derive; `source: 'rejected'` tells + /// `calendarDays` (substrate.dart) to skip staging this window rather than + /// force it, so the day re-derives with no main sleep at all. Reversible + /// the same way as any other override: [clearSleepOverride]. + Future rejectSleep(String date) async { + if (repo == null) return; + final sleep = await repo!.getDaySleep(date); + final onset = (sleep['onset_ts'] as num?)?.toInt(); + final offset = (sleep['wake_ts'] as num?)?.toInt(); + // The rejected window is stored for the record, same as a rejected nap — + // it is never read back once source is 'rejected' (staging is skipped + // outright), so an absent detected window falls back to a harmless + // same-day placeholder rather than blocking the rejection. + final fallback = DateTime.parse(date).millisecondsSinceEpoch ~/ 1000; + await LocalDb.putSleepOverride( + dayId: date, + onsetTs: onset ?? fallback, + offsetTs: offset ?? (fallback + 1), + source: 'rejected', + ); + await _reanalyzeForOverride(); + } + /// Remove a manual/confirmed override for [date] — revert to auto/fallback. Future clearSleepOverride(String date) async { await LocalDb.deleteSleepOverride(date); diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 08086d356..b75be198c 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -427,6 +427,12 @@ class _SleepDetailState extends State { } if (!d.hasNight) { + // "No night" also covers a night the user REJECTED outright (edge#248): + // staging was skipped on purpose, so this day has no window at all — but + // that must stay reversible the same way any other override is. + final rejectedDay = d.day; + final rejected = + (d.night['sleep_source'] as String?) == 'rejected' && rejectedDay != null; return detailScaffold(c, title, [ ...dayNavRow(_day ?? d.day, d.days, _goDay), const SizedBox(height: S.x2), @@ -435,13 +441,27 @@ class _SleepDetailState extends State { // those says so and leaves the stepper above it, so it is a day you // walk off rather than a dead end. StatusCard( - l?.sleepDetailNoNightTitle ?? 'No night to show', - l?.sleepDetailNoNightBody ?? - 'No stretch of band recordings long enough to score.', - fix: l?.sleepDetailNoNightFix ?? - 'Wear the band overnight and sync in the morning', - icon: LucideIcons.moon, + rejected + ? (l?.sleepDetailRejectedTitle ?? 'Marked as not sleep') + : (l?.sleepDetailNoNightTitle ?? 'No night to show'), + rejected + ? (l?.sleepDetailRejectedBody ?? + 'You told us this stretch was not sleep, so nothing is ' + 'scored for it.') + : (l?.sleepDetailNoNightBody ?? + 'No stretch of band recordings long enough to score.'), + fix: rejected ? '' : (l?.sleepDetailNoNightFix ?? + 'Wear the band overnight and sync in the morning'), + icon: rejected ? LucideIcons.undo2 : LucideIcons.moon, ), + if (rejected) ...[ + const SizedBox(height: S.x2), + TextButton( + onPressed: _saving ? null : () => _clearWindow(rejectedDay), + child: Text( + l?.sleepDetailUndoRejection ?? 'Undo — go back to automatic'), + ), + ], ]); } @@ -655,6 +675,13 @@ class _SleepDetailState extends State { child: Text(l?.sleepDetailBackToAutomatic ?? 'Back to automatic'), ), + // The missing third answer next to "Looks right"/"Edit": naps + // already have a reject action (sleep_nap source='rejected'); + // a whole-night main-sleep session didn't (edge#248). + TextButton( + onPressed: busy ? null : () => _rejectWindow(day), + child: Text(l?.sleepDetailNotSleep ?? 'Not sleep'), + ), ]), if (busy) ...[ const SizedBox(height: S.x2), @@ -681,6 +708,9 @@ class _SleepDetailState extends State { Future _clearWindow(String day) => _runOverride(() => context.read().clearSleepOverride(day)); + Future _rejectWindow(String day) => + _runOverride(() => context.read().rejectSleep(day)); + /// Two pickers, seeded from the window we already have — the user is /// correcting times, not entering a date, so the DATES stay as measured and /// only the clock moves. A wake that lands before the onset belongs to the diff --git a/test/derive_prepare_test.dart b/test/derive_prepare_test.dart index 7b65a40f6..069ce3cd5 100644 --- a/test/derive_prepare_test.dart +++ b/test/derive_prepare_test.dart @@ -117,4 +117,58 @@ void main() { expect(inBed, greaterThan(3 * 3600)); expect(inBed, lessThanOrEqualTo(4 * 3600 + 60)); }); + + test( + 'a "rejected" sleep override skips staging entirely (edge#248)', + () { + // Same still-wrist-plus-low-HR shape as the "forces + stages" case + // above — without the override this would stage as sleep. + final start = DateTime(2026, 6, 27, 0, 0).millisecondsSinceEpoch ~/ 1000; + final end = DateTime(2026, 6, 27, 5, 0).millisecondsSinceEpoch ~/ 1000; + final ts = []; + final hr = []; + for (var t = start; t <= end; t++) { + ts.add(t); + hr.add(50); + } + final sub = Substrate( + tsSec: ts, + hr: hr, + rrTsMs: const [], + rrMs: const [], + ax: List.filled(ts.length, 0.02), + ay: List.filled(ts.length, 0.02), + az: List.filled(ts.length, 1.0), + spo2Red: List.filled(ts.length, 0), + spo2Ir: List.filled(ts.length, 0), + skinTemp: List.filled(ts.length, 0), + skinContact: List.filled(ts.length, 0), + ); + + final onsetSec = + DateTime(2026, 6, 27, 0, 30).millisecondsSinceEpoch ~/ 1000; + final offsetSec = + DateTime(2026, 6, 27, 4, 30).millisecondsSinceEpoch ~/ 1000; + + final out = prepareDerivationPayload( + sub, + targetDay: '2026-06-27', + override: SleepWindowOverride( + dayId: '2026-06-27', + onsetSec: onsetSec, + offsetSec: offsetSec, + source: 'rejected', + ), + ); + expect(out.days, hasLength(1)); + final day = out.days.first; + // The rejection is on the record... + expect(day.sleepSource, 'rejected'); + expect(day.flags, contains('SLEEP_REJECTED')); + // ...but nothing is staged from it — this day has no sleep at all. + expect(day.sleepJson['tst_sec'], isNull); + expect(day.sleepOnsetSec, 0); + expect(day.sleepOffsetSec, 0); + }, + ); } From d47230f1690aac8373d2033d18c8f30cd0bcba42 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:46:55 +0530 Subject: [PATCH 7/9] don't drop legit same-second rr packets, only out-of-order ones --- lib/data/local_repository_impl.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index a8c42fef4..53deabfc3 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -4102,10 +4102,13 @@ Map _spotCheckCompute(List records) { for (final hex in records) { final rr = proto.realtimeRr(hex); if (rr != null) { - // A duplicate or out-of-order packet timestamp cannot prove adjacency - // to whatever came before it — drop the whole packet rather than let - // it splice onto the series at the wrong place. - if (lastPacketTs == null || rr.ts > lastPacketTs) { + // rr.ts is whole SECONDS, and a live 0x2B packet can legitimately land + // more than once in the same second — only an out-of-order (strictly + // older) timestamp can't prove adjacency to what came before it, so + // only that gets dropped; an equal timestamp is accepted like normal + // (sourcery flagged the earlier strict `>` as silently losing real + // beats on any multi-packet-per-second burst). + if (lastPacketTs == null || rr.ts >= lastPacketTs) { lastPacketTs = rr.ts; final ts = rr.ts.toDouble() * 1000; for (final v in rr.rrMs) { @@ -4153,7 +4156,10 @@ Map _breathingCoherenceCompute( for (final hex in records) { final rr = proto.realtimeRr(hex); if (rr != null) { - if (lastPacketTs == null || rr.ts > lastPacketTs) { + // See _spotCheckCompute above: rr.ts is whole seconds, so only a + // strictly older (out-of-order) timestamp gets dropped, not an equal + // one — a live packet can legitimately land more than once a second. + if (lastPacketTs == null || rr.ts >= lastPacketTs) { lastPacketTs = rr.ts; final ts = rr.ts.toDouble() * 1000; for (final v in rr.rrMs) { From f53555fb21dcb9768a10c1c757c585af53c3baca Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:20:39 +0530 Subject: [PATCH 8/9] share the rr decode/dedup loop between spot-check and coherence compute --- lib/data/local_repository_impl.dart | 88 ++++++++++++++--------------- 1 file changed, 41 insertions(+), 47 deletions(-) diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 53deabfc3..a5085571e 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -4089,36 +4089,48 @@ Map? stressSummaryForToday( // Top-level (no `this` capture) + only file-scoped `proto`/`ana` top-level // functions + a List of hex frames in, a plain Map out — all sendable. -Map _spotCheckCompute(List records) { - // Decode RR from the live RR-bearing frames (0x28 / R10), clean, compute HRV. - // rrTsMs carries each beat's packet timestamp (ms) alongside it — same seam - // getNightBeats uses (line ~867) — so correctRr can re-anchor at a dropout - // and hrvTime's seam detection can refuse to diff across it (edge#286: a - // flat rrMs-only list let a real packet gap read as two successive beats). +/// Decode RR beats from the live RR-bearing frames (0x28 / R10), shared by +/// [_spotCheckCompute] and [_breathingCoherenceCompute] (CodeRabbit noted the +/// duplication on edge#308). rrTsMs carries each beat's packet timestamp +/// (ms) alongside it — same seam getNightBeats uses (line ~867) — so +/// correctRr can re-anchor at a dropout and hrvTime's/coherence's seam +/// detection can refuse to diff across it (edge#286: a flat rrMs-only list +/// let a real packet gap read as two successive beats). rr.ts is whole +/// SECONDS, and a live 0x2B packet can legitimately land more than once in +/// the same second — only an out-of-order (strictly older) timestamp can't +/// prove adjacency to what came before it, so only that gets dropped; an +/// equal timestamp is accepted like normal (sourcery flagged the earlier +/// strict `>` as silently losing real beats on any multi-packet-per-second +/// burst). +({List rrMs, List rrTsMs}) _decodeLiveRr( + List records, +) { final rrMs = []; final rrTsMs = []; - final hrs = []; int? lastPacketTs; for (final hex in records) { final rr = proto.realtimeRr(hex); - if (rr != null) { - // rr.ts is whole SECONDS, and a live 0x2B packet can legitimately land - // more than once in the same second — only an out-of-order (strictly - // older) timestamp can't prove adjacency to what came before it, so - // only that gets dropped; an equal timestamp is accepted like normal - // (sourcery flagged the earlier strict `>` as silently losing real - // beats on any multi-packet-per-second burst). - if (lastPacketTs == null || rr.ts >= lastPacketTs) { - lastPacketTs = rr.ts; - final ts = rr.ts.toDouble() * 1000; - for (final v in rr.rrMs) { - if (v > 0) { - rrMs.add(v.toDouble()); - rrTsMs.add(ts); - } - } + if (rr == null || (lastPacketTs != null && rr.ts < lastPacketTs)) { + continue; + } + lastPacketTs = rr.ts; + final ts = rr.ts.toDouble() * 1000; + for (final v in rr.rrMs) { + if (v > 0) { + rrMs.add(v.toDouble()); + rrTsMs.add(ts); } } + } + return (rrMs: rrMs, rrTsMs: rrTsMs); +} + +Map _spotCheckCompute(List records) { + final decoded = _decodeLiveRr(records); + final rrMs = decoded.rrMs; + final rrTsMs = decoded.rrTsMs; + final hrs = []; + for (final hex in records) { try { final s = proto.decodeRecord(hex); if (s != null && s.hr > 0) hrs.add(s.hr.toDouble()); @@ -4145,32 +4157,14 @@ Map _breathingCoherenceCompute( List records, double? pacedHz, ) { - // Decode RR from the live RR-bearing frames (0x28 / R10) — same seam - // spotCheck uses — then run McCraty & Zayas 2014 cardiac coherence. - // rrTsMs (see _spotCheckCompute above) lets correctRr re-anchor at a real + // Decode RR from the live RR-bearing frames (0x28 / R10) via the shared + // _decodeLiveRr helper (same seam spotCheck uses), then run McCraty & + // Zayas 2014 cardiac coherence. rrTsMs lets correctRr re-anchor at a real // packet gap so the coherence time axis is the real wall clock, not a // gap-free cumsum (edge#286). - final rrMs = []; - final rrTsMs = []; - int? lastPacketTs; - for (final hex in records) { - final rr = proto.realtimeRr(hex); - if (rr != null) { - // See _spotCheckCompute above: rr.ts is whole seconds, so only a - // strictly older (out-of-order) timestamp gets dropped, not an equal - // one — a live packet can legitimately land more than once a second. - if (lastPacketTs == null || rr.ts >= lastPacketTs) { - lastPacketTs = rr.ts; - final ts = rr.ts.toDouble() * 1000; - for (final v in rr.rrMs) { - if (v > 0) { - rrMs.add(v.toDouble()); - rrTsMs.add(ts); - } - } - } - } - } + final decoded = _decodeLiveRr(records); + final rrMs = decoded.rrMs; + final rrTsMs = decoded.rrTsMs; if (rrMs.length < 20) { return {'ok': false, 'n_beats': rrMs.length}; } From a6ec852a0ce33e3a5fa23424fe770dab95f58070 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:27:45 +0530 Subject: [PATCH 9/9] clear the old health window when retiming a workout setWorkoutWindow overwrote the row with the new start/end, then exportWorkoutId only deleted inside that new window before writing - if a retime narrowed or moved the range the old health sample never got cleaned up and sat there stale next to the new one. now deletes the old window first, before the row gets overwritten. --- lib/data/local_repository_impl.dart | 9 +++++ lib/health/health_export.dart | 23 ++++++++++++ ...ealth_workout_export_delete_gate_test.dart | 35 +++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index a5085571e..cdedd3278 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -2536,6 +2536,15 @@ class LocalRepositoryImpl extends LocalRepository { if (existing['status'] == 'live') { throw StateError('setWorkoutWindow: session $id is still live'); } + // Clear the OLD Health window before the retimed row overwrites it — the + // caller's subsequent exportWorkoutId only deletes inside the NEW window, + // so a narrowed/moved retime would otherwise leave the previous sample + // stranded outside it. + final oldStart = (existing['start_ts'] as num?)?.toInt(); + final oldEnd = (existing['end_ts'] as num?)?.toInt(); + if (oldStart != null && oldEnd != null) { + await HealthExporter.deleteWorkoutWindow(oldStart, oldEnd); + } // A retimed session keeps its id, so the OLD row is replaced rather than // orphaned — including its GPS route, which still belongs to it. return _writeManualSession( diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 0e7a41ca1..faa72a980 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -298,6 +298,29 @@ class HealthExporter { } } + /// Clear a stale workout's OLD window before the caller writes its retimed + /// replacement. [exportWorkout] only ever deletes inside the row's CURRENT + /// `[start,end]` — fine for a same-window re-export, but `setWorkoutWindow` + /// narrowing or moving a session leaves its previous Health sample outside + /// that window, so it survives untouched. Call this with the row's window + /// as it stood BEFORE the retime, then let the normal exportWorkoutId path + /// write the new one. Best-effort; never throws. + static Future deleteWorkoutWindow(int startTs, int endTs) async { + try { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(kHealthSyncPref) != true) return; + await shared._ensureConfigured(); + if (await shared._androidUnavailable() != null) return; + await shared._deleteOwnSamples( + HealthDataType.WORKOUT, + DateTime.fromMillisecondsSinceEpoch(startTs * 1000), + DateTime.fromMillisecondsSinceEpoch(endTs * 1000), + ); + } catch (e) { + debugPrint('[health] deleteWorkoutWindow: $e'); + } + } + /// True on iOS/macOS (Apple Health); false on Android (Health Connect). static bool get isApple => Platform.isIOS || Platform.isMacOS; diff --git a/test/health_workout_export_delete_gate_test.dart b/test/health_workout_export_delete_gate_test.dart index c96bda068..52cf7fe96 100644 --- a/test/health_workout_export_delete_gate_test.dart +++ b/test/health_workout_export_delete_gate_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/health/health_export.dart'; +import 'package:shared_preferences/shared_preferences.dart'; /// A stand-in for the platform health store, driven over the `health` plugin's /// own method channel — the only seam `HealthExporter` reaches the store @@ -126,4 +127,38 @@ void main() { expect(store.calls, containsAllInOrder(['delete', 'writeWorkoutData'])); }); }); + + group('deleteWorkoutWindow (retime cleanup)', () { + // setWorkoutWindow only has the OLD [start,end] before it overwrites the + // row — this is what it calls to clear that range so a narrowed/moved + // retime doesn't leave the previous Health sample stranded outside the + // new window (which is all `exportWorkoutId`'s own delete ever reaches). + late _FakeHealthStore store; + + tearDown(() => store.remove()); + + test('clears the old window when health sync is on', () async { + SharedPreferences.setMockInitialValues({'health_sync': true}); + store = _FakeHealthStore(deleteResult: true)..install(); + + final oldStart = DateTime(2026, 8, 26, 18, 30); + final oldEnd = DateTime(2026, 8, 26, 19, 15); + await HealthExporter.deleteWorkoutWindow( + oldStart.millisecondsSinceEpoch ~/ 1000, + oldEnd.millisecondsSinceEpoch ~/ 1000, + ); + + expect(store.calls, contains('delete')); + expect(store.calls, isNot(contains('writeWorkoutData'))); + }); + + test('no-ops when health sync is off', () async { + SharedPreferences.setMockInitialValues({'health_sync': false}); + store = _FakeHealthStore(deleteResult: true)..install(); + + await HealthExporter.deleteWorkoutWindow(0, 100); + + expect(store.calls, isEmpty); + }); + }); }