diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index f606ad4..d3c5004 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -558,7 +558,29 @@ class LocalRepositoryImpl extends LocalRepository { // confirmed / none — drives the Sleep screen's confirm prompt + edit affordance. final sleepSource = (b['sleep_source'] as String?) ?? 'auto'; if (tst == null) { - return {'has_sleep': false, 'sleep_source': sleepSource}; + // NO NIGHT SLEEP — but that is not the same as no sleep at all. + // + // A nap-only or night-shift day still has detected daytime periods, and + // this early return used to drop them on the floor: the screen showed + // "No sleep recorded for this night" while the very same nap was stored, + // credited against sleep need, and drawn as a band on the Timeline. + // Three surfaces, two answers. + // + // `has_sleep` stays FALSE — it means what it says, that there is no + // NIGHT to render a hypnogram, stages or efficiency for, and inventing + // one from a nap would be exactly the conflation this file avoids + // elsewhere. The periods ride along so the screen can show what it does + // know instead of claiming nothing happened. + final napPeriods = _periodsWithMainStages(b, const {}); + if (napPeriods.isEmpty) { + return {'has_sleep': false, 'sleep_source': sleepSource}; + } + return { + 'has_sleep': false, + 'sleep_source': sleepSource, + 'periods': napPeriods, + 'total_asleep_min': _totalAsleepMin(b, napPeriods), + }; } final spt = (win?['spt_sec'] as num?); final waso = (acct?['waso_sec'] as num?); @@ -573,7 +595,28 @@ class LocalRepositoryImpl extends LocalRepository { } final sleepConf = _sub(b, 'sleep.accounting')?['confidence'] as num?; - return { + // Sleep periods (main + naps) for the periods screen. The main period is + // enriched HERE with the hypnogram + stage minutes: derivation builds the + // periods in a second isolate that never receives `series.hypnogram`, so + // this is the first point where the whole bundle is in hand. Naps carry + // neither by design — no stage claim is made for them. + // + // Naps carry their own confidence and the screen draws a ConfDot for any + // period that has one, so omitting the main period's left the main card as + // the ONLY one with no dot — reading as "unknown" for the best-evidenced + // period on the screen. Stays null when accounting had no confidence, + // which correctly draws nothing. + final periods = _periodsWithMainStages( + b, + { + 'light_min': min('light_sec'), + 'deep_min': min('deep_sec'), + 'rem_min': min('rem_sec'), + 'nrem_min': min('nrem_sec'), + }, + mainConfidence: sleepConf, + ); + final night = { // Shape matches sleep_detail_screen's contract exactly. 'has_sleep': true, 'sleep_source': sleepSource, @@ -612,22 +655,14 @@ class LocalRepositoryImpl extends LocalRepository { // periods in a second isolate that never receives `series.hypnogram`, so // this is the first point where the whole bundle is in hand. Naps carry // neither by design — no stage claim is made for them. - 'periods': _periodsWithMainStages( - b, - { - 'light_min': min('light_sec'), - 'deep_min': min('deep_sec'), - 'rem_min': min('rem_sec'), - 'nrem_min': min('nrem_sec'), - }, - // Naps carry their own confidence and the screen draws a ConfDot for - // any period that has one, so omitting the main period's left the main - // card as the ONLY one with no dot — reading as "unknown" for the - // best-evidenced period on the screen. Stays null when accounting had - // no confidence, which correctly draws nothing. - mainConfidence: sleepConf, - ), - 'total_asleep_min': (b['sleep_periods'] as Map?)?['total_asleep_min'], + 'periods': periods, + // The hero total must equal the sum of the cards under it — a user can + // add them up. `_boundedPeriod` can CORRECT a period on read (clamping a + // duration to its own window, dropping a degenerate one), which makes the + // stored total stale, so it is recomputed from what is actually + // rendered. See [_totalAsleepMin] for why an absent stored total stays + // absent rather than being recomputed into a confident number. + 'total_asleep_min': _totalAsleepMin(b, periods), // Sleep cycles — Rosenblum 2024 "fractal cycles" (HRV-adapted): peak-to- // peak of the smoothed per-minute RMSSD series (REM peaks / NREM troughs). 'cycles': _sub(b, 'sleep')?['cycles'] ?? const [], @@ -643,6 +678,19 @@ class LocalRepositoryImpl extends LocalRepository { // position PROXY, NOT supine/side/prone body position. 'wrist_orientation': b['wrist_orientation'], }; + // NOTE: this used to re-map the periods here through + // `sleepPeriodsForScreen` and overwrite `night['periods']`. That translator + // read `start`/`end`/`asleep_min` -- the vocabulary the producer emitted + // when this branch was written. Since #204 the producer emits + // `onset_ts`/`wake_ts`/`duration_min` directly, and `_periodsWithMainStages` + // (above) already attaches the hypnogram, stage minutes and the main + // period's confidence, translating any legacy payload on read. + // + // Keeping the overwrite after that rebase would have read every period + // under keys that no longer exist, produced an EMPTY list, and blanked the + // whole screen -- main sleep included -- while the hero still showed a + // total. One source per concern: the writer-side seam owns this now. + return night; } /// The persisted sleep periods with the MAIN period's hypnogram and stage @@ -667,15 +715,16 @@ class LocalRepositoryImpl extends LocalRepository { }; return [ for (final p in raw.whereType()) - if (p['is_main'] != true) - _canonicalPeriod(p) - else - { - ..._canonicalPeriod(p), - if (hypno.isNotEmpty) 'hypnogram': hypno, - if (stages.isNotEmpty) 'stages': stages, - 'confidence': ?mainConfidence, - }, + if (_boundedPeriod(_canonicalPeriod(p)) case final bp?) + if (bp['is_main'] != true) + bp + else + { + ...bp, + if (hypno.isNotEmpty) 'hypnogram': hypno, + if (stages.isNotEmpty) 'stages': stages, + 'confidence': ?mainConfidence, + }, ]; } @@ -713,6 +762,68 @@ class LocalRepositoryImpl extends LocalRepository { }; } + /// A period's reported asleep minutes can never exceed its own window. + /// + /// Ported from #205, which added it at the (now-removed) screen-side + /// translator. It is a real invariant and belongs here, at the one seam the + /// screen reads: `duration_min` and the window come from different producers + /// (staging TST vs the detected bounds), so nothing else stops a card + /// claiming more sleep than the period it sits in. Clamped, not dropped — + /// the window is the trustworthy half. + /// + /// Returns null for a period with no usable window at all, so the caller can + /// drop it rather than render a zero-length card. + Map? _boundedPeriod(Map m) { + final onset = (m['onset_ts'] as num?)?.toInt(); + final wake = (m['wake_ts'] as num?)?.toInt(); + if (onset == null || wake == null || wake <= onset) { + // Keep it only if it carries no window claim at all; a period whose + // window is present but degenerate is junk. + return (onset == null && wake == null) ? m : null; + } + final windowMin = ((wake - onset) / 60).round(); + final dur = (m['duration_min'] as num?)?.toInt(); + if (dur == null || (dur >= 0 && dur <= windowMin)) return m; + // NEGATIVE is not "too small", it is CORRUPT — there is no such thing as + // minus fifty minutes of sleep. It is dropped to unknown rather than + // clamped to either end: clamping UP to the window would invent a full + // night out of garbage, and clamping DOWN to 0 would state "you did not + // sleep", which is a measurement we do not have. Unknown then propagates + // through `_totalAsleepMin`, so the hero reads "—" instead of a total + // built on a value we know is nonsense. + if (dur < 0) return {...m, 'duration_min': null}; + return {...m, 'duration_min': windowMin}; + } + + /// The day's total asleep minutes, consistent with the cards on screen. + /// + /// ABSENT STAYS ABSENT. A null stored total means the producer could not + /// state one — most often because nap detection abstained, so the day holds + /// an unknown NUMBER of unmeasured naps (see `_sleepPeriods`). Recomputing a + /// sum from the periods we happen to have would turn that honest "—" into a + /// confident figure that silently omits them, which is the exact claim the + /// producer refused to make. + /// + /// Otherwise the total is recomputed from the RENDERED periods rather than + /// trusted verbatim, because `_boundedPeriod` may have corrected one on read + /// and the stored sum would then be stale — leaving the hero disagreeing with + /// the cards a user can add up. A period whose own duration is unknown makes + /// the sum unknown again, for the same reason it does at the writer. + num? _totalAsleepMin( + Map b, + List> periods, + ) { + final stored = (b['sleep_periods'] as Map?)?['total_asleep_min']; + if (stored == null) return null; + var sum = 0; + for (final p in periods) { + final d = (p['duration_min'] as num?)?.toInt(); + if (d == null) return null; + sum += d; + } + return sum; + } + /// Mean completed-cycle length (min), or null when no cycles. num? _cyclesMeanMin(Map b) { final cyc = _sub(b, 'sleep')?['cycles']; diff --git a/lib/ui/sleep/sleep_detail_screen.dart b/lib/ui/sleep/sleep_detail_screen.dart index c7e8250..fd81972 100644 --- a/lib/ui/sleep/sleep_detail_screen.dart +++ b/lib/ui/sleep/sleep_detail_screen.dart @@ -201,6 +201,54 @@ class _SleepDetailScreenState extends State { await _runOverride(() => app.clearSleepOverride(widget.date)); } + /// Every sleep of this day, naps included. The Sleep tab renders this screen + /// embedded, so the AppScaffold action below never builds there — without a + /// second entry point the periods screen was unreachable in the shipped app. + + /// Daytime periods on a day with no detected night, for the empty state. + /// + /// Read straight from the repository payload rather than through + /// [SleepNightContent], which is not built in this phase. + List> _emptyStateNaps() { + final raw = _data['periods']; + if (raw is! List) return const []; + return [ + for (final e in raw) + if (e is Map && e['is_main'] != true) e.cast(), + ]; + } + + Widget _emptyStateNapsCard(List> naps) { + // ONE authoritative total, not a second one computed here. The repository + // already decides this (`_totalAsleepMin`) and deliberately returns null + // when the producer could not state a complete figure — most often because + // nap detection abstained, so the day holds an unknown NUMBER of naps. + // Re-summing the periods that happen to be present would present a partial + // figure as the day's total, which is exactly the claim the layer below + // refused to make. + final total = (_data['total_asleep_min'] as num?)?.toInt(); + final value = total == null + ? '—' + : (total >= 60 ? '${total ~/ 60}h ${total % 60}m' : '${total}m'); + return SurfaceCard( + padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), + child: ListRow( + icon: OsIcon.bedtime, + title: naps.length == 1 ? 'Daytime nap' : '${naps.length} daytime naps', + subtitle: 'Tap for the full breakdown', + value: value, + onTap: _openPeriods, + ), + ); + } + + void _openPeriods() { + Navigator.of(context).push( + themedRoute((_) => SleepPeriodsScreen(date: widget.date), + name: 'SleepPeriodsScreen'), + ); + } + /// Run a sleep-override change with a busy state, then reload this night. Future _runOverride(Future Function() action) async { setState(() => _phase = _Phase.loading); @@ -218,15 +266,30 @@ class _SleepDetailScreenState extends State { List _sections() { if (_phase == _Phase.loading) return [_loading()]; if (_phase == _Phase.empty) { + // "No NIGHT" is not "no sleep". A nap-only or night-shift day has + // detected daytime periods, and showing the bare empty card while the + // same nap is credited against sleep need and drawn on the Timeline is + // the screen contradicting the rest of the app. The night breakdown is + // still genuinely absent — there is no hypnogram, no stages, no + // efficiency — so the card stays; the naps are shown alongside it rather + // than dressed up as a night. + final naps = _emptyStateNaps(); return [ StateCard( icon: OsIcon.sleep, title: 'No sleep recorded for this night', - message: 'Wear your strap overnight and sync — your breakdown ' - 'appears once a night has been recorded.', + message: naps.isEmpty + ? 'Wear your strap overnight and sync — your breakdown ' + 'appears once a night has been recorded.' + : 'No overnight sleep was detected, so there is no stage ' + 'breakdown for this day. Daytime sleep is listed below.', actionLabel: 'Add sleep times', onAction: _editSleepTimes, ), + if (naps.isNotEmpty) ...[ + const SizedBox(height: Sp.x3), + _emptyStateNapsCard(naps), + ], ]; } if (_phase == _Phase.error) { @@ -247,6 +310,7 @@ class _SleepDetailScreenState extends State { onEditTimes: _editSleepTimes, onConfirmFallback: _confirmFallback, onClearOverride: _clearOverride, + onOpenPeriods: _openPeriods, showSleepCoach: widget.showSleepCoach, ), ]; @@ -266,13 +330,7 @@ class _SleepDetailScreenState extends State { subtitle: _prettyDate(), actions: [ // All sleeps of the day (naps included) — the multi-period view. - RoundIconButton( - OsIcon.bedtime, - onTap: () => Navigator.of(context).push( - themedRoute((_) => SleepPeriodsScreen(date: widget.date), - name: 'SleepPeriodsScreen'), - ), - ), + RoundIconButton(OsIcon.bedtime, onTap: _openPeriods), ], body: RefreshIndicator( onRefresh: _load, @@ -310,6 +368,10 @@ class SleepNightContent extends StatelessWidget { final VoidCallback onConfirmFallback; final VoidCallback onClearOverride; + /// Open the per-period breakdown (main sleep + naps). Null in tests and + /// anywhere navigation isn't available; the naps row then stays hidden. + final VoidCallback? onOpenPeriods; + /// Render the Sleep Coach card (tonight's need/bedtime/wake/alarm) inline, /// between the Cycles and Nocturnal-heart sections — it only makes sense /// for TODAY's night, so only the Today segment's caller sets this true @@ -325,6 +387,7 @@ class SleepNightContent extends StatelessWidget { required this.onEditTimes, required this.onConfirmFallback, required this.onClearOverride, + this.onOpenPeriods, this.showSleepCoach = false, }); @@ -367,6 +430,33 @@ class SleepNightContent extends StatelessWidget { num? get _cyclesMean => _num(data['cycles_mean_min']); + /// Naps this day: the non-main sleep periods. This screen shows the night + /// only, so without a row for them a daytime sleep is invisible here. + List> get _naps { + final raw = data['periods']; + if (raw is! List) return const []; + return raw + .map((e) => _map(e)) + .where((m) => m.isNotEmpty && m['is_main'] != true) + .toList(); + } + + /// Minutes asleep across this day's naps, or NULL if any nap's duration is + /// unknown. + /// + /// `?? 0` here would silently under-report by exactly the part we could not + /// measure, and present the remainder as the full nap total — the same + /// absent-is-not-zero rule the producer and the periods screen follow. + num? get _napMin { + num sum = 0; + for (final p in _naps) { + final d = _num(p['duration_min']); + if (d == null) return null; + sum += d; + } + return sum; + } + List> get _cycleSeries { final raw = data['cycle_series']; if (raw is! List) return const []; @@ -450,6 +540,10 @@ class SleepNightContent extends StatelessWidget { _hero(context), const SizedBox(height: Sp.x4), _summaryBento(context), + if (onOpenPeriods != null && _naps.isNotEmpty) ...[ + const SizedBox(height: Sp.x3), + _napsRow(), + ], // ── ESTIMATED STAGE BLOCK (below the trustworthy numbers) ── const SizedBox(height: Sp.x6), _stagesHeader(), @@ -556,6 +650,24 @@ class SleepNightContent extends StatelessWidget { ); } + /// Daytime sleep, on the way to the per-period breakdown. The numbers above + /// are the night only, so the row says so rather than implying the nap is in + /// them: it isn't in TST, and it doesn't move readiness. It does count toward + /// tonight's sleep need (nap credit, Sleep Coach). + Widget _napsRow() { + final n = _naps.length; + return SurfaceCard( + padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), + child: ListRow( + icon: OsIcon.bedtime, + title: n == 1 ? 'Daytime nap' : '$n daytime naps', + subtitle: 'Not included in the night above', + value: _hm(_napMin), + onTap: onOpenPeriods, + ), + ); + } + /// Subtle "fix it" affordance shown under an auto-detected night. Widget _editTimesFooter() { if (_sleepSource != 'auto') return const SizedBox.shrink(); diff --git a/test/sleep_naps_visible_test.dart b/test/sleep_naps_visible_test.dart new file mode 100644 index 0000000..28ac47e --- /dev/null +++ b/test/sleep_naps_visible_test.dart @@ -0,0 +1,163 @@ +// A two-hour afternoon nap was detected, stored, and drawn as a band on the +// day timeline — and was still nowhere to be found on the Sleep screen. Two +// separate reasons, both pinned here: +// +// 1. The engine writes each period as `is_main` / `start` / `end` / +// `asleep_min`; the periods screen reads `onset_ts` / `wake_ts` / +// `duration_min`. Every card rendered as "0m" with no time range. +// 2. The only route into that screen was an AppScaffold action, and the Sleep +// tab embeds SleepNightContent (embedded: true), so the scaffold — and the +// action with it — never builds in the shipped app. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/theme/theme.dart'; +import 'package:openstrap_edge/theme/tokens.dart'; +import 'package:openstrap_edge/ui/sleep/sleep_detail_screen.dart' + show SleepNightContent; + + +/// The night payload the mapping enriches the main period from. +const _night = { + 'duration_min': 400, // TST — less than the 410-minute window + 'efficiency': 0.93, + 'stages_confidence': 0.62, + 'light_min': 220, + 'deep_min': 60, + 'rem_min': 120, + 'hypnogram': [ + {'t': 1000000, 'stage': 'light'}, + ], +}; + +Widget _host(Widget child) { + AppColors.active = kDarkPalette; + return MaterialApp( + theme: buildOpenStrapTheme(kDarkPalette), + home: Scaffold(body: SingleChildScrollView(child: child)), + ); +} + +String _today() { + final d = DateTime.now(); + return '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; +} + +Map _nightWithNap() => { + ..._night, + 'has_sleep': true, + 'sleep_source': 'auto', + 'need_min': 480, + 'onset_ts': 1000000, + 'wake_ts': 1024600, + // The vocabulary the producer emits since #204. This used to be built by + // `sleepPeriodsForScreen`, which translated the old + // start/end/asleep_min shape; that translator is gone because the writer + // now emits these keys directly and `_periodsWithMainStages` enriches + // them on read. + 'periods': const [ + { + 'is_main': true, + 'onset_ts': 1000000, + 'wake_ts': 1024600, + 'duration_min': 400, + 'efficiency': 0.93, + 'confidence': 0.62, + 'stages': {'light_min': 220, 'deep_min': 60, 'rem_min': 120}, + }, + { + 'is_main': false, + 'onset_ts': 1060000, + 'wake_ts': 1066060, + 'duration_min': 101, + }, + ], + }; + +void main() { + group('SleepNightContent naps row', () { + testWidgets('a nap is visible on the night screen and opens the breakdown', + (t) async { + t.view.physicalSize = const Size(390, 3600); + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + var opened = 0; + await t.pumpWidget(_host(SleepNightContent( + data: _nightWithNap(), + date: _today(), + onEditTimes: () {}, + onConfirmFallback: () {}, + onClearOverride: () {}, + onOpenPeriods: () => opened++, + ))); + await t.pump(const Duration(milliseconds: 1200)); + + expect(find.text('Daytime nap'), findsOneWidget); + expect(find.text('1h 41m'), findsOneWidget); + await t.tap(find.text('Daytime nap')); + await t.pump(const Duration(milliseconds: 400)); + expect(opened, 1); + expect(t.takeException(), isNull); + }); + + testWidgets('no naps, no row', (t) async { + t.view.physicalSize = const Size(390, 3600); + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + final data = _nightWithNap(); + // Main sleep only — the nap row must not appear. + data['periods'] = + [(data['periods'] as List).first as Map]; + await t.pumpWidget(_host(SleepNightContent( + data: data, + date: _today(), + onEditTimes: () {}, + onConfirmFallback: () {}, + onClearOverride: () {}, + onOpenPeriods: () {}, + ))); + await t.pump(const Duration(milliseconds: 1200)); + + expect(find.text('Daytime nap'), findsNothing); + }); + }); + + group('an unknown nap duration is never rendered as zero', () { + testWidgets('the naps row shows "—" when a nap duration is unknown', + (t) async { + t.view.physicalSize = const Size(390, 3600); + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + final data = _nightWithNap(); + final periods = (data['periods'] as List).cast>(); + data['periods'] = [ + periods.first, + {...periods[1], 'duration_min': null}, + ]; + + await t.pumpWidget(_host(SleepNightContent( + data: data, + date: _today(), + onEditTimes: () {}, + onConfirmFallback: () {}, + onClearOverride: () {}, + onOpenPeriods: () {}, + ))); + await t.pumpAndSettle(); + + expect(find.text('Daytime nap'), findsOneWidget); + expect( + find.text('—'), + findsWidgets, + reason: 'summing an unknown as 0 would under-report the nap total', + ); + expect(find.text('0m'), findsNothing); + }); + }); +} diff --git a/test/sleep_periods_legacy_keys_test.dart b/test/sleep_periods_legacy_keys_test.dart index aa8b5da..1643d69 100644 --- a/test/sleep_periods_legacy_keys_test.dart +++ b/test/sleep_periods_legacy_keys_test.dart @@ -54,25 +54,27 @@ void main() { const napOnset = onset + 14 * 3600; const napWake = napOnset + 40 * 60; - Future seed(Map sleepPeriods) async { + Future seed(Map sleepPeriods, {bool night = true}) async { await LocalDb.putDayResult( dayId: '2026-06-15', algoVersion: 1, // a pre-rename generation payloadJson: jsonEncode({ 'scalars': {'tst_min': 420.0}, - 'sleep': { - 'accounting': { - 'confidence': 0.7, - 'value': {'tst_sec': 420 * 60, 'efficiency_pct': 92.0}, - }, - 'window': { - 'value': { - 'onset_ms': onset * 1000, - 'offset_ms': wake * 1000, - 'spt_sec': 7 * 3600, - }, - }, - }, + 'sleep': night + ? { + 'accounting': { + 'confidence': 0.7, + 'value': {'tst_sec': 420 * 60, 'efficiency_pct': 92.0}, + }, + 'window': { + 'value': { + 'onset_ms': onset * 1000, + 'offset_ms': wake * 1000, + 'spt_sec': 7 * 3600, + }, + }, + } + : const {}, 'sleep_periods': sleepPeriods, }), windowJson: '{}', @@ -235,4 +237,184 @@ void main() { expect(periods.first['wake_ts'], isNull); }, ); + + // Ported from #205, which added these at its (now-removed) screen-side + // translator. They are real invariants and belong at the surviving seam. + test( + 'a period cannot report more asleep minutes than its own window', + () async { + await seed({ + 'periods': [ + { + 'is_main': false, + 'onset_ts': napOnset, + 'wake_ts': napOnset + 30 * 60, // a 30-minute window + 'duration_min': 101, // ...claiming 101 minutes of sleep + }, + ], + 'total_asleep_min': 101, + }); + + final sleep = await repo.getDaySleep('2026-06-15'); + final periods = (sleep['periods'] as List).cast>(); + expect( + periods.single['duration_min'], + 30, + reason: 'clamped to the window, which is the trustworthy half', + ); + }, + ); + + test('a degenerate window is dropped, not rendered as a zero-length card', + () async { + await seed({ + 'periods': [ + {'is_main': true, 'onset_ts': onset, 'wake_ts': wake, 'duration_min': 420}, + {'is_main': false, 'onset_ts': napOnset, 'wake_ts': napOnset}, + {'is_main': false, 'onset_ts': napWake, 'wake_ts': napOnset}, + ], + 'total_asleep_min': 420, + }); + + final sleep = await repo.getDaySleep('2026-06-15'); + final periods = (sleep['periods'] as List).cast>(); + expect(periods, hasLength(1), reason: 'only the real main sleep survives'); + expect(periods.single['is_main'], isTrue); + }); + + test( + 'the hero total equals the sum of the CARDS after a period is clamped', + () async { + await seed({ + 'periods': [ + {'is_main': true, 'onset_ts': onset, 'wake_ts': wake, 'duration_min': 420}, + // Claims 101 min of sleep inside a 30-minute window. + { + 'is_main': false, + 'onset_ts': napOnset, + 'wake_ts': napOnset + 30 * 60, + 'duration_min': 101, + }, + ], + 'total_asleep_min': 521, // what the producer summed, pre-clamp + }); + + final sleep = await repo.getDaySleep('2026-06-15'); + final periods = (sleep['periods'] as List).cast>(); + final cardSum = periods.fold( + 0, + (a, p) => a + ((p['duration_min'] as num?)?.toInt() ?? 0), + ); + expect(cardSum, 450); + expect( + sleep['total_asleep_min'], + 450, + reason: 'a user can add the cards up; the hero must not disagree', + ); + }, + ); + + test( + 'an ABSENT stored total is NOT recomputed into a confident number', + () async { + // total_asleep_min null = nap detection abstained, so the day holds an + // unknown NUMBER of unmeasured naps (#204). Summing the periods we do + // have would silently omit them. + await seed({ + 'periods': [ + {'is_main': true, 'onset_ts': onset, 'wake_ts': wake, 'duration_min': 420}, + ], + 'total_asleep_min': null, + }); + + final sleep = await repo.getDaySleep('2026-06-15'); + expect((sleep['periods'] as List), hasLength(1)); + expect( + sleep['total_asleep_min'], + isNull, + reason: 'absent stays absent — the screen renders "—"', + ); + }, + ); + + test('a period with an unknown duration makes the total unknown again', + () async { + await seed({ + 'periods': [ + {'is_main': true, 'onset_ts': onset, 'wake_ts': wake, 'duration_min': null}, + {'is_main': false, 'onset_ts': napOnset, 'wake_ts': napWake, 'duration_min': 38}, + ], + 'total_asleep_min': 38, + }); + + final sleep = await repo.getDaySleep('2026-06-15'); + expect(sleep['total_asleep_min'], isNull); + }); + + group('nap-only day — no detected night', () { + test('the naps are attached instead of being dropped on the floor', () async { + // The `tst == null` early return used to drop these, so the screen said + // "No sleep recorded for this night" while the same nap was credited + // against sleep need and drawn on the Timeline. + await seed({ + 'periods': [ + { + 'is_main': false, + 'onset_ts': napOnset, + 'wake_ts': napWake, + 'duration_min': 38, + }, + ], + 'total_asleep_min': 38, + }, night: false); + + final sleep = await repo.getDaySleep('2026-06-15'); + expect( + sleep['has_sleep'], + isFalse, + reason: 'there is genuinely no NIGHT to stage — that stays honest', + ); + expect((sleep['periods'] as List), hasLength(1)); + expect(sleep['total_asleep_min'], 38); + }); + + test('a day with neither night nor naps is unchanged', () async { + await seed({'periods': const [], 'total_asleep_min': null}, night: false); + final sleep = await repo.getDaySleep('2026-06-15'); + expect(sleep['has_sleep'], isFalse); + expect(sleep['periods'], isNull, reason: 'nothing to show, nothing added'); + }); + }); + + test( + 'a NEGATIVE duration is corrupt, not small — it becomes unknown, and is ' + 'never clamped up into a full night', + () async { + await seed({ + 'periods': [ + { + 'is_main': false, + 'onset_ts': onset, + 'wake_ts': wake, // a 7-hour window + 'duration_min': -50, + }, + ], + 'total_asleep_min': -50, + }, night: false); + + final sleep = await repo.getDaySleep('2026-06-15'); + final periods = (sleep['periods'] as List).cast>(); + expect(periods.single['duration_min'], isNull); + expect( + periods.single['duration_min'], + isNot(420), + reason: 'clamping up would invent a full night out of garbage', + ); + expect( + sleep['total_asleep_min'], + isNull, + reason: 'unknown propagates — the hero reads "—"', + ); + }, + ); }