From cc2fd0432247a603ccb084beacdaa59aad81f8e1 Mon Sep 17 00:00:00 2001 From: uvtqt Date: Tue, 10 Nov 2026 19:45:13 +0100 Subject: [PATCH 1/2] Only say a night's recordings were removed when the prune removed them The Sleep screen showed "This night's detailed recordings are no longer on the phone, so its sleep can't be corrected now" for every day that could not be corrected. Not correctable only means the day has no substrate, which is also true of a fresh install, a night the band was never worn, or a night imported from elsewhere, none of which ever had recordings here. Seen on a fresh install with no band paired. LocalDb.decodedPrunedFor(day) answers whether the day starts before the decoded_pruned_before cut the raw prune records (false when nothing has been pruned), exposed as AppState.sleepRecordingsPruned next to sleepCorrectable. The screen shows the note only for a pruned day or a rejected night (recorded by definition); otherwise it shows no correction controls and no extra note, leaving the card's own "no stretch of band recordings" text as the explanation. Both places that used the note are covered. --- lib/data/db.dart | 11 +++++ lib/state/app_state.dart | 5 +++ lib/ui2/screens/sleep_detail.dart | 29 ++++++++++-- test/sleep_recordings_pruned_test.dart | 61 ++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 test/sleep_recordings_pruned_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index c3ab9f1f6..d34e433d3 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -4546,6 +4546,17 @@ class LocalDb { return v == null ? null : int.tryParse(v); } + /// Whether the raw prune has already deleted [day]'s decoded recordings: + /// the day starts before the [kDecodedPrunedBeforeCursor] cut, which the + /// prune only ever sets at a local midnight. False when nothing has been + /// pruned yet, so "no recordings" on a fresh install or an unworn night is + /// not mistaken for "deleted". + static Future decodedPrunedFor(String day) async { + final cut = await getCursorInt(kDecodedPrunedBeforeCursor); + final start = localDayStartSec(day); + return cut != null && start != null && start < cut; + } + /// The primary device's durable record frontier (`rec_ts_hw`), or null when /// no record has been banked. Builds before the commit guard could persist /// '0' from a record-less commit, so a non-positive value reads as unset. diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 0d6267716..f5cb7d198 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2764,6 +2764,11 @@ class AppState extends ChangeNotifier { sleepOnsetSec: onsetSec, ); + /// Why [sleepCorrectable] can be false for a day with no substrate: its + /// recordings were pruned (true), or there never were any (false). + Future sleepRecordingsPruned(String date) => + LocalDb.decodedPrunedFor(date); + /// Refuses a correction [sleepCorrectable] rules out, before anything is /// written: the row would sit in sleep_override unapplied. Future _requireSleepSubstrate( diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index bf2f8a4c2..a3856ab10 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -527,6 +527,11 @@ class _SleepDetailState extends State with RevisionReload { /// Unknown counts as correctable, which is how the screen behaved before. bool _correctable = true; + /// Whether the day's recordings were actually pruned, as opposed to never + /// recorded (a fresh install, a night without the band). Only the first may + /// say "no longer on the phone"; both leave the day uncorrectable. + bool _recordingsPruned = false; + /// 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 @@ -605,10 +610,14 @@ class _SleepDetailState extends State with RevisionReload { var correctable = true; var wholeNightKept = true; var confirmable = true; + var recordingsPruned = false; if (app != null && d.day != null) { try { correctable = await app.sleepCorrectable(d.day!, sleepSource: 'rejected'); + if (!correctable) { + recordingsPruned = await app.sleepRecordingsPruned(d.day!); + } wholeNightKept = correctable && await app.sleepCorrectable(d.day!); final onset = (d.night['onset_ts'] as num?)?.toInt(); @@ -634,6 +643,7 @@ class _SleepDetailState extends State with RevisionReload { _lastGood = d, _rough = rough, _correctable = correctable, + _recordingsPruned = recordingsPruned, _wholeNightKept = wholeNightKept, _confirmable = confirmable, _loading = false, @@ -738,8 +748,18 @@ class _SleepDetailState extends State with RevisionReload { // 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), + // Uncorrectable with nothing ever recorded (a fresh install, a + // night without the band) offers no button and says nothing more: + // the card above already says why there is no night. "No longer on + // the phone" is only true once the prune has actually run past it; + // a rejected night was recorded by definition. + if (_correctable) ...[ + const SizedBox(height: S.x2), + _eveningPrunedNote(c, l), + ] else if (rejected || _recordingsPruned) ...[ + const SizedBox(height: S.x2), + _uncorrectableNote(c, l), + ], ] else if (rejected) ...[ const SizedBox(height: S.x2), TextButton( @@ -1040,8 +1060,11 @@ class _SleepDetailState extends State with RevisionReload { ), ], const SizedBox(height: S.x2), + // No buttons either way when uncorrectable; the note only when the + // prune really removed this night's recordings (not, say, a night + // imported from elsewhere that never had any here). if (!_correctable) - _uncorrectableNote(c, l) + _recordingsPruned ? _uncorrectableNote(c, l) : const SizedBox.shrink() else Wrap(spacing: S.x2, children: [ if (fallback && _confirmable) diff --git a/test/sleep_recordings_pruned_test.dart b/test/sleep_recordings_pruned_test.dart new file mode 100644 index 000000000..14c78e676 --- /dev/null +++ b/test/sleep_recordings_pruned_test.dart @@ -0,0 +1,61 @@ +// The Sleep screen used to tell a fresh install, and any night the band was +// never worn, that "this night's detailed recordings are no longer on the +// phone". Both are uncorrectable because there is no substrate, but only a +// day the raw prune has actually cut may say its recordings were removed. +// `LocalDb.decodedPrunedFor` is that distinction. + +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/data/day_label.dart'; +import 'package:openstrap_edge/data/db.dart'; + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_sleep_recordings_pruned_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + tearDownAll(() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + setUp(() async { + await (await LocalDb.instance) + .delete('sync_cursor', where: 'name = ?', whereArgs: [ + LocalDb.kDecodedPrunedBeforeCursor, + ]); + }); + + test('nothing pruned yet (a fresh install): no day counts as pruned', + () async { + expect(await LocalDb.decodedPrunedFor(todayLabel()), isFalse); + expect(await LocalDb.decodedPrunedFor('2020-01-01'), isFalse); + }); + + test('days before the cut are pruned; the cut day and later are not', + () async { + // The prune only ever cuts at a local midnight. + await LocalDb.setCursor(LocalDb.kDecodedPrunedBeforeCursor, + '${localDayStartSec('2026-06-10')}'); + + expect(await LocalDb.decodedPrunedFor('2026-06-09'), isTrue); + expect(await LocalDb.decodedPrunedFor('2026-06-10'), isFalse); + expect(await LocalDb.decodedPrunedFor('2026-06-11'), isFalse); + }); + + test('a malformed day label is never reported as pruned', () async { + await LocalDb.setCursor(LocalDb.kDecodedPrunedBeforeCursor, + '${localDayStartSec('2026-06-10')}'); + + expect(await LocalDb.decodedPrunedFor('not-a-day'), isFalse); + }); +} From f41e154bb6c5940ceca8afd44cb7a06988d3db86 Mon Sep 17 00:00:00 2001 From: uvtqt Date: Wed, 11 Nov 2026 13:14:30 +0100 Subject: [PATCH 2/2] Sleep note: an imported day was never pruned A day imported from elsewhere sits before the prune cut without ever having had recordings on this phone, so it must not say they were removed. --- lib/data/db.dart | 7 +++++-- test/sleep_recordings_pruned_test.dart | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index d34e433d3..d82d791af 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -4550,11 +4550,14 @@ class LocalDb { /// the day starts before the [kDecodedPrunedBeforeCursor] cut, which the /// prune only ever sets at a local midnight. False when nothing has been /// pruned yet, so "no recordings" on a fresh install or an unworn night is - /// not mistaken for "deleted". + /// not mistaken for "deleted", and false for a day that was imported. static Future decodedPrunedFor(String day) async { final cut = await getCursorInt(kDecodedPrunedBeforeCursor); final start = localDayStartSec(day); - return cut != null && start != null && start < cut; + if (cut == null || start == null || start >= cut) return false; + // An imported day sits before the cut without ever having had recordings + // here, so there was nothing for the prune to remove. + return !(await importedDates()).contains(day); } /// The primary device's durable record frontier (`rec_ts_hw`), or null when diff --git a/test/sleep_recordings_pruned_test.dart b/test/sleep_recordings_pruned_test.dart index 14c78e676..48ec2e6bb 100644 --- a/test/sleep_recordings_pruned_test.dart +++ b/test/sleep_recordings_pruned_test.dart @@ -52,6 +52,19 @@ void main() { expect(await LocalDb.decodedPrunedFor('2026-06-11'), isFalse); }); + test('an imported day before the cut never had recordings to prune', + () async { + await LocalDb.setCursor(LocalDb.kDecodedPrunedBeforeCursor, + '${localDayStartSec('2026-06-10')}'); + final db = await LocalDb.instance; + await db.insert('metric_series_version', + {'date': '2026-06-08', 'algo_version': 1, 'source': 'whoop_csv'}); + addTearDown(() => db.delete('metric_series_version')); + + expect(await LocalDb.decodedPrunedFor('2026-06-08'), isFalse); + expect(await LocalDb.decodedPrunedFor('2026-06-09'), isTrue); + }); + test('a malformed day label is never reported as pruned', () async { await LocalDb.setCursor(LocalDb.kDecodedPrunedBeforeCursor, '${localDayStartSec('2026-06-10')}');