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/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/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index bc72f5144..cdedd3278 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}; } @@ -2528,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( @@ -4081,17 +4098,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. +/// 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 hrs = []; + 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 (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()); @@ -4100,7 +4148,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; @@ -4118,21 +4166,18 @@ 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. - final rrMs = []; - 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()); - } - } - } + // 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 decoded = _decodeLiveRr(records); + final rrMs = decoded.rrMs; + final rrTsMs = decoded.rrTsMs; 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/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/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/l10n/app_en.arb b/lib/l10n/app_en.arb index 9db068866..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." @@ -9274,9 +9290,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/state/app_state.dart b/lib/state/app_state.dart index 26d5cd61c..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); @@ -2644,7 +2670,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 +2699,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). @@ -5385,7 +5426,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']}).'); } } 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), 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); + }, + ); } 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/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); + }); + }); } 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/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); + }); + }); } 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;