From 4a22a386b2434fbf252e762b61a523e4b5c5530a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 08:27:58 +0530 Subject: [PATCH 01/41] temperature flag: score the raw nightly skin temp, not skin_temp_z the illness temperature flag standardises its own input, but it was handed skin_temp_z. that z is scored against a baseline that already holds the earlier fever nights, so it falls night over night, and standardising it a second time let a real multi-night rise drop under the two-night persistence rule and read as normal. the cross-day record now carries the raw nightly mean and the device family whose unit it is in, and the flag reads that series. nights from a different family than the newest one are left out rather than mixed (gen4 adc counts vs gen5 centi-degrees). the anomaly detector and glass-box keep using the z. --- lib/compute/crossday_pipeline.dart | 35 +++++++- lib/compute/derivation_engine.dart | 7 ++ test/crossday_pipeline_test.dart | 79 +++++++++++++++++++ .../fixtures/whoop_freeze/gen4_minus0400.json | 6 ++ test/fixtures/whoop_freeze/gen4_plus0530.json | 6 ++ test/fixtures/whoop_freeze/gen4_utc.json | 6 ++ .../fixtures/whoop_freeze/gen5_minus0400.json | 6 ++ test/fixtures/whoop_freeze/gen5_plus0530.json | 6 ++ test/fixtures/whoop_freeze/gen5_utc.json | 6 ++ 9 files changed, 154 insertions(+), 3 deletions(-) diff --git a/lib/compute/crossday_pipeline.dart b/lib/compute/crossday_pipeline.dart index 5a8481eb9..7daef1fd1 100644 --- a/lib/compute/crossday_pipeline.dart +++ b/lib/compute/crossday_pipeline.dart @@ -31,7 +31,8 @@ import 'onehz_pipeline.dart' show needInputNote; /// /// Each [daysOldestFirst] element is one day shaped like: /// {'date': 'YYYY-MM-DD', 'rhr': num?, 'rmssd': num?, 'readiness': num?, -/// 'resp_rate': num?, 'skin_temp_z': num?, 'trimp': num?, +/// 'resp_rate': num?, 'skin_temp_z': num?, 'skin_temp_adc': num?, +/// 'skin_temp_family': String?, 'trimp': num?, /// 'onset_sec': int?, 'wake_sec': int?, 'tst_min': int?, /// 'hypnogram': [{start,end,stage}]?} /// @@ -67,6 +68,33 @@ Map buildCrossDayBundle( final tempList = [ for (final d in days) _numOrNull(d['skin_temp_z']), ]; + // The temperature illness flag standardises its input once, itself, so it + // takes the RAW nightly mean — never `skin_temp_z`. That z is already scored + // against a mean/SD that holds the earlier fever nights, so it decays night + // over night, and standardising it again let a real multi-night rise fall + // under the flag's two-night persistence threshold. + // + // Raw values are in their device family's unit (gen4 ADC counts, gen5 + // centi-°C, a wearable's own scale), which the z-score had been hiding. Only + // nights in the newest scored night's family are kept; the rest are null + // rather than mixed into one baseline. + final rawTemp = [ + for (final d in days) _numOrNull(d['skin_temp_adc']), + ]; + final tempFamily = [ + for (final d in days) d['skin_temp_family'] as String?, + ]; + String? latestTempFamily; + for (var i = n - 1; i >= 0; i--) { + if (rawTemp[i] != null) { + latestTempFamily = tempFamily[i]; + break; + } + } + final tempRawList = [ + for (var i = 0; i < n; i++) + tempFamily[i] == latestTempFamily ? rawTemp[i] : null, + ]; // A day flagged `unsettled` (today, until it was derived from a complete // overnight: the data edge at least an hour past wake) is a truncated @@ -129,7 +157,7 @@ Map buildCrossDayBundle( for (var i = 0; i < n; i++) days[i]['skin_temp_illness_ok'] == false ? null - : settled(i, tempList[i]), + : settled(i, tempRawList[i]), ], luteal: luteal); // ── circadian: mid-sleep, free/work split, jetlag, chronotype, sleep debt ── @@ -504,7 +532,8 @@ Map buildCrossDayBundle( .length >= 2), ); - final latestTemp = at(tempIllness, newestScored((i) => tempList[i] != null)); + final latestTemp = + at(tempIllness, newestScored((i) => tempRawList[i] != null)); // per-day flags (for notifications / trends): asleep/illness/anomaly/temp, // plus the nightly RESTING HR the "your resting HR trend shifted" CUSUM diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index fba1cc9e6..3e8d8b39c 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -6830,6 +6830,13 @@ class DerivationEngine { 'readiness': col('readiness') ?? sc('readiness'), 'resp_rate': sc('resp_rate'), 'skin_temp_z': sc('skin_temp_z'), + // The RAW nightly mean and the family whose unit it is in, for the + // temperature illness flag: it standardises its input itself, and a + // z-score handed to it is standardised twice against a baseline that + // already holds the earlier fever nights, so a multi-night rise fades + // below its threshold before the persistence rule can fire. + 'skin_temp_adc': sc('skin_temp_adc'), + 'skin_temp_family': payload['device_family'] as String?, // Only the refusal is written: an eligible night reads as before. if (!skinTempIllnessEligible( payload['device_family'] as String?, sc('skin_temp_ambient_r'))) diff --git a/test/crossday_pipeline_test.dart b/test/crossday_pipeline_test.dart index 53383d294..afcf2f6c4 100644 --- a/test/crossday_pipeline_test.dart +++ b/test/crossday_pipeline_test.dart @@ -7,6 +7,8 @@ // injected RHR spike trips the illness flag, and that absent inputs degrade to // honest absent envelopes (never a thrown exception, never a fabricated number). +import 'dart:math' as math; + import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/compute/crossday_pipeline.dart'; @@ -48,6 +50,7 @@ List> _synthDays( 'readiness': readiness, 'resp_rate': resp, 'skin_temp_z': temp, + 'skin_temp_adc': 3000.0 + wobble * 8.0, if (withTrimp) 'trimp': trimp, if (withSleep) 'onset_sec': onsetSec, if (withSleep) 'wake_sec': wakeSec, @@ -738,4 +741,80 @@ void _wiredFamilies() { expect(sc['value'], '—'); }); }); + + // The temperature flag standardises its own input. It was handed + // `skin_temp_z`, which is already scored against a baseline that holds the + // earlier fever nights, so the z it saw decayed night over night and a real + // multi-night rise never stayed above threshold for two nights. + group('the temperature flag reads the raw nightly mean', () { + // Deterministic noise around 3000 ADC, SD ~8. + const noise = [-9.0, 4.0, 11.0, -3.0, -12.0, 7.0, 1.0, -6.0, 13.0, -5.0, + 2.0, -10.0, 8.0, -1.0, 5.0, -7.0, 10.0, -4.0, 0.0, 6.0]; + + // The edge z exactly as the per-day pipeline computes it: trailing 28 raw + // nightly means strictly before the night, sample SD, gated at SD >= 1. + double? edgeZ(List raw, int i) { + final hist = raw.sublist(i - 28 < 0 ? 0 : i - 28, i); + if (hist.length < 3) return null; + final mean = hist.reduce((a, b) => a + b) / hist.length; + var ss = 0.0; + for (final v in hist) { + ss += (v - mean) * (v - mean); + } + final sd = ss == 0 ? 0.0 : math.sqrt(ss / (hist.length - 1)); + return sd >= 1 ? (raw[i] - mean) / sd : null; + } + + List> feverDays({ + required double feverSd, + String? Function(int i)? family, + double Function(int i, double v)? shift, + }) { + const baseline = 40; + final raw = [ + for (var i = 0; i < baseline; i++) 3000 + noise[i % noise.length], + for (var i = 0; i < 3; i++) 3000 + feverSd * 8, + ]; + var dt = DateTime(2024, 1, 1); + final out = >[]; + for (var i = 0; i < raw.length; i++) { + final v = shift == null ? raw[i] : shift(i, raw[i]); + out.add({ + 'date': '${dt.year}-${dt.month.toString().padLeft(2, '0')}-' + '${dt.day.toString().padLeft(2, '0')}', + 'skin_temp_z': edgeZ(raw, i), + 'skin_temp_adc': v, + if (family != null) 'skin_temp_family': family(i), + }); + dt = dt.add(const Duration(days: 1)); + } + return out; + } + + List flags(List> days) => [ + for (final r in buildCrossDayBundle(days, const {})['recent'] as List) + (r as Map)['temp'] as bool, + ]; + + test('a three-night rise is flagged on its second and third nights', () { + final f = flags(feverDays(feverSd: 3)); + expect(f.sublist(f.length - 3), [false, true, true]); + }); + + test('nights in another family\'s unit are not mixed into the baseline', + () { + // A gen4 history in ADC counts, then a swap to gen5 reading centi-°C + // around 3400 for the last five nights. No fever anywhere: mixed into + // one series the swap itself reads as a sustained rise. + final days = feverDays( + feverSd: 0, + family: (i) => i < 38 ? 'gen4' : 'gen5', + shift: (i, v) => i < 38 ? v : 3400 + noise[i % noise.length] / 4, + ); + final f = flags(days); + expect(f.contains(true), isFalse); + final b = buildCrossDayBundle(days, const {}); + expect((b['temp_illness'] as Map)['date'], days.last['date']); + }); + }); } diff --git a/test/fixtures/whoop_freeze/gen4_minus0400.json b/test/fixtures/whoop_freeze/gen4_minus0400.json index 82c4b8122..0f6486fb3 100644 --- a/test/fixtures/whoop_freeze/gen4_minus0400.json +++ b/test/fixtures/whoop_freeze/gen4_minus0400.json @@ -76992,6 +76992,8 @@ "readiness": 27.033995461954163, "resp_rate": 13.51304972588547, "skin_temp_z": 3.808966711097082, + "skin_temp_adc": 30405.99974110511, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 227.5841832798056, "strain": null, @@ -77203,6 +77205,8 @@ "readiness": 27.03409387728019, "resp_rate": 13.51304972588547, "skin_temp_z": 2.581920140166515, + "skin_temp_adc": 30405.999593165176, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 221.15132594116318, "strain": null, @@ -77414,6 +77418,8 @@ "readiness": 66.11544177079463, "resp_rate": 13.51304972588547, "skin_temp_z": 2.083475796676784, + "skin_temp_adc": 30406.000406834824, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 218.15549360346512, "strain": null, diff --git a/test/fixtures/whoop_freeze/gen4_plus0530.json b/test/fixtures/whoop_freeze/gen4_plus0530.json index 071bd2e28..aee7b941b 100644 --- a/test/fixtures/whoop_freeze/gen4_plus0530.json +++ b/test/fixtures/whoop_freeze/gen4_plus0530.json @@ -76992,6 +76992,8 @@ "readiness": 27.03382323567632, "resp_rate": 13.51304972588547, "skin_temp_z": 3.8090587513754603, + "skin_temp_adc": 30406.0, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 227.5841832798056, "strain": null, @@ -77203,6 +77205,8 @@ "readiness": 27.033601802905416, "resp_rate": 13.51304972588547, "skin_temp_z": 2.5820748428079483, + "skin_temp_adc": 30406.000332864856, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 221.15132594116318, "strain": null, @@ -77414,6 +77418,8 @@ "readiness": 66.1155535435143, "resp_rate": 13.51304972588547, "skin_temp_z": 2.083348400888466, + "skin_temp_adc": 30406.00018492492, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 218.15549360346512, "strain": null, diff --git a/test/fixtures/whoop_freeze/gen4_utc.json b/test/fixtures/whoop_freeze/gen4_utc.json index 985e1bd8f..1f3fb028e 100644 --- a/test/fixtures/whoop_freeze/gen4_utc.json +++ b/test/fixtures/whoop_freeze/gen4_utc.json @@ -76992,6 +76992,8 @@ "readiness": 27.03355259577805, "resp_rate": 13.51304972588547, "skin_temp_z": 3.8092033860986256, + "skin_temp_adc": 30406.000406834824, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 227.5841832798056, "strain": null, @@ -77203,6 +77205,8 @@ "readiness": 27.033651010089166, "resp_rate": 13.51304972588547, "skin_temp_z": 2.581998719140345, + "skin_temp_adc": 30406.00025889489, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 221.15132594116318, "strain": null, @@ -77414,6 +77418,8 @@ "readiness": 66.11559080104756, "resp_rate": 13.51304972588547, "skin_temp_z": 2.083305937349391, + "skin_temp_adc": 30406.000110954952, + "skin_temp_family": "gen4", "skin_temp_illness_ok": false, "trimp": 218.15549360346512, "strain": null, diff --git a/test/fixtures/whoop_freeze/gen5_minus0400.json b/test/fixtures/whoop_freeze/gen5_minus0400.json index 6c4a2008d..9a1ad6617 100644 --- a/test/fixtures/whoop_freeze/gen5_minus0400.json +++ b/test/fixtures/whoop_freeze/gen5_minus0400.json @@ -77505,6 +77505,8 @@ "readiness": 72.95885235383324, "resp_rate": 13.51304972588547, "skin_temp_z": 3.808966711097082, + "skin_temp_adc": 30405.99974110511, + "skin_temp_family": "gen5", "trimp": 267.9104825050325, "strain": null, "steps": 18240.0, @@ -77715,6 +77717,8 @@ "readiness": 72.95885235383324, "resp_rate": 13.51304972588547, "skin_temp_z": 2.581920140166515, + "skin_temp_adc": 30405.999593165176, + "skin_temp_family": "gen5", "trimp": 260.0235516950477, "strain": null, "steps": 17640.0, @@ -77925,6 +77929,8 @@ "readiness": 61.79377627786914, "resp_rate": 13.51304972588547, "skin_temp_z": 2.083475796676784, + "skin_temp_adc": 30406.000406834824, + "skin_temp_family": "gen5", "trimp": 256.0693413232808, "strain": null, "steps": 17640.0, diff --git a/test/fixtures/whoop_freeze/gen5_plus0530.json b/test/fixtures/whoop_freeze/gen5_plus0530.json index 3109bf0c1..d9fd17db9 100644 --- a/test/fixtures/whoop_freeze/gen5_plus0530.json +++ b/test/fixtures/whoop_freeze/gen5_plus0530.json @@ -77505,6 +77505,8 @@ "readiness": 72.95885235383324, "resp_rate": 13.51304972588547, "skin_temp_z": 3.8090587513754603, + "skin_temp_adc": 30406.0, + "skin_temp_family": "gen5", "trimp": 267.9104825050325, "strain": null, "steps": 18240.0, @@ -77715,6 +77717,8 @@ "readiness": 72.95885235383324, "resp_rate": 13.51304972588547, "skin_temp_z": 2.5820748428079483, + "skin_temp_adc": 30406.000332864856, + "skin_temp_family": "gen5", "trimp": 260.0235516950477, "strain": null, "steps": 17640.0, @@ -77925,6 +77929,8 @@ "readiness": 61.79377627786914, "resp_rate": 13.51304972588547, "skin_temp_z": 2.083348400888466, + "skin_temp_adc": 30406.00018492492, + "skin_temp_family": "gen5", "trimp": 256.0693413232808, "strain": null, "steps": 17640.0, diff --git a/test/fixtures/whoop_freeze/gen5_utc.json b/test/fixtures/whoop_freeze/gen5_utc.json index 781e9ee03..1eea70804 100644 --- a/test/fixtures/whoop_freeze/gen5_utc.json +++ b/test/fixtures/whoop_freeze/gen5_utc.json @@ -77505,6 +77505,8 @@ "readiness": 72.95885235383324, "resp_rate": 13.51304972588547, "skin_temp_z": 3.8092033860986256, + "skin_temp_adc": 30406.000406834824, + "skin_temp_family": "gen5", "trimp": 267.9104825050325, "strain": null, "steps": 18240.0, @@ -77715,6 +77717,8 @@ "readiness": 72.95885235383324, "resp_rate": 13.51304972588547, "skin_temp_z": 2.581998719140345, + "skin_temp_adc": 30406.00025889489, + "skin_temp_family": "gen5", "trimp": 260.0235516950477, "strain": null, "steps": 17640.0, @@ -77925,6 +77929,8 @@ "readiness": 61.79377627786914, "resp_rate": 13.51304972588547, "skin_temp_z": 2.083305937349391, + "skin_temp_adc": 30406.000110954952, + "skin_temp_family": "gen5", "trimp": 256.0693413232808, "strain": null, "steps": 17640.0, From 8fea9f4225e182be4ec1531772a2db18934bb25b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 08:37:45 +0530 Subject: [PATCH 02/41] date the lnrmssd history so the plews rolling 7 spans calendar nights readinessLnRmssd was only ever handed values, so its 7-night baseline was the last 7 stored rows. after a wear gap those are nights from weeks ago, and the first night back got a suppressed/normal/elevated band scored against them. the baseline cache now carries the dates alongside the ln_rmssd history and the pipeline passes them through, so the window is 7 calendar nights and abstains until it refills. --- lib/compute/derivation_engine.dart | 13 ++++ lib/compute/onehz_pipeline.dart | 17 ++++- test/readiness_lnrmssd_wear_gap_test.dart | 86 +++++++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 test/readiness_lnrmssd_wear_gap_test.dart diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 3e8d8b39c..9174bf922 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -2717,6 +2717,17 @@ class _BaselineHistoryCache { return [for (var i = from; i < end; i++) series[i].value]; } + /// The `yyyy-MM-dd` dates of exactly the entries [valuesBefore] returns for + /// the same arguments, index for index. A rows window can't tell a night + /// from yesterday from one three weeks back across a wear gap; the dates + /// let a calendar-windowed baseline drop the stale ones. + List datesBefore(String key, String beforeDate, {String? family}) { + final series = _seriesFor(key, family); + final end = _beforeIndex(series, beforeDate); + final from = end <= _baselineWindowDays ? 0 : end - _baselineWindowDays; + return [for (var i = from; i < end; i++) series[i].date]; + } + static List _trailing(List<_DatedValue> samples) { final from = samples.length <= _baselineWindowDays ? 0 @@ -6403,6 +6414,8 @@ class DerivationEngine { List before(String key) => history.valuesBefore(key, date, family: _baselineFamily(key, input.deviceFamily)); m['ln_rmssd_history'] = before('ln_rmssd'); + m['ln_rmssd_dates'] = history.datesBefore('ln_rmssd', date, + family: _baselineFamily('ln_rmssd', input.deviceFamily)); m['rhr_history'] = before('rhr'); m['resp_history'] = before('resp_rate'); // Robust nocturnal RMSSD history (the `rmssd` series) — feeds the EWMA hrv diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index d2f2880e2..89567c575 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -347,6 +347,11 @@ class DayBundleInput { // ── profile + trailing baseline history ─────────────────────────────────── final Map profile; final List lnRmssdHistory; + + /// `yyyy-MM-dd` dates parallel to [lnRmssdHistory]. Without them the Plews + /// rolling-7 baseline is the last 7 ROWS, which after a wear gap are nights + /// from weeks ago scored as if they were last week. + final List lnRmssdDates; final List rhrHistory; final List respHistory; @@ -428,6 +433,7 @@ class DayBundleInput { this.tonightSleepOnsetSec = 0, required this.profile, this.lnRmssdHistory = const [], + this.lnRmssdDates = const [], this.rhrHistory = const [], this.respHistory = const [], this.rmssdHistory = const [], @@ -461,6 +467,7 @@ class DayBundleInput { 'tonight_sleep_onset_sec': tonightSleepOnsetSec, 'profile': profile, 'ln_rmssd_history': lnRmssdHistory, + 'ln_rmssd_dates': lnRmssdDates, 'rhr_history': rhrHistory, 'resp_history': respHistory, 'rmssd_history': rmssdHistory, @@ -513,6 +520,7 @@ class DayBundleInput { (m['tonight_sleep_onset_sec'] as num?)?.toInt() ?? 0, profile: ((m['profile'] as Map?) ?? const {}).cast(), lnRmssdHistory: dbls('ln_rmssd_history'), + lnRmssdDates: strs('ln_rmssd_dates'), rhrHistory: dbls('rhr_history'), respHistory: dbls('resp_history'), rmssdHistory: dbls('rmssd_history'), @@ -961,8 +969,15 @@ Map deriveDayBundle(Map inputJson) { // (`_attachHistory` → `_BaselineHistoryCache.valuesBefore`), which is what // makes this single append the correct, non-duplicating one. final lnHist = [...d.lnRmssdHistory, ?lnToday]; + // Dated so the rolling 7 is 7 CALENDAR nights: after a wear gap the last 7 + // rows are weeks old, and scoring tonight against them gave a confident + // band against a stale centre instead of abstaining until it refills. + // Only when the dates line up with the values; otherwise the rows window. + final lnDates = d.lnRmssdDates.length == d.lnRmssdHistory.length + ? [...d.lnRmssdDates, if (lnToday != null) d.date] + : null; final lnReadiness = lnHist.length >= 4 - ? readinessLnRmssd(lnHist) + ? readinessLnRmssd(lnHist, dates: lnDates) : Metric.absent( tier: Tier.high, inputs_used: const ['ln_rmssd_history'], diff --git a/test/readiness_lnrmssd_wear_gap_test.dart b/test/readiness_lnrmssd_wear_gap_test.dart new file mode 100644 index 000000000..a51ed48af --- /dev/null +++ b/test/readiness_lnrmssd_wear_gap_test.dart @@ -0,0 +1,86 @@ +// The Plews rolling-7 lnRMSSD baseline was handed only values, so it took the +// last 7 stored ROWS. After a wear gap those are nights from weeks ago, and +// the first night back got a confident suppressed/normal/elevated band scored +// against them. With the dates passed through, the window is 7 calendar +// nights and abstains until it refills. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; + +const int _t0 = 1786700000; +const int _nightSec = 2 * 3600; + +String _label(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}' + '-${d.day.toString().padLeft(2, '0')}'; + +Map _lnReadiness({ + required List historyDates, + bool withDates = true, +}) { + final rr = [], ts = []; + var t = _t0 * 1000.0; + for (var i = 0; i < _nightSec; i++) { + final v = 1000.0 + 16.0 * math.sin(2 * math.pi * i / 12); + t += v; + rr.add(v); + ts.add(t); + } + final sleepTs = [for (var i = 0; i < _nightSec; i++) _t0 + i]; + final hr = List.filled(_nightSec, 60); + final b = deriveDayBundle( + DayBundleInput( + date: '2026-08-14', + dayTsSec: sleepTs, + dayHr: hr, + sleepTsSec: sleepTs, + sleepHr: hr, + sleepRrMs: rr, + sleepRrTsMs: ts, + sleepSkinTemp: List.filled(_nightSec, 0), + sleepJson: const {'tst_sec': _nightSec}, + hypnoStages: const [], + sleepOnsetSec: _t0, + sleepOffsetSec: (ts.last / 1000).ceil() + 1, + profile: const {'age': 30, 'sex': 'm', 'weight_kg': 70, 'height_cm': 175}, + deviceFamily: 'gen4', + lnRmssdHistory: [ + for (var i = 0; i < historyDates.length; i++) 3.4 + 0.05 * (i % 3) + ], + lnRmssdDates: withDates ? historyDates : const [], + ).toJson(), + ); + return ((b['clinical'] as Map)['readiness_lnrmssd'] as Map) + .cast(); +} + +List _nightsEnding(DateTime last, int n) => [ + for (var i = n - 1; i >= 0; i--) + _label(DateTime(last.year, last.month, last.day - i)) + ]; + +void main() { + test('nights from before a three-week gap are not a rolling-7 baseline', () { + // 21 nights ending Jul 24, nothing since; tonight is Aug 14. + final env = + _lnReadiness(historyDates: _nightsEnding(DateTime(2026, 7, 24), 21)); + expect(env['value'], isNot(isA())); + expect(env['note'] as String, startsWith('need_baseline:')); + }); + + test('without dates it still falls back to the rows window', () { + final env = _lnReadiness( + historyDates: _nightsEnding(DateTime(2026, 7, 24), 21), + withDates: false); + expect(env['value'], isA()); + }); + + test('an unbroken week before tonight still computes', () { + final env = + _lnReadiness(historyDates: _nightsEnding(DateTime(2026, 8, 13), 21)); + expect(env['value'], isA()); + expect((env['value'] as Map)['band'], isNotNull); + }); +} From ca829e179bbe32680419b41f4c3dbfacb2e78d2e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 09:30:09 +0530 Subject: [PATCH 03/41] keep unsettled nights out of the skin temp baseline a night the strap spent partly warming up reads hundreds of counts low. readiness already refused it as that night's temp input, but its mean was still banked as skin_temp_adc, so it sat in the next 28 nights' baseline, pulled the median down and roughly doubled the sd. normal nights then read warm and a real elevation's skin_temp_z was damped below 2. an unsettled night (settled fraction under 0.8) now banks no skin_temp_adc and gets no z or baseline-block value of its own. nights with no measurable settled fraction are unchanged. --- lib/compute/onehz_pipeline.dart | 21 ++++++-- test/unsettled_skin_temp_baseline_test.dart | 57 +++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 test/unsettled_skin_temp_baseline_test.dart diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 89567c575..dbf8b35db 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -848,11 +848,23 @@ Map deriveDayBundle(Map inputJson) { // every number that leans on it below is served at half confidence and // marked provisional. Always false on a band, so its payload is unchanged. final tempProvisional = settledTemp.value?.provisional ?? false; + // The nightly mean every LATER night is measured against. A night the strap + // spent partly warming up (or half off the wrist) reads hundreds of counts + // low; readiness already refuses it as tonight's input, but banking it as + // `skin_temp_adc` would sit in the next 28 nights' baseline, drag the median + // down and roughly double the SD — normal nights then read warm and a real + // elevation's skin_temp_z is damped below 2. So an unsettled night neither + // enters the series nor gets a z of its own. A null fraction (no settle band + // for the family) is unmeasured, not unsettled, and passes as before. + final double? skinTempBanked = (skinTempSettledFrac != null && + skinTempSettledFrac < kMinSettledFraction) + ? null + : skinTempAdc; // STEP 2 — z-score today's RAW mean against the RAW-ADC baseline history (NOT // the previously-computed z-scores; that unit mismatch was the bug). Gated on // ≥3 prior raw means. double? skinTempZ; - if (skinTempAdc != null && d.skinTempAdcHistory.length >= 3) { + if (skinTempBanked != null && d.skinTempAdcHistory.length >= 3) { final base = _mean(d.skinTempAdcHistory)!; final sd = _stddev(d.skinTempAdcHistory); // Quantum floor matches analytics' tempInput(quantum: 1) guard @@ -860,7 +872,7 @@ Map deriveDayBundle(Map inputJson) { // oscillating between two adjacent ADC counts has a nonzero but // sub-quantum SD, which standardizes ordinary quantization noise into // an inflated z. Below 1 ADC count of dispersion, abstain instead. - if (sd != null && sd >= 1) skinTempZ = (skinTempAdc - base) / sd; + if (sd != null && sd >= 1) skinTempZ = (skinTempBanked - base) / sd; } // WHOOP 5 stores skin temperature in real °C (centi-°C in this array), so @@ -1660,7 +1672,7 @@ Map deriveDayBundle(Map inputJson) { 'resp': baselineBlock(d.respHistory, respToday, Baselines.respCfg), 'skin_temp': baselineBlock( d.skinTempAdcHistory, - skinTempAdc, + skinTempBanked, _skinTempAdcCfg, ), }; @@ -1773,7 +1785,8 @@ Map deriveDayBundle(Map inputJson) { // skin_temp_z. ALWAYS present when there's sleep+temp data (even while z is // still null in the ≤3-day bootstrap), so the series fills and z starts // computing from ~day 4. This is the series _attachHistory must feed back. - 'skin_temp_adc': skinTempAdc, + // Null on an unsettled night (see `skinTempBanked`). + 'skin_temp_adc': skinTempBanked, // WHOOP 5 only: °C above (+) or below (-) the user's usual night. // Key only when present: no new key on every other night. if (skinTempDevC != null) 'skin_temp_dev_c': _round(skinTempDevC, 2), diff --git a/test/unsettled_skin_temp_baseline_test.dart b/test/unsettled_skin_temp_baseline_test.dart new file mode 100644 index 000000000..0e3c4dbe3 --- /dev/null +++ b/test/unsettled_skin_temp_baseline_test.dart @@ -0,0 +1,57 @@ +// A night the strap spent partly warming up reads low. Readiness refuses it as +// tonight's temp input; it must not be banked as `skin_temp_adc` either, or it +// sits in every later night's baseline and drags the median and SD with it. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; + +void main() { + Map scalars(List temps, List history) { + const t0 = 1780000000; + const n = 6 * 3600; + final b = deriveDayBundle( + DayBundleInput( + date: '2026-06-01', + dayTsSec: [for (var i = 0; i < n; i++) t0 + i], + dayHr: List.filled(n, 60), + sleepTsSec: const [], + sleepHr: const [], + sleepRrTsMs: const [], + sleepRrMs: const [], + sleepSkinTemp: temps, + sleepJson: const {}, + hypnoStages: const [], + sleepOnsetSec: 0, + sleepOffsetSec: 0, + profile: const {}, + skinTempAdcHistory: history, + deviceFamily: 'gen4', + ).toJson(), + ); + return (b['scalars'] as Map).cast(); + } + + const history = [995.0, 1000.0, 1005.0, 998.0, 1002.0]; + + test('a night with half of it cold is not banked and gets no z', () { + final temps = [ + ...List.filled(200, 500), + ...List.filled(200, 1000), + ]; + final s = scalars(temps, history); + expect(s['skin_temp_settled_frac'] as num, lessThan(0.8)); + expect(s['skin_temp_adc'], isNull); + expect(s['skin_temp_z'], isNull); + }); + + test('a settled night is still banked and z-scored', () { + final temps = [ + ...List.filled(10, 500), + ...List.filled(390, 1000), + ]; + final s = scalars(temps, history); + expect(s['skin_temp_settled_frac'] as num, greaterThanOrEqualTo(0.8)); + expect(s['skin_temp_adc'], isNotNull); + expect(s['skin_temp_z'], isNotNull); + }); +} From a4f4284d9938cd7bcc81bf7a528c344d69e1d1a9 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 11:27:53 +0530 Subject: [PATCH 04/41] sleep corrections on pruned nights could never apply the sleep screen offered not sleep / change the times / i was asleep / back to automatic on every derived night, but the forced re-derive only covers days that still have decoded rows. once retention pruned a night, the override was stored, nothing re-staged it, and the screen told the user to tap re-analyze everything, which has the same scope and can't help either. the row then sat in sleep_override unapplied. the correction calls now refuse a day with no substrate before writing anything, and the sleep screen swaps the buttons for a note saying the night's recordings are gone. also fixed the comment that claimed the engine force-includes every override day. --- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 4 ++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/state/app_state.dart | 41 ++++++++++++++++- lib/ui2/screens/sleep_detail.dart | 48 +++++++++++++++++++- test/sleep_override_rederive_test.dart | 61 ++++++++++++++++++++++++++ 10 files changed, 156 insertions(+), 4 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index f4ca2b4f8..a04c277ef 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1699,6 +1699,7 @@ "sleepDetailBedTimeHelp": "INS BETT GEGANGEN UM", "sleepDetailWakeTimeHelp": "AUFGESTANDEN UM", "sleepDetailReanalyseFailed": "Die Nacht wurde nicht neu ausgewertet – entweder lief bereits eine andere Auswertung, oder sie ist fehlgeschlagen. Deine Zeiten sind gespeichert; „Alles neu analysieren“ unter „Deine Daten“ wendet sie an.", + "sleepDetailCorrectionUnavailable": "Die detaillierten Aufzeichnungen dieser Nacht sind nicht mehr auf dem Telefon, daher lässt sich ihr Schlaf nicht mehr korrigieren.", "sleepDetailNoHypnogramTitle": "Kein Hypnogramm für diese Nacht", "sleepDetailNoHypnogramBody": "Für die Schlafphasen werden Bewegung und Herzschlag-Abstände benötigt. Eines davon fehlte.", "sleepDetailThroughTheNight": "Im Verlauf der Nacht", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7e68c3652..9630ab6c6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -8466,6 +8466,10 @@ "description": "Help text on the time picker for when the user got up." }, "sleepDetailReanalyseFailed": "The night wasn’t re-analysed; another run was busy or it failed. Your times are saved: open Your data and tap Re-analyze everything.", + "sleepDetailCorrectionUnavailable": "This night’s detailed recordings are no longer on the phone, so its sleep can’t be corrected now.", + "@sleepDetailCorrectionUnavailable": { + "description": "Shown instead of the sleep correction buttons, or after one was refused, when the night's raw recordings have been pruned and it can no longer be re-analysed." + }, "@sleepDetailReanalyseFailed": { "description": "Fallback error message when re-analysis after a window edit could not be confirmed." }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index c1745c6e5..5c07835ea 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1659,6 +1659,7 @@ "sleepDetailBedTimeHelp": "CUÁNDO TE ACOSTASTE", "sleepDetailWakeTimeHelp": "CUÁNDO TE LEVANTASTE", "sleepDetailReanalyseFailed": "La noche no se ha vuelto a analizar: ya había otro reanálisis en curso, o falló. Las horas que estableciste están guardadas; \"Reanalizar todo\" en Tus datos las aplicará.", + "sleepDetailCorrectionUnavailable": "Los registros detallados de esta noche ya no están en el teléfono, así que su sueño ya no se puede corregir.", "sleepDetailNoHypnogramTitle": "No hay hipnograma para esta noche", "sleepDetailNoHypnogramBody": "El cálculo de fases necesita movimiento y ritmo cardíaco. Faltaba uno de los dos.", "sleepDetailThroughTheNight": "Durante la noche", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b05e1838a..6514d291f 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1649,6 +1649,7 @@ "sleepDetailBedTimeHelp": "HEURE DU COUCHER", "sleepDetailWakeTimeHelp": "HEURE DU LEVER", "sleepDetailReanalyseFailed": "La nuit n'a pas été réanalysée : une autre réanalyse était déjà en cours, ou elle a échoué. Les horaires que vous avez définis sont enregistrés ; « Tout réanalyser » dans Vos données les appliquera.", + "sleepDetailCorrectionUnavailable": "Les enregistrements détaillés de cette nuit ne sont plus sur le téléphone, son sommeil ne peut donc plus être corrigé.", "sleepDetailNoHypnogramTitle": "Aucun hypnogramme pour cette nuit", "sleepDetailNoHypnogramBody": "L'analyse des phases nécessite le mouvement et le rythme cardiaque. L'un des deux manquait.", "sleepDetailThroughTheNight": "Au fil de la nuit", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index bd9159b0e..dd45498da 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1649,6 +1649,7 @@ "sleepDetailBedTimeHelp": "आप बिस्तर पर कब गए", "sleepDetailWakeTimeHelp": "आप कब उठे", "sleepDetailReanalyseFailed": "रात का दोबारा विश्लेषण नहीं हो पाया — या तो कोई और पुनः विश्लेषण पहले से चल रहा था, या यह विफल हो गया। आपके सेट किए गए समय सेव हैं; 'आपका डेटा' में 'सब कुछ दोबारा विश्लेषण करें' उन्हें लागू कर देगा।", + "sleepDetailCorrectionUnavailable": "इस रात की विस्तृत रिकॉर्डिंग अब फ़ोन पर नहीं हैं, इसलिए इसकी नींद अब ठीक नहीं की जा सकती।", "sleepDetailNoHypnogramTitle": "इस रात के लिए कोई हिप्नोग्राम नहीं", "sleepDetailNoHypnogramBody": "चरण तय करने के लिए हलचल और हृदय-धड़कन की टाइमिंग चाहिए। इनमें से एक मौजूद नहीं था।", "sleepDetailThroughTheNight": "पूरी रात के दौरान", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e0bba689c..fab3f57db 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3904,6 +3904,7 @@ "sleepDetailBedTimeHelp": "КОГДА ВЫ ЛЕГЛИ В ПОСТЕЛЬ", "sleepDetailWakeTimeHelp": "КОГДА ВЫ ВСТАЛИ", "sleepDetailReanalyseFailed": "Ночь не пересчитана: шёл другой пересчёт или произошла ошибка. Время сохранено: откройте «Ваши данные» и запустите повторный анализ.", + "sleepDetailCorrectionUnavailable": "Подробные записи этой ночи уже удалены с телефона, поэтому её сон больше нельзя исправить.", "sleepDetailNoHypnogramTitle": "За эту ночь гипнограммы нет", "sleepDetailNoHypnogramBody": "Для определения стадий нужны движение и межударные интервалы. Одного из источников не было.", "sleepDetailThroughTheNight": "В течение ночи", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 28860b86c..1753ee267 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1649,6 +1649,7 @@ "sleepDetailBedTimeHelp": "上床时间", "sleepDetailWakeTimeHelp": "起床时间", "sleepDetailReanalyseFailed": "这一夜未能重新分析——可能另一次重新分析正在进行,也可能分析失败了。你设置的时间已保存;在“你的数据”中点击“重新分析全部”即可应用。", + "sleepDetailCorrectionUnavailable": "这一夜的详细记录已不在手机上,因此无法再更正这一夜的睡眠。", "sleepDetailNoHypnogramTitle": "这一夜没有睡眠阶段图", "sleepDetailNoHypnogramBody": "阶段判定需要运动和心跳时序数据,其中有一项缺失。", "sleepDetailThroughTheNight": "整夜变化", diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 95415862b..8a4f8e76d 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -53,7 +53,11 @@ import '../cloud/companion_client.dart'; import '../compute/derivation_engine.dart'; import '../compute/derive_scheduler.dart'; import '../compute/inputs/canonical.dart' - show flagOnOrPrimary, onWearableDaysChanged, refreshSessionSensorSources; + show + flagOnOrPrimary, + onWearableDaysChanged, + refreshSessionSensorSources, + substrateDays; import '../compute/manual_session.dart' show supersededSuggestionIds; import '../compute/hr_max.dart'; import '../compute/inputs/measurement_inputs.dart' @@ -2461,6 +2465,22 @@ class AppState extends ChangeNotifier { // ── SLEEP OVERRIDE (manual entry + fallback confirm) ──────────────────────── + /// Whether a sleep correction on [date] can reach the derived night. The + /// forced re-derive only covers days that still have substrate (decoded band + /// rows or the active wearable's rows); once retention prunes a day's raw, + /// nothing re-stages it, so an override stored for it would never show and + /// "Re-analyze everything" couldn't apply it either. + Future sleepCorrectable(String date) async => + (await substrateDays()).contains(date); + + /// Refuses a correction on a day [sleepCorrectable] rules out, before + /// anything is written: the row would sit in sleep_override unapplied. + Future _requireSleepSubstrate(String date) async { + if (!await sleepCorrectable(date)) { + throw SleepCorrectionUnavailable(date); + } + } + /// 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. @@ -2473,6 +2493,7 @@ class AppState extends ChangeNotifier { final onsetSec = onset.millisecondsSinceEpoch ~/ 1000; final offsetSec = offset.millisecondsSinceEpoch ~/ 1000; if (offsetSec <= onsetSec) return; + await _requireSleepSubstrate(date); await LocalDb.putSleepOverride( dayId: date, onsetTs: onsetSec, @@ -2491,6 +2512,7 @@ class AppState extends ChangeNotifier { 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 _requireSleepSubstrate(date); await LocalDb.putSleepOverride( dayId: date, onsetTs: onset, @@ -2517,6 +2539,7 @@ class AppState extends ChangeNotifier { // 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 _requireSleepSubstrate(date); await LocalDb.putSleepOverride( dayId: date, onsetTs: onset ?? fallback, @@ -2527,13 +2550,18 @@ class AppState extends ChangeNotifier { } /// Remove a manual/confirmed override for [date] — revert to auto/fallback. + /// Refused on a pruned day like the others: deleting the row there would + /// leave the derived night still showing the user's window. Future clearSleepOverride(String date) async { + await _requireSleepSubstrate(date); await LocalDb.deleteSleepOverride(date); await _reanalyzeForOverride(date); } /// 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). + /// the user's window. A force pass re-derives finalized override days, but + /// only among days that still have substrate — callers check that first + /// ([sleepCorrectable]). Future _reanalyzeForOverride([String? editedDay]) async { // run() returns 0 without queueing while ANY pass holds the derive latch // (a drain's light pass, the post-drain rescan, another edit), and only a @@ -5375,3 +5403,12 @@ class AppState extends ChangeNotifier { } } } + +/// A sleep correction on a day whose substrate has been pruned: no re-derive +/// can reach it, so nothing was written. +class SleepCorrectionUnavailable implements Exception { + SleepCorrectionUnavailable(this.day); + final String day; + @override + String toString() => 'no recordings left to re-analyse $day'; +} diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 2839b72d8..05d04d5ab 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -430,6 +430,12 @@ class _SleepDetailState extends State with RevisionReload { /// Why the last correction did not take. Null when it did. String? _overrideFailed; + /// Whether the shown night can still be corrected. False once the day's + /// recordings have been pruned: no re-analysis reaches it any more, so the + /// correction buttons would only ever fail (see AppState.sleepCorrectable). + /// Unknown counts as correctable, which is how the screen behaved before. + bool _correctable = true; + /// Last night's state against the user's own nights, when it was rough enough /// to say so and has not been dismissed. See `rough_night.dart` — it REPLACES /// the elevated-sleeping-HR card rather than joining it, so one night can @@ -488,8 +494,18 @@ class _SleepDetailState extends State with RevisionReload { if (mounted && gen == _loadGen) setState(() => _loading = false); return; } + AppState? app; + try { + app = context.read(); + } catch (_) {} try { final d = await SleepData.load(repo, want: _day); + var correctable = true; + if (app != null && d.day != null) { + try { + correctable = await app.sleepCorrectable(d.day!); + } catch (_) {} + } // LAST NIGHT ONLY. The card names the luteal phase, and `getCycle` knows // today's phase and no other day's — so a card offered while stepping // back through the record would either drop that half or invent it. @@ -500,7 +516,12 @@ class _SleepDetailState extends State with RevisionReload { ? await loadRoughNight(repo, d.day!, c: mounted ? context : null) : null; if (mounted && gen == _loadGen) { - setState(() => (_d = d, _rough = rough, _loading = false)); + setState(() => ( + _d = d, + _rough = rough, + _correctable = correctable, + _loading = false, + )); } } catch (_) { if (mounted && gen == _loadGen) setState(() => _loading = false); @@ -560,7 +581,10 @@ class _SleepDetailState extends State with RevisionReload { 'Wear the band overnight and sync in the morning'), icon: rejected ? LucideIcons.undo2 : LucideIcons.moon, ), - if (rejected) ...[ + if (rejectedDay != null && !_correctable) ...[ + const SizedBox(height: S.x2), + _uncorrectableNote(c, l), + ] else if (rejected) ...[ const SizedBox(height: S.x2), TextButton( onPressed: _saving ? null : () => _clearWindow(rejectedDay), @@ -839,6 +863,9 @@ class _SleepDetailState extends State with RevisionReload { ), ], const SizedBox(height: S.x2), + if (!_correctable) + _uncorrectableNote(c, l) + else Wrap(spacing: S.x2, children: [ if (fallback) TextButton( @@ -884,6 +911,15 @@ class _SleepDetailState extends State with RevisionReload { ]; } + /// Stands in for the correction buttons on a night that can no longer be + /// re-analysed, so it isn't offered a fix that can't land. + Widget _uncorrectableNote(BuildContext c, AppLocalizations? l) => Text( + l?.sleepDetailCorrectionUnavailable ?? + 'This night\u2019s detailed recordings are no longer on the phone, ' + 'so its sleep can\u2019t be corrected now.', + style: F.cap.copyWith(color: P.of(c).ink3), + ); + Future _confirmWindow(String day) => _runOverride( day, () => context.read().confirmSleep(day), @@ -964,9 +1000,17 @@ class _SleepDetailState extends State with RevisionReload { _saving = true; _overrideFailed = null; }); + final unavailable = + AppLocalizations.of(context)?.sleepDetailCorrectionUnavailable; String? failed; try { await write(); + } on SleepCorrectionUnavailable { + // Nothing was written, and re-analysing everything can't reach this day + // either, so the generic "tap Re-analyze everything" advice is wrong. + failed = unavailable ?? + 'This night\u2019s detailed recordings are no longer on the phone, ' + 'so its sleep can\u2019t be corrected now.'; } catch (e) { failed = '$e'; } finally { diff --git a/test/sleep_override_rederive_test.dart b/test/sleep_override_rederive_test.dart index 9e91c6d66..f147f9832 100644 --- a/test/sleep_override_rederive_test.dart +++ b/test/sleep_override_rederive_test.dart @@ -15,6 +15,28 @@ import 'package:openstrap_edge/data/db.dart'; import 'package:openstrap_edge/health/health_export.dart'; import 'package:openstrap_edge/state/app_state.dart'; +/// One decoded band second at noon on [day], so the day has substrate for a +/// forced re-derive to reach. +Future _seedDay(String day) async { + final d = DateTime.parse(day); + final ts = DateTime(d.year, d.month, d.day, 12).millisecondsSinceEpoch ~/ 1000; + await (await LocalDb.instance).insert( + 'decoded_onehz', + { + 'device_id': '', + 'ts_ms': ts * 1000, + 'rec_ts': ts, + 'counter': ts % 100000, + 'hr': 60, + 'ax': 0.0, + 'ay': -0.5, + 'az': 0.98, + 'device_family': 'gen4', + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -37,6 +59,7 @@ void main() { test('an edit made while another derive pass runs waits for it', () async { final app = AppState.forTesting(); addTearDown(app.dispose); + await _seedDay(todayLabel()); DerivationEngine.debugRunning = true; // a drain pass / rescan in flight addTearDown(() => DerivationEngine.debugRunning = false); @@ -63,6 +86,7 @@ void main() { addTearDown(() => DerivationEngine.debugRunning = false); final day = todayLabel(); + await _seedDay(day); await LocalDb.setFrozenHeadline(day, 45); final edit = app.setSleepOverride( day, @@ -83,6 +107,7 @@ void main() { test('an edit doesn\'t wait for a health export already running', () async { final app = AppState.forTesting(); addTearDown(app.dispose); + await _seedDay('2026-09-20'); final exporting = Completer(); final running = HealthExporter.shared.debugRunLocked( () => exporting.future, @@ -142,4 +167,40 @@ void main() { await LocalDb.deleteSleepOverride('2026-09-30'); expect(await LocalDb.frozenHeadline(), isNull); }); + + test('a correction on a night whose recordings were pruned is refused, ' + 'not stored', () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + // No decoded rows for this day: the force re-derive can't reach it, so a + // stored override would never show and "Re-analyze everything" couldn't + // apply it either. + const day = '2026-08-01'; + expect(await app.sleepCorrectable(day), isFalse); + await expectLater( + app.setSleepOverride( + day, DateTime(2026, 7, 31, 23), DateTime(2026, 8, 1, 7)), + throwsA(isA()), + ); + expect(await LocalDb.getSleepOverride(day), isNull); + expect(app.reanalyzing, isFalse); + + // An override stored back when the day still had raw stays put: deleting + // it would leave the derived night showing the user's window anyway. + await LocalDb.putSleepOverride( + dayId: day, + onsetTs: 1000, + offsetTs: 2000, + source: 'manual', + ); + await expectLater( + app.clearSleepOverride(day), + throwsA(isA()), + ); + expect(await LocalDb.getSleepOverride(day), isNotNull); + await LocalDb.deleteSleepOverride(day); + + await _seedDay(day); + expect(await app.sleepCorrectable(day), isTrue); + }); } From 33a7bc51161bbf7bb5346b4e3271a163a76e532f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 11:32:59 +0530 Subject: [PATCH 05/41] sleep corrections on the oldest kept night still came back unapplied the prune cuts on local midnight, so the oldest kept day still has its own rows and passed the substrate check, but its night window starts the noon before and the derive declines to overwrite a stored night from that partial window. confirming the detected times, going back to automatic, or a pre-midnight bedtime there were stored and then shown the "tap re-analyze everything" advice, which hits the same decline. the correction check now mirrors that decline per action, through one shared predicate the derive guard uses too. on that night the sleep screen keeps not sleep and set/change the times, hides the other two, and says why; a bedtime before midnight is refused before anything is written. --- lib/compute/derivation_engine.dart | 52 +++++++++++++++-- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 4 ++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/state/app_state.dart | 66 ++++++++++++++++------ lib/ui2/screens/sleep_detail.dart | 53 ++++++++++++----- test/sleep_override_rederive_test.dart | 78 ++++++++++++++++++++++++++ 11 files changed, 223 insertions(+), 36 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 9174bf922..576c7bd02 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -4945,6 +4945,49 @@ class DerivationEngine { _localDayLabelToSec(dayId) - kNocturnalSearchLookbackSec) < prunedBeforeSec; + /// [windowTruncatedByPrune] as the derive's prune guard applies it to a day + /// whose night came from [sleepSource]: a rejected night has nothing to + /// protect, and a user-set window ('manual'/'confirmed') only needs its own + /// onset ([sleepOnsetSec]) kept. + static bool sleepWindowTruncatedByPrune( + String dayId, + int? prunedBeforeSec, { + String? sleepSource, + int? sleepOnsetSec, + }) => + sleepSource != 'rejected' && + windowTruncatedByPrune( + dayId, + prunedBeforeSec, + forcedOnsetSec: sleepSource == 'manual' || sleepSource == 'confirmed' + ? sleepOnsetSec + : null, + ); + + /// Whether a re-derive of [dayId] with its night from [sleepSource] (and + /// [sleepOnsetSec] for a user-set window) would be declined by the prune + /// guard in [_derivePreparedDay] and keep the stored row. Lets a sleep + /// correction be refused up front instead of stored and never applied. + static Future pruneDeclinesSleepRederive( + String dayId, { + required String sleepSource, + int? sleepOnsetSec, + }) async { + if (!sleepWindowTruncatedByPrune( + dayId, + await LocalDb.getCursorInt(_prunedBeforeCursor), + sleepSource: sleepSource, + sleepOnsetSec: sleepOnsetSec, + )) { + return false; + } + final existing = await LocalDb.dayResult(dayId); + return existing != null && + (existing['rhr'] != null || + existing['rmssd'] != null || + existing['readiness'] != null); + } + /// A stable, cheap signature of the CURRENT rolling baseline — the same inputs /// the readiness/illness baselines fold over. We take the trailing /// _baselineWindowDays derived rows and the median of each baseline series @@ -5418,14 +5461,11 @@ class DerivationEngine { // An import brings its own substrate, the prune can't have cut it. // A rejected night has nothing to protect either. (!suppliedSubstrate && - day.sleepSource != 'rejected' && - windowTruncatedByPrune( + sleepWindowTruncatedByPrune( day.date, await LocalDb.getCursorInt(_prunedBeforeCursor), - forcedOnsetSec: day.sleepSource == 'manual' || - day.sleepSource == 'confirmed' - ? day.sleepOnsetSec - : null, + sleepSource: day.sleepSource, + sleepOnsetSec: day.sleepOnsetSec, )))) { final existingNight = await LocalDb.dayResult(day.date); final existingHadNight = existingNight != null && diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index a04c277ef..aa6a95433 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1700,6 +1700,7 @@ "sleepDetailWakeTimeHelp": "AUFGESTANDEN UM", "sleepDetailReanalyseFailed": "Die Nacht wurde nicht neu ausgewertet – entweder lief bereits eine andere Auswertung, oder sie ist fehlgeschlagen. Deine Zeiten sind gespeichert; „Alles neu analysieren“ unter „Deine Daten“ wendet sie an.", "sleepDetailCorrectionUnavailable": "Die detaillierten Aufzeichnungen dieser Nacht sind nicht mehr auf dem Telefon, daher lässt sich ihr Schlaf nicht mehr korrigieren.", + "sleepDetailCorrectionEveningPruned": "Der Abend vor dieser Nacht ist nicht mehr auf dem Telefon, daher lassen sich nur eine Schlafenszeit nach Mitternacht oder „Kein Schlaf“ übernehmen.", "sleepDetailNoHypnogramTitle": "Kein Hypnogramm für diese Nacht", "sleepDetailNoHypnogramBody": "Für die Schlafphasen werden Bewegung und Herzschlag-Abstände benötigt. Eines davon fehlte.", "sleepDetailThroughTheNight": "Im Verlauf der Nacht", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9630ab6c6..95c4a2249 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -8467,6 +8467,10 @@ }, "sleepDetailReanalyseFailed": "The night wasn’t re-analysed; another run was busy or it failed. Your times are saved: open Your data and tap Re-analyze everything.", "sleepDetailCorrectionUnavailable": "This night’s detailed recordings are no longer on the phone, so its sleep can’t be corrected now.", + "sleepDetailCorrectionEveningPruned": "The evening before this night is no longer on the phone, so only a bedtime after midnight, or Not sleep, can be applied.", + "@sleepDetailCorrectionEveningPruned": { + "description": "Shown on the oldest night still kept, or after a correction there was refused: the evening part of the night has been pruned, so a bedtime before midnight, confirming the detected times, or reverting to automatic cannot be applied." + }, "@sleepDetailCorrectionUnavailable": { "description": "Shown instead of the sleep correction buttons, or after one was refused, when the night's raw recordings have been pruned and it can no longer be re-analysed." }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5c07835ea..e64d6aaa4 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1660,6 +1660,7 @@ "sleepDetailWakeTimeHelp": "CUÁNDO TE LEVANTASTE", "sleepDetailReanalyseFailed": "La noche no se ha vuelto a analizar: ya había otro reanálisis en curso, o falló. Las horas que estableciste están guardadas; \"Reanalizar todo\" en Tus datos las aplicará.", "sleepDetailCorrectionUnavailable": "Los registros detallados de esta noche ya no están en el teléfono, así que su sueño ya no se puede corregir.", + "sleepDetailCorrectionEveningPruned": "La tarde anterior a esta noche ya no está en el teléfono, así que solo se puede aplicar una hora de acostarse después de medianoche o «No es sueño».", "sleepDetailNoHypnogramTitle": "No hay hipnograma para esta noche", "sleepDetailNoHypnogramBody": "El cálculo de fases necesita movimiento y ritmo cardíaco. Faltaba uno de los dos.", "sleepDetailThroughTheNight": "Durante la noche", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6514d291f..b5150698f 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1650,6 +1650,7 @@ "sleepDetailWakeTimeHelp": "HEURE DU LEVER", "sleepDetailReanalyseFailed": "La nuit n'a pas été réanalysée : une autre réanalyse était déjà en cours, ou elle a échoué. Les horaires que vous avez définis sont enregistrés ; « Tout réanalyser » dans Vos données les appliquera.", "sleepDetailCorrectionUnavailable": "Les enregistrements détaillés de cette nuit ne sont plus sur le téléphone, son sommeil ne peut donc plus être corrigé.", + "sleepDetailCorrectionEveningPruned": "La soirée précédant cette nuit n’est plus sur le téléphone : on peut seulement indiquer une heure de coucher après minuit, ou marquer la nuit comme n’étant pas du sommeil.", "sleepDetailNoHypnogramTitle": "Aucun hypnogramme pour cette nuit", "sleepDetailNoHypnogramBody": "L'analyse des phases nécessite le mouvement et le rythme cardiaque. L'un des deux manquait.", "sleepDetailThroughTheNight": "Au fil de la nuit", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index dd45498da..6f0f2a758 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1650,6 +1650,7 @@ "sleepDetailWakeTimeHelp": "आप कब उठे", "sleepDetailReanalyseFailed": "रात का दोबारा विश्लेषण नहीं हो पाया — या तो कोई और पुनः विश्लेषण पहले से चल रहा था, या यह विफल हो गया। आपके सेट किए गए समय सेव हैं; 'आपका डेटा' में 'सब कुछ दोबारा विश्लेषण करें' उन्हें लागू कर देगा।", "sleepDetailCorrectionUnavailable": "इस रात की विस्तृत रिकॉर्डिंग अब फ़ोन पर नहीं हैं, इसलिए इसकी नींद अब ठीक नहीं की जा सकती।", + "sleepDetailCorrectionEveningPruned": "इस रात से पहले की शाम अब फ़ोन पर नहीं है, इसलिए केवल आधी रात के बाद का सोने का समय दिया जा सकता है, या इस रात को नींद नहीं के रूप में चिह्नित किया जा सकता है।", "sleepDetailNoHypnogramTitle": "इस रात के लिए कोई हिप्नोग्राम नहीं", "sleepDetailNoHypnogramBody": "चरण तय करने के लिए हलचल और हृदय-धड़कन की टाइमिंग चाहिए। इनमें से एक मौजूद नहीं था।", "sleepDetailThroughTheNight": "पूरी रात के दौरान", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fab3f57db..805348da9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3905,6 +3905,7 @@ "sleepDetailWakeTimeHelp": "КОГДА ВЫ ВСТАЛИ", "sleepDetailReanalyseFailed": "Ночь не пересчитана: шёл другой пересчёт или произошла ошибка. Время сохранено: откройте «Ваши данные» и запустите повторный анализ.", "sleepDetailCorrectionUnavailable": "Подробные записи этой ночи уже удалены с телефона, поэтому её сон больше нельзя исправить.", + "sleepDetailCorrectionEveningPruned": "Вечер перед этой ночью уже удалён с телефона, поэтому можно применить только время отхода ко сну после полуночи или «Не сон».", "sleepDetailNoHypnogramTitle": "За эту ночь гипнограммы нет", "sleepDetailNoHypnogramBody": "Для определения стадий нужны движение и межударные интервалы. Одного из источников не было.", "sleepDetailThroughTheNight": "В течение ночи", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1753ee267..d900882d9 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1650,6 +1650,7 @@ "sleepDetailWakeTimeHelp": "起床时间", "sleepDetailReanalyseFailed": "这一夜未能重新分析——可能另一次重新分析正在进行,也可能分析失败了。你设置的时间已保存;在“你的数据”中点击“重新分析全部”即可应用。", "sleepDetailCorrectionUnavailable": "这一夜的详细记录已不在手机上,因此无法再更正这一夜的睡眠。", + "sleepDetailCorrectionEveningPruned": "这一夜之前的傍晚记录已不在手机上,因此只能设置午夜之后的入睡时间,或将这一夜标记为不是睡眠。", "sleepDetailNoHypnogramTitle": "这一夜没有睡眠阶段图", "sleepDetailNoHypnogramBody": "阶段判定需要运动和心跳时序数据,其中有一项缺失。", "sleepDetailThroughTheNight": "整夜变化", diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 8a4f8e76d..ce64d6513 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2470,15 +2470,43 @@ class AppState extends ChangeNotifier { /// rows or the active wearable's rows); once retention prunes a day's raw, /// nothing re-stages it, so an override stored for it would never show and /// "Re-analyze everything" couldn't apply it either. - Future sleepCorrectable(String date) async => - (await substrateDays()).contains(date); + /// + /// The prune cuts on local midnight, so the oldest kept day still has its + /// own rows but lost the evening half of its night. The derive declines to + /// overwrite a stored night from that partial window, so on that day a + /// correction only lands when the night it asks for doesn't reach below the + /// cut: [sleepSource] is what the night would become ('rejected', 'manual', + /// 'confirmed', or 'auto' when clearing) and [onsetSec] the user's bedtime + /// for a user-set window. + Future sleepCorrectable( + String date, { + String sleepSource = 'auto', + int? onsetSec, + }) async => + (await substrateDays()).contains(date) && + !await DerivationEngine.pruneDeclinesSleepRederive( + date, + sleepSource: sleepSource, + sleepOnsetSec: onsetSec, + ); - /// Refuses a correction on a day [sleepCorrectable] rules out, before - /// anything is written: the row would sit in sleep_override unapplied. - Future _requireSleepSubstrate(String date) async { - if (!await sleepCorrectable(date)) { + /// Refuses a correction [sleepCorrectable] rules out, before anything is + /// written: the row would sit in sleep_override unapplied. + Future _requireSleepSubstrate( + String date, { + required String sleepSource, + int? onsetSec, + }) async { + if (!(await substrateDays()).contains(date)) { throw SleepCorrectionUnavailable(date); } + if (await DerivationEngine.pruneDeclinesSleepRederive( + date, + sleepSource: sleepSource, + sleepOnsetSec: onsetSec, + )) { + throw SleepCorrectionUnavailable(date, eveningPruned: true); + } } /// Manual sleep entry (Approach 1): the user gives the in-bed window for [date] @@ -2493,7 +2521,7 @@ class AppState extends ChangeNotifier { final onsetSec = onset.millisecondsSinceEpoch ~/ 1000; final offsetSec = offset.millisecondsSinceEpoch ~/ 1000; if (offsetSec <= onsetSec) return; - await _requireSleepSubstrate(date); + await _requireSleepSubstrate(date, sleepSource: source, onsetSec: onsetSec); await LocalDb.putSleepOverride( dayId: date, onsetTs: onsetSec, @@ -2512,7 +2540,8 @@ class AppState extends ChangeNotifier { 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 _requireSleepSubstrate(date); + await _requireSleepSubstrate(date, + sleepSource: 'confirmed', onsetSec: onset); await LocalDb.putSleepOverride( dayId: date, onsetTs: onset, @@ -2539,7 +2568,7 @@ class AppState extends ChangeNotifier { // 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 _requireSleepSubstrate(date); + await _requireSleepSubstrate(date, sleepSource: 'rejected'); await LocalDb.putSleepOverride( dayId: date, onsetTs: onset ?? fallback, @@ -2553,15 +2582,16 @@ class AppState extends ChangeNotifier { /// Refused on a pruned day like the others: deleting the row there would /// leave the derived night still showing the user's window. Future clearSleepOverride(String date) async { - await _requireSleepSubstrate(date); + await _requireSleepSubstrate(date, sleepSource: 'auto'); await LocalDb.deleteSleepOverride(date); await _reanalyzeForOverride(date); } /// Force-derive after a sleep-override change so the affected day restages from /// the user's window. A force pass re-derives finalized override days, but - /// only among days that still have substrate — callers check that first - /// ([sleepCorrectable]). + /// only among days that still have substrate, and it keeps the stored night + /// when the prune has cut into the asked-for window — callers check both + /// first ([sleepCorrectable]). Future _reanalyzeForOverride([String? editedDay]) async { // run() returns 0 without queueing while ANY pass holds the derive latch // (a drain's light pass, the post-drain rescan, another edit), and only a @@ -5404,11 +5434,15 @@ class AppState extends ChangeNotifier { } } -/// A sleep correction on a day whose substrate has been pruned: no re-derive -/// can reach it, so nothing was written. +/// A sleep correction no re-derive can apply, so nothing was written: the +/// day's substrate has been pruned, or ([eveningPruned]) the day is the oldest +/// one kept and the night asked for starts before the midnight prune cut. class SleepCorrectionUnavailable implements Exception { - SleepCorrectionUnavailable(this.day); + SleepCorrectionUnavailable(this.day, {this.eveningPruned = false}); final String day; + final bool eveningPruned; @override - String toString() => 'no recordings left to re-analyse $day'; + String toString() => eveningPruned + ? 'the evening before $day is no longer recorded' + : 'no recordings left to re-analyse $day'; } diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 05d04d5ab..ffb6536e7 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -436,6 +436,12 @@ class _SleepDetailState extends State with RevisionReload { /// Unknown counts as correctable, which is how the screen behaved before. bool _correctable = true; + /// Whether the night's whole derive window is still kept. False on the + /// oldest kept day: the prune cut at its midnight took the evening half, so + /// confirming the detected times or going back to automatic would be + /// declined; only a bedtime after midnight, or Not sleep, can land. + bool _wholeNightKept = true; + /// Last night's state against the user's own nights, when it was rough enough /// to say so and has not been dismissed. See `rough_night.dart` — it REPLACES /// the elevated-sleeping-HR card rather than joining it, so one night can @@ -501,9 +507,13 @@ class _SleepDetailState extends State with RevisionReload { try { final d = await SleepData.load(repo, want: _day); var correctable = true; + var wholeNightKept = true; if (app != null && d.day != null) { try { - correctable = await app.sleepCorrectable(d.day!); + correctable = + await app.sleepCorrectable(d.day!, sleepSource: 'rejected'); + wholeNightKept = + correctable && await app.sleepCorrectable(d.day!); } catch (_) {} } // LAST NIGHT ONLY. The card names the luteal phase, and `getCycle` knows @@ -520,6 +530,7 @@ class _SleepDetailState extends State with RevisionReload { _d = d, _rough = rough, _correctable = correctable, + _wholeNightKept = wholeNightKept, _loading = false, )); } @@ -581,9 +592,10 @@ class _SleepDetailState extends State with RevisionReload { 'Wear the band overnight and sync in the morning'), icon: rejected ? LucideIcons.undo2 : LucideIcons.moon, ), - if (rejectedDay != null && !_correctable) ...[ + // Undo goes back to automatic, which needs the whole night window. + if (rejectedDay != null && (!_correctable || !_wholeNightKept)) ...[ const SizedBox(height: S.x2), - _uncorrectableNote(c, l), + _correctable ? _eveningPrunedNote(c, l) : _uncorrectableNote(c, l), ] else if (rejected) ...[ const SizedBox(height: S.x2), TextButton( @@ -867,7 +879,7 @@ class _SleepDetailState extends State with RevisionReload { _uncorrectableNote(c, l) else Wrap(spacing: S.x2, children: [ - if (fallback) + if (fallback && _wholeNightKept) TextButton( onPressed: busy ? null : () => _confirmWindow(day), child: Text(l?.sleepDetailConfirmTimes ?? 'These times are right'), @@ -878,7 +890,7 @@ class _SleepDetailState extends State with RevisionReload { ? (l?.sleepDetailChangeTimes ?? 'Change the times') : (l?.sleepDetailSetTimesMyself ?? 'Set the times myself')), ), - if (mine) + if (mine && _wholeNightKept) TextButton( onPressed: busy ? null : () => _clearWindow(day), child: @@ -892,6 +904,7 @@ class _SleepDetailState extends State with RevisionReload { child: Text(l?.sleepDetailNotSleep ?? 'Not sleep'), ), ]), + if (_correctable && !_wholeNightKept) _eveningPrunedNote(c, l), if (busy) ...[ const SizedBox(height: S.x2), Text(l?.sleepDetailReanalysing ?? 'Re-analysing the night…', @@ -920,6 +933,14 @@ class _SleepDetailState extends State with RevisionReload { style: F.cap.copyWith(color: P.of(c).ink3), ); + /// On the oldest kept night: says why only some corrections are offered. + Widget _eveningPrunedNote(BuildContext c, AppLocalizations? l) => Text( + l?.sleepDetailCorrectionEveningPruned ?? + 'The evening before this night is no longer on the phone, so only ' + 'a bedtime after midnight, or Not sleep, can be applied.', + style: F.cap.copyWith(color: P.of(c).ink3), + ); + Future _confirmWindow(String day) => _runOverride( day, () => context.read().confirmSleep(day), @@ -1000,17 +1021,22 @@ class _SleepDetailState extends State with RevisionReload { _saving = true; _overrideFailed = null; }); - final unavailable = - AppLocalizations.of(context)?.sleepDetailCorrectionUnavailable; + final l = AppLocalizations.of(context); String? failed; try { await write(); - } on SleepCorrectionUnavailable { - // Nothing was written, and re-analysing everything can't reach this day - // either, so the generic "tap Re-analyze everything" advice is wrong. - failed = unavailable ?? - 'This night\u2019s detailed recordings are no longer on the phone, ' - 'so its sleep can\u2019t be corrected now.'; + } on SleepCorrectionUnavailable catch (e) { + // Nothing was written, and re-analysing everything can't reach this + // night either, so the generic "tap Re-analyze everything" advice is + // wrong. + failed = e.eveningPruned + ? (l?.sleepDetailCorrectionEveningPruned ?? + 'The evening before this night is no longer on the phone, so ' + 'only a bedtime after midnight, or Not sleep, can be ' + 'applied.') + : (l?.sleepDetailCorrectionUnavailable ?? + 'This night\u2019s detailed recordings are no longer on the ' + 'phone, so its sleep can\u2019t be corrected now.'); } catch (e) { failed = '$e'; } finally { @@ -1032,7 +1058,6 @@ class _SleepDetailState extends State with RevisionReload { ? applied(_d?.night ?? const {}) : _source != before; if (failed != null || !took) { - final l = AppLocalizations.of(context); setState(() => _overrideFailed = failed ?? l?.sleepDetailReanalyseFailed ?? 'The night wasn’t re-analysed; another run was busy or it failed. Your times are saved: open Your data and tap Re-analyze everything.'); diff --git a/test/sleep_override_rederive_test.dart b/test/sleep_override_rederive_test.dart index f147f9832..d76567d02 100644 --- a/test/sleep_override_rederive_test.dart +++ b/test/sleep_override_rederive_test.dart @@ -203,4 +203,82 @@ void main() { await _seedDay(day); expect(await app.sleepCorrectable(day), isTrue); }); + + test('on the oldest kept night only a correction the prune leaves whole is ' + 'offered or stored', () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + // The prune cuts on local midnight: the day keeps its own rows but the + // evening before is gone, and the stored night came from the whole window. + const day = '2026-08-10'; + final midnight = DateTime(2026, 8, 10).millisecondsSinceEpoch ~/ 1000; + await _seedDay(day); + await LocalDb.setCursor('decoded_pruned_before', '$midnight'); + addTearDown(() async => (await LocalDb.instance).delete('sync_cursor', + where: 'name = ?', whereArgs: ['decoded_pruned_before'])); + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + payloadJson: '{}', + windowJson: '{}', + rhr: 52, + ); + + // Back to automatic / confirming a bedtime from the evening before would + // be declined by the derive and keep the stored night. + expect(await app.sleepCorrectable(day), isFalse); + expect( + await app.sleepCorrectable(day, + sleepSource: 'confirmed', onsetSec: midnight - 3600), + isFalse); + // Not sleep and a bedtime after midnight still land. + expect(await app.sleepCorrectable(day, sleepSource: 'rejected'), isTrue); + expect( + await app.sleepCorrectable(day, + sleepSource: 'manual', onsetSec: midnight + 1800), + isTrue); + + await expectLater( + app.setSleepOverride( + day, DateTime(2026, 8, 9, 23), DateTime(2026, 8, 10, 7)), + throwsA(isA() + .having((e) => e.eveningPruned, 'eveningPruned', isTrue)), + ); + expect(await LocalDb.getSleepOverride(day), isNull); + + await LocalDb.putSleepOverride( + dayId: day, + onsetTs: midnight + 1800, + offsetTs: midnight + 7 * 3600, + source: 'manual', + ); + await expectLater( + app.clearSleepOverride(day), + throwsA(isA() + .having((e) => e.eveningPruned, 'eveningPruned', isTrue)), + ); + expect(await LocalDb.getSleepOverride(day), isNotNull, + reason: 'refused before the row was deleted'); + await LocalDb.deleteSleepOverride(day); + }); + + test('the prune guard spares a rejected night and keeps a user window on ' + 'its own onset', () { + const day = '2026-08-10'; + final midnight = DateTime(2026, 8, 10).millisecondsSinceEpoch ~/ 1000; + expect(DerivationEngine.sleepWindowTruncatedByPrune(day, midnight), isTrue); + expect( + DerivationEngine.sleepWindowTruncatedByPrune(day, midnight, + sleepSource: 'rejected'), + isFalse); + expect( + DerivationEngine.sleepWindowTruncatedByPrune(day, midnight, + sleepSource: 'manual', sleepOnsetSec: midnight + 60), + isFalse); + expect( + DerivationEngine.sleepWindowTruncatedByPrune(day, midnight, + sleepSource: 'confirmed', sleepOnsetSec: midnight - 60), + isTrue); + expect(DerivationEngine.sleepWindowTruncatedByPrune(day, null), isFalse); + }); } From 1fa670415c92f215001e5f23cf06232f1114d78b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 11:39:12 +0530 Subject: [PATCH 06/41] "i was asleep" on a band-off night was reported as a busy re-analysis "i was asleep" is offered on nights with no detected sleep, and the usual reason there is none is that the band was on the charger. a window over those hours is mostly unobserved, so the segmenter abstains with a reason, but the day then derived with sleep_source 'none' as if no override existed. the reason was dropped, the sleep screen said another run was busy, and it told the user to tap re-analyze everything, which gives the same answer. a user-set window that stages to nothing now keeps its source and the segmenter's absence reason (a window with no samples at all gets the same unobserved reason), the no-night read passes absence_reason through, and the sleep screen says the times are saved but the band wasn't recording for most of those hours, or that it recorded no sleep in them. --- lib/compute/substrate.dart | 28 +++++- lib/data/local_repository_impl.dart | 10 ++- lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 8 ++ lib/l10n/app_es.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_hi.arb | 2 + lib/l10n/app_ru.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/ui2/screens/sleep_detail.dart | 25 +++++- test/sleep_override_unobserved_test.dart | 107 +++++++++++++++++++++++ 11 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 test/sleep_override_unobserved_test.dart diff --git a/lib/compute/substrate.dart b/lib/compute/substrate.dart index c0de5916b..ffc5e196b 100644 --- a/lib/compute/substrate.dart +++ b/lib/compute/substrate.dart @@ -1150,7 +1150,9 @@ class PhysioDay { /// 'auto_fallback' — HR-led fallback (van Hees found nothing); LOW confidence, /// surface a "is this right?" prompt /// 'vendor_staged' — the band's own hypnogram (window + stages), gated - /// 'manual' — user typed the window (Approach 1) + /// 'manual' — user typed the window (Approach 1); with no + /// [sleep] present when the window staged to nothing + /// (its `absence_reason` says why) /// 'confirmed' — user accepted the fallback's proposal /// 'none' — no sleep at all final String sleepSource; @@ -1463,6 +1465,30 @@ List calendarDays( // it was a rejection rather than an ordinary absence — sleep_detail // reads this to keep the "not sleep" state from re-prompting. sleepSource = 'rejected'; + } else if (ov != null) { + // A user-set window that staged to nothing — most often "I was + // asleep" on a night the band spent on the charger, so most of the + // window was never observed. Labelling it 'none' made it look like no + // override existed: the screen then blamed a busy re-analysis and sent + // the user to a re-run that gives the same answer. Keep the source and + // the segmenter's absence (it carries `absence_reason`) so the screen + // can say why the saved window has no night in it. + // + // A window with no samples at all comes back as the bare `absent` + // (no reason); give it the same unobserved reason the segmenter gives + // a thinly-recorded one, so "the band wasn't recording" isn't shown as + // "no sleep found". + final span = ov.offsetSec - ov.onsetSec; + final have = _lowerBound(sub.tsSec, ov.offsetSec) - + _lowerBound(sub.tsSec, ov.onsetSec); + seg = (s.absenceReason == null && + span > 0 && + have < span * ana.kMinObservedFractionForSleep) + ? ana.SleepSegmentation.unobservedWindow( + 'only ${have}s of a ${span}s window were recorded; below the ' + '${(ana.kMinObservedFractionForSleep * 100).round()}% floor') + : s; + sleepSource = src; } } diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 4cb1bb776..403d8089a 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -1031,12 +1031,20 @@ class LocalRepositoryImpl extends LocalRepository { // elsewhere. The periods ride along so the screen can show what it does // know instead of claiming nothing happened. final napPeriods = _periodsWithMainStages(b, const {}); + // Why a window produced nothing, when the segmenter knows — a user-set + // window the band barely recorded is the case the Sleep screen explains. + final absence = _sub(b, 'sleep')?['absence_reason'] as String?; if (napPeriods.isEmpty) { - return {'has_sleep': false, 'sleep_source': sleepSource}; + return { + 'has_sleep': false, + 'sleep_source': sleepSource, + 'absence_reason': ?absence, + }; } return { 'has_sleep': false, 'sleep_source': sleepSource, + 'absence_reason': ?absence, 'periods': napPeriods, 'total_asleep_min': _totalAsleepMin(b, napPeriods), }; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index aa6a95433..16b1271dc 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1699,6 +1699,8 @@ "sleepDetailBedTimeHelp": "INS BETT GEGANGEN UM", "sleepDetailWakeTimeHelp": "AUFGESTANDEN UM", "sleepDetailReanalyseFailed": "Die Nacht wurde nicht neu ausgewertet – entweder lief bereits eine andere Auswertung, oder sie ist fehlgeschlagen. Deine Zeiten sind gespeichert; „Alles neu analysieren“ unter „Deine Daten“ wendet sie an.", + "sleepDetailWindowUnrecorded": "Deine Zeiten sind gespeichert, aber das Band hat in diesen Stunden größtenteils nichts aufgezeichnet (nicht getragen oder beim Laden), daher gibt es keine Nacht zum Auswerten.", + "sleepDetailWindowNoSleep": "Deine Zeiten sind gespeichert, aber die Aufzeichnungen des Bands aus diesen Stunden zeigen keinen Schlaf zum Auswerten.", "sleepDetailCorrectionUnavailable": "Die detaillierten Aufzeichnungen dieser Nacht sind nicht mehr auf dem Telefon, daher lässt sich ihr Schlaf nicht mehr korrigieren.", "sleepDetailCorrectionEveningPruned": "Der Abend vor dieser Nacht ist nicht mehr auf dem Telefon, daher lassen sich nur eine Schlafenszeit nach Mitternacht oder „Kein Schlaf“ übernehmen.", "sleepDetailNoHypnogramTitle": "Kein Hypnogramm für diese Nacht", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 95c4a2249..187a856d5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -8466,6 +8466,14 @@ "description": "Help text on the time picker for when the user got up." }, "sleepDetailReanalyseFailed": "The night wasn’t re-analysed; another run was busy or it failed. Your times are saved: open Your data and tap Re-analyze everything.", + "sleepDetailWindowUnrecorded": "Your times are saved, but the band wasn’t recording for most of those hours (off your wrist or charging), so there’s no night to score.", + "sleepDetailWindowNoSleep": "Your times are saved, but the band’s recordings for those hours show no sleep to score.", + "@sleepDetailWindowUnrecorded": { + "description": "Shown after the user set their own sleep window (e.g. \"I was asleep\") and the band recorded too little of it to stage a night." + }, + "@sleepDetailWindowNoSleep": { + "description": "Shown after the user set their own sleep window and the band recorded it, but staging found no sleep in it." + }, "sleepDetailCorrectionUnavailable": "This night’s detailed recordings are no longer on the phone, so its sleep can’t be corrected now.", "sleepDetailCorrectionEveningPruned": "The evening before this night is no longer on the phone, so only a bedtime after midnight, or Not sleep, can be applied.", "@sleepDetailCorrectionEveningPruned": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index e64d6aaa4..5c971dc7a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1659,6 +1659,8 @@ "sleepDetailBedTimeHelp": "CUÁNDO TE ACOSTASTE", "sleepDetailWakeTimeHelp": "CUÁNDO TE LEVANTASTE", "sleepDetailReanalyseFailed": "La noche no se ha vuelto a analizar: ya había otro reanálisis en curso, o falló. Las horas que estableciste están guardadas; \"Reanalizar todo\" en Tus datos las aplicará.", + "sleepDetailWindowUnrecorded": "Tus horas están guardadas, pero la pulsera no estuvo registrando durante la mayor parte de ese tiempo (no la llevabas puesta o se estaba cargando), así que no hay noche que puntuar.", + "sleepDetailWindowNoSleep": "Tus horas están guardadas, pero los registros de la pulsera de esas horas no muestran sueño que puntuar.", "sleepDetailCorrectionUnavailable": "Los registros detallados de esta noche ya no están en el teléfono, así que su sueño ya no se puede corregir.", "sleepDetailCorrectionEveningPruned": "La tarde anterior a esta noche ya no está en el teléfono, así que solo se puede aplicar una hora de acostarse después de medianoche o «No es sueño».", "sleepDetailNoHypnogramTitle": "No hay hipnograma para esta noche", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b5150698f..a2b74513e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1649,6 +1649,8 @@ "sleepDetailBedTimeHelp": "HEURE DU COUCHER", "sleepDetailWakeTimeHelp": "HEURE DU LEVER", "sleepDetailReanalyseFailed": "La nuit n'a pas été réanalysée : une autre réanalyse était déjà en cours, ou elle a échoué. Les horaires que vous avez définis sont enregistrés ; « Tout réanalyser » dans Vos données les appliquera.", + "sleepDetailWindowUnrecorded": "Vos horaires sont enregistrés, mais le bracelet n'a pas enregistré pendant la majeure partie de ces heures (retiré du poignet ou en charge) : il n'y a donc pas de nuit à évaluer.", + "sleepDetailWindowNoSleep": "Vos horaires sont enregistrés, mais les enregistrements du bracelet pour ces heures ne montrent aucun sommeil à évaluer.", "sleepDetailCorrectionUnavailable": "Les enregistrements détaillés de cette nuit ne sont plus sur le téléphone, son sommeil ne peut donc plus être corrigé.", "sleepDetailCorrectionEveningPruned": "La soirée précédant cette nuit n’est plus sur le téléphone : on peut seulement indiquer une heure de coucher après minuit, ou marquer la nuit comme n’étant pas du sommeil.", "sleepDetailNoHypnogramTitle": "Aucun hypnogramme pour cette nuit", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 6f0f2a758..a4dc0ce8b 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1649,6 +1649,8 @@ "sleepDetailBedTimeHelp": "आप बिस्तर पर कब गए", "sleepDetailWakeTimeHelp": "आप कब उठे", "sleepDetailReanalyseFailed": "रात का दोबारा विश्लेषण नहीं हो पाया — या तो कोई और पुनः विश्लेषण पहले से चल रहा था, या यह विफल हो गया। आपके सेट किए गए समय सेव हैं; 'आपका डेटा' में 'सब कुछ दोबारा विश्लेषण करें' उन्हें लागू कर देगा।", + "sleepDetailWindowUnrecorded": "आपके समय सेव हैं, लेकिन उन घंटों में ज़्यादातर समय बैंड रिकॉर्ड नहीं कर रहा था (कलाई पर नहीं था या चार्ज हो रहा था), इसलिए स्कोर करने के लिए कोई रात नहीं है।", + "sleepDetailWindowNoSleep": "आपके समय सेव हैं, लेकिन उन घंटों की बैंड रिकॉर्डिंग में स्कोर करने लायक कोई नींद नहीं दिखती।", "sleepDetailCorrectionUnavailable": "इस रात की विस्तृत रिकॉर्डिंग अब फ़ोन पर नहीं हैं, इसलिए इसकी नींद अब ठीक नहीं की जा सकती।", "sleepDetailCorrectionEveningPruned": "इस रात से पहले की शाम अब फ़ोन पर नहीं है, इसलिए केवल आधी रात के बाद का सोने का समय दिया जा सकता है, या इस रात को नींद नहीं के रूप में चिह्नित किया जा सकता है।", "sleepDetailNoHypnogramTitle": "इस रात के लिए कोई हिप्नोग्राम नहीं", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 805348da9..052cdbde8 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3904,6 +3904,8 @@ "sleepDetailBedTimeHelp": "КОГДА ВЫ ЛЕГЛИ В ПОСТЕЛЬ", "sleepDetailWakeTimeHelp": "КОГДА ВЫ ВСТАЛИ", "sleepDetailReanalyseFailed": "Ночь не пересчитана: шёл другой пересчёт или произошла ошибка. Время сохранено: откройте «Ваши данные» и запустите повторный анализ.", + "sleepDetailWindowUnrecorded": "Время сохранено, но браслет почти всё это время ничего не записывал (был снят или заряжался), поэтому оценивать нечего.", + "sleepDetailWindowNoSleep": "Время сохранено, но в записях браслета за эти часы нет сна, который можно оценить.", "sleepDetailCorrectionUnavailable": "Подробные записи этой ночи уже удалены с телефона, поэтому её сон больше нельзя исправить.", "sleepDetailCorrectionEveningPruned": "Вечер перед этой ночью уже удалён с телефона, поэтому можно применить только время отхода ко сну после полуночи или «Не сон».", "sleepDetailNoHypnogramTitle": "За эту ночь гипнограммы нет", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d900882d9..f3ebeb2ed 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1649,6 +1649,8 @@ "sleepDetailBedTimeHelp": "上床时间", "sleepDetailWakeTimeHelp": "起床时间", "sleepDetailReanalyseFailed": "这一夜未能重新分析——可能另一次重新分析正在进行,也可能分析失败了。你设置的时间已保存;在“你的数据”中点击“重新分析全部”即可应用。", + "sleepDetailWindowUnrecorded": "你设置的时间已保存,但这段时间里手环大部分时候没有记录(没戴在手腕上或在充电),所以没有可评分的夜晚。", + "sleepDetailWindowNoSleep": "你设置的时间已保存,但手环在这段时间的记录中没有可评分的睡眠。", "sleepDetailCorrectionUnavailable": "这一夜的详细记录已不在手机上,因此无法再更正这一夜的睡眠。", "sleepDetailCorrectionEveningPruned": "这一夜之前的傍晚记录已不在手机上,因此只能设置午夜之后的入睡时间,或将这一夜标记为不是睡眠。", "sleepDetailNoHypnogramTitle": "这一夜没有睡眠阶段图", diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index ffb6536e7..ccb891d15 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -586,7 +586,8 @@ class _SleepDetailState extends State with RevisionReload { ? (l?.sleepDetailRejectedBody ?? 'You told us this stretch was not sleep, so nothing is ' 'scored for it.') - : (l?.sleepDetailNoNightBody ?? + : (_userWindowEmpty(d.night, l) ?? + l?.sleepDetailNoNightBody ?? 'No stretch of band recordings long enough to score.'), fix: rejected ? '' : (l?.sleepDetailNoNightFix ?? 'Wear the band overnight and sync in the morning'), @@ -1059,11 +1060,33 @@ class _SleepDetailState extends State with RevisionReload { : _source != before; if (failed != null || !took) { setState(() => _overrideFailed = failed ?? + _userWindowEmpty(_d?.night ?? const {}, l) ?? l?.sleepDetailReanalyseFailed ?? 'The night wasn’t re-analysed; another run was busy or it failed. Your times are saved: open Your data and tap Re-analyze everything.'); } } + /// Why a night the user set the times for still has nothing to show, or + /// null when it isn't one. The re-analysis ran and kept the window; the band + /// just recorded too little of it (charger, off the wrist) or no sleep in it, + /// so the generic "another run was busy, re-analyse everything" would send + /// the user to a re-run that gives the same answer. + static String? _userWindowEmpty( + Map n, AppLocalizations? l) { + final src = n['sleep_source']; + if ((src != 'manual' && src != 'confirmed') || n['has_sleep'] == true) { + return null; + } + return n['absence_reason'] != null + ? (l?.sleepDetailWindowUnrecorded ?? + 'Your times are saved, but the band wasn\u2019t recording for most ' + 'of those hours (off your wrist or charging), so there\u2019s ' + 'no night to score.') + : (l?.sleepDetailWindowNoSleep ?? + 'Your times are saved, but the band\u2019s recordings for those ' + 'hours show no sleep to score.'); + } + /// Where the drawn window came from: 'auto', 'auto_fallback', 'manual' or /// 'confirmed'. String? get _source => (_d?.night['sleep_source'] as String?) ?? 'auto'; diff --git a/test/sleep_override_unobserved_test.dart b/test/sleep_override_unobserved_test.dart new file mode 100644 index 000000000..4a150e7b0 --- /dev/null +++ b/test/sleep_override_unobserved_test.dart @@ -0,0 +1,107 @@ +// "I was asleep" is offered on nights with no detected sleep, and the usual +// reason there is none is that the band was on the charger. A user-set window +// over those hours is mostly unobserved, so the segmenter abstains with a +// reason. The day used to derive with sleep_source 'none' — as if no override +// existed — which threw the reason away and left the Sleep screen blaming a +// busy re-analysis. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/substrate.dart'; + +int _sec(DateTime t) => t.millisecondsSinceEpoch ~/ 1000; + +/// Band worn [from, to) at 1 Hz: still wrist, resting HR. +void _wear(int from, int to, List ts, List hr, List ax, + List ay, List az) { + for (var t = from; t < to; t++) { + ts.add(t); + hr.add(60); + final rad = (t % 3) * 0.5 * math.pi / 180.0; + ax.add(math.sin(rad)); + ay.add(0.0); + az.add(math.cos(rad)); + } +} + +Substrate _sub(List<(int, int)> worn) { + final ts = [], hr = []; + final ax = [], ay = [], az = []; + for (final (a, b) in worn) { + _wear(a, b, ts, hr, ax, ay, az); + } + final n = ts.length; + return Substrate( + tsSec: ts, + hr: hr, + rrTsMs: const [], + rrMs: const [], + ax: ax, + ay: ay, + az: az, + spo2Red: List.filled(n, 0), + spo2Ir: List.filled(n, 0), + skinTemp: List.filled(n, 0), + skinContact: List.filled(n, 0), + ); +} + +void main() { + final eve = DateTime(2025, 6, 14); + final day = DateTime(2025, 6, 15); + final onset = _sec(DateTime(2025, 6, 14, 23)); + final wake = _sec(DateTime(2025, 6, 15, 7)); + const dayId = '2025-06-15'; + + PhysioDay derive(Substrate sub, String source) => calendarDays( + sub, + override: SleepWindowOverride( + dayId: dayId, + onsetSec: onset, + offsetSec: wake, + source: source, + ), + ).firstWhere((d) => d.date == dayId); + + test('a mostly-unrecorded manual window keeps its source and its reason', + () { + // Worn 18:00–22:00, on the charger 22:00–06:00, worn 06:00–12:00. + final sub = _sub([ + (_sec(DateTime(eve.year, eve.month, eve.day, 18)), + _sec(DateTime(eve.year, eve.month, eve.day, 22))), + (_sec(DateTime(day.year, day.month, day.day, 6)), + _sec(DateTime(day.year, day.month, day.day, 12))), + ]); + final d = derive(sub, 'manual'); + expect(d.sleep.present, isFalse); + expect(d.sleepSource, 'manual'); + expect(d.sleep.absenceReason, isNotNull); + expect(d.flags, contains('NO_SLEEP_DETECTED')); + }); + + test('a manual window with no samples at all says it was unrecorded', () { + // Nothing between 20:00 and 08:00 — the window holds zero seconds. + final sub = _sub([ + (_sec(DateTime(eve.year, eve.month, eve.day, 18)), + _sec(DateTime(eve.year, eve.month, eve.day, 20))), + (_sec(DateTime(day.year, day.month, day.day, 8)), + _sec(DateTime(day.year, day.month, day.day, 12))), + ]); + final d = derive(sub, 'manual'); + expect(d.sleep.present, isFalse); + expect(d.sleepSource, 'manual'); + expect(d.sleep.absenceReason, isNotNull); + }); + + test('a rejected night is unchanged', () { + final sub = _sub([ + (_sec(DateTime(eve.year, eve.month, eve.day, 18)), + _sec(DateTime(day.year, day.month, day.day, 12))), + ]); + final d = derive(sub, 'rejected'); + expect(d.sleep.present, isFalse); + expect(d.sleepSource, 'rejected'); + expect(d.flags, contains('SLEEP_REJECTED')); + }); +} From 7f198b132dad71fa394f486fea745541889c306f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 11:48:00 +0530 Subject: [PATCH 07/41] a night marked not sleep never became today's overnight marking today's night as not sleep flags it SLEEP_REJECTED, but the today freshness scan only took a day as the overnight when it had sleep or NO_SLEEP_DETECTED, and held any windowless today until noon first. home then showed the previous night held over as still being worked out for the rest of the day. a rejected night now counts as settled and as a real overnight. the sleep screen opened without a day also reloaded onto whatever home called last night after the edit, so it jumped to the previous night and never showed the not-sleep confirmation. it now stays on the edited night. --- lib/data/db.dart | 11 +++++-- lib/ui2/screens/sleep_detail.dart | 5 ++++ test/today_no_sleep_overnight_test.dart | 39 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index cd0edbb79..33ca9b03e 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -11537,7 +11537,14 @@ class LocalDb { nowSec: nowSec, )?.wire; } + final flags = decoded['flags']; + // 'Not sleep' is the user's own verdict on the night, so it needs no + // settling and is as final an overnight as a detected absence. Without + // this a rejected today was never the overnight day: Home held the + // previous night over as "still being worked out" for the rest of it. + final rejected = flags is List && flags.contains('SLEEP_REJECTED'); if (dayId == today && + !rejected && !overnightSettled( sleepOffsetSec: wakeSec, dataEdgeSec: edgeSec, @@ -11553,9 +11560,9 @@ class LocalDb { if (latestOvernightDay == null) { final sleep = ((decoded['sleep'] as Map?)?['accounting'] as Map?)?['value']; - final flags = decoded['flags']; final hasSleep = sleep is Map && sleep['tst_sec'] != null; - final noSleep = flags is List && flags.contains('NO_SLEEP_DETECTED'); + final noSleep = rejected || + (flags is List && flags.contains('NO_SLEEP_DETECTED')); if (hasSleep || noSleep) { latestOvernightDay = dayId; latestOvernightComputedAt = (row['computed_at'] as num?)?.toInt(); diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index ccb891d15..49e477395 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -1044,6 +1044,11 @@ class _SleepDetailState extends State with RevisionReload { if (mounted) setState(() => _saving = false); } final before = _source; + // Opened without a day, the screen reloads onto whichever night Home + // treats as last night, and an edit (Not sleep above all) can change + // that answer to another night. Pin the edited night unless the user has + // already stepped away from it. + if (_day == null && _d?.day == day) _day = day; if (mounted) await _load(); if (!mounted) return; // EVERY failure below this screen is silent: the forced re-derive catches diff --git a/test/today_no_sleep_overnight_test.dart b/test/today_no_sleep_overnight_test.dart index 101dad848..cd8101baa 100644 --- a/test/today_no_sleep_overnight_test.dart +++ b/test/today_no_sleep_overnight_test.dart @@ -85,6 +85,45 @@ void main() { reason: "$twoAgo's 77 is not $yesterday's readiness"); }); + test("a night marked 'Not sleep' is today's overnight, not a held-over one", + () async { + final today = todayLabel(); + final now = DateTime.now(); + final yesterday = + todayLabel(DateTime(now.year, now.month, now.day - 1, 12)); + await LocalDb.putDayResult( + dayId: yesterday, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'scalars': {'readiness': 77}, + 'sleep': { + 'accounting': { + 'value': {'tst_sec': 25200}, + }, + }, + }), + windowJson: '{}', + ); + // No window and no data edge: before the fix this was held as unsettled + // until noon, then skipped for lacking NO_SLEEP_DETECTED. + await LocalDb.putDayResult( + dayId: today, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'scalars': {'steps': 300}, + 'flags': ['SLEEP_REJECTED'], + }), + windowJson: '{}', + ); + await LocalDb.refreshComputeFreshness(); + + final t = await LocalRepositoryImpl(getProfileMap: () => {}).getToday(); + final status = t['status'] as Map; + expect(status['overnight_day'], today); + expect(status['overnight_state'], 'ready'); + expect(status['showing_prior_overnight'], false); + }); + test('an import restamps the night getToday reads', () async { final today = todayLabel(); final now = DateTime.now(); From 8bac3d6e88c32c6c632e7e406c33f36c802bdc7a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 12:04:22 +0530 Subject: [PATCH 08/41] don't carry the old night's sleep blocks onto a retimed or rejected night when the second half of a re-derive failed, carry-forward copied every missing block from the previous row, including sleep_periods, naps and the other blocks computed against the main sleep window. after a retime or a 'not sleep' rejection those described the old night: the rejected stretch came back as a sleep period, and a retimed night showed the old period times next to the new hypnogram. the row could also be filed complete and finalized. now the sleep-owned blocks (and nap_min) are withheld when the sleep window differs from the previous row's, and that row stays partial and unfinalized so a later pass computes the new night's blocks. --- lib/compute/derivation_engine.dart | 58 +++++++++++++++++++-- test/carry_forward_detail_test.dart | 78 +++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 576c7bd02..5e8a1ebda 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -5836,10 +5836,13 @@ class DerivationEngine { if (_isRealDayResult(existing)) { final prev = _decodeBundle(existing!['payload_json']); if (prev != null) { - final recovered = carryForwardDetail(prev, bundle); + final sleepMoved = sleepWindowChanged(prev, bundle); + final recovered = + carryForwardDetail(prev, bundle, carrySleep: !sleepMoved); final prevVersion = (existing['algo_version'] as num?)?.toInt(); final outcome = recoveryOutcome( recovered: recovered, + sleepMoved: sleepMoved, prevPartial: (existing['partial'] as num?)?.toInt() == 1, prevVersion: prevVersion, prevFinalized: (existing['finalized'] as num?)?.toInt() == 1, @@ -6222,14 +6225,23 @@ class DerivationEngine { /// explicitly and a null there is a real "we could not measure this today" /// that must survive. Only keys the failed second half never got to add are /// restored — a freshly computed value always wins. + /// + /// [carrySleep] false withholds the blocks the second half derives from the + /// main sleep window ([kSleepOwnedDetailKeys], plus `nap_min`). When the + /// night was retimed or rejected since [prev] was written, those blocks + /// describe the OLD night: carried onto the new window they listed a rejected + /// stretch as a sleep period, and put the old period times beside the new + /// hypnogram. @visibleForTesting static bool carryForwardDetail( Map prev, - Map next, - ) { + Map next, { + bool carrySleep = true, + }) { var carried = false; for (final e in prev.entries) { if (e.key == 'scalars' || e.key == 'series') continue; + if (!carrySleep && kSleepOwnedDetailKeys.contains(e.key)) continue; if (next.containsKey(e.key) || e.value == null) continue; next[e.key] = e.value; carried = true; @@ -6256,6 +6268,7 @@ class DerivationEngine { // only replaced when something was actually carried into it.) Map? merged; for (final e in p.entries) { + if (!carrySleep && sub == 'scalars' && e.key == 'nap_min') continue; if (n.containsKey(e.key) || e.value == null) continue; (merged ??= Map.from(n))[e.key] = e.value; carried = true; @@ -6265,6 +6278,38 @@ class DerivationEngine { return carried; } + /// Second-half bundle keys computed against the main sleep window. Only the + /// offloaded second half writes them, so a failed pass has none of its own. + @visibleForTesting + static const kSleepOwnedDetailKeys = { + 'sleep_periods', + 'sleep_charging', + 'naps', + 'restlessness', + 'restlessness_map', + 'wrist_orientation', + 'advanced_sleep', + 'daytime_hrv', + }; + + /// Whether the main sleep window in [next] (isolate 1's, always present) + /// differs from the one [prev] was derived against — a retimed night, a + /// rejected one, or a cleared override. Both absent is no change. + @visibleForTesting + static bool sleepWindowChanged( + Map prev, + Map next, + ) { + (Object?, Object?) edges(Map b) { + final v = ((b['sleep'] as Map?)?['window'] as Map?)?['value']; + // An absent envelope stores '—', not a window object. + if (v is! Map) return (null, null); + return (v['onset_ms'], v['offset_ms']); + } + + return edges(prev) != edges(next); + } + /// A night staged off a paired device's records (its hypnogram, or our /// window over its HR), not off the primary band's own rows. Not /// 'auto_fallback' by itself: the band's own HR-led night carries it too. @@ -6354,6 +6399,10 @@ class DerivationEngine { /// those blocks are computed differently now. A cross-version carry therefore /// stays partial and unfinalized, so a later pass recomputes it for real /// instead of locking last version's curves in under this version's number. + /// + /// [sleepMoved]: the night changed since [prevPartial]'s row, so its sleep + /// blocks were withheld and the new night has none yet. That row is thinner + /// than the one it replaces and must stay open for a pass that completes. @visibleForTesting static ({bool partial, bool finalized}) recoveryOutcome({ required bool recovered, @@ -6361,9 +6410,10 @@ class DerivationEngine { required int? prevVersion, required bool prevFinalized, required bool finalizedByAge, + bool sleepMoved = false, }) { final sameVersion = prevVersion == kAlgoVersion; - if (!recovered || prevPartial || !sameVersion) { + if (!recovered || prevPartial || !sameVersion || sleepMoved) { // Stays partial, but keep whatever the caller had already decided about // finalizing: an IMPORT force-finalizes even a partial day, because there // is no stored raw to ever recompute it from. diff --git a/test/carry_forward_detail_test.dart b/test/carry_forward_detail_test.dart index deb983a31..773cb90b5 100644 --- a/test/carry_forward_detail_test.dart +++ b/test/carry_forward_detail_test.dart @@ -104,4 +104,82 @@ void main() { expect(next['scalars'], same(scalars)); expect(next['series'], same(series)); }); + + // A retimed or rejected night re-derives the day. If that pass's second half + // fails, the previous row's sleep blocks describe the OLD night and must not + // be grafted onto the new window. + group('a changed night does not carry the old night\'s sleep blocks', () { + Map withWindow(Map b, int? on, int? off) { + final sleep = Map.from(b['sleep'] as Map); + sleep['window'] = { + 'value': on == null ? '\u2014' : {'onset_ms': on, 'offset_ms': off}, + }; + return b..['sleep'] = sleep; + } + + const oldOn = 1786750200 * 1000, oldOff = 1786775400 * 1000; + Map prevRow() { + final prev = withWindow(_storedCompleteRow(), oldOn, oldOff); + prev['sleep_periods'] = { + 'periods': [ + {'is_main': true, 'onset_ts': oldOn ~/ 1000, 'wake_ts': oldOff ~/ 1000}, + ], + }; + prev['restlessness'] = {'value': 0.2}; + prev['hr_ceiling'] = {'value': 170}; + return prev; + } + + for (final (label, on, off) in [ + ('retimed', 1786744800 * 1000, 1786777200 * 1000), + ('rejected', null, null), + ]) { + test(label, () { + final prev = prevRow(); + final next = withWindow(_freshBundle(), on, off); + final moved = DerivationEngine.sleepWindowChanged(prev, next); + expect(moved, isTrue); + expect( + DerivationEngine.carryForwardDetail(prev, next, carrySleep: !moved), + isTrue); + for (final k in DerivationEngine.kSleepOwnedDetailKeys) { + expect(next.containsKey(k), isFalse, reason: k); + } + expect((next['scalars'] as Map).containsKey('nap_min'), isFalse); + // Detail that does not hang off the night still comes across. + expect(next['hr_ceiling'], prev['hr_ceiling']); + expect((next['series'] as Map)['hrv_day'], + (prev['series'] as Map)['hrv_day']); + + final outcome = DerivationEngine.recoveryOutcome( + recovered: true, + prevPartial: false, + prevVersion: kAlgoVersion, + prevFinalized: true, + finalizedByAge: false, + sleepMoved: moved, + ); + expect(outcome.partial, isTrue); + expect(outcome.finalized, isFalse); + }); + } + + test('the same night still carries its sleep blocks', () { + final prev = prevRow(); + final next = withWindow(_freshBundle(), oldOn, oldOff); + expect(DerivationEngine.sleepWindowChanged(prev, next), isFalse); + DerivationEngine.carryForwardDetail(prev, next); + expect(next['sleep_periods'], prev['sleep_periods']); + expect(next['naps'], prev['naps']); + expect((next['scalars'] as Map)['nap_min'], 25.0); + }); + + test('no night either side is no change', () { + expect( + DerivationEngine.sleepWindowChanged( + withWindow(_storedCompleteRow(), null, null), + withWindow(_freshBundle(), null, null)), + isFalse); + }); + }); } From bf4deaacb219e6520fb19bc0f502b72f68b1d7df Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 12:19:13 +0530 Subject: [PATCH 09/41] a derive job queued mid-refresh could sit with no timer armed an enqueue's snapshot refresh returned straight away if another refresh was already in flight, so its _arm read the old "nothing pending" and armed nothing. when the in-flight refresh was one that never arms (an offload or foreground flip with an empty queue), the job waited for the next unrelated event. a refresh asked for mid-flight now reruns the query and every caller awaits the final pass. --- lib/compute/derive_scheduler.dart | 46 +++++++++----- test/derive_scheduler_refresh_race_test.dart | 66 ++++++++++++++++++++ 2 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 test/derive_scheduler_refresh_race_test.dart diff --git a/lib/compute/derive_scheduler.dart b/lib/compute/derive_scheduler.dart index fbbfc7ce9..c0cf67b8b 100644 --- a/lib/compute/derive_scheduler.dart +++ b/lib/compute/derive_scheduler.dart @@ -78,7 +78,15 @@ class DeriveScheduler { bool _pendingLight = false; bool _pendingHeavy = false; Timer? _timer; - bool _refreshing = false; + + // The in-flight snapshot refresh, and whether another was asked for while + // it ran. A request that lands mid-refresh can't just return: the caller + // (an enqueue, say) arms its settle timer off the snapshot it awaited, and + // the in-flight query may predate its insert — or the in-flight refresh is + // one that never arms. Either way the job sat queued with no timer until + // some unrelated event re-armed the scheduler. + Future? _refreshing; + bool _refreshAgain = false; Future init() async { await LocalDb.recoverComputeJobs(); @@ -262,21 +270,31 @@ class DeriveScheduler { } } - Future _refreshSnapshot() async { - if (_refreshing) return; - _refreshing = true; + Future _refreshSnapshot() { + final inFlight = _refreshing; + if (inFlight != null) { + _refreshAgain = true; + return inFlight; + } + return _refreshing = _refreshLoop(); + } + + Future _refreshLoop() async { try { - final jobs = await LocalDb.computeJobs(state: 'queued', limit: 50); - _pendingLight = jobs.any( - (job) => - job['type']?.toString() == 'derive_light', - ); - _pendingHeavy = jobs.any( - (job) => - job['type']?.toString() == 'derive_heavy', - ); + do { + _refreshAgain = false; + final jobs = await LocalDb.computeJobs(state: 'queued', limit: 50); + _pendingLight = jobs.any( + (job) => + job['type']?.toString() == 'derive_light', + ); + _pendingHeavy = jobs.any( + (job) => + job['type']?.toString() == 'derive_heavy', + ); + } while (_refreshAgain && !_disposed); } finally { - _refreshing = false; + _refreshing = null; onChanged(); } } diff --git a/test/derive_scheduler_refresh_race_test.dart b/test/derive_scheduler_refresh_race_test.dart new file mode 100644 index 000000000..213bf5e87 --- /dev/null +++ b/test/derive_scheduler_refresh_race_test.dart @@ -0,0 +1,66 @@ +// A refresh of the scheduler's pending snapshot that is already in flight when +// an enqueue asks for its own must not swallow that request: the enqueue arms +// its settle timer off the snapshot it awaited, and a refresh that returned +// early left it reading "nothing pending" with the job sitting queued. + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/compute/derive_scheduler.dart'; +import 'package:openstrap_edge/data/db.dart'; + +import 'support/app_state_derive_harness.dart'; + +const _db = 'openstrap_derive_scheduler_refresh_race.db'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() => deriveDbSetUp(_db)); + tearDownAll(() => deriveDbTearDown(_db)); + setUp(deriveDbReset); + + Future<({int runs, DeriveScheduler s})> scenario({required bool flip}) async { + final db = await LocalDb.instance; + var runs = 0; + final s = DeriveScheduler( + run: ({required DeriveJobKind kind}) async => runs++, + log: (_) {}, + onChanged: () {}, + lightSettle: const Duration(milliseconds: 50), + ); + addTearDown(s.dispose); + // Hold the database so the enqueue transaction, and anything issued after + // it, queue up behind this one in a known order. + final gate = Completer(); + final holder = db.transaction((txn) => gate.future); + await Future.delayed(const Duration(milliseconds: 20)); + s.markStoredData(); + await Future.delayed(const Duration(milliseconds: 20)); + if (flip) { + // An offload settling while the enqueue is still in its transaction: + // with nothing pending yet, _arm starts a refresh that arms nothing. + s.setOffloadActive(true); + s.setOffloadActive(false); + } + gate.complete(); + await holder; + await until(() => runs > 0, + within: const Duration(seconds: 3), what: 'the queued job drained'); + return (runs: runs, s: s); + } + + test('a queued job drains with no concurrent refresh', () async { + final r = await scenario(flip: false); + expect(r.runs, 1); + }); + + test('a queued job still drains when an idle refresh was in flight', + () async { + final r = await scenario(flip: true); + expect(r.runs, 1); + await until(() => !r.s.pendingLight, what: 'snapshot caught up'); + expect(await LocalDb.computeJobs(state: 'queued'), isEmpty); + }); +} From 0f868b772fb33792f9d916d742fca67991c90422 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 12:26:03 +0530 Subject: [PATCH 10/41] a night marked 'not sleep' came back as a nap to review rejecting a detected night drops the main window, so nap detection had nothing to exclude and found the same still, low-hr stretch again. it was then offered as "did you nap?", sometimes with a notification, for the time the user had just said was not sleep. the previous day's buffered window could propose it too, since tonight's sleep is absent for a rejected night. nap suggestions overlapping a rejected window (this night's or tonight's) are now dropped. the one-second placeholder stored when there was no detected window is ignored. --- lib/compute/derivation_engine.dart | 37 +++++++++++++++++++++ lib/state/app_state.dart | 8 ++--- test/nap_attribution_test.dart | 53 ++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 5e8a1ebda..8236ebc11 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -5677,6 +5677,7 @@ class DerivationEngine { dataNowSec: dataNowSec, tonightSleepOnsetSec: day.tonightSleepOnsetSec, tonightSleepOffsetSec: day.tonightSleepOffsetSec, + rejectedSleepSpans: await _rejectedSleepSpans(day.date, day.endSec), ); await debugSecondHalfHook?.call(day.date); final blocks = @@ -9276,6 +9277,7 @@ class DerivationEngine { int? napExcludeEndSec, int tonightSleepOnsetSec = 0, int tonightSleepOffsetSec = 0, + List> rejectedSleep = const [], }) { try { final n = s.length; @@ -9360,6 +9362,15 @@ class DerivationEngine { t0 + nap.endSec > tonightSleepOnsetSec) { return false; } + // A night the user marked 'Not sleep' leaves no main window behind, + // so the detector finds the same still, low-HR stretch again and + // would ask "did you nap?" about the time they just said was not + // sleep. + for (final r in rejectedSleep) { + if (t0 + nap.startSec < r[1] && t0 + nap.endSec > r[0]) { + return false; + } + } if (attributionEndSec == null) return true; return t0 + nap.startSec < attributionEndSec; }).toList(); @@ -9663,6 +9674,7 @@ class DerivationEngine { napExcludeEndSec: inp.napExcludeEndSec ?? offset, tonightSleepOnsetSec: inp.tonightSleepOnsetSec, tonightSleepOffsetSec: inp.tonightSleepOffsetSec, + rejectedSleep: inp.rejectedSleepSpans, ); bundlePatch['sleep_periods'] = _sleepPeriods( onset, @@ -10259,6 +10271,24 @@ class DerivationEngine { Substrate s, int endSec) => _hrrForBout(s, endSec); + /// Main-sleep windows the user rejected for this day's night and for + /// tonight's (the next label's, which can begin inside this day's nap + /// window). Read on the main isolate; the worker has no database. + static Future>> _rejectedSleepSpans( + String date, int dayEndSec) async { + final out = >[]; + for (final label in {date, localDateLabel(dayEndSec)}) { + final ov = await LocalDb.getSleepOverride(label); + if (ov == null || ov['source'] != 'rejected') continue; + final on = (ov['onset_ts'] as num).toInt(); + final off = (ov['offset_ts'] as num).toInt(); + // A reject with no detected window stores a one-second placeholder at + // midnight; it marks nothing and must not knock out a nap spanning it. + if (off - on > 60) out.add([on, off]); + } + return out; + } + /// Test seam for [_attachNaps] — the day-boundary attribution rules (drop /// tomorrow's leading nap, drop yesterday's trailing one) decide which day a /// nap's minutes are credited to, and are cheap to state directly. @@ -10278,6 +10308,7 @@ class DerivationEngine { int? napExcludeEndSec, int tonightSleepOnsetSec = 0, int tonightSleepOffsetSec = 0, + List> rejectedSleep = const [], }) => _attachNaps( bundle, @@ -10294,6 +10325,7 @@ class DerivationEngine { napExcludeEndSec: napExcludeEndSec, tonightSleepOnsetSec: tonightSleepOnsetSec, tonightSleepOffsetSec: tonightSleepOffsetSec, + rejectedSleep: rejectedSleep, ); void _log(String m) { @@ -10415,6 +10447,10 @@ class _DayBlocksInput { /// See `PreparedDerivationDay.tonightSleepOnsetSec`. 0 = none. final int tonightSleepOnsetSec; final int tonightSleepOffsetSec; + + /// Windows the user marked 'Not sleep' (this night and tonight's), as + /// `[startSec, endSec]`. Nap suggestions overlapping one are dropped. + final List> rejectedSleepSpans; const _DayBlocksInput({ required this.daySub, required this.napSub, @@ -10436,6 +10472,7 @@ class _DayBlocksInput { this.quietHrrHistory = const [], required this.savedSessions, this.napEdits = const [], + this.rejectedSleepSpans = const [], required this.wristOffSpans, required this.chargingSpans, required this.mainTstMin, diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index ce64d6513..2dfc31a77 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2563,10 +2563,10 @@ class AppState extends ChangeNotifier { 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. + // The rejected window is stored like a rejected nap. Staging never reads + // it (it is skipped outright); nap detection does, so the same stretch is + // not offered back as a nap. An absent detected window falls back to a + // one-second placeholder rather than blocking the rejection. final fallback = DateTime.parse(date).millisecondsSinceEpoch ~/ 1000; await _requireSleepSubstrate(date, sleepSource: 'rejected'); await LocalDb.putSleepOverride( diff --git a/test/nap_attribution_test.dart b/test/nap_attribution_test.dart index 08430c200..9e6a0ba9c 100644 --- a/test/nap_attribution_test.dart +++ b/test/nap_attribution_test.dart @@ -475,4 +475,57 @@ void main() { expect(attach(onset: bed, offset: up), isEmpty); }); }); + + group("a night marked 'Not sleep' is not re-proposed as a nap", () { + const midnight = 1750000800; + // The #566 shape: a still evening block 17:51-20:00 the detector booked as + // the night, which the user then rejected. A rejected night leaves no main + // window (onset/offset 0), so only the rejected span can stop it. + const from = 17 * 3600 + 51 * 60; + const to = 20 * 3600; + final s = _daySubstrate( + startSec: midnight, + lengthSec: 23 * 3600, + napFromSec: from, + napToSec: to, + ); + + List> attach(List> rejected) { + final candidates = >[]; + DerivationEngine.debugAttachNaps( + {}, + {}, + s, + 0, + 0, + candidates: candidates, + attributionStartSec: midnight, + attributionEndSec: midnight + 86400, + rejectedSleep: rejected, + ); + return candidates; + } + + test('without the rejected span the bout comes back as a nap', () { + expect(attach(const []), hasLength(1)); + }); + + test('with it, no nap is suggested over the rejected stretch', () { + expect( + attach(const [ + [midnight + from, midnight + to], + ]), + isEmpty, + ); + }); + + test('a rejected span elsewhere leaves a real nap alone', () { + expect( + attach(const [ + [midnight + 2 * 3600, midnight + 6 * 3600], + ]), + hasLength(1), + ); + }); + }); } From c6513de50bf01f6ace9503cb63db0b9b8840fdfd Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 12:32:43 +0530 Subject: [PATCH 11/41] strain rescale backfill cutoff walked back in absolute hours the cutoff for days that still have raw was data edge minus N*24 h from local midnight. across a 23 h spring-forward day that lands at 23:00 the day before, so the cutoff came out a day early and one already-pruned day was skipped as still having raw. the backfill is one-shot, so that day stayed on the old strain scale for good. the cutoff now counts calendar days, the same way the pruner cuts. (cherry picked from commit bb3946cc23e45e7b2dd81ca612e84b2fe92e081c) --- lib/compute/strain_backfill.dart | 19 +++++++++++++++---- test/strain_rescale_backfill_test.dart | 20 +++++++++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/compute/strain_backfill.dart b/lib/compute/strain_backfill.dart index c3b3e5b08..4ee91d72b 100644 --- a/lib/compute/strain_backfill.dart +++ b/lib/compute/strain_backfill.dart @@ -31,6 +31,7 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:openstrap_analytics/onehz.dart' as ana; import '../data/day_label.dart'; @@ -136,7 +137,7 @@ Future backfillStrainScale({ final days = [ for (final r in strainRows) ?(r['date'] as String?), ]..sort(); - final cutoff = _shiftDays(days.last, -rawRetentionDays); + final cutoff = strainRescaleCutoff(days.last); var seriesDays = 0; var bundleDays = 0; @@ -280,6 +281,16 @@ Map? _decode(Object? json) { } } -/// Shift a 'YYYY-MM-DD' label by [days] calendar days. -String _shiftDays(String day, int days) => - dayLabelOf(DateTime.parse(day).add(Duration(days: days))); +/// The first day still treated as having raw, [rawRetentionDays] CALENDAR days +/// before the data edge [lastDay]. +/// +/// Calendar arithmetic, matching the pruner's cut on local day labels. An +/// absolute `Duration` walk back from local midnight across a 23 h +/// spring-forward day lands at 23:00 the day before, so the cutoff came out a +/// day early and that day — already pruned — was skipped as "still has raw", +/// then the one-shot marked itself done and left it on the old scale for good. +/// A malformed label falls back to itself, which skips every day: leaving a +/// day for a re-derive is the safe side. +@visibleForTesting +String strainRescaleCutoff(String lastDay) => + dayLabelBefore(lastDay, rawRetentionDays) ?? lastDay; diff --git a/test/strain_rescale_backfill_test.dart b/test/strain_rescale_backfill_test.dart index ef7c8fd55..751844356 100644 --- a/test/strain_rescale_backfill_test.dart +++ b/test/strain_rescale_backfill_test.dart @@ -14,8 +14,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; -import 'package:openstrap_edge/compute/derivation_engine.dart' show kAlgoVersion; +import 'package:openstrap_edge/compute/derivation_engine.dart' + show kAlgoVersion, rawRetentionDays; import 'package:openstrap_edge/compute/strain_backfill.dart'; +import 'package:openstrap_edge/data/day_label.dart'; import 'package:openstrap_edge/data/db.dart'; /// Seed a derived day the way the engine would have before the rescale. @@ -264,4 +266,20 @@ void main() { expect(await seriesValue('strain', '2026-06-02'), closeTo(12.79, 1e-9)); }); }); + + group('strainRescaleCutoff — calendar days back from the data edge', () { + test('counts calendar days across a spring-forward week', () { + // 2027-03-14 is the US spring-forward day (23 h). From local midnight on + // 2027-03-16 an absolute walk of rawRetentionDays * 24 h lands at 23:00 + // the day before the calendar answer, which the pruner has already + // cleared. Fails before the fix under TZ=America/New_York. + const edge = '2027-03-16'; + final expected = dayLabelOf(DateTime(2027, 3, 16 - rawRetentionDays)); + expect(strainRescaleCutoff(edge), expected); + }); + + test('a malformed edge label skips everything rather than throwing', () { + expect(strainRescaleCutoff('bad'), 'bad'); + }); + }); } From 82ab27a36fe2d302261447db1d2166ffcebd37f7 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:17:17 +0530 Subject: [PATCH 12/41] logging a nap accepted a window that hadn't happened yet the naps screen only checked duration and overlap, so accepting the default 14:00-15:00 pickers in the morning saved a nap that was still in the future. it credited today's sleep need with an hour not slept and suppressed the real nap once it was detected. a manual nap now has to have ended before it can be logged, the same rule the detected-nap editor already applies. (cherry picked from commit 19a13d98a746a670ca39e4156073d1b0936896dd) --- lib/compute/nap_edits.dart | 5 +++++ lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 4 ++++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/ui2/screens/naps.dart | 6 ++++++ test/nap_edits_test.dart | 13 +++++++++++++ 10 files changed, 34 insertions(+) diff --git a/lib/compute/nap_edits.dart b/lib/compute/nap_edits.dart index cc042c9de..ccc49887d 100644 --- a/lib/compute/nap_edits.dart +++ b/lib/compute/nap_edits.dart @@ -64,6 +64,11 @@ bool manualNapWindowIsValid(int startSec, int endSec) { return d >= kMinManualNapSec && d <= kMaxManualNapSec; } +/// Whether a logged nap has already finished at [nowSec]. A window that ends +/// later is a nap that has not happened yet: it would credit today's sleep need +/// with time not slept, and suppress the real nap when the detector finds it. +bool manualNapWindowHasEnded(int endSec, int nowSec) => endSec <= nowSec; + /// Apply [edits] to the detector's [detected] naps. /// /// A rejected window removes every detected nap it OVERLAPS, not only one that diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 16b1271dc..880d220a9 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -421,6 +421,7 @@ "napsWokeUpHelp": "AUFGEWACHT UM", "napsInvalidWindow": "Ein Nickerchen dauert zwischen 5 Minuten und 6 Stunden. Alles Längere ist Schlaf und gehört zur Nacht, wo die Schlafphasen ausgewertet werden.", "napsOverlap": "Das überschneidet sich mit einem Nickerchen, das an diesem Tag schon eingetragen ist. Entferne zuerst das andere, damit dieselbe Zeit nicht doppelt zählt.", + "napsInFuture": "Dieses Nickerchen ist noch nicht vorbei. Trage es ein, wenn du wach bist.", "napsNotReanalysed": "Der Tag wurde nicht neu ausgewertet, weil gerade schon eine andere Auswertung lief. Deine Änderung ist gespeichert und wird beim nächsten Mal berücksichtigt.", "napsTitle": "Nickerchen", "napsNoReadingTitle": "Keine Daten zu Nickerchen an diesem Tag", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 187a856d5..d07bbbe1f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1951,6 +1951,10 @@ "@napsOverlap": { "description": "Validation error when a manually logged nap overlaps an existing one." }, + "napsInFuture": "That nap hasn't finished yet. Log it once you're awake.", + "@napsInFuture": { + "description": "Validation error when a manually logged nap ends after the current time." + }, "napsNotReanalysed": "The day was not re-analysed — another analysis was already running. Your edit is saved and will apply next time.", "@napsNotReanalysed": { "description": "Fallback error when a nap edit saved but the day could not be re-derived because another analysis was already running." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5c971dc7a..3781d0f1d 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -393,6 +393,7 @@ "napsWokeUpHelp": "CUÁNDO TE DESPERTASTE", "napsInvalidWindow": "Una siesta dura entre 5 minutos y 6 horas. Cualquier cosa más larga es un sueño nocturno y pertenece a la noche, donde se pueden leer las fases.", "napsOverlap": "Eso se superpone con una siesta ya registrada este día. Elimina esa primero, en lugar de contar la misma hora dos veces.", + "napsInFuture": "Esa siesta todavía no ha terminado. Regístrala cuando te despiertes.", "napsNotReanalysed": "El día no se ha vuelto a analizar: ya había otro análisis en curso. Tu edición está guardada y se aplicará la próxima vez.", "napsTitle": "Siestas", "napsNoReadingTitle": "Sin datos de siestas para este día", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index a2b74513e..dfbc075dc 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -393,6 +393,7 @@ "napsWokeUpHelp": "QUAND VOUS VOUS ÊTES RÉVEILLÉ", "napsInvalidWindow": "Une sieste dure entre 5 minutes et 6 heures. Au-delà, il s'agit d'un sommeil, et cela appartient à la nuit, où les phases peuvent être lues.", "napsOverlap": "Cela chevauche une sieste déjà enregistrée ce jour-là. Supprimez d'abord celle-ci plutôt que de compter la même heure deux fois.", + "napsInFuture": "Cette sieste n'est pas encore terminée. Enregistrez-la une fois réveillé.", "napsNotReanalysed": "La journée n'a pas été réanalysée — une autre analyse était déjà en cours. Votre modification est enregistrée et s'appliquera la prochaine fois.", "napsTitle": "Siestes", "napsNoReadingTitle": "Aucune donnée de sieste pour cette journée", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a4dc0ce8b..ea16f1440 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -393,6 +393,7 @@ "napsWokeUpHelp": "आप कब जागे", "napsInvalidWindow": "झपकी 5 मिनट से 6 घंटे के बीच होती है। इससे अधिक लंबी अवधि नींद मानी जाती है, और वह रात के हिस्से में आती है जहाँ चरणों को पढ़ा जा सकता है।", "napsOverlap": "यह इस दिन पहले से मौजूद एक झपकी से टकराता है। उसे पहले हटाएँ, बजाय इसके कि उसी घंटे को दो बार गिना जाए।", + "napsInFuture": "यह झपकी अभी खत्म नहीं हुई है। जागने के बाद इसे दर्ज करें।", "napsNotReanalysed": "दिन का पुनः विश्लेषण नहीं हुआ — पहले से ही एक अन्य विश्लेषण चल रहा था। आपका बदलाव सहेज लिया गया है और अगली बार लागू होगा।", "napsTitle": "झपकियाँ", "napsNoReadingTitle": "इस दिन के लिए कोई झपकी रीडिंग नहीं", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 052cdbde8..5073c8288 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -821,6 +821,7 @@ "napsWokeUpHelp": "КОГДА ВЫ ПРОСНУЛИСЬ", "napsInvalidWindow": "Дневной сон длится от 5 минут до 6 часов. Более долгий сон относится к ночному, где можно просмотреть его стадии.", "napsOverlap": "Этот период пересекается с уже записанным дневным сном. Сначала удалите прежнюю запись, чтобы не учитывать одно время дважды.", + "napsInFuture": "Этот дневной сон ещё не закончился. Запишите его, когда проснётесь.", "napsNotReanalysed": "Повторный анализ дня не выполнен: уже идёт другой анализ. Изменение сохранено и будет учтено в следующий раз.", "napsTitle": "Дневной сон", "napsNoReadingTitle": "Нет данных о дневном сне за этот день", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f3ebeb2ed..56939f8a5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -393,6 +393,7 @@ "napsWokeUpHelp": "醒来时间", "napsInvalidWindow": "小睡的时长应在 5 分钟到 6 小时之间。超过这个时长应算作夜间睡眠,属于可以读取睡眠阶段的夜晚记录。", "napsOverlap": "该时间段与当天已有的一次小睡重叠。请先删除那一次记录,而不是把同一小时计算两次。", + "napsInFuture": "这次小睡还没有结束。醒来后再记录。", "napsNotReanalysed": "当天未被重新分析——已有另一项分析正在运行。你的修改已保存,将在下次生效。", "napsTitle": "小睡", "napsNoReadingTitle": "当天没有小睡数据", diff --git a/lib/ui2/screens/naps.dart b/lib/ui2/screens/naps.dart index 56c371811..306c31145 100644 --- a/lib/ui2/screens/naps.dart +++ b/lib/ui2/screens/naps.dart @@ -226,6 +226,12 @@ class _NapsScreenState extends State { 'stages can be read.'); return; } + if (!manualNapWindowHasEnded( + e, DateTime.now().millisecondsSinceEpoch ~/ 1000)) { + setState(() => _failed = l?.napsInFuture ?? + "That nap hasn't finished yet. Log it once you're awake."); + return; + } if (napOverlapsExisting(s, e, _d?.naps ?? const [])) { setState(() => _failed = l?.napsOverlap ?? 'That overlaps a nap already on this day. ' diff --git a/test/nap_edits_test.dart b/test/nap_edits_test.dart index a4a913e9d..2b745b4bf 100644 --- a/test/nap_edits_test.dart +++ b/test/nap_edits_test.dart @@ -224,6 +224,19 @@ void main() { }); }); + group('manualNapWindowHasEnded', () { + test('accepts a nap that is over', () { + expect(manualNapWindowHasEnded(14 * hour, 15 * hour), isTrue); + expect(manualNapWindowHasEnded(15 * hour, 15 * hour), isTrue); + }); + + test('rejects a window that ends after now', () { + // The default 14:00-15:00 pickers accepted at 09:30. + expect(manualNapWindowHasEnded(15 * hour, 9 * hour + 1800), isFalse); + expect(manualNapWindowHasEnded(15 * hour + 1, 15 * hour), isFalse); + }); + }); + group('napOverlapsExisting', () { final existing = [nap(13 * hour, 14 * hour)]; From 9f91cebec46857823fd4b33a6ee4602bc32b7459 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 12:33:10 +0530 Subject: [PATCH 13/41] cycle-phase flag counted a day short across spring-forward the luteal flag parsed cycle-start and night labels as local midnight, so any span holding the 23 h spring-forward day floored one day low in inDays. a cycle logged across march came out a day short and the night at its midpoint lost (or gained) the second-half annotation on the skin-temp flag. labels are parsed as utc midnight now, the same way _isNextDay already does. (cherry picked from commit ca8ef62a9f1953eaa0913e4377eaadc6d95e5989) --- lib/compute/crossday_pipeline.dart | 8 ++- test/luteal_flags_dst_test.dart | 82 ++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 test/luteal_flags_dst_test.dart diff --git a/lib/compute/crossday_pipeline.dart b/lib/compute/crossday_pipeline.dart index 7daef1fd1..644beb824 100644 --- a/lib/compute/crossday_pipeline.dart +++ b/lib/compute/crossday_pipeline.dart @@ -747,8 +747,12 @@ bool _isFreeDay(String? date) { /// four-week luteal phase — it stops counting. List _lutealFlags(List dates, List cycleStartDates) { final out = List.filled(dates.length, false); + // Labels parsed as UTC midnight, as in [_isNextDay]: at local midnight a + // span holding the 23 h spring-forward day floors one day short in `inDays`, + // which shortens that cycle and moves its midpoint night. + DateTime? day(String s) => DateTime.tryParse('${s}T00:00:00Z'); final starts = [ - for (final s in cycleStartDates) ?DateTime.tryParse(s), + for (final s in cycleStartDates) ?day(s), ]..sort(); if (starts.isEmpty) return out; @@ -759,7 +763,7 @@ List _lutealFlags(List dates, List cycleStartDates) { final medianGap = _median(gaps); for (var i = 0; i < dates.length; i++) { - final d = DateTime.tryParse(dates[i]); + final d = day(dates[i]); if (d == null) continue; // Nearest PRECEDING start (inclusive: the start day is cycle day 1). var si = -1; diff --git a/test/luteal_flags_dst_test.dart b/test/luteal_flags_dst_test.dart new file mode 100644 index 000000000..410f3e858 --- /dev/null +++ b/test/luteal_flags_dst_test.dart @@ -0,0 +1,82 @@ +// The cycle-phase flag counts calendar days between logged cycle starts. Parsed +// as local midnight, a span that holds the 23 h spring-forward day is an hour +// short of whole days and `inDays` floors it one day low, so a cycle measured +// across March came out a day short and the night at its midpoint flipped. The +// process zone is moved to America/New_York with setenv+tzset (see +// day_window_dst_test.dart) so this fails without the fix on any host. + +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/crossday_pipeline.dart'; + +typedef _SetenvNative = Int32 Function(Pointer, Pointer, Int32); +typedef _SetenvDart = int Function(Pointer, Pointer, int); +typedef _UnsetenvNative = Int32 Function(Pointer); +typedef _UnsetenvDart = int Function(Pointer); +typedef _TzsetNative = Void Function(); +typedef _TzsetDart = void Function(); + +void _setProcessTz(String? tz) { + final lib = DynamicLibrary.process(); + final key = 'TZ'.toNativeUtf8(); + try { + if (tz == null) { + lib.lookupFunction<_UnsetenvNative, _UnsetenvDart>('unsetenv')(key); + } else { + final value = tz.toNativeUtf8(); + lib.lookupFunction<_SetenvNative, _SetenvDart>('setenv')(key, value, 1); + calloc.free(value); + } + lib.lookupFunction<_TzsetNative, _TzsetDart>('tzset')(); + } finally { + calloc.free(key); + } +} + +/// 30 nights ending 2024-01-30, each with a skin-temp reading so the +/// temperature flag has a newest night to publish. +List> _days() { + final out = >[]; + for (var i = 0; i < 30; i++) { + final d = DateTime.utc(2024, 1, 1).add(Duration(days: i)); + final wobble = (i % 5) - 2; + out.add({ + 'date': d.toIso8601String().substring(0, 10), + 'skin_temp_z': wobble * 0.4, + 'skin_temp_adc': 3000.0 + wobble * 8.0, + }); + } + return out; +} + +bool _lutealOn(List starts) { + final out = buildCrossDayBundle(_days(), const {}, cycleStartDates: starts); + return ((out['temp_illness'] as Map)['luteal'] as bool?) ?? false; +} + +void main() { + final originalTz = Platform.environment['TZ']; + setUpAll(() => _setProcessTz('America/New_York')); + tearDownAll(() => _setProcessTz(originalTz)); + + test('a cycle spanning spring-forward keeps its full length', () { + expect( + DateTime(2023, 3, 12, 12).difference(DateTime(2023, 3, 12)), + const Duration(hours: 11), + reason: 'setenv(TZ)+tzset() did not apply; the test would be vacuous', + ); + // 2023-02-26 → 2023-03-25 is 27 days and holds 2023-03-12, the US + // spring-forward day. That single completed gap is her median length, so + // the open cycle from 2024-01-17 is 27 days long and the newest night, + // 2024-01-30, is day 13 of it: 13 < 13.5, first half. + expect( + _lutealOn(const ['2023-02-26', '2023-03-25', '2024-01-17']), + isFalse, + ); + // Same shape a day later: day 14 of 27 is the second half. + expect(_lutealOn(const ['2023-02-26', '2023-03-25', '2024-01-16']), isTrue); + }); +} From 5937e11d921c1d9bf4f84c4c04cab46d0be1392a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:35:54 +0530 Subject: [PATCH 14/41] stop 'fix the times' from wiping an old workout's score retiming rescored the window only from decoded_onehz, which is pruned after a few days and never existed for a whoop import. on an older session the rescore came back empty and the replace-write blanked its strain, calories, max/avg hr, zones and frozen trace for good, then told the user no heart rate was recorded. a scored session with no 1 hz heart rate left over the new window is now refused before the health store or the row is touched. (cherry picked from commit 96e3af6c3840dcc4b69c65d7fd8b1c37547a5c96) --- lib/compute/manual_session.dart | 10 ++++++ lib/data/local_repository_impl.dart | 17 +++++++++++ test/manual_workout_repo_test.dart | 47 +++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/lib/compute/manual_session.dart b/lib/compute/manual_session.dart index 5dabed301..f70006233 100644 --- a/lib/compute/manual_session.dart +++ b/lib/compute/manual_session.dart @@ -61,6 +61,13 @@ enum ManualWindowError { /// The window intersects one already in the log. Overlap would double-count /// the same minutes and confuse `savedSpans` auto-detect exclusion. overlapsExisting, + + /// Retiming a scored session whose new window has no 1 Hz heart rate left + /// behind it (pruned past retention, or never on the band — a WHOOP import). + /// The rescore would come back empty and the replace-write would blank the + /// banked strain, calories, HR, zones and trace with nothing to rebuild + /// them from, so the edit is refused instead. + scoreNotRecomputable, } extension ManualWindowErrorMessage on ManualWindowError { @@ -73,6 +80,9 @@ extension ManualWindowErrorMessage on ManualWindowError { ManualWindowError.inFuture => "That window hasn't happened yet.", ManualWindowError.overlapsExisting => 'That overlaps a workout already in your log.', + ManualWindowError.scoreNotRecomputable => + "The heart rate behind this workout is no longer stored, so moving " + "its times would erase its strain and calories.", }; } diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 403d8089a..dc440046b 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -2975,6 +2975,16 @@ class LocalRepositoryImpl extends LocalRepository { if (existing['status'] == 'live') { throw StateError('setWorkoutWindow: session $id is still live'); } + // The rescore reads only the 1 Hz substrate, which is pruned after + // `rawRetentionDays` and never existed for an imported session. With none + // over the new window the replace-write would land every banked figure as + // null — strain, calories, HR, zones, the frozen trace — and nothing could + // rebuild them. Refuse before touching the Health store or the row. + if (_hasBankedScore(existing) && + (await LocalDb.hrSamplesInRange(startTs, endTs)).isEmpty) { + throw const ManualWindowException( + ManualWindowError.scoreNotRecomputable); + } // 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 @@ -2995,6 +3005,13 @@ class LocalRepositoryImpl extends LocalRepository { ); } + static bool _hasBankedScore(Map row) => + row['strain'] != null || + row['calories'] != null || + row['max_hr'] != null || + row['avg_hr'] != null || + row['trace_json'] != null; + /// Shared writer for both user-timed paths: score the window from the 1 Hz /// substrate, persist, and retire any auto-detect suggestion it covers. /// diff --git a/test/manual_workout_repo_test.dart b/test/manual_workout_repo_test.dart index 234640b3b..a253f71b4 100644 --- a/test/manual_workout_repo_test.dart +++ b/test/manual_workout_repo_test.dart @@ -657,4 +657,51 @@ void main() { expect(rows, hasLength(1)); }, ); + + test( + 'retiming a scored session whose heart rate is gone refuses rather than ' + 'blanking its strain, calories, HR and trace', + () async { + // A WHOOP-import row: its strain and calories came from WHOOP, and no + // band substrate exists over it (the same state a band session is in + // once its day is past raw retention). + final start = sessionStart - 60 * 86400; + final banked = { + 'id': 'w-banked', + 'start_ts': start, + 'end_ts': start + 3600, + 'type': 'run', + 'status': 'done', + 'duration_min': 60, + 'source': 'whoop', + 'strain': 14.2, + 'calories': 620.0, + 'max_hr': 181, + 'avg_hr': 152, + 'zone_min_json': '[5,10,20,15,10]', + 'trace_json': '[[0,150],[60,152]]', + 'trace_samples': 2, + 'created_at': start * 1000, + }; + await LocalDb.putSession(banked); + addTearDown(() => LocalDb.deleteSession('w-banked')); + + await expectLater( + repo.setWorkoutWindow('w-banked', + startTs: start, endTs: start + 3600 + 300), + throwsA(isA().having((e) => e.error, 'error', + ManualWindowError.scoreNotRecomputable)), + ); + + final row = await LocalDb.session('w-banked'); + expect(row!['end_ts'], start + 3600); + expect(row['strain'], 14.2); + expect(row['calories'], 620.0); + expect(row['max_hr'], 181); + expect(row['avg_hr'], 152); + expect(row['zone_min_json'], '[5,10,20,15,10]'); + expect(row['trace_json'], isNotNull); + expect(row['source'], 'whoop'); + }, + ); } From 5bc0928e453cff1f1dcfc2f3fa4718ed51814403 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 12:35:49 +0530 Subject: [PATCH 15/41] re-analyze showed a failed derive as "0 days re-analyzed" the derive returns the days it computed, so a pass that threw and a pass with nothing to do both came back as 0, and days it had to skip were never counted anywhere a caller could see. reanalyzeAll swallowed its own errors into the same 0, and the data screen rendered it as an ordinary result. that is the button for blank screens, which is exactly when a failing derive is the cause. the engine now keeps the last run's error and skipped-day count, reanalyzeAll returns them, and the note says the re-analysis failed (or how many days could not be analyzed) and is styled as a failure. (cherry picked from commit 7ae7bd20fd0ff9ccc3180c36a83cec2606b23de2) --- lib/compute/derivation_engine.dart | 14 ++++++- lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 18 +++++++++ lib/l10n/app_es.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_hi.arb | 2 + lib/l10n/app_ru.arb | 16 ++++++++ lib/l10n/app_zh.arb | 2 + lib/state/app_state.dart | 23 +++++++++--- lib/ui2/profile/data.dart | 21 +++++++++-- test/reanalyze_outcome_test.dart | 60 ++++++++++++++++++++++++++++++ 11 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 test/reanalyze_outcome_test.dart diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 8236ebc11..c217a91e7 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -2994,6 +2994,14 @@ class DerivationEngine { Map snapshot() => Map.from(_diag); + /// What the last [run] that actually started could not do. Its return value + /// is the days it derived, so a pass that threw and a pass with nothing to + /// derive both come back as 0; a caller reporting the outcome to the user + /// reads these to tell them apart. Reset when a run takes the latch, so a + /// run dropped by the latch leaves the previous run's values in place. + String? lastRunError; + int lastRunFailedDays = 0; + /// Run a derivation pass. [heavy]=false runs a bounded light pass over the /// freshness-critical day: TODAY when raw has reached today, else the latest /// pending day. [heavy]=true sweeps every recomputable day. @@ -3031,7 +3039,9 @@ class DerivationEngine { ..['active_days'] = [] ..['concurrency'] = _deriveConcurrency ..['last_error'] = null; - + lastRunError = null; + lastRunFailedDays = 0; + FirebaseTraceHandle? runTrace; try { // Heavy/force passes only. Light passes run many times a day (including @@ -3280,9 +3290,11 @@ class DerivationEngine { ); } } + lastRunFailedDays = failures; return done; } catch (e, st) { _diag['last_error'] = '$e'; + lastRunError = '$e'; _log('derive ERROR: $e\n$st'); return 0; } finally { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 880d220a9..52c0ccbd4 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -141,6 +141,8 @@ "dataDatabaseShared": "Datenbank geteilt. Sie enthält alle Daten.", "dataEncryptedBackupShared": "Verschlüsseltes Backup geteilt. Ohne die Passphrase kann es niemand öffnen – weder diese App noch wir.", "dataDaysReanalyzed": "{n, plural, one{1 Tag neu analysiert.} other{{n} Tage neu analysiert.}}", + "dataReanalyzeFailed": "Neuanalyse fehlgeschlagen: {error}", + "dataReanalyzeDaysFailed": "{n, plural, one{1 Tag konnte nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.} other{{n} Tage konnten nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.}}", "dataBackupFailed": "Backup fehlgeschlagen: {error}", "dataBackupSkipped": "Backup übersprungen.", "dataBackedUpTo": "Gesichert nach {path}", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d07bbbe1f..d43f2d021 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -615,6 +615,24 @@ } } }, + "dataReanalyzeFailed": "Re-analysis failed: {error}", + "@dataReanalyzeFailed": { + "description": "Re-analyze result: the pass itself failed.", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "dataReanalyzeDaysFailed": "{n, plural, one{1 day could not be analyzed. If this keeps happening, please report it.} other{{n} days could not be analyzed. If this keeps happening, please report it.}}", + "@dataReanalyzeDaysFailed": { + "description": "Re-analyze result: N days could not be derived.", + "placeholders": { + "n": { + "type": "int" + } + } + }, "dataBackupFailed": "Backup failed: {error}", "@dataBackupFailed": { "description": "Backup result: failed.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 3781d0f1d..59e738bec 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -138,6 +138,8 @@ "dataDatabaseShared": "Base de datos compartida. Es la copia completa.", "dataEncryptedBackupShared": "Copia de seguridad cifrada compartida. Sin esa frase de contraseña nadie puede abrirla, ni siquiera esta app, ni nosotros.", "dataDaysReanalyzed": "{n, plural, one{1 día reanalizado.} other{{n} días reanalizados.}}", + "dataReanalyzeFailed": "El reanálisis falló: {error}", + "dataReanalyzeDaysFailed": "{n, plural, one{No se pudo analizar 1 día. Si vuelve a pasar, por favor infórmalo.} other{No se pudieron analizar {n} días. Si vuelve a pasar, por favor infórmalo.}}", "dataBackupFailed": "Copia de seguridad fallida: {error}", "dataBackupSkipped": "Copia de seguridad omitida.", "dataBackedUpTo": "Copia de seguridad en {path}", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index dfbc075dc..45d417752 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -138,6 +138,8 @@ "dataDatabaseShared": "Base de données partagée. C’est la copie complète.", "dataEncryptedBackupShared": "Sauvegarde chiffrée partagée. Sans cette phrase secrète, personne ne peut l’ouvrir — y compris cette application, et y compris nous.", "dataDaysReanalyzed": "{n, plural, one{{n} jour réanalysé.} other{{n} jours réanalysés.}}", + "dataReanalyzeFailed": "La réanalyse a échoué : {error}", + "dataReanalyzeDaysFailed": "{n, plural, one{{n} jour n’a pas pu être analysé. Si cela se reproduit, merci de le signaler.} other{{n} jours n’ont pas pu être analysés. Si cela se reproduit, merci de le signaler.}}", "dataBackupFailed": "Échec de la sauvegarde : {error}", "dataBackupSkipped": "Sauvegarde ignorée.", "dataBackedUpTo": "Sauvegardé dans {path}", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index ea16f1440..17fc57164 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -138,6 +138,8 @@ "dataDatabaseShared": "डेटाबेस साझा किया गया। यह पूरी प्रति है।", "dataEncryptedBackupShared": "एन्क्रिप्टेड बैकअप साझा किया गया। उस पासफ़्रेज़ के बिना कोई इसे नहीं खोल सकता — यह ऐप भी नहीं, और हम भी नहीं।", "dataDaysReanalyzed": "{n, plural, one{1 दिन फिर से विश्लेषित।} other{{n} दिन फिर से विश्लेषित।}}", + "dataReanalyzeFailed": "फिर से विश्लेषण विफल: {error}", + "dataReanalyzeDaysFailed": "{n, plural, one{1 दिन का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।} other{{n} दिनों का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।}}", "dataBackupFailed": "बैकअप विफल: {error}", "dataBackupSkipped": "बैकअप छोड़ दिया गया।", "dataBackedUpTo": "{path} पर बैकअप लिया गया", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 5073c8288..34670b024 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -208,6 +208,22 @@ } } }, + "dataReanalyzeFailed": "Повторный анализ не удался: {error}", + "@dataReanalyzeFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "dataReanalyzeDaysFailed": "{n, plural, one{Не удалось проанализировать {n} день. Если это повторяется, пожалуйста, сообщите об этом.} few{Не удалось проанализировать {n} дня. Если это повторяется, пожалуйста, сообщите об этом.} many{Не удалось проанализировать {n} дней. Если это повторяется, пожалуйста, сообщите об этом.} other{Не удалось проанализировать {n} дня. Если это повторяется, пожалуйста, сообщите об этом.}}", + "@dataReanalyzeDaysFailed": { + "placeholders": { + "n": { + "type": "int" + } + } + }, "dataBackupFailed": "Ошибка резервного копирования: {error}", "@dataBackupFailed": { "placeholders": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 56939f8a5..f93184cc6 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -138,6 +138,8 @@ "dataDatabaseShared": "数据库已分享。这是完整的副本。", "dataEncryptedBackupShared": "加密备份已分享。没有该密码短语任何人都无法打开它——包括本应用,也包括我们。", "dataDaysReanalyzed": "{n, plural, other{已重新分析 {n} 天。}}", + "dataReanalyzeFailed": "重新分析失败:{error}", + "dataReanalyzeDaysFailed": "{n, plural, other{有 {n} 天无法分析。如果反复出现,请报告此问题。}}", "dataBackupFailed": "备份失败:{error}", "dataBackupSkipped": "已跳过备份。", "dataBackedUpTo": "已备份到 {path}", diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 2dfc31a77..88605a3a8 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -196,6 +196,10 @@ bool recoveryNightSettled({ ); } +/// What "Re-analyze data" did: days derived, days the pass could not derive, +/// and the error when the pass itself failed. +typedef ReanalyzeOutcome = ({int days, int failedDays, String? error}); + class AppState extends ChangeNotifier { late final BleEngine engine; @@ -2418,10 +2422,13 @@ class AppState extends ChangeNotifier { } /// User-initiated "Re-analyze data": force-derive EVERY day that has raw, - /// ignoring the derived cursor, then refresh the UI. Returns the number of days - /// derived (for a result message). Use when screens are empty despite stored raw. - Future reanalyzeAll() async { - if (reanalyzing) return 0; + /// ignoring the derived cursor, then refresh the UI. Returns the days derived, + /// the days that could not be, and the error when the pass itself failed (for + /// a result message). Use when screens are empty despite stored raw — which + /// is exactly when a failing derive is the likely cause, so a failure must + /// not come back looking like an ordinary "0 days". + Future reanalyzeAll() async { + if (reanalyzing) return (days: 0, failedDays: 0, error: null); reanalyzing = true; reanalyzeProgress = 'Analyzing…'; notifyListeners(); @@ -2452,10 +2459,14 @@ class AppState extends ChangeNotifier { ); await LocalDb.refreshComputeFreshness(); bumpInsights(); - return n; + return ( + days: n, + failedDays: _derive.lastRunFailedDays, + error: _derive.lastRunError, + ); } catch (e) { _log('[derive] reanalyze failed: $e'); - return 0; + return (days: 0, failedDays: 0, error: '$e'); } finally { reanalyzing = false; reanalyzeProgress = ''; diff --git a/lib/ui2/profile/data.dart b/lib/ui2/profile/data.dart index 242243095..5524a2e0f 100644 --- a/lib/ui2/profile/data.dart +++ b/lib/ui2/profile/data.dart @@ -217,7 +217,17 @@ class _DataScreenState extends State { r['date'] as String: (r['value'] as num).round(), }; final before = await recovery(); - final n = await app.reanalyzeAll(); + final (days: n, failedDays: failedDays, error: error) = + await app.reanalyzeAll(); + // The derive reports a failed pass as 0 days, the same as nothing to do; + // shown as an ordinary "0 days re-analyzed." it reads as "there is nothing + // here" on the very screens this button exists to repair. + if (error != null) { + return ( + l?.dataReanalyzeFailed(error) ?? 'Re-analysis failed: $error', + true + ); + } final after = await recovery(); // Which days' recovery a re-analysis actually moved, newest first. // ponytail: first 5 only; the note is a heads-up, not an audit log. @@ -230,13 +240,18 @@ class _DataScreenState extends State { final label = d == null ? c.day : ml.formatShortMonthDay(d); return '$label ${c.from} → ${c.to}'; }).join(', '); - final done = + var done = l?.dataDaysReanalyzed(n) ?? '$n day${n == 1 ? '' : 's'} re-analyzed.'; + if (failedDays > 0) { + final notDone = l?.dataReanalyzeDaysFailed(failedDays) ?? + '$failedDays day${failedDays == 1 ? '' : 's'} could not be analyzed.'; + done = '$done $notDone'; + } return ( changed.isEmpty ? done : '$done ${l?.dataRecoveryChanged(changed) ?? 'Recovery changed: $changed'}', - false + failedDays > 0 ); } diff --git a/test/reanalyze_outcome_test.dart b/test/reanalyze_outcome_test.dart new file mode 100644 index 000000000..3ea682536 --- /dev/null +++ b/test/reanalyze_outcome_test.dart @@ -0,0 +1,60 @@ +// "Re-analyze data" exists for screens that are blank despite stored data, +// which is when a failing derive is the likely cause. run() returns the days it +// derived, so a pass that threw and a pass with nothing to derive both return +// 0; the re-analyze note used to render either one as an ordinary +// "0 days re-analyzed." These pin that the failure is told apart. +// +// Order matters: the first two tests run before any database factory exists, +// so every query inside the pass throws. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/compute/profile.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/state/app_state.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('a pass that throws reports its error, not just 0 days', () async { + final eng = DerivationEngine(log: (_) {}); + final n = await eng.run(const Profile(), heavy: true, force: true); + expect(n, 0); + expect(eng.lastRunError, isNotNull); + }); + + test('reanalyzeAll hands a failed pass back as an error', () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + final out = await app.reanalyzeAll(); + expect(out.days, 0); + expect(out.error, isNotNull); + expect(app.reanalyzing, isFalse); + }); + + test('a pass with nothing to derive is 0 days and no error', () async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_reanalyze_outcome_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + addTearDown(() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + final eng = DerivationEngine(log: (_) {}); + // Left over from a failed pass on the same engine: a clean run resets it. + eng.lastRunError = 'stale'; + eng.lastRunFailedDays = 3; + final n = await eng.run(const Profile(), heavy: true, force: true); + expect(n, 0); + expect(eng.lastRunError, isNull); + expect(eng.lastRunFailedDays, 0); + }); +} From 8db773d03b0f329417d3b8d357e77768ff553c91 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:53:17 +0530 Subject: [PATCH 16/41] re-analyze could report a drain pass's outcome as its own reanalyzeAll read the engine's last-run error and skipped-day count after awaiting the freshness refresh, but run() has already released its latch by then. a light pass kicked off by a band drain in that gap resets both fields, so a failed re-analysis could come back as an ordinary result again, or the light pass's failure could be reported as the re-analysis's. run() now hands its outcome to an onOutcome callback from its finally block, while it still holds the latch, and reanalyzeAll uses that instead of the shared fields. (cherry picked from commit 82d3d18148d4d81f96c01baa458578a113d59ec4) --- lib/compute/derivation_engine.dart | 7 +++++++ lib/state/app_state.dart | 14 ++++++++++++-- test/reanalyze_outcome_test.dart | 29 +++++++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index c217a91e7..69f1c2ca8 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -2999,6 +2999,9 @@ class DerivationEngine { /// derive both come back as 0; a caller reporting the outcome to the user /// reads these to tell them apart. Reset when a run takes the latch, so a /// run dropped by the latch leaves the previous run's values in place. + /// Any later pass on this shared engine (a drain's light pass) resets them + /// too, so a caller that must attribute the outcome to its own run passes + /// [run]'s `onOutcome` instead of reading these after other awaits. String? lastRunError; int lastRunFailedDays = 0; @@ -3014,6 +3017,7 @@ class DerivationEngine { bool heavy = false, bool force = false, void Function(String day, int index, int total)? onDayDone, + void Function(int failedDays, String? error)? onOutcome, }) async { if (_running) return 0; _running = true; @@ -3298,6 +3302,9 @@ class DerivationEngine { _log('derive ERROR: $e\n$st'); return 0; } finally { + // Before any await: this run still holds the latch, so the fields are + // its own on every exit path, early returns included. + onOutcome?.call(lastRunFailedDays, lastRunError); // Storage housekeeping runs here, after everything, still holding // `_running`. See _runStorageHousekeeping — this is the only place every // entry path and every early return actually reaches. diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 88605a3a8..efab43440 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2444,10 +2444,20 @@ class AppState extends ChangeNotifier { } await LocalDb.releaseFrozenHeadline(LocalDb.localDayLabelNow()); } while (_derive.running); + // The outcome comes back through onOutcome rather than the engine's + // lastRun* fields: run() releases its latch before this resumes, and a + // drain's light pass taking it during the awaits below would reset those + // fields and hand its result back as this re-analysis's. + var failedDays = 0; + String? runError; final n = await _derive.run( _profile, heavy: true, force: true, + onOutcome: (failed, error) { + failedDays = failed; + runError = error; + }, // Per-day callback: surface progress AND refresh the UI so each real day's // metrics appear as soon as it's derived (Today fills in one day at a time). onDayDone: (day, index, total) async { @@ -2461,8 +2471,8 @@ class AppState extends ChangeNotifier { bumpInsights(); return ( days: n, - failedDays: _derive.lastRunFailedDays, - error: _derive.lastRunError, + failedDays: failedDays, + error: runError, ); } catch (e) { _log('[derive] reanalyze failed: $e'); diff --git a/test/reanalyze_outcome_test.dart b/test/reanalyze_outcome_test.dart index 3ea682536..70f1abb78 100644 --- a/test/reanalyze_outcome_test.dart +++ b/test/reanalyze_outcome_test.dart @@ -23,9 +23,19 @@ void main() { test('a pass that throws reports its error, not just 0 days', () async { final eng = DerivationEngine(log: (_) {}); - final n = await eng.run(const Profile(), heavy: true, force: true); + String? reported; + final n = await eng.run( + const Profile(), + heavy: true, + force: true, + onOutcome: (_, error) => reported = error, + ); expect(n, 0); expect(eng.lastRunError, isNotNull); + // The per-call outcome survives a later pass on the same engine resetting + // the shared fields. + eng.lastRunError = null; + expect(reported, isNotNull); }); test('reanalyzeAll hands a failed pass back as an error', () async { @@ -52,9 +62,24 @@ void main() { // Left over from a failed pass on the same engine: a clean run resets it. eng.lastRunError = 'stale'; eng.lastRunFailedDays = 3; - final n = await eng.run(const Profile(), heavy: true, force: true); + var called = false; + int? failed; + String? error = 'unset'; + final n = await eng.run( + const Profile(), + heavy: true, + force: true, + onOutcome: (f, e) { + called = true; + failed = f; + error = e; + }, + ); expect(n, 0); expect(eng.lastRunError, isNull); expect(eng.lastRunFailedDays, 0); + expect(called, isTrue); + expect(failed, 0); + expect(error, isNull); }); } From 20fe22c0d1a3580d032669120bdfccdb84bb2c74 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 15:42:53 +0530 Subject: [PATCH 17/41] low readiness agreement test failed before 16:00 the seeded wake was a flat 13 h back, so before 4pm local it landed before 03:00 (or on yesterday) and the pin refused it as not a main night. the ring then read null and both cases failed depending on the hour the suite ran. the wake is now 13 h back but never earlier than 03:01 today. --- test/low_readiness_agreement_test.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/low_readiness_agreement_test.dart b/test/low_readiness_agreement_test.dart index dde95b3ad..89fdb7a27 100644 --- a/test/low_readiness_agreement_test.dart +++ b/test/low_readiness_agreement_test.dart @@ -52,8 +52,15 @@ void main() { // Today's night, scored (so the ring shows today's overnight), with NO // readiness of its own; the crossday rollup says today is settled. Future seed({required bool nullRow}) async { + // 13 h back, but never before 03:00 today: a pin only holds for a wake at + // or after kMinPinWakeHour, so before 16:00 a plain 13 h offset fell on a + // wake the pin refuses and the ring read null. + final now = DateTime.now(); + final earliest = DateTime(now.year, now.month, now.day, kMinPinWakeHour, 1); + final back = now.subtract(const Duration(hours: 13)); final wakeSec = - DateTime.now().millisecondsSinceEpoch ~/ 1000 - 13 * 3600; + (back.isAfter(earliest) ? back : earliest).millisecondsSinceEpoch ~/ + 1000; final db = await LocalDb.instance; await db.delete('day_result'); await db.delete('metric_series'); From fba634b4a6632a3fddbc59313fd06c9af485911c Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 16:04:16 +0530 Subject: [PATCH 18/41] profile forms accepted any number as age, height or weight a birth year typed into the age box saved as age 1990 and put the tanaka ceiling at -1185 bpm, so every day's zones were banded off it. an imperial height typed as feet.inches (5.10) saved as 13 cm. onboarding and edit profile now refuse values outside a believable range and say so, and estimatedMaxHr returns no ceiling for an implausible stored age instead of a negative one. (cherry picked from commit b19ec2eaa9beec51b33a8ccb6dd2591a5b8ade67) --- lib/compute/hr_max.dart | 6 +- lib/compute/profile.dart | 18 +++++ lib/l10n/app_de.arb | 9 ++- lib/l10n/app_en.arb | 55 +++++++------ lib/l10n/app_es.arb | 9 ++- lib/l10n/app_fr.arb | 9 ++- lib/l10n/app_hi.arb | 9 ++- lib/l10n/app_ru.arb | 42 ++++++---- lib/l10n/app_zh.arb | 9 ++- lib/ui2/grammar.dart | 16 ++++ lib/ui2/onboarding/profile_setup.dart | 17 ++++ lib/ui2/profile/settings.dart | 22 +++++- test/profile_plausibility_test.dart | 109 ++++++++++++++++++++++++++ 13 files changed, 267 insertions(+), 63 deletions(-) create mode 100644 test/profile_plausibility_test.dart diff --git a/lib/compute/hr_max.dart b/lib/compute/hr_max.dart index 57884a24f..649c29d58 100644 --- a/lib/compute/hr_max.dart +++ b/lib/compute/hr_max.dart @@ -24,6 +24,8 @@ import 'dart:math' as math; import 'package:openstrap_analytics/onehz.dart' as ana; +import 'profile.dart' show plausibleAgeYears; + // ── THE app's HR ceiling for zones / TRIMP / calories ──────────────────────── // // There used to be five of these. `220 − age` at local_repository_impl.dart, @@ -63,7 +65,9 @@ import 'package:openstrap_analytics/onehz.dart' as ana; /// the band actually MEASURED — is `observedCeilingBpm`, and that one still /// refuses for an unknown family, in analytics' `observed_max_hr.dart`. double? estimatedMaxHr(num? age, String? deviceFamily) { - if (age == null || age <= 0) return null; + // An age already stored before the profile forms checked it (a birth year, + // a negative) gets no ceiling rather than a negative one. + if (age == null || !plausibleAgeYears(age)) return null; return 208.0 - 0.7 * age.toDouble(); } diff --git a/lib/compute/profile.dart b/lib/compute/profile.dart index 1caaeec71..6b6d73413 100644 --- a/lib/compute/profile.dart +++ b/lib/compute/profile.dart @@ -79,6 +79,24 @@ class Profile { ageYears != null && weightKg != null && sex != null; } +/// The ranges a typed body measurement has to fall in to be believed. +/// +/// They are typo traps, not a population model: a birth year typed into the +/// age box (1990) put Tanaka's ceiling at -1185 bpm, and an imperial height +/// typed as feet.inches ("5.10") stored 13 cm. Anything inside these bounds is +/// a real person; anything outside is a mis-entry, and scoring it would band +/// every minute of every day off a body that does not exist. +const kPlausibleAgeYears = (min: 1, max: 120); +const kPlausibleHeightCm = (min: 50.0, max: 272.0); +const kPlausibleWeightKg = (min: 20.0, max: 400.0); + +bool plausibleAgeYears(num v) => + v.round() >= kPlausibleAgeYears.min && v.round() <= kPlausibleAgeYears.max; +bool plausibleHeightCm(num v) => + v >= kPlausibleHeightCm.min && v <= kPlausibleHeightCm.max; +bool plausibleWeightKg(num v) => + v >= kPlausibleWeightKg.min && v <= kPlausibleWeightKg.max; + /// Body mass index (kg/m²) off a weight and a height, or null without both. double? bmiOf(num? weightKg, num? heightCm) => weightKg == null || heightCm == null || heightCm <= 0 diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 52c0ccbd4..e004db51d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -141,8 +141,6 @@ "dataDatabaseShared": "Datenbank geteilt. Sie enthält alle Daten.", "dataEncryptedBackupShared": "Verschlüsseltes Backup geteilt. Ohne die Passphrase kann es niemand öffnen – weder diese App noch wir.", "dataDaysReanalyzed": "{n, plural, one{1 Tag neu analysiert.} other{{n} Tage neu analysiert.}}", - "dataReanalyzeFailed": "Neuanalyse fehlgeschlagen: {error}", - "dataReanalyzeDaysFailed": "{n, plural, one{1 Tag konnte nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.} other{{n} Tage konnten nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.}}", "dataBackupFailed": "Backup fehlgeschlagen: {error}", "dataBackupSkipped": "Backup übersprungen.", "dataBackedUpTo": "Gesichert nach {path}", @@ -423,7 +421,6 @@ "napsWokeUpHelp": "AUFGEWACHT UM", "napsInvalidWindow": "Ein Nickerchen dauert zwischen 5 Minuten und 6 Stunden. Alles Längere ist Schlaf und gehört zur Nacht, wo die Schlafphasen ausgewertet werden.", "napsOverlap": "Das überschneidet sich mit einem Nickerchen, das an diesem Tag schon eingetragen ist. Entferne zuerst das andere, damit dieselbe Zeit nicht doppelt zählt.", - "napsInFuture": "Dieses Nickerchen ist noch nicht vorbei. Trage es ein, wenn du wach bist.", "napsNotReanalysed": "Der Tag wurde nicht neu ausgewertet, weil gerade schon eine andere Auswertung lief. Deine Änderung ist gespeichert und wird beim nächsten Mal berücksichtigt.", "napsTitle": "Nickerchen", "napsNoReadingTitle": "Keine Daten zu Nickerchen an diesem Tag", @@ -2785,5 +2782,9 @@ }, "deviceUseSheetOff": "Du kannst es jederzeit ausschalten. Was es beigetragen hat, wird dann wieder entfernt.", "deviceUseSheetConfirm": "Nutzen", - "deviceUseSheetCancel": "Nicht jetzt" + "deviceUseSheetCancel": "Nicht jetzt", + "dataReanalyzeFailed": "Neuanalyse fehlgeschlagen: {error}", + "dataReanalyzeDaysFailed": "{n, plural, one{1 Tag konnte nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.} other{{n} Tage konnten nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.}}", + "napsInFuture": "Dieses Nickerchen ist noch nicht vorbei. Trage es ein, wenn du wach bist.", + "profileImplausibleFields": "Bitte {fields} prüfen. Der Wert liegt außerhalb des gültigen Bereichs, daher wurde nichts gespeichert." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d43f2d021..2bb077ac9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -615,24 +615,6 @@ } } }, - "dataReanalyzeFailed": "Re-analysis failed: {error}", - "@dataReanalyzeFailed": { - "description": "Re-analyze result: the pass itself failed.", - "placeholders": { - "error": { - "type": "String" - } - } - }, - "dataReanalyzeDaysFailed": "{n, plural, one{1 day could not be analyzed. If this keeps happening, please report it.} other{{n} days could not be analyzed. If this keeps happening, please report it.}}", - "@dataReanalyzeDaysFailed": { - "description": "Re-analyze result: N days could not be derived.", - "placeholders": { - "n": { - "type": "int" - } - } - }, "dataBackupFailed": "Backup failed: {error}", "@dataBackupFailed": { "description": "Backup result: failed.", @@ -1969,10 +1951,6 @@ "@napsOverlap": { "description": "Validation error when a manually logged nap overlaps an existing one." }, - "napsInFuture": "That nap hasn't finished yet. Log it once you're awake.", - "@napsInFuture": { - "description": "Validation error when a manually logged nap ends after the current time." - }, "napsNotReanalysed": "The day was not re-analysed — another analysis was already running. Your edit is saved and will apply next time.", "@napsNotReanalysed": { "description": "Fallback error when a nap edit saved but the day could not be re-derived because another analysis was already running." @@ -15673,7 +15651,7 @@ "@reportDatabaseCovered": { "description": "Issue report: line under the 2-week switch while the full database switch is on." }, - "reportStageSlice": "Preparing last 2 weeks\u2026", + "reportStageSlice": "Preparing last 2 weeks…", "@reportStageSlice": { "description": "Report screen: progress text inside the busy button while the 2-week database is built." }, @@ -15821,5 +15799,36 @@ "type": "String" } } + }, + "dataReanalyzeFailed": "Re-analysis failed: {error}", + "@dataReanalyzeFailed": { + "description": "Re-analyze result: the pass itself failed.", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "dataReanalyzeDaysFailed": "{n, plural, one{1 day could not be analyzed. If this keeps happening, please report it.} other{{n} days could not be analyzed. If this keeps happening, please report it.}}", + "@dataReanalyzeDaysFailed": { + "description": "Re-analyze result: N days could not be derived.", + "placeholders": { + "n": { + "type": "int" + } + } + }, + "napsInFuture": "That nap hasn't finished yet. Log it once you're awake.", + "@napsInFuture": { + "description": "Validation error when a manually logged nap ends after the current time." + }, + "profileImplausibleFields": "Check {fields}. That value is out of range, so nothing was saved.", + "@profileImplausibleFields": { + "description": "Shown when a typed age, height or weight is outside the believable range.", + "placeholders": { + "fields": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 59e738bec..4b1cbb931 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -138,8 +138,6 @@ "dataDatabaseShared": "Base de datos compartida. Es la copia completa.", "dataEncryptedBackupShared": "Copia de seguridad cifrada compartida. Sin esa frase de contraseña nadie puede abrirla, ni siquiera esta app, ni nosotros.", "dataDaysReanalyzed": "{n, plural, one{1 día reanalizado.} other{{n} días reanalizados.}}", - "dataReanalyzeFailed": "El reanálisis falló: {error}", - "dataReanalyzeDaysFailed": "{n, plural, one{No se pudo analizar 1 día. Si vuelve a pasar, por favor infórmalo.} other{No se pudieron analizar {n} días. Si vuelve a pasar, por favor infórmalo.}}", "dataBackupFailed": "Copia de seguridad fallida: {error}", "dataBackupSkipped": "Copia de seguridad omitida.", "dataBackedUpTo": "Copia de seguridad en {path}", @@ -395,7 +393,6 @@ "napsWokeUpHelp": "CUÁNDO TE DESPERTASTE", "napsInvalidWindow": "Una siesta dura entre 5 minutos y 6 horas. Cualquier cosa más larga es un sueño nocturno y pertenece a la noche, donde se pueden leer las fases.", "napsOverlap": "Eso se superpone con una siesta ya registrada este día. Elimina esa primero, en lugar de contar la misma hora dos veces.", - "napsInFuture": "Esa siesta todavía no ha terminado. Regístrala cuando te despiertes.", "napsNotReanalysed": "El día no se ha vuelto a analizar: ya había otro análisis en curso. Tu edición está guardada y se aplicará la próxima vez.", "napsTitle": "Siestas", "napsNoReadingTitle": "Sin datos de siestas para este día", @@ -2689,5 +2686,9 @@ }, "deviceUseSheetOff": "Puedes desactivarlo cuando quieras. Lo que haya aportado se quitará.", "deviceUseSheetConfirm": "Usarlo", - "deviceUseSheetCancel": "Ahora no" + "deviceUseSheetCancel": "Ahora no", + "dataReanalyzeFailed": "El reanálisis falló: {error}", + "dataReanalyzeDaysFailed": "{n, plural, one{No se pudo analizar 1 día. Si vuelve a pasar, por favor infórmalo.} other{No se pudieron analizar {n} días. Si vuelve a pasar, por favor infórmalo.}}", + "napsInFuture": "Esa siesta todavía no ha terminado. Regístrala cuando te despiertes.", + "profileImplausibleFields": "Revisa {fields}. El valor está fuera de rango, así que no se guardó nada." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 45d417752..86303abf5 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -138,8 +138,6 @@ "dataDatabaseShared": "Base de données partagée. C’est la copie complète.", "dataEncryptedBackupShared": "Sauvegarde chiffrée partagée. Sans cette phrase secrète, personne ne peut l’ouvrir — y compris cette application, et y compris nous.", "dataDaysReanalyzed": "{n, plural, one{{n} jour réanalysé.} other{{n} jours réanalysés.}}", - "dataReanalyzeFailed": "La réanalyse a échoué : {error}", - "dataReanalyzeDaysFailed": "{n, plural, one{{n} jour n’a pas pu être analysé. Si cela se reproduit, merci de le signaler.} other{{n} jours n’ont pas pu être analysés. Si cela se reproduit, merci de le signaler.}}", "dataBackupFailed": "Échec de la sauvegarde : {error}", "dataBackupSkipped": "Sauvegarde ignorée.", "dataBackedUpTo": "Sauvegardé dans {path}", @@ -395,7 +393,6 @@ "napsWokeUpHelp": "QUAND VOUS VOUS ÊTES RÉVEILLÉ", "napsInvalidWindow": "Une sieste dure entre 5 minutes et 6 heures. Au-delà, il s'agit d'un sommeil, et cela appartient à la nuit, où les phases peuvent être lues.", "napsOverlap": "Cela chevauche une sieste déjà enregistrée ce jour-là. Supprimez d'abord celle-ci plutôt que de compter la même heure deux fois.", - "napsInFuture": "Cette sieste n'est pas encore terminée. Enregistrez-la une fois réveillé.", "napsNotReanalysed": "La journée n'a pas été réanalysée — une autre analyse était déjà en cours. Votre modification est enregistrée et s'appliquera la prochaine fois.", "napsTitle": "Siestes", "napsNoReadingTitle": "Aucune donnée de sieste pour cette journée", @@ -2660,5 +2657,9 @@ }, "deviceUseSheetOff": "Vous pouvez le désactiver à tout moment. Ce qu'il a apporté sera alors retiré.", "deviceUseSheetConfirm": "L'utiliser", - "deviceUseSheetCancel": "Pas maintenant" + "deviceUseSheetCancel": "Pas maintenant", + "dataReanalyzeFailed": "La réanalyse a échoué : {error}", + "dataReanalyzeDaysFailed": "{n, plural, one{{n} jour n’a pas pu être analysé. Si cela se reproduit, merci de le signaler.} other{{n} jours n’ont pas pu être analysés. Si cela se reproduit, merci de le signaler.}}", + "napsInFuture": "Cette sieste n'est pas encore terminée. Enregistrez-la une fois réveillé.", + "profileImplausibleFields": "Vérifiez {fields}. La valeur est hors limites, rien n'a donc été enregistré." } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 17fc57164..696efdc08 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -138,8 +138,6 @@ "dataDatabaseShared": "डेटाबेस साझा किया गया। यह पूरी प्रति है।", "dataEncryptedBackupShared": "एन्क्रिप्टेड बैकअप साझा किया गया। उस पासफ़्रेज़ के बिना कोई इसे नहीं खोल सकता — यह ऐप भी नहीं, और हम भी नहीं।", "dataDaysReanalyzed": "{n, plural, one{1 दिन फिर से विश्लेषित।} other{{n} दिन फिर से विश्लेषित।}}", - "dataReanalyzeFailed": "फिर से विश्लेषण विफल: {error}", - "dataReanalyzeDaysFailed": "{n, plural, one{1 दिन का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।} other{{n} दिनों का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।}}", "dataBackupFailed": "बैकअप विफल: {error}", "dataBackupSkipped": "बैकअप छोड़ दिया गया।", "dataBackedUpTo": "{path} पर बैकअप लिया गया", @@ -395,7 +393,6 @@ "napsWokeUpHelp": "आप कब जागे", "napsInvalidWindow": "झपकी 5 मिनट से 6 घंटे के बीच होती है। इससे अधिक लंबी अवधि नींद मानी जाती है, और वह रात के हिस्से में आती है जहाँ चरणों को पढ़ा जा सकता है।", "napsOverlap": "यह इस दिन पहले से मौजूद एक झपकी से टकराता है। उसे पहले हटाएँ, बजाय इसके कि उसी घंटे को दो बार गिना जाए।", - "napsInFuture": "यह झपकी अभी खत्म नहीं हुई है। जागने के बाद इसे दर्ज करें।", "napsNotReanalysed": "दिन का पुनः विश्लेषण नहीं हुआ — पहले से ही एक अन्य विश्लेषण चल रहा था। आपका बदलाव सहेज लिया गया है और अगली बार लागू होगा।", "napsTitle": "झपकियाँ", "napsNoReadingTitle": "इस दिन के लिए कोई झपकी रीडिंग नहीं", @@ -2660,5 +2657,9 @@ }, "deviceUseSheetOff": "आप इसे कभी भी बंद कर सकते हैं। तब इसका जोड़ा हुआ सब हटा दिया जाएगा।", "deviceUseSheetConfirm": "इस्तेमाल करें", - "deviceUseSheetCancel": "अभी नहीं" + "deviceUseSheetCancel": "अभी नहीं", + "dataReanalyzeFailed": "फिर से विश्लेषण विफल: {error}", + "dataReanalyzeDaysFailed": "{n, plural, one{1 दिन का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।} other{{n} दिनों का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।}}", + "napsInFuture": "यह झपकी अभी खत्म नहीं हुई है। जागने के बाद इसे दर्ज करें।", + "profileImplausibleFields": "{fields} जाँचें। मान सीमा से बाहर है, इसलिए कुछ भी सहेजा नहीं गया।" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 34670b024..309b2e1b0 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -208,22 +208,6 @@ } } }, - "dataReanalyzeFailed": "Повторный анализ не удался: {error}", - "@dataReanalyzeFailed": { - "placeholders": { - "error": { - "type": "String" - } - } - }, - "dataReanalyzeDaysFailed": "{n, plural, one{Не удалось проанализировать {n} день. Если это повторяется, пожалуйста, сообщите об этом.} few{Не удалось проанализировать {n} дня. Если это повторяется, пожалуйста, сообщите об этом.} many{Не удалось проанализировать {n} дней. Если это повторяется, пожалуйста, сообщите об этом.} other{Не удалось проанализировать {n} дня. Если это повторяется, пожалуйста, сообщите об этом.}}", - "@dataReanalyzeDaysFailed": { - "placeholders": { - "n": { - "type": "int" - } - } - }, "dataBackupFailed": "Ошибка резервного копирования: {error}", "@dataBackupFailed": { "placeholders": { @@ -837,7 +821,6 @@ "napsWokeUpHelp": "КОГДА ВЫ ПРОСНУЛИСЬ", "napsInvalidWindow": "Дневной сон длится от 5 минут до 6 часов. Более долгий сон относится к ночному, где можно просмотреть его стадии.", "napsOverlap": "Этот период пересекается с уже записанным дневным сном. Сначала удалите прежнюю запись, чтобы не учитывать одно время дважды.", - "napsInFuture": "Этот дневной сон ещё не закончился. Запишите его, когда проснётесь.", "napsNotReanalysed": "Повторный анализ дня не выполнен: уже идёт другой анализ. Изменение сохранено и будет учтено в следующий раз.", "napsTitle": "Дневной сон", "napsNoReadingTitle": "Нет данных о дневном сне за этот день", @@ -7543,5 +7526,30 @@ "type": "String" } } + }, + "dataReanalyzeFailed": "Повторный анализ не удался: {error}", + "@dataReanalyzeFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "dataReanalyzeDaysFailed": "{n, plural, one{Не удалось проанализировать {n} день. Если это повторяется, пожалуйста, сообщите об этом.} few{Не удалось проанализировать {n} дня. Если это повторяется, пожалуйста, сообщите об этом.} many{Не удалось проанализировать {n} дней. Если это повторяется, пожалуйста, сообщите об этом.} other{Не удалось проанализировать {n} дня. Если это повторяется, пожалуйста, сообщите об этом.}}", + "@dataReanalyzeDaysFailed": { + "placeholders": { + "n": { + "type": "int" + } + } + }, + "napsInFuture": "Этот дневной сон ещё не закончился. Запишите его, когда проснётесь.", + "profileImplausibleFields": "Проверьте: {fields}. Значение вне допустимого диапазона, поэтому ничего не сохранено.", + "@profileImplausibleFields": { + "placeholders": { + "fields": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f93184cc6..f07f1c53b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -138,8 +138,6 @@ "dataDatabaseShared": "数据库已分享。这是完整的副本。", "dataEncryptedBackupShared": "加密备份已分享。没有该密码短语任何人都无法打开它——包括本应用,也包括我们。", "dataDaysReanalyzed": "{n, plural, other{已重新分析 {n} 天。}}", - "dataReanalyzeFailed": "重新分析失败:{error}", - "dataReanalyzeDaysFailed": "{n, plural, other{有 {n} 天无法分析。如果反复出现,请报告此问题。}}", "dataBackupFailed": "备份失败:{error}", "dataBackupSkipped": "已跳过备份。", "dataBackedUpTo": "已备份到 {path}", @@ -395,7 +393,6 @@ "napsWokeUpHelp": "醒来时间", "napsInvalidWindow": "小睡的时长应在 5 分钟到 6 小时之间。超过这个时长应算作夜间睡眠,属于可以读取睡眠阶段的夜晚记录。", "napsOverlap": "该时间段与当天已有的一次小睡重叠。请先删除那一次记录,而不是把同一小时计算两次。", - "napsInFuture": "这次小睡还没有结束。醒来后再记录。", "napsNotReanalysed": "当天未被重新分析——已有另一项分析正在运行。你的修改已保存,将在下次生效。", "napsTitle": "小睡", "napsNoReadingTitle": "当天没有小睡数据", @@ -2660,5 +2657,9 @@ }, "deviceUseSheetOff": "你可以随时关闭它。届时它添加的内容会被移除。", "deviceUseSheetConfirm": "使用", - "deviceUseSheetCancel": "暂不" + "deviceUseSheetCancel": "暂不", + "dataReanalyzeFailed": "重新分析失败:{error}", + "dataReanalyzeDaysFailed": "{n, plural, other{有 {n} 天无法分析。如果反复出现,请报告此问题。}}", + "napsInFuture": "这次小睡还没有结束。醒来后再记录。", + "profileImplausibleFields": "请检查{fields}。数值超出范围,因此未保存任何内容。" } diff --git a/lib/ui2/grammar.dart b/lib/ui2/grammar.dart index d0492030b..0a23e31b6 100644 --- a/lib/ui2/grammar.dart +++ b/lib/ui2/grammar.dart @@ -1836,6 +1836,22 @@ void sayUnreadable(BuildContext c, List fields) { ); } +/// Say which fields hold a number that cannot be a real body — a birth year +/// in the age box, feet.inches in an inches box. Same contract as +/// [sayUnreadable]: the form stops and nothing is saved. +void sayImplausible(BuildContext c, List fields) { + if (fields.isEmpty) return; + final joined = fields.join(', '); + ScaffoldMessenger.of(c).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(c)?.profileImplausibleFields(joined) ?? + 'Check $joined. That value is out of range, so nothing was saved.', + ), + ), + ); +} + /// The one destructive confirm. Names what goes and what stays, and there is /// no undo behind any caller of it. /// diff --git a/lib/ui2/onboarding/profile_setup.dart b/lib/ui2/onboarding/profile_setup.dart index bf7ab1d58..27a4668e6 100644 --- a/lib/ui2/onboarding/profile_setup.dart +++ b/lib/ui2/onboarding/profile_setup.dart @@ -15,6 +15,8 @@ import '../../l10n/display_text.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../compute/profile.dart' + show plausibleAgeYears, plausibleHeightCm, plausibleWeightKg; import '../../l10n/app_localizations.dart'; import '../../l10n/date_text.dart'; import '../../state/app_state.dart'; @@ -140,6 +142,21 @@ class _ProfileSetupViewState extends State { sayUnreadable(context, bad); return; } + final age = Typed.of(_age.text).value; + final height = _u.heightToCm(_height.text); + final weight = _u.weightToKg(_weight.text); + final off = [ + if (age != null && !plausibleAgeYears(age)) + AppLocalizations.of(context)?.settingsAgeFieldLabel ?? 'Age', + if (height != null && !plausibleHeightCm(height)) + uiText(context, _u.heightLabel), + if (weight != null && !plausibleWeightKg(weight)) + uiText(context, _u.weightLabel), + ]; + if (off.isNotEmpty) { + sayImplausible(context, off); + return; + } await widget.onCycleTracking?.call(_cycleOn); await widget.onSave(_fields()); } diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index 3b617a623..744eab2e4 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -19,6 +19,8 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../compute/profile.dart' + show plausibleAgeYears, plausibleHeightCm, plausibleWeightKg; import '../../data/off_lookup.dart'; import '../../health/health_export.dart' show HealthLinkState; import '../../health/health_import_state.dart'; @@ -1618,13 +1620,29 @@ class _EditProfileViewState extends State { sayUnreadable(context, bad); return; } + final heightCm = + height.value == null ? null : _u.heightToCm(_height.text); + final weightKg = + weight.value == null ? null : _u.weightToKg(_weight.text); + final off = [ + if (age.value case final a? when !plausibleAgeYears(a)) + (l?.settingsAgeFieldLabel ?? 'Age'), + if (heightCm != null && !plausibleHeightCm(heightCm)) + uiText(context, _u.heightLabel), + if (weightKg != null && !plausibleWeightKg(weightKg)) + uiText(context, _u.weightLabel), + ]; + if (off.isNotEmpty) { + sayImplausible(context, off); + return; + } widget.onSave({ 'name': _name.text.trim().isEmpty ? null : _name.text.trim(), 'sex': _sex, 'age': age.value?.round(), // Typed in the units on the label, stored in metric. - 'height_cm': height.value == null ? null : _u.heightToCm(_height.text), - 'weight_kg': weight.value == null ? null : _u.weightToKg(_weight.text), + 'height_cm': heightCm, + 'weight_kg': weightKg, }); } diff --git a/test/profile_plausibility_test.dart b/test/profile_plausibility_test.dart new file mode 100644 index 000000000..389d89a21 --- /dev/null +++ b/test/profile_plausibility_test.dart @@ -0,0 +1,109 @@ +// A number that parses is not yet a body. +// +// Both profile forms used to accept any finite number: a birth year typed into +// the age box saved as age 1990, and Tanaka put that user's HR ceiling at +// -1185 bpm, so every zone of every day was banded off it. An imperial height +// typed as feet.inches ("5.10") saved as 13 cm. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/hr_max.dart'; +import 'package:openstrap_edge/compute/profile.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; +import 'package:openstrap_edge/ui2/onboarding/profile_setup.dart'; +import 'package:openstrap_edge/ui2/profile/settings.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Widget _frame(Widget child) => MaterialApp( + theme: buildTheme(Brightness.light), + home: child, + ); + +Future _pump(WidgetTester t, Widget child) async { + t.view.physicalSize = const Size(1200, 3600); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(_frame(child)); + await t.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + group('estimatedMaxHr', () { + test('a stored birth year gets no ceiling, not a negative one', () { + expect(estimatedMaxHr(1990, 'gen4'), isNull); + expect(estimatedMaxHr(-5, 'gen4'), isNull); + expect(estimatedMaxHr(0, 'gen4'), isNull); + }); + + test('a real age is unchanged', () { + expect(estimatedMaxHr(30, 'gen4'), closeTo(187.0, 1e-9)); + expect(estimatedMaxHr(120, 'gen4'), closeTo(124.0, 1e-9)); + }); + }); + + test('plausibility bounds', () { + expect(plausibleAgeYears(1990), isFalse); + expect(plausibleAgeYears(0.4), isFalse); + expect(plausibleAgeYears(34), isTrue); + expect(plausibleHeightCm(12.95), isFalse); + expect(plausibleHeightCm(178), isTrue); + expect(plausibleWeightKg(0), isFalse); + expect(plausibleWeightKg(72.4), isTrue); + }); + + testWidgets('edit profile refuses a birth year typed as age', (t) async { + Map? saved; + await _pump( + t, + EditProfileView( + initial: const {'sex': 'm', 'age': 34, 'height_cm': 178.0}, + onSave: (f) async => saved = f, + )); + + await t.enterText(find.widgetWithText(TextField, '34'), '1990'); + await t.tap(find.text('Save')); + await t.pumpAndSettle(); + + expect(saved, isNull); + expect(find.textContaining('out of range'), findsOneWidget); + }); + + testWidgets('edit profile still saves a real profile', (t) async { + Map? saved; + await _pump( + t, + EditProfileView( + initial: const {'sex': 'm', 'age': 34, 'height_cm': 178.0}, + onSave: (f) async => saved = f, + )); + + await t.tap(find.text('Save')); + await t.pumpAndSettle(); + + expect(saved?['age'], 34); + expect(saved?['height_cm'], 178.0); + }); + + testWidgets('onboarding refuses feet.inches typed into an inches box', + (t) async { + Map? saved; + await _pump( + t, + ProfileSetupView( + initial: const {'sex': 'm', 'age': 34, 'height_cm': 178.0}, + units: UnitsController.seed(UnitSystem.imperial), + onSave: (f) async => saved = f, + )); + + // 178 cm pre-fills as 70 total inches. + await t.enterText(find.widgetWithText(TextField, '70'), '5.10'); + await t.tap(find.text('Continue')); + await t.pumpAndSettle(); + + expect(saved, isNull); + expect(find.textContaining('out of range'), findsOneWidget); + }); +} From b17f8f52f0c2402fc4a2b208b0dd2d16b42721bf Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:06:46 +0530 Subject: [PATCH 19/41] nap cleanup skipped days the readiness backfill had restamped the next-night nap cleanup only looked at days stored below v106, but the raw-free readiness backfill restamps old bundles at the current version and copies their naps through untouched. most pre-v106 history therefore kept the phantom nap and its nap_min. the cleanup now checks every stored day (a v106+ derive has nothing to drop), and its done-key moves to v2 so installs that already ran the gated pass run it once more. (cherry picked from commit e2abd7bc8552640625999b7e63b3f68cdbf10c16) --- lib/compute/nap_overlap_cleanup.dart | 14 +++-- lib/data/db.dart | 4 +- test/nap_overlap_cleanup_test.dart | 86 ++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 test/nap_overlap_cleanup_test.dart diff --git a/lib/compute/nap_overlap_cleanup.dart b/lib/compute/nap_overlap_cleanup.dart index 21834bae5..44e126e4d 100644 --- a/lib/compute/nap_overlap_cleanup.dart +++ b/lib/compute/nap_overlap_cleanup.dart @@ -16,9 +16,6 @@ import 'nap_edits.dart' show napMinutes; /// `compute_freshness` key marking the cleanup as applied (an import clears it). const String kNapNextNightCleanupKey = LocalDb.kNapNextNightCleanupKey; -/// Last algo version that could book tonight's sleep as a nap. -const int kNapNextNightFixedIn = 106; - /// [naps] without the detected ones overlapping the next day's main sleep /// [nextOnsetSec, nextOffsetSec). A nap the user logged (it carries a /// `source`) is kept. Null when nothing is dropped. @@ -39,15 +36,20 @@ List>? napsWithoutNextNight( return kept.length == naps.length ? null : kept; } -/// Runs the cleanup once over every stored day below [kNapNextNightFixedIn]. -/// Returns the days rewritten. Marked done only after it finishes. +/// Runs the cleanup once over every stored day. Returns the days rewritten. +/// Marked done only after it finishes. +/// +/// Not gated on the stored algo version: the raw-free readiness and strain +/// backfills restamp an old bundle at the current version while copying its +/// naps through untouched, so a pre-v106 double count can sit under a v112+ +/// stamp. A day derived at v106+ has no detected nap inside the next night, +/// so the check is a no-op there. Future cleanNextNightNapsOnce() async { if (await LocalDb.computeFreshness(kNapNextNightCleanupKey) != null) return 0; final versions = await LocalDb.dayResultVersions(); final days = versions.keys.toList()..sort(); var cleaned = 0; for (final day in days) { - if (versions[day]! >= kNapNextNightFixedIn) continue; // Calendar arithmetic on the label (noon avoids any DST edge). final d = DateTime.parse(day); final nextDay = dayLabelOf(DateTime(d.year, d.month, d.day + 1, 12)); diff --git a/lib/data/db.dart b/lib/data/db.dart index 33ca9b03e..1d52de1a9 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -8872,7 +8872,9 @@ class LocalDb { /// `compute_freshness` key marking the raw-free next-night nap cleanup /// (compute/nap_overlap_cleanup.dart) as applied. Cleared by an import. - static const String kNapNextNightCleanupKey = 'nap_next_night_cleanup_v1'; + /// v2: the v1 pass skipped every day the readiness backfill had restamped + /// at v112+, so installs that already ran it run it once more. + static const String kNapNextNightCleanupKey = 'nap_next_night_cleanup_v2'; /// `compute_freshness` key for [bumpUsageCounter]'s local counts. static const String kUsageCountersKey = 'usage_counters'; diff --git a/test/nap_overlap_cleanup_test.dart b/test/nap_overlap_cleanup_test.dart new file mode 100644 index 000000000..7e051d480 --- /dev/null +++ b/test/nap_overlap_cleanup_test.dart @@ -0,0 +1,86 @@ +// The raw-free next-night nap cleanup reaches old bundles whatever version +// stamp they carry now: the readiness backfill restamps a pre-v106 day at the +// current version without touching its naps. + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/compute/derivation_engine.dart' show kAlgoVersion; +import 'package:openstrap_edge/compute/nap_overlap_cleanup.dart'; +import 'package:openstrap_edge/data/db.dart'; + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_nap_overlap_cleanup_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + test('a pre-v106 day restamped by the readiness backfill is still cleaned', + () async { + // 2026-08-02 22:30Z -> 2026-08-03 06:30Z: the next day's main sleep. + const onset = 1785709800; + const offset = onset + 8 * 3600; + final afternoon = { + 'start': onset - 7 * 3600, + 'end': onset - 7 * 3600 + 1800, + 'duration_min': 30, + }; + final phantom = {'start': onset, 'end': onset + 2400, 'duration_min': 40}; + final payload = { + 'naps': { + 'value': [afternoon, phantom], + 'count': 2, + }, + 'scalars': {'nap_min': 70.0, 'readiness': 64.0}, + }; + // Same bundle as a v100 derive, but stamped the way the backfill leaves it. + await LocalDb.putDayResult( + dayId: '2026-08-02', + algoVersion: kAlgoVersion, + payloadJson: jsonEncode(payload), + windowJson: '{}', + finalized: true, + readiness: 64.0, + ); + await LocalDb.putDayResult( + dayId: '2026-08-03', + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'sleep': { + 'window': { + 'value': {'onset_ms': onset * 1000, 'offset_ms': offset * 1000}, + }, + }, + }), + windowJson: '{}', + finalized: true, + ); + + expect(await cleanNextNightNapsOnce(), 1); + final row = await LocalDb.dayResult('2026-08-02'); + expect((row!['algo_version'] as num).toInt(), kAlgoVersion); + final out = jsonDecode(row['payload_json'] as String) as Map; + final naps = (out['naps'] as Map)['value'] as List; + expect(naps.map((n) => (n as Map)['start']), [afternoon['start']]); + expect((out['naps'] as Map)['count'], 1); + expect((out['scalars'] as Map)['nap_min'], 30.0); + expect((out['scalars'] as Map)['readiness'], 64.0); + final series = await LocalDb.metricSeries('nap_min'); + expect(series.where((r) => r['date'] == '2026-08-02').single['value'], 30.0); + + // One-shot: a second call does nothing. + expect(await cleanNextNightNapsOnce(), 0); + }); +} From 45f8d8ed986395e1601f5a7e3b9db1763ae1099a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:09:26 +0530 Subject: [PATCH 20/41] breathing-rate variability ignored the artifact gate that withholds the breathing rate the per-window rsa rates behind brv_cv and brv_slope were computed with an artifact fraction of 0, so a loose-strap night showed the breathing rate as withheld and still published how much it varied. the windows now take the night's artifact fraction, so brv abstains on the same nights resp_rate does. (cherry picked from commit 26a0b49388ccf2f0a085b8f602d0e63bc0668f40) --- lib/compute/onehz_pipeline.dart | 16 +++++++++---- test/brv_artifact_gate_test.dart | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 test/brv_artifact_gate_test.dart diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index dbf8b35db..ca39e1d30 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -749,7 +749,8 @@ Map deriveDayBundle(Map inputJson) { // ── BREATHING-RATE VARIABILITY (per-window RSA over the sleep NN) ────────── // Window the cleaned sleep NN into ~30-min bins, take each bin's RSA resp rate, // then BRV = dispersion + Theil-Sen trend of those per-window rates. - final respWindows = _respPerWindow(nn, nnTimes); + final respWindows = + respPerWindow(nn, nnTimes, artifactFraction: artifactFraction); final brv = respWindows.length >= 3 ? breathingRateVariability(respWindows) : Metric.absent( @@ -1922,9 +1923,16 @@ double? sleepClockOffsetSec(int epochSec) { /// Per-window RSA respiratory rates (br/min) for the BRV estimator. Buckets the /// cleaned NN into [windowMs] (~30-min) bins by beat time and runs [rsaRespRate] /// on each bin with ≥[minBeats] beats; keeps only resolved windows. -List _respPerWindow( +/// +/// [artifactFraction] is the night's share of corrected beats. The night-level +/// [rsaRespRate] call refuses the breathing rate when that share is above its +/// gate, because interpolated beats move the RSA peak; the bins are cut from +/// the same corrected NN, so they carry the same gate. With 0 here, BRV was +/// published on nights whose breathing rate was withheld as unmeasurable. +List respPerWindow( List nn, List nnTimes, { + required double artifactFraction, double windowMs = 1800000.0, int minBeats = 60, }) { @@ -1942,8 +1950,8 @@ List _respPerWindow( for (final idx in idxs) { final segNn = binsNn[idx]!; if (segNn.length < minBeats) continue; - // NN is already artifact-corrected upstream → artifactFraction 0. - final r = rsaRespRate(segNn, binsTs[idx]!, artifactFraction: 0.0); + final r = + rsaRespRate(segNn, binsTs[idx]!, artifactFraction: artifactFraction); final b = r.present ? r.value!.brpm : null; if (b != null) out.add(b); } diff --git a/test/brv_artifact_gate_test.dart b/test/brv_artifact_gate_test.dart new file mode 100644 index 000000000..becedac93 --- /dev/null +++ b/test/brv_artifact_gate_test.dart @@ -0,0 +1,40 @@ +// Bug: the night's breathing rate (`rsaRespRate` over the corrected sleep NN) +// is withheld when more than 30% of beats needed correction, but the per-window +// rates feeding breathing-rate variability were computed from the same +// corrected NN with `artifactFraction: 0.0` hard-coded. On a loose-strap night +// the screen showed resp_rate as "—" and still published brv_cv/brv_slope +// directly underneath it, built from interpolated beats. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; + +void main() { + // 3 h of beats at ~60 bpm with a clean 15 br/min (0.25 Hz) RSA modulation. + (List, List) rsaNight() { + final nn = []; + final ts = []; + var t = 1780000000000.0; + while (t < 1780000000000.0 + 3 * 3600 * 1000) { + final rr = 1000.0 + 60.0 * math.sin(2 * math.pi * 0.25 * t / 1000.0); + t += rr; + nn.add(rr); + ts.add(t); + } + return (nn, ts); + } + + test('windows resolve on a clean night', () { + final (nn, ts) = rsaNight(); + final clean = respPerWindow(nn, ts, artifactFraction: 0.0); + expect(clean.length, greaterThanOrEqualTo(3)); + // Below the gate the fraction only moves confidence, never the rate. + expect(respPerWindow(nn, ts, artifactFraction: 0.10), clean); + }); + + test('windows carry the night-level artifact gate', () { + final (nn, ts) = rsaNight(); + expect(respPerWindow(nn, ts, artifactFraction: 0.38), isEmpty); + }); +} From dc5410fe1f4a17833f2e8057e3aa2369461ebcb8 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:09:17 +0530 Subject: [PATCH 21/41] a night with no sleep-session rmssd stored yesterday's lnrmssd band as today's when tonight's rmssd was refused or missing, the plews readiness was still run on the history alone, so the previous night was scored as tonight and its suppressed/normal/elevated band landed under today's date. it is now absent with a note on those nights. (cherry picked from commit 149a52e982c5d8527069567a3fef7d3e3ef3f832) --- lib/compute/onehz_pipeline.dart | 16 ++++++++++++---- test/readiness_lnrmssd_wear_gap_test.dart | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index ca39e1d30..ad0a29274 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -989,15 +989,23 @@ Map deriveDayBundle(Map inputJson) { final lnDates = d.lnRmssdDates.length == d.lnRmssdHistory.length ? [...d.lnRmssdDates, if (lnToday != null) d.date] : null; - final lnReadiness = lnHist.length >= 4 - ? readinessLnRmssd(lnHist, dates: lnDates) - : Metric.absent( + final lnReadiness = lnHist.length < 4 + ? Metric.absent( tier: Tier.high, inputs_used: const ['ln_rmssd_history'], // A BASELINE shortfall, so it gets the baseline grammar the UI // already turns into "Need N more nights". note: needBaselineNote(have: lnHist.length, need: 4), - ); + ) + // Without tonight's value the last element is the previous night, and + // scoring it would store yesterday's band under today. + : lnToday == null + ? const Metric.absent( + tier: Tier.high, + inputs_used: ['ln_rmssd_history', 'sleep_session_rmssd'], + note: 'no sleep-session RMSSD tonight, so there is no night to score', + ) + : readinessLnRmssd(lnHist, dates: lnDates); // ── STRAIN: Banister TRIMP over the WAKE span (per-minute day HR) ────────── final prof = d.profile; diff --git a/test/readiness_lnrmssd_wear_gap_test.dart b/test/readiness_lnrmssd_wear_gap_test.dart index a51ed48af..6ce86f8c6 100644 --- a/test/readiness_lnrmssd_wear_gap_test.dart +++ b/test/readiness_lnrmssd_wear_gap_test.dart @@ -19,6 +19,7 @@ String _label(DateTime d) => Map _lnReadiness({ required List historyDates, bool withDates = true, + bool tonightRr = true, }) { final rr = [], ts = []; var t = _t0 * 1000.0; @@ -37,8 +38,8 @@ Map _lnReadiness({ dayHr: hr, sleepTsSec: sleepTs, sleepHr: hr, - sleepRrMs: rr, - sleepRrTsMs: ts, + sleepRrMs: tonightRr ? rr : const [], + sleepRrTsMs: tonightRr ? ts : const [], sleepSkinTemp: List.filled(_nightSec, 0), sleepJson: const {'tst_sec': _nightSec}, hypnoStages: const [], @@ -83,4 +84,13 @@ void main() { expect(env['value'], isA()); expect((env['value'] as Map)['band'], isNotNull); }); + + test('a night with no RMSSD does not score the previous night as tonight', + () { + final env = _lnReadiness( + historyDates: _nightsEnding(DateTime(2026, 8, 13), 21), + tonightRr: false); + expect(env['value'], isNot(isA())); + expect(env['note'] as String, contains('no sleep-session RMSSD')); + }); } From ab52e320fb69c7e1bf344467af3b02812cc325ba Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:11:14 +0530 Subject: [PATCH 22/41] re-timing a night to a mostly unrecorded window was silently declined when i set my own sleep window and too little of it was recorded, the window staged to nothing and every night scalar came back null. the night-substrate guard read that as raw aged out from under a real night and kept the old auto row, so the correction never applied and every later pass declined it again. the prune check had the same problem: an unstaged window has onset 0, which counts as cut by any prune. a user-set window (manual or confirmed) no longer trips the night-substrate check, and the prune check uses the override's own onset when the staged one is 0, falling back to the day's lookback. (cherry picked from commit d27315369aa2ec4315b69e82cc76e55db0c4f19d) --- lib/compute/derivation_engine.dart | 32 ++++++++++++++++++++++--- test/derive_result_protection_test.dart | 12 ++++++++++ test/sleep_override_rederive_test.dart | 13 ++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 69f1c2ca8..039e96caf 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -4978,7 +4978,13 @@ class DerivationEngine { windowTruncatedByPrune( dayId, prunedBeforeSec, - forcedOnsetSec: sleepSource == 'manual' || sleepSource == 'confirmed' + // A user window the segmenter abstained on carries onset 0 (no + // staged window), and 0 is older than any prune cursor: fall back to + // the day's own lookback rather than calling every such day cut. + forcedOnsetSec: (sleepSource == 'manual' || + sleepSource == 'confirmed') && + sleepOnsetSec != null && + sleepOnsetSec > 0 ? sleepOnsetSec : null, ); @@ -5468,9 +5474,16 @@ class DerivationEngine { // cut keeps the oldest kept day's own rows but drops the evening half of // its night, so sleepSub is non-empty and the scalars come back from a // partial night. Every path (algo bump, force, override, rescan) lands here. + // A user-set window is staged from the override, not from a replayed + // candidate, so an empty night substrate there means the window itself + // had too little recording (or no sleep), not that raw aged out — the + // prune leg below answers that against the override's own onset. + final userSetWindow = + day.sleepSource == 'manual' || day.sleepSource == 'confirmed'; if (!producedNothing && (nightSubstrateRegressed( sleepRejected: day.sleepSource == 'rejected', + userSetWindow: userSetWindow, sleepSubEmpty: sleepSub.isEmpty, nightScalarsNull: scMap == null || (scMap['rhr'] == null && @@ -5484,7 +5497,11 @@ class DerivationEngine { day.date, await LocalDb.getCursorInt(_prunedBeforeCursor), sleepSource: day.sleepSource, - sleepOnsetSec: day.sleepOnsetSec, + sleepOnsetSec: day.sleepOnsetSec > 0 || !userSetWindow + ? day.sleepOnsetSec + : ((await LocalDb.getSleepOverride(day.date))?[ + 'onset_ts'] as num?) + ?.toInt(), )))) { final existingNight = await LocalDb.dayResult(day.date); final existingHadNight = existingNight != null && @@ -6218,11 +6235,20 @@ class DerivationEngine { /// [sleepRejected] (the user said "not sleep") is never a regression: the /// null night is what they asked for, and the existing row is the very /// night they rejected. + /// + /// [userSetWindow] (a 'manual' or 'confirmed' override) is not one either: + /// that window is staged from the override itself, so an empty night + /// substrate means too little of it was recorded, and the existing row is + /// usually the auto night being corrected — declining kept that night on + /// screen and re-declined every later pass. Whether the prune cut the + /// user's window is the prune check's job, on the override's own onset. static bool nightSubstrateRegressed({ bool sleepRejected = false, + bool userSetWindow = false, required bool sleepSubEmpty, required bool nightScalarsNull, - }) => !sleepRejected && sleepSubEmpty && nightScalarsNull; + }) => + !sleepRejected && !userSetWindow && sleepSubEmpty && nightScalarsNull; /// Whether [row] is a REAL derived day result worth protecting — i.e. not a /// skip marker and not an all-absent shell. diff --git a/test/derive_result_protection_test.dart b/test/derive_result_protection_test.dart index 511592fde..502dba79c 100644 --- a/test/derive_result_protection_test.dart +++ b/test/derive_result_protection_test.dart @@ -511,6 +511,18 @@ void main() { ); }); + test('a user-set window with too little recorded is not a regression, ' + 'so the correction replaces the auto night it was correcting', () { + expect( + DerivationEngine.nightSubstrateRegressed( + userSetWindow: true, + sleepSubEmpty: true, + nightScalarsNull: true, + ), + isFalse, + ); + }); + test('real night scalars computed is not a regression', () { expect( DerivationEngine.nightSubstrateRegressed( diff --git a/test/sleep_override_rederive_test.dart b/test/sleep_override_rederive_test.dart index d76567d02..76f816dc0 100644 --- a/test/sleep_override_rederive_test.dart +++ b/test/sleep_override_rederive_test.dart @@ -281,4 +281,17 @@ void main() { isTrue); expect(DerivationEngine.sleepWindowTruncatedByPrune(day, null), isFalse); }); + + test('a user window that staged to nothing (onset 0) is judged on the ' + "day's lookback, not as cut by every prune", () { + const day = '2026-08-12'; + final cut = DateTime(2026, 8, 10).millisecondsSinceEpoch ~/ 1000; + for (final source in ['manual', 'confirmed']) { + expect( + DerivationEngine.sleepWindowTruncatedByPrune(day, cut, + sleepSource: source, sleepOnsetSec: 0), + isFalse, + reason: source); + } + }); } From ba660548c3a0b818bd555e3f9659591eaa56cdda Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:11:25 +0530 Subject: [PATCH 23/41] back to automatic and undo on not sleep never reached a finalized night clearing a sleep override deletes its row before the re-derive, so the force pass no longer saw the day as an override day and skipped it as finalized. any night 2-5 days back kept the manual window or stayed marked not sleep. the edited day is now passed to the force pass explicitly, and a failed undo on a rejected night shows the same failure card the other branches do. (cherry picked from commit 2727a8e770e9acc70a3a1f2d25b042d0b03eda97) --- lib/compute/derivation_engine.dart | 5 ++ lib/state/app_state.dart | 6 +- lib/ui2/screens/sleep_detail.dart | 7 ++ test/cleared_override_finalized_day_test.dart | 81 +++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 test/cleared_override_finalized_day_test.dart diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 039e96caf..716b6052f 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -3016,6 +3016,7 @@ class DerivationEngine { Profile profile, { bool heavy = false, bool force = false, + Set editedDays = const {}, void Function(String day, int index, int total)? onDayDone, void Function(int failedDays, String? error)? onOutcome, }) async { @@ -3146,6 +3147,10 @@ class DerivationEngine { ...await LocalDb.sleepOverrideDays(), // A nap edit on a finalized day has to take effect too — same reason. ...await LocalDb.napEditDays(), + // A day whose override was just CLEARED is no longer listed above, but + // its finalized row still holds the user's window (or the rejected + // night) and has to be re-derived back to automatic. + ...editedDays, }; final todoDays = [ for (final day in scope.targetDays) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index efab43440..af270bcfa 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2634,7 +2634,11 @@ class AppState extends ChangeNotifier { } if (editedDay != null) await LocalDb.releaseFrozenHeadline(editedDay); } while (_derive.running); - await _derive.run(_profile, force: true); + await _derive.run( + _profile, + force: true, + editedDays: {if (editedDay != null) editedDay}, + ); if (editedDay != null) { // A finalized day behind the export cursor is never re-written, so // the corrected (or rejected) night would never reach the health store. diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 49e477395..c17f6ddf2 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -604,6 +604,13 @@ class _SleepDetailState extends State with RevisionReload { child: Text( l?.sleepDetailUndoRejection ?? 'Undo — go back to automatic'), ), + if (!_saving && _overrideFailed != null) + StatusCard( + l?.sleepDetailCorrectionFailedTitle ?? + 'That correction has not been applied', + _overrideFailed!, + icon: LucideIcons.triangleAlert, + ), ] else if (rejectedDay != null) ...[ // A night the detector missed is the other half of "it got it // wrong": the same manual window a mis-staged night gets, stored as diff --git a/test/cleared_override_finalized_day_test.dart b/test/cleared_override_finalized_day_test.dart new file mode 100644 index 000000000..0eb36a91f --- /dev/null +++ b/test/cleared_override_finalized_day_test.dart @@ -0,0 +1,81 @@ +// Clearing a sleep override deletes its row before the re-derive, so the day +// is no longer an override day. A night old enough to be finalized has to be +// re-derived anyway, or "Back to automatic" / "Undo" on Not sleep leaves the +// user's window (or the rejected night) on screen. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/compute/profile.dart'; +import 'package:openstrap_edge/data/db.dart'; + +const _profile = Profile( + ageYears: 35, + sex: 'm', + weightKg: 75, + heightCm: 178, + restingHrManual: 55, +); + +Future _seedDay(String day) async { + final d = DateTime.parse(day); + final ts = DateTime(d.year, d.month, d.day, 12).millisecondsSinceEpoch ~/ 1000; + await (await LocalDb.instance).insert( + 'decoded_onehz', + { + 'device_id': '', + 'ts_ms': ts * 1000, + 'rec_ts': ts, + 'counter': ts % 100000, + 'hr': 60, + 'ax': 0.0, + 'ay': -0.5, + 'az': 0.98, + 'device_family': 'gen4', + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_cleared_override_finalized_day_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + test('a force pass re-derives a finalized day whose override was cleared', + () async { + const day = '2026-08-20'; + await _seedDay(day); + // Written finalized while the override was in place. + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + payloadJson: '{}', + windowJson: '{}', + finalized: true, + ); + expect(await LocalDb.sleepOverrideDays(), isNot(contains(day))); + + final engine = DerivationEngine(); + await engine.run(_profile, force: true); + expect(engine.snapshot()['todo_days'], 0, + reason: 'without the edited day a finalized day is skipped'); + + await engine.run(_profile, force: true, editedDays: {day}); + expect(engine.snapshot()['todo_days'], 1); + }); +} From 2ad1dfb1b0d71c7f12612139777ebf05fb2882fc Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:12:06 +0530 Subject: [PATCH 24/41] day strain chart drew a flat line under a workout-raised headline the headline strain is floored at the strongest finished session that day, but the strain-through-the-day curve under it was left as the hr-only build-up, so a 0.6 workout on an otherwise quiet day showed 0.6 above a line pinned at 0. the curve now gets the same floor from each session's end onward, with a point at the end of a session that finished after the last hr minute, so it finishes on the headline. (cherry picked from commit 05272b143713eadc8d3a502dc6d66c9a009bd9f4) --- lib/compute/derivation_engine.dart | 77 +++++++++++++++++++++++++++++- test/study_scoring_rules_test.dart | 50 +++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 716b6052f..4580bd620 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -5795,8 +5795,7 @@ class DerivationEngine { } } // A session floor raised the scalar above the HR-only envelope: the - // envelope carries the same number and says why. `series.strain_curve` - // stays the HR build-up through the day. + // envelope carries the same number and says why. final strainEnv = (bundle['clinical'] as Map?)?['strain']; final scStrain = scMap?['strain']; if (strainEnv is Map && @@ -5809,6 +5808,23 @@ class DerivationEngine { 'logged this day can raise (heart-rate strain ' '${hrStrain.toStringAsFixed(1)})'; } + // `series.strain_curve` is drawn right under that headline on the same + // 0-21 axis. Left as the HR-only build-up it ended below a floored + // headline — a flat line at 0 under "0.6" — so it gets the same floor + // from each session's end onward. Copied into a fresh dynamic-valued map + // for the same reason as `coverage` above: the pipeline's series map + // literal has a narrower inferred value type. + final seriesNow = bundle['series']; + final curveNow = seriesNow is Map ? seriesNow['strain_curve'] : null; + if (scStrain is num && curveNow is List && curveNow.isNotEmpty) { + final seriesMap = Map.from(seriesNow as Map); + seriesMap['strain_curve'] = strainCurveFlooredAtSessions( + curveNow, + savedSessions, + capSec: localNextMidnightSecForDayLabel(day.date) - 1, + ); + bundle['series'] = seriesMap; + } // Method, family and class on each envelope of a wearable's day // (inputs/canonical.dart). A band's day is untouched. tagEnvelopes(bundle['clinical'] as Map?, daySub.deviceFamily); @@ -7599,6 +7615,63 @@ class DerivationEngine { return best; } + /// [curve] (`series.strain_curve`, `{t, v}` points) with each point raised + /// to the strain of every finished, non-private session that had ENDED by + /// then — the same sessions [strainFlooredAtSessions] lifts the headline to. + /// Without this the headline could read 0.6 above a line pinned at 0 all + /// day. A session ending after the last point adds one point at its end + /// (capped at [capSec]) so the line still finishes on the headline. An + /// empty curve stays empty: there is no HR build-up to draw. + @visibleForTesting + static List> strainCurveFlooredAtSessions( + List curve, + List> sessions, { + int? capSec, + }) { + final points = >[ + for (final p in curve.whereType()) + if (p['t'] is num && p['v'] is num) + {'t': p['t'], 'v': p['v']}, + ]; + if (points.isEmpty) return points; + final floors = <(int, double)>[]; + for (final s in sessions) { + if (s['status'] != 'done') continue; + if ((s['private'] as num?)?.toInt() == 1) continue; + final v = (s['strain'] as num?)?.toDouble(); + final end = (s['end_ts'] as num?)?.toInt(); + if (v == null || !v.isFinite || end == null) continue; + floors.add((end, v)); + } + if (floors.isEmpty) return points; + floors.sort((a, b) => a.$1.compareTo(b.$1)); + var fi = 0; + var floor = double.negativeInfinity; + for (final p in points) { + final t = (p['t'] as num).toInt(); + while (fi < floors.length && floors[fi].$1 <= t) { + if (floors[fi].$2 > floor) floor = floors[fi].$2; + fi++; + } + if (floor > (p['v'] as num)) p['v'] = floor; + } + var lastT = (points.last['t'] as num).toInt(); + var lastV = (points.last['v'] as num).toDouble(); + for (; fi < floors.length; fi++) { + final (end, v) = floors[fi]; + if (v <= lastV) continue; + final t = capSec != null && end > capSec ? capSec : end; + if (t <= lastT) { + points.last['v'] = v; + } else { + points.add({'t': t, 'v': v}); + lastT = t; + } + lastV = v; + } + return points; + } + static void _applyWakeDayFeatures( Map bundle, Map? scMap, diff --git a/test/study_scoring_rules_test.dart b/test/study_scoring_rules_test.dart index d11af913d..ac7cb5a41 100644 --- a/test/study_scoring_rules_test.dart +++ b/test/study_scoring_rules_test.dart @@ -20,6 +20,56 @@ void main() { expect(DerivationEngine.strainFlooredAtSessions(null, s), isNull); }); + test('strain curve is floored at a session from its end, like the headline', + () { + // #471: a workout scored 0.6 while the day's HR build-up stayed at 0 — + // the headline read 0.6 above a line pinned at 0. + final curve = [ + for (var t = 1000; t <= 1300; t += 60) {'t': t, 'v': 0.0}, + ]; + final s = [ + {'status': 'done', 'strain': 0.6, 'end_ts': 1120}, + {'status': 'live', 'strain': 9.0, 'end_ts': 1000}, + {'status': 'done', 'strain': 9.0, 'end_ts': 1000, 'private': 1}, + ]; + final out = DerivationEngine.strainCurveFlooredAtSessions(curve, s); + expect([for (final p in out) p['v']], [0.0, 0.0, 0.6, 0.6, 0.6, 0.6]); + expect(out.last['v'], + DerivationEngine.strainFlooredAtSessions(0.0, s)); + // The stored curve is not mutated in place. + expect(curve.every((p) => p['v'] == 0.0), isTrue); + + // HR build-up already above the session keeps its own values. + final high = [ + {'t': 1000, 'v': 0.2}, + {'t': 1200, 'v': 4.0}, + ]; + expect( + [ + for (final p in DerivationEngine.strainCurveFlooredAtSessions( + high, [{'status': 'done', 'strain': 3.0, 'end_ts': 1100}])) + p['v'] + ], + [0.2, 4.0]); + + // A session ending after the last point adds a point at its end, capped. + final late = DerivationEngine.strainCurveFlooredAtSessions( + [{'t': 1000, 'v': 1.0}], + [{'status': 'done', 'strain': 5.0, 'end_ts': 9000}], + capSec: 2000, + ); + expect(late, [ + {'t': 1000, 'v': 1.0}, + {'t': 2000, 'v': 5.0}, + ]); + + // No HR build-up to draw: no curve is invented from a session. + expect( + DerivationEngine.strainCurveFlooredAtSessions( + const [], [{'status': 'done', 'strain': 5.0, 'end_ts': 900}]), + isEmpty); + }); + test('resting HR fallback is the median of three or more prior nights', () { expect(rhrFallbackFromHistory([55, 60]), isNull); expect(rhrFallbackFromHistory([55, 70, 60]), 60); From 7907baa77eb937ed9a754e732a5e868dce162d23 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:11:39 +0530 Subject: [PATCH 25/41] readiness breakdown ranked tonight against every device's history the 'what helped / held you back' breakdown and the percentile-of-you ranks built their hrv, resting hr and breathing-rate history from every prior night, so after switching from the band to a ring the ring's first weeks were ranked against the band's numbers and hrv sat under 'held you back' while the headline readiness, which keeps a baseline per method family, called it normal. the cross-day rollup now keeps only prior nights of tonight's method family for those inputs; a whoop 4 to whoop 5 swap is still one history. (cherry picked from commit 8e76bdb78b333ef42b36e7991293d974280efbcf) --- lib/compute/crossday_pipeline.dart | 38 +++++++++++++++--- lib/compute/derivation_engine.dart | 2 + test/crossday_pipeline_test.dart | 62 ++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/lib/compute/crossday_pipeline.dart b/lib/compute/crossday_pipeline.dart index 644beb824..c722efc90 100644 --- a/lib/compute/crossday_pipeline.dart +++ b/lib/compute/crossday_pipeline.dart @@ -50,11 +50,17 @@ import 'onehz_pipeline.dart' show needInputNote; /// that day (TS-11). A day with more than one is dropped by the analytics, not /// split — it belongs to no single type. Empty ⇒ `session_cost` is absent, /// which is also its state for the first ten sessions of any type. +/// +/// [methodFamilies] maps a device family to its method family (the +/// engine's `kMethodFamily`). The breakdown's HRV, resting HR and breathing +/// rate compare tonight only with prior nights of tonight's method family. +/// Empty ⇒ nights compare by their raw device family. Map buildCrossDayBundle( List> daysOldestFirst, Map profile, { List cycleStartDates = const [], Map> sessionTypesByDate = const {}, + Map methodFamilies = const {}, }) { final days = daysOldestFirst; final n = days.length; @@ -205,13 +211,35 @@ Map buildCrossDayBundle( : allDurH.sublist(allDurH.length - 7); final sleepDebt = ana.sleepDebt(recentDurH, freeDurH); + // A ring's vendor RMSSD or a 1-min watch's resting HR is not the same + // quantity as a band's 1 Hz one, and the headline readiness keeps a separate + // baseline per method family for exactly that reason. The breakdown below + // ranks tonight against its history, so that history has to be split the + // same way, or the first weeks on a new wearable read as "held you back" + // against the old one's numbers while the headline calls them normal. + // `skin_temp_family` is the day's device family (it is not temperature- + // specific); an unstamped day is a band's, as in the baseline cache. + String? methodOf(int i) { + final device = (days[i]['skin_temp_family'] as String?) ?? 'gen4'; + return methodFamilies[device] ?? device; + } + + final tonightMethod = n == 0 ? null : methodOf(n - 1); + List sameMethod(List series) => [ + for (var i = 0; i < n; i++) + i == n - 1 || methodOf(i) == tonightMethod ? series[i] : null, + ]; + final rmssdOwn = sameMethod(rmssdList); + final rhrOwn = sameMethod(rhrList); + final respOwn = sameMethod(respList); + // ── percentile-of-you for today vs history (history = all-but-last) ──────── // rhr is LOWER-is-better — the same orientation `_glassInput` ten lines below // has always passed for this exact list. Unoriented, a night with the user's // lowest resting HR in a month came back labelled "among your worst". final percentiles = { - 'rmssd': _pctOfYou(rmssdList, ana.Better.higher), - 'rhr': _pctOfYou(rhrList, ana.Better.lower), + 'rmssd': _pctOfYou(rmssdOwn, ana.Better.higher), + 'rhr': _pctOfYou(rhrOwn, ana.Better.lower), 'readiness': _pctOfYou(readyList, ana.Better.higher), }; @@ -219,12 +247,12 @@ Map buildCrossDayBundle( // rmssd higher-better; rhr/resp lower-better; skin_temp_z lower-ABS better // (so we orient temp by its absolute deviation, lower=better). final gbInputs = []; - final gbRmssd = _glassInput('hrv', rmssdList, ana.wHrv, lowerIsBetter: false); + final gbRmssd = _glassInput('hrv', rmssdOwn, ana.wHrv, lowerIsBetter: false); if (gbRmssd != null) gbInputs.add(gbRmssd); final gbRhr = - _glassInput('rhr', rhrList, ana.wRhr, lowerIsBetter: true, quantum: 1); + _glassInput('rhr', rhrOwn, ana.wRhr, lowerIsBetter: true, quantum: 1); if (gbRhr != null) gbInputs.add(gbRhr); - final gbResp = _glassInput('resp', respList, ana.wResp, lowerIsBetter: true); + final gbResp = _glassInput('resp', respOwn, ana.wResp, lowerIsBetter: true); if (gbResp != null) gbInputs.add(gbResp); // temp: use absolute z so "further from your baseline" is worse. `tempList` // here is `skin_temp_z` — already a z-score against the raw-ADC baseline diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 4580bd620..07c26845a 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -59,6 +59,7 @@ import 'inputs/garmin_inputs.dart' show isWearableFamily, kHrCadenceSec, + kMethodFamily, kOneHzRunGapSec, kWearableFamilies, methodFamily; @@ -6677,6 +6678,7 @@ class DerivationEngine { profileMap, cycleStartDates: cycleStarts, sessionTypesByDate: sessionTypes, + methodFamilies: kMethodFamily, ) ..['algo_version'] = kAlgoVersion ..['built_for_day'] = builtForDay diff --git a/test/crossday_pipeline_test.dart b/test/crossday_pipeline_test.dart index afcf2f6c4..80f040187 100644 --- a/test/crossday_pipeline_test.dart +++ b/test/crossday_pipeline_test.dart @@ -817,4 +817,66 @@ void _wiredFamilies() { expect((b['temp_illness'] as Map)['date'], days.last['date']); }); }); + + group('the breakdown ranks tonight against its own method family', () { + const methods = {'gen4': 'hr_1hz', 'gen5': 'hr_1hz', 'oura': 'hr_5min'}; + + // 30 band nights, then 20 ring nights whose vendor HRV runs ~20 ms lower + // and whose resting HR runs ~8 bpm higher. Both series are steady within + // their own device; only the switch is a step. + List> switched(String later) { + final days = _synthDays(50); + for (var i = 0; i < days.length; i++) { + final wobble = (i % 5) - 2; + final ring = i >= 30; + days[i]['skin_temp_family'] = ring ? later : 'gen4'; + if (ring && later == 'oura') { + days[i]['rmssd'] = 25.0 + wobble * 1.5; + days[i]['rhr'] = 63.0 + wobble; + } + } + // Tonight is the ring's median night, not its worst. + days.last['rmssd'] = 25.0; + days.last['rhr'] = 63.0; + return days; + } + + Map pct(Map b, String k) => + ((b['percentiles'] as Map)[k] as Map)['value'] as Map; + + test('a new ring is not ranked against the band it replaced', () { + final b = buildCrossDayBundle(switched('oura'), const {}, + methodFamilies: methods); + // Only the 19 prior ring nights are history. + expect(pct(b, 'rmssd')['n'], 19); + expect(pct(b, 'rhr')['n'], 19); + // Tonight is the ring's median, so it ranks mid-history. Mixed with the + // 30 band nights it ranked near the bottom (~20th percentile for HRV, + // ~80th for resting HR). + expect(pct(b, 'rmssd')['percentile_of_you'], greaterThan(40)); + expect(pct(b, 'rhr')['percentile_of_you'], lessThan(60)); + final items = (((b['readiness_glassbox'] as Map)['value'] + as Map)['breakdown'] as List) + .cast(); + final hrv = items.firstWhere((m) => m['label'] == 'hrv'); + expect(hrv['percentile_of_you'], greaterThan(40)); + }); + + test('WHOOP 4 to WHOOP 5 keeps one history (same method family)', () { + final b = buildCrossDayBundle(switched('gen5'), const {}, + methodFamilies: methods); + expect(pct(b, 'rmssd')['n'], 49); + expect(pct(b, 'rhr')['n'], 49); + }); + + test('an unstamped night counts as a band night', () { + final days = switched('gen5'); + for (var i = 0; i < 20; i++) { + days[i].remove('skin_temp_family'); + } + final b = + buildCrossDayBundle(days, const {}, methodFamilies: methods); + expect(pct(b, 'rmssd')['n'], 49); + }); + }); } From 0611cb8a54a245e1327b52be4b9d6db36facec84 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:17:30 +0530 Subject: [PATCH 26/41] the timezone travel hold never let go on its own after a trip of 3h or more, every pending day next to a finalized one was held until the user happened to run re-analyze, and nothing on screen said so. days stayed half-scored for the whole trip. the guard now remembers which zone the phone moved to and since when; once that zone has stood for a day, a heavy pass derives every pending raw day under it (what the restage would have done) and the hold clears the same way a restage clears it. (cherry picked from commit fff33e874b25f6c12b12a0a86e0d49943d9c6094) --- lib/compute/derivation_engine.dart | 118 +++++++++++++++++++++++------ test/derivation_scope_test.dart | 112 +++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 24 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 07c26845a..e87008a88 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -2393,6 +2393,60 @@ Future debugBaselineSignature() async => (await _BaselineHistoryCache.load()).toArtifactJson()['signature'] as String; +/// How long the device has to sit in a new timezone (>=3h from the guard's +/// baseline) before a heavy pass releases the travel hold on its own. Long +/// enough that a layover or a same-day hop does not trigger it. +const int kTzTravelSettleMs = 24 * 60 * 60 * 1000; +const int _tzJumpThresholdMin = 180; + +/// One step of the timezone-travel guard, pure so it can be tested without a +/// database or a real clock. [stored] is the decoded `tz_travel_guard` +/// payload; `write` is what to persist (null = leave it as it is). +/// +/// `offset_min` is the baseline zone and only moves when no jump stands. +/// While one does, `away_offset_min`/`away_since_ms` record which zone the +/// device is in and since when, so the hold can end once that zone has +/// stood for [kTzTravelSettleMs] instead of waiting for a manual restage. +@visibleForTesting +({bool held, bool settled, Map? write}) evaluateTzTravelGuard({ + required Map? stored, + required int nowOffsetMin, + required int nowMs, +}) { + final lastOffsetMin = (stored?['offset_min'] as num?)?.toInt(); + final jumped = lastOffsetMin != null && + (nowOffsetMin - lastOffsetMin).abs() >= _tzJumpThresholdMin; + if (!jumped) { + return ( + held: false, + settled: false, + write: {'offset_min': nowOffsetMin}, + ); + } + final awayOffsetMin = (stored?['away_offset_min'] as num?)?.toInt(); + final awaySinceMs = (stored?['away_since_ms'] as num?)?.toInt(); + // A different away zone (a second hop) or a clock that went backwards + // restarts the wait rather than counting time spent somewhere else. + if (awayOffsetMin != nowOffsetMin || + awaySinceMs == null || + awaySinceMs > nowMs) { + return ( + held: true, + settled: false, + write: { + 'offset_min': lastOffsetMin, + 'away_offset_min': nowOffsetMin, + 'away_since_ms': nowMs, + }, + ); + } + return ( + held: true, + settled: nowMs - awaySinceMs >= kTzTravelSettleMs, + write: null, + ); +} + @visibleForTesting ({List days, String reason}) selectLightDeriveDays({ required Set rawDays, @@ -4497,7 +4551,26 @@ class DerivationEngine { // silently landing on an already-finalized day_id and looking lost. Once // that's detected, hold off auto-deriving anything adjacent to finalized // data until "Re-analyze data" (full restage) resolves it properly. - if (await _timezoneTravelSuspected()) { + final tz = await _tzTravelGuard(); + if (tz.held) { + // Holding forever is worse than the relabel risk: every day that lands + // next to a newly finalized one is held too, for the whole trip, and + // nothing on screen tells the user that only "Re-analyze data" lets + // them go. Once the new zone has stood for a day, a heavy pass does what + // that restage would — derive every pending raw day under the current + // zone — and the end of run() clears the hold if none of them failed. + if (heavy && tz.settled) { + _log( + 'derive: new timezone stable — deriving the held days and ' + 'releasing the timezone hold', + ); + return _scopeForDays( + rawDays, + reason: 'tz-travel-settled', + fullHistory: true, + rawDays: rawDays, + ); + } final adjacent = { for (final day in finalized) ..._adjacentDayIds(day), }; @@ -4555,40 +4628,37 @@ class DerivationEngine { /// True right after the device timezone jumps by more than a real DST shift /// ever would (>=3h) — a strong signal of actual cross-timezone travel /// rather than a seasonal clock change. Stays true across repeated calls - /// (derive runs many times a day) by only ever updating the persisted - /// baseline offset when NO jump is detected — updating it unconditionally + /// (derive runs many times a day) by only ever moving the persisted + /// baseline offset when NO jump is detected — moving it unconditionally /// would make the very next call see lastOffset == nowOffset and silently - /// drop the guard after a single pass. `force` (full restage) is what - /// resets it, per _deriveScope. - static const int _tzJumpThresholdMin = 180; - Future _timezoneTravelSuspected() async { - final nowOffsetMin = DateTime.now().timeZoneOffset.inMinutes; + /// drop the guard after a single pass. A full restage resets it, and so + /// does a heavy pass once the new zone has stood for + /// [kTzTravelSettleMs] (see _deriveScope). + Future _timezoneTravelSuspected() async => + (await _tzTravelGuard()).held; + + Future<({bool held, bool settled})> _tzTravelGuard() async { final row = await LocalDb.baseline('tz_travel_guard'); final raw = row?['payload_json']; - int? lastOffsetMin; + Map? stored; if (raw is String && raw.isNotEmpty) { try { final d = jsonDecode(raw); - if (d is Map) lastOffsetMin = (d['offset_min'] as num?)?.toInt(); + if (d is Map) stored = d; } catch (_) { // fall through — treat as unknown } } - if (lastOffsetMin == null) { - await LocalDb.putBaseline( - 'tz_travel_guard', - jsonEncode({'offset_min': nowOffsetMin}), - ); - return false; - } - final jumped = (nowOffsetMin - lastOffsetMin).abs() >= _tzJumpThresholdMin; - if (!jumped) { - await LocalDb.putBaseline( - 'tz_travel_guard', - jsonEncode({'offset_min': nowOffsetMin}), - ); + final step = evaluateTzTravelGuard( + stored: stored, + nowOffsetMin: DateTime.now().timeZoneOffset.inMinutes, + nowMs: DateTime.now().millisecondsSinceEpoch, + ); + final write = step.write; + if (write != null) { + await LocalDb.putBaseline('tz_travel_guard', jsonEncode(write)); } - return jumped; + return (held: step.held, settled: step.settled); } _DeriveScope _scopeForDays( diff --git a/test/derivation_scope_test.dart b/test/derivation_scope_test.dart index 780f2d3f3..956d7cd3d 100644 --- a/test/derivation_scope_test.dart +++ b/test/derivation_scope_test.dart @@ -42,4 +42,116 @@ void main() { }, ); }); + + group('evaluateTzTravelGuard', () { + const t0 = 1760000000000; + const hour = 60 * 60 * 1000; + + test('first call seeds the baseline and does not hold', () { + final step = evaluateTzTravelGuard( + stored: null, + nowOffsetMin: -300, + nowMs: t0, + ); + expect(step.held, isFalse); + expect(step.write, {'offset_min': -300}); + }); + + test('a DST-sized shift moves the baseline without holding', () { + final step = evaluateTzTravelGuard( + stored: {'offset_min': -300}, + nowOffsetMin: -240, + nowMs: t0, + ); + expect(step.held, isFalse); + expect(step.write, {'offset_min': -240}); + }); + + test('a 3h+ jump holds, keeps the old baseline and starts the clock', () { + final step = evaluateTzTravelGuard( + stored: {'offset_min': -300}, + nowOffsetMin: 0, + nowMs: t0, + ); + expect(step.held, isTrue); + expect(step.settled, isFalse); + expect(step.write, { + 'offset_min': -300, + 'away_offset_min': 0, + 'away_since_ms': t0, + }); + }); + + test('the hold settles once the new zone has stood for a day', () { + final stored = { + 'offset_min': -300, + 'away_offset_min': 0, + 'away_since_ms': t0, + }; + final early = evaluateTzTravelGuard( + stored: stored, + nowOffsetMin: 0, + nowMs: t0 + 23 * hour, + ); + expect(early.held, isTrue); + expect(early.settled, isFalse); + expect(early.write, isNull); + + final later = evaluateTzTravelGuard( + stored: stored, + nowOffsetMin: 0, + nowMs: t0 + kTzTravelSettleMs, + ); + expect(later.held, isTrue); + expect(later.settled, isTrue); + expect(later.write, isNull); + }); + + test('a second hop restarts the wait', () { + final step = evaluateTzTravelGuard( + stored: { + 'offset_min': -300, + 'away_offset_min': 0, + 'away_since_ms': t0, + }, + nowOffsetMin: 540, + nowMs: t0 + 30 * hour, + ); + expect(step.held, isTrue); + expect(step.settled, isFalse); + expect(step.write, { + 'offset_min': -300, + 'away_offset_min': 540, + 'away_since_ms': t0 + 30 * hour, + }); + }); + + test('flying home clears the away state', () { + final step = evaluateTzTravelGuard( + stored: { + 'offset_min': -300, + 'away_offset_min': 0, + 'away_since_ms': t0, + }, + nowOffsetMin: -300, + nowMs: t0 + 5 * hour, + ); + expect(step.held, isFalse); + expect(step.write, {'offset_min': -300}); + }); + + test('a clock that went backwards restarts the wait', () { + final step = evaluateTzTravelGuard( + stored: { + 'offset_min': -300, + 'away_offset_min': 0, + 'away_since_ms': t0, + }, + nowOffsetMin: 0, + nowMs: t0 - hour, + ); + expect(step.settled, isFalse); + expect(step.write?['away_since_ms'], t0 - hour); + }); + }); } From 8c46f5daf803ad3b6f3a4a516c98371869b2b047 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:51:30 +0530 Subject: [PATCH 27/41] a warm-bedroom night hid an open skin temperature run the published temp_illness entry picked the newest night with a raw skin temp value, but a whoop 4 night excluded as ambient-correlated still has one while the flag saw null and held it normal. that placeholder became the headline, so an elevated two-night run published as normal and its alert never fired. the headline now uses the same predicate that builds the flag's input. (cherry picked from commit 762760085e30076c345377c61c99c69460ea1905) --- lib/compute/crossday_pipeline.dart | 9 +++++++-- test/crossday_pipeline_test.dart | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/compute/crossday_pipeline.dart b/lib/compute/crossday_pipeline.dart index c722efc90..cd1b04662 100644 --- a/lib/compute/crossday_pipeline.dart +++ b/lib/compute/crossday_pipeline.dart @@ -560,8 +560,13 @@ Map buildCrossDayBundle( .length >= 2), ); - final latestTemp = - at(tempIllness, newestScored((i) => tempRawList[i] != null)); + // Same predicate that built the flag's input: an ambient-excluded night + // still has a raw value, but the flag saw null and held it normal. + final latestTemp = at( + tempIllness, + newestScored((i) => + days[i]['skin_temp_illness_ok'] != false && tempRawList[i] != null), + ); // per-day flags (for notifications / trends): asleep/illness/anomaly/temp, // plus the nightly RESTING HR the "your resting HR trend shifted" CUSUM diff --git a/test/crossday_pipeline_test.dart b/test/crossday_pipeline_test.dart index 80f040187..383c0f06c 100644 --- a/test/crossday_pipeline_test.dart +++ b/test/crossday_pipeline_test.dart @@ -801,6 +801,18 @@ void _wiredFamilies() { expect(f.sublist(f.length - 3), [false, true, true]); }); + // A warm, ambient-correlated night is left out of the flag's input but + // still carries its raw value. Publishing its held-normal entry hid the + // open run from every alert surface. + test('an ambient-excluded last night does not hide an elevated run', () { + final days = feverDays(feverSd: 3); + days.last['skin_temp_illness_ok'] = false; + final b = buildCrossDayBundle(days, const {}); + final tmp = b['temp_illness'] as Map; + expect(tmp['date'], days[days.length - 2]['date']); + expect(tmp['flag'], 'elevated'); + }); + test('nights in another family\'s unit are not mixed into the baseline', () { // A gen4 history in ADC counts, then a swap to gen5 reading centi-°C From 620b96764e4bb1b41e812fab6a7262d813f8504a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 15:08:51 +0530 Subject: [PATCH 28/41] gen4 freeze golden pinned the warm-bedroom placeholder as temp_illness all three crossday nights in the gen4 freeze fixtures are ambient-excluded, so the flag never scored any of them and the old headline was the held-normal placeholder for 2026-07-15. with the headline now limited to nights the flag actually scored, gen4 has no temp_illness entry and the bundle stores null. this moves the pinned gen4 output in all three zones and needs owner sign-off before it lands; gen5 is unchanged. (cherry picked from commit 7d1bc2a2b0794ea5020e9f2fd0e186bf1c147258) --- test/fixtures/whoop_freeze/gen4_minus0400.json | 7 +------ test/fixtures/whoop_freeze/gen4_plus0530.json | 7 +------ test/fixtures/whoop_freeze/gen4_utc.json | 7 +------ 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/test/fixtures/whoop_freeze/gen4_minus0400.json b/test/fixtures/whoop_freeze/gen4_minus0400.json index 0f6486fb3..90775bbfe 100644 --- a/test/fixtures/whoop_freeze/gen4_minus0400.json +++ b/test/fixtures/whoop_freeze/gen4_minus0400.json @@ -77640,12 +77640,7 @@ "drivers": [], "note": "need_baseline:have=2,need=10" }, - "temp_illness": { - "date": "2026-07-15", - "flag": "normal", - "luteal": false, - "confidence": 0.0 - }, + "temp_illness": null, "load": { "value": "—", "confidence": 0.0, diff --git a/test/fixtures/whoop_freeze/gen4_plus0530.json b/test/fixtures/whoop_freeze/gen4_plus0530.json index aee7b941b..ea65f93dd 100644 --- a/test/fixtures/whoop_freeze/gen4_plus0530.json +++ b/test/fixtures/whoop_freeze/gen4_plus0530.json @@ -77640,12 +77640,7 @@ "drivers": [], "note": "need_baseline:have=2,need=10" }, - "temp_illness": { - "date": "2026-07-15", - "flag": "normal", - "luteal": false, - "confidence": 0.0 - }, + "temp_illness": null, "load": { "value": "—", "confidence": 0.0, diff --git a/test/fixtures/whoop_freeze/gen4_utc.json b/test/fixtures/whoop_freeze/gen4_utc.json index 1f3fb028e..3b928cbb3 100644 --- a/test/fixtures/whoop_freeze/gen4_utc.json +++ b/test/fixtures/whoop_freeze/gen4_utc.json @@ -77640,12 +77640,7 @@ "drivers": [], "note": "need_baseline:have=2,need=10" }, - "temp_illness": { - "date": "2026-07-15", - "flag": "normal", - "luteal": false, - "confidence": 0.0 - }, + "temp_illness": null, "load": { "value": "—", "confidence": 0.0, From aa7e3bfa592b3daf70a1e5c5e32186a77cfd8414 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:54:17 +0530 Subject: [PATCH 29/41] a night with no gravity data said no sleep was found instead of that the band missed it when a night's records mostly decoded without a gravity vector, the hr-led fallback found the heart-rate dip but could not stage it (only seconds with a valid vector count as observed), and the unobserved reason it returned was thrown away. the day now keeps that reason, still with no window and source none, and the sleep screen says the band recorded too little movement data for that night. the comment that called the fallback the degraded mode for these nights now says what it can and cannot stage. (cherry picked from commit cf3d675340724e2f7387c133880d6903321f5952) --- lib/compute/substrate.dart | 22 ++++++++++-- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 4 +++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/ui2/screens/sleep_detail.dart | 21 ++++++++++++ test/substrate_accel_absence_test.dart | 46 ++++++++++++++++++++++++++ 10 files changed, 97 insertions(+), 2 deletions(-) diff --git a/lib/compute/substrate.dart b/lib/compute/substrate.dart index ffc5e196b..08d8664f3 100644 --- a/lib/compute/substrate.dart +++ b/lib/compute/substrate.dart @@ -1322,6 +1322,10 @@ List calendarDays( ana.SleepSegmentation s; String src; + // The HR-led fallback's own absence, when it found a night-shaped HR dip + // ending in this day but the band recorded too little of that window to + // stage it. Kept so the day can say why instead of a bare "no sleep". + ana.SleepSegmentation? fallbackUnobserved; 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 — @@ -1368,8 +1372,11 @@ List calendarDays( ); } else { // Not an error and not "no sleep" — just no accel evidence. Fall - // through to the HR-led path below, which is exactly the degraded - // mode for this and is already marked low-confidence. + // through to the HR-led path below. It can still stage a night whose + // missing accel sits outside the HR dip (a gappy evening), but not + // one whose dip itself lacks gravity: the forced-window stager only + // counts a second as observed when it has a valid vector, so that + // window comes back unobserved and its reason is kept below. s = ana.SleepSegmentation.absent; } src = 'auto'; @@ -1396,6 +1403,10 @@ List calendarDays( if (s2.present) { s = s2; src = 'auto_fallback'; + } else if (s2.absenceReason != null && + cand.offsetSec >= dayStart && + cand.offsetSec < dayEnd) { + fallbackUnobserved = s2; } } } @@ -1489,6 +1500,13 @@ List calendarDays( '${(ana.kMinObservedFractionForSleep * 100).round()}% floor') : s; sleepSource = src; + } else if (fallbackUnobserved != null) { + // The HR dip says a night happened and the band could not record + // enough of it. The source stays 'none' (nothing was staged, and + // 'auto_fallback' promises a window), but the reason rides along so the + // screen can say the band missed the night rather than that no sleep + // was found. + seg = fallbackUnobserved; } } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e004db51d..a3d1625bd 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1701,6 +1701,7 @@ "sleepDetailReanalyseFailed": "Die Nacht wurde nicht neu ausgewertet – entweder lief bereits eine andere Auswertung, oder sie ist fehlgeschlagen. Deine Zeiten sind gespeichert; „Alles neu analysieren“ unter „Deine Daten“ wendet sie an.", "sleepDetailWindowUnrecorded": "Deine Zeiten sind gespeichert, aber das Band hat in diesen Stunden größtenteils nichts aufgezeichnet (nicht getragen oder beim Laden), daher gibt es keine Nacht zum Auswerten.", "sleepDetailWindowNoSleep": "Deine Zeiten sind gespeichert, aber die Aufzeichnungen des Bands aus diesen Stunden zeigen keinen Schlaf zum Auswerten.", + "sleepDetailNightUnrecorded": "Deine Herzfrequenz deutet auf eine Nacht Schlaf hin, aber das Band hat dabei größtenteils zu wenige Bewegungsdaten aufgezeichnet, daher gibt es keine Nacht zum Auswerten.", "sleepDetailCorrectionUnavailable": "Die detaillierten Aufzeichnungen dieser Nacht sind nicht mehr auf dem Telefon, daher lässt sich ihr Schlaf nicht mehr korrigieren.", "sleepDetailCorrectionEveningPruned": "Der Abend vor dieser Nacht ist nicht mehr auf dem Telefon, daher lassen sich nur eine Schlafenszeit nach Mitternacht oder „Kein Schlaf“ übernehmen.", "sleepDetailNoHypnogramTitle": "Kein Hypnogramm für diese Nacht", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2bb077ac9..72ef7a75e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -8468,6 +8468,10 @@ "sleepDetailReanalyseFailed": "The night wasn’t re-analysed; another run was busy or it failed. Your times are saved: open Your data and tap Re-analyze everything.", "sleepDetailWindowUnrecorded": "Your times are saved, but the band wasn’t recording for most of those hours (off your wrist or charging), so there’s no night to score.", "sleepDetailWindowNoSleep": "Your times are saved, but the band’s recordings for those hours show no sleep to score.", + "sleepDetailNightUnrecorded": "Your heart rate shows a night’s sleep, but the band recorded too little movement data for most of it, so there’s no night to score.", + "@sleepDetailNightUnrecorded": { + "description": "Shown on an automatically detected night with no sleep, when heart rate found a night but the band recorded too little movement data across it to stage one." + }, "@sleepDetailWindowUnrecorded": { "description": "Shown after the user set their own sleep window (e.g. \"I was asleep\") and the band recorded too little of it to stage a night." }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 4b1cbb931..d41756802 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1661,6 +1661,7 @@ "sleepDetailReanalyseFailed": "La noche no se ha vuelto a analizar: ya había otro reanálisis en curso, o falló. Las horas que estableciste están guardadas; \"Reanalizar todo\" en Tus datos las aplicará.", "sleepDetailWindowUnrecorded": "Tus horas están guardadas, pero la pulsera no estuvo registrando durante la mayor parte de ese tiempo (no la llevabas puesta o se estaba cargando), así que no hay noche que puntuar.", "sleepDetailWindowNoSleep": "Tus horas están guardadas, pero los registros de la pulsera de esas horas no muestran sueño que puntuar.", + "sleepDetailNightUnrecorded": "Tu frecuencia cardíaca indica una noche de sueño, pero la pulsera registró muy pocos datos de movimiento durante la mayor parte, así que no hay noche que puntuar.", "sleepDetailCorrectionUnavailable": "Los registros detallados de esta noche ya no están en el teléfono, así que su sueño ya no se puede corregir.", "sleepDetailCorrectionEveningPruned": "La tarde anterior a esta noche ya no está en el teléfono, así que solo se puede aplicar una hora de acostarse después de medianoche o «No es sueño».", "sleepDetailNoHypnogramTitle": "No hay hipnograma para esta noche", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 86303abf5..6a46284ab 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1651,6 +1651,7 @@ "sleepDetailReanalyseFailed": "La nuit n'a pas été réanalysée : une autre réanalyse était déjà en cours, ou elle a échoué. Les horaires que vous avez définis sont enregistrés ; « Tout réanalyser » dans Vos données les appliquera.", "sleepDetailWindowUnrecorded": "Vos horaires sont enregistrés, mais le bracelet n'a pas enregistré pendant la majeure partie de ces heures (retiré du poignet ou en charge) : il n'y a donc pas de nuit à évaluer.", "sleepDetailWindowNoSleep": "Vos horaires sont enregistrés, mais les enregistrements du bracelet pour ces heures ne montrent aucun sommeil à évaluer.", + "sleepDetailNightUnrecorded": "Votre fréquence cardiaque indique une nuit de sommeil, mais le bracelet a enregistré trop peu de données de mouvement pendant la majeure partie : il n'y a donc pas de nuit à évaluer.", "sleepDetailCorrectionUnavailable": "Les enregistrements détaillés de cette nuit ne sont plus sur le téléphone, son sommeil ne peut donc plus être corrigé.", "sleepDetailCorrectionEveningPruned": "La soirée précédant cette nuit n’est plus sur le téléphone : on peut seulement indiquer une heure de coucher après minuit, ou marquer la nuit comme n’étant pas du sommeil.", "sleepDetailNoHypnogramTitle": "Aucun hypnogramme pour cette nuit", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 696efdc08..b0e68c68d 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1651,6 +1651,7 @@ "sleepDetailReanalyseFailed": "रात का दोबारा विश्लेषण नहीं हो पाया — या तो कोई और पुनः विश्लेषण पहले से चल रहा था, या यह विफल हो गया। आपके सेट किए गए समय सेव हैं; 'आपका डेटा' में 'सब कुछ दोबारा विश्लेषण करें' उन्हें लागू कर देगा।", "sleepDetailWindowUnrecorded": "आपके समय सेव हैं, लेकिन उन घंटों में ज़्यादातर समय बैंड रिकॉर्ड नहीं कर रहा था (कलाई पर नहीं था या चार्ज हो रहा था), इसलिए स्कोर करने के लिए कोई रात नहीं है।", "sleepDetailWindowNoSleep": "आपके समय सेव हैं, लेकिन उन घंटों की बैंड रिकॉर्डिंग में स्कोर करने लायक कोई नींद नहीं दिखती।", + "sleepDetailNightUnrecorded": "आपकी हृदय गति रात की नींद दिखाती है, लेकिन ज़्यादातर समय बैंड ने बहुत कम मूवमेंट डेटा रिकॉर्ड किया, इसलिए स्कोर करने के लिए कोई रात नहीं है।", "sleepDetailCorrectionUnavailable": "इस रात की विस्तृत रिकॉर्डिंग अब फ़ोन पर नहीं हैं, इसलिए इसकी नींद अब ठीक नहीं की जा सकती।", "sleepDetailCorrectionEveningPruned": "इस रात से पहले की शाम अब फ़ोन पर नहीं है, इसलिए केवल आधी रात के बाद का सोने का समय दिया जा सकता है, या इस रात को नींद नहीं के रूप में चिह्नित किया जा सकता है।", "sleepDetailNoHypnogramTitle": "इस रात के लिए कोई हिप्नोग्राम नहीं", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 309b2e1b0..b49a75c37 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3906,6 +3906,7 @@ "sleepDetailReanalyseFailed": "Ночь не пересчитана: шёл другой пересчёт или произошла ошибка. Время сохранено: откройте «Ваши данные» и запустите повторный анализ.", "sleepDetailWindowUnrecorded": "Время сохранено, но браслет почти всё это время ничего не записывал (был снят или заряжался), поэтому оценивать нечего.", "sleepDetailWindowNoSleep": "Время сохранено, но в записях браслета за эти часы нет сна, который можно оценить.", + "sleepDetailNightUnrecorded": "Пульс указывает на ночной сон, но большую часть ночи браслет записал слишком мало данных о движении, поэтому оценивать нечего.", "sleepDetailCorrectionUnavailable": "Подробные записи этой ночи уже удалены с телефона, поэтому её сон больше нельзя исправить.", "sleepDetailCorrectionEveningPruned": "Вечер перед этой ночью уже удалён с телефона, поэтому можно применить только время отхода ко сну после полуночи или «Не сон».", "sleepDetailNoHypnogramTitle": "За эту ночь гипнограммы нет", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f07f1c53b..440324cb7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1651,6 +1651,7 @@ "sleepDetailReanalyseFailed": "这一夜未能重新分析——可能另一次重新分析正在进行,也可能分析失败了。你设置的时间已保存;在“你的数据”中点击“重新分析全部”即可应用。", "sleepDetailWindowUnrecorded": "你设置的时间已保存,但这段时间里手环大部分时候没有记录(没戴在手腕上或在充电),所以没有可评分的夜晚。", "sleepDetailWindowNoSleep": "你设置的时间已保存,但手环在这段时间的记录中没有可评分的睡眠。", + "sleepDetailNightUnrecorded": "你的心率显示有一夜睡眠,但手环在大部分时间里记录的动作数据太少,所以没有可评分的夜晚。", "sleepDetailCorrectionUnavailable": "这一夜的详细记录已不在手机上,因此无法再更正这一夜的睡眠。", "sleepDetailCorrectionEveningPruned": "这一夜之前的傍晚记录已不在手机上,因此只能设置午夜之后的入睡时间,或将这一夜标记为不是睡眠。", "sleepDetailNoHypnogramTitle": "这一夜没有睡眠阶段图", diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index c17f6ddf2..ca3b0b9b0 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -587,6 +587,7 @@ class _SleepDetailState extends State with RevisionReload { 'You told us this stretch was not sleep, so nothing is ' 'scored for it.') : (_userWindowEmpty(d.night, l) ?? + _autoNightUnrecorded(d.night, l) ?? l?.sleepDetailNoNightBody ?? 'No stretch of band recordings long enough to score.'), fix: rejected ? '' : (l?.sleepDetailNoNightFix ?? @@ -1099,6 +1100,26 @@ class _SleepDetailState extends State with RevisionReload { 'hours show no sleep to score.'); } + /// Why an automatic night has nothing to show, when the derive knows: the + /// heart rate found a night but the band recorded too little movement + /// across it to stage one. Null otherwise, so an ordinary empty night keeps + /// the generic message. + static String? _autoNightUnrecorded( + Map n, AppLocalizations? l) { + final src = n['sleep_source']; + if (src == 'manual' || + src == 'confirmed' || + src == 'rejected' || + n['has_sleep'] == true || + n['absence_reason'] == null) { + return null; + } + return l?.sleepDetailNightUnrecorded ?? + 'Your heart rate shows a night\u2019s sleep, but the band recorded ' + 'too little movement data for most of it, so there\u2019s no ' + 'night to score.'; + } + /// Where the drawn window came from: 'auto', 'auto_fallback', 'manual' or /// 'confirmed'. String? get _source => (_d?.night['sleep_source'] as String?) ?? 'auto'; diff --git a/test/substrate_accel_absence_test.dart b/test/substrate_accel_absence_test.dart index 8cce2a93e..2a4a37639 100644 --- a/test/substrate_accel_absence_test.dart +++ b/test/substrate_accel_absence_test.dart @@ -290,6 +290,52 @@ void main() { }); }); + // A night routed to the HR-led fallback because it has no gravity cannot be + // staged by it either: the forced-window stager only counts a second as + // observed when its gravity vector is valid. The fallback then returns an + // unobserved absence with a reason, which used to be dropped, so the day read + // as an ordinary "no sleep detected" while the band was worn all night. + group('a gravity-less night keeps the fallback\'s reason', () { + test('the day carries absence_reason, still with no window and no source', + () { + // 08:00 the day before -> 08:00, 1 Hz. Awake 70 bpm, asleep 50 bpm from + // 23:00 to 07:00. The morning before the search window gives the HR-led + // detector a daytime baseline, so it proposes the 23:00-07:00 dip. + final midnight = _localMidnightOf(1750000000); + final start = midnight - 16 * 3600; + const n = 24 * 3600; + final ts = [], hr = []; + for (var i = 0; i < n; i++) { + final t = start + i; + ts.add(t); + hr.add(t >= midnight - 3600 && t < midnight + 7 * 3600 ? 50 : 70); + } + final sub = Substrate( + tsSec: ts, + hr: hr, + rrTsMs: const [], + rrMs: const [], + ax: List.filled(n, 0), + ay: List.filled(n, 0), + az: List.filled(n, 0), + spo2Red: List.filled(n, 0), + spo2Ir: List.filled(n, 0), + skinTemp: List.filled(n, 0), + skinContact: List.filled(n, 0), + ); + final wake = midnight + 7 * 3600; + final day = calendarDays(sub) + .singleWhere((d) => d.startSec <= wake && wake < d.endSec); + expect(day.sleep.present, isFalse); + expect(day.sleepSource, 'none'); + expect(day.flags, contains('NO_SLEEP_DETECTED')); + expect(day.sleep.absenceReason, isNotNull, + reason: 'the band missed the night; that is not "no sleep found"'); + expect(day.sleep.toJson()['absence_reason'], isNotNull, + reason: 'the reason has to survive into the stored day'); + }); + }); + // CV-09 — daytime HRV had no motion gate at all: RR was filtered to // 300-2000 ms and successive deltas to 200 ms and nothing else, so a bin of // walking entered the average as low HRV and the number read as stress when From aec960a214fc676ad1e7121d7c885afa64970c52 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:53:23 +0530 Subject: [PATCH 30/41] the illness anomaly standardised skin temp twice and ignored warm-room nights the multivariate anomaly standardises every column itself, but crossday fed it skin_temp_z, which is already scored against a baseline holding the earlier fever nights. the temp term shrank night over night, so a multi-night rise could drop out of the two-night persistence run. it also skipped the ambient exclusion the temperature flag applies, so a night that tracked a warm bedroom still pushed the distance up as temp-up. both alerts now read the same input: the raw nightly mean in the newest family, minus nights marked skin_temp_illness_ok = false. the newest-scored lookup for each uses that same input. readiness still gets the z. (cherry picked from commit b57fd0597f4a5c7c33cb9127770767de454273a6) --- lib/compute/crossday_pipeline.dart | 41 +++++++++------- test/crossday_pipeline_test.dart | 75 ++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 17 deletions(-) diff --git a/lib/compute/crossday_pipeline.dart b/lib/compute/crossday_pipeline.dart index cd1b04662..8fc272991 100644 --- a/lib/compute/crossday_pipeline.dart +++ b/lib/compute/crossday_pipeline.dart @@ -111,6 +111,17 @@ Map buildCrossDayBundle( final unsettled = [for (final d in days) d['unsettled'] == true]; T? settled(int i, T? v) => unsettled[i] ? null : v; + // The skin-temp input both temperature-reading alerts take: the raw nightly + // mean in the newest family, minus any WHOOP 4 night whose skin temp moved + // with the band's ambient channel (`skin_temp_illness_ok`, see + // `skinTempIllnessEligible`) — a warm bedroom is not a fever. A record + // without the key (built by an older caller) keeps the old behaviour. + // Withholding `settled()` is applied at each use. + final tempAlertList = [ + for (var i = 0; i < n; i++) + days[i]['skin_temp_illness_ok'] == false ? null : tempRawList[i], + ]; + // ── illness CUSUM (NightSignal) on nightly RHR ───────────────────────────── final illness = ana.illnessCusum(dates, [ for (var i = 0; i < n; i++) settled(i, rhrList[i]), @@ -120,12 +131,18 @@ Map buildCrossDayBundle( // Build one AnomalyFeatures per day (same length as dates). Days with all // features null still occupy a slot — the detector handles the nulls // internally (needs ≥2 present features tonight to compute a distance). + // + // temp is the raw nightly mean, not `skin_temp_z`, for the same reason as + // the temperature flag: the detector standardises every column itself, and + // the z had already been scored against a baseline holding the earlier fever + // nights, so the temp term shrank night over night and a sustained rise + // could fall out of the two-night persistence run. final feats = [ for (var i = 0; i < n; i++) ana.AnomalyFeatures( rhr: settled(i, rhrList[i]), hrv: settled(i, rmssdList[i]), - temp: settled(i, tempList[i]), + temp: settled(i, tempAlertList[i]), resp: settled(i, respList[i]), ), ]; @@ -154,16 +171,8 @@ Map buildCrossDayBundle( // exception still fires, with the confound named. Suppressing a real illness // is worse than the false alarm. final luteal = _lutealFlags(dates, cycleStartDates); - // - // A WHOOP 4 night whose skin temp moved with the band's ambient channel is - // left out (`skin_temp_illness_ok`, see `skinTempIllnessEligible`): the - // illness watch should not fire on a warm bedroom. A record without the key - // (built by an older caller) keeps the old behaviour. final tempIllness = ana.tempIllnessFlag(dates, [ - for (var i = 0; i < n; i++) - days[i]['skin_temp_illness_ok'] == false - ? null - : settled(i, tempRawList[i]), + for (var i = 0; i < n; i++) settled(i, tempAlertList[i]), ], luteal: luteal); // ── circadian: mid-sleep, free/work split, jetlag, chronotype, sleep debt ── @@ -540,7 +549,8 @@ Map buildCrossDayBundle( // publishing it would hide a run that is still open. So each family // publishes the newest settled night that carried ITS inputs (illness: the // nightly RHR; anomaly: the ≥2 features it needs to score a night; - // temperature: the skin-temperature z). Outputs are parallel to `dates`. + // temperature: the raw skin-temperature mean it was allowed to read). + // Outputs are parallel to `dates`. // The entry carries its OWN `date`; consumers must date findings with it, // never with `recent.last`. int? newestScored(bool Function(int i) hasInput) { @@ -555,18 +565,15 @@ Map buildCrossDayBundle( final latestAnomaly = at( anomaly, newestScored((i) => - [rhrList[i], rmssdList[i], tempList[i], respList[i]] + [rhrList[i], rmssdList[i], tempAlertList[i], respList[i]] .where((v) => v != null) .length >= 2), ); // Same predicate that built the flag's input: an ambient-excluded night // still has a raw value, but the flag saw null and held it normal. - final latestTemp = at( - tempIllness, - newestScored((i) => - days[i]['skin_temp_illness_ok'] != false && tempRawList[i] != null), - ); + final latestTemp = + at(tempIllness, newestScored((i) => tempAlertList[i] != null)); // per-day flags (for notifications / trends): asleep/illness/anomaly/temp, // plus the nightly RESTING HR the "your resting HR trend shifted" CUSUM diff --git a/test/crossday_pipeline_test.dart b/test/crossday_pipeline_test.dart index 383c0f06c..f1bd9c382 100644 --- a/test/crossday_pipeline_test.dart +++ b/test/crossday_pipeline_test.dart @@ -830,6 +830,81 @@ void _wiredFamilies() { }); }); + // The multivariate anomaly standardises every column itself, so its temp + // feature is the raw nightly mean. It was handed `skin_temp_z`, which is + // scored against a baseline that already holds the earlier fever nights, and + // it skipped the ambient exclusion the temperature flag applies. + group('the anomaly reads the raw, ambient-gated nightly temp', () { + const noise = [-9.0, 4.0, 11.0, -3.0, -12.0, 7.0, 1.0, -6.0, 13.0, -5.0, + 2.0, -10.0, 8.0, -1.0, 5.0, -7.0, 10.0, -4.0, 0.0, 6.0]; + + // 40 ordinary nights, then [tail] nights whose raw temp is [tailTemp]. + // `skin_temp_z` is whatever [z] returns, so a test can make it disagree + // with the raw mean. + List> nights({ + int tail = 3, + double tailTemp = 3040, + double? Function(int i)? z, + bool Function(int i)? ambient, + }) { + var dt = DateTime(2024, 1, 1); + final out = >[]; + for (var i = 0; i < 40 + tail; i++) { + final w = noise[i % noise.length]; + out.add({ + 'date': '${dt.year}-${dt.month.toString().padLeft(2, '0')}-' + '${dt.day.toString().padLeft(2, '0')}', + 'rhr': 55.0 + w / 4, + 'rmssd': 45.0 + w / 2, + 'resp_rate': 14.0 + w / 20, + 'skin_temp_z': z == null ? null : z(i), + 'skin_temp_adc': i < 40 ? 3000 + w : tailTemp, + 'skin_temp_family': 'gen4', + if (ambient != null && ambient(i)) 'skin_temp_illness_ok': false, + }); + dt = dt.add(const Duration(days: 1)); + } + return out; + } + + double? tempDriver(List> days) { + final a = buildCrossDayBundle(days, const {})['anomaly'] as Map?; + if (a == null) return null; + for (final d in a['drivers'] as List) { + if ((d as Map)['label'] == 'temp') { + return (d['contribution'] as num).toDouble(); + } + } + return null; + } + + test('a raw rise reaches the anomaly even when no z was stored', () { + final days = nights(); + expect((buildCrossDayBundle(days, const {})['anomaly'] as Map)['date'], + days.last['date']); + final t = tempDriver(days); + expect(t, isNotNull); + expect(t!, greaterThan(2)); + }); + + test('the temp term follows the raw rise, not a z that disagrees', () { + // A z claiming every night is ordinary while the raw mean has risen. + final days = nights(z: (i) => 0.0); + expect(tempDriver(days)!, greaterThan(2)); + }); + + test('a night that tracked the ambient channel carries no temp term', () { + final days = nights( + tail: 1, + z: (i) => i < 40 ? noise[i % noise.length] / 8 : 4.0, + ambient: (i) => i == 40, + ); + final a = buildCrossDayBundle(days, const {})['anomaly'] as Map; + expect(a['date'], days.last['date']); + expect(tempDriver(days), isNull); + }); + }); + group('the breakdown ranks tonight against its own method family', () { const methods = {'gen4': 'hr_1hz', 'gen5': 'hr_1hz', 'oura': 'hr_5min'}; From 988044a9ab67efbd633f5ac7cea8ccb3e2deeaf0 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:52:33 +0530 Subject: [PATCH 31/41] nightly skin temp averaged in the cold warm-up samples on nights that passed the settle gate a night 80-99% settled still had its raw mean banked and fed to readiness, so up to a fifth of the night reading hundreds of counts low pulled the value several between-night SDs cold. readiness read that as a cool night and the baseline banked it as an outlier. i use the settled mean now for skin_temp_adc, its z and readiness's temp input; families with no settle band (gen5) keep the raw mean. (cherry picked from commit d8af49084174fe82832a6fbc77c899322af0c007) --- lib/compute/onehz_pipeline.dart | 26 ++++++++++++++------- test/unsettled_skin_temp_baseline_test.dart | 15 ++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index ad0a29274..47bc9559c 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -849,6 +849,14 @@ Map deriveDayBundle(Map inputJson) { // every number that leans on it below is served at half confidence and // marked provisional. Always false on a band, so its payload is unchanged. final tempProvisional = settledTemp.value?.provisional ?? false; + // The night's value is the mean of its SETTLED samples whenever the family + // has a settle band. The fraction gate below only refuses nights under 0.80; + // a night at 0.85 still carries 15% warm-up or off-body samples reading + // hundreds of counts low, and the raw mean folds them in — tens of counts + // cold against a ~6.5-count between-night SD, which readiness reads as a + // cool night and the baseline banks as an outlier. A family with no band + // (gen5) has no settled mean and keeps the raw one. + final double? skinTempNightly = settledTemp.value?.mean ?? skinTempAdc; // The nightly mean every LATER night is measured against. A night the strap // spent partly warming up (or half off the wrist) reads hundreds of counts // low; readiness already refuses it as tonight's input, but banking it as @@ -860,8 +868,8 @@ Map deriveDayBundle(Map inputJson) { final double? skinTempBanked = (skinTempSettledFrac != null && skinTempSettledFrac < kMinSettledFraction) ? null - : skinTempAdc; - // STEP 2 — z-score today's RAW mean against the RAW-ADC baseline history (NOT + : skinTempNightly; + // STEP 2 — z-score today's nightly mean against the RAW-ADC baseline history (NOT // the previously-computed z-scores; that unit mismatch was the bug). Gated on // ≥3 prior raw means. double? skinTempZ; @@ -910,12 +918,13 @@ Map deriveDayBundle(Map inputJson) { // own oriented robust-z internally (consistent with the other inputs, which // pass raw values + their raw baselines). // - // The mean stays RAW — value and baseline have to be the same quantity, and - // the stored history is a series of raw nightly means. The settled fraction - // is the GATE on using it at all: below 0.80 the driver is refused for this - // night, by name, and readiness renormalises over the three that are left. + // Value and baseline have to be the same quantity: this is the same + // settled nightly mean that is banked as `skin_temp_adc`. The settled + // fraction is the GATE on using it at all: below 0.80 the driver is refused + // for this night, by name, and readiness renormalises over the three that + // are left. tempInput( - skinTempAdc, + skinTempNightly, d.skinTempAdcHistory, settledFraction: skinTempSettledFrac, ), @@ -1790,7 +1799,8 @@ Map deriveDayBundle(Map inputJson) { 'ln_rmssd': lnToday, 'resp_rate': respToday, 'skin_temp_z': skinTempZ, - // RAW nightly skin-temp ADC mean — the personal BASELINE series for + // Nightly skin-temp ADC mean (of the settled samples, where the family + // has a settle band) — the personal BASELINE series for // skin_temp_z. ALWAYS present when there's sleep+temp data (even while z is // still null in the ≤3-day bootstrap), so the series fills and z starts // computing from ~day 4. This is the series _attachHistory must feed back. diff --git a/test/unsettled_skin_temp_baseline_test.dart b/test/unsettled_skin_temp_baseline_test.dart index 0e3c4dbe3..5ee07d161 100644 --- a/test/unsettled_skin_temp_baseline_test.dart +++ b/test/unsettled_skin_temp_baseline_test.dart @@ -54,4 +54,19 @@ void main() { expect(s['skin_temp_adc'], isNotNull); expect(s['skin_temp_z'], isNotNull); }); + + test('a night that passes the gate banks its settled mean, not the raw one', + () { + // 15% of the night reads 250 counts low (warm-up after charging). That is + // over the 0.80 gate, so the night counts — but the cold samples must not + // be averaged into it: the raw mean is 962.5, ~10 SD below this baseline. + final temps = [ + ...List.filled(60, 750), + ...List.filled(340, 1000), + ]; + final s = scalars(temps, history); + expect(s['skin_temp_settled_frac'] as num, closeTo(0.85, 1e-6)); + expect(s['skin_temp_adc'] as num, closeTo(1000, 1e-6)); + expect(s['skin_temp_z'] as num, closeTo(0, 1e-6)); + }); } From 59c778e68634525b30fe9642231ddcd12033f84b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:55:45 +0530 Subject: [PATCH 32/41] a logged nap over the main sleep was counted twice the naps screen only checked a logged nap against the day's other naps, so logging the doze after the alarm (06:00-08:00 against a night ending 07:45) was accepted, and every reader then added its full length on top of the night's tst: nap_min, total asleep and the sleep-need nap credit. a manual nap that overlaps a derived main sleep (the night ending that day or the one starting that evening) is now refused, both on the screen with a message and in putNapEdit itself, using the same main-window check the suggestion path already applies. (cherry picked from commit 23a5b376f00ea0d76a3d0112cc68c2a14fdc3035) --- lib/data/activity_store.dart | 66 ++++++++++++++++------- lib/data/db.dart | 15 ++++++ lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 4 ++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/ui2/screens/naps.dart | 8 +++ test/activity_review_regression_test.dart | 44 +++++++++++++++ 11 files changed, 123 insertions(+), 20 deletions(-) diff --git a/lib/data/activity_store.dart b/lib/data/activity_store.dart index f84c12324..6c69eedea 100644 --- a/lib/data/activity_store.dart +++ b/lib/data/activity_store.dart @@ -298,19 +298,7 @@ class ActivityStore { whereArgs: [e, s], ); if (naps.isNotEmpty) return true; - final day = dayLabelOf(DateTime.fromMillisecondsSinceEpoch(s * 1000)); - final end = DateTime.fromMillisecondsSinceEpoch(e * 1000); - // An evening interval can overlap the main sleep owned by tomorrow's - // wake date. Walk calendar dates rather than adding a fixed 24 hours. - final next = dayLabelOf(DateTime(end.year, end.month, end.day + 1)); - final nights = await tx.rawQuery( - '''SELECT d.* FROM day_result d - WHERE d.day_id BETWEEN ? AND ? AND d.algo_version = - (SELECT MAX(r.algo_version) FROM day_result r WHERE r.day_id = d.day_id AND r.algo_version <= ?)''', - [day, next, kAlgoVersion], - ); - for (final r in nights) { - final b = jsonDecode(r['payload_json'] as String) as Map; + for (final b in await _nightsAround(tx, s, e)) { // Before its first recompute, a pre-upgrade nap still lives only in the // day bundle. It is already logged and takes precedence just as a nap // that has been copied into the accepted-record table does. @@ -321,17 +309,55 @@ class ActivityStore { if (ns != null && ne != null && s < ne && e > ns) return true; } } - final value = ((b['sleep'] as Map?)?['window'] as Map?)?['value']; - // Absent metric envelopes store '—', not a window object. - final w = value is Map ? value : null; - final ns = w?['onset_ms'] as num?, ne = w?['offset_ms'] as num?; - if (ns != null && ne != null && s * 1000 < ne && e * 1000 > ns) { - return true; - } + if (_mainSleepOverlaps(b, s, e)) return true; } return (await _overlappingSessions(tx, s, e)).isNotEmpty; } + /// Whether [s, e) overlaps a main sleep the derived history already holds — + /// the night that ends on [s]'s date, or the one that begins that evening + /// and belongs to tomorrow. A nap logged inside one is time the night's TST + /// already counts, and every reader sums naps on top of it. + static Future overlapsMainSleep( + DatabaseExecutor tx, + int s, + int e, + ) async { + for (final b in await _nightsAround(tx, s, e)) { + if (_mainSleepOverlaps(b, s, e)) return true; + } + return false; + } + + static Future> _nightsAround( + DatabaseExecutor tx, + int s, + int e, + ) async { + final day = dayLabelOf(DateTime.fromMillisecondsSinceEpoch(s * 1000)); + final end = DateTime.fromMillisecondsSinceEpoch(e * 1000); + // An evening interval can overlap the main sleep owned by tomorrow's + // wake date. Walk calendar dates rather than adding a fixed 24 hours. + final next = dayLabelOf(DateTime(end.year, end.month, end.day + 1)); + final nights = await tx.rawQuery( + '''SELECT d.* FROM day_result d + WHERE d.day_id BETWEEN ? AND ? AND d.algo_version = + (SELECT MAX(r.algo_version) FROM day_result r WHERE r.day_id = d.day_id AND r.algo_version <= ?)''', + [day, next, kAlgoVersion], + ); + return [ + for (final r in nights) jsonDecode(r['payload_json'] as String) as Map, + ]; + } + + static bool _mainSleepOverlaps(Map bundle, int s, int e) { + final value = ((bundle['sleep'] as Map?)?['window'] as Map?)?['value']; + // Absent metric envelopes store '—', not a window object. + final w = value is Map ? value : null; + final ns = w?['onset_ms'] as num?, ne = w?['offset_ms'] as num?; + return ns != null && ne != null && s * 1000 < ne && e * 1000 > ns; + } + static Future supersede( DatabaseExecutor tx, ActivityKind kind, diff --git a/lib/data/db.dart b/lib/data/db.dart index 1d52de1a9..e9d182631 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -12088,6 +12088,15 @@ class LocalDb { }) async { final db = await instance; await db.transaction((tx) async { + // A logged nap is credited whole on top of the main night's TST (nap_min, + // total asleep, sleep-need credit), so one typed over the end of the + // night — the doze after the alarm — counts those minutes twice. + if (source == 'manual' && + await ActivityStore.overlapsMainSleep(tx, startTs, endTs)) { + throw const ActivityReviewException( + 'That overlaps your main sleep, which already counts that time.', + ); + } String? snapshot; if (source == 'rejected') { // A pre-upgrade nap may still live only in its retained day result. @@ -12157,6 +12166,12 @@ class LocalDb { }); } + /// Whether [startTs, endTs) overlaps a main sleep already derived — the + /// check [putNapEdit] enforces for a logged nap, readable ahead of the write + /// so the entry screen can say why in the user's language. + static Future napOverlapsMainSleep(int startTs, int endTs) async => + ActivityStore.overlapsMainSleep(await instance, startTs, endTs); + static Future>> napEdits(String dayId) async { final db = await instance; return db.query( diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index a3d1625bd..caab8fcf5 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -421,6 +421,7 @@ "napsWokeUpHelp": "AUFGEWACHT UM", "napsInvalidWindow": "Ein Nickerchen dauert zwischen 5 Minuten und 6 Stunden. Alles Längere ist Schlaf und gehört zur Nacht, wo die Schlafphasen ausgewertet werden.", "napsOverlap": "Das überschneidet sich mit einem Nickerchen, das an diesem Tag schon eingetragen ist. Entferne zuerst das andere, damit dieselbe Zeit nicht doppelt zählt.", + "napsOverlapsSleep": "Das überschneidet sich mit deinem Hauptschlaf, der diese Zeit schon zählt. Trage nur den Teil nach dem Aufstehen ein oder korrigiere stattdessen die Nacht.", "napsNotReanalysed": "Der Tag wurde nicht neu ausgewertet, weil gerade schon eine andere Auswertung lief. Deine Änderung ist gespeichert und wird beim nächsten Mal berücksichtigt.", "napsTitle": "Nickerchen", "napsNoReadingTitle": "Keine Daten zu Nickerchen an diesem Tag", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 72ef7a75e..f57947b32 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1951,6 +1951,10 @@ "@napsOverlap": { "description": "Validation error when a manually logged nap overlaps an existing one." }, + "napsOverlapsSleep": "That overlaps your main sleep, which already counts that time. Log only the part after you got up, or correct the night instead.", + "@napsOverlapsSleep": { + "description": "Validation error when a manually logged nap overlaps the main sleep window." + }, "napsNotReanalysed": "The day was not re-analysed — another analysis was already running. Your edit is saved and will apply next time.", "@napsNotReanalysed": { "description": "Fallback error when a nap edit saved but the day could not be re-derived because another analysis was already running." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index d41756802..5090dfc23 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -393,6 +393,7 @@ "napsWokeUpHelp": "CUÁNDO TE DESPERTASTE", "napsInvalidWindow": "Una siesta dura entre 5 minutos y 6 horas. Cualquier cosa más larga es un sueño nocturno y pertenece a la noche, donde se pueden leer las fases.", "napsOverlap": "Eso se superpone con una siesta ya registrada este día. Elimina esa primero, en lugar de contar la misma hora dos veces.", + "napsOverlapsSleep": "Eso se superpone con tu sueño principal, que ya cuenta ese tiempo. Registra solo la parte posterior a levantarte o corrige la noche.", "napsNotReanalysed": "El día no se ha vuelto a analizar: ya había otro análisis en curso. Tu edición está guardada y se aplicará la próxima vez.", "napsTitle": "Siestas", "napsNoReadingTitle": "Sin datos de siestas para este día", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6a46284ab..599e9e8a1 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -393,6 +393,7 @@ "napsWokeUpHelp": "QUAND VOUS VOUS ÊTES RÉVEILLÉ", "napsInvalidWindow": "Une sieste dure entre 5 minutes et 6 heures. Au-delà, il s'agit d'un sommeil, et cela appartient à la nuit, où les phases peuvent être lues.", "napsOverlap": "Cela chevauche une sieste déjà enregistrée ce jour-là. Supprimez d'abord celle-ci plutôt que de compter la même heure deux fois.", + "napsOverlapsSleep": "Cela chevauche votre sommeil principal, qui compte déjà ce temps. Enregistrez seulement la partie après votre lever, ou corrigez plutôt la nuit.", "napsNotReanalysed": "La journée n'a pas été réanalysée — une autre analyse était déjà en cours. Votre modification est enregistrée et s'appliquera la prochaine fois.", "napsTitle": "Siestes", "napsNoReadingTitle": "Aucune donnée de sieste pour cette journée", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index b0e68c68d..94a430258 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -393,6 +393,7 @@ "napsWokeUpHelp": "आप कब जागे", "napsInvalidWindow": "झपकी 5 मिनट से 6 घंटे के बीच होती है। इससे अधिक लंबी अवधि नींद मानी जाती है, और वह रात के हिस्से में आती है जहाँ चरणों को पढ़ा जा सकता है।", "napsOverlap": "यह इस दिन पहले से मौजूद एक झपकी से टकराता है। उसे पहले हटाएँ, बजाय इसके कि उसी घंटे को दो बार गिना जाए।", + "napsOverlapsSleep": "यह आपकी मुख्य नींद से टकराता है, जो उस समय को पहले से गिनती है। केवल उठने के बाद का हिस्सा दर्ज करें, या इसके बजाय रात को ठीक करें।", "napsNotReanalysed": "दिन का पुनः विश्लेषण नहीं हुआ — पहले से ही एक अन्य विश्लेषण चल रहा था। आपका बदलाव सहेज लिया गया है और अगली बार लागू होगा।", "napsTitle": "झपकियाँ", "napsNoReadingTitle": "इस दिन के लिए कोई झपकी रीडिंग नहीं", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index b49a75c37..4a8597964 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -821,6 +821,7 @@ "napsWokeUpHelp": "КОГДА ВЫ ПРОСНУЛИСЬ", "napsInvalidWindow": "Дневной сон длится от 5 минут до 6 часов. Более долгий сон относится к ночному, где можно просмотреть его стадии.", "napsOverlap": "Этот период пересекается с уже записанным дневным сном. Сначала удалите прежнюю запись, чтобы не учитывать одно время дважды.", + "napsOverlapsSleep": "Этот период пересекается с основным сном, который уже учитывает это время. Запишите только часть после подъёма или исправьте ночь.", "napsNotReanalysed": "Повторный анализ дня не выполнен: уже идёт другой анализ. Изменение сохранено и будет учтено в следующий раз.", "napsTitle": "Дневной сон", "napsNoReadingTitle": "Нет данных о дневном сне за этот день", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 440324cb7..2e45f9561 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -393,6 +393,7 @@ "napsWokeUpHelp": "醒来时间", "napsInvalidWindow": "小睡的时长应在 5 分钟到 6 小时之间。超过这个时长应算作夜间睡眠,属于可以读取睡眠阶段的夜晚记录。", "napsOverlap": "该时间段与当天已有的一次小睡重叠。请先删除那一次记录,而不是把同一小时计算两次。", + "napsOverlapsSleep": "该时间段与你的主睡眠重叠,主睡眠已经计入了这段时间。请只记录起床之后的部分,或改为修正当晚的睡眠。", "napsNotReanalysed": "当天未被重新分析——已有另一项分析正在运行。你的修改已保存,将在下次生效。", "napsTitle": "小睡", "napsNoReadingTitle": "当天没有小睡数据", diff --git a/lib/ui2/screens/naps.dart b/lib/ui2/screens/naps.dart index 306c31145..c3e97e442 100644 --- a/lib/ui2/screens/naps.dart +++ b/lib/ui2/screens/naps.dart @@ -238,6 +238,14 @@ class _NapsScreenState extends State { 'Remove that one first, rather than counting the same hour twice.'); return; } + if (await LocalDb.napOverlapsMainSleep(s, e)) { + if (!mounted) return; + setState(() => _failed = l?.napsOverlapsSleep ?? + 'That overlaps your main sleep, which already counts that time. ' + 'Log only the part after you got up, or correct the night instead.'); + return; + } + if (!mounted) return; await _edit(() => LocalDb.putNapEdit( dayId: day, startTs: s, endTs: e, source: 'manual')); } diff --git a/test/activity_review_regression_test.dart b/test/activity_review_regression_test.dart index c19f34d80..c7e2c0c06 100644 --- a/test/activity_review_regression_test.dart +++ b/test/activity_review_regression_test.dart @@ -62,6 +62,50 @@ void main() { }), ); + test('a logged nap over the end of the main sleep is refused', () async { + // The night ran 02:00 to 10:15; logging 10:00-10:30 as a nap would credit + // the 15 minutes the night already counts a second time. + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + windowJson: '{}', + finalized: true, + payloadJson: jsonEncode({ + 'scalars': {'nap_min': 0}, + 'naps': {'value': [], 'count': 0}, + 'sleep': { + 'window': { + 'value': { + 'onset_ms': (start - 8 * 3600) * 1000, + 'offset_ms': (start + 900) * 1000, + }, + }, + }, + }), + ); + expect(await LocalDb.napOverlapsMainSleep(start, start + 1800), isTrue); + await expectLater( + LocalDb.putNapEdit( + dayId: day, + startTs: start, + endTs: start + 1800, + source: 'manual', + ), + throwsA(isA()), + ); + expect(await LocalDb.napEdits(day), isEmpty); + // The part after getting up is still a nap. + expect(await LocalDb.napOverlapsMainSleep(start + 900, start + 1800), + isFalse); + await LocalDb.putNapEdit( + dayId: day, + startTs: start + 900, + endTs: start + 1800, + source: 'manual', + ); + expect(await LocalDb.napEdits(day), hasLength(1)); + }); + test('a normal absent sleep window permits new nap detection', () async { await result(day, absentSleep: true); expect( From 4210f92de059b67fbd729bfa55de88847bbfd1ef Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:55:12 +0530 Subject: [PATCH 33/41] a stored implausible age blamed the strap for missing strain, calories and zones an age saved before the profile forms checked it (a birth year like 1990, or 0) gives no hrmax estimate, but the absence ladders only treated a null age as an age problem and fell through to the unknown-device-family note. those days then said the recordings weren't stamped with a strap, on a correctly stamped whoop day. the ladders now name the age when it is missing or implausible. (cherry picked from commit 07a3cd34fa64a32d59eb17ac0f66bfb4ce9d12d2) --- lib/compute/derivation_engine.dart | 9 +- lib/compute/onehz_pipeline.dart | 6 +- lib/data/local_repository_impl.dart | 4 +- test/implausible_age_absence_note_test.dart | 121 ++++++++++++++++++++ 4 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 test/implausible_age_absence_note_test.dart diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index e87008a88..29f34ca3d 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -8319,8 +8319,8 @@ class DerivationEngine { // age/weight are read by `wakeDayEnergy` straight off the profile now, so // they are no longer unpacked here — TRIMP only needs the sex constant. final sex = profile.sex?.toLowerCase(); - // ONE definition (hr_max.dart): null when age is unknown OR when we cannot - // say which strap measured this day. + // ONE definition (hr_max.dart): null when the age is unknown or not a + // plausible age. final hrMax = estimatedMaxHr(profile.ageYears, daySub.deviceFamily); final rhrForTrimp = restingHr ?? profile.restingHrManual?.toDouble(); // THIS user's quiet-waking level, from PRIOR days only — the same list the @@ -8350,7 +8350,10 @@ class DerivationEngine { // gates on exactly these inputs; the two must not disagree about WHY. final ceilingAbsent = hrMax != null ? null - : profile.ageYears == null + // An age stored before the profile forms checked it (a birth year, 0) + // has no ceiling either, and it is the age that needs fixing, not the + // strap. + : profile.ageYears == null || !plausibleAgeYears(profile.ageYears!) ? needInputNote('age') // We know the age; we do not know what measured the HR, so we // have no ceiling to band it against. diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 47bc9559c..9c83f05f4 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -39,7 +39,7 @@ import 'hr_max.dart' smoothedMaxHr, smoothedMinHr, trainingZones; -import 'profile.dart' show workoutSex; +import 'profile.dart' show plausibleAgeYears, workoutSex; import 'step_cadence.dart' show cadenceSpmForMinutes; // Same argument: a pure `DateTime` lookup, no DB / IO / Flutter binding. It is // the ONE definition of "the UTC offset in effect at this instant" in the tree, @@ -1071,7 +1071,9 @@ Map deriveDayBundle(Map inputJson) { // `zone_timeline`, so they gate on the set and not on the age estimate. final ceilingAbsentNote = hrMax != null ? null - : age == null + // A pre-validation age (a birth year, 0) also yields no ceiling; name + // the age, not the strap. + : age == null || !plausibleAgeYears(age) ? needInputNote('age') // The age is known; what measured this HR is not, so there is no ceiling // to band it against (analytics' device.dart contract). diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index dc440046b..504f64f5c 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -4423,7 +4423,9 @@ class LocalRepositoryImpl extends LocalRepository { final age = _profileAge(); final zonesNote = set != null ? null - : age == null + // A pre-validation age (a birth year, 0) has no ceiling estimate + // either; the fix is the age, not the strap. + : age == null || !plausibleAgeYears(age) ? needInputNote('age') : ana.unknownFamilyNote(family); // TS-05 — the 28-day distribution is ABSENT, not captioned, until BOTH zone diff --git a/test/implausible_age_absence_note_test.dart b/test/implausible_age_absence_note_test.dart new file mode 100644 index 000000000..b765cdd2c --- /dev/null +++ b/test/implausible_age_absence_note_test.dart @@ -0,0 +1,121 @@ +// A stored age the profile forms would now refuse (a birth year typed into the +// age box, or 0) gives no HRmax estimate, so strain, calories and the ceiling +// are absent. The reason on those figures has to name the age: it used to fall +// through to `unknown_device_family`, which the screens render as "these +// recordings are not stamped with which strap made them" on a correctly +// stamped WHOOP day, and nothing pointed at the profile. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; +import 'package:openstrap_edge/compute/profile.dart'; +import 'package:openstrap_edge/compute/substrate.dart'; + +const int _t0 = 1786699980; + +/// 300 waking minutes at 62 bpm then 60 at 130, one sample per second. +Substrate _sub() { + final ts = [], hr = []; + final ax = [], ay = [], az = []; + var i = 0; + for (final (mins, bpm) in const [(300, 62), (60, 130)]) { + for (var s = 0; s < mins * 60; s++, i++) { + ts.add(_t0 + i); + hr.add(bpm); + ax.add(0.01 * ((i % 7) - 3)); + ay.add(0.01 * ((i % 5) - 2)); + az.add(1.0); + } + } + final n = ts.length; + return Substrate( + tsSec: ts, + hr: hr, + rrTsMs: const [], + rrMs: const [], + ax: ax, + ay: ay, + az: az, + spo2Red: List.filled(n, 0), + spo2Ir: List.filled(n, 0), + skinTemp: List.filled(n, 0), + skinContact: List.filled(n, 0), + deviceFamily: 'gen4', + ); +} + +Map _engineNotes(int age) { + final sub = _sub(); + final scalars = {}; + final bundle = {}; + DerivationEngine.applyDayActivity( + bundle: bundle, + scalars: scalars, + daySub: sub, + profile: + Profile(ageYears: age, sex: 'm', weightKg: 75, heightCm: 178), + sleepOnsetSec: 0, + sleepOffsetSec: 0, + dayStartSec: sub.tsSec.first, + dayCalendarEndSec: sub.tsSec.last + 1, + dataNowSec: sub.tsSec.last + 1, + restingHr: 55, + quietHrrHistory: List.filled(7, 0.20), + ); + expect(scalars['strain'], isNull); + return (bundle['absent_notes'] as Map).cast(); +} + +Map _pipelineNotes(int age) { + final sub = _sub(); + final bundle = deriveDayBundle(DayBundleInput( + date: '2026-08-15', + dayTsSec: sub.tsSec, + dayHr: sub.hr, + sleepTsSec: const [], + sleepHr: const [], + sleepRrTsMs: const [], + sleepRrMs: const [], + sleepSkinTemp: const [], + sleepJson: const {}, + hypnoStages: const [], + sleepOnsetSec: 0, + sleepOffsetSec: 0, + profile: { + 'age': age, + 'sex': 'm', + 'weight_kg': 75, + 'height_cm': 178, + 'resting_hr': 55, + }, + deviceFamily: 'gen4', + quietHrrHistory: List.filled(7, 0.20), + ).toJson()); + return (bundle['absent_notes'] as Map).cast(); +} + +void main() { + for (final age in const [1990, 0]) { + group('stored age $age on a stamped gen4 day', () { + test('the engine half names the age, not the strap', () { + final notes = _engineNotes(age); + expect(notes['strain'], 'need_input:name=age'); + expect(notes['calories'], 'need_input:name=age'); + for (final n in notes.values) { + expect(n as String, isNot(startsWith('unknown_device_family'))); + } + }); + + test('the pure pipeline names the age, not the strap', () { + final notes = _pipelineNotes(age); + expect(notes['strain'], 'need_input:name=age'); + expect(notes['max_hr_used'], 'need_input:name=age'); + expect(notes['calories'], 'need_input:name=age'); + for (final n in notes.values) { + expect(n as String, isNot(startsWith('unknown_device_family'))); + } + }); + }); + } +} From 70154f8e10af2841d938e7436b71d8a2f180a610 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:55:49 +0530 Subject: [PATCH 34/41] the temperature illness watch went blind for a week around unstamped nights the watch kept only nights whose family stamp matched the newest one, and compared the raw stamp, so a null stamp counted as a different strap. after upgrading, the first stamped night dropped every older unstamped night, and a mixed-strap newest night dropped every stamped one; either way the flag sat on need_baseline until a week of matching nights built up. an unstamped night now counts as a band night, as it already does for the breakdown and the per-day baseline, and the family is taken from the newest stamped night. a wearable's nights still never take an unstamped one. (cherry picked from commit 608df892e0829403609f1482409f8e5d0274749d) --- lib/compute/crossday_pipeline.dart | 22 ++++++++++++++++++---- test/crossday_pipeline_test.dart | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/lib/compute/crossday_pipeline.dart b/lib/compute/crossday_pipeline.dart index 8fc272991..e49f5ab87 100644 --- a/lib/compute/crossday_pipeline.dart +++ b/lib/compute/crossday_pipeline.dart @@ -82,8 +82,16 @@ Map buildCrossDayBundle( // // Raw values are in their device family's unit (gen4 ADC counts, gen5 // centi-°C, a wearable's own scale), which the z-score had been hiding. Only - // nights in the newest scored night's family are kept; the rest are null - // rather than mixed into one baseline. + // nights in the newest stamped scored night's family are kept; the rest are + // null rather than mixed into one baseline. + // + // An unstamped night (written before the stamp existed, or a day spanning + // two straps) is a band's, as `methodOf` below and the per-day baseline + // cache already treat it. Comparing the raw stamp instead made the first + // stamped night after an upgrade drop the whole unstamped history, and a + // mixed-strap newest night drop every stamped one, leaving the watch on + // `need_baseline` for a week either way. A wearable's nights still never + // take an unstamped one. final rawTemp = [ for (final d in days) _numOrNull(d['skin_temp_adc']), ]; @@ -92,14 +100,20 @@ Map buildCrossDayBundle( ]; String? latestTempFamily; for (var i = n - 1; i >= 0; i--) { - if (rawTemp[i] != null) { + if (rawTemp[i] != null && tempFamily[i] != null) { latestTempFamily = tempFamily[i]; break; } } + final unstampedFits = latestTempFamily == null || + latestTempFamily == 'gen4' || + latestTempFamily == 'gen5'; final tempRawList = [ for (var i = 0; i < n; i++) - tempFamily[i] == latestTempFamily ? rawTemp[i] : null, + tempFamily[i] == latestTempFamily || + (tempFamily[i] == null && unstampedFits) + ? rawTemp[i] + : null, ]; // A day flagged `unsettled` (today, until it was derived from a complete diff --git a/test/crossday_pipeline_test.dart b/test/crossday_pipeline_test.dart index f1bd9c382..758e2e978 100644 --- a/test/crossday_pipeline_test.dart +++ b/test/crossday_pipeline_test.dart @@ -828,6 +828,26 @@ void _wiredFamilies() { final b = buildCrossDayBundle(days, const {}); expect((b['temp_illness'] as Map)['date'], days.last['date']); }); + + test('an unstamped history stays in the baseline once nights are stamped', + () { + // Rows from before the family stamp existed, then five stamped nights + // that include the rise. Too few stamped nights to baseline on their own. + final f = flags(feverDays( + feverSd: 3, + family: (i) => i < 38 ? null : 'gen4', + )); + expect(f.sublist(f.length - 3), [false, true, true]); + }); + + test('an unstamped newest night keeps the stamped history', () { + // A strap-swap day carries no single family; it is still a band night. + final f = flags(feverDays( + feverSd: 3, + family: (i) => i == 42 ? null : 'gen4', + )); + expect(f.sublist(f.length - 3), [false, true, true]); + }); }); // The multivariate anomaly standardises every column itself, so its temp From 7d3b241a17076b93543f157b217d7eb72ad24792 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 15:01:26 +0530 Subject: [PATCH 35/41] drop the 0-100 strain probe from the cadence rig the rig still called trimpStrain, which the analytics sweep deleted along with the rest of the leftover 0-100 strain scale. it compiled only because the pin still points at a commit that has it; re-pinning to the sweep would have broken the whole file. probing that scale said nothing about the 0-21 strain shown in the app, so the probe goes and the grid-size check counts eleven metrics. (cherry picked from commit f68c2eec77ec8ead52a74dfab0cba50da02a5299) --- test/cadence_decimation_rig_test.dart | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/test/cadence_decimation_rig_test.dart b/test/cadence_decimation_rig_test.dart index 9d17ada12..a89d4ddaa 100644 --- a/test/cadence_decimation_rig_test.dart +++ b/test/cadence_decimation_rig_test.dart @@ -23,7 +23,7 @@ // the RR beats attached to it — no interpolation, no bucket averaging. That is // also the physically honest model: a 60 s band emits fewer records, it does // not emit averages of the records it skipped. Per-metric notes below say where -// a metric's own contract would have justified averaging (none of the twelve +// a metric's own contract would have justified averaging (none of the eleven // declare a mean input; `relative_odi` computes its own AC/DC windows and // `cardio_stager` its own epoch means, both from raw samples). // @@ -245,19 +245,6 @@ _Probe _hrZonesProbe(List<_Row> rows) { return (t?.total, t == null); } -_Probe _loadTrimpProbe(List<_Row> rows) { - final valid = [for (final r in rows) if (r.hr > 0) r]; - final m = trimpStrain( - [for (final r in valid) r.hr.toDouble()], - [for (final r in valid) r.recTs.toDouble()], - // Fixed anchors: the rig compares a metric against ITSELF at 1 Hz, so the - // anchors only have to be constant across cadences, not personal. - maxHr: 190, - restingHr: 50, - ); - return (m.value, !m.present); -} - _Probe _orientationProbe(List<_Row> rows) { final tilts = positionSeries([ for (final r in rows) @@ -457,7 +444,6 @@ void main() { run('cardioStager.wakePct', '%', n, _cardioStagerProbe(nRows, nBeats)); run('hrRecovery.dropBpm', 'bpm', n, _hrRecoveryProbe(dRows)); run('hrZones.totalSec', 's', n, _hrZonesProbe(dRows)); - run('loadTrimp.strain', '0-100', n, _loadTrimpProbe(dRows)); run('orientation.epochs', 'n', n, _orientationProbe(nRows)); run('cycles.n', 'n', n, _cyclesProbe(nBeats, nightA, nightB)); run('respRate.riiv', 'brpm', n, _riivProbe(nRows)); @@ -498,7 +484,7 @@ void main() { // NO correctness assertions. Only that the rig produced a full grid — // if a probe starts throwing, this is what notices. - expect(cells.length, _cadences.length * 12 + 1); + expect(cells.length, _cadences.length * 11 + 1); }, timeout: const Timeout(Duration(minutes: 10))); } } From 502764dcc33b9f71cf80aaea1e84aa6382b818b8 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 22:03:34 +0530 Subject: [PATCH 36/41] bump kAlgoVersion to 116 so stored days pick up the scoring fixes --- lib/compute/derivation_engine.dart | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 29f34ca3d..dc8446793 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1811,7 +1811,20 @@ import 'vendor_sleep.dart'; // store `skin_temp_ambient_r` and the illness watch leaves out WHOOP 4 nights // whose skin temp tracks ambient (|r| >= 0.8, or unmeasured). One-shot, // raw-free: days derived before v106 drop naps overlapping the next night. -const int kAlgoVersion = 113; +// 113 → 116 (edge-only; 114 and 115 are claimed by the open sleep-continuation +// change): the temperature illness flag scores the raw nightly skin temp, not +// skin_temp_z, and the illness anomaly standardises it once; the skin temp +// baseline leaves out unsettled nights and the nightly mean drops the cold +// warm-up samples; a warm-bedroom night no longer hides an open skin temp run +// and unstamped nights no longer blind the watch for a week; the Plews +// rolling-7 lnRMSSD history is dated by calendar night and a night with no +// sleep-session RMSSD stores no band; BRV honours the breathing-rate artifact +// gate; the readiness breakdown ranks tonight against the same device's +// history; the cycle-phase flag counts days across DST; the day strain curve +// carries a workout-raised headline; a logged nap over the main sleep is +// counted once; an implausible stored age is named as the reason strain, +// calories and zones are absent. +const int kAlgoVersion = 116; /// The sibling SHAs this version was derived against, asserted against /// pubspec.yaml in test/db_serve_version_and_reads_test.dart. /// From fa5c755a5d2c14348ed00e9c719ba5b08f242c0a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 22:07:20 +0530 Subject: [PATCH 37/41] use a null-aware element for the edited day --- lib/state/app_state.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index af270bcfa..fe6267e37 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2637,7 +2637,7 @@ class AppState extends ChangeNotifier { await _derive.run( _profile, force: true, - editedDays: {if (editedDay != null) editedDay}, + editedDays: {?editedDay}, ); if (editedDay != null) { // A finalized day behind the export cursor is never re-written, so From 8d9930c6865198328efddda46c6b3e07645b3f8b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 22:42:36 +0530 Subject: [PATCH 38/41] freeze goldens carry algo_version 116 --- test/fixtures/whoop_freeze/gen4_minus0400.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen4_plus0530.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen4_utc.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen5_minus0400.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen5_plus0530.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen5_utc.json | 18 +++++++++--------- 6 files changed, 54 insertions(+), 54 deletions(-) diff --git a/test/fixtures/whoop_freeze/gen4_minus0400.json b/test/fixtures/whoop_freeze/gen4_minus0400.json index 90775bbfe..2509c77ca 100644 --- a/test/fixtures/whoop_freeze/gen4_minus0400.json +++ b/test/fixtures/whoop_freeze/gen4_minus0400.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen4_plus0530.json b/test/fixtures/whoop_freeze/gen4_plus0530.json index ea65f93dd..4f489c854 100644 --- a/test/fixtures/whoop_freeze/gen4_plus0530.json +++ b/test/fixtures/whoop_freeze/gen4_plus0530.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen4_utc.json b/test/fixtures/whoop_freeze/gen4_utc.json index 3b928cbb3..080f8e0ad 100644 --- a/test/fixtures/whoop_freeze/gen4_utc.json +++ b/test/fixtures/whoop_freeze/gen4_utc.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-14", "active_min": 239, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_minus0400.json b/test/fixtures/whoop_freeze/gen5_minus0400.json index 9a1ad6617..c1752aa1d 100644 --- a/test/fixtures/whoop_freeze/gen5_minus0400.json +++ b/test/fixtures/whoop_freeze/gen5_minus0400.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_plus0530.json b/test/fixtures/whoop_freeze/gen5_plus0530.json index d9fd17db9..05bb93f35 100644 --- a/test/fixtures/whoop_freeze/gen5_plus0530.json +++ b/test/fixtures/whoop_freeze/gen5_plus0530.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_utc.json b/test/fixtures/whoop_freeze/gen5_utc.json index 1eea70804..f09f6882b 100644 --- a/test/fixtures/whoop_freeze/gen5_utc.json +++ b/test/fixtures/whoop_freeze/gen5_utc.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-14", "active_min": 239, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 116, "payload_json": { "day_id": "2026-07-15", "active_min": 40, From 2feb049d223aa0ac69ddd512ea8e4b9503b6ef8d Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 13:01:10 +0530 Subject: [PATCH 39/41] sleep corrections on the oldest kept night, imperial profile saves, a few edge cases "i was asleep" and "these times are right" were hidden on the oldest kept night even when the bedtime they'd write lands after the prune cut. an untouched imperial weight at the floor (20 kg shows as 44 lb) read back under it and blocked saving the profile. a reject stored under tomorrow was missed when the day's data stopped before midnight. the nap cleanup threw on a next night with no sleep and stopped there. a throwing outcome callback could leave the derive latch set. the out-of-range message now reads right for more than one field. --- lib/compute/derivation_engine.dart | 22 ++++++++++--- lib/compute/nap_overlap_cleanup.dart | 8 +++-- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_es.arb | 2 +- lib/l10n/app_fr.arb | 2 +- lib/l10n/app_hi.arb | 2 +- lib/l10n/app_ru.arb | 2 +- lib/state/units_controller.dart | 15 +++++++++ lib/ui2/grammar.dart | 2 +- lib/ui2/onboarding/profile_setup.dart | 13 +++++--- lib/ui2/profile/settings.dart | 20 +++++++++--- lib/ui2/screens/sleep_detail.dart | 21 ++++++++++-- test/nap_overlap_cleanup_test.dart | 26 +++++++++++++++ test/profile_plausibility_test.dart | 34 ++++++++++++++++++++ test/rejected_sleep_next_label_test.dart | 41 ++++++++++++++++++++++++ 16 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 test/rejected_sleep_next_label_test.dart diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 99887dbac..563fb7455 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -3401,7 +3401,13 @@ class DerivationEngine { } finally { // Before any await: this run still holds the latch, so the fields are // its own on every exit path, early returns included. - onOutcome?.call(lastRunFailedDays, lastRunError); + // Guarded: a throwing callback would skip `_running = false` below and + // leave every later derive taking the busy skip until restart. + try { + onOutcome?.call(lastRunFailedDays, lastRunError); + } catch (e) { + _log('[derive] outcome callback threw: $e'); + } // Storage housekeeping runs here, after everything, still holding // `_running`. See _runStorageHousekeeping — this is the only place every // entry path and every early return actually reaches. @@ -5834,7 +5840,7 @@ class DerivationEngine { dataNowSec: dataNowSec, tonightSleepOnsetSec: day.tonightSleepOnsetSec, tonightSleepOffsetSec: day.tonightSleepOffsetSec, - rejectedSleepSpans: await _rejectedSleepSpans(day.date, day.endSec), + rejectedSleepSpans: await _rejectedSleepSpans(day.date), ); await debugSecondHalfHook?.call(day.date); final blocks = @@ -10514,13 +10520,19 @@ class DerivationEngine { Substrate s, int endSec) => _hrrForBout(s, endSec); + @visibleForTesting + static Future>> debugRejectedSleepSpans(String date) => + _rejectedSleepSpans(date); + /// Main-sleep windows the user rejected for this day's night and for /// tonight's (the next label's, which can begin inside this day's nap /// window). Read on the main isolate; the worker has no database. - static Future>> _rejectedSleepSpans( - String date, int dayEndSec) async { + static Future>> _rejectedSleepSpans(String date) async { final out = >[]; - for (final label in {date, localDateLabel(dayEndSec)}) { + // The next CALENDAR label, not the label of the day's last sample: on a + // day whose data stops before midnight that is `date` again, and a reject + // stored under tomorrow would never be read. + for (final label in {date, dayLabelBefore(date, -1) ?? date}) { final ov = await LocalDb.getSleepOverride(label); if (ov == null || ov['source'] != 'rejected') continue; final on = (ov['onset_ts'] as num).toInt(); diff --git a/lib/compute/nap_overlap_cleanup.dart b/lib/compute/nap_overlap_cleanup.dart index 44e126e4d..2888fe657 100644 --- a/lib/compute/nap_overlap_cleanup.dart +++ b/lib/compute/nap_overlap_cleanup.dart @@ -58,9 +58,11 @@ Future cleanNextNightNapsOnce() async { final payload = _decode(row?['payload_json']); final naps = (payload?['naps'] as Map?)?['value']; if (payload == null || naps is! List || naps.isEmpty) continue; - final win = (((_decode((await LocalDb.dayResult(nextDay))?['payload_json']) - ?['sleep'] as Map?)?['window'] as Map?)?['value']) as Map?; - final on = win?['onset_ms'], off = win?['offset_ms']; + // '—' on a night with no sleep: a cast would throw and end the pass. + final win = ((_decode((await LocalDb.dayResult(nextDay))?['payload_json']) + ?['sleep'] as Map?)?['window'] as Map?)?['value']; + if (win is! Map) continue; + final on = win['onset_ms'], off = win['offset_ms']; if (on is! num || off is! num) continue; final kept = napsWithoutNextNight( [for (final n in naps) if (n is Map) n.cast()], diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e54620941..1605f31b5 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2789,5 +2789,5 @@ "dataReanalyzeFailed": "Neuanalyse fehlgeschlagen: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{1 Tag konnte nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.} other{{n} Tage konnten nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.}}", "napsInFuture": "Dieses Nickerchen ist noch nicht vorbei. Trage es ein, wenn du wach bist.", - "profileImplausibleFields": "Bitte {fields} prüfen. Der Wert liegt außerhalb des gültigen Bereichs, daher wurde nichts gespeichert." + "profileImplausibleFields": "Bitte {fields} prüfen: außerhalb des gültigen Bereichs, daher wurde nichts gespeichert." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b72135859..2cf7ca953 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15834,7 +15834,7 @@ "@napsInFuture": { "description": "Validation error when a manually logged nap ends after the current time." }, - "profileImplausibleFields": "Check {fields}. That value is out of range, so nothing was saved.", + "profileImplausibleFields": "Check {fields}: out of range, so nothing was saved.", "@profileImplausibleFields": { "description": "Shown when a typed age, height or weight is outside the believable range.", "placeholders": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 7c44b9216..29be05423 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2693,5 +2693,5 @@ "dataReanalyzeFailed": "El reanálisis falló: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{No se pudo analizar 1 día. Si vuelve a pasar, por favor infórmalo.} other{No se pudieron analizar {n} días. Si vuelve a pasar, por favor infórmalo.}}", "napsInFuture": "Esa siesta todavía no ha terminado. Regístrala cuando te despiertes.", - "profileImplausibleFields": "Revisa {fields}. El valor está fuera de rango, así que no se guardó nada." + "profileImplausibleFields": "Revisa {fields}: fuera de rango, así que no se guardó nada." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index af4336b82..d7a377c37 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2664,5 +2664,5 @@ "dataReanalyzeFailed": "La réanalyse a échoué : {error}", "dataReanalyzeDaysFailed": "{n, plural, one{{n} jour n’a pas pu être analysé. Si cela se reproduit, merci de le signaler.} other{{n} jours n’ont pas pu être analysés. Si cela se reproduit, merci de le signaler.}}", "napsInFuture": "Cette sieste n'est pas encore terminée. Enregistrez-la une fois réveillé.", - "profileImplausibleFields": "Vérifiez {fields}. La valeur est hors limites, rien n'a donc été enregistré." + "profileImplausibleFields": "Vérifiez {fields} : hors limites, rien n'a donc été enregistré." } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index dce5ee3fa..ffa8ee646 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2664,5 +2664,5 @@ "dataReanalyzeFailed": "फिर से विश्लेषण विफल: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{1 दिन का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।} other{{n} दिनों का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।}}", "napsInFuture": "यह झपकी अभी खत्म नहीं हुई है। जागने के बाद इसे दर्ज करें।", - "profileImplausibleFields": "{fields} जाँचें। मान सीमा से बाहर है, इसलिए कुछ भी सहेजा नहीं गया।" + "profileImplausibleFields": "{fields} जाँचें: सीमा से बाहर, इसलिए कुछ भी सहेजा नहीं गया।" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2e07b9278..c2c980120 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7547,7 +7547,7 @@ } }, "napsInFuture": "Этот дневной сон ещё не закончился. Запишите его, когда проснётесь.", - "profileImplausibleFields": "Проверьте: {fields}. Значение вне допустимого диапазона, поэтому ничего не сохранено.", + "profileImplausibleFields": "Проверьте: {fields} — вне допустимого диапазона, поэтому ничего не сохранено.", "@profileImplausibleFields": { "placeholders": { "fields": { diff --git a/lib/state/units_controller.dart b/lib/state/units_controller.dart index b61d29c43..276b94fc0 100644 --- a/lib/state/units_controller.dart +++ b/lib/state/units_controller.dart @@ -169,6 +169,21 @@ class UnitsController extends ChangeNotifier { return isImperial ? v * _kgPerLb : v; } + /// [weightToKg], except that a field still showing exactly what + /// [weightField] wrote for [shownKg] gives [shownKg] back. Imperial shows + /// whole pounds, so an untouched field otherwise drifts off the stored value + /// on every save (20 kg -> "44" -> 19.96 kg, under the plausible floor). + double? weightToKgKeeping(String text, num? shownKg) => + shownKg != null && text.trim() == weightField(shownKg) + ? shownKg.toDouble() + : weightToKg(text); + + /// [heightToCm] with the same untouched-field rule as [weightToKgKeeping]. + double? heightToCmKeeping(String text, num? shownCm) => + shownCm != null && text.trim() == heightField(shownCm) + ? shownCm.toDouble() + : heightToCm(text); + /// Parse a height field (display units = total inches when imperial) → cm. double? heightToCm(String text) { final v = double.tryParse(text.trim()); diff --git a/lib/ui2/grammar.dart b/lib/ui2/grammar.dart index 0a23e31b6..9d6c2b4e3 100644 --- a/lib/ui2/grammar.dart +++ b/lib/ui2/grammar.dart @@ -1846,7 +1846,7 @@ void sayImplausible(BuildContext c, List fields) { SnackBar( content: Text( AppLocalizations.of(c)?.profileImplausibleFields(joined) ?? - 'Check $joined. That value is out of range, so nothing was saved.', + 'Check $joined: out of range, so nothing was saved.', ), ), ); diff --git a/lib/ui2/onboarding/profile_setup.dart b/lib/ui2/onboarding/profile_setup.dart index 27a4668e6..429de0f83 100644 --- a/lib/ui2/onboarding/profile_setup.dart +++ b/lib/ui2/onboarding/profile_setup.dart @@ -108,6 +108,11 @@ class _ProfileSetupViewState extends State { static String _str(Object? v) => v == null ? '' : '$v'; + double? _heightCm() => _u.heightToCmKeeping( + _height.text, widget.initial['height_cm'] as num?); + double? _weightKg() => _u.weightToKgKeeping( + _weight.text, widget.initial['weight_kg'] as num?); + /// Null until touched: then it follows the sex picked, on for female. bool? _cycle; bool get _cycleOn => _cycle ?? (widget.cycleTracking == true || _sex == 'f'); @@ -126,8 +131,8 @@ class _ProfileSetupViewState extends State { Map _fields() => { if (_sex != null) 'sex': _sex, if (Typed.of(_age.text).value case final v?) 'age': v.round(), - 'height_cm': ?_u.heightToCm(_height.text), - 'weight_kg': ?_u.weightToKg(_weight.text), + 'height_cm': ?_heightCm(), + 'weight_kg': ?_weightKg(), }; /// Continue, unless something typed cannot be read — a typo is not a blank, @@ -143,8 +148,8 @@ class _ProfileSetupViewState extends State { return; } final age = Typed.of(_age.text).value; - final height = _u.heightToCm(_height.text); - final weight = _u.weightToKg(_weight.text); + final height = _heightCm(); + final weight = _weightKg(); final off = [ if (age != null && !plausibleAgeYears(age)) AppLocalizations.of(context)?.settingsAgeFieldLabel ?? 'Age', diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index 859cad7f5..8378e8857 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -1539,6 +1539,12 @@ class _EditProfileViewState extends State { TextEditingController(text: _u.weightField(widget.initial['weight_kg'] as num?)); late String? _sex = (widget.initial['sex'] as String?)?.toLowerCase(); + /// The metric values the height/weight fields were last filled from, so an + /// untouched field saves the value it came from (see + /// [UnitsController.weightToKgKeeping]). + late num? _shownHeightCm = widget.initial['height_cm'] as num?; + late num? _shownWeightKg = widget.initial['weight_kg'] as num?; + /// When the store last gave us something. Null is "never", which is also /// what an unreadable preference reads as — the first-run word on the button /// is the safe one either way. @@ -1576,8 +1582,10 @@ class _EditProfileViewState extends State { if (!mounted) return; if (fields != null) { _age.text = _s(fields['age']); - _height.text = _u.heightField(fields['height_cm'] as num?); - _weight.text = _u.weightField(fields['weight_kg'] as num?); + _shownHeightCm = fields['height_cm'] as num?; + _shownWeightKg = fields['weight_kg'] as num?; + _height.text = _u.heightField(_shownHeightCm); + _weight.text = _u.weightField(_shownWeightKg); _sex = (fields['sex'] as String?)?.toLowerCase() ?? _sex; } setState(() { @@ -1628,9 +1636,13 @@ class _EditProfileViewState extends State { return; } final heightCm = - height.value == null ? null : _u.heightToCm(_height.text); + height.value == null + ? null + : _u.heightToCmKeeping(_height.text, _shownHeightCm); final weightKg = - weight.value == null ? null : _u.weightToKg(_weight.text); + weight.value == null + ? null + : _u.weightToKgKeeping(_weight.text, _shownWeightKg); final off = [ if (age.value case final a? when !plausibleAgeYears(a)) (l?.settingsAgeFieldLabel ?? 'Age'), diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index ca3b0b9b0..5990af466 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -442,6 +442,11 @@ class _SleepDetailState extends State with RevisionReload { /// declined; only a bedtime after midnight, or Not sleep, can land. bool _wholeNightKept = true; + /// Whether "These times are right" can land. Not the same as + /// [_wholeNightKept]: on the oldest kept day a fallback window that starts + /// after the prune cut can still be confirmed. + bool _confirmable = true; + /// Last night's state against the user's own nights, when it was rough enough /// to say so and has not been dismissed. See `rough_night.dart` — it REPLACES /// the elevated-sleeping-HR card rather than joining it, so one night can @@ -508,12 +513,19 @@ class _SleepDetailState extends State with RevisionReload { final d = await SleepData.load(repo, want: _day); var correctable = true; var wholeNightKept = true; + var confirmable = true; if (app != null && d.day != null) { try { correctable = await app.sleepCorrectable(d.day!, sleepSource: 'rejected'); wholeNightKept = correctable && await app.sleepCorrectable(d.day!); + final onset = (d.night['onset_ts'] as num?)?.toInt(); + confirmable = wholeNightKept || + (correctable && + onset != null && + await app.sleepCorrectable(d.day!, + sleepSource: 'confirmed', onsetSec: onset)); } catch (_) {} } // LAST NIGHT ONLY. The card names the luteal phase, and `getCycle` knows @@ -531,6 +543,7 @@ class _SleepDetailState extends State with RevisionReload { _rough = rough, _correctable = correctable, _wholeNightKept = wholeNightKept, + _confirmable = confirmable, _loading = false, )); } @@ -595,7 +608,10 @@ class _SleepDetailState extends State with RevisionReload { icon: rejected ? LucideIcons.undo2 : LucideIcons.moon, ), // Undo goes back to automatic, which needs the whole night window. - if (rejectedDay != null && (!_correctable || !_wholeNightKept)) ...[ + // "I was asleep" does not: a bedtime after midnight still lands, and + // the write is refused (and said so) when it doesn't. + if (rejectedDay != null && + (!_correctable || (rejected && !_wholeNightKept))) ...[ const SizedBox(height: S.x2), _correctable ? _eveningPrunedNote(c, l) : _uncorrectableNote(c, l), ] else if (rejected) ...[ @@ -623,6 +639,7 @@ class _SleepDetailState extends State with RevisionReload { : () => _addWindow(rejectedDay), child: Text(l?.sleepDetailIWasAsleep ?? 'I was asleep'), ), + if (!_wholeNightKept) _eveningPrunedNote(c, l), if (_saving) Text(l?.sleepDetailReanalysing ?? 'Re-analysing the night…', style: F.cap.copyWith(color: P.of(c).ink3)), @@ -888,7 +905,7 @@ class _SleepDetailState extends State with RevisionReload { _uncorrectableNote(c, l) else Wrap(spacing: S.x2, children: [ - if (fallback && _wholeNightKept) + if (fallback && _confirmable) TextButton( onPressed: busy ? null : () => _confirmWindow(day), child: Text(l?.sleepDetailConfirmTimes ?? 'These times are right'), diff --git a/test/nap_overlap_cleanup_test.dart b/test/nap_overlap_cleanup_test.dart index 7e051d480..c0abd7b05 100644 --- a/test/nap_overlap_cleanup_test.dart +++ b/test/nap_overlap_cleanup_test.dart @@ -68,6 +68,32 @@ void main() { finalized: true, ); + // An earlier day whose next night has no sleep ('—') is skipped, not a + // throw that ends the pass before the day above is reached. + await LocalDb.putDayResult( + dayId: '2026-07-30', + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'naps': { + 'value': [afternoon], + 'count': 1, + }, + }), + windowJson: '{}', + finalized: true, + ); + await LocalDb.putDayResult( + dayId: '2026-07-31', + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'sleep': { + 'window': {'value': '—'}, + }, + }), + windowJson: '{}', + finalized: true, + ); + expect(await cleanNextNightNapsOnce(), 1); final row = await LocalDb.dayResult('2026-08-02'); expect((row!['algo_version'] as num).toInt(), kAlgoVersion); diff --git a/test/profile_plausibility_test.dart b/test/profile_plausibility_test.dart index 389d89a21..ee7bbf07b 100644 --- a/test/profile_plausibility_test.dart +++ b/test/profile_plausibility_test.dart @@ -106,4 +106,38 @@ void main() { expect(saved, isNull); expect(find.textContaining('out of range'), findsOneWidget); }); + // Imperial pre-fills whole pounds: 20 kg shows "44", which reads back as + // 19.96 kg, under the floor. An untouched field keeps the stored value. + testWidgets('an untouched imperial weight on the floor still saves', + (t) async { + Map? saved; + await _pump( + t, + EditProfileView( + initial: const {'sex': 'f', 'age': 34, 'weight_kg': 20.0}, + units: UnitsController.seed(UnitSystem.imperial), + onSave: (f) async => saved = f, + )); + + await t.tap(find.text('Save')); + await t.pumpAndSettle(); + + expect(saved?['weight_kg'], 20.0); + }); + + testWidgets('onboarding keeps an untouched imperial weight', (t) async { + Map? saved; + await _pump( + t, + ProfileSetupView( + initial: const {'sex': 'f', 'age': 34, 'weight_kg': 20.0}, + units: UnitsController.seed(UnitSystem.imperial), + onSave: (f) async => saved = f, + )); + + await t.tap(find.text('Continue')); + await t.pumpAndSettle(); + + expect(saved?['weight_kg'], 20.0); + }); } diff --git a/test/rejected_sleep_next_label_test.dart b/test/rejected_sleep_next_label_test.dart new file mode 100644 index 000000000..83bd93088 --- /dev/null +++ b/test/rejected_sleep_next_label_test.dart @@ -0,0 +1,41 @@ +// A rejected main sleep stored under tomorrow's label has to reach today's nap +// filter even when today's data stops hours before midnight. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/data/db.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_rejected_sleep_next_label_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + test('a reject stored under the next calendar day is read', () async { + final on = DateTime(2026, 3, 4, 22).millisecondsSinceEpoch ~/ 1000; + final off = DateTime(2026, 3, 5, 6).millisecondsSinceEpoch ~/ 1000; + await LocalDb.putSleepOverride( + dayId: '2026-03-05', + onsetTs: on, + offsetTs: off, + source: 'rejected', + ); + expect(await DerivationEngine.debugRejectedSleepSpans('2026-03-04'), [ + [on, off], + ]); + }); +} From 462b532516ad6985a0e0edb05a8c13c5e03d6e72 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 14:21:38 +0530 Subject: [PATCH 40/41] drop next-night phantoms that were copied aside as legacy naps a rewrite of an old day copies its detected naps into the edits table as legacy, so the one-shot cleanup kept the very nap it was meant to drop and every later derive attached it again. --- lib/compute/nap_overlap_cleanup.dart | 20 ++++++--- lib/data/db.dart | 10 +++++ test/nap_overlap_cleanup_test.dart | 65 ++++++++++++++++++++++++++++ test/study_scoring_rules_test.dart | 1 + 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/lib/compute/nap_overlap_cleanup.dart b/lib/compute/nap_overlap_cleanup.dart index 2888fe657..e3c2a5671 100644 --- a/lib/compute/nap_overlap_cleanup.dart +++ b/lib/compute/nap_overlap_cleanup.dart @@ -17,8 +17,10 @@ import 'nap_edits.dart' show napMinutes; const String kNapNextNightCleanupKey = LocalDb.kNapNextNightCleanupKey; /// [naps] without the detected ones overlapping the next day's main sleep -/// [nextOnsetSec, nextOffsetSec). A nap the user logged (it carries a -/// `source`) is kept. Null when nothing is dropped. +/// [nextOnsetSec, nextOffsetSec). A nap the user logged or confirmed is kept. +/// A `legacy` one is not: that is an old detection copied aside when its day +/// was rewritten, so it carries the same double count. Null when nothing is +/// dropped. List>? napsWithoutNextNight( List> naps, int nextOnsetSec, @@ -26,7 +28,8 @@ List>? napsWithoutNextNight( ) { final kept = [ for (final n in naps) - if (n['source'] != null || + if (n['source'] == 'manual' || + n['source'] == 'confirmed' || n['start'] is! num || n['end'] is! num || (n['end'] as num) <= nextOnsetSec || @@ -54,16 +57,19 @@ Future cleanNextNightNapsOnce() async { final d = DateTime.parse(day); final nextDay = dayLabelOf(DateTime(d.year, d.month, d.day + 1, 12)); if (!versions.containsKey(nextDay)) continue; - final row = await LocalDb.dayResult(day); - final payload = _decode(row?['payload_json']); - final naps = (payload?['naps'] as Map?)?['value']; - if (payload == null || naps is! List || naps.isEmpty) continue; // '—' on a night with no sleep: a cast would throw and end the pass. final win = ((_decode((await LocalDb.dayResult(nextDay))?['payload_json']) ?['sleep'] as Map?)?['window'] as Map?)?['value']; if (win is! Map) continue; final on = win['onset_ms'], off = win['offset_ms']; if (on is! num || off is! num) continue; + // A rewrite of this day may already have copied the phantom aside as a + // legacy edit, and every later derive would attach it again. + await LocalDb.deleteLegacyNapsOverlapping(day, on ~/ 1000, off ~/ 1000); + final row = await LocalDb.dayResult(day); + final payload = _decode(row?['payload_json']); + final naps = (payload?['naps'] as Map?)?['value']; + if (payload == null || naps is! List || naps.isEmpty) continue; final kept = napsWithoutNextNight( [for (final n in naps) if (n is Map) n.cast()], on ~/ 1000, diff --git a/lib/data/db.dart b/lib/data/db.dart index 999168681..39ed15c5c 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -12243,6 +12243,16 @@ class LocalDb { static Future napOverlapsMainSleep(int startTs, int endTs) async => ActivityStore.overlapsMainSleep(await instance, startTs, endTs); + /// Drops [dayId]'s `legacy` nap edits overlapping [startSec, endSec). Logged, + /// confirmed and rejected edits are left alone. + static Future deleteLegacyNapsOverlapping( + String dayId, int startSec, int endSec) async { + final db = await instance; + return db.delete('sleep_nap', + where: "day_id = ? AND source = 'legacy' AND start_ts < ? AND end_ts > ?", + whereArgs: [dayId, endSec, startSec]); + } + static Future>> napEdits(String dayId) async { final db = await instance; return db.query( diff --git a/test/nap_overlap_cleanup_test.dart b/test/nap_overlap_cleanup_test.dart index c0abd7b05..0643ff9c8 100644 --- a/test/nap_overlap_cleanup_test.dart +++ b/test/nap_overlap_cleanup_test.dart @@ -109,4 +109,69 @@ void main() { // One-shot: a second call does nothing. expect(await cleanNextNightNapsOnce(), 0); }); + + test('a phantom already copied aside as a legacy edit is dropped too', + () async { + await LocalDb.deleteComputeFreshness(kNapNextNightCleanupKey); + // 2026-09-02 22:30Z -> 2026-09-03 06:30Z. + const onset = 1788388200; + const offset = onset + 8 * 3600; + final legacy = { + 'start': onset, + 'end': onset + 2400, + 'duration_min': 40, + 'source': 'legacy', + }; + final manual = { + 'start': onset - 3 * 3600, + 'end': onset - 3 * 3600 + 1200, + 'duration_min': 20, + 'source': 'manual', + }; + await LocalDb.putDayResult( + dayId: '2026-09-02', + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'naps': { + 'value': [manual, legacy], + 'count': 2, + }, + 'scalars': {'nap_min': 60.0}, + }), + windowJson: '{}', + finalized: true, + ); + await LocalDb.putDayResult( + dayId: '2026-09-03', + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'sleep': { + 'window': { + 'value': {'onset_ms': onset * 1000, 'offset_ms': offset * 1000}, + }, + }, + }), + windowJson: '{}', + finalized: true, + ); + final db = await LocalDb.instance; + for (final n in [legacy, manual]) { + await db.insert('sleep_nap', { + 'day_id': '2026-09-02', + 'start_ts': n['start'], + 'end_ts': n['end'], + 'source': n['source'], + 'created_at': 0, + 'payload_json': jsonEncode(n), + }); + } + + expect(await cleanNextNightNapsOnce(), 1); + final out = jsonDecode( + (await LocalDb.dayResult('2026-09-02'))!['payload_json'] as String) as Map; + expect(((out['naps'] as Map)['value'] as List).map((n) => (n as Map)['source']), + ['manual']); + final edits = await LocalDb.napEdits('2026-09-02'); + expect(edits.map((e) => e['source']), ['manual']); + }); } diff --git a/test/study_scoring_rules_test.dart b/test/study_scoring_rules_test.dart index ac7cb5a41..6625644e4 100644 --- a/test/study_scoring_rules_test.dart +++ b/test/study_scoring_rules_test.dart @@ -100,6 +100,7 @@ void main() { {'start': 100, 'end': 200, 'duration_min': 2}, {'start': 900, 'end': 1200, 'duration_min': 5}, {'start': 950, 'end': 1000, 'duration_min': 1, 'source': 'manual'}, + {'start': 990, 'end': 1100, 'duration_min': 2, 'source': 'legacy'}, ]; final kept = napsWithoutNextNight(naps, 1000, 5000)!; expect(kept.map((n) => n['start']), [100, 950]); From 1a9268e8cfafcb931f818d5213c4b872595fc830 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 15:19:09 +0530 Subject: [PATCH 41/41] re-arm the next-night nap cleanup when an import brings nap edits an import carrying only nap edits recomposes them into kept days, which can bring a legacy next-night nap back, but the cleanup marker was only cleared when day results came in too. --- lib/data/db.dart | 7 +++++-- test/nap_overlap_cleanup_test.dart | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 39ed15c5c..08604659c 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -10706,8 +10706,11 @@ class LocalDb { // import. Rewind it. if ((counts['day_result'] ?? 0) > 0) { await putComputeFreshness(kReencodeCursorKey, jsonEncode({})); - // Same for the raw-free nap cleanup: imported old days may carry the - // next-night nap double count it removes, so it runs again. + } + // Same for the raw-free nap cleanup: imported old days, or legacy nap + // edits recomposed into kept days, may carry the next-night nap double + // count it removes, so it runs again. + if ((counts['day_result'] ?? 0) > 0 || (counts['sleep_nap'] ?? 0) > 0) { await deleteComputeFreshness(kNapNextNightCleanupKey); } // Last, so it can never be mistaken for a table row count by anything that diff --git a/test/nap_overlap_cleanup_test.dart b/test/nap_overlap_cleanup_test.dart index 0643ff9c8..8a9c2aa21 100644 --- a/test/nap_overlap_cleanup_test.dart +++ b/test/nap_overlap_cleanup_test.dart @@ -3,6 +3,7 @@ // current version without touching its naps. import 'dart:convert'; +import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; @@ -174,4 +175,28 @@ void main() { final edits = await LocalDb.napEdits('2026-09-02'); expect(edits.map((e) => e['source']), ['manual']); }); + + test('importing nap edits alone re-arms the cleanup', () async { + final db = await LocalDb.instance; + await db.insert('sleep_nap', { + 'day_id': '2026-09-02', + 'start_ts': 1788388200, + 'end_ts': 1788388200 + 2400, + 'source': 'legacy', + 'created_at': 0, + 'payload_json': jsonEncode({'duration_min': 40, 'source': 'legacy'}), + }); + final dir = Directory.systemTemp.createTempSync('nap-cleanup-import-'); + addTearDown(() => dir.deleteSync(recursive: true)); + final path = p.join(dir.path, 'backup.db'); + await db.execute('VACUUM INTO ?', [path]); + await db.delete('sleep_nap', + where: 'day_id = ? AND source = ?', whereArgs: ['2026-09-02', 'legacy']); + await LocalDb.putComputeFreshness(kNapNextNightCleanupKey, '{}'); + + final counts = await LocalDb.importFromDbFile(path); + expect(counts['day_result'] ?? 0, 0); + expect(counts['sleep_nap'], greaterThan(0)); + expect(await LocalDb.computeFreshness(kNapNextNightCleanupKey), isNull); + }); }