diff --git a/lib/compute/crossday_pipeline.dart b/lib/compute/crossday_pipeline.dart index e49f5ab87..2f923b638 100644 --- a/lib/compute/crossday_pipeline.dart +++ b/lib/compute/crossday_pipeline.dart @@ -24,7 +24,7 @@ import '../data/day_label.dart'; // IO, no Flutter binding, so importing it does not compromise this file's // isolate safety. One grammar for "a required input was missing" across both // pipelines rather than a second one here. -import 'onehz_pipeline.dart' show needInputNote; +import 'onehz_pipeline.dart' show kSleepClockAnchorSec, needInputNote; /// Build the cross-day analytics bundle from a time-ordered (OLDEST FIRST) list /// of per-day records and the user profile. @@ -34,6 +34,8 @@ import 'onehz_pipeline.dart' show needInputNote; /// '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?, +/// 'clock_shift_sec': int? (present only for a night recorded in another +/// zone than the phone's current one — see [recordedClockShiftSec]), /// 'hypnogram': [{start,end,stage}]?} /// /// Returns a JSON-safe map of the latest/aggregate cross-day results. Every @@ -209,7 +211,10 @@ Map buildCrossDayBundle( // LOCAL clock-hours [0,24). onset/wake are epoch SECONDS (UTC); `% 86400` // would give the UTC time-of-day, not the user's wall clock — convert via a // local DateTime so a UTC+5:30 user's 08:00 wake reads as 08:00, not 02:30. - final midH = _localTodMin(midSec.round()) / 60.0; + // …and on the clock the night was LIVED on, not the phone's current one: + // after a flight every past night would otherwise move by the zone change + // and chronotype/social jetlag would read the trip as a schedule shift. + final midH = _nightClockMin(midSec.round(), d) / 60.0; final free = _isFreeDay(d['date'] as String?); if (free) { freeMidH.add(midH); @@ -482,9 +487,24 @@ Map buildCrossDayBundle( note: need.present ? needInputNote('tst_min') : need.note, ); // typical wake clock-minute + efficiency from recent days (medians). + // + // The wake is read on the clock of the zone each night was LIVED in, not the + // phone's current zone. Converting the absolute instants with today's zone + // is what printed "Target bedtime 4:00 PM / Target wake 1:31 AM" to a user + // who had flown 7 h west: 08:31 wakes recorded at UTC+2 re-read as 01:31 + // in the new zone, and the bedtime backed off them landed mid-afternoon. + // + // DESIGN: the targets are a LOCAL CLOCK schedule (wake ~07:45, bed ~23:30), + // carried into whatever zone the phone is in now — the social schedule moves + // with the traveller, the absolute instants do not. Nothing here models the + // body clock catching up across the trip; the median over up to 90 nights + // absorbs the first jet-lagged nights in the new zone, and as nights are + // lived there it simply keeps reading their local clock too. See + // [recordedClockShiftSec] for how a stored night's own clock is recovered. final wakeMins = [ for (final d in days) - if (d['wake_sec'] != null) _localTodMin((d['wake_sec'] as num).toInt()), + if (d['wake_sec'] != null) + _nightClockMin((d['wake_sec'] as num).toInt(), d), ]; final effs = [for (final d in days) ?_numOrNull(d['efficiency'])]; final typicalWakeMin = _median(wakeMins); @@ -741,6 +761,89 @@ double _localTodMin(int epochSec) { return t.hour * 60.0 + t.minute + t.second / 60.0; } +/// Local minute-of-day [0,1440) of [epochSec] on the clock night [d] was LIVED +/// on: [_localTodMin] moved by the record's `clock_shift_sec` (see +/// [recordedClockShiftSec]). A record without one was recorded in the phone's +/// current zone, or we cannot tell — and this IS [_localTodMin], to the second. +double _nightClockMin(int epochSec, Map d) { + final shift = d['clock_shift_sec']; + final shiftMin = shift is num && shift.isFinite ? shift / 60.0 : 0.0; + return (_localTodMin(epochSec) + shiftMin) % 1440.0; +} + +/// How far a stored night's OWN local clock sits from the phone's CURRENT +/// zone, in seconds [0,86400) — 0 when it was recorded in this zone, or when +/// we cannot tell. Called by the record builder +/// (`DerivationEngine.crossDayInputRecord`), which writes it as +/// `clock_shift_sec` only when it is non-zero. +/// +/// WHY THIS EXISTS. `onset_sec`/`wake_sec` are absolute instants. The only way +/// to put one on a wall clock is a zone, and `DateTime` only knows the phone's +/// CURRENT zone: after a trip it converts every past night with the new zone's +/// rules (DST-correct for the new zone, but the user was never there). The +/// zone they were in is not stored on the day row — but the per-day pipeline +/// wrote `sleep_onset_sec` (onehz_pipeline's `sleepClockOffsetSec`): the +/// onset's LOCAL clock position, frozen at derive time in the zone the phone +/// was in then ([onsetClockSec], signed seconds either side of +/// [kSleepClockAnchorSec]). Comparing that frozen clock with the same instant +/// re-read in today's zone yields the frame shift, which then applies to every +/// other instant of that night (wake, mid-sleep). +/// +/// SNAPPED TO 15 MIN, because every real UTC offset is a multiple of 15 min +/// and the two clocks are the same instant read twice — any residue is +/// rounding, never a zone. +/// +/// LIMITS, stated rather than hidden: +/// - A night without the frozen clock (derived before SLP-09 started writing +/// it, mid-August 2026) reads in the current zone, as it always did. Those +/// age out of the 90-night window. +/// - A night first derived AFTER the trip (synced only once there) froze +/// the new zone's clock and reads in it. A RE-derive keeps the clock the +/// stored row already had ([storedNightClockShiftSec]), so "Re-analyze +/// data" or a version bump does not move a pre-trip night. +/// - A DST change inside the night itself is applied with the CURRENT zone's +/// rules at the wake instant: off by at most an hour, twice a year, for a +/// traveller only (the shift is 0 at home). +int recordedClockShiftSec({ + required num? onsetSec, + required num? onsetClockSec, +}) { + if (onsetSec == null || onsetClockSec == null || !onsetClockSec.isFinite) { + return 0; + } + final recordedSecOfDay = + (onsetClockSec.round() + kSleepClockAnchorSec) % 86400; + final nowFrameSecOfDay = (_localTodMin(onsetSec.toInt()) * 60).round(); + final raw = (recordedSecOfDay - nowFrameSecOfDay) % 86400; + return ((raw / 900).round() * 900) % 86400; +} + +/// [recordedClockShiftSec] of a stored `day_result` payload: its sleep onset +/// instant against the `sleep_onset_sec` clock it froze. 0 when either is +/// missing. +int storedNightClockShiftSec(Map? payload) { + final sleep = payload?['sleep']; + final win = sleep is Map ? sleep['window'] : null; + final val = win is Map ? win['value'] : null; + final onsetMs = val is Map ? val['onset_ms'] : null; + final scalars = payload?['scalars']; + final clock = scalars is Map ? scalars['sleep_onset_sec'] : null; + return recordedClockShiftSec( + onsetSec: onsetMs is num ? (onsetMs / 1000).round() : null, + onsetClockSec: clock is num ? clock : null, + ); +} + +/// A frozen sleep clock (signed seconds either side of +/// [kSleepClockAnchorSec], as `sleepClockOffsetSec` writes it) moved by +/// [shiftSec] and wrapped back into (-12 h, +12 h]. Null stays null. +double? shiftRecordedClockSec(num? clockSec, int shiftSec) { + if (clockSec == null || !clockSec.isFinite) return null; + var v = (clockSec + shiftSec) % 86400; + if (v > 43200) v -= 86400; + return v.toDouble(); +} + double? _mean(List xs) { if (xs.isEmpty) return null; var s = 0.0; diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 0a8002c1b..8a0437de5 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1825,7 +1825,13 @@ import 'vendor_sleep.dart'; // 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; +// 116 → 117 (#624): the cross-day rollup reads each night's onset/wake/mid-sleep +// on the clock it was lived on (`clock_shift_sec`, recovered from the stored +// `sleep_onset_sec`), so after a zone change the Sleep Coach targets, typical +// wake, chronotype and social jetlag no longer move by the zone difference. A +// re-derive keeps the stored night's clock. Unchanged for a user who has not +// changed zone. Edge-only. +const int kAlgoVersion = 117; /// The sibling SHAs this version was derived against, asserted against /// pubspec.yaml in test/db_serve_version_and_reads_test.dart. /// @@ -3249,6 +3255,13 @@ class DerivationEngine { await _withWearableEdge(await LocalDb.lastDecodedRecTs()) ?? 0; if (dataNowSec <= 0) { _log('derive: no decoded data'); + // A restored backup can hold every day and no decoded data. The + // rollup is built from stored days, so a zone change still rebuilds + // it here; otherwise the reader keeps abstaining on the old zone. + if (await _crossDayZoneStale()) { + await _refreshBaselines(); + await _runCrossDay(profile); + } return 0; } final finalized = await LocalDb.finalizedDayIds(kAlgoVersion); @@ -3275,7 +3288,9 @@ class DerivationEngine { // the todo set; re-score it here too rather than leave it a pass. final rescored = await _quietLevelRepass(profile, dataNowSec, scope.rawDays); - if (rescored > 0) { + // A zone change with no day to derive still moves every past night's + // clock: rebuild the rollup, or it keeps the old zone's reading. + if (rescored > 0 || await _crossDayZoneStale()) { await _refreshBaselines(); await _runCrossDay(profile); await _runNotifications(); @@ -6086,6 +6101,32 @@ class DerivationEngine { } } + // RE-DERIVING KEEPS THE NIGHT'S OWN CLOCK. The pipeline freezes + // `sleep_onset_sec`/`midsleep_sec` with the phone's zone NOW, so re-deriving + // a night lived before a trip (re-analyze, a rescan, a version bump) would + // overwrite the only record of the zone it was lived in, and the cross-day + // rollup would read it on the new clock. Carry the stored row's shift. + // + // Two numbers off the stored row, extracted in SQLite: decoding the whole + // previous bundle here cost a full payload read and curve expansion on + // this isolate for every derived day, to learn nothing for anyone who has + // not changed zone. + final prevClock = await LocalDb.storedNightClock(day.date); + if (prevClock != null) { + final onsetMs = prevClock.onsetMs; + final shift = recordedClockShiftSec( + onsetSec: onsetMs == null ? null : (onsetMs / 1000).round(), + onsetClockSec: prevClock.onsetClockSec, + ); + final s = bundle['scalars']; + if (shift != 0 && s is Map) { + for (final k in const ['sleep_onset_sec', 'midsleep_sec']) { + final v = shiftRecordedClockSec(s[k] as num?, shift); + if (v != null) s[k] = v; + } + } + } + final scalars = (bundle['scalars'] as Map?)?.cast() ?? const {}; double? sc(String k) => (scalars[k] as num?)?.toDouble(); @@ -6780,6 +6821,9 @@ class DerivationEngine { Future _runCrossDay(Profile profile) async { try { final reviewRevision = await LocalDb.activityReviewRevision(); + // The zone the inputs are read in, taken BEFORE they are read: a change + // during the build must not stamp old-zone shifts with the new zone. + final zone = crossDayZoneStamp(); final days = await _crossDayInputDays(); if (days.length < 3) { _log('crossday: only ${days.length} usable day(s) — skip'); @@ -6824,7 +6868,8 @@ class DerivationEngine { ) ..['algo_version'] = kAlgoVersion ..['built_for_day'] = builtForDay - ..['built_at_epoch'] = builtAtEpoch; + ..['built_at_epoch'] = builtAtEpoch + ..['zone'] = zone; // Encode-safety BEFORE jsonEncode, never a try/catch around it: one // non-finite leaf must cost that leaf, not the whole artifact. final paths = []; @@ -6835,6 +6880,12 @@ class DerivationEngine { _crossDayTimeout, label: 'crossday', ); + if (crossDayZoneStamp() != zone) { + // The zone moved while this was built: drop it; the stale check + // rebuilds it on the next pass in the new zone. + _log('crossday: zone changed during the build; not stored'); + return false; + } if (!await LocalDb.putReviewedBaseline('crossday', bundleJson, reviewRevision)) { _log('crossday: newer activity review; leaving its refresh queued'); return false; @@ -6896,6 +6947,48 @@ class DerivationEngine { return out; } + /// The phone zone a `crossday_input` artifact was built in. Its records are + /// zone-RELATIVE (`clock_shift_sec` is "this night's clock minus the zone the + /// record was built in"), so an artifact built before a flight, served after + /// it on the same day, would re-read every past night on the new clock — the + /// "Target bedtime 4:00 PM" bug again, until the next rebuild. A mismatch + /// (or an artifact without the stamp) rebuilds. Name AND offset: a DST change + /// alone rebuilds too, which is one cheap rebuild twice a year. + /// + /// The abbreviation alone collides (Denver and Phoenix are both "MST" in + /// winter), so the stamp also carries the zone's offsets mid-January and + /// mid-July: two zones that agree on all three keep every night on the same + /// clock anyway. + static String crossDayZoneStamp() { + final now = DateTime.now(); + int offsetAt(int month) => DateTime.fromMillisecondsSinceEpoch( + DateTime.utc(now.year, month, 15, 12).millisecondsSinceEpoch) + .timeZoneOffset + .inMinutes; + return zoneStampOf(now.timeZoneName, now.timeZoneOffset.inMinutes, + offsetAt(1), offsetAt(7)); + } + + @visibleForTesting + static String zoneStampOf( + String name, int nowOffsetMin, int janOffsetMin, int julOffsetMin) => + '$name|$nowOffsetMin|$janOffsetMin|$julOffsetMin'; + + /// Whether the stored cross-day ROLLUP was built in another zone. The + /// rollup, not its inputs: [_refreshBaselines] re-stamps the inputs before + /// the rollup is written, so a rollup that then timed out would read as + /// current on the inputs' stamp and never be retried. + Future _crossDayZoneStale() async { + final raw = (await LocalDb.baseline('crossday'))?['payload_json']; + if (raw is! String || raw.isEmpty) return false; + try { + final decoded = jsonDecode(raw); + return decoded is Map && decoded['zone'] != crossDayZoneStamp(); + } catch (_) { + return false; + } + } + Future>> _crossDayInputDays() async { final reviewRevision = await LocalDb.activityReviewRevision(); final artifact = await LocalDb.baseline('crossday_input'); @@ -6908,7 +7001,8 @@ class DerivationEngine { // day makes `_todayNum` read yesterday's strain and nap minutes as // today's (§3.3). See [crossDayArtifactUsableToday]. if (crossDayArtifactUsableToday(decoded, LocalDb.localDayLabelNow()) && - (decoded as Map)['review_revision'] == reviewRevision) { + (decoded as Map)['review_revision'] == reviewRevision && + decoded['zone'] == crossDayZoneStamp()) { final rows = decoded['days'] as List; return [ for (final row in rows) @@ -6934,6 +7028,7 @@ class DerivationEngine { final rows = await LocalDb.recentDayResults(_crossDayWindow); final today = LocalDb.localDayLabelNow(); final imported = await LocalDb.importedDates(); + final zone = crossDayZoneStamp(); final (days, json) = await _runIsolateCancellable(() { final days = >[]; for (final row in rows.reversed) { @@ -6957,6 +7052,7 @@ class DerivationEngine { 'algo_version': kAlgoVersion, 'built_for_day': today, 'review_revision': reviewRevision, + 'zone': zone, 'days': days, }) ); @@ -7136,6 +7232,10 @@ class DerivationEngine { final tstSec = (acctVal?['tst_sec'] as num?)?.toDouble(); final inBedSec = (acctVal?['in_bed_sec'] as num?)?.toDouble(); final observedSec = (acctVal?['observed_in_bed_sec'] as num?)?.toDouble(); + final clockShift = recordedClockShiftSec( + onsetSec: onsetMs == null ? null : (onsetMs / 1000).round(), + onsetClockSec: sc('sleep_onset_sec'), + ); return { 'date': date, @@ -7164,6 +7264,17 @@ class DerivationEngine { 'efficiency': sc('efficiency'), 'onset_sec': onsetMs == null ? null : (onsetMs / 1000).round(), 'wake_sec': offsetMs == null ? null : (offsetMs / 1000).round(), + // Which clock the night was LIVED on, relative to the phone's zone now. + // `onset_sec`/`wake_sec` are absolute instants with no zone attached; + // SLP-09's `sleep_onset_sec` scalar froze the onset's LOCAL clock in the + // zone the phone was in when the day was derived, and the gap between + // the two readings is the zone change. Without it a flight slid every + // past wake by the shift and the Sleep Coach printed "Target bedtime + // 4:00 PM / Target wake 1:31 AM" after 7 h west. + // + // Only the shift is written: a night recorded in this zone (the shift + // is 0) carries no new key and reads exactly as before. + if (clockShift != 0) 'clock_shift_sec': clockShift, 'tst_min': tstSec == null ? null : (tstSec / 60).round(), // Fraction of the night we actually watched — wall-clock in-bed minus the // seconds nobody observed, over in-bed. TS-11 drops a morning whose night @@ -7184,7 +7295,10 @@ class DerivationEngine { // does not exist far enough back. `circadianNonparametric` documents HR // as an accepted input alongside activity. 24 numbers a day, so the // artifact stays small. - 'hourly_hr': hourlyHrProfile(series?['hr_curve']), + // Binned on the clock the day was lived on, like onset and wake: after a + // flight the phone's hours are not the hours that day's HR was lived in. + 'hourly_hr': + hourlyHrProfile(series?['hr_curve'], clockShiftSec: clockShift), }; } @@ -7195,7 +7309,11 @@ class DerivationEngine { /// it, so a bin is always a mean over genuine readings — never an average of /// one stray second, and never an imputed value. Gaps stay null and the /// consumer excludes the day; nothing is filled in. - static List hourlyHrProfile(Object? hrCurve, {int minMinutes = 5}) { + /// + /// [clockShiftSec] moves each sample onto the day's own clock (see + /// [recordedClockShiftSec]); 0 bins on the phone's current local hour. + static List hourlyHrProfile(Object? hrCurve, + {int minMinutes = 5, int clockShiftSec = 0}) { final sums = List.filled(24, 0); final counts = List.filled(24, 0); if (hrCurve is List) { @@ -7207,7 +7325,9 @@ class DerivationEngine { // LOCAL hour. The day model is local-midnight-to-midnight, so the UTC // hour would smear a user's evening into the next bin (and, for a // non-integral-offset zone, into the wrong one entirely). - final h = DateTime.fromMillisecondsSinceEpoch(t * 1000).hour; + final lt = DateTime.fromMillisecondsSinceEpoch(t * 1000); + final sod = lt.hour * 3600 + lt.minute * 60 + lt.second; + final h = ((sod + clockShiftSec) % 86400) ~/ 3600; if (h < 0 || h > 23) continue; sums[h] += v; counts[h] += 1; diff --git a/lib/compute/inputs/canonical.dart b/lib/compute/inputs/canonical.dart index e5c231d7d..ff4b2ca8b 100644 --- a/lib/compute/inputs/canonical.dart +++ b/lib/compute/inputs/canonical.dart @@ -18,7 +18,7 @@ import '../../ble/adapters/_registry.dart' import '../../data/day_label.dart'; import '../../data/db.dart'; import '../crossday_pipeline.dart' show buildCrossDayBundle; -import '../derivation_engine.dart' show kAlgoVersion; +import '../derivation_engine.dart' show DerivationEngine, kAlgoVersion; import '../substrate.dart'; import '../vendor_sleep.dart'; import 'colmi_inputs.dart'; @@ -1412,7 +1412,12 @@ Map _decode(Object? raw) => raw is String ? jsonDecode(raw) as Map : const {}; Future crossDayAsOf(String date) async { final stored = _decode((await LocalDb.baseline('crossday'))?['payload_json']); final recent = stored['recent']; + // Both artifacts read each night on its lived clock relative to the zone + // they were built in; read in another zone they move every night by the + // difference, so they are served only in the zone they were built in. + final zone = DerivationEngine.crossDayZoneStamp(); if ((stored['algo_version'] as num?)?.toInt() == kAlgoVersion && + stored['zone'] == zone && recent is List && recent.isNotEmpty && recent.last is Map && (recent.last as Map)['date'] == date) { return stored; @@ -1424,7 +1429,8 @@ Future crossDayAsOf(String date) async { final input = jsonDecode(raw); if (input is! Map || (input['algo_version'] as num?)?.toInt() != kAlgoVersion || - input['review_revision'] != reviewRevision) { + input['review_revision'] != reviewRevision || + input['zone'] != zone) { return const {}; } final days = >[ diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 9c83f05f4..9f188d117 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -1911,7 +1911,12 @@ Map deriveDayBundle(Map inputJson) { /// /// Any fixed hour would do; 04:00 is the one furthest from a normal bedtime /// AND from a normal wake time, so neither series sits near the wrap. -const int _sleepClockAnchorSec = 4 * 3600; +/// +/// Public because the cross-day rollup reads `sleep_onset_sec` back to learn +/// which clock a stored night was LIVED on (crossday_pipeline's +/// `recordedClockShiftSec`); decoding it with a second copy of this number +/// is the drift that would quietly put every night an anchor-change off. +const int kSleepClockAnchorSec = 4 * 3600; /// A sleep instant's LOCAL clock position, in seconds either side of 04:00 — /// negative before, positive after. Null for an absent window (never 0, which @@ -1932,7 +1937,7 @@ double? sleepClockOffsetSec(int epochSec) { if (epochSec <= 0) return null; final local = epochSec + tzOffsetSecondsAt(epochSec); final secOfDay = ((local % 86400) + 86400) % 86400; - var offset = secOfDay - _sleepClockAnchorSec; + var offset = secOfDay - kSleepClockAnchorSec; if (offset > 43200) offset -= 86400; if (offset <= -43200) offset += 86400; return offset.toDouble(); diff --git a/lib/data/db.dart b/lib/data/db.dart index 1dfb259b8..01639369d 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -9113,6 +9113,42 @@ class LocalDb { return rows.isEmpty ? null : _withDate(rows.first); } + /// The two numbers a re-derive needs to keep a stored night on the clock it + /// was lived on: the served row's sleep onset instant (ms) and the + /// `sleep_onset_sec` clock it froze. Read with json_extract, so the bundle is + /// never decoded on the calling isolate — a re-derive runs this for every + /// day. Null when the day has no served row, the row is a skip marker, or + /// the payload is malformed. + static Future<({num? onsetMs, num? onsetClockSec})?> storedNightClock( + String dayId) async { + final db = await instance; + // Nested CASE: json_extract raises on a malformed document and only CASE + // branches are evaluated lazily. + final rows = await db.rawQuery( + 'SELECT ' + "CASE WHEN json_valid(payload_json) THEN CASE WHEN " + "json_type(payload_json, '\$.skipped') = 'true' THEN 1 ELSE 0 END " + 'ELSE 1 END AS bad, ' + "CASE WHEN json_valid(payload_json) THEN " + "json_extract(payload_json, '\$.sleep.window.value.onset_ms') END " + 'AS onset_ms, ' + "CASE WHEN json_valid(payload_json) THEN " + "json_extract(payload_json, '\$.scalars.sleep_onset_sec') END " + 'AS onset_clock, skipped ' + 'FROM day_result WHERE day_id = ? AND algo_version <= ? ' + 'ORDER BY algo_version DESC LIMIT 1', + [dayId, _servedAlgoCeiling], + ); + if (rows.isEmpty) return null; + final r = rows.first; + if (r['bad'] == 1 || (r['skipped'] as num?)?.toInt() == 1) return null; + final onset = r['onset_ms'], clock = r['onset_clock']; + return ( + onsetMs: onset is num ? onset : null, + onsetClockSec: clock is num ? clock : null, + ); + } + /// The `readiness_absent_diag` diagnostic off [day]'s stored bundle — per /// input `{value, baseline_n}` plus the composite's own `note`. Produced on /// every day readiness comes back absent; null when [day] is null, has no diff --git a/test/crossday_as_of_zone_test.dart b/test/crossday_as_of_zone_test.dart new file mode 100644 index 000000000..bc8ae34fe --- /dev/null +++ b/test/crossday_as_of_zone_test.dart @@ -0,0 +1,149 @@ +// The cross-day artifacts hold each night on its lived clock relative to the +// zone they were built in. Read in another zone, every night moves by the +// difference, so a past day's rollup is served only in the zone it was built +// in; elsewhere it abstains until the next pass rebuilds it. + +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'; +import 'package:openstrap_edge/compute/profile.dart'; +import 'package:openstrap_edge/compute/inputs/canonical.dart' + show crossDayAsOf; +import 'package:openstrap_edge/data/db.dart'; + +Map _day(String date, int onset) => { + 'date': date, + 'rhr': 52, + 'rmssd': 60, + 'readiness': 70, + 'onset_sec': onset, + 'wake_sec': onset + 8 * 3600, + 'tst_min': 450, + 'efficiency': 95, + }; + +Future _putInput(String zone) async { + final base = DateTime.utc(2026, 9, 1, 22).millisecondsSinceEpoch ~/ 1000; + final days = [ + for (var i = 0; i < 5; i++) + _day('2026-09-0${i + 2}', base + i * 86400), + ]; + await LocalDb.putBaseline( + 'crossday_input', + jsonEncode({ + 'algo_version': kAlgoVersion, + 'built_for_day': '2026-09-06', + 'review_revision': await LocalDb.activityReviewRevision(), + 'zone': zone, + 'days': days, + }), + ); +} + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_crossday_as_of_zone_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('inputs built in this zone rebuild a past day', () async { + await _putInput(DerivationEngine.crossDayZoneStamp()); + expect(await crossDayAsOf('2026-09-05'), isNotEmpty); + }); + + test('inputs built in another zone abstain', () async { + await _putInput('Elsewhere|-420'); + expect(await crossDayAsOf('2026-09-05'), isEmpty); + }); + + test('zones sharing an abbreviation do not share a stamp', () { + // Denver and Phoenix both read "MST" in January; only Denver moves in July. + final denver = DerivationEngine.zoneStampOf('MST', -420, -420, -360); + final phoenix = DerivationEngine.zoneStampOf('MST', -420, -420, -420); + expect(denver, isNot(phoenix)); + expect(DerivationEngine.crossDayZoneStamp(), + DerivationEngine.crossDayZoneStamp(), + reason: 'stable within one zone'); + }); + + test('a restored backup with no band data rebuilds an old-zone rollup', + () async { + // Stored days, no decoded rows, and a rollup from before a flight. + for (var i = 0; i < 5; i++) { + final day = '2026-09-0${i + 2}'; + final onset = + DateTime.utc(2026, 9, i + 1, 22).millisecondsSinceEpoch ~/ 1000; + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + finalized: true, + rhr: 52, + readiness: 70, + windowJson: '{}', + payloadJson: jsonEncode({ + 'scalars': {'rhr': 52, 'readiness': 70, 'efficiency': 95}, + 'sleep': { + 'window': { + 'value': { + 'onset_ms': onset * 1000, + 'offset_ms': (onset + 8 * 3600) * 1000, + }, + }, + 'accounting': { + 'value': {'tst_sec': 27000}, + }, + }, + }), + ); + } + await LocalDb.putBaseline( + 'crossday', + jsonEncode({ + 'algo_version': kAlgoVersion, + 'zone': 'Elsewhere|-420|-420|-420', + 'recent': const [], + }), + ); + // The inputs were already re-read in this zone before the rollup write + // failed: the retry has to go by the rollup's own stamp. + await _putInput(DerivationEngine.crossDayZoneStamp()); + await DerivationEngine().run(const Profile( + ageYears: 35, + sex: 'm', + weightKg: 75, + heightCm: 178, + )); + final rollup = jsonDecode( + (await LocalDb.baseline('crossday'))!['payload_json'] as String) as Map; + expect(rollup['zone'], DerivationEngine.crossDayZoneStamp()); + }); + + test('a stored rollup from another zone is not served as current', + () async { + await _putInput('Elsewhere|-420'); + await LocalDb.putBaseline( + 'crossday', + jsonEncode({ + 'algo_version': kAlgoVersion, + 'zone': 'Elsewhere|-420', + 'recent': [ + {'date': '2026-09-06'}, + ], + }), + ); + expect(await crossDayAsOf('2026-09-06'), isEmpty); + }); +} diff --git a/test/fixtures/whoop_freeze/gen4_minus0400.json b/test/fixtures/whoop_freeze/gen4_minus0400.json index 2509c77ca..887783571 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": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "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 4f489c854..f56647ae3 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": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "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 080f8e0ad..685a4c57b 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": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 239, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "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 c1752aa1d..9c0ce70c2 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": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "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 05bb93f35..2efc7740d 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": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "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 f09f6882b..8415368ca 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": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 239, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/sleep_coach_timezone_test.dart b/test/sleep_coach_timezone_test.dart new file mode 100644 index 000000000..cae229371 --- /dev/null +++ b/test/sleep_coach_timezone_test.dart @@ -0,0 +1,294 @@ +// SLEEP COACH ACROSS A TIME-ZONE CHANGE. +// +// A user flew 7 h west and, at 08:33 local, the Sleep tab read "Target bedtime +// 4:00 PM / Target wake 1:31 AM". The cross-day rollup took the typical wake +// as the median of every past wake INSTANT converted with the phone's CURRENT +// zone, so 08:31 wakes recorded at UTC+2 re-read as 01:31 and the bedtime +// backed off them landed mid-afternoon. +// +// The fix keeps the targets a LOCAL CLOCK schedule: each night is read on the +// clock it was lived on (recovered from the per-day `sleep_onset_sec` scalar, +// which froze the onset's local clock at derive time) and that schedule is +// applied in whatever zone the phone is in now. +// +// These tests build the rows by hand with the history's zone written out +// explicitly (UTC+2, or Europe's CEST→CET), so they assert the same answer in +// ANY runner zone. Under the old code they fail in every zone except the one +// the history was recorded in. Worth running under a few zones, e.g. +// TZ=Etc/GMT+5 flutter test test/sleep_coach_timezone_test.dart (UTC−5) +// TZ=America/New_York flutter test test/sleep_coach_timezone_test.dart +// TZ=Europe/Amsterdam flutter test test/sleep_coach_timezone_test.dart + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/crossday_pipeline.dart'; +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart' + show kSleepClockAnchorSec, sleepClockOffsetSec; + +/// Epoch seconds for wall-clock [h]:[m] on [y]-[mo]-[d] in a zone at +/// [offsetMin] from UTC. +int _at(int y, int mo, int d, int h, int m, int offsetMin) => + DateTime.utc(y, mo, d, h, m).millisecondsSinceEpoch ~/ 1000 - + offsetMin * 60; + +/// What `sleepClockOffsetSec` would have written for [localH]:[localM] — +/// signed seconds either side of 04:00 — had the phone been in that zone. +double _clockAsRecorded(int localH, int localM) { + var off = localH * 3600 + localM * 60 - kSleepClockAnchorSec; + if (off > 43200) off -= 86400; + if (off <= -43200) off += 86400; + return off.toDouble(); +} + +String _label(DateTime d) => + '${d.year}-${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + +/// One stored day as `day_result` holds it: the sleep window as absolute +/// instants, plus the onset's frozen local clock in `scalars`. +({Map row, Map payload}) _stored({ + required String day, + required int onsetSec, + required int wakeSec, + required double? onsetClockSec, +}) => + ( + row: {'day_id': day, 'finalized': 1}, + payload: { + 'scalars': { + 'efficiency': 95.0, + 'sleep_onset_sec': ?onsetClockSec, + }, + 'sleep': { + 'window': { + 'value': {'onset_ms': onsetSec * 1000, 'offset_ms': wakeSec * 1000}, + }, + 'accounting': { + 'value': {'tst_sec': (wakeSec - onsetSec) * 0.95}, + }, + }, + }, + ); + +/// Three weeks of nights lived at UTC+2: in bed 23:30, up 07:45 (one night +/// falls asleep after midnight, 00:20, so the day boundary is crossed). +List<({Map row, Map payload})> _europe() { + const tz = 120; + final out = <({Map row, Map payload})>[]; + for (var i = 0; i < 21; i++) { + // The night of Sep i+1 → the morning of Sep i+2 (labelled by the morning). + final late = i == 10; + final onset = late + ? _at(2026, 9, i + 2, 0, 20, tz) + : _at(2026, 9, i + 1, 23, 30, tz); + final wake = _at(2026, 9, i + 2, 7, 45, tz); + out.add(_stored( + day: _label(DateTime(2026, 9, i + 2)), + onsetSec: onset, + wakeSec: wake, + onsetClockSec: late ? _clockAsRecorded(0, 20) : _clockAsRecorded(23, 30), + )); + } + return out; +} + +List> _records( + List<({Map row, Map payload})> stored, +) => [ + for (final s in stored) + DerivationEngine.crossDayInputRecord(s.row, s.payload, + today: '2026-12-31', imported: const {})!, + ]; + +Map _coach(List> days) => + (buildCrossDayBundle(days, const {})['sleep_coach'] as Map) + .cast(); + +double _min(Map coach, String key, String field) => + (((coach[key] as Map)['value'] as Map)[field] as num).toDouble(); + +/// Circular distance in minutes on a 24 h clock. +double _clockDist(double a, double b) { + final d = (a - b).abs() % 1440; + return d > 720 ? 1440 - d : d; +} + +void main() { + group('history recorded at UTC+2, phone now elsewhere', () { + test('targets read ~23:30 / 07:45 local, not 16:00 / 01:31', () { + final days = _records(_europe()); + final coach = _coach(days); + final wake = _min(coach, 'wake', 'wake_min_of_day'); + final bed = _min(coach, 'bedtime', 'bedtime_min_of_day'); + final needSec = _min(coach, 'need', 'need_sec'); + + // Target wake = the bedtime plus the in-bed time it was backed off, so it + // lands back on the typical wake: 07:45 on the clock the user lives by. + expect(_clockDist(wake, 7 * 60 + 45), lessThan(1.0), + reason: 'target wake $wake min'); + // Bedtime is that wake minus need ÷ efficiency, on the same clock. + final expectedBed = (7 * 60 + 45 - needSec / 0.95 / 60) % 1440; + expect(_clockDist(bed, expectedBed), lessThan(1.0)); + // …which for this history is the late-evening bedtime it kept, not a + // mid-afternoon one. + expect(_clockDist(bed, 23 * 60 + 30), lessThan(45.0), + reason: 'target bedtime $bed min'); + expect(_clockDist(bed, 16 * 60), greaterThan(300.0)); + expect(_clockDist(wake, 60 + 31), greaterThan(300.0)); + }); + + test('the record carries the shift only when the zone actually differs', + () { + final days = _records(_europe()); + final onset = days.first['onset_sec'] as int; + final hereOffsetMin = + DateTime.fromMillisecondsSinceEpoch(onset * 1000).timeZoneOffset + .inMinutes; + if (hereOffsetMin == 120) { + // Runner is in the history's own zone: nothing written, old path. + expect(days.first.containsKey('clock_shift_sec'), isFalse); + } else { + expect(days.first['clock_shift_sec'], + ((120 - hereOffsetMin) * 60) % 86400); + } + }); + + test('a few nights already lived in the new zone do not move the target', + () { + // The first nights after landing are derived in the phone's new zone and + // carry no shift; read on their own clock they are the same schedule. + final stored = _europe(); + for (var i = 0; i < 3; i++) { + final dLocal = DateTime(2026, 9, 23 + i, 23, 30); + final onset = dLocal.millisecondsSinceEpoch ~/ 1000; + final wake = + DateTime(2026, 9, 24 + i, 7, 45).millisecondsSinceEpoch ~/ 1000; + stored.add(_stored( + day: _label(DateTime(2026, 9, 24 + i)), + onsetSec: onset, + wakeSec: wake, + onsetClockSec: sleepClockOffsetSec(onset), + )); + } + final coach = _coach(_records(stored)); + expect(_clockDist(_min(coach, 'wake', 'wake_min_of_day'), 7 * 60 + 45), + lessThan(1.0)); + }); + }); + + test('history straddling a DST change keeps its local schedule', () { + // Europe leaves CEST (UTC+2) for CET (UTC+1) on 25 Oct 2026. The user went + // to bed at 23:30 and woke at 07:45 on BOTH sides; the instants moved by an + // hour, the clock did not, and neither should the target. + final stored = <({Map row, Map payload})>[]; + for (var i = 0; i < 20; i++) { + final morning = DateTime(2026, 10, 15 + i); + final tz = morning.isBefore(DateTime(2026, 10, 25, 12)) ? 120 : 60; + final night = DateTime(2026, 10, 14 + i); + stored.add(_stored( + day: _label(morning), + onsetSec: _at(night.year, night.month, night.day, 23, 30, tz), + wakeSec: _at(morning.year, morning.month, morning.day, 7, 45, tz), + onsetClockSec: _clockAsRecorded(23, 30), + )); + } + final days = _records(stored); + final coach = _coach(days); + expect(_clockDist(_min(coach, 'wake', 'wake_min_of_day'), 7 * 60 + 45), + lessThan(1.0)); + }); + + test('a night recorded in the current zone reads exactly as before', () { + // At home the frozen clock and today's reading agree, so no key is written + // and the bundle is identical to one built without the scalar at all. + final withClock = + <({Map row, Map payload})>[]; + final without = + <({Map row, Map payload})>[]; + for (var i = 0; i < 21; i++) { + final onset = + DateTime(2026, 9, i + 1, 23, 10).millisecondsSinceEpoch ~/ 1000; + final wake = + DateTime(2026, 9, i + 2, 7, 5).millisecondsSinceEpoch ~/ 1000; + final day = _label(DateTime(2026, 9, i + 2)); + withClock.add(_stored( + day: day, + onsetSec: onset, + wakeSec: wake, + onsetClockSec: sleepClockOffsetSec(onset))); + without.add( + _stored(day: day, onsetSec: onset, wakeSec: wake, onsetClockSec: null)); + } + final a = _records(withClock); + expect(a.any((d) => d.containsKey('clock_shift_sec')), isFalse); + expect(_coach(a).toString(), _coach(_records(without)).toString()); + expect(_clockDist(_min(_coach(a), 'wake', 'wake_min_of_day'), 7 * 60 + 5), + lessThan(1.0)); + }); + + test('re-deriving a pre-trip night keeps the clock it was lived on', () { + // A re-derive freezes `sleep_onset_sec` in the phone's zone NOW; carrying + // the stored row's shift puts it back on the UTC+2 clock, so the cross-day + // record is the same as before the re-derive, in any runner zone. + for (final prev in _europe()) { + final onsetMs = (((prev.payload['sleep'] as Map)['window'] + as Map)['value'] as Map)['onset_ms'] as int; + final onset = onsetMs ~/ 1000; + final original = + (prev.payload['scalars'] as Map)['sleep_onset_sec'] as double; + final rederived = sleepClockOffsetSec(onset); + final carried = shiftRecordedClockSec( + rederived, storedNightClockShiftSec(prev.payload)); + expect(carried, original); + + final again = _stored( + day: prev.row['day_id'] as String, + onsetSec: onset, + wakeSec: onset + 8 * 3600, + onsetClockSec: carried, + ); + expect(_records([again]).single['clock_shift_sec'], + _records([prev]).single['clock_shift_sec']); + } + }); + + test('a day lived at UTC+2 keeps its heart rate in its own hours', () { + // One night at UTC+2 plus six minutes of HR at 14:00-14:05 its time. In + // any runner zone the record bins that HR at 14, not at the phone's hour. + final night = _europe().first; + final onsetMs = (((night.payload['sleep'] as Map)['window'] as Map)['value'] + as Map)['onset_ms'] as int; + final afternoon = onsetMs ~/ 1000 + 14 * 3600 + 30 * 60; // 23:30 + 14:30 + final payload = { + ...night.payload, + 'series': { + 'hr_curve': [ + for (var i = 0; i < 6; i++) {'t': afternoon + i * 60, 'v': 70 + i}, + ], + }, + }; + final hourly = _records([(row: night.row, payload: payload)]) + .single['hourly_hr'] as List; + expect(hourly[14], closeTo(72.5, 1e-9)); + expect([for (var h = 0; h < 24; h++) if (h != 14) hourly[h]], + everyElement(isNull)); + }); + + test('a shifted clock wraps back into ±12 h and null stays null', () { + expect(shiftRecordedClockSec(43000, 1000), -42400); + expect(shiftRecordedClockSec(-43000, 86400 - 1000), 42400); + expect(shiftRecordedClockSec(-16200, 0), -16200); + expect(shiftRecordedClockSec(null, 7200), isNull); + expect(storedNightClockShiftSec(null), 0); + expect(storedNightClockShiftSec(const {'scalars': {}}), 0); + }); + + test('no frozen clock and no onset → no shift, never a guessed one', () { + expect(recordedClockShiftSec(onsetSec: null, onsetClockSec: -16200), 0); + expect(recordedClockShiftSec(onsetSec: 1789000000, onsetClockSec: null), 0); + expect( + recordedClockShiftSec( + onsetSec: 1789000000, onsetClockSec: double.nan), + 0); + }); +} diff --git a/test/stored_night_clock_test.dart b/test/stored_night_clock_test.dart new file mode 100644 index 000000000..3a752f5a9 --- /dev/null +++ b/test/stored_night_clock_test.dart @@ -0,0 +1,108 @@ +// A re-derive carries the stored night's clock forward. It reads two numbers +// off the stored row with json_extract instead of decoding the bundle; these +// tests hold that read to what decoding the bundle answered. + +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/crossday_pipeline.dart' + show recordedClockShiftSec, storedNightClockShiftSec; +import 'package:openstrap_edge/compute/derivation_engine.dart' show kAlgoVersion; +import 'package:openstrap_edge/data/db.dart'; + +Map _bundle({int? onsetMs, double? clock}) => { + 'sleep': { + 'window': { + 'value': {'onset_ms': ?onsetMs, 'offset_ms': ?onsetMs}, + }, + }, + 'scalars': {'rhr': 52, 'sleep_onset_sec': ?clock}, + 'series': { + 'hr_curve': [ + for (var i = 0; i < 50; i++) {'t': 1767225600 + i * 60, 'v': 60}, + ], + }, + }; + +Future _put(String day, Object payload, + {int? version, bool skipped = false}) => + LocalDb.putDayResult( + dayId: day, + algoVersion: version ?? kAlgoVersion, + payloadJson: payload is String ? payload : jsonEncode(payload), + windowJson: '{}', + skipped: skipped, + ); + +int _shift(({num? onsetMs, num? onsetClockSec})? c) => c == null + ? 0 + : recordedClockShiftSec( + onsetSec: c.onsetMs == null ? null : (c.onsetMs! / 1000).round(), + onsetClockSec: c.onsetClockSec, + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const name = 'stored_night_clock_test.db'; + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + await LocalDb.close(); + LocalDb.dbName = name; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), name), + ); + }); + + tearDownAll(LocalDb.close); + + test('matches the decoded bundle for a night lived in another zone', () async { + // 23:10 local onset, frozen two hours east of wherever the runner is. + final onset = DateTime(2026, 9, 1, 23, 10); + final onsetMs = onset.millisecondsSinceEpoch; + final local = onset.hour * 3600 + onset.minute * 60; + var clock = (local + 7200 - 4 * 3600) % 86400; + if (clock > 43200) clock -= 86400; + final b = _bundle(onsetMs: onsetMs, clock: clock.toDouble()); + await _put('2026-09-02', b); + + final got = await LocalDb.storedNightClock('2026-09-02'); + expect(got, isNotNull); + expect(_shift(got), storedNightClockShiftSec(b)); + expect(_shift(got), isNot(0)); + }); + + test('null for no row, a skip marker, or a malformed payload', () async { + expect(await LocalDb.storedNightClock('2026-01-01'), isNull); + + await _put('2026-09-03', _bundle(onsetMs: 1, clock: 1), skipped: true); + expect(await LocalDb.storedNightClock('2026-09-03'), isNull); + + await _put('2026-09-04', {'skipped': true}); + expect(await LocalDb.storedNightClock('2026-09-04'), isNull); + + await _put('2026-09-05', '{not json'); + expect(await LocalDb.storedNightClock('2026-09-05'), isNull); + }); + + test('reads the served version, not an older one under it', () async { + await _put('2026-09-06', _bundle(onsetMs: 1000, clock: 60), + version: kAlgoVersion - 1); + await _put('2026-09-06', _bundle(), version: kAlgoVersion); + final got = await LocalDb.storedNightClock('2026-09-06'); + expect(got, isNotNull); + expect(got!.onsetMs, isNull); + expect(got.onsetClockSec, isNull); + + // A newer skip marker hides the older real row, as dayResult does. + await _put('2026-09-07', _bundle(onsetMs: 1000, clock: 60), + version: kAlgoVersion - 1); + await _put('2026-09-07', {'skipped': true}, + version: kAlgoVersion, skipped: true); + expect(await LocalDb.storedNightClock('2026-09-07'), isNull); + }); +}