diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 3af2549..79f222a 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -117,7 +117,17 @@ import 'substrate.dart'; // compute to `Calories.activeEnergy` (Keytel + height term) without a version // bump; combined with the v25 features above, bump so finalized days recompute // onto the new calorie formula instead of silently carrying the old values. -const int kAlgoVersion = 26; +// v27: WEAR fix — worn-time / coverage / on-off segments were defined as hr>0, +// which misreads daytime PPG drop-out as off-wrist and collapsed a 24 h-worn day +// to ~the sleep window (~7-8 h). Wear is now RECORD presence (gap-detected), in +// both the `worn_min` scalar (onehz_pipeline) and the `_wearBlock` detail. Bump +// so finalized days recompute the corrected wear ("Re-analyze data" restages all). +// v28: SLEEP rescue — manual sleep entry + HR-led fallback. When accel-led +// detection finds nothing, an HR-dip fallback now proposes a window (source +// 'auto_fallback', low confidence); a user can type/confirm a window +// (sleep_override table → source 'manual'/'confirmed') which force-derives even +// a finalized day. Bump so fallback-eligible days restage. +const int kAlgoVersion = 28; /// Raw is kept this many days past derivation, then pruned (derived stays). const int rawRetentionDays = 14; @@ -348,9 +358,14 @@ class DerivationEngine { return 0; } final finalized = await LocalDb.finalizedDayIds(kAlgoVersion); + // A user sleep override (manual / confirmed) must take effect even on a + // FINALIZED (locked) day — it's the user's word. Force those back into the + // todo set. (No-raw days are guarded in the per-day loop so we never + // clobber a good manual result with an empty re-derive once raw is pruned.) + final overrideDays = await LocalDb.sleepOverrideDays(); final todoDays = [ for (final day in scope.targetDays) - if (!finalized.contains(day)) day, + if (!finalized.contains(day) || overrideDays.contains(day)) day, ]; if (todoDays.isEmpty) { _log('derive: all days finalized — nothing to do'); @@ -380,6 +395,16 @@ class DerivationEngine { try { _diag['stage'] = 'prepare'; final prepared = await _prepareTargetDay(dayId); + // Override day whose raw has been pruned (≥14 d): re-deriving would + // produce an empty/absent result and clobber the user's manual sleep. + // Keep the existing locked result instead. + if (prepared != null && + prepared.daySub.isEmpty && + overrideDays.contains(dayId)) { + _log('derive day $dayId skipped: override day, raw pruned — kept'); + onDayDone?.call(dayId, i + 1, todoDays.length); + continue; + } if (prepared != null) { _diag['prepared_days'] = (_diag['prepared_days'] as int) + 1; _diag['stage'] = 'per_day'; @@ -475,20 +500,35 @@ class DerivationEngine { } Future _sleepCandidateForDay(String dayId) async { - final finalized = await LocalDb.finalizedDayIds(kAlgoVersion); - if (finalized.contains(dayId)) { - final cached = await LocalDb.sleepSessionCandidate(dayId, kAlgoVersion); - final raw = cached?['payload_json']; - if (raw is String && raw.isNotEmpty) { - try { - final decoded = jsonDecode(raw); - if (decoded is Map) { - return SleepSessionCandidate.fromJson( - decoded.cast(), - ); + // A user sleep override is the source of truth — never serve the cached auto + // candidate, and don't cache the override result (so a later edit / clear is + // not shadowed by a stale artifact). The auto path keeps its finalized cache. + final overrideRow = await LocalDb.getSleepOverride(dayId); + final override = overrideRow == null + ? null + : SleepWindowOverride( + dayId: dayId, + onsetSec: (overrideRow['onset_ts'] as num).toInt(), + offsetSec: (overrideRow['offset_ts'] as num).toInt(), + source: overrideRow['source'] as String? ?? 'manual', + ); + + if (override == null) { + final finalized = await LocalDb.finalizedDayIds(kAlgoVersion); + if (finalized.contains(dayId)) { + final cached = await LocalDb.sleepSessionCandidate(dayId, kAlgoVersion); + final raw = cached?['payload_json']; + if (raw is String && raw.isNotEmpty) { + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + return SleepSessionCandidate.fromJson( + decoded.cast(), + ); + } + } catch (_) { + // Fall through to rebuild the artifact. } - } catch (_) { - // Fall through to rebuild the artifact. } } } @@ -498,12 +538,18 @@ class DerivationEngine { range.$2, dayId: dayId, ); - final candidate = prepareSleepSessionCandidate(searchSub, targetDay: dayId); - await LocalDb.putSleepSessionCandidate( - dayId: dayId, - algoVersion: kAlgoVersion, - payloadJson: jsonEncode(candidate.toJson()), + final candidate = prepareSleepSessionCandidate( + searchSub, + targetDay: dayId, + override: override, ); + if (override == null) { + await LocalDb.putSleepSessionCandidate( + dayId: dayId, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode(candidate.toJson()), + ); + } return candidate; } @@ -962,6 +1008,11 @@ class DerivationEngine { () => deriveDayBundle(withHistory), ).timeout(_perDayTimeout); + // Where this day's sleep window came from (auto / auto_fallback / manual / + // confirmed) — drives the Sleep screen's "is this right?" prompt + the + // manual-edit affordance. Carried verbatim from the segmentation candidate. + bundle['sleep_source'] = day.sleepSource; + final scMap = (bundle['scalars'] as Map?)?.cast(); final wake = _buildWakeDayFeatures( daySub, @@ -1880,8 +1931,19 @@ class DerivationEngine { return out; } - /// On/off-wrist segments over the day (on = hr>0): the runs, first/last on, - /// longest off gap, worn minutes + time-coverage. All from the day HR. + /// On/off-wrist segments over the day from RECORD PRESENCE — the runs, + /// first/last on, longest off gap, worn minutes + time-coverage. + /// + /// Wear is whether a 1 Hz record EXISTS, not whether HR locked. The band logs + /// to flash only while on-wrist (off-wrist it stops and emits WRIST_OFF), so a + /// record means worn. The old `hr>0` rule misread normal daytime PPG drop-out + /// (HR only locks on a still wrist with good optical contact — mostly SLEEP) + /// as off-wrist, collapsing a 24 h-worn day to ~the sleep window (~7-8 h). Off + /// periods are now GAPS in the record stream longer than [offGapSec]. + /// + /// CAVEAT: this assumes the band does NOT keep logging while off-wrist. If a + /// future firmware streams off-wrist records, add a skin-temp/motion on-body + /// gate here (the substrate carries accel + skinTemp). Map _wearBlock(Substrate s) { final n = s.length; if (n == 0) { @@ -1894,32 +1956,42 @@ class DerivationEngine { 'coverage_pct': 0, }; } + const offGapSec = 120; // a >2-min hole in the 1 Hz stream = off / not worn final segments = >[]; - int? firstOn, lastOn; + final firstOn = s.tsSec.first; + final lastOn = s.tsSec.last + 1; var longestOff = 0, wornSec = 0; - var i = 0; - while (i < n) { - final on = s.hr[i] > 0; - var j = i; - while (j < n && (s.hr[j] > 0) == on) { - j++; - } - final startTs = s.tsSec[i], endTs = s.tsSec[j - 1] + 1; + var runStart = s.tsSec.first; + var prev = s.tsSec.first; + + void closeOnRun(int endTs) { segments.add({ - 'on': on, - 'start': startTs, + 'on': true, + 'start': runStart, 'end': endTs, - 'len_min': ((endTs - startTs) / 60).round(), + 'len_min': ((endTs - runStart) / 60).round(), }); - if (on) { - firstOn ??= startTs; - lastOn = endTs; - wornSec += endTs - startTs; - } else if (endTs - startTs > longestOff) { - longestOff = endTs - startTs; + wornSec += endTs - runStart; + } + + for (var i = 1; i < n; i++) { + final ts = s.tsSec[i]; + final gap = ts - prev; + if (gap > offGapSec) { + closeOnRun(prev + 1); + segments.add({ + 'on': false, + 'start': prev + 1, + 'end': ts, + 'len_min': (gap / 60).round(), + }); + if (gap > longestOff) longestOff = gap; + runStart = ts; } - i = j; + prev = ts; } + closeOnRun(prev + 1); + final totalSec = s.tsSec.last - s.tsSec.first + 1; return { 'segments': segments, diff --git a/lib/compute/derive_prepare.dart b/lib/compute/derive_prepare.dart index 52044d7..f49bee9 100644 --- a/lib/compute/derive_prepare.dart +++ b/lib/compute/derive_prepare.dart @@ -14,6 +14,7 @@ class PreparedDerivationDay { final List hypnoStages; final int sleepOnsetSec; final int sleepOffsetSec; + final String sleepSource; final Substrate daySub; final Substrate sleepSub; @@ -28,6 +29,7 @@ class PreparedDerivationDay { required this.sleepOffsetSec, required this.daySub, required this.sleepSub, + this.sleepSource = 'auto', }); Map toJson() => { @@ -39,6 +41,7 @@ class PreparedDerivationDay { 'hypno_stages': hypnoStages, 'sleep_onset_sec': sleepOnsetSec, 'sleep_offset_sec': sleepOffsetSec, + 'sleep_source': sleepSource, 'day_sub': daySub.toJson(), 'sleep_sub': sleepSub.toJson(), }; @@ -56,6 +59,7 @@ class PreparedDerivationDay { hypnoStages: strs('hypno_stages'), sleepOnsetSec: (m['sleep_onset_sec'] as num?)?.toInt() ?? 0, sleepOffsetSec: (m['sleep_offset_sec'] as num?)?.toInt() ?? 0, + sleepSource: m['sleep_source'] as String? ?? 'auto', daySub: Substrate.fromJson( ((m['day_sub'] as Map?) ?? const {}).cast(), ), @@ -102,6 +106,7 @@ class SleepSessionCandidate { final List hypnoStages; final int sleepOnsetSec; final int sleepOffsetSec; + final String sleepSource; const SleepSessionCandidate({ required this.dayId, @@ -111,6 +116,7 @@ class SleepSessionCandidate { required this.hypnoStages, required this.sleepOnsetSec, required this.sleepOffsetSec, + this.sleepSource = 'auto', }); bool get present => sleepJson['tst_sec'] != null; @@ -123,6 +129,7 @@ class SleepSessionCandidate { 'hypno_stages': hypnoStages, 'sleep_onset_sec': sleepOnsetSec, 'sleep_offset_sec': sleepOffsetSec, + 'sleep_source': sleepSource, }; static SleepSessionCandidate fromJson(Map m) { @@ -136,6 +143,7 @@ class SleepSessionCandidate { hypnoStages: strs('hypno_stages'), sleepOnsetSec: (m['sleep_onset_sec'] as num?)?.toInt() ?? 0, sleepOffsetSec: (m['sleep_offset_sec'] as num?)?.toInt() ?? 0, + sleepSource: m['sleep_source'] as String? ?? 'auto', ); } @@ -147,6 +155,7 @@ class SleepSessionCandidate { hypnoStages: const [], sleepOnsetSec: 0, sleepOffsetSec: 0, + sleepSource: 'none', ); PreparedDerivationDay toPreparedDay({ @@ -161,6 +170,7 @@ class SleepSessionCandidate { hypnoStages: hypnoStages, sleepOnsetSec: sleepOnsetSec, sleepOffsetSec: sleepOffsetSec, + sleepSource: sleepSource, daySub: daySub, sleepSub: sleepSub, ); @@ -233,12 +243,13 @@ void derivationPrepareWorker(SendPort mainSendPort) { PreparedDerivationPayload prepareDerivationPayload( Substrate sub, { String? targetDay, + SleepWindowOverride? override, }) { if (sub.isEmpty || sub.lastTs == null) { return const PreparedDerivationPayload(dataNowSec: 0, days: []); } final days = []; - for (final day in calendarDays(sub)) { + for (final day in calendarDays(sub, override: override)) { if (targetDay != null && day.date != targetDay) continue; final daySub = sub.slice(day.startSec, day.endSec); final sleepSub = day.hasSleep @@ -273,6 +284,7 @@ PreparedDerivationPayload prepareDerivationPayload( hypnoStages: hypno, sleepOnsetSec: onsetSec, sleepOffsetSec: offsetSec, + sleepSource: day.sleepSource, daySub: daySub, sleepSub: sleepSub, ), @@ -284,8 +296,10 @@ PreparedDerivationPayload prepareDerivationPayload( SleepSessionCandidate prepareSleepSessionCandidate( Substrate sub, { required String targetDay, + SleepWindowOverride? override, }) { - final payload = prepareDerivationPayload(sub, targetDay: targetDay); + final payload = + prepareDerivationPayload(sub, targetDay: targetDay, override: override); if (payload.days.isEmpty) return SleepSessionCandidate.absent(targetDay); final day = payload.days.first; return SleepSessionCandidate( @@ -296,6 +310,7 @@ SleepSessionCandidate prepareSleepSessionCandidate( hypnoStages: day.hypnoStages, sleepOnsetSec: day.sleepOnsetSec, sleepOffsetSec: day.sleepOffsetSec, + sleepSource: day.sleepSource, ); } diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 95562d4..0f8b7f8 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -194,6 +194,20 @@ Map deriveDayBundle(Map inputJson) { final dayHr = [for (final h in d.dayHr) h.toDouble()]; final dayHrValid = dayHr.where((h) => h > 0).toList(); + // ── WORN minutes — distinct wall-clock minutes that have ANY record ──────── + // Wear is RECORD presence, NOT valid HR. The band logs 1 Hz to flash only + // while on-wrist (off-wrist it stops and emits WRIST_OFF), so a record in a + // minute means the band was worn that minute. We deliberately do NOT gate on + // HR>0: a valid HR needs a still wrist + good optical contact, which happens + // mostly during SLEEP, so an HR-valid count collapses "worn" to ~the sleep + // duration (the 24 h-worn-shows-7 h bug). Bucketing by real epoch-second + // timestamp (not array index) is also gap-safe. + final wornMinuteBuckets = {}; + for (final ts in d.dayTsSec) { + wornMinuteBuckets.add(ts ~/ 60); + } + final wornMin = wornMinuteBuckets.length; + // ── HR over the SLEEP WINDOW (RHR / dip night-side) ──────────────────────── final sleepHr = [for (final h in d.sleepHr) h.toDouble()]; @@ -797,9 +811,7 @@ Map deriveDayBundle(Map inputJson) { : null, // Sleep efficiency % + worn minutes → their own day/week/month/3M trends. 'efficiency': effPct == null ? null : _round(effPct, 1), - 'worn_min': dayHrValid.isEmpty - ? null - : (dayHrValid.length / 60).roundToDouble(), + 'worn_min': wornMin == 0 ? null : wornMin.toDouble(), }, }; } diff --git a/lib/compute/substrate.dart b/lib/compute/substrate.dart index f266433..1555a70 100644 --- a/lib/compute/substrate.dart +++ b/lib/compute/substrate.dart @@ -335,6 +335,15 @@ class PhysioDay { /// Honest flags (e.g. LOW_CONFIDENCE_RECOVERY for fallback days). final List flags; + /// Where this day's sleep WINDOW came from: + /// 'auto' — accel-led van Hees detection (the normal path) + /// 'auto_fallback' — HR-led fallback (van Hees found nothing); LOW confidence, + /// surface a "is this right?" prompt + /// 'manual' — user typed the window (Approach 1) + /// 'confirmed' — user accepted the fallback's proposal + /// 'none' — no sleep at all + final String sleepSource; + const PhysioDay({ required this.date, required this.startSec, @@ -344,11 +353,29 @@ class PhysioDay { required this.sleepHiIdx, required this.confidence, required this.flags, + this.sleepSource = 'auto', }); bool get hasSleep => sleep.present; } +/// A user-asserted sleep window for one day — manual entry (Approach 1) or a +/// confirmation of the HR-led fallback (Approach 2). Passed into [calendarDays] +/// so it overrides auto detection for the matching [dayId]. +class SleepWindowOverride { + final String dayId; + final int onsetSec; + final int offsetSec; + final String source; // 'manual' | 'confirmed' + + const SleepWindowOverride({ + required this.dayId, + required this.onsetSec, + required this.offsetSec, + required this.source, + }); +} + /// Local YYYY-MM-DD label for an epoch-second instant. String localDateLabel(int epochSec) { final d = DateTime.fromMillisecondsSinceEpoch(epochSec * 1000, isUtc: false); @@ -370,7 +397,7 @@ String localDateLabel(int epochSec) { /// A sleep that crosses midnight is attributed to the day it ENDS; its window /// indices (sleepLoIdx/Hi) point into the full substrate, so the coordinator /// still slices the whole window for HRV/RHR/recovery regardless of the boundary. -List calendarDays(Substrate sub) { +List calendarDays(Substrate sub, {SleepWindowOverride? override}) { if (sub.isEmpty) return const []; final accel = sub.accelSamples(); final hr = sub.hr1hz(); @@ -403,7 +430,12 @@ List calendarDays(Substrate sub) { var seg = ana.SleepSegmentation.absent; var sleepLo = 0, sleepHi = 0; - if (hiS - loS >= 600) { + var sleepSource = 'none'; + final dayLabel = localDateLabel(dayStart); + // Does the user have an override (manual / confirmed) for THIS day? + final ov = + (override != null && override.dayId == dayLabel) ? override : null; + if (hiS - loS >= 600 || ov != null) { final habitualMidsleepSec = ana.habitualMidsleepSecFromHistory( sleepHistory, tzOffsetSeconds: DateTime.now().timeZoneOffset.inSeconds, @@ -411,8 +443,10 @@ List calendarDays(Substrate sub) { // Daytime HR baseline = valid HR before the nocturnal search window. final base = [for (var i = 0; i < loS; i++) if (hr[i] > 0) hr[i]]; final hrBaseline = base.length >= 60 ? base : null; + final accelSlice = accel.sublist(loS, hiS); + final hrSlice = hr.sublist(loS, hiS); // RR beats within the search slice (absolute ms) for RMSSD-based staging. - final s0 = sub.tsSec[loS] * 1000; + final s0 = sub.tsSec[loS.clamp(0, sub.length - 1)] * 1000; final s1 = sub.tsSec[(hiS - 1).clamp(0, sub.length - 1)] * 1000; final rrMsSeg = []; final rrTsSeg = []; @@ -423,21 +457,65 @@ List calendarDays(Substrate sub) { rrTsSeg.add(t); } } - final s = ana.segmentSleep( - accel.sublist(loS, hiS), - hr.sublist(loS, hiS), - hrBaseline: hrBaseline, - rrMs: rrMsSeg, - rrTsMs: rrTsSeg, - habitualMidsleepSec: habitualMidsleepSec, - ); - // Attribute ONLY if the sleep's wake (offset) falls within this day. + + ana.SleepSegmentation s; + String src; + if (ov != null) { + // The user's word — force the window, skip detection entirely. + s = ana.segmentSleep( + accelSlice, + hrSlice, + hrBaseline: hrBaseline, + rrMs: rrMsSeg, + rrTsMs: rrTsSeg, + forcedWindow: (onsetSec: ov.onsetSec, offsetSec: ov.offsetSec), + ); + src = ov.source; // 'manual' | 'confirmed' + } else { + s = ana.segmentSleep( + accelSlice, + hrSlice, + hrBaseline: hrBaseline, + rrMs: rrMsSeg, + rrTsMs: rrTsSeg, + habitualMidsleepSec: habitualMidsleepSec, + ); + src = 'auto'; + if (!s.present) { + // Approach 2: accel-led detection found nothing → HR-led fallback. + // Propose the longest sustained nocturnal HR dip, then STAGE it via the + // forced-window path. Marked low-confidence for a "is this right?" prompt. + final tsSlice = [for (var i = loS; i < hiS; i++) sub.tsSec[i]]; + final cand = + ana.hrLedSleepWindow(hrSlice, tsSlice, hrBaseline: hrBaseline); + if (cand != null) { + final s2 = ana.segmentSleep( + accelSlice, + hrSlice, + hrBaseline: hrBaseline, + rrMs: rrMsSeg, + rrTsMs: rrTsSeg, + forcedWindow: + (onsetSec: cand.onsetSec, offsetSec: cand.offsetSec), + ); + if (s2.present) { + s = s2; + src = 'auto_fallback'; + } + } + } + } + if (s.present && s.window != null) { final offSec = s.window!.offsetMs! ~/ 1000; - if (offSec >= dayStart && offSec < dayEnd) { + // Auto/fallback: attribute only if the wake lands in this calendar day. + // Manual/confirmed: trust the user — attribute to the day they set it on. + final userSet = ov != null; + if (userSet || (offSec >= dayStart && offSec < dayEnd)) { seg = s; sleepLo = loS + s.window!.onsetIdx; sleepHi = loS + s.window!.offsetIdx; + sleepSource = src; final onsetSec = s.window!.onsetMs == null ? 0 : (s.window!.onsetMs! / 1000).round(); @@ -445,7 +523,7 @@ List calendarDays(Substrate sub) { sleepHistory.add(( startSec: onsetSec, endSec: offSec, - dayKey: localDateLabel(dayStart), + dayKey: dayLabel, )); } } @@ -453,15 +531,21 @@ List calendarDays(Substrate sub) { } days.add(PhysioDay( - date: localDateLabel(dayStart), + date: dayLabel, startSec: cs, endSec: ce, sleep: seg, sleepLoIdx: sleepLo, sleepHiIdx: sleepHi, confidence: seg.present ? seg.confidence : 0.0, - flags: - seg.present ? const [] : const ['NO_SLEEP_DETECTED'], + sleepSource: sleepSource, + flags: seg.present + ? (sleepSource == 'auto_fallback' + ? const ['SLEEP_FALLBACK'] + : (sleepSource == 'manual' || sleepSource == 'confirmed' + ? const ['SLEEP_MANUAL'] + : const [])) + : const ['NO_SLEEP_DETECTED'], )); dayStart = dayEnd; } diff --git a/lib/data/db.dart b/lib/data/db.dart index da46f4a..83d93d0 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -57,6 +57,7 @@ class LocalDb { await _createPrimitiveArtifacts(db); await _createLiveCoverage(db); await _createWorkoutSuggestions(db); + await _createSleepOverride(db); await _ensureCoachViews(db); }, onUpgrade: (db, oldV, newV) async { @@ -204,11 +205,16 @@ class LocalDb { await _ensureSessionSchema(db); // adds hrr_bpm await _createWorkoutSuggestions(db); } + if (oldV < 20) { + // Manual / confirmed sleep windows (Approach 1 + the fallback's + // "is this right?" confirm). Additive table; survives algo bumps. + await _createSleepOverride(db); + } }, onOpen: (db) async { await _repairOpenSchema(db); }, - version: 19, + version: 20, ); } @@ -234,6 +240,7 @@ class LocalDb { await _ensureSessionSchema(db); await _ensureSyncStateSchema(db); await _createWorkoutSuggestions(db); + await _createSleepOverride(db); // Views LAST — they depend on metric_series / day_result / baselines / sessions // / notifications all existing. DROP+CREATE so a shape change takes effect. await _ensureCoachViews(db); @@ -297,6 +304,70 @@ class LocalDb { '''); } + // ── SLEEP OVERRIDE (manual / confirmed sleep windows) ─────────────────────── + // The user's word on when they slept — either typed in manually (Approach 1) + // or a confirmation of the HR-led fallback's proposal (Approach 2). Stored + // SEPARATELY from the derived day_result so it survives finalization AND any + // kAlgoVersion bump: the engine re-applies it on every derive of that day. + // source: 'manual' — user typed the times + // 'confirmed' — user accepted the fallback's proposed window + // Times are epoch SECONDS (phone clock; raw rec_ts is SET_CLOCK'd to match). + static Future _createSleepOverride(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS sleep_override ( + day_id TEXT PRIMARY KEY, + onset_ts INTEGER NOT NULL, + offset_ts INTEGER NOT NULL, + source TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + '''); + } + + /// Upsert the user's sleep window for [dayId] (local date label). [source] is + /// 'manual' or 'confirmed'. Replaces any prior override for that day. + static Future putSleepOverride({ + required String dayId, + required int onsetTs, + required int offsetTs, + required String source, + }) async { + final db = await instance; + await db.insert( + 'sleep_override', + { + 'day_id': dayId, + 'onset_ts': onsetTs, + 'offset_ts': offsetTs, + 'source': source, + 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + /// The user's sleep window for [dayId], or null if none. + static Future?> getSleepOverride(String dayId) async { + final db = await instance; + final rows = await db.query('sleep_override', + where: 'day_id = ?', whereArgs: [dayId], limit: 1); + return rows.isEmpty ? null : rows.first; + } + + /// Remove the override for [dayId] (revert to auto detection). + static Future deleteSleepOverride(String dayId) async { + final db = await instance; + await db.delete('sleep_override', where: 'day_id = ?', whereArgs: [dayId]); + } + + /// Every day that currently has a user override — these must be force-derived + /// even when finalized, so an edit to a locked day actually takes effect. + static Future> sleepOverrideDays() async { + final db = await instance; + final rows = await db.query('sleep_override', columns: ['day_id']); + return {for (final r in rows) r['day_id'] as String}; + } + // ── 100 Hz STEP COVERAGE ──────────────────────────────────────────────────── // Device-time windows the live AN-2554 pedometer actually counted (real steps). // The 1 Hz estimate excludes any minute that falls inside one of these windows diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index aab2f5a..649707d 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -504,7 +504,12 @@ class LocalRepositoryImpl extends LocalRepository { final acct = _sub(b, 'sleep.accounting.value'); final win = _sub(b, 'sleep.window.value'); final tst = (acct?['tst_sec'] as num?); - if (tst == null) return const {'has_sleep': false}; + // Provenance of this day's sleep window: auto / auto_fallback / manual / + // confirmed / none — drives the Sleep screen's confirm prompt + edit affordance. + final sleepSource = (b['sleep_source'] as String?) ?? 'auto'; + if (tst == null) { + return {'has_sleep': false, 'sleep_source': sleepSource}; + } final spt = (win?['spt_sec'] as num?); final waso = (acct?['waso_sec'] as num?); final effPct = (acct?['efficiency_pct'] as num?); @@ -521,6 +526,7 @@ class LocalRepositoryImpl extends LocalRepository { return { // Shape matches sleep_detail_screen's contract exactly. 'has_sleep': true, + 'sleep_source': sleepSource, 'duration_min': (tst / 60).round(), 'in_bed_min': spt == null ? null : (spt / 60).round(), 'awake_min': waso == null ? null : (waso / 60).round(), @@ -623,7 +629,6 @@ class LocalRepositoryImpl extends LocalRepository { final b = await _bundleForDate(date); if (b == null) return const {}; final cov = _sub(b, 'coverage'); - final valid = (cov?['hr_valid'] as num?)?.toInt() ?? 0; final total = (cov?['hr_samples'] as num?)?.toInt() ?? 0; // Wear block (on/off segments, first/last on, longest off) computed in the // engine; fall back to the coverage counts when absent. @@ -631,10 +636,11 @@ class LocalRepositoryImpl extends LocalRepository { ? (b['wear'] as Map).cast() : null; return { - 'worn_min': (w?['worn_min'] as num?)?.toInt() ?? (valid / 60).round(), + // Wear = RECORD presence, not valid HR (HR drops out during daytime + // motion). Fall back to the total record count, never hr_valid. + 'worn_min': (w?['worn_min'] as num?)?.toInt() ?? (total / 60).round(), 'coverage_pct': - (w?['coverage_pct'] as num?)?.toInt() ?? - (total == 0 ? 0 : (100 * valid / total).round()), + (w?['coverage_pct'] as num?)?.toInt() ?? (total > 0 ? 100 : 0), 'segments': w?['segments'] ?? const [], 'first_on': w?['first_on'], 'last_on': w?['last_on'], diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 048ccff..4c23d36 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -851,6 +851,71 @@ class AppState extends ChangeNotifier { } } + // ── SLEEP OVERRIDE (manual entry + fallback confirm) ──────────────────────── + + /// Manual sleep entry (Approach 1): the user gives the in-bed window for [date] + /// (local YYYY-MM-DD). Stored as the source of truth, then a force re-derive + /// restages that day FROM the window — even if it was finalized/locked. + Future setSleepOverride( + String date, + DateTime onset, + DateTime offset, { + String source = 'manual', + }) async { + final onsetSec = onset.millisecondsSinceEpoch ~/ 1000; + final offsetSec = offset.millisecondsSinceEpoch ~/ 1000; + if (offsetSec <= onsetSec) return; + await LocalDb.putSleepOverride( + dayId: date, + onsetTs: onsetSec, + offsetTs: offsetSec, + source: source, + ); + await _reanalyzeForOverride(); + } + + /// Confirm the HR-led fallback's proposal for [date] (Approach 2): accept the + /// window it already computed, promoting 'auto_fallback' → 'confirmed' so the + /// prompt stops showing. Reads the current window from the derived day. + Future confirmSleep(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(); + if (onset == null || offset == null || offset <= onset) return; + await LocalDb.putSleepOverride( + dayId: date, + onsetTs: onset, + offsetTs: offset, + source: 'confirmed', + ); + await _reanalyzeForOverride(); + } + + /// Remove a manual/confirmed override for [date] — revert to auto/fallback. + Future clearSleepOverride(String date) async { + await LocalDb.deleteSleepOverride(date); + await _reanalyzeForOverride(); + } + + /// Force-derive after a sleep-override change so the affected day restages from + /// the user's window (the engine force-includes override days even if locked). + Future _reanalyzeForOverride() async { + if (reanalyzing) return; + reanalyzing = true; + notifyListeners(); + try { + await _derive.run(_profile, force: true); + await LocalDb.refreshComputeFreshness(); + dbCounts = await LocalDb.counts(); + } catch (e) { + _log('[derive] sleep-override re-derive failed: $e'); + } finally { + reanalyzing = false; + notifyListeners(); + } + } + /// Debounced "new data stored" callback from the engine (continuous listening has /// no discrete sync end). The engine already coalesced the burst; we run a single /// LIGHT derive over the affected day(s) and refresh DB counts for the UI. diff --git a/lib/ui/sleep/sleep_detail_screen.dart b/lib/ui/sleep/sleep_detail_screen.dart index 5f717da..d40fd48 100644 --- a/lib/ui/sleep/sleep_detail_screen.dart +++ b/lib/ui/sleep/sleep_detail_screen.dart @@ -112,6 +112,10 @@ class _SleepDetailScreenState extends State { num? get _regularity => _num(_data['regularity']); // 0..100 bool get _stagesBeta => _bool(_data['stages_beta']) ?? false; + // Where this night's window came from: auto / auto_fallback / manual / confirmed + // / none. Drives the confirm prompt + the manual-edit affordance. + String get _sleepSource => (_data['sleep_source'] as String?) ?? 'auto'; + // 4-class wrist stager: Awake / Light / Deep / REM. Light+Deep is the legacy // combined "Core" (nrem_min). Deep is a LOW-CONFIDENCE overlay; the whole stage // block is badged as an estimate. All values come from the day-sleep payload. @@ -284,12 +288,24 @@ class _SleepDetailScreenState extends State { _stateCard(Ic.moon, 'No sleep recorded for this night', 'Wear your strap overnight and sync. Your sleep breakdown will ' 'appear here once a night has been recorded.'), + const SizedBox(height: Sp.x4), + // Approach 1: let the user enter the window so we can still stage it. + _manualEntryCard(), ]; } if (_phase == _Phase.error) { return [_stateCard(Ic.cloud, "Couldn't load this night", _error ?? 'Please try again.')]; } return [ + // Provenance: when this night was rescued by the HR-led fallback, ask the + // user to confirm/correct it; for any night, allow an edit. + if (_sleepSource == 'auto_fallback') ...[ + _fallbackConfirmBanner(), + const SizedBox(height: Sp.x4), + ] else if (_sleepSource == 'manual' || _sleepSource == 'confirmed') ...[ + _manualBadge(), + const SizedBox(height: Sp.x4), + ], // ── TRUSTWORTHY BLOCK FIRST ────────────────────────────────────────── // Lead with the figures we stand behind: duration, efficiency, and the // onset/wake timing. These come straight from the van Hees window + @@ -367,9 +383,183 @@ class _SleepDetailScreenState extends State { MetricRow(icon: Ic.calendar, accent: AppColors.inkSoft, label: 'Consistency', info: infoFor('regularity'), value: 'Need a few more nights'), ]), + // Any night can be corrected by hand. + const SizedBox(height: Sp.x6), + _editTimesFooter(), ]; } + // ── manual sleep entry + fallback confirm (Approaches 1 & 2) ──────────────── + + /// Small pill action used by the sleep-source widgets. + Widget _pill(String label, Color bg, Color fg, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: Sp.x5, vertical: Sp.x3), + alignment: Alignment.center, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(R.pill), + ), + child: Text(label, style: AppText.label.copyWith(color: fg)), + ), + ); + } + + /// No-sleep night → offer to enter the window manually so we can still stage it. + Widget _manualEntryCard() { + return ProCard( + child: Padding( + padding: const EdgeInsets.all(Sp.x5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Slept but nothing showed up?', style: AppText.title), + const SizedBox(height: Sp.x2), + Text( + 'A restless night or a loose band can hide sleep from auto-detection. ' + 'Enter when you slept and we’ll work out the rest from your data.', + style: AppText.captionMuted, + ), + const SizedBox(height: Sp.x4), + _pill('Add sleep times', AppColors.coral, Colors.white, + _editSleepTimes), + ], + ), + ), + ); + } + + /// Fallback night → "estimated from heart rate, is this right?" + Widget _fallbackConfirmBanner() { + return ProCard( + child: Padding( + padding: const EdgeInsets.all(Sp.x5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Estimated from your heart rate', style: AppText.title), + const SizedBox(height: Sp.x2), + Text( + 'We couldn’t detect this night from movement, so we estimated it from ' + 'your heart-rate dip. Does the timing look right?', + style: AppText.captionMuted, + ), + const SizedBox(height: Sp.x4), + Row(children: [ + _pill('Looks right', AppColors.coral, Colors.white, + _confirmFallback), + const SizedBox(width: Sp.x3), + _pill('Edit', AppColors.surfaceAlt, AppColors.inkMuted, + _editSleepTimes), + ]), + ], + ), + ), + ); + } + + /// Manual / confirmed night → small badge + edit / revert. + Widget _manualBadge() { + final confirmed = _sleepSource == 'confirmed'; + return ProCard( + child: Padding( + padding: const EdgeInsets.all(Sp.x4), + child: Row(children: [ + AppIcon(Ic.check, size: 16, color: AppColors.good), + const SizedBox(width: Sp.x3), + Expanded( + child: Text( + confirmed ? 'You confirmed these times' : 'You set these times', + style: AppText.caption, + ), + ), + _pill('Edit', AppColors.surfaceAlt, AppColors.inkMuted, + _editSleepTimes), + const SizedBox(width: Sp.x2), + _pill('Use auto', AppColors.surfaceAlt, AppColors.inkMuted, + _clearOverride), + ]), + ), + ); + } + + /// Subtle "fix it" affordance shown under an auto-detected night. + Widget _editTimesFooter() { + if (_sleepSource != 'auto') return const SizedBox.shrink(); + return Center( + child: TextButton( + onPressed: _editSleepTimes, + child: Text('Sleep times look off? Fix them', + style: AppText.caption.copyWith(color: AppColors.inkMuted)), + ), + ); + } + + /// Two time pickers (onset, wake) → store the window + restage the day. + Future _editSleepTimes() async { + final existingOnset = _num(_data['onset_ts']); + final existingWake = _num(_data['wake_ts']); + TimeOfDay todFrom(num? sec, TimeOfDay fallback) => sec == null + ? fallback + : TimeOfDay.fromDateTime( + DateTime.fromMillisecondsSinceEpoch(sec.toInt() * 1000).toLocal()); + + final onset = await showTimePicker( + context: context, + initialTime: todFrom(existingOnset, const TimeOfDay(hour: 23, minute: 0)), + helpText: 'When did you fall asleep?', + ); + if (onset == null || !mounted) return; + final wake = await showTimePicker( + context: context, + initialTime: todFrom(existingWake, const TimeOfDay(hour: 7, minute: 0)), + helpText: 'When did you wake up?', + ); + if (wake == null || !mounted) return; + + final parts = widget.date.split('-').map(int.tryParse).toList(); + if (parts.length != 3 || parts.any((e) => e == null)) return; + final wakeDay = DateTime(parts[0]!, parts[1]!, parts[2]!); + // An evening onset (≥ noon) belongs to the PREVIOUS calendar day. + final onsetDay = onset.hour >= 12 + ? wakeDay.subtract(const Duration(days: 1)) + : wakeDay; + var onsetDt = DateTime( + onsetDay.year, onsetDay.month, onsetDay.day, onset.hour, onset.minute); + var wakeDt = DateTime( + wakeDay.year, wakeDay.month, wakeDay.day, wake.hour, wake.minute); + if (!wakeDt.isAfter(onsetDt)) { + wakeDt = wakeDt.add(const Duration(days: 1)); + } + + final app = context.read(); + await _runOverride(() => app.setSleepOverride(widget.date, onsetDt, wakeDt)); + } + + Future _confirmFallback() async { + final app = context.read(); + await _runOverride(() => app.confirmSleep(widget.date)); + } + + Future _clearOverride() async { + final app = context.read(); + await _runOverride(() => app.clearSleepOverride(widget.date)); + } + + /// Run a sleep-override change with a busy overlay, then reload this night. + Future _runOverride(Future Function() action) async { + setState(() => _phase = _Phase.loading); + try { + await action(); + } catch (_) { + // fall through to reload; _load surfaces any real error + } + if (!mounted) return; + await _load(); + } + @override Widget build(BuildContext context) { // Embedded in the Sleep screen: just the sections (its ListView scrolls). diff --git a/pubspec.lock b/pubspec.lock index 7ee789c..e443702 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -668,10 +668,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1143,26 +1143,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.16" timezone: dependency: "direct main" description: diff --git a/test/derive_prepare_test.dart b/test/derive_prepare_test.dart index 31d1612..82e6f73 100644 --- a/test/derive_prepare_test.dart +++ b/test/derive_prepare_test.dart @@ -66,4 +66,52 @@ void main() { final none = prepareDerivationPayload(sub, targetDay: '2026-06-28'); expect(none.days, isEmpty); }); + + test('a sleep override forces + stages the user window (Approach 1)', () { + // 5 h of still wrist + low HR on the target day. + 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); // low sleeping HR + } + 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), + ); + + 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: 'manual', + ), + ); + expect(out.days, hasLength(1)); + final day = out.days.first; + expect(day.sleepSource, 'manual'); + // The forced window was staged → sleep is present (not absent). + expect(day.sleepJson['tst_sec'], isNotNull); + // In-bed window ≈ the user's 4 h (allow boundary rounding). + final inBed = (day.sleepJson['in_bed_sec'] as num).toInt(); + expect(inBed, greaterThan(3 * 3600)); + expect(inBed, lessThanOrEqualTo(4 * 3600 + 60)); + }); }