From 0cb3d8dbc4b513ee72bcdc420b2c7adf5aeb095f Mon Sep 17 00:00:00 2001 From: uvtqt Date: Wed, 11 Nov 2026 09:18:28 +0100 Subject: [PATCH 1/3] Opt-in setting to log clear naps automatically (#625) A detected nap only counts once it is confirmed, and the nap prompt is off by default, so a correct detection is usually lost. With the new switch on Detected activities turned on, a pending nap at or above confidence 0.8 that ended after the switch was turned on is confirmed as detected once the data edge is napBridgeSec past its end. Everything else stays a suggestion. Off by default; imports are never auto-logged. --- lib/compute/derivation_engine.dart | 21 ++++ lib/data/activity_store.dart | 45 +++++++ lib/l10n/app_de.arb | 4 +- lib/l10n/app_en.arb | 4 +- lib/l10n/app_es.arb | 4 +- lib/l10n/app_fr.arb | 4 +- lib/l10n/app_hi.arb | 4 +- lib/l10n/app_ru.arb | 4 +- lib/l10n/app_th.arb | 4 +- lib/l10n/app_zh.arb | 4 +- lib/state/prefs.dart | 5 + lib/ui2/profile/gallery.dart | 1 + lib/ui2/screens/detected_activities.dart | 37 ++++++ test/nap_auto_log_row_test.dart | 35 ++++++ test/nap_auto_log_test.dart | 152 +++++++++++++++++++++++ 15 files changed, 320 insertions(+), 8 deletions(-) create mode 100644 test/nap_auto_log_row_test.dart create mode 100644 test/nap_auto_log_test.dart diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 16eb8578b..1ac4dcdac 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -22,6 +22,8 @@ import '../data/activity_store.dart'; import '../sync/reset_gate.dart'; import '../models/activity_suggestion.dart'; +import '../state/prefs.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'dart:async'; import 'dart:convert'; import 'dart:io' show Platform; @@ -6445,9 +6447,28 @@ class DerivationEngine { ...await activityStore.reconcile(ActivityKind.workout, blocks.suggestionsToPersist), ...await activityStore.reconcile(ActivityKind.nap, blocks.napSuggestions), ]; + // Opt-in (#625): a clear, settled nap is logged without asking. Never + // for an import, which has no user at the other end of "just now". + final napAutoLogPref = forceFinalize + ? null + : (await SharedPreferences.getInstance()) + .getInt(Prefs.napAutoLogSinceSec); + // 0 is the switch turned back off. + final napAutoLogSince = (napAutoLogPref ?? 0) > 0 ? napAutoLogPref : null; + try { + await activityStore.autoLogNaps( + sinceSec: napAutoLogSince, + dataEdgeSec: dataNowSec, + settleSec: ana.napBridgeSec, + ); + } catch (e) { + _log('nap auto-log failed for ${day.date}: $e'); + } for (final suggestion in newSuggestions) { // The rows are already banked; a failed prompt must not mark the day partial. try { + // It is about to be logged for them; asking as well would be noise. + if (ActivityStore.napAutoLoggable(suggestion, napAutoLogSince)) continue; if (forceFinalize || !await activityStore.mayNotifyNew(suggestion)) continue; final nap = suggestion.kind == ActivityKind.nap; await NotificationCenter.instance.emit(NotificationEvent( diff --git a/lib/data/activity_store.dart b/lib/data/activity_store.dart index 6c69eedea..57ff8695a 100644 --- a/lib/data/activity_store.dart +++ b/lib/data/activity_store.dart @@ -426,6 +426,7 @@ class ActivityStore { required int endTs, Map? session, bool edited = false, + bool auto = false, }) => db.transaction((tx) async { final row = await _check(tx, expected, 'confirmed'); if (row == null) return; @@ -487,6 +488,8 @@ class ActivityStore { 'start': startTs, 'end': endTs, 'source': 'confirmed', + // Logged by the opt-in setting, not by a tap (#625). + if (auto) 'auto_logged': true, }), }); await _changed(tx, day); @@ -500,6 +503,48 @@ class ActivityStore { await _changed(tx, row['day_id'] as String); }); + /// The bar for logging a nap without asking (#625). The detector's + /// confidence is a composite of HR coverage, HR dip, stillness and wear, + /// in [0.2, 0.85]; it is not a probability and 0.8 is not a validated cut. + /// ponytail: one fixed bar, the value the issue asked for. Make it a user + /// choice if people want it looser or stricter. + static const double napAutoLogMinConfidence = 0.8; + + /// Whether the opt-in setting would log [s] without asking. [sinceSec] is + /// when the setting was turned on (null = off): a nap that ended before + /// that is never logged behind the user's back. + static bool napAutoLoggable(ActivitySuggestion s, int? sinceSec) { + final c = s.details['confidence']; + return sinceSec != null && + s.kind == ActivityKind.nap && + s.status == ActivityReviewStatus.pending && + c is num && + c >= napAutoLogMinConfidence && + s.endTs >= sinceSec; + } + + /// Confirms every [napAutoLoggable] pending nap exactly as detected, once + /// the data edge is [settleSec] past its end, so sleep that resumes after a + /// short arousal can still be bridged into it first. A nap that cannot be + /// confirmed (it overlaps saved sleep, or changed underneath) stays pending. + Future autoLogNaps({ + required int? sinceSec, + required int dataEdgeSec, + required int settleSec, + }) async { + if (sinceSec == null) return; + for (final s in await pending()) { + if (!napAutoLoggable(s, sinceSec) || s.endTs + settleSec > dataEdgeSec) { + continue; + } + try { + await confirm(s, startTs: s.startTs, endTs: s.endTs, auto: true); + } on ActivityReviewException { + // Left for the user to review. + } + } + } + static Future _changed(DatabaseExecutor tx, String day) async { await _ensureMeta(tx); await tx.rawUpdate( diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 32339bc6f..7845f9095 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3472,5 +3472,7 @@ "type": "int" } } - } + }, + "napAutoLogTitle": "Eindeutige Nickerchen automatisch eintragen", + "napAutoLogSub": "Ein erkanntes Nickerchen mit starken Anzeichen wird ohne Nachfrage eingetragen. Du kannst es weiterhin bearbeiten oder entfernen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0d39a254a..f46ed766c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16343,5 +16343,7 @@ "type": "int" } } - } + }, + "napAutoLogTitle": "Log clear naps automatically", + "napAutoLogSub": "A detected nap with strong evidence is added without asking. You can still edit or remove it" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0fb047874..07f63a989 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2892,5 +2892,7 @@ } }, "reportContactLabel": "Contacto (opcional)", - "reportContactHint": "Correo o Discord, solo si quieres una respuesta" + "reportContactHint": "Correo o Discord, solo si quieres una respuesta", + "napAutoLogTitle": "Registrar automáticamente las siestas claras", + "napAutoLogSub": "Una siesta detectada con indicios sólidos se añade sin preguntar. Puedes editarla o eliminarla después" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 41cab9ff7..c5e4469d4 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2863,5 +2863,7 @@ } }, "reportContactLabel": "Contact (facultatif)", - "reportContactHint": "E-mail ou Discord, seulement si vous voulez une réponse" + "reportContactHint": "E-mail ou Discord, seulement si vous voulez une réponse", + "napAutoLogTitle": "Enregistrer automatiquement les siestes nettes", + "napAutoLogSub": "Une sieste détectée avec des indices solides est ajoutée sans demander. Vous pouvez toujours la modifier ou la supprimer" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 6159de0a0..88e4777d9 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2863,5 +2863,7 @@ } }, "reportContactLabel": "संपर्क (वैकल्पिक)", - "reportContactHint": "ईमेल या Discord, केवल अगर आप जवाब चाहते हैं" + "reportContactHint": "ईमेल या Discord, केवल अगर आप जवाब चाहते हैं", + "napAutoLogTitle": "स्पष्ट झपकियाँ अपने-आप दर्ज करें", + "napAutoLogSub": "मज़बूत संकेतों वाली पहचानी गई झपकी बिना पूछे जोड़ दी जाती है। आप इसे बाद में भी बदल या हटा सकते हैं" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 84bc7cfc2..25969a4e4 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7907,5 +7907,7 @@ }, "whoopFirmwareWarningTitle": "Пользователям WHOOP: не подключайтесь снова к приложению WHOOP", "whoopFirmwareWarningAction": "Если вы пользуетесь OpenStrap или другим приложением с открытым кодом либо планируете перейти на него позже, не подключайте браслет к официальному приложению WHOOP снова. Оно может автоматически установить обновления прошивки. Удалите или отключите приложение WHOOP, чтобы оно не обновляло ваш браслет.", - "whoopFirmwareWarningDetails": "В сообщениях о прошивках WHOOP 5.0 / MG 50.43.1.0 и 50.43.2.0 описывается заблокированный доступ к данным. Bluetooth при этом может подключаться. OpenStrap не может выпустить подписанный WHOOP токен членства, необходимый для разблокировки затронутых браслетов. Мы работаем над решением, но исправления пока нет." + "whoopFirmwareWarningDetails": "В сообщениях о прошивках WHOOP 5.0 / MG 50.43.1.0 и 50.43.2.0 описывается заблокированный доступ к данным. Bluetooth при этом может подключаться. OpenStrap не может выпустить подписанный WHOOP токен членства, необходимый для разблокировки затронутых браслетов. Мы работаем над решением, но исправления пока нет.", + "napAutoLogTitle": "Автоматически записывать явный дневной сон", + "napAutoLogSub": "Обнаруженный дневной сон с убедительными признаками добавляется без запроса. Его по-прежнему можно изменить или удалить" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 6347b62ca..5d6ec8c60 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16328,5 +16328,7 @@ }, "whoopFirmwareWarningTitle": "ผู้ใช้ WHOOP: อย่าเชื่อมต่อกับแอป WHOOP อีก", "whoopFirmwareWarningAction": "หากคุณใช้ OpenStrap หรือแอปโอเพนซอร์สอื่น หรือวางแผนจะเปลี่ยนไปใช้ในภายหลัง อย่าเชื่อมต่อสายรัดกับแอป WHOOP อย่างเป็นทางการอีก แอปอาจติดตั้งอัปเดตเฟิร์มแวร์โดยอัตโนมัติ ให้ถอนการติดตั้งหรือปิดใช้งานแอป WHOOP เพื่อไม่ให้แอปอัปเดตสายรัดของคุณ", - "whoopFirmwareWarningDetails": "มีรายงานเกี่ยวกับเฟิร์มแวร์ WHOOP 5.0 / MG เวอร์ชัน 50.43.1.0 และ 50.43.2.0 ว่าเข้าถึงข้อมูลไม่ได้ บลูทูธอาจยังเชื่อมต่อได้ OpenStrap ไม่สามารถออกโทเค็นสมาชิกที่ WHOOP ลงนามซึ่งจำเป็นต่อการปลดล็อกสายรัดที่ได้รับผลกระทบ เรากำลังหาทางแก้ไข แต่ยังไม่มีวิธีแก้ในตอนนี้" + "whoopFirmwareWarningDetails": "มีรายงานเกี่ยวกับเฟิร์มแวร์ WHOOP 5.0 / MG เวอร์ชัน 50.43.1.0 และ 50.43.2.0 ว่าเข้าถึงข้อมูลไม่ได้ บลูทูธอาจยังเชื่อมต่อได้ OpenStrap ไม่สามารถออกโทเค็นสมาชิกที่ WHOOP ลงนามซึ่งจำเป็นต่อการปลดล็อกสายรัดที่ได้รับผลกระทบ เรากำลังหาทางแก้ไข แต่ยังไม่มีวิธีแก้ในตอนนี้", + "napAutoLogTitle": "บันทึกการงีบที่ชัดเจนโดยอัตโนมัติ", + "napAutoLogSub": "การงีบที่ตรวจพบและมีหลักฐานชัดเจนจะถูกเพิ่มโดยไม่ถาม คุณยังแก้ไขหรือลบได้" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 472e0d493..c25628d2d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2863,5 +2863,7 @@ } }, "reportContactLabel": "联系方式(可选)", - "reportContactHint": "邮箱或 Discord,仅在你希望得到回复时填写" + "reportContactHint": "邮箱或 Discord,仅在你希望得到回复时填写", + "napAutoLogTitle": "自动记录明确的小睡", + "napAutoLogSub": "证据充分的小睡会直接记录,不再询问。之后仍可编辑或删除" } diff --git a/lib/state/prefs.dart b/lib/state/prefs.dart index d83147674..c846d9d4d 100644 --- a/lib/state/prefs.dart +++ b/lib/state/prefs.dart @@ -95,6 +95,11 @@ class Prefs { /// list and nothing reads it except the surfaces it reveals. static const String devMode = 'dev.mode'; + /// "Log clear naps automatically" (#625). Absent = off. Holds WHEN it was + /// turned on, in epoch seconds, so only naps that end after that are logged + /// without asking: switching it on never rewrites days already past. + static const String napAutoLogSinceSec = 'activity.nap_auto_log_since_sec'; + /// Per-metric range toggle on the shared MetricScreen (Today/Week/Month/3M). /// Keyed by the metric id so Sleep / Heart / Body each remember independently. static String metricTab(String metric) => 'ui.metric_tab.$metric'; diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 5fd5a0966..81f4f5293 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -835,6 +835,7 @@ Map extraCases() => { startTs: 1787157000, endTs: 1787164200, revision: 0, status: ActivityReviewStatus.pending, details: const {}), onConfirm: () {}, onEdit: () {}, onDiscard: () {}), + 'nap_auto_log_row': const NapAutoLogRow(), 'nap_row': const MetricRow(LucideIcons.sun, C.indigo, 'Daytime sleep', '1h 12m', sub: '2 naps · Sunday, 16 August'), diff --git a/lib/ui2/screens/detected_activities.dart b/lib/ui2/screens/detected_activities.dart index ba0be02fc..807cb2517 100644 --- a/lib/ui2/screens/detected_activities.dart +++ b/lib/ui2/screens/detected_activities.dart @@ -6,8 +6,10 @@ import '../../compute/manual_session.dart' show ManualWindowErrorMessage, ManualWindowException; import '../../compute/nap_edits.dart'; import '../../models/activity_suggestion.dart'; +import '../../state/prefs.dart'; import '../../theme/theme_switcher.dart' show themedRoute; import '../activity/catalogue.dart'; +import '../profile/profile.dart' show SetRow; import '../ui2.dart'; import 'home_screen.dart'; import 'log_workout.dart'; @@ -183,6 +185,7 @@ class _DetectedActivitiesScreenState extends State : () => _act(s, discard: true), ), ), + const NapAutoLogRow(), ], ), ), @@ -193,6 +196,40 @@ class _DetectedActivitiesScreenState extends State } } +/// The opt-in switch for logging clear naps without asking (#625). Off by +/// default. Turning it on stores the moment it was turned on, so only naps +/// that end after that are logged for the user. +class NapAutoLogRow extends StatefulWidget { + const NapAutoLogRow({super.key}); + + @override + State createState() => _NapAutoLogRowState(); +} + +class _NapAutoLogRowState extends State { + @override + Widget build(BuildContext c) { + final l = AppLocalizations.of(c); + final on = Prefs.getInt(Prefs.napAutoLogSinceSec, 0) > 0; + return Surface( + child: SetRow( + LucideIcons.moon, + C.domHealth, + l?.napAutoLogTitle ?? 'Log clear naps automatically', + sub: l?.napAutoLogSub ?? + 'A detected nap with strong evidence is added without asking. ' + 'You can still edit or remove it', + value: on ? (l?.stateOn ?? 'On') : (l?.stateOff ?? 'Off'), + chevron: false, + onTap: () => setState(() => Prefs.setInt( + Prefs.napAutoLogSinceSec, + on ? 0 : DateTime.now().millisecondsSinceEpoch ~/ 1000, + )), + ), + ); + } +} + /// Routes inherit the app's theme and reduced-motion policy. Route themedActivityRoute(Widget child) => themedRoute((_) => child, name: child.runtimeType.toString()); diff --git a/test/nap_auto_log_row_test.dart b/test/nap_auto_log_row_test.dart new file mode 100644 index 000000000..6d2b06ffd --- /dev/null +++ b/test/nap_auto_log_row_test.dart @@ -0,0 +1,35 @@ +// #625: the switch on Detected activities. Off by default; turning it on +// stores when it was turned on, turning it off clears that. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/state/prefs.dart'; +import 'package:openstrap_edge/ui2/screens/detected_activities.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + testWidgets('off by default, a tap turns it on, another turns it off', + (t) async { + SharedPreferences.setMockInitialValues({}); + await Prefs.ensureLoaded(); + await t.pumpWidget( + const MaterialApp(home: Scaffold(body: NapAutoLogRow())), + ); + expect(find.text('Off'), findsOneWidget); + expect(Prefs.getInt(Prefs.napAutoLogSinceSec, 0), 0); + + final before = DateTime.now().millisecondsSinceEpoch ~/ 1000; + await t.tap(find.text('Log clear naps automatically')); + await t.pump(); + expect(find.text('On'), findsOneWidget); + expect( + Prefs.getInt(Prefs.napAutoLogSinceSec, 0), + greaterThanOrEqualTo(before), + ); + + await t.tap(find.text('Log clear naps automatically')); + await t.pump(); + expect(find.text('Off'), findsOneWidget); + expect(Prefs.getInt(Prefs.napAutoLogSinceSec, 0), 0); + }); +} diff --git a/test/nap_auto_log_test.dart b/test/nap_auto_log_test.dart new file mode 100644 index 000000000..6e76ba3d8 --- /dev/null +++ b/test/nap_auto_log_test.dart @@ -0,0 +1,152 @@ +// #625: the opt-in "log clear naps automatically" setting. Off, nothing is +// logged without a tap. On, only a pending nap at or above the confidence bar, +// ended after the switch was turned on, and settled behind the data edge, is +// confirmed exactly as detected. Everything else stays a suggestion. + +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/data/activity_store.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/models/activity_suggestion.dart'; + +void main() { + late Database db; + late ActivityStore store; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final start = now - 7200; + final end = start + 1800; + const settle = 300; + + Map nap(double confidence) => { + 'start': start, + 'end': end, + 'duration_min': 24, + 'in_bed_min': 30, + 'confidence': confidence, + }; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + setUp(() async { + await LocalDb.close(); + LocalDb.dbName = 'nap_auto_log_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + db = await LocalDb.instance; + await db.update('activity_review_meta', {'activated_at': start - 1}); + store = ActivityStore(db); + }); + tearDown(() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + Future detect(double confidence) async => + (await store.reconcile(ActivityKind.nap, [nap(confidence)])).single; + + Future run({int? since, int? edge}) => store.autoLogNaps( + sinceSec: since, + dataEdgeSec: edge ?? now, + settleSec: settle, + ); + + Future>> logged() => db.query('sleep_nap'); + + test('off: a clear nap stays a suggestion', () async { + final s = await detect(0.85); + await run(since: null); + expect(await logged(), isEmpty); + expect((await store.get(s.id))!.status, ActivityReviewStatus.pending); + }); + + test('on: a clear, settled nap is logged once, as detected', () async { + final s = await detect(0.85); + await run(since: start - 1); + await run(since: start - 1); + final rows = await logged(); + expect(rows, hasLength(1)); + expect(rows.single['start_ts'], start); + expect(rows.single['end_ts'], end); + expect(rows.single['source'], 'confirmed'); + final payload = jsonDecode(rows.single['payload_json'] as String) as Map; + expect(payload['auto_logged'], true); + expect(payload['duration_min'], 24); + expect((await store.get(s.id))!.status, ActivityReviewStatus.confirmed); + }); + + test('a tap-confirmed nap is not marked as auto-logged', () async { + final s = await detect(0.85); + await store.confirm(s, startTs: start, endTs: end); + final payload = + jsonDecode((await logged()).single['payload_json'] as String) as Map; + expect(payload.containsKey('auto_logged'), isFalse); + }); + + test('on: a nap below the bar stays a suggestion', () async { + final s = await detect(0.79); + await run(since: start - 1); + expect(await logged(), isEmpty); + expect((await store.get(s.id))!.status, ActivityReviewStatus.pending); + }); + + test('on: waits until the data edge is past the nap by the settle time', + () async { + await detect(0.85); + await run(since: start - 1, edge: end + settle - 1); + expect(await logged(), isEmpty); + await run(since: start - 1, edge: end + settle); + expect(await logged(), hasLength(1)); + }); + + test('on: a nap that ended before the switch was turned on is left alone', + () async { + await detect(0.85); + await run(since: end + 1); + expect(await logged(), isEmpty); + }); + + test('on: a nap the user discarded is never logged', () async { + final s = await detect(0.85); + await store.discard(s); + await store.reconcile(ActivityKind.nap, [nap(0.85)]); + await run(since: start - 1); + expect(await logged(), isEmpty); + }); + + test('only pending naps with a measured confidence qualify', () { + ActivitySuggestion s( + ActivityKind kind, + Object? confidence, [ + ActivityReviewStatus status = ActivityReviewStatus.pending, + ]) => ActivitySuggestion( + id: 'x', + kind: kind, + startTs: start, + endTs: end, + revision: 0, + status: status, + details: {'confidence': confidence}, + ); + bool ok(ActivitySuggestion x) => + ActivityStore.napAutoLoggable(x, start - 1); + expect(ok(s(ActivityKind.nap, 0.8)), isTrue); + expect(ok(s(ActivityKind.nap, null)), isFalse); + expect(ok(s(ActivityKind.workout, 0.85)), isFalse); + expect( + ok(s(ActivityKind.nap, 0.85, ActivityReviewStatus.discarded)), + isFalse, + ); + expect( + ActivityStore.napAutoLoggable(s(ActivityKind.nap, 0.85), null), + isFalse, + ); + }); +} From b8c0e8543496d8364431c4078ec353e899a27fce Mon Sep 17 00:00:00 2001 From: uvtqt Date: Wed, 11 Nov 2026 10:28:18 +0100 Subject: [PATCH 2/3] Nap auto-log: review fixes - A nap synced more than seven days late is still logged. - Imported history is never logged, on any later pass. - A nap ending in the same second the switch was turned on is left alone. - The settings read sits inside the guard, so it cannot mark a day partial. - A clear nap that could not be logged yet still gets its prompt. - The gallery preview no longer changes the real setting. --- lib/compute/derivation_engine.dart | 33 +++++++++++++++--------------- lib/data/activity_store.dart | 20 ++++++++++++++++-- lib/ui2/profile/gallery.dart | 3 ++- test/nap_auto_log_test.dart | 25 ++++++++++++++++++++++ 4 files changed, 62 insertions(+), 19 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 1ac4dcdac..782158d24 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -6448,27 +6448,28 @@ class DerivationEngine { ...await activityStore.reconcile(ActivityKind.nap, blocks.napSuggestions), ]; // Opt-in (#625): a clear, settled nap is logged without asking. Never - // for an import, which has no user at the other end of "just now". - final napAutoLogPref = forceFinalize - ? null - : (await SharedPreferences.getInstance()) + // on an import pass, which has no user at the other end of "just now". + // Inside its own try, like the prompts below: an optional convenience + // must not mark the day partial. + if (!forceFinalize) { + try { + final since = (await SharedPreferences.getInstance()) .getInt(Prefs.napAutoLogSinceSec); - // 0 is the switch turned back off. - final napAutoLogSince = (napAutoLogPref ?? 0) > 0 ? napAutoLogPref : null; - try { - await activityStore.autoLogNaps( - sinceSec: napAutoLogSince, - dataEdgeSec: dataNowSec, - settleSec: ana.napBridgeSec, - ); - } catch (e) { - _log('nap auto-log failed for ${day.date}: $e'); + await activityStore.autoLogNaps( + // 0 is the switch turned back off. + sinceSec: (since ?? 0) > 0 ? since : null, + dataEdgeSec: dataNowSec, + settleSec: ana.napBridgeSec, + ); + } catch (e) { + _log('nap auto-log failed for ${day.date}: $e'); + } } for (final suggestion in newSuggestions) { // The rows are already banked; a failed prompt must not mark the day partial. try { - // It is about to be logged for them; asking as well would be noise. - if (ActivityStore.napAutoLoggable(suggestion, napAutoLogSince)) continue; + // A nap logged just above is no longer pending, so [mayNotifyNew] + // already declines it. One that could not be logged yet still asks. if (forceFinalize || !await activityStore.mayNotifyNew(suggestion)) continue; final nap = suggestion.kind == ActivityKind.nap; await NotificationCenter.instance.emit(NotificationEvent( diff --git a/lib/data/activity_store.dart b/lib/data/activity_store.dart index 57ff8695a..f71a05cbc 100644 --- a/lib/data/activity_store.dart +++ b/lib/data/activity_store.dart @@ -520,7 +520,9 @@ class ActivityStore { s.status == ActivityReviewStatus.pending && c is num && c >= napAutoLogMinConfidence && - s.endTs >= sinceSec; + // Strictly after: the switch is stored in whole seconds, and a nap + // that ended in that same second ended before it was turned on. + s.endTs > sinceSec; } /// Confirms every [napAutoLoggable] pending nap exactly as detected, once @@ -533,7 +535,21 @@ class ActivityStore { required int settleSec, }) async { if (sinceSec == null) return; - for (final s in await pending()) { + await migrateLegacy(); + // Imported history is never logged for the user: the same line + // [mayNotifyNew] draws for prompts. + final importedThrough = + (await db.query('activity_review_meta')).single['alert_after_ts'] + as int; + // Not [pending]: its seven-day floor is for what the review list shows. A + // band that syncs after a longer gap still has naps to log. + final rows = await db.query( + 'activity_suggestions', + where: "status = 'pending' AND kind = 'nap' AND end_ts > ?", + whereArgs: [math.max(sinceSec, importedThrough)], + orderBy: 'start_ts ASC, id ASC', + ); + for (final s in rows.map(ActivitySuggestion.fromRow)) { if (!napAutoLoggable(s, sinceSec) || s.endTs + settleSec > dataEdgeSec) { continue; } diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 81f4f5293..f8ee47bda 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -835,7 +835,8 @@ Map extraCases() => { startTs: 1787157000, endTs: 1787164200, revision: 0, status: ActivityReviewStatus.pending, details: const {}), onConfirm: () {}, onEdit: () {}, onDiscard: () {}), - 'nap_auto_log_row': const NapAutoLogRow(), + // Inert here: a tap on the real row changes the real setting. + 'nap_auto_log_row': const IgnorePointer(child: NapAutoLogRow()), 'nap_row': const MetricRow(LucideIcons.sun, C.indigo, 'Daytime sleep', '1h 12m', sub: '2 naps · Sunday, 16 August'), diff --git a/test/nap_auto_log_test.dart b/test/nap_auto_log_test.dart index 6e76ba3d8..cc8612d0f 100644 --- a/test/nap_auto_log_test.dart +++ b/test/nap_auto_log_test.dart @@ -113,6 +113,31 @@ void main() { expect(await logged(), isEmpty); }); + test('on: a nap that ended in the same second the switch was turned on ' + 'is left alone', () async { + await detect(0.85); + await run(since: end); + expect(await logged(), isEmpty); + }); + + test('on: a nap synced more than a week late is still logged', () async { + final oldStart = now - 9 * 86400; + await db.update('activity_review_meta', {'activated_at': oldStart - 1}); + await store.reconcile(ActivityKind.nap, [ + {...nap(0.85), 'start': oldStart, 'end': oldStart + 1800}, + ]); + expect(await store.pending(), isEmpty, reason: 'past the review list'); + await run(since: oldStart - 1); + expect(await logged(), hasLength(1)); + }); + + test('on: a nap from imported history is left alone', () async { + await detect(0.85); + await ActivityStore.suppressImportedAlerts(db); + await run(since: start - 1); + expect(await logged(), isEmpty); + }); + test('on: a nap the user discarded is never logged', () async { final s = await detect(0.85); await store.discard(s); From c9fdc73208d656d0a2191dc2d37c645de696e6ee Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 13 Nov 2026 14:33:59 +0530 Subject: [PATCH 3/3] nap auto-log: only log naps this pass found, say so when the switch can't save, put the switch on the naps screen, apply the review right after --- lib/compute/derivation_engine.dart | 15 +++++++- lib/data/activity_store.dart | 23 ++++++++++- lib/data/backup_extras.dart | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 3 +- 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/state/derive_coordinator.dart | 6 +++ lib/state/prefs.dart | 10 +++++ lib/ui2/screens/detected_activities.dart | 25 ++++++++++-- lib/ui2/screens/naps.dart | 3 ++ test/backup/settings_audit_guard_test.dart | 3 ++ test/nap_auto_log_test.dart | 45 +++++++++++++++++++--- 17 files changed, 128 insertions(+), 13 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index ad8d8b2c8..72a80a78e 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -3130,6 +3130,18 @@ class DerivationEngine { /// are decided before the write transaction, so the slower one's PARTIAL row /// could land on top of the faster one's complete row. static bool _running = false; + + static int _autoLoggedNaps = 0; + + /// How many naps the opt-in auto-log confirmed since the last call. Logging + /// one queues a durable review job for its day; the day's nap credit only + /// lands once that job runs, so the caller kicks it off after the pass + /// instead of leaving it for the next app resume. + static int takeAutoLoggedNaps() { + final n = _autoLoggedNaps; + _autoLoggedNaps = 0; + return n; + } bool get running => _running; /// Hold or release the process-wide latch, as another pass would. @@ -6460,11 +6472,12 @@ class DerivationEngine { try { final since = (await SharedPreferences.getInstance()) .getInt(Prefs.napAutoLogSinceSec); - await activityStore.autoLogNaps( + _autoLoggedNaps += await activityStore.autoLogNaps( // 0 is the switch turned back off. sinceSec: (since ?? 0) > 0 ? since : null, dataEdgeSec: dataNowSec, settleSec: ana.napBridgeSec, + current: blocks.napSuggestions, ); } catch (e) { _log('nap auto-log failed for ${day.date}: $e'); diff --git a/lib/data/activity_store.dart b/lib/data/activity_store.dart index 719bdc4a4..620f46603 100644 --- a/lib/data/activity_store.dart +++ b/lib/data/activity_store.dart @@ -562,12 +562,27 @@ class ActivityStore { /// the data edge is [settleSec] past its end, so sleep that resumes after a /// short arousal can still be bridged into it first. A nap that cannot be /// confirmed (it overlaps saved sleep, or changed underneath) stays pending. - Future autoLogNaps({ + /// Returns how many were logged. + /// + /// [current] is the nap candidates this pass detected. Only a pending row + /// that overlaps one of them is a candidate: [reconcile] only retires rows + /// that overlap a re-detection, so a nap a later pass no longer finds stays + /// pending, and must not be confirmed behind the user's back. + Future autoLogNaps({ required int? sinceSec, required int dataEdgeSec, required int settleSec, + required List> current, }) async { - if (sinceSec == null) return; + if (sinceSec == null) return 0; + final spans = <(int, int)>[ + for (final c in current) + ( + ((c['start_ts'] ?? c['start']) as num).toInt(), + ((c['end_ts'] ?? c['end']) as num).toInt(), + ), + ]; + if (spans.isEmpty) return 0; await migrateLegacy(); // Imported history is never logged for the user: the same line // [mayNotifyNew] draws for prompts. @@ -582,16 +597,20 @@ class ActivityStore { whereArgs: [math.max(sinceSec, importedThrough)], orderBy: 'start_ts ASC, id ASC', ); + var logged = 0; for (final s in rows.map(ActivitySuggestion.fromRow)) { if (!napAutoLoggable(s, sinceSec) || s.endTs + settleSec > dataEdgeSec) { continue; } + if (!spans.any((c) => s.startTs < c.$2 && s.endTs > c.$1)) continue; try { await confirm(s, startTs: s.startTs, endTs: s.endTs, auto: true); + logged++; } on ActivityReviewException { // Left for the user to review. } } + return logged; } static Future _changed(DatabaseExecutor tx, String day) async { diff --git a/lib/data/backup_extras.dart b/lib/data/backup_extras.dart index 80f857027..e0416e107 100644 --- a/lib/data/backup_extras.dart +++ b/lib/data/backup_extras.dart @@ -89,6 +89,7 @@ class ImportSummary extends MapView { /// backed-up key missing here is written as-is; the guard test keeps this /// table complete. Keys in [kPrefsNotBackedUp] need no entry. const Map kPrefTypes = { + 'activity.nap_auto_log_since_sec': 'int', 'ai.journal_done_day': 'string', 'ai_evening_enabled': 'bool', 'ai_evening_min': 'int', diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d2bd2ff3e..bea96ee7b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3504,6 +3504,7 @@ "strengthSaveFailed": "Änderungen konnten nicht gespeichert werden", "napAutoLogTitle": "Eindeutige Nickerchen automatisch eintragen", "napAutoLogSub": "Ein erkanntes Nickerchen mit starken Anzeichen wird ohne Nachfrage eingetragen. Du kannst es weiterhin bearbeiten oder entfernen", + "napAutoLogSaveFailed": "Die Einstellung konnte nicht gespeichert werden. Versuche es erneut.", "settingsStepCalibration": "Schrittkalibrierung", "settingsStepCalibrationSub": "Lernt, wie stark dein Band deine Schritte zu hoch oder zu niedrig zählt, und korrigiert das auf dem Smartphone. Nichts wird auf das Band geschrieben und nichts verlässt das Gerät", "stepCalTitle": "Schrittkalibrierung", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3473d56ff..9fee91f2b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16400,5 +16400,6 @@ "strengthSaveFailed": "Failed to save changes", "@strengthSaveFailed": {"description": "Strength set editor copy."}, "napAutoLogTitle": "Log clear naps automatically", - "napAutoLogSub": "A detected nap with strong evidence is added without asking. You can still edit or remove it" + "napAutoLogSub": "A detected nap with strong evidence is added without asking. You can still edit or remove it", + "napAutoLogSaveFailed": "That setting could not be saved. Try again." } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index a9d3bb766..4ef981610 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2923,6 +2923,7 @@ "reportContactHint": "Correo o Discord, solo si quieres una respuesta", "napAutoLogTitle": "Registrar automáticamente las siestas claras", "napAutoLogSub": "Una siesta detectada con indicios sólidos se añade sin preguntar. Puedes editarla o eliminarla después", + "napAutoLogSaveFailed": "No se pudo guardar el ajuste. Inténtalo de nuevo.", "pairBandNotSensor": "Ese dispositivo es una pulsera {label}, no un sensor sencillo. No se guardó nada.", "alarmNextLabel": "SIGUIENTE", "alarmScheduleGroup": "Horario semanal", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 425ce6921..811a9336e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2894,6 +2894,7 @@ "reportContactHint": "E-mail ou Discord, seulement si vous voulez une réponse", "napAutoLogTitle": "Enregistrer automatiquement les siestes nettes", "napAutoLogSub": "Une sieste détectée avec des indices solides est ajoutée sans demander. Vous pouvez toujours la modifier ou la supprimer", + "napAutoLogSaveFailed": "Le réglage n’a pas pu être enregistré. Réessayez.", "pairBandNotSensor": "Cet appareil est un {label}, c’est-à-dire un bracelet, pas un simple capteur. Rien n’a été enregistré.", "whoopFirmwareWarningTitle": "Utilisateurs WHOOP : ne vous reconnectez pas à l’application WHOOP", "whoopFirmwareWarningAction": "Si vous utilisez OpenStrap ou une autre application open source, ou si vous comptez y passer plus tard, ne reconnectez pas votre bracelet à l’application WHOOP officielle. Elle peut installer des mises à jour du firmware automatiquement. Désinstallez ou désactivez l’application WHOOP pour l’empêcher de mettre à jour votre bracelet.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 7025024b7..6f7f2b5c7 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2894,6 +2894,7 @@ "reportContactHint": "ईमेल या Discord, केवल अगर आप जवाब चाहते हैं", "napAutoLogTitle": "स्पष्ट झपकियाँ अपने-आप दर्ज करें", "napAutoLogSub": "मज़बूत संकेतों वाली पहचानी गई झपकी बिना पूछे जोड़ दी जाती है। आप इसे बाद में भी बदल या हटा सकते हैं", + "napAutoLogSaveFailed": "यह सेटिंग सहेजी नहीं जा सकी। फिर से कोशिश करें।", "pairBandNotSensor": "यह डिवाइस {label} है, यानी एक बैंड, साधारण सेंसर नहीं। कुछ भी सहेजा नहीं गया।", "whoopFirmwareWarningTitle": "WHOOP उपयोगकर्ता: WHOOP ऐप से दोबारा कनेक्ट न करें", "whoopFirmwareWarningAction": "अगर आप OpenStrap या कोई दूसरा ओपन-सोर्स ऐप इस्तेमाल करते हैं, या बाद में किसी पर जाने की सोच रहे हैं, तो अपना बैंड आधिकारिक WHOOP ऐप से दोबारा कनेक्ट न करें। वह फ़र्मवेयर अपडेट अपने-आप इंस्टॉल कर सकता है। WHOOP ऐप को अनइंस्टॉल या अक्षम कर दें, ताकि वह आपके बैंड को अपडेट न कर सके।", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a2f2039ff..becc9d30c 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7938,6 +7938,7 @@ "whoopFirmwareWarningDetails": "В сообщениях о прошивках WHOOP 5.0 / MG 50.43.1.0 и 50.43.2.0 описывается заблокированный доступ к данным. Bluetooth при этом может подключаться. OpenStrap не может выпустить подписанный WHOOP токен членства, необходимый для разблокировки затронутых браслетов. Мы работаем над решением, но исправления пока нет.", "napAutoLogTitle": "Автоматически записывать явный дневной сон", "napAutoLogSub": "Обнаруженный дневной сон с убедительными признаками добавляется без запроса. Его по-прежнему можно изменить или удалить", + "napAutoLogSaveFailed": "Не удалось сохранить настройку. Попробуйте ещё раз.", "pairBandNotSensor": "Это устройство — {label}, то есть браслет, а не простой датчик. Ничего не сохранено.", "@pairBandNotSensor": { "placeholders": { diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 505cd9572..2b4b3678a 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16359,6 +16359,7 @@ "whoopFirmwareWarningDetails": "มีรายงานเกี่ยวกับเฟิร์มแวร์ WHOOP 5.0 / MG เวอร์ชัน 50.43.1.0 และ 50.43.2.0 ว่าเข้าถึงข้อมูลไม่ได้ บลูทูธอาจยังเชื่อมต่อได้ OpenStrap ไม่สามารถออกโทเค็นสมาชิกที่ WHOOP ลงนามซึ่งจำเป็นต่อการปลดล็อกสายรัดที่ได้รับผลกระทบ เรากำลังหาทางแก้ไข แต่ยังไม่มีวิธีแก้ในตอนนี้", "napAutoLogTitle": "บันทึกการงีบที่ชัดเจนโดยอัตโนมัติ", "napAutoLogSub": "การงีบที่ตรวจพบและมีหลักฐานชัดเจนจะถูกเพิ่มโดยไม่ถาม คุณยังแก้ไขหรือลบได้", + "napAutoLogSaveFailed": "บันทึกการตั้งค่าไม่สำเร็จ ลองอีกครั้ง", "pairBandNotSensor": "อุปกรณ์นั้นคือ {label} ซึ่งเป็นสายรัด ไม่ใช่เซ็นเซอร์ธรรมดา ไม่ได้บันทึกอะไรไว้", "@pairBandNotSensor": { "description": "Shown when a device picked as a plain sensor turns out to be a band. Nothing was saved. {label} is the band’s name.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 75e90c44c..0feed66b0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2894,6 +2894,7 @@ "reportContactHint": "邮箱或 Discord,仅在你希望得到回复时填写", "napAutoLogTitle": "自动记录明确的小睡", "napAutoLogSub": "证据充分的小睡会直接记录,不再询问。之后仍可编辑或删除", + "napAutoLogSaveFailed": "无法保存该设置,请重试。", "pairBandNotSensor": "这台设备是 {label},是手环,不是普通传感器。没有保存任何内容。", "whoopFirmwareWarningTitle": "WHOOP 用户:请勿重新连接 WHOOP 应用", "whoopFirmwareWarningAction": "如果你在使用 OpenStrap 或其他开源应用,或打算以后改用,请不要再把手环连接到 WHOOP 官方应用。它可能会自动安装固件更新。请卸载或停用 WHOOP 应用,以免它更新你的手环。", diff --git a/lib/state/derive_coordinator.dart b/lib/state/derive_coordinator.dart index 234939ac9..989925919 100644 --- a/lib/state/derive_coordinator.dart +++ b/lib/state/derive_coordinator.dart @@ -132,6 +132,12 @@ class DeriveCoordinator { } catch (e) { _log('[derive] session rescore failed: $e'); } + // Naps the opt-in auto-log confirmed during this pass: their review job + // is durable but only runs on a resume or an edit, so the day's nap + // credit would otherwise wait for one of those. + if (DerivationEngine.takeAutoLoggedNaps() > 0) { + unawaited(refreshActivityReviews(retry: true)); + } bumpInsights(); _notify(); // screens re-fetch from the derived store // Same signal, for the surfaces that can't listen: home/lock-screen diff --git a/lib/state/prefs.dart b/lib/state/prefs.dart index 0c8593484..34b48b5a2 100644 --- a/lib/state/prefs.dart +++ b/lib/state/prefs.dart @@ -80,6 +80,16 @@ class Prefs { } } + /// [setBoolAcked] for an int: false when there is no storage or the platform + /// refused the write, never a throw. + static Future setIntAcked(String key, int value) async { + try { + return await _sp?.setInt(key, value) ?? false; + } catch (_) { + return false; + } + } + // ── selection keys (one namespace; keep them disjoint) ────────────────────── /// The five-tab bar's index (Home, Health, Nutrition, Workout, Wellness). /// Read once to migrate, never written. diff --git a/lib/ui2/screens/detected_activities.dart b/lib/ui2/screens/detected_activities.dart index 3c6640c33..26047aaa7 100644 --- a/lib/ui2/screens/detected_activities.dart +++ b/lib/ui2/screens/detected_activities.dart @@ -221,13 +221,30 @@ class _NapAutoLogRowState extends State { 'You can still edit or remove it', value: on ? (l?.stateOn ?? 'On') : (l?.stateOff ?? 'Off'), chevron: false, - onTap: () => setState(() => Prefs.setInt( - Prefs.napAutoLogSinceSec, - on ? 0 : DateTime.now().millisecondsSinceEpoch ~/ 1000, - )), + onTap: () => _toggle(c, on), ), ); } + + /// SharedPreferences updates its cache before the platform confirms and + /// never rolls it back, so a refused write would read as On for the rest of + /// the session and be Off again at the next launch. Say so, and put the + /// cache back. + Future _toggle(BuildContext c, bool on) async { + final before = Prefs.getInt(Prefs.napAutoLogSinceSec, 0); + final messenger = ScaffoldMessenger.maybeOf(c); + final text = AppLocalizations.of(c)?.napAutoLogSaveFailed ?? + 'That setting could not be saved. Try again.'; + final ok = await Prefs.setIntAcked( + Prefs.napAutoLogSinceSec, + on ? 0 : DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + if (!ok) { + Prefs.setInt(Prefs.napAutoLogSinceSec, before); + messenger?.showSnackBar(SnackBar(content: Text(text))); + } + if (mounted) setState(() {}); + } } /// Routes inherit the app's theme and reduced-motion policy. diff --git a/lib/ui2/screens/naps.dart b/lib/ui2/screens/naps.dart index abf0c9343..aab439255 100644 --- a/lib/ui2/screens/naps.dart +++ b/lib/ui2/screens/naps.dart @@ -37,6 +37,7 @@ import '../../l10n/app_localizations.dart'; import '../../models/metric.dart' show whyFromNote; import '../../state/app_state.dart'; import '../ui2.dart'; +import 'detected_activities.dart' show NapAutoLogRow; import 'home_screen.dart' show clockOfTs, hm, repoOf; import 'metric_detail.dart' show dayNavRow, detailScaffold, pickDay; @@ -406,6 +407,8 @@ class _NapsScreenState extends State { color: C.domHealth, onTap: _busy || day == null ? null : () => _log(day), ), + const SizedBox(height: S.x3), + const NapAutoLogRow(), if (d.rejected.isNotEmpty) ...[ Section( l?.napsRemovedSection ?? 'Removed', diff --git a/test/backup/settings_audit_guard_test.dart b/test/backup/settings_audit_guard_test.dart index 5d55e803c..f58008777 100644 --- a/test/backup/settings_audit_guard_test.dart +++ b/test/backup/settings_audit_guard_test.dart @@ -18,6 +18,9 @@ const _mustTravel = { 'backup.cadence', 'gesture_double_tap', 'band.strap_name', 'health_sync', 'phone_steps', 'cycle_tracking_enabled', 'update_checks_enabled', 'nutrition.barcode_lookup', 'share.map_tiles', 'dev.mode', + // The user's opt-in, with when it was given. Restored imports are fenced by + // alert_after_ts, so a restored timestamp cannot log old naps. + 'activity.nap_auto_log_since_sec', 'coach_api', 'coach_model', 'coach_timeout_seconds', 'coach_use_chatgpt', 'ai_morning_enabled', 'ai_morning_min', 'ai_evening_enabled', 'ai_evening_min', diff --git a/test/nap_auto_log_test.dart b/test/nap_auto_log_test.dart index cc8612d0f..dcd505e1d 100644 --- a/test/nap_auto_log_test.dart +++ b/test/nap_auto_log_test.dart @@ -52,10 +52,15 @@ void main() { Future detect(double confidence) async => (await store.reconcile(ActivityKind.nap, [nap(confidence)])).single; - Future run({int? since, int? edge}) => store.autoLogNaps( + Future run({ + int? since, + int? edge, + List>? current, + }) => store.autoLogNaps( sinceSec: since, dataEdgeSec: edge ?? now, settleSec: settle, + current: current ?? [nap(0.85)], ); Future>> logged() => db.query('sleep_nap'); @@ -82,6 +87,25 @@ void main() { expect((await store.get(s.id))!.status, ActivityReviewStatus.confirmed); }); + test('reports how many it logged, so the caller can apply the review', + () async { + await detect(0.85); + final first = await store.autoLogNaps( + sinceSec: start - 1, + dataEdgeSec: now, + settleSec: settle, + current: [nap(0.85)], + ); + final again = await store.autoLogNaps( + sinceSec: start - 1, + dataEdgeSec: now, + settleSec: settle, + current: [nap(0.85)], + ); + expect(first, 1); + expect(again, 0); + }); + test('a tap-confirmed nap is not marked as auto-logged', () async { final s = await detect(0.85); await store.confirm(s, startTs: start, endTs: end); @@ -90,6 +114,18 @@ void main() { expect(payload.containsKey('auto_logged'), isFalse); }); + test('on: a pending nap this pass no longer detects is never logged', + () async { + final s = await detect(0.85); + await run(since: start - 1, current: const []); + // A different nap elsewhere in the day does not vouch for it either. + await run(since: start - 1, current: [ + {'start': end + 7200, 'end': end + 9000, 'confidence': 0.9}, + ]); + expect(await logged(), isEmpty); + expect((await store.get(s.id))!.status, ActivityReviewStatus.pending); + }); + test('on: a nap below the bar stays a suggestion', () async { final s = await detect(0.79); await run(since: start - 1); @@ -123,11 +159,10 @@ void main() { test('on: a nap synced more than a week late is still logged', () async { final oldStart = now - 9 * 86400; await db.update('activity_review_meta', {'activated_at': oldStart - 1}); - await store.reconcile(ActivityKind.nap, [ - {...nap(0.85), 'start': oldStart, 'end': oldStart + 1800}, - ]); + final old = {...nap(0.85), 'start': oldStart, 'end': oldStart + 1800}; + await store.reconcile(ActivityKind.nap, [old]); expect(await store.pending(), isEmpty, reason: 'past the review list'); - await run(since: oldStart - 1); + await run(since: oldStart - 1, current: [old]); expect(await logged(), hasLength(1)); });