From cc2fd0432247a603ccb084beacdaa59aad81f8e1 Mon Sep 17 00:00:00 2001 From: uvtqt Date: Tue, 10 Nov 2026 19:45:13 +0100 Subject: [PATCH 1/3] 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/3] 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')}'); From 7b7699df0f508916e64467cd192bbef5a51ca0a3 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:32:35 +0530 Subject: [PATCH 3/3] sleep note: explain never-recorded nights, day must be wholly before the prune cut, log cursor read failure --- lib/data/db.dart | 8 ++++---- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 4 ++++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_th.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/ui2/screens/sleep_detail.dart | 21 ++++++++++++++++----- test/sleep_recordings_pruned_test.dart | 9 +++++++++ 11 files changed, 40 insertions(+), 9 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index d82d791af..7e453fe0b 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -4547,14 +4547,14 @@ class LocalDb { } /// 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 + /// the whole day lies before the [kDecodedPrunedBeforeCursor] cut, which the + /// prune only ever sets at a local midnight (a day straddling it is not). 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; + final end = localDayEndSec(day); + if (cut == null || end == null || end > 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); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 32339bc6f..3d4fd6393 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1734,6 +1734,7 @@ "sleepDetailWindowNoSleep": "Deine Zeiten sind gespeichert, aber die Aufzeichnungen des Bands aus diesen Stunden zeigen keinen Schlaf zum Auswerten.", "sleepDetailNightUnrecorded": "Deine Herzfrequenz deutet auf eine Nacht Schlaf hin, aber das Band hat dabei größtenteils zu wenige Bewegungsdaten aufgezeichnet, daher gibt es keine Nacht zum Auswerten.", "sleepDetailCorrectionUnavailable": "Die detaillierten Aufzeichnungen dieser Nacht sind nicht mehr auf dem Telefon, daher lässt sich ihr Schlaf nicht mehr korrigieren.", + "sleepDetailCorrectionNoRecordings": "Für diese Nacht liegen keine detaillierten Aufzeichnungen auf dem Telefon, daher kann ihr Schlaf nicht korrigiert werden.", "sleepDetailCorrectionEveningPruned": "Der Abend vor dieser Nacht ist nicht mehr auf dem Telefon, daher lassen sich nur eine Schlafenszeit nach Mitternacht oder „Kein Schlaf“ übernehmen.", "sleepDetailNoHypnogramTitle": "Kein Hypnogramm für diese Nacht", "sleepDetailNoHypnogramBody": "Für die Berechnung der Schlafphasen werden Bewegungsdaten und Herzschlag-Abstände benötigt. Mindestens eine dieser Datenquellen fehlt.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0d39a254a..b3465451c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -8570,6 +8570,10 @@ "description": "Shown after the user set their own sleep window and the band recorded it, but staging found no sleep in it." }, "sleepDetailCorrectionUnavailable": "This night’s detailed recordings are no longer on the phone, so its sleep can’t be corrected now.", + "sleepDetailCorrectionNoRecordings": "This night has no detailed recordings on the phone, so its sleep can’t be corrected.", + "@sleepDetailCorrectionNoRecordings": { + "description": "Shown instead of the sleep correction buttons on a night with no detailed recordings on the phone that were never pruned (imported or never worn)." + }, "sleepDetailCorrectionEveningPruned": "The evening before this night is no longer on the phone, so only a bedtime after midnight, or Not sleep, can be applied.", "@sleepDetailCorrectionEveningPruned": { "description": "Shown on the oldest night still kept, or after a correction there was refused: the evening part of the night has been pruned, so a bedtime before midnight, confirming the detected times, or reverting to automatic cannot be applied." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0fb047874..02c65ae1f 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1681,6 +1681,7 @@ "sleepDetailWindowNoSleep": "Tus horas están guardadas, pero los registros de la pulsera de esas horas no muestran sueño que puntuar.", "sleepDetailNightUnrecorded": "Tu frecuencia cardíaca indica una noche de sueño, pero la pulsera registró muy pocos datos de movimiento durante la mayor parte, así que no hay noche que puntuar.", "sleepDetailCorrectionUnavailable": "Los registros detallados de esta noche ya no están en el teléfono, así que su sueño ya no se puede corregir.", + "sleepDetailCorrectionNoRecordings": "Esta noche no tiene registros detallados en el teléfono, así que su sueño no se puede corregir.", "sleepDetailCorrectionEveningPruned": "La tarde anterior a esta noche ya no está en el teléfono, así que solo se puede aplicar una hora de acostarse después de medianoche o «No es sueño».", "sleepDetailNoHypnogramTitle": "No hay hipnograma para esta noche", "sleepDetailNoHypnogramBody": "El cálculo de fases necesita movimiento y ritmo cardíaco. Faltaba uno de los dos.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 41cab9ff7..2073e71b2 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1671,6 +1671,7 @@ "sleepDetailWindowNoSleep": "Vos horaires sont enregistrés, mais les enregistrements du bracelet pour ces heures ne montrent aucun sommeil à évaluer.", "sleepDetailNightUnrecorded": "Votre fréquence cardiaque indique une nuit de sommeil, mais le bracelet a enregistré trop peu de données de mouvement pendant la majeure partie : il n'y a donc pas de nuit à évaluer.", "sleepDetailCorrectionUnavailable": "Les enregistrements détaillés de cette nuit ne sont plus sur le téléphone, son sommeil ne peut donc plus être corrigé.", + "sleepDetailCorrectionNoRecordings": "Cette nuit n’a pas d’enregistrements détaillés sur le téléphone, son sommeil ne peut donc pas être corrigé.", "sleepDetailCorrectionEveningPruned": "La soirée précédant cette nuit n’est plus sur le téléphone : on peut seulement indiquer une heure de coucher après minuit, ou marquer la nuit comme n’étant pas du sommeil.", "sleepDetailNoHypnogramTitle": "Aucun hypnogramme pour cette nuit", "sleepDetailNoHypnogramBody": "L'analyse des phases nécessite le mouvement et le rythme cardiaque. L'un des deux manquait.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 6159de0a0..005157bcc 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1671,6 +1671,7 @@ "sleepDetailWindowNoSleep": "आपके समय सेव हैं, लेकिन उन घंटों की बैंड रिकॉर्डिंग में स्कोर करने लायक कोई नींद नहीं दिखती।", "sleepDetailNightUnrecorded": "आपकी हृदय गति रात की नींद दिखाती है, लेकिन ज़्यादातर समय बैंड ने बहुत कम मूवमेंट डेटा रिकॉर्ड किया, इसलिए स्कोर करने के लिए कोई रात नहीं है।", "sleepDetailCorrectionUnavailable": "इस रात की विस्तृत रिकॉर्डिंग अब फ़ोन पर नहीं हैं, इसलिए इसकी नींद अब ठीक नहीं की जा सकती।", + "sleepDetailCorrectionNoRecordings": "इस रात की विस्तृत रिकॉर्डिंग फ़ोन पर नहीं हैं, इसलिए इसकी नींद को सुधारा नहीं जा सकता।", "sleepDetailCorrectionEveningPruned": "इस रात से पहले की शाम अब फ़ोन पर नहीं है, इसलिए केवल आधी रात के बाद का सोने का समय दिया जा सकता है, या इस रात को नींद नहीं के रूप में चिह्नित किया जा सकता है।", "sleepDetailNoHypnogramTitle": "इस रात के लिए कोई हिप्नोग्राम नहीं", "sleepDetailNoHypnogramBody": "चरण तय करने के लिए हलचल और हृदय-धड़कन की टाइमिंग चाहिए। इनमें से एक मौजूद नहीं था।", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 84bc7cfc2..de1521e0b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3940,6 +3940,7 @@ "sleepDetailWindowNoSleep": "Время сохранено, но в записях браслета за эти часы нет сна, который можно оценить.", "sleepDetailNightUnrecorded": "Пульс указывает на ночной сон, но большую часть ночи браслет записал слишком мало данных о движении, поэтому оценивать нечего.", "sleepDetailCorrectionUnavailable": "Подробные записи этой ночи уже удалены с телефона, поэтому её сон больше нельзя исправить.", + "sleepDetailCorrectionNoRecordings": "Для этой ночи на телефоне нет подробных записей, поэтому её сон нельзя исправить.", "sleepDetailCorrectionEveningPruned": "Вечер перед этой ночью уже удалён с телефона, поэтому можно применить только время отхода ко сну после полуночи или «Не сон».", "sleepDetailNoHypnogramTitle": "За эту ночь гипнограммы нет", "sleepDetailNoHypnogramBody": "Для определения стадий нужны движение и межударные интервалы. Одного из источников не было.", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 6347b62ca..aa4d0d37d 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -8558,6 +8558,7 @@ "description": "Shown after the user set their own sleep window and the band recorded it, but staging found no sleep in it." }, "sleepDetailCorrectionUnavailable": "ข้อมูลบันทึกรายละเอียดของคืนนี้ไม่ได้อยู่ในโทรศัพท์แล้ว จึงแก้ไขการนอนไม่ได้", + "sleepDetailCorrectionNoRecordings": "คืนนี้ไม่มีข้อมูลบันทึกรายละเอียดในโทรศัพท์ จึงแก้ไขการนอนของคืนนี้ไม่ได้", "sleepDetailCorrectionEveningPruned": "ข้อมูลช่วงเย็นก่อนคืนนี้ไม่ได้อยู่ในโทรศัพท์แล้ว จึงใช้ได้เฉพาะเวลาเข้านอนหลังเที่ยงคืน หรือ “ไม่ใช่การนอน”", "@sleepDetailCorrectionEveningPruned": { "description": "Shown on the oldest night still kept, or after a correction there was refused: the evening part of the night has been pruned, so a bedtime before midnight, confirming the detected times, or reverting to automatic cannot be applied." diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 472e0d493..a83b36d00 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1671,6 +1671,7 @@ "sleepDetailWindowNoSleep": "你设置的时间已保存,但手环在这段时间的记录中没有可评分的睡眠。", "sleepDetailNightUnrecorded": "你的心率显示有一夜睡眠,但手环在大部分时间里记录的动作数据太少,所以没有可评分的夜晚。", "sleepDetailCorrectionUnavailable": "这一夜的详细记录已不在手机上,因此无法再更正这一夜的睡眠。", + "sleepDetailCorrectionNoRecordings": "这一夜手机上没有详细记录,因此无法修正其睡眠。", "sleepDetailCorrectionEveningPruned": "这一夜之前的傍晚记录已不在手机上,因此只能设置午夜之后的入睡时间,或将这一夜标记为不是睡眠。", "sleepDetailNoHypnogramTitle": "这一夜没有睡眠阶段图", "sleepDetailNoHypnogramBody": "阶段判定需要运动和心跳时序数据,其中有一项缺失。", diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index a3856ab10..30c2a1278 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -626,7 +626,9 @@ class _SleepDetailState extends State with RevisionReload { onset != null && await app.sleepCorrectable(d.day!, sleepSource: 'confirmed', onsetSec: onset)); - } catch (_) {} + } catch (e) { + debugPrint('sleep detail: correctability read failed: $e'); + } } // LAST NIGHT ONLY. The card names the luteal phase, and `getCycle` knows // today's phase and no other day's — so a card offered while stepping @@ -1060,11 +1062,12 @@ 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). + // No buttons when uncorrectable; the note says why: pruned + // recordings, or (imported, never worn) nothing to re-analyse. if (!_correctable) - _recordingsPruned ? _uncorrectableNote(c, l) : const SizedBox.shrink() + _recordingsPruned + ? _uncorrectableNote(c, l) + : _noRecordingsNote(c, l) else Wrap(spacing: S.x2, children: [ if (fallback && _confirmable) @@ -1121,6 +1124,14 @@ class _SleepDetailState extends State with RevisionReload { style: F.cap.copyWith(color: P.of(c).ink3), ); + /// An uncorrectable night whose recordings were never here to prune. + Widget _noRecordingsNote(BuildContext c, AppLocalizations? l) => Text( + l?.sleepDetailCorrectionNoRecordings ?? + 'This night has no detailed recordings on the phone, so its sleep ' + 'can\u2019t be corrected.', + style: F.cap.copyWith(color: P.of(c).ink3), + ); + /// On the oldest kept night: says why only some corrections are offered. Widget _eveningPrunedNote(BuildContext c, AppLocalizations? l) => Text( l?.sleepDetailCorrectionEveningPruned ?? diff --git a/test/sleep_recordings_pruned_test.dart b/test/sleep_recordings_pruned_test.dart index 48ec2e6bb..40533f8c2 100644 --- a/test/sleep_recordings_pruned_test.dart +++ b/test/sleep_recordings_pruned_test.dart @@ -71,4 +71,13 @@ void main() { expect(await LocalDb.decodedPrunedFor('not-a-day'), isFalse); }); + + test('a day straddling the cut still has recordings, so is not pruned', + () async { + final mid = localDayStartSec('2026-06-09')! + 12 * 3600; + await LocalDb.setCursor(LocalDb.kDecodedPrunedBeforeCursor, '$mid'); + + expect(await LocalDb.decodedPrunedFor('2026-06-09'), isFalse); + expect(await LocalDb.decodedPrunedFor('2026-06-08'), isTrue); + }); }