diff --git a/lib/data/db.dart b/lib/data/db.dart index c3ab9f1f6..d82d791af 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -4546,6 +4546,20 @@ 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", and false for a day that was imported. + static Future decodedPrunedFor(String day) async { + final cut = await getCursorInt(kDecodedPrunedBeforeCursor); + final start = localDayStartSec(day); + 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 /// 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..48ec2e6bb --- /dev/null +++ b/test/sleep_recordings_pruned_test.dart @@ -0,0 +1,74 @@ +// 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('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')}'); + + expect(await LocalDb.decodedPrunedFor('not-a-day'), isFalse); + }); +}