From 05e605ad7beec4b8a2b5f186bfc6e0fbc9a18553 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 13 Nov 2026 07:16:12 +0000 Subject: [PATCH 01/14] ai: arm the morning briefing only when last night's data exists, and after the typical wake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The morning briefing notification fired at a fixed 08:00 regardless of whether the band had synced — a "your briefing is ready" the deep link could not back. Three changes, all gated at ARM time (an OS-scheduled notification fires with no Dart running, so the plan is the only place the gate can live): - aiReminderPlan gains lastNightDataReady: the morning slot is omitted when no data for the night that just ended has arrived (synced sample from today, or a derived crossday row for today) — same contract as the evening sweep slot, which is armed only when a finding exists. - The slot time follows the sleep coach's typical wake (morningBriefSlotMin: max(configured morningMin, wake + 30 min)), so a user still asleep at 08:00 is not buzzed; unknown wake keeps the old fixed time. - A pass that runs just after the slot (e.g. the morning sync landing at 08:30) fires the briefing late within a 3 h grace window instead of rolling it to tomorrow (morningBriefLateFire). The scheduler re-runs on every sync completion/foreground pass, so a late sync re-arms the slot as soon as the data lands. No notification strings changed, so all l10n arb files stay untouched. Tests: data gate, wake-derived slot (incl. quiet-hours drop), late-fire grace window. Co-authored-by: BucciMobile --- lib/ai/reminder_plan.dart | 43 ++++++++++- lib/notify/notification_center.dart | 48 ++++++++++-- lib/state/app_state.dart | 64 ++++++++++++---- test/ai_morning_brief_arming_test.dart | 25 ++++++ test/ai_reminder_plan_test.dart | 89 ++++++++++++++++++++++ test/ai_reminders_local_endpoint_test.dart | 1 + 6 files changed, 247 insertions(+), 23 deletions(-) diff --git a/lib/ai/reminder_plan.dart b/lib/ai/reminder_plan.dart index 633444867..08d95129d 100644 --- a/lib/ai/reminder_plan.dart +++ b/lib/ai/reminder_plan.dart @@ -45,10 +45,46 @@ class AiReminderSlot { /// never announced themselves. bool aiRemindersConfigured(CoachConfig? config) => config?.configured ?? false; +/// How long after the typical wake the morning briefing waits, when the sleep +/// coach has a measured wake time. The user's own [morningMin] stays the floor: +/// an early riser still gets the briefing at their configured time, a late +/// riser gets it after they actually wake instead of while still asleep. +const int kMorningBriefAfterWakeMin = 30; + +/// The morning briefing's minute-of-day: [morningMin] (the user's floor), +/// pushed out to wake + [kMorningBriefAfterWakeMin] when the typical wake is +/// known and later. Null wake (no sleep history yet) is exactly the old fixed +/// time — never an invented one. +int morningBriefSlotMin(int morningMin, double? typicalWakeMinOfDay) { + final floor = morningMin % 1440; + if (typicalWakeMinOfDay == null || !typicalWakeMinOfDay.isFinite) { + return floor; + } + final afterWake = + (typicalWakeMinOfDay.round() + kMorningBriefAfterWakeMin) % 1440; + return afterWake > floor ? afterWake : floor; +} + /// The full desired schedule for the AI nudges. Briefing slots exist only when /// the coach is configured (a notification into an "add your key" wall would /// be a nag, not a feature); the journal prompt needs no key (manual mode). /// +/// The morning briefing additionally requires [lastNightDataReady]: the slot +/// is only armed when data for the night that just ended has actually arrived +/// (a synced sample from today, or a derived day for today). A notification +/// that says "your briefing is ready" while the band has not synced yet is a +/// promise the deep link cannot keep — the same contract as the evening slot +/// below, which is armed only when the sweep found something. The check runs +/// at ARM time, never at fire time: an OS-scheduled notification fires with no +/// Dart running, so this plan is the only place the gate can live. The caller +/// re-runs it on every sync completion, so a late sync re-arms the slot the +/// moment the data lands. +/// +/// [typicalWakeMinOfDay] (the sleep coach's median wake, or null) moves the +/// morning slot to wake + [kMorningBriefAfterWakeMin] when that is later than +/// the user's configured [AiPrefs.morningMin], so a user still asleep at 08:00 +/// is not buzzed — see [morningBriefSlotMin]. +/// /// [sweepHeadline] is the nightly sweep's strongest finding for today, or null /// when it found nothing — which is most days. Null means the evening slot is /// not in the plan AT ALL: the sweep's whole contract is that it may say @@ -59,7 +95,9 @@ List aiReminderPlan( AiPrefs prefs, { required bool remindersEnabled, required bool aiConfigured, + required bool lastNightDataReady, double? bedtimeMinOfDay, + double? typicalWakeMinOfDay, required bool journalDoneToday, String? sweepHeadline, // Both OS-scheduled AI slots fire with no Dart running, same as @@ -69,8 +107,9 @@ List aiReminderPlan( }) { if (!remindersEnabled) return const []; final out = []; - if (aiConfigured && prefs.morningEnabled) { - final m = _capToQuietHours(quiet, prefs.morningMin % 1440); + if (aiConfigured && prefs.morningEnabled && lastNightDataReady) { + final m = _capToQuietHours( + quiet, morningBriefSlotMin(prefs.morningMin, typicalWakeMinOfDay)); if (m != null) { out.add(AiReminderSlot( id: NotificationService.idMorningBrief, diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart index 2b1fdb837..86ddfd6bc 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -883,17 +883,42 @@ class NotificationCenter { return slotMin > nowMin; } + /// How long after its slot the morning briefing may still fire "late" + /// instead of rolling to tomorrow — see [morningBriefLateFire]. Covers the + /// pass that runs when the morning sync lands just after the slot time. + static const int morningBriefLateGraceMin = 3 * 60; + + /// When a morning-briefing pass at [now] finds the slot already past today, + /// the instant to fire at instead of rolling to tomorrow: [now] + 2 min, + /// but only within [morningBriefLateGraceMin] of the slot. Beyond that the + /// briefing is tomorrow's — announcing last night at 14:00 is the recap + /// problem all over again. Null means "roll to tomorrow" as before. + /// + /// Only reachable when the slot was armed at all, i.e. last-night data was + /// already there — this never fires a briefing the data cannot back. + @visibleForTesting + static DateTime? morningBriefLateFire(int slotMin, DateTime now) { + final nowMin = now.hour * 60 + now.minute; + if (slotMin <= nowMin && nowMin < slotMin + morningBriefLateGraceMin) { + return now.add(const Duration(minutes: 2)); + } + return null; + } + /// Re-assert the three AI slots (morning briefing, nightly sweep, pre-sleep /// journal prompt). /// /// The nightly sweep earns the interruption the same way the weekly /// lookback does — [sweepHeadline] is a finding that already exists, /// computed on-device before this is called, and it IS the notification's - /// body. The morning briefing is armed for a BYOK user with the switch on; - /// its body is static. The journal prompt is a nudge with no finding behind - /// it; [aiReminderPlan] still describes it (it is the plan, not the policy) - /// and [NotificationService.maySchedule] still refuses it, quietly, right - /// here. + /// body. The morning briefing is armed for a BYOK user with the switch on + /// AND last night's data actually present ([lastNightDataReady] — a briefing + /// announced before the band has synced is a promise the deep link cannot + /// keep); its slot follows the typical wake when that is known + /// ([typicalWakeMinOfDay]). Its body is static. The journal prompt is a + /// nudge with no finding behind it; [aiReminderPlan] still describes it (it + /// is the plan, not the policy) and [NotificationService.maySchedule] still + /// refuses it, quietly, right here. /// /// The cancels stay unconditional: they are what clears yesterday's slot when /// today has nothing to say, and what clears whatever an older build left. @@ -901,7 +926,9 @@ class NotificationCenter { NotificationPrefs prefs, AiPrefs ai, { required bool aiConfigured, + required bool lastNightDataReady, double? bedtimeMinOfDay, + double? typicalWakeMinOfDay, required bool journalDoneToday, String? sweepHeadline, }) async { @@ -913,22 +940,29 @@ class NotificationCenter { ai, remindersEnabled: prefs.remindersEnabled, aiConfigured: aiConfigured, + lastNightDataReady: lastNightDataReady, bedtimeMinOfDay: bedtimeMinOfDay, + typicalWakeMinOfDay: typicalWakeMinOfDay, journalDoneToday: journalDoneToday, sweepHeadline: sweepHeadline, quiet: prefs, ).where((s) => NotificationService.maySchedule(s.id)).toList(); if (plan.isEmpty) return; await svc.ensureTimezone(); - final nowMin = DateTime.now().hour * 60 + DateTime.now().minute; + final now = DateTime.now(); + final nowMin = now.hour * 60 + now.minute; for (final s in plan) { if (!aiSlotArmable(s.id, s.hour * 60 + s.minute, nowMin)) continue; + var at = svc.nextDailyInstant(s.hour, s.minute); + if (s.id == NotificationService.idMorningBrief) { + at = morningBriefLateFire(s.hour * 60 + s.minute, now) ?? at; + } await svc.scheduleOnce( id: s.id, category: NotifCategory.reminders, title: s.title, body: s.body, - at: svc.nextDailyInstant(s.hour, s.minute), + at: at, route: s.route, ); } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 0d6267716..5e74fbb91 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3317,7 +3317,9 @@ class AppState extends ChangeNotifier { prefs, ai, aiConfigured: aiRemindersConfigured(coachConfig), + lastNightDataReady: await _lastNightDataReady(cd.recent), bedtimeMinOfDay: cd.bedtimeMin, + typicalWakeMinOfDay: cd.wakeMin, journalDoneToday: BriefingStore.journalDoneToday(), sweepHeadline: await _sweepHeadlineNow(), ); @@ -3327,27 +3329,35 @@ class AppState extends ChangeNotifier { } /// Everything the reminder scheduler needs from the crossday rollup, in ONE - /// read: the Sleep Coach's recommended bedtime (local minutes past midnight, - /// or null when not yet learned) and the per-day `recent[]` rows the weekly - /// lookback's finding summarizes. Read in one place because three schedules - /// hang off it — check-in, wind-down, nightly sweep, weekly lookback — and - /// a second copy of this parse is a second thing to get wrong. - Future<({double? bedtimeMin, List> recent})> - _readCrossdaySummary() async { + /// read: the Sleep Coach's recommended bedtime and typical wake (local + /// minutes past midnight, or null when not yet learned) and the per-day + /// `recent[]` rows the weekly lookback's finding summarizes. Read in one + /// place because three schedules hang off it — check-in, wind-down, nightly + /// sweep, weekly lookback — and a second copy of this parse is a second + /// thing to get wrong. + Future< + ({double? bedtimeMin, + double? wakeMin, + List> recent})> _readCrossdaySummary() async { try { final cd = await LocalDb.baseline('crossday'); final m = cd?['payload_json']; if (m is! String) { - return (bedtimeMin: null, recent: const >[]); + return (bedtimeMin: null, wakeMin: null, recent: const >[]); } final j = jsonDecode(m); if (j is! Map) { - return (bedtimeMin: null, recent: const >[]); + return (bedtimeMin: null, wakeMin: null, recent: const >[]); } - final bt = (j['sleep_coach'] as Map?)?['bedtime']; + final coach = (j['sleep_coach'] as Map?)?.cast(); + final bt = coach?['bedtime']; final v = bt is Map ? bt['value'] : null; final bedtime = (v is Map ? (v['bedtime_min_of_day'] as num?) : null)?.toDouble(); + final wk = coach?['wake']; + final wv = wk is Map ? wk['value'] : null; + final wake = + (wv is Map ? (wv['wake_min_of_day'] as num?) : null)?.toDouble(); // Same rows `DerivationEngine._runNotifications` consumes for the daily // exception — {date, rhr, unsettled, illness, anomaly, temp}. final rawRecent = j['recent']; @@ -3356,11 +3366,37 @@ class AppState extends ChangeNotifier { for (final r in rawRecent) if (r is Map) r.cast(), ]; - return (bedtimeMin: bedtime, recent: recent); + return (bedtimeMin: bedtime, wakeMin: wake, recent: recent); } catch (_) { - // No rollup → no learned bedtime and an empty week: every consumer has - // its own honest silence for that. - return (bedtimeMin: null, recent: const >[]); + // No rollup → no learned bedtime/wake and an empty week: every consumer + // has its own honest silence for that. + return (bedtimeMin: null, wakeMin: null, recent: const >[]); + } + } + + /// Whether data for the night that just ended has actually arrived: a synced + /// band sample from TODAY (local day label), or — for sources that write + /// derived days without raw samples, e.g. a cloud ring — a crossday row + /// already derived for today. This is the morning briefing's arm gate: the + /// notification promises last night's sleep, so it is armed only when that + /// data exists. False on a read failure too — the next pass (sync + /// completion, foreground resume) re-arms as soon as the data lands, and a + /// silently missing briefing beats one the deep link cannot back. + Future _lastNightDataReady(List> recent) async { + try { + final today = todayLabel(); + final s = lastSynced ?? await LocalDb.latestSample(); + if (s != null && + s.tsEpoch > 0 && + dayLabelOf(DateTime.fromMillisecondsSinceEpoch(s.tsEpoch * 1000)) == + today) { + return true; + } + final last = recent.isEmpty ? null : recent.last['date']; + return last is String && last == today; + } catch (e) { + _log('[notify] last-night data check skipped: $e'); + return false; } } diff --git a/test/ai_morning_brief_arming_test.dart b/test/ai_morning_brief_arming_test.dart index c1c54330b..f6c601c06 100644 --- a/test/ai_morning_brief_arming_test.dart +++ b/test/ai_morning_brief_arming_test.dart @@ -15,6 +15,7 @@ void main() { const AiPrefs(), remindersEnabled: const NotificationPrefs().remindersEnabled, aiConfigured: true, + lastNightDataReady: true, journalDoneToday: false, ).where((s) => NotificationService.maySchedule(s.id)); return [ @@ -38,4 +39,28 @@ void main() { expect(NotificationCenter.aiSlotArmable(id, 21 * 60, 21 * 60), isFalse); expect(NotificationCenter.aiSlotArmable(id, 21 * 60, 22 * 60), isFalse); }); + + test('a slot just past fires late within the grace window, not tomorrow', () { + // Sync landed at 08:30 for an 08:00 slot: fire ~now instead of next 08:00. + final late = + NotificationCenter.morningBriefLateFire(8 * 60, DateTime(2025, 1, 6, 8, 30)); + expect(late, isNotNull); + expect(late!.hour, 8); + expect(late.minute, 32); + }); + + test('a slot long past rolls to tomorrow — no 14:00 "morning" briefing', () { + expect( + NotificationCenter.morningBriefLateFire(8 * 60, DateTime(2025, 1, 6, 11, 0)), + isNull); + expect( + NotificationCenter.morningBriefLateFire(8 * 60, DateTime(2025, 1, 6, 23, 0)), + isNull); + }); + + test('a slot still ahead is not "late"', () { + expect( + NotificationCenter.morningBriefLateFire(8 * 60, DateTime(2025, 1, 6, 7, 59)), + isNull); + }); } diff --git a/test/ai_reminder_plan_test.dart b/test/ai_reminder_plan_test.dart index a10ea6fa1..7de8c66fd 100644 --- a/test/ai_reminder_plan_test.dart +++ b/test/ai_reminder_plan_test.dart @@ -15,6 +15,7 @@ void main() { const AiPrefs(), remindersEnabled: true, aiConfigured: true, + lastNightDataReady: true, journalDoneToday: false, sweepHeadline: 'Resting heart rate 62 bpm — the highest in 41 days', ); @@ -29,6 +30,7 @@ void main() { const AiPrefs(), remindersEnabled: true, aiConfigured: false, + lastNightDataReady: true, journalDoneToday: false, sweepHeadline: 'Steps 21,400 — the highest in 60 days', ); @@ -43,6 +45,7 @@ void main() { const AiPrefs(), remindersEnabled: false, aiConfigured: true, + lastNightDataReady: true, journalDoneToday: false, ); expect(plan, isEmpty); @@ -53,6 +56,7 @@ void main() { const AiPrefs(morningEnabled: false, journalEnabled: false), remindersEnabled: true, aiConfigured: true, + lastNightDataReady: true, journalDoneToday: false, sweepHeadline: 'HRV 28 ms — the lowest in 34 days', ); @@ -65,6 +69,7 @@ void main() { const AiPrefs(), remindersEnabled: true, aiConfigured: true, + lastNightDataReady: true, journalDoneToday: true, ).firstWhere((s) => s.id == NotificationService.idJournalLog); expect(done.skipToday, isTrue); @@ -75,6 +80,7 @@ void main() { const AiPrefs(), // journalMin defaults to AUTO remindersEnabled: true, aiConfigured: true, + lastNightDataReady: true, bedtimeMinOfDay: 23 * 60, // 23:00 journalDoneToday: false, ).firstWhere((s) => s.id == NotificationService.idJournalLog); @@ -88,6 +94,7 @@ void main() { const AiPrefs(journalMin: 21 * 60 + 15), remindersEnabled: true, aiConfigured: true, + lastNightDataReady: true, bedtimeMinOfDay: 23 * 60, journalDoneToday: false, ).firstWhere((s) => s.id == NotificationService.idJournalLog); @@ -100,6 +107,7 @@ void main() { const AiPrefs(), remindersEnabled: true, aiConfigured: true, + lastNightDataReady: true, journalDoneToday: false, sweepHeadline: 'Readiness 31 — below your usual range', ); @@ -118,6 +126,7 @@ void main() { const AiPrefs(), remindersEnabled: true, aiConfigured: true, + lastNightDataReady: true, journalDoneToday: false, ); expect(plan.map((s) => s.id), @@ -130,6 +139,7 @@ void main() { const AiPrefs(), remindersEnabled: true, aiConfigured: true, + lastNightDataReady: true, journalDoneToday: false, sweepHeadline: headline, ).firstWhere((s) => s.id == NotificationService.idEveningBrief); @@ -141,6 +151,7 @@ void main() { const AiPrefs(), // eveningMin default 20:00 — the fallback, not the time remindersEnabled: true, aiConfigured: true, + lastNightDataReady: true, bedtimeMinOfDay: 21 * 60 + 15, // 21:15 — well clear of quiet hours journalDoneToday: false, sweepHeadline: 'x', @@ -157,6 +168,7 @@ void main() { const AiPrefs(), remindersEnabled: true, aiConfigured: true, + lastNightDataReady: true, bedtimeMinOfDay: 23 * 60 + 30, journalDoneToday: false, sweepHeadline: 'x', @@ -174,6 +186,7 @@ void main() { const AiPrefs(), remindersEnabled: true, aiConfigured: true, + lastNightDataReady: true, bedtimeMinOfDay: 23 * 60 + 30, journalDoneToday: false, sweepHeadline: 'x', @@ -189,6 +202,7 @@ void main() { const AiPrefs(), remindersEnabled: true, aiConfigured: true, + lastNightDataReady: true, journalDoneToday: false, sweepHeadline: 'x', ).firstWhere((s) => s.id == NotificationService.idEveningBrief); @@ -282,4 +296,79 @@ void main() { AiPrefs.eveningLatestMin); }); }); + + group('morning briefing waits for last night\'s data', () { + test('no data for the night that just ended, no slot', () { + final plan = aiReminderPlan( + const AiPrefs(), + remindersEnabled: true, + aiConfigured: true, + lastNightDataReady: false, + journalDoneToday: false, + ); + expect(plan.map((s) => s.id), + isNot(contains(NotificationService.idMorningBrief))); + }); + + test('data ready arms the slot; the other AI slots are unaffected', () { + final plan = aiReminderPlan( + const AiPrefs(), + remindersEnabled: true, + aiConfigured: true, + lastNightDataReady: true, + journalDoneToday: false, + sweepHeadline: 'x', + ); + final ids = plan.map((s) => s.id).toSet(); + expect(ids, contains(NotificationService.idMorningBrief)); + expect(ids, contains(NotificationService.idEveningBrief)); + expect(ids, contains(NotificationService.idJournalLog)); + }); + }); + + group('morningBriefSlotMin follows the typical wake', () { + test('no learned wake keeps the configured time', () { + expect(morningBriefSlotMin(8 * 60, null), 8 * 60); + }); + + test('an early wake keeps the configured floor', () { + // Wake 06:00 + 30 = 06:30, but the user asked for nothing before 08:00. + expect(morningBriefSlotMin(8 * 60, 6 * 60), 8 * 60); + }); + + test('a late wake pushes the slot past it', () { + // Wake 09:30 -> 10:00, so a user still asleep at 08:00 is not buzzed. + expect(morningBriefSlotMin(8 * 60, 9 * 60 + 30), 10 * 60); + }); + + test('the slot lands inside the plan, quiet-hours cap still applies', () { + final slot = aiReminderPlan( + const AiPrefs(), + remindersEnabled: true, + aiConfigured: true, + lastNightDataReady: true, + typicalWakeMinOfDay: 9 * 60 + 30, + journalDoneToday: false, + ).firstWhere((s) => s.id == NotificationService.idMorningBrief); + expect(slot.hour, 10); + expect(slot.minute, 0); + }); + + test('a wake-derived slot inside quiet hours is dropped, not moved', () { + final plan = aiReminderPlan( + const AiPrefs(morningMin: 0), + remindersEnabled: true, + aiConfigured: true, + lastNightDataReady: true, + typicalWakeMinOfDay: 23 * 60 + 45, // wake+30 = 00:15, inside 22:00-07:00 + journalDoneToday: false, + quiet: const NotificationPrefs( + quietEnabled: true, + quietStartMin: 22 * 60, + quietEndMin: 7 * 60), + ); + expect(plan.map((s) => s.id), + isNot(contains(NotificationService.idMorningBrief))); + }); + }); } diff --git a/test/ai_reminders_local_endpoint_test.dart b/test/ai_reminders_local_endpoint_test.dart index 348d0c44d..679036a30 100644 --- a/test/ai_reminders_local_endpoint_test.dart +++ b/test/ai_reminders_local_endpoint_test.dart @@ -19,6 +19,7 @@ void main() { const AiPrefs(morningEnabled: true, eveningEnabled: true), remindersEnabled: true, aiConfigured: aiRemindersConfigured(cfg), + lastNightDataReady: true, journalDoneToday: false, sweepHeadline: 'HRV well below your usual', )) From a2b562b73078ce534519bf0bcaa2f0c2a453637f Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 13 Nov 2026 07:26:17 +0000 Subject: [PATCH 02/14] address coderabbit review on morning-brief arming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aiSlotArmable: the morning slot arms for TODAY's occurrence only (slot ahead, or inside the late-fire grace window); a later pass no longer arms tomorrow off today's readiness — tomorrow's pass establishes it. - scheduleAiReminders: persistent per-day "announced" guard (FiredKeyStore, key morning_brief_announced:) so repeated passes within the grace window neither cancel a pending occurrence nor arm a second alert after it fired; the guard applies to the scheduleOnce path, not just emit. - The late-fire instant is itself checked against quiet hours before arming; a catch-up landing inside the protected window is dropped. - _onDataStored re-arms the AI reminders after every durable sync store, so a morning sync arms the briefing even without a foreground pass. - _lastNightDataReady queries LocalDb.latestSample() before falling back to the cached lastSynced, which can hold yesterday's sample. Co-authored-by: BucciMobile --- lib/notify/notification_center.dart | 59 +++++++++++++++++++++----- lib/state/app_state.dart | 10 ++++- test/ai_morning_brief_arming_test.dart | 19 +++++++-- 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart index 86ddfd6bc..4dc5379d8 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -872,14 +872,19 @@ class NotificationCenter { /// about a day it did not happen on. Nothing is armed instead — this re-runs /// on the next foreground pass, which is where the finding is recomputed. /// - /// The morning briefing's body names no day, so it rolls to tomorrow. With - /// the same today-only rule it was armed only by a pass before its time: - /// a user whose last foreground of the day came after 08:00 (and on - /// Android the foreground service keeps the process alive overnight, so - /// init never re-runs) got no briefing the next morning. + /// The morning briefing is armed for TODAY's occurrence only: while its + /// slot is still ahead, or inside the late-fire grace window just past it. + /// A pass later in the day must NOT arm tomorrow — tomorrow's readiness is + /// established by tomorrow's own pass, and arming ahead would let the OS + /// fire a briefing whose overnight data never arrived. (Before the + /// last-night-data gate, rolling to tomorrow was the fix for a user whose + /// last foreground pass came after the slot; the data gate makes that safe + /// to drop.) @visibleForTesting static bool aiSlotArmable(int id, int slotMin, int nowMin) { - if (id == NotificationService.idMorningBrief) return true; + if (id == NotificationService.idMorningBrief) { + return slotMin > nowMin || nowMin < slotMin + morningBriefLateGraceMin; + } return slotMin > nowMin; } @@ -920,8 +925,16 @@ class NotificationCenter { /// is the plan, not the policy) and [NotificationService.maySchedule] still /// refuses it, quietly, right here. /// - /// The cancels stay unconditional: they are what clears yesterday's slot when - /// today has nothing to say, and what clears whatever an older build left. + /// The evening and journal cancels stay unconditional: they are what clears + /// yesterday's slot when today has nothing to say, and what clears whatever + /// an older build left. The morning cancel is conditional on the + /// announced-today guard below. + /// + /// Dedupe-key prefix for "today's morning-briefing occurrence was already + /// announced (armed)". Date-prefixed like the emit path's fired keys, so + /// tomorrow is a fresh key and the store prunes itself. + static const String morningBriefAnnouncedPrefix = 'morning_brief_announced:'; + Future scheduleAiReminders( NotificationPrefs prefs, AiPrefs ai, { @@ -933,7 +946,18 @@ class NotificationCenter { String? sweepHeadline, }) async { final svc = NotificationService.instance; - await svc.cancel(NotificationService.idMorningBrief); + final now = DateTime.now(); + final today = todayLabel(now); + // A morning occurrence already announced today must not be cancelled and + // re-armed by a later pass: re-showing the OS id only replaces a pending + // post, and a one-shot that already fired is gone from the pending list, + // so without this persistent guard every pass inside the grace window + // would schedule another alert for the same day. + final morningAnnounced = await const FiredKeyStore() + .hasFired('$morningBriefAnnouncedPrefix$today'); + if (!morningAnnounced) { + await svc.cancel(NotificationService.idMorningBrief); + } await svc.cancel(NotificationService.idEveningBrief); await svc.cancel(NotificationService.idJournalLog); final plan = aiReminderPlan( @@ -949,13 +973,22 @@ class NotificationCenter { ).where((s) => NotificationService.maySchedule(s.id)).toList(); if (plan.isEmpty) return; await svc.ensureTimezone(); - final now = DateTime.now(); final nowMin = now.hour * 60 + now.minute; for (final s in plan) { if (!aiSlotArmable(s.id, s.hour * 60 + s.minute, nowMin)) continue; + if (s.id == NotificationService.idMorningBrief && morningAnnounced) { + continue; + } var at = svc.nextDailyInstant(s.hour, s.minute); if (s.id == NotificationService.idMorningBrief) { - at = morningBriefLateFire(s.hour * 60 + s.minute, now) ?? at; + final late = morningBriefLateFire(s.hour * 60 + s.minute, now); + if (late != null) { + // The plan checked the SLOT against quiet hours, but the late-fire + // instant is a different minute — check it itself, and never arm a + // catch-up that lands inside the protected window. + if (prefs.inQuietHours(late.hour * 60 + late.minute)) continue; + at = late; + } } await svc.scheduleOnce( id: s.id, @@ -965,6 +998,10 @@ class NotificationCenter { at: at, route: s.route, ); + if (s.id == NotificationService.idMorningBrief) { + await const FiredKeyStore() + .recordFired('$morningBriefAnnouncedPrefix$today'); + } } } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 5e74fbb91..65cdb81ee 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -2968,6 +2968,11 @@ class AppState extends ChangeNotifier { } notifyListeners(); _deriveScheduler.markStoredData(); + // A sync that just stored last night's data is exactly when the morning + // briefing's readiness gate can flip to true — re-arm the AI reminders + // now, on every durable store path, so a morning sync arms the slot + // even when no foreground pass or alarm event follows. + unawaited(_ensureRemindersScheduled()); }()); } @@ -3385,7 +3390,10 @@ class AppState extends ChangeNotifier { Future _lastNightDataReady(List> recent) async { try { final today = todayLabel(); - final s = lastSynced ?? await LocalDb.latestSample(); + // Query the DB first: the cached `lastSynced` is only refreshed at + // startup and on wipe, so it can hold yesterday's sample and would hide + // a freshly synced one from the `??` fallback. + final s = await LocalDb.latestSample() ?? lastSynced; if (s != null && s.tsEpoch > 0 && dayLabelOf(DateTime.fromMillisecondsSinceEpoch(s.tsEpoch * 1000)) == diff --git a/test/ai_morning_brief_arming_test.dart b/test/ai_morning_brief_arming_test.dart index f6c601c06..1d841d498 100644 --- a/test/ai_morning_brief_arming_test.dart +++ b/test/ai_morning_brief_arming_test.dart @@ -1,5 +1,6 @@ -// The morning briefing must be armed for tomorrow by a pass that runs after -// its time today; the nightly sweep must not roll over. +// The morning briefing arms for TODAY only — ahead of its slot, or inside the +// late-fire grace window just past it — so a later pass never arms tomorrow's +// occurrence off today's data readiness; the nightly sweep is today-only too. import 'package:flutter_test/flutter_test.dart'; @@ -26,13 +27,23 @@ void main() { ]; } - test('morning briefing is armed whatever time the last pass ran', () { - for (final now in [6 * 60, 8 * 60, 9 * 60, 12 * 60, 21 * 60, 23 * 60 + 59]) { + test('morning briefing arms for today only: ahead of the slot or in grace', () { + // Slot is 08:00; the grace window reaches 11:00. + for (final now in [6 * 60, 7 * 60 + 59, 8 * 60, 9 * 60, 10 * 60 + 59]) { expect(armedAt(now), contains(NotificationService.idMorningBrief), reason: 'pass at minute $now'); } }); + test('past the grace window the morning slot is NOT armed for tomorrow', () { + // A pass at 14:00 must not arm tomorrow 08:00: tomorrow's readiness is + // established by tomorrow's own pass, not reused from today. + for (final now in [11 * 60, 12 * 60, 21 * 60, 23 * 60 + 59]) { + expect(armedAt(now), isNot(contains(NotificationService.idMorningBrief)), + reason: 'pass at minute $now'); + } + }); + test('evening sweep is still today-only', () { const id = NotificationService.idEveningBrief; expect(NotificationCenter.aiSlotArmable(id, 21 * 60, 20 * 60), isTrue); From 1bc6709f12e9c0860483c49bd0ce4804a43a1760 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 13 Nov 2026 07:36:38 +0000 Subject: [PATCH 03/14] address second coderabbit review round on morning-brief arming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cancel a pending morning briefing when the plan excludes it (switch off, no data, quiet hours) even if today's occurrence was already announced — a disabled preference wins over the anti-repeat guard. The pending slot is only preserved when it is both in the plan and already announced. - scheduleOnce now returns whether the OS actually accepted the registration (false on refused id, denied permission, plugin throw); the daily morning_brief_announced guard is recorded only on success, so a transient scheduling failure does not suppress the briefing for the day. Co-authored-by: BucciMobile --- lib/notify/notification_center.dart | 29 +++++++++++++++++++--------- lib/notify/notification_service.dart | 17 ++++++++++++---- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart index 4dc5379d8..d8063b2f7 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -927,8 +927,9 @@ class NotificationCenter { /// /// The evening and journal cancels stay unconditional: they are what clears /// yesterday's slot when today has nothing to say, and what clears whatever - /// an older build left. The morning cancel is conditional on the - /// announced-today guard below. + /// an older build left. The morning cancel happens unless today's occurrence + /// is both in the plan AND already announced (then the pending one is + /// preserved, not re-armed). /// /// Dedupe-key prefix for "today's morning-briefing occurrence was already /// announced (armed)". Date-prefixed like the emit path's fired keys, so @@ -955,11 +956,6 @@ class NotificationCenter { // would schedule another alert for the same day. final morningAnnounced = await const FiredKeyStore() .hasFired('$morningBriefAnnouncedPrefix$today'); - if (!morningAnnounced) { - await svc.cancel(NotificationService.idMorningBrief); - } - await svc.cancel(NotificationService.idEveningBrief); - await svc.cancel(NotificationService.idJournalLog); final plan = aiReminderPlan( ai, remindersEnabled: prefs.remindersEnabled, @@ -971,6 +967,18 @@ class NotificationCenter { sweepHeadline: sweepHeadline, quiet: prefs, ).where((s) => NotificationService.maySchedule(s.id)).toList(); + // Cancel the pending morning briefing whenever it is not being preserved: + // when the plan excludes it (switch off, no data, quiet hours) the cancel + // must happen EVEN IF today's occurrence was already announced — a disabled + // preference wins over the anti-repeat guard — and when it will be armed + // fresh below, the cancel clears whatever an older build left. + final morningInPlan = + plan.any((s) => s.id == NotificationService.idMorningBrief); + if (!morningInPlan || !morningAnnounced) { + await svc.cancel(NotificationService.idMorningBrief); + } + await svc.cancel(NotificationService.idEveningBrief); + await svc.cancel(NotificationService.idJournalLog); if (plan.isEmpty) return; await svc.ensureTimezone(); final nowMin = now.hour * 60 + now.minute; @@ -990,7 +998,7 @@ class NotificationCenter { at = late; } } - await svc.scheduleOnce( + final armed = await svc.scheduleOnce( id: s.id, category: NotifCategory.reminders, title: s.title, @@ -998,7 +1006,10 @@ class NotificationCenter { at: at, route: s.route, ); - if (s.id == NotificationService.idMorningBrief) { + // Record the daily guard only when the OS actually accepted the + // registration — a denied permission or a failed zonedSchedule must not + // suppress the briefing for the rest of the day. + if (armed && s.id == NotificationService.idMorningBrief) { await const FiredKeyStore() .recordFired('$morningBriefAnnouncedPrefix$today'); } diff --git a/lib/notify/notification_service.dart b/lib/notify/notification_service.dart index 115bec5e7..6d03a5833 100644 --- a/lib/notify/notification_service.dart +++ b/lib/notify/notification_service.dart @@ -622,7 +622,13 @@ class NotificationService { /// This is how the weekly lookback is armed — once, for a week that actually /// found something. (It also still carries the "time to move" nudge's call, /// which [schedulableIds] now refuses.) - Future scheduleOnce({ + /// + /// Returns whether the one-shot was actually REGISTERED with the OS: false + /// when the id is not schedulable, when permission is denied, or when the + /// plugin throws. Callers that record "this was armed" state (e.g. the + /// morning-briefing daily guard) must key off this — recording on a refused + /// or failed registration would suppress the alert for the rest of the day. + Future scheduleOnce({ required int id, required NotifCategory category, required String title, @@ -631,8 +637,8 @@ class NotificationService { String? route, }) async { try { - if (!_maySchedule(id)) return; - if (!await ensurePermission(allowPrompt: false)) return; + if (!_maySchedule(id)) return false; + if (!await ensurePermission(allowPrompt: false)) return false; final when = tz.TZDateTime.from(at, tz.local); await _plugin.zonedSchedule( id, @@ -645,7 +651,10 @@ class NotificationService { UILocalNotificationDateInterpretation.absoluteTime, payload: route, ); - } catch (_) {} + return true; + } catch (_) { + return false; + } } Future cancel(int id) async { From a92181ff9bfdd99def8561147fb13f2fd2235a5b Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 13 Nov 2026 07:47:16 +0000 Subject: [PATCH 04/14] address third coderabbit review round on morning-brief arming - reminder_plan: the quiet-hours cap may not move a wake-derived morning slot earlier than its own wake+30 minimum (wake 21:30, quiet from 22:00 would cap 22:00 to 21:30 = exactly at wake); such a slot is omitted. A plain configured time keeps the shared cap-then-refuse semantics. - scheduleAiReminders is now serialized through _synchronized like scheduleStandingReminders, so the announced-guard read, cancels, registration and guard write run as one critical section. - Pending vs delivered are tracked separately: pendingIds() distinguishes an armed one-shot from a fired one (no longer pending). A pending occurrence is preserved; a delivered one is not re-armed; when the plan drops the morning slot the announced key is released, so re-enabling the switch or a transient readiness failure can arm it again. - The clock is re-read after the awaits before resolving the occurrence, so a pass crossing the slot boundary mid-method cannot arm tomorrow with today's readiness. - _lastNightDataReady skips an unsettled crossday row, matching the weekly lookback's rule. Co-authored-by: BucciMobile --- lib/ai/reminder_plan.dart | 12 +++- lib/notify/notification_center.dart | 103 +++++++++++++++++++-------- lib/notify/notification_service.dart | 12 ++++ lib/state/app_state.dart | 9 ++- test/ai_reminder_plan_test.dart | 38 ++++++++++ 5 files changed, 141 insertions(+), 33 deletions(-) diff --git a/lib/ai/reminder_plan.dart b/lib/ai/reminder_plan.dart index 08d95129d..52c229a6c 100644 --- a/lib/ai/reminder_plan.dart +++ b/lib/ai/reminder_plan.dart @@ -108,8 +108,16 @@ List aiReminderPlan( if (!remindersEnabled) return const []; final out = []; if (aiConfigured && prefs.morningEnabled && lastNightDataReady) { - final m = _capToQuietHours( - quiet, morningBriefSlotMin(prefs.morningMin, typicalWakeMinOfDay)); + final floor = prefs.morningMin % 1440; + final slot = morningBriefSlotMin(prefs.morningMin, typicalWakeMinOfDay); + var m = _capToQuietHours(quiet, slot); + // The quiet-hours cap can pull the slot EARLIER than the wake-derived + // minimum (wake 21:30 → slot 22:00, quiet from 22:00 → capped to 21:30, + // i.e. exactly at wake). A briefing at wake time is not "after wake": when + // the cap moves a wake-derived slot below its own minimum, quiet hours + // have prevented a valid slot — omit it rather than fire early. A plain + // configured time keeps the shared cap-then-refuse semantics. + if (m != null && slot > floor && m < slot) m = null; if (m != null) { out.add(AiReminderSlot( id: NotificationService.idMorningBrief, diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart index d8063b2f7..8be87b142 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -927,15 +927,19 @@ class NotificationCenter { /// /// The evening and journal cancels stay unconditional: they are what clears /// yesterday's slot when today has nothing to say, and what clears whatever - /// an older build left. The morning cancel happens unless today's occurrence - /// is both in the plan AND already announced (then the pending one is - /// preserved, not re-armed). + /// an older build left. The morning cancel is the exception — see below. /// - /// Dedupe-key prefix for "today's morning-briefing occurrence was already - /// announced (armed)". Date-prefixed like the emit path's fired keys, so + /// Dedupe-key prefix for "today's morning-briefing occurrence is armed (or + /// was delivered)". Date-prefixed like the emit path's fired keys, so /// tomorrow is a fresh key and the store prunes itself. static const String morningBriefAnnouncedPrefix = 'morning_brief_announced:'; + /// Public entry point — serialized through [_synchronized] exactly like + /// [scheduleStandingReminders]: the guard read, the cancels, the registration + /// and the guard write must run as ONE critical section, or two overlapping + /// passes (foreground resume racing a sync-completion callback) can both read + /// "not announced", one cancel the other's pending registration, and leave + /// the day without a briefing. Future scheduleAiReminders( NotificationPrefs prefs, AiPrefs ai, { @@ -945,17 +949,29 @@ class NotificationCenter { double? typicalWakeMinOfDay, required bool journalDoneToday, String? sweepHeadline, + }) => + _synchronized(() => _scheduleAiReminders( + prefs, + ai, + aiConfigured: aiConfigured, + lastNightDataReady: lastNightDataReady, + bedtimeMinOfDay: bedtimeMinOfDay, + typicalWakeMinOfDay: typicalWakeMinOfDay, + journalDoneToday: journalDoneToday, + sweepHeadline: sweepHeadline, + )); + + Future _scheduleAiReminders( + NotificationPrefs prefs, + AiPrefs ai, { + required bool aiConfigured, + required bool lastNightDataReady, + double? bedtimeMinOfDay, + double? typicalWakeMinOfDay, + required bool journalDoneToday, + String? sweepHeadline, }) async { final svc = NotificationService.instance; - final now = DateTime.now(); - final today = todayLabel(now); - // A morning occurrence already announced today must not be cancelled and - // re-armed by a later pass: re-showing the OS id only replaces a pending - // post, and a one-shot that already fired is gone from the pending list, - // so without this persistent guard every pass inside the grace window - // would schedule another alert for the same day. - final morningAnnounced = await const FiredKeyStore() - .hasFired('$morningBriefAnnouncedPrefix$today'); final plan = aiReminderPlan( ai, remindersEnabled: prefs.remindersEnabled, @@ -967,29 +983,59 @@ class NotificationCenter { sweepHeadline: sweepHeadline, quiet: prefs, ).where((s) => NotificationService.maySchedule(s.id)).toList(); - // Cancel the pending morning briefing whenever it is not being preserved: - // when the plan excludes it (switch off, no data, quiet hours) the cancel - // must happen EVEN IF today's occurrence was already announced — a disabled - // preference wins over the anti-repeat guard — and when it will be armed - // fresh below, the cancel clears whatever an older build left. - final morningInPlan = - plan.any((s) => s.id == NotificationService.idMorningBrief); - if (!morningInPlan || !morningAnnounced) { + AiReminderSlot? morningSlot; + for (final s in plan) { + if (s.id == NotificationService.idMorningBrief) morningSlot = s; + } + await svc.ensureTimezone(); + // Read the clock AFTER the awaits above: a pass that captured `now` at + // 07:59:59 must not resolve tomorrow's occurrence with today's readiness + // just because the intervening awaits crossed the slot boundary. + final now = DateTime.now(); + final today = todayLabel(now); + final nowMin = now.hour * 60 + now.minute; + // A morning occurrence already announced today must not be re-armed by a + // later pass: re-showing the OS id only replaces a pending post, and a + // one-shot that already fired is gone from the pending list, so without + // this persistent guard every pass inside the grace window would schedule + // another alert for the same day. + final announcedKey = '$morningBriefAnnouncedPrefix$today'; + final morningAnnounced = + await const FiredKeyStore().hasFired(announcedKey); + // Pending vs delivered: a one-shot that already fired is no longer pending. + final hasPendingMorning = morningSlot != null && + (await svc.pendingIds()).contains(NotificationService.idMorningBrief); + var morningPreserved = false; + if (morningSlot == null) { + // Not armed by this pass (switch off, no data, quiet hours): cancel any + // pending one — a disabled preference wins over the anti-repeat guard — + // and RELEASE the announced key, so a later eligible pass (e.g. the user + // re-enabling the switch, or a transient readiness failure resolving) can + // arm the occurrence again. + await svc.cancel(NotificationService.idMorningBrief); + await const FiredKeyStore().release(announcedKey); + } else if (morningAnnounced && + (hasPendingMorning || + morningSlot.hour * 60 + morningSlot.minute <= nowMin)) { + // Today's occurrence is pending (preserve it) or already delivered (slot + // past and no longer pending) — neither is cancelled nor re-armed. + morningPreserved = true; + } else { + // Fresh arm below (or the pending one was lost while still ahead). await svc.cancel(NotificationService.idMorningBrief); } await svc.cancel(NotificationService.idEveningBrief); await svc.cancel(NotificationService.idJournalLog); if (plan.isEmpty) return; - await svc.ensureTimezone(); - final nowMin = now.hour * 60 + now.minute; for (final s in plan) { - if (!aiSlotArmable(s.id, s.hour * 60 + s.minute, nowMin)) continue; - if (s.id == NotificationService.idMorningBrief && morningAnnounced) { + final slotMin = s.hour * 60 + s.minute; + if (!aiSlotArmable(s.id, slotMin, nowMin)) continue; + if (s.id == NotificationService.idMorningBrief && morningPreserved) { continue; } var at = svc.nextDailyInstant(s.hour, s.minute); if (s.id == NotificationService.idMorningBrief) { - final late = morningBriefLateFire(s.hour * 60 + s.minute, now); + final late = morningBriefLateFire(slotMin, now); if (late != null) { // The plan checked the SLOT against quiet hours, but the late-fire // instant is a different minute — check it itself, and never arm a @@ -1010,8 +1056,7 @@ class NotificationCenter { // registration — a denied permission or a failed zonedSchedule must not // suppress the briefing for the rest of the day. if (armed && s.id == NotificationService.idMorningBrief) { - await const FiredKeyStore() - .recordFired('$morningBriefAnnouncedPrefix$today'); + await const FiredKeyStore().recordFired(announcedKey); } } } diff --git a/lib/notify/notification_service.dart b/lib/notify/notification_service.dart index 6d03a5833..d7883114d 100644 --- a/lib/notify/notification_service.dart +++ b/lib/notify/notification_service.dart @@ -663,6 +663,18 @@ class NotificationService { } catch (_) {} } + /// Ids of the currently PENDING (armed, not yet fired) OS notifications. + /// A one-shot that already fired is no longer in this list — that is the + /// only way Dart can tell "armed" from "delivered" for a scheduled slot. + Future> pendingIds() async { + try { + final reqs = await _plugin.pendingNotificationRequests(); + return reqs.map((r) => r.id).toSet(); + } catch (_) { + return const {}; + } + } + /// Drop every notification this app has scheduled or posted. /// /// Part of "Delete everything": a scheduled alarm or wind-down reminder that diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 65cdb81ee..e034fa212 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3400,8 +3400,13 @@ class AppState extends ChangeNotifier { today) { return true; } - final last = recent.isEmpty ? null : recent.last['date']; - return last is String && last == today; + // The crossday pipeline flags today's row `unsettled` while the night is + // still settling — same rows the weekly lookback skips. A row that is + // not settled yet is not "last night's data arrived". + final last = recent.isEmpty ? null : recent.last; + return last is Map && + last['date'] == today && + last['unsettled'] != true; } catch (e) { _log('[notify] last-night data check skipped: $e'); return false; diff --git a/test/ai_reminder_plan_test.dart b/test/ai_reminder_plan_test.dart index 7de8c66fd..3f7017bc8 100644 --- a/test/ai_reminder_plan_test.dart +++ b/test/ai_reminder_plan_test.dart @@ -354,6 +354,44 @@ void main() { expect(slot.minute, 0); }); + test('quiet-hours cap never moves a wake-derived slot before wake+30', () { + // Wake 21:30 → slot 22:00; quiet from 22:00 would cap it to 21:30, i.e. + // exactly AT wake — that is not "after wake", so the slot is omitted. + final plan = aiReminderPlan( + const AiPrefs(morningMin: 8 * 60), + remindersEnabled: true, + aiConfigured: true, + lastNightDataReady: true, + typicalWakeMinOfDay: 21 * 60 + 30, + journalDoneToday: false, + quiet: const NotificationPrefs( + quietEnabled: true, + quietStartMin: 22 * 60, + quietEndMin: 7 * 60), + ); + expect(plan.map((s) => s.id), + isNot(contains(NotificationService.idMorningBrief))); + }); + + test('a plain configured time keeps the shared cap-then-refuse rule', () { + // No learned wake: slot 08:00, quiet 08:30-09:00 caps nothing (cap is + // 08:00, slot is not past it, not inside the window) — still armed. + final plan = aiReminderPlan( + const AiPrefs(morningMin: 8 * 60), + remindersEnabled: true, + aiConfigured: true, + lastNightDataReady: true, + journalDoneToday: false, + quiet: const NotificationPrefs( + quietEnabled: true, + quietStartMin: 8 * 60 + 30, + quietEndMin: 9 * 60), + ); + final slot = plan + .firstWhere((s) => s.id == NotificationService.idMorningBrief); + expect(slot.hour * 60 + slot.minute, 8 * 60); + }); + test('a wake-derived slot inside quiet hours is dropped, not moved', () { final plan = aiReminderPlan( const AiPrefs(morningMin: 0), From dd92b03ea6e78868fa967883299c216974afbaea Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 13 Nov 2026 08:12:40 +0000 Subject: [PATCH 05/14] fix CI compile error + fourth coderabbit round on morning-brief arming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app_state: _lastNightDataReady now reads recent.last without a nullable intermediate (the `last is Map` promotion did not hold, breaking the analyzer/test compile in CI). - notification_center: release the daily announced key only when the cancelled registration was still pending (undelivered); after delivery the guard is retained, so disable+re-enable inside the grace window cannot alert twice. The armed slot minute is recorded alongside, and a pending occurrence is preserved only when it matches the current plan slot — a changed requested/wake-derived time replaces the pending notification. Co-authored-by: BucciMobile --- lib/notify/notification_center.dart | 47 ++++++++++++++++++++++------- lib/state/app_state.dart | 7 ++--- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart index 8be87b142..a6b0e30b6 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -1003,25 +1003,46 @@ class NotificationCenter { final morningAnnounced = await const FiredKeyStore().hasFired(announcedKey); // Pending vs delivered: a one-shot that already fired is no longer pending. - final hasPendingMorning = morningSlot != null && - (await svc.pendingIds()).contains(NotificationService.idMorningBrief); + final hasPendingMorning = (await svc.pendingIds()) + .contains(NotificationService.idMorningBrief); + // The slot minute the pending registration was armed for, so a changed + // requested time (user setting or wake-derived) replaces the pending one + // instead of being preserved by the anti-repeat guard. + final armedSlotKey = 'morning_brief_slot:$today'; + int? armedSlot; + try { + final p = await SharedPreferences.getInstance(); + await p.reload(); + armedSlot = p.getInt(armedSlotKey); + } catch (_) {} + final slotMin = + morningSlot == null ? null : morningSlot.hour * 60 + morningSlot.minute; var morningPreserved = false; if (morningSlot == null) { // Not armed by this pass (switch off, no data, quiet hours): cancel any // pending one — a disabled preference wins over the anti-repeat guard — - // and RELEASE the announced key, so a later eligible pass (e.g. the user - // re-enabling the switch, or a transient readiness failure resolving) can - // arm the occurrence again. + // but release the announced key ONLY when that registration was still + // undelivered (pending). If the occurrence already fired (no longer + // pending), the guard stays, so a re-enable inside the grace window + // cannot alert a second time; and if nothing was ever armed there is no + // key to release anyway. await svc.cancel(NotificationService.idMorningBrief); - await const FiredKeyStore().release(announcedKey); + if (hasPendingMorning) { + await const FiredKeyStore().release(announcedKey); + try { + final p = await SharedPreferences.getInstance(); + await p.remove(armedSlotKey); + } catch (_) {} + } } else if (morningAnnounced && - (hasPendingMorning || - morningSlot.hour * 60 + morningSlot.minute <= nowMin)) { - // Today's occurrence is pending (preserve it) or already delivered (slot - // past and no longer pending) — neither is cancelled nor re-armed. + ((slotMin! <= nowMin && !hasPendingMorning) || // delivered already + (hasPendingMorning && armedSlot == slotMin))) { + // Delivered occurrence, or pending one matching the current plan slot: + // neither is cancelled nor re-armed. morningPreserved = true; } else { - // Fresh arm below (or the pending one was lost while still ahead). + // Fresh arm below, pending lost while still ahead, or the requested time + // changed (pending slot != plan slot) — replace the pending one. await svc.cancel(NotificationService.idMorningBrief); } await svc.cancel(NotificationService.idEveningBrief); @@ -1057,6 +1078,10 @@ class NotificationCenter { // suppress the briefing for the rest of the day. if (armed && s.id == NotificationService.idMorningBrief) { await const FiredKeyStore().recordFired(announcedKey); + try { + final p = await SharedPreferences.getInstance(); + await p.setInt(armedSlotKey, s.hour * 60 + s.minute); + } catch (_) {} } } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index e034fa212..19e7b8c42 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3403,10 +3403,9 @@ class AppState extends ChangeNotifier { // The crossday pipeline flags today's row `unsettled` while the night is // still settling — same rows the weekly lookback skips. A row that is // not settled yet is not "last night's data arrived". - final last = recent.isEmpty ? null : recent.last; - return last is Map && - last['date'] == today && - last['unsettled'] != true; + if (recent.isEmpty) return false; + final last = recent.last; + return last['date'] == today && last['unsettled'] != true; } catch (e) { _log('[notify] last-night data check skipped: $e'); return false; From bab733a4676c79c791e0fd80489f0f66bfed6671 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 13 Nov 2026 08:32:36 +0000 Subject: [PATCH 06/14] track the armed morning slot via FiredKeyStore, not a new prefs key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pref_types_guard test (on main, so active in the PR merge ref) requires every runtime-built SharedPreferences key to be listed in its _dynamicReads table. Encode the armed slot minute in the date-prefixed fired-store key ('morning_brief_slot::') instead of a new prefs int — those reads are already covered by the fired_keys.dart dynamic entry, and the keys prune with the store. Co-authored-by: BucciMobile --- lib/notify/notification_center.dart | 31 ++++++++++------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart index a6b0e30b6..0146af5fe 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -1005,18 +1005,16 @@ class NotificationCenter { // Pending vs delivered: a one-shot that already fired is no longer pending. final hasPendingMorning = (await svc.pendingIds()) .contains(NotificationService.idMorningBrief); - // The slot minute the pending registration was armed for, so a changed - // requested time (user setting or wake-derived) replaces the pending one - // instead of being preserved by the anti-repeat guard. - final armedSlotKey = 'morning_brief_slot:$today'; - int? armedSlot; - try { - final p = await SharedPreferences.getInstance(); - await p.reload(); - armedSlot = p.getInt(armedSlotKey); - } catch (_) {} + // The slot minute is encoded in the fired-store key (not a separate prefs + // int), so a changed requested time (user setting or wake-derived) + // replaces the pending one instead of being preserved by the anti-repeat + // guard. Date-prefixed like the announced key, so the store prunes itself. final slotMin = morningSlot == null ? null : morningSlot.hour * 60 + morningSlot.minute; + final slotKey = + slotMin == null ? null : 'morning_brief_slot:$today:$slotMin'; + final slotArmed = + slotKey == null ? false : await const FiredKeyStore().hasFired(slotKey); var morningPreserved = false; if (morningSlot == null) { // Not armed by this pass (switch off, no data, quiet hours): cancel any @@ -1029,15 +1027,11 @@ class NotificationCenter { await svc.cancel(NotificationService.idMorningBrief); if (hasPendingMorning) { await const FiredKeyStore().release(announcedKey); - try { - final p = await SharedPreferences.getInstance(); - await p.remove(armedSlotKey); - } catch (_) {} } } else if (morningAnnounced && ((slotMin! <= nowMin && !hasPendingMorning) || // delivered already - (hasPendingMorning && armedSlot == slotMin))) { - // Delivered occurrence, or pending one matching the current plan slot: + (hasPendingMorning && slotArmed))) { + // Delivered occurrence, or pending one armed for the current plan slot: // neither is cancelled nor re-armed. morningPreserved = true; } else { @@ -1078,10 +1072,7 @@ class NotificationCenter { // suppress the briefing for the rest of the day. if (armed && s.id == NotificationService.idMorningBrief) { await const FiredKeyStore().recordFired(announcedKey); - try { - final p = await SharedPreferences.getInstance(); - await p.setInt(armedSlotKey, s.hour * 60 + s.minute); - } catch (_) {} + await const FiredKeyStore().recordFired(slotKey!); } } } From a0dc197f6e26ff4b6cf6399e5b8a36d1145fab15 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 13 Nov 2026 08:52:55 +0000 Subject: [PATCH 07/14] fifth coderabbit round: keep the delivery guard across slot changes - Delivery is read off the previously armed occurrence (announced && no longer pending), not the new plan slot: moving the briefing time later after it fired no longer re-arms it today. - When an undelivered pending registration is replaced (slot mismatch), the announced key is released on cancel and re-recorded only if the replacement scheduleOnce succeeds, so a failed replacement stays retryable. Co-authored-by: BucciMobile --- lib/notify/notification_center.dart | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart index 0146af5fe..38f527047 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -1028,16 +1028,22 @@ class NotificationCenter { if (hasPendingMorning) { await const FiredKeyStore().release(announcedKey); } - } else if (morningAnnounced && - ((slotMin! <= nowMin && !hasPendingMorning) || // delivered already - (hasPendingMorning && slotArmed))) { - // Delivered occurrence, or pending one armed for the current plan slot: - // neither is cancelled nor re-armed. + } else if (morningAnnounced && (!hasPendingMorning || slotArmed)) { + // Delivery is read off the PREVIOUSLY armed occurrence, not the new plan + // slot: announced + no longer pending means it already fired — that guard + // stays even if the user moves the slot later today. Announced + still + // pending + armed for the current plan slot means preserve the pending + // one. Neither is cancelled nor re-armed. morningPreserved = true; } else { - // Fresh arm below, pending lost while still ahead, or the requested time - // changed (pending slot != plan slot) — replace the pending one. + // Re-arm below: fresh arm, or replacing an undelivered pending whose slot + // no longer matches the plan. Clear the guard when cancelling an + // undelivered registration, and record it again ONLY if the replacement + // registration succeeds — a failed replacement must stay retryable. await svc.cancel(NotificationService.idMorningBrief); + if (morningAnnounced && hasPendingMorning) { + await const FiredKeyStore().release(announcedKey); + } } await svc.cancel(NotificationService.idEveningBrief); await svc.cancel(NotificationService.idJournalLog); From f9fc33f302b354d135ea78f6fbe2ee977f1ef182 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 13 Nov 2026 09:53:41 +0000 Subject: [PATCH 08/14] address sourcery review on edge#709 - AppState serializes whole reminder passes (_reminderChain), so a stale not-ready pass can no longer cancel a briefing a newer ready pass armed. - LocalDb.latestSample() applies the primary-band filter to the samples fallback too, so a peripheral sensor cannot unlock the briefing. - _lastNightDataReady refuses a same-day sample while today's crossday row is unsettled; a settled row for today remains the cloud-source path. - pendingIds() returns null on query failure (unknown state, never read as not-pending/delivered); cancel() returns whether the plugin accepted it, and the announced key is released only after a successful cancel of a known-pending registration. - morningBriefSlotMin: a wake+30 wrapping past midnight wins over the configured floor (the floor would fire before the wake). Co-authored-by: BucciMobile --- lib/ai/reminder_plan.dart | 9 +++++++-- lib/data/db.dart | 11 ++++++++++- lib/notify/notification_center.dart | 21 ++++++++++++++------ lib/notify/notification_service.dart | 17 ++++++++++++---- lib/state/app_state.dart | 29 +++++++++++++++++++++++----- test/ai_reminder_plan_test.dart | 6 ++++++ 6 files changed, 75 insertions(+), 18 deletions(-) diff --git a/lib/ai/reminder_plan.dart b/lib/ai/reminder_plan.dart index 52c229a6c..826b6da5f 100644 --- a/lib/ai/reminder_plan.dart +++ b/lib/ai/reminder_plan.dart @@ -60,8 +60,13 @@ int morningBriefSlotMin(int morningMin, double? typicalWakeMinOfDay) { if (typicalWakeMinOfDay == null || !typicalWakeMinOfDay.isFinite) { return floor; } - final afterWake = - (typicalWakeMinOfDay.round() + kMorningBriefAfterWakeMin) % 1440; + final raw = typicalWakeMinOfDay.round() + kMorningBriefAfterWakeMin; + final afterWake = raw % 1440; + // A wake+30 that wraps past midnight lands after the wake BY DEFINITION, so + // the wrapped minute-of-day wins — comparing only the wrapped value against + // the floor would pick the floor (e.g. 08:00) even though it fires HOURS + // BEFORE a 23:50 wake. + if (raw >= 1440) return afterWake; return afterWake > floor ? afterWake : floor; } diff --git a/lib/data/db.dart b/lib/data/db.dart index c3ab9f1f6..51fb65be0 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -9364,7 +9364,16 @@ class LocalDb { if (decodedRows.isNotEmpty) { return Sample.fromDecodedRow(decodedRows.first); } - final rows = await db.query('samples', orderBy: 'ts DESC', limit: 1); + // Same primary-band filter as the decoded query above: without it the + // fallback could return a peripheral sensor's row, and callers that read + // this as "how fresh is the BAND" (e.g. the morning-briefing readiness + // gate) would be unlocked by a sensor that carries no band data. + final rows = await db.query( + 'samples', + where: kPrimaryBandSourceSql, + orderBy: 'ts DESC', + limit: 1, + ); return rows.isEmpty ? null : Sample.fromDbMap(rows.first); } diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart index 38f527047..737a781f1 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -1003,8 +1003,13 @@ class NotificationCenter { final morningAnnounced = await const FiredKeyStore().hasFired(announcedKey); // Pending vs delivered: a one-shot that already fired is no longer pending. - final hasPendingMorning = (await svc.pendingIds()) - .contains(NotificationService.idMorningBrief); + // A FAILED query (null) is an unknown state — assume pending, so it is + // never read as "not pending" (which would suppress the briefing) nor as + // "delivered". + final pending = await svc.pendingIds(); + final pendingKnown = pending != null; + final hasPendingMorning = + pending?.contains(NotificationService.idMorningBrief) ?? true; // The slot minute is encoded in the fired-store key (not a separate prefs // int), so a changed requested time (user setting or wake-derived) // replaces the pending one instead of being preserved by the anti-repeat @@ -1024,8 +1029,10 @@ class NotificationCenter { // pending), the guard stays, so a re-enable inside the grace window // cannot alert a second time; and if nothing was ever armed there is no // key to release anyway. - await svc.cancel(NotificationService.idMorningBrief); - if (hasPendingMorning) { + // A failed cancel leaves the stale alert armed — then the announced key + // must NOT be released (the registration may still be pending). + final cancelled = await svc.cancel(NotificationService.idMorningBrief); + if (cancelled && pendingKnown && hasPendingMorning) { await const FiredKeyStore().release(announcedKey); } } else if (morningAnnounced && (!hasPendingMorning || slotArmed)) { @@ -1040,8 +1047,10 @@ class NotificationCenter { // no longer matches the plan. Clear the guard when cancelling an // undelivered registration, and record it again ONLY if the replacement // registration succeeds — a failed replacement must stay retryable. - await svc.cancel(NotificationService.idMorningBrief); - if (morningAnnounced && hasPendingMorning) { + // (If the cancel itself failed, the pending post is still there; the + // re-arm below replaces it by id, so arming is still safe.) + final cancelled = await svc.cancel(NotificationService.idMorningBrief); + if (cancelled && morningAnnounced && hasPendingMorning) { await const FiredKeyStore().release(announcedKey); } } diff --git a/lib/notify/notification_service.dart b/lib/notify/notification_service.dart index d7883114d..eb206bd2d 100644 --- a/lib/notify/notification_service.dart +++ b/lib/notify/notification_service.dart @@ -657,21 +657,30 @@ class NotificationService { } } - Future cancel(int id) async { + /// Returns whether the cancel actually reached the plugin. False on a + /// plugin error — callers that treat "slot cleared" as fact must key off + /// this; a swallowed failure leaves the stale notification armed. + Future cancel(int id) async { try { await _plugin.cancel(id); - } catch (_) {} + return true; + } catch (_) { + return false; + } } /// Ids of the currently PENDING (armed, not yet fired) OS notifications. /// A one-shot that already fired is no longer in this list — that is the /// only way Dart can tell "armed" from "delivered" for a scheduled slot. - Future> pendingIds() async { + /// NULL means the query FAILED: an unknown pending state must not be read + /// as "not pending" (which would suppress a briefing for the day) nor as + /// "delivered". + Future?> pendingIds() async { try { final reqs = await _plugin.pendingNotificationRequests(); return reqs.map((r) => r.id).toSet(); } catch (_) { - return const {}; + return null; } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 19e7b8c42..6d2dd7638 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3270,9 +3270,21 @@ class AppState extends ChangeNotifier { return true; } + /// Serializes whole reminder passes. The readiness read inside a pass and + /// the (internally serialized) scheduling must run in the same order: + /// without this chain, an older `lastNightDataReady: false` pass can reach + /// the scheduler after a newer ready pass and cancel the briefing it armed. + Future _reminderChain = Future.value(); + /// (Re)register standing scheduled reminders per the user's prefs. Idempotent; /// safe to call repeatedly (cancels + re-schedules). Best-effort. - Future _ensureRemindersScheduled() async { + Future _ensureRemindersScheduled() { + final pass = _reminderChain.then((_) => _ensureRemindersScheduledLocked()); + _reminderChain = pass.catch((_) {}); + return pass; + } + + Future _ensureRemindersScheduledLocked() async { try { final prefs = await NotificationPrefs.load(); // ONE crossday read feeds every schedule that hangs off the rollup: @@ -3390,9 +3402,18 @@ class AppState extends ChangeNotifier { Future _lastNightDataReady(List> recent) async { try { final today = todayLabel(); + // The crossday pipeline flags today's row `unsettled` while the night is + // still settling — same rows the weekly lookback skips. A same-day band + // sample is NOT sufficient while that row is unsettled: the overnight + // is still building and the briefing cannot be generated yet. + if (recent.isNotEmpty) { + final last = recent.last; + if (last['date'] == today && last['unsettled'] == true) return false; + } // Query the DB first: the cached `lastSynced` is only refreshed at // startup and on wipe, so it can hold yesterday's sample and would hide - // a freshly synced one from the `??` fallback. + // a freshly synced one from the `??` fallback. latestSample() is + // primary-band only, so a peripheral sensor cannot unlock the briefing. final s = await LocalDb.latestSample() ?? lastSynced; if (s != null && s.tsEpoch > 0 && @@ -3400,9 +3421,7 @@ class AppState extends ChangeNotifier { today) { return true; } - // The crossday pipeline flags today's row `unsettled` while the night is - // still settling — same rows the weekly lookback skips. A row that is - // not settled yet is not "last night's data arrived". + // Cloud sources without raw samples: a settled derived row for today. if (recent.isEmpty) return false; final last = recent.last; return last['date'] == today && last['unsettled'] != true; diff --git a/test/ai_reminder_plan_test.dart b/test/ai_reminder_plan_test.dart index 3f7017bc8..c3659aa1e 100644 --- a/test/ai_reminder_plan_test.dart +++ b/test/ai_reminder_plan_test.dart @@ -341,6 +341,12 @@ void main() { expect(morningBriefSlotMin(8 * 60, 9 * 60 + 30), 10 * 60); }); + test('a wake+30 wrapping past midnight wins over the floor', () { + // Wake 23:50 -> wake+30 = 00:20 next day. The wrapped 00:20 is after + // the wake by definition; the floor 08:00 would fire BEFORE the wake. + expect(morningBriefSlotMin(8 * 60, 23 * 60 + 50), 20); + }); + test('the slot lands inside the plan, quiet-hours cap still applies', () { final slot = aiReminderPlan( const AiPrefs(), From 2c1f8dfc8dd5c9336f16d894ba5924c5d083f617 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 13 Nov 2026 10:13:05 +0000 Subject: [PATCH 09/14] address coderabbit round on edge#709 (a0dc197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notification_center: read the clock after the LAST await (guard reads, pending query, cancels) so armability, late-fire window and occurrence resolution use one instant — no arming tomorrow's occurrence with today's readiness across a slot boundary. - db: latestSample() now takes the newest sample across BOTH stores (decoded_onehz and the primary-band samples fallback) instead of returning yesterday's decoded row when today's records are in samples. Co-authored-by: BucciMobile --- lib/data/db.dart | 12 ++++++++---- lib/notify/notification_center.dart | 13 +++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 51fb65be0..4026a4143 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -9361,9 +9361,8 @@ class LocalDb { orderBy: 'rec_ts DESC, counter DESC', limit: 1, ); - if (decodedRows.isNotEmpty) { - return Sample.fromDecodedRow(decodedRows.first); - } + final decoded = + decodedRows.isEmpty ? null : Sample.fromDecodedRow(decodedRows.first); // Same primary-band filter as the decoded query above: without it the // fallback could return a peripheral sensor's row, and callers that read // this as "how fresh is the BAND" (e.g. the morning-briefing readiness @@ -9374,7 +9373,12 @@ class LocalDb { orderBy: 'ts DESC', limit: 1, ); - return rows.isEmpty ? null : Sample.fromDbMap(rows.first); + final plain = rows.isEmpty ? null : Sample.fromDbMap(rows.first); + // Newest across BOTH stores: decoded_onehz holding only yesterday while + // today's records already sit in `samples` must still read as today. + if (decoded == null) return plain; + if (plain == null) return decoded; + return plain.tsEpoch > decoded.tsEpoch ? plain : decoded; } static Future> counts() async { diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart index 737a781f1..7c9e79661 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -988,12 +988,7 @@ class NotificationCenter { if (s.id == NotificationService.idMorningBrief) morningSlot = s; } await svc.ensureTimezone(); - // Read the clock AFTER the awaits above: a pass that captured `now` at - // 07:59:59 must not resolve tomorrow's occurrence with today's readiness - // just because the intervening awaits crossed the slot boundary. - final now = DateTime.now(); - final today = todayLabel(now); - final nowMin = now.hour * 60 + now.minute; + final today = todayLabel(DateTime.now()); // A morning occurrence already announced today must not be re-armed by a // later pass: re-showing the OS id only replaces a pending post, and a // one-shot that already fired is gone from the pending list, so without @@ -1057,6 +1052,12 @@ class NotificationCenter { await svc.cancel(NotificationService.idEveningBrief); await svc.cancel(NotificationService.idJournalLog); if (plan.isEmpty) return; + // Read the clock after the LAST await (guard reads, pending query, cancels) + // so the armability check, the late-fire window and the occurrence + // resolution all use one instant: a pass that captured `now` at 07:59:59 + // must not arm tomorrow's occurrence with today's readiness. + final now = DateTime.now(); + final nowMin = now.hour * 60 + now.minute; for (final s in plan) { final slotMin = s.hour * 60 + s.minute; if (!aiSlotArmable(s.id, slotMin, nowMin)) continue; From 3c3e087db20539a0a48adc51bf2b04cd2a1d423c Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 13 Nov 2026 10:34:04 +0000 Subject: [PATCH 10/14] fix: use catchError (catch is a keyword, broke compile in CI) Co-authored-by: BucciMobile --- lib/state/app_state.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 6d2dd7638..f6dce7293 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3280,7 +3280,7 @@ class AppState extends ChangeNotifier { /// safe to call repeatedly (cancels + re-schedules). Best-effort. Future _ensureRemindersScheduled() { final pass = _reminderChain.then((_) => _ensureRemindersScheduledLocked()); - _reminderChain = pass.catch((_) {}); + _reminderChain = pass.catchError((_) {}); return pass; } From fbe66a7680dd58d60287a064613fe61f8eeb6c0c Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 13 Nov 2026 10:50:25 +0000 Subject: [PATCH 11/14] fix: samples table keys by device_id, not source (primary band = device_id '') Co-authored-by: BucciMobile --- lib/data/db.dart | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 4026a4143..1ee236cc9 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -9363,13 +9363,15 @@ class LocalDb { ); final decoded = decodedRows.isEmpty ? null : Sample.fromDecodedRow(decodedRows.first); - // Same primary-band filter as the decoded query above: without it the - // fallback could return a peripheral sensor's row, and callers that read - // this as "how fresh is the BAND" (e.g. the morning-briefing readiness - // gate) would be unlocked by a sensor that carries no band data. + // Primary band only, same meaning as the decoded query above: without it + // the fallback could return a peripheral sensor's row, and callers that + // read this as "how fresh is the BAND" (e.g. the morning-briefing + // readiness gate) would be unlocked by a sensor with no band data. The + // samples table keys by device_id ('' = primary band), not `source`. final rows = await db.query( 'samples', - where: kPrimaryBandSourceSql, + where: 'device_id = ?', + whereArgs: [kPrimaryDeviceId], orderBy: 'ts DESC', limit: 1, ); From 9666a962716905f874db48c2e50c0cf8ef658201 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 13 Nov 2026 17:16:24 +0530 Subject: [PATCH 12/14] morning briefing: prune its guard keys, re-arm after a time change back, no double announce when pending is unreadable, coalesce reminder passes --- lib/data/db.dart | 24 ++++- lib/notify/fired_keys.dart | 38 +++++++- lib/notify/notification_center.dart | 51 ++++++++++- lib/state/app_state.dart | 14 +-- lib/state/coalescing_runner.dart | 33 +++++++ test/coalescing_runner_test.dart | 71 ++++++++++++++ test/morning_brief_guard_test.dart | 112 +++++++++++++++++++++++ test/notification_claim_atomic_test.dart | 49 ++++++++++ 8 files changed, 377 insertions(+), 15 deletions(-) create mode 100644 lib/state/coalescing_runner.dart create mode 100644 test/coalescing_runner_test.dart create mode 100644 test/morning_brief_guard_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index 412c0b719..9a787d1ad 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -2218,12 +2218,30 @@ class LocalDb { /// to expire against, and they're rare enough to be self-limiting. Matches /// the retention semantics of the SharedPreferences fallback exactly. static Future pruneNotifFired(String cutoffDate) async { + const date = "'[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'"; await _guardedWrite( (db) => db.rawDelete( "DELETE FROM notif_fired " - "WHERE substr(key, 1, 10) GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' " - "AND substr(key, 1, 10) < ?", - [cutoffDate], + "WHERE (substr(key, 1, 10) GLOB $date AND substr(key, 1, 10) < ?) " + // The morning briefing's guards carry their date after a fixed prefix + // (FiredKeyStore.datedAfterPrefixes): 24 and 19 characters long. + "OR (key GLOB 'morning_brief_announced:*' " + "AND substr(key, 25, 10) GLOB $date AND substr(key, 25, 10) < ?) " + "OR (key GLOB 'morning_brief_slot:*' " + "AND substr(key, 20, 10) GLOB $date AND substr(key, 20, 10) < ?)", + [cutoffDate, cutoffDate, cutoffDate], + ), + bestEffort: true, + ); + } + + /// Delete every claim whose key starts with [prefix]. See + /// [FiredKeyStore.releasePrefix]. + static Future releaseNotifFiredPrefix(String prefix) async { + await _guardedWrite( + (db) => db.rawDelete( + 'DELETE FROM notif_fired WHERE substr(key, 1, ?) = ?', + [prefix.length, prefix], ), bestEffort: true, ); diff --git a/lib/notify/fired_keys.dart b/lib/notify/fired_keys.dart index ff5b38599..58584d36d 100644 --- a/lib/notify/fired_keys.dart +++ b/lib/notify/fired_keys.dart @@ -218,12 +218,48 @@ class FiredKeyStore { final cutoff = _cutoffLabel(); for (final k in p.getKeys()) { if (!k.startsWith(_prefix)) continue; - final d = leadingDate(k.substring(_prefix.length)); + final d = expiryDate(k.substring(_prefix.length)); if (d != null && d.compareTo(cutoff) < 0) await p.remove(k); } } catch (_) {/* bounding is best-effort — never break a record on it */} } + /// Key families whose date follows a fixed prefix instead of leading the key + /// (the morning briefing's guards). They are pruned by that embedded date; the + /// SQL in [LocalDb.pruneNotifFired] must list the same prefixes. + static const List datedAfterPrefixes = [ + 'morning_brief_announced:', + 'morning_brief_slot:', + ]; + + /// The day a key expires against: its leading date, or the date right after + /// one of [datedAfterPrefixes]. Null for keys with no day to expire against. + static String? expiryDate(String dedupeKey) { + final lead = leadingDate(dedupeKey); + if (lead != null) return lead; + for (final p in datedAfterPrefixes) { + if (dedupeKey.startsWith(p)) return leadingDate(dedupeKey.substring(p.length)); + } + return null; + } + + /// Give back every claim whose key starts with [prefix] (table row and prefs + /// mirror). For key families where one value supersedes another, so a stale + /// sibling cannot be read as the current one. Best-effort; never throws. + Future releasePrefix(String prefix) async { + try { + await LocalDb.releaseNotifFiredPrefix(prefix); + } catch (_) {/* degraded mode has no rows to give back */} + try { + final p = await _prefs(); + if (p == null) return; + await _reload(p); + for (final k in p.getKeys().toList()) { + if (k.startsWith('$_prefix$prefix')) await p.remove(k); + } + } catch (_) {/* mirror is best-effort */} + } + /// The leading "YYYY-MM-DD" of a dedupeKey, or null if it isn't dated. static String? leadingDate(String dedupeKey) { if (dedupeKey.length < 10) return null; diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart index 7c9e79661..731d6b0f3 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -934,6 +934,34 @@ class NotificationCenter { /// tomorrow is a fresh key and the store prunes itself. static const String morningBriefAnnouncedPrefix = 'morning_brief_announced:'; + /// Dedupe-key prefix for "the morning slot armed for this minute today". + /// `:`. Only ONE slot is current at a time, so a + /// replacement releases every slot key of the day — a stale key for an + /// earlier minute would otherwise read as "armed for the current slot" when + /// the user moves the time A to B and back to A, and preserve B's pending post. + static const String morningBriefSlotPrefix = 'morning_brief_slot:'; + + /// What a pass does with the morning briefing, given what it can see. Pure so + /// the failure-mode rules are testable without the OS scheduler. + /// + /// [pendingKnown] false means the pending query FAILED. Then an + /// already-announced day is left alone: with the post's state unknown it may + /// already have been delivered, and replacing it would announce a second + /// time. The next pass with a readable pending list picks up a moved slot. + static MorningBriefAction morningBriefAction({ + required bool slotPlanned, + required bool announced, + required bool pendingKnown, + required bool hasPending, + required bool slotArmed, + }) { + if (!slotPlanned) return MorningBriefAction.cancel; + if (announced && (!pendingKnown || !hasPending || slotArmed)) { + return MorningBriefAction.preserve; + } + return MorningBriefAction.rearm; + } + /// Public entry point — serialized through [_synchronized] exactly like /// [scheduleStandingReminders]: the guard read, the cancels, the registration /// and the guard write must run as ONE critical section, or two overlapping @@ -1011,12 +1039,19 @@ class NotificationCenter { // guard. Date-prefixed like the announced key, so the store prunes itself. final slotMin = morningSlot == null ? null : morningSlot.hour * 60 + morningSlot.minute; - final slotKey = - slotMin == null ? null : 'morning_brief_slot:$today:$slotMin'; + final slotDayPrefix = '$morningBriefSlotPrefix$today:'; + final slotKey = slotMin == null ? null : '$slotDayPrefix$slotMin'; final slotArmed = slotKey == null ? false : await const FiredKeyStore().hasFired(slotKey); var morningPreserved = false; - if (morningSlot == null) { + final action = morningBriefAction( + slotPlanned: morningSlot != null, + announced: morningAnnounced, + pendingKnown: pendingKnown, + hasPending: hasPendingMorning, + slotArmed: slotArmed, + ); + if (action == MorningBriefAction.cancel) { // Not armed by this pass (switch off, no data, quiet hours): cancel any // pending one — a disabled preference wins over the anti-repeat guard — // but release the announced key ONLY when that registration was still @@ -1029,8 +1064,9 @@ class NotificationCenter { final cancelled = await svc.cancel(NotificationService.idMorningBrief); if (cancelled && pendingKnown && hasPendingMorning) { await const FiredKeyStore().release(announcedKey); + await const FiredKeyStore().releasePrefix(slotDayPrefix); } - } else if (morningAnnounced && (!hasPendingMorning || slotArmed)) { + } else if (action == MorningBriefAction.preserve) { // Delivery is read off the PREVIOUSLY armed occurrence, not the new plan // slot: announced + no longer pending means it already fired — that guard // stays even if the user moves the slot later today. Announced + still @@ -1045,8 +1081,10 @@ class NotificationCenter { // (If the cancel itself failed, the pending post is still there; the // re-arm below replaces it by id, so arming is still safe.) final cancelled = await svc.cancel(NotificationService.idMorningBrief); - if (cancelled && morningAnnounced && hasPendingMorning) { + if (cancelled && morningAnnounced && pendingKnown && hasPendingMorning) { await const FiredKeyStore().release(announcedKey); + // The old slot's key goes with it: only the slot armed below is current. + await const FiredKeyStore().releasePrefix(slotDayPrefix); } } await svc.cancel(NotificationService.idEveningBrief); @@ -1093,3 +1131,6 @@ class NotificationCenter { } } } + +/// See [NotificationCenter.morningBriefAction]. +enum MorningBriefAction { cancel, preserve, rearm } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 7a5443ca0..ac93ecf3c 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -146,6 +146,7 @@ import '../telemetry/health_uploader.dart'; import '../widget/widget_service.dart'; import '../sync/file_log.dart'; import 'breathing_controller.dart'; +import 'coalescing_runner.dart'; import 'package:uuid/uuid.dart'; export 'derive_coordinator.dart' show DeriveRunHook; @@ -3319,15 +3320,16 @@ class AppState extends ChangeNotifier { /// the (internally serialized) scheduling must run in the same order: /// without this chain, an older `lastNightDataReady: false` pass can reach /// the scheduler after a newer ready pass and cancel the briefing it armed. - Future _reminderChain = Future.value(); + /// + /// Requests that arrive while a pass is queued but not yet started join it + /// (it reads its inputs when it starts), so a drain's many store paths cost + /// at most one running pass and one waiting pass, not one pass per batch. + late final CoalescingRunner _reminderRunner = + CoalescingRunner(_ensureRemindersScheduledLocked); /// (Re)register standing scheduled reminders per the user's prefs. Idempotent; /// safe to call repeatedly (cancels + re-schedules). Best-effort. - Future _ensureRemindersScheduled() { - final pass = _reminderChain.then((_) => _ensureRemindersScheduledLocked()); - _reminderChain = pass.catchError((_) {}); - return pass; - } + Future _ensureRemindersScheduled() => _reminderRunner.run(); Future _ensureRemindersScheduledLocked() async { try { diff --git a/lib/state/coalescing_runner.dart b/lib/state/coalescing_runner.dart new file mode 100644 index 000000000..74458da4a --- /dev/null +++ b/lib/state/coalescing_runner.dart @@ -0,0 +1,33 @@ +// coalescing_runner.dart — serialise a job, and fold requests that arrive while +// another run is still waiting its turn into that one waiting run. +// +// The reminder pass reads the crossday rollup and the pending-notification list. +// It was triggered on every durable store path, so a drain of many batches +// queued one full pass per batch. A pass reads its inputs when it STARTS, so a +// request that arrives while a run is queued-but-not-started is already served +// by it: it joins instead of adding another. A request that arrives while a run +// is mid-flight still queues one more, because that run has already read. + +class CoalescingRunner { + CoalescingRunner(this._job); + + final Future Function() _job; + Future _tail = Future.value(); + Future? _queued; + + /// Run the job after anything already running. Completes with that run's + /// result (joined callers share the queued run's completion). + Future run() { + final queued = _queued; + if (queued != null) return queued; + late final Future pass; + pass = _tail.then((_) { + // Started: later requests can no longer be served by this run. + if (identical(_queued, pass)) _queued = null; + return _job(); + }); + _queued = pass; + _tail = pass.catchError((_) {}); + return pass; + } +} diff --git a/test/coalescing_runner_test.dart b/test/coalescing_runner_test.dart new file mode 100644 index 000000000..e0aa8236b --- /dev/null +++ b/test/coalescing_runner_test.dart @@ -0,0 +1,71 @@ +// The reminder pass is serialised, and a burst of requests collapses into one +// running pass plus at most one waiting pass. + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/state/coalescing_runner.dart'; + +void main() { + test('requests while a run is waiting join it instead of queueing more', + () async { + var runs = 0; + final gate = Completer(); + final r = CoalescingRunner(() async { + runs++; + if (runs == 1) await gate.future; + }); + + final first = r.run(); // starts, parks on the gate + await Future.delayed(Duration.zero); + final burst = [for (var i = 0; i < 50; i++) r.run()]; + gate.complete(); + await Future.wait([first, ...burst]); + + // One run in flight when the burst arrived, one for the whole burst. + expect(runs, 2); + }); + + test('a request during a running pass still gets a pass that starts after it', + () async { + final order = []; + final gate = Completer(); + var n = 0; + final r = CoalescingRunner(() async { + final id = ++n; + order.add('start$id'); + if (id == 1) await gate.future; + order.add('end$id'); + }); + + final a = r.run(); + await Future.delayed(Duration.zero); + final b = r.run(); + gate.complete(); + await Future.wait([a, b]); + + expect(order, ['start1', 'end1', 'start2', 'end2']); + }); + + test('passes never overlap', () async { + var live = 0, peak = 0; + final r = CoalescingRunner(() async { + peak = ++live > peak ? live : peak; + await Future.delayed(const Duration(milliseconds: 5)); + live--; + }); + await Future.wait([for (var i = 0; i < 20; i++) r.run()]); + expect(peak, 1); + }); + + test('a failing pass does not stop later ones', () async { + var n = 0; + final r = CoalescingRunner(() async { + if (++n == 1) throw StateError('boom'); + }); + await expectLater(r.run(), throwsStateError); + await r.run(); + expect(n, 2); + }); +} diff --git a/test/morning_brief_guard_test.dart b/test/morning_brief_guard_test.dart new file mode 100644 index 000000000..b7c1178dd --- /dev/null +++ b/test/morning_brief_guard_test.dart @@ -0,0 +1,112 @@ +// The morning briefing's persisted guards: what a pass decides when the pending +// list cannot be read, and that moving the time A to B and back to A re-arms A. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/notify/fired_keys.dart'; +import 'package:openstrap_edge/notify/notification_center.dart'; + +MorningBriefAction _act({ + bool planned = true, + bool announced = true, + bool known = true, + bool pending = true, + bool armed = false, +}) => + NotificationCenter.morningBriefAction( + slotPlanned: planned, + announced: announced, + pendingKnown: known, + hasPending: pending, + slotArmed: armed, + ); + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + group('decision', () { + test('no planned slot cancels', () { + expect(_act(planned: false), MorningBriefAction.cancel); + }); + test('fresh day arms', () { + expect(_act(announced: false, pending: false), MorningBriefAction.rearm); + }); + test('announced and already delivered is never re-armed', () { + expect(_act(pending: false), MorningBriefAction.preserve); + expect(_act(pending: false, armed: false), MorningBriefAction.preserve); + }); + test('announced, pending, same slot is preserved', () { + expect(_act(armed: true), MorningBriefAction.preserve); + }); + test('announced, pending, moved slot is replaced', () { + expect(_act(armed: false), MorningBriefAction.rearm); + }); + test('an unreadable pending list never replaces an announced day', () { + // Unknown state is assumed pending by the caller; replacing it would + // announce a second time if it had in fact already been delivered. + expect(_act(known: false, pending: true, armed: false), + MorningBriefAction.preserve); + expect(_act(known: false, pending: true, armed: true), + MorningBriefAction.preserve); + }); + test('an unreadable pending list still arms a day that was never announced', + () { + expect(_act(announced: false, known: false, pending: true), + MorningBriefAction.rearm); + }); + }); + + group('slot keys', () { + const store = FiredKeyStore(); + const day = '2026-11-13'; + final prefix = '${NotificationCenter.morningBriefSlotPrefix}$day:'; + + // The replacement the scheduler performs: release the day's slot keys, + // then record the slot it armed. + Future arm(int slotMin) async { + await store.releasePrefix(prefix); + await store.recordFired('$prefix$slotMin'); + } + + test('A to B to A re-arms A instead of preserving B', () async { + await arm(480); // armed for 08:00 + expect(await store.hasFired('${prefix}510'), isFalse); + + await arm(510); // user moves it to 08:30 + expect(await store.hasFired('${prefix}510'), isTrue); + + // Back to 08:00: the old key for 08:00 must not read as "armed for this + // slot", or the pending 08:30 post would be preserved. + expect(await store.hasFired('${prefix}480'), isFalse); + expect( + NotificationCenter.morningBriefAction( + slotPlanned: true, + announced: true, + pendingKnown: true, + hasPending: true, + slotArmed: await store.hasFired('${prefix}480'), + ), + MorningBriefAction.rearm, + ); + }); + + test('the key families are the ones the pruner knows about', () { + expect(FiredKeyStore.datedAfterPrefixes, + contains(NotificationCenter.morningBriefAnnouncedPrefix)); + expect(FiredKeyStore.datedAfterPrefixes, + contains(NotificationCenter.morningBriefSlotPrefix)); + }); + + test('expiryDate reads the date inside the key', () { + expect(FiredKeyStore.expiryDate('2026-11-13:low_read'), '2026-11-13'); + expect( + FiredKeyStore.expiryDate( + '${NotificationCenter.morningBriefAnnouncedPrefix}2026-11-13'), + '2026-11-13'); + expect(FiredKeyStore.expiryDate('${prefix}480'), '2026-11-13'); + expect(FiredKeyStore.expiryDate('alarm_fired:12345'), isNull); + expect(FiredKeyStore.expiryDate('morning_brief_slot:oops'), isNull); + }); + }); +} diff --git a/test/notification_claim_atomic_test.dart b/test/notification_claim_atomic_test.dart index 4fe94c348..17c1d8022 100644 --- a/test/notification_claim_atomic_test.dart +++ b/test/notification_claim_atomic_test.dart @@ -255,5 +255,54 @@ void main() { expect(await store.claim('${_dayOffset(0)}:trigger'), isTrue); expect(await LocalDb.notifFiredExists('alarm_fired:12345'), isTrue); }); + + test('the morning briefing guards expire by the date inside the key', + () async { + // They are `[:]`, so the date does not lead the key + // and a leading-date prune never saw them: one row per day, forever. + final old = _dayOffset(-(FiredKeyStore.retentionDays + 5)); + final recent = _dayOffset(-1); + final keys = [ + '${NotificationCenter.morningBriefAnnouncedPrefix}$old', + '${NotificationCenter.morningBriefSlotPrefix}$old:480', + '${NotificationCenter.morningBriefAnnouncedPrefix}$recent', + '${NotificationCenter.morningBriefSlotPrefix}$recent:480', + ]; + SharedPreferences.setMockInitialValues({ + for (final k in keys) 'notif_fired:$k': true, + }); + await LocalDb.seedNotifFired(keys); + + expect(await const FiredKeyStore().claim('${_dayOffset(0)}:trigger'), + isTrue); + + expect(await LocalDb.notifFiredExists(keys[0]), isFalse); + expect(await LocalDb.notifFiredExists(keys[1]), isFalse); + expect(await LocalDb.notifFiredExists(keys[2]), isTrue); + expect(await LocalDb.notifFiredExists(keys[3]), isTrue); + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + expect(prefs.containsKey('notif_fired:${keys[0]}'), isFalse); + expect(prefs.containsKey('notif_fired:${keys[1]}'), isFalse); + expect(prefs.getBool('notif_fired:${keys[2]}'), isTrue); + expect(prefs.getBool('notif_fired:${keys[3]}'), isTrue); + }); + + test('releasePrefix gives back only the matching claims', () async { + const store = FiredKeyStore(); + final day = _dayOffset(0); + final slotA = '${NotificationCenter.morningBriefSlotPrefix}$day:480'; + final slotB = '${NotificationCenter.morningBriefSlotPrefix}$day:510'; + final announced = '${NotificationCenter.morningBriefAnnouncedPrefix}$day'; + for (final k in [slotA, slotB, announced]) { + expect(await store.claim(k), isTrue); + } + + await store.releasePrefix('${NotificationCenter.morningBriefSlotPrefix}$day:'); + + expect(await store.hasFired(slotA), isFalse); + expect(await store.hasFired(slotB), isFalse); + expect(await store.hasFired(announced), isTrue); + }); }); } From 744b507afb647dd513c2531226bca8647826b491 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 13 Nov 2026 12:59:40 +0000 Subject: [PATCH 13/14] morning briefing: replace the day's slot keys after a successful arm even if the preceding cancel failed Co-authored-by: BucciMobile --- lib/notify/notification_center.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart index 731d6b0f3..f78feaaee 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -1126,6 +1126,10 @@ class NotificationCenter { // suppress the briefing for the rest of the day. if (armed && s.id == NotificationService.idMorningBrief) { await const FiredKeyStore().recordFired(announcedKey); + // Replace the day's slot keys even if the preceding cancel failed — + // only the slot armed here is current; a stale slot key would make a + // later change back to the old time preserve the wrong pending post. + await const FiredKeyStore().releasePrefix(slotDayPrefix); await const FiredKeyStore().recordFired(slotKey!); } } From 29f718c55aad01a2c18f1e6fb1e7fce6ff0924a1 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 13 Nov 2026 19:10:13 +0530 Subject: [PATCH 14/14] morning briefing: resolve the occurrence from the clock the pass decided on, drop a stale slot key when the replacement registers --- lib/notify/fired_keys.dart | 8 ++++++++ lib/notify/notification_center.dart | 4 ++-- lib/notify/notification_service.dart | 10 ++++++++-- test/notification_claim_atomic_test.dart | 18 ++++++++++++++++++ test/notification_schedule_dst_test.dart | 13 +++++++++++++ 5 files changed, 49 insertions(+), 4 deletions(-) diff --git a/lib/notify/fired_keys.dart b/lib/notify/fired_keys.dart index 58584d36d..423ac3f57 100644 --- a/lib/notify/fired_keys.dart +++ b/lib/notify/fired_keys.dart @@ -243,6 +243,14 @@ class FiredKeyStore { return null; } + /// Make [key] the only claim under [prefix]: whatever sibling a failed + /// cancel left behind is given back first, so a later change back to that + /// sibling cannot read it as the current one. + Future replaceUnderPrefix(String prefix, String key) async { + await releasePrefix(prefix); + await recordFired(key); + } + /// Give back every claim whose key starts with [prefix] (table row and prefs /// mirror). For key families where one value supersedes another, so a stale /// sibling cannot be read as the current one. Best-effort; never throws. diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart index 731d6b0f3..364be00bf 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -1102,7 +1102,7 @@ class NotificationCenter { if (s.id == NotificationService.idMorningBrief && morningPreserved) { continue; } - var at = svc.nextDailyInstant(s.hour, s.minute); + var at = svc.nextDailyInstant(s.hour, s.minute, now: now); if (s.id == NotificationService.idMorningBrief) { final late = morningBriefLateFire(slotMin, now); if (late != null) { @@ -1126,7 +1126,7 @@ class NotificationCenter { // suppress the briefing for the rest of the day. if (armed && s.id == NotificationService.idMorningBrief) { await const FiredKeyStore().recordFired(announcedKey); - await const FiredKeyStore().recordFired(slotKey!); + await const FiredKeyStore().replaceUnderPrefix(slotDayPrefix, slotKey!); } } } diff --git a/lib/notify/notification_service.dart b/lib/notify/notification_service.dart index eb206bd2d..46a2e2fe6 100644 --- a/lib/notify/notification_service.dart +++ b/lib/notify/notification_service.dart @@ -533,8 +533,14 @@ class NotificationService { /// ahead, else tomorrow. For arming a slot whose BODY is about one specific /// day (the nightly sweep) as a one-shot; the caller checks which day it /// landed on. - DateTime nextDailyInstant(int hour, int minute) => - _nextInstanceOf(hour, minute); + /// + /// [now] pins the instant the occurrence is resolved against, so a caller + /// that already decided something at one clock reading gets an occurrence + /// from that same reading instead of a second one taken a moment later. + DateTime nextDailyInstant(int hour, int minute, {DateTime? now}) => + now == null + ? _nextInstanceOf(hour, minute) + : nextInstanceOf(tz.TZDateTime.from(now, tz.local), hour, minute); /// Shared gate for every scheduled slot — see [schedulableIds]. bool _maySchedule(int id) { diff --git a/test/notification_claim_atomic_test.dart b/test/notification_claim_atomic_test.dart index 17c1d8022..cef5b364d 100644 --- a/test/notification_claim_atomic_test.dart +++ b/test/notification_claim_atomic_test.dart @@ -304,5 +304,23 @@ void main() { expect(await store.hasFired(slotB), isFalse); expect(await store.hasFired(announced), isTrue); }); + + test('replaceUnderPrefix leaves the new slot key as the only one', () async { + const store = FiredKeyStore(); + final day = _dayOffset(0); + final prefix = '${NotificationCenter.morningBriefSlotPrefix}$day:'; + final slotA = '${prefix}480'; + final slotB = '${prefix}510'; + final announced = '${NotificationCenter.morningBriefAnnouncedPrefix}$day'; + // A failed cancel while moving A to B left A's key in place. + expect(await store.claim(slotA), isTrue); + expect(await store.claim(announced), isTrue); + + await store.replaceUnderPrefix(prefix, slotB); + + expect(await store.hasFired(slotA), isFalse); + expect(await store.hasFired(slotB), isTrue); + expect(await store.hasFired(announced), isTrue); + }); }); } diff --git a/test/notification_schedule_dst_test.dart b/test/notification_schedule_dst_test.dart index 9ffad4f29..9c55ba417 100644 --- a/test/notification_schedule_dst_test.dart +++ b/test/notification_schedule_dst_test.dart @@ -136,4 +136,17 @@ void main() { } }); }); + + group('nextDailyInstant resolves against the clock it is given', () { + setUpAll(() => tz.setLocalLocation(ny)); + + test('a pass that read 07:59 still gets today, one that read 08:01 tomorrow', + () { + final svc = NotificationService.instance; + final before = tz.TZDateTime(ny, 2026, 6, 10, 7, 59, 59); + final after = tz.TZDateTime(ny, 2026, 6, 10, 8, 0, 1); + expect(svc.nextDailyInstant(8, 0, now: before).day, 10); + expect(svc.nextDailyInstant(8, 0, now: after).day, 11); + }); + }); }