diff --git a/lib/ai/reminder_plan.dart b/lib/ai/reminder_plan.dart index 633444867..826b6da5f 100644 --- a/lib/ai/reminder_plan.dart +++ b/lib/ai/reminder_plan.dart @@ -45,10 +45,51 @@ 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 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; +} + /// 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 +100,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 +112,17 @@ 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 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/data/db.dart b/lib/data/db.dart index 3c5ee4f3b..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, ); @@ -9466,11 +9484,26 @@ class LocalDb { orderBy: 'rec_ts DESC, counter DESC', limit: 1, ); - if (decodedRows.isNotEmpty) { - return Sample.fromDecodedRow(decodedRows.first); - } - final rows = await db.query('samples', orderBy: 'ts DESC', limit: 1); - return rows.isEmpty ? null : Sample.fromDbMap(rows.first); + final decoded = + decodedRows.isEmpty ? null : Sample.fromDecodedRow(decodedRows.first); + // 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: 'device_id = ?', + whereArgs: [kPrimaryDeviceId], + orderBy: 'ts DESC', + limit: 1, + ); + 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/fired_keys.dart b/lib/notify/fired_keys.dart index ff5b38599..423ac3f57 100644 --- a/lib/notify/fired_keys.dart +++ b/lib/notify/fired_keys.dart @@ -218,12 +218,56 @@ 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; + } + + /// 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. + 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 2b1fdb837..59158bd93 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -872,65 +872,268 @@ 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; } + /// 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. - /// - /// 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. + /// 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 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 the exception — see below. + /// + /// 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:'; + + /// 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 + /// 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, { required bool aiConfigured, + required bool lastNightDataReady, + double? bedtimeMinOfDay, + 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; - await svc.cancel(NotificationService.idMorningBrief); - await svc.cancel(NotificationService.idEveningBrief); - await svc.cancel(NotificationService.idJournalLog); final plan = aiReminderPlan( 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; + AiReminderSlot? morningSlot; + for (final s in plan) { + if (s.id == NotificationService.idMorningBrief) morningSlot = s; + } await svc.ensureTimezone(); - final nowMin = DateTime.now().hour * 60 + DateTime.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 + // 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. + // 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 + // guard. Date-prefixed like the announced key, so the store prunes itself. + final slotMin = + morningSlot == null ? null : morningSlot.hour * 60 + morningSlot.minute; + final slotDayPrefix = '$morningBriefSlotPrefix$today:'; + final slotKey = slotMin == null ? null : '$slotDayPrefix$slotMin'; + final slotArmed = + slotKey == null ? false : await const FiredKeyStore().hasFired(slotKey); + var morningPreserved = false; + 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 + // 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. + // 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); + await const FiredKeyStore().releasePrefix(slotDayPrefix); + } + } 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 + // pending + armed for the current plan slot means preserve the pending + // one. Neither is cancelled nor re-armed. + morningPreserved = true; + } else { + // 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. + // (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 && 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); + 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) { - if (!aiSlotArmable(s.id, s.hour * 60 + s.minute, nowMin)) continue; - await svc.scheduleOnce( + 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, now: now); + if (s.id == NotificationService.idMorningBrief) { + 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 + // catch-up that lands inside the protected window. + if (prefs.inQuietHours(late.hour * 60 + late.minute)) continue; + at = late; + } + } + final armed = 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, ); + // 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(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().replaceUnderPrefix(slotDayPrefix, slotKey!); + } } } } + +/// See [NotificationCenter.morningBriefAction]. +enum MorningBriefAction { cancel, preserve, rearm } diff --git a/lib/notify/notification_service.dart b/lib/notify/notification_service.dart index 115bec5e7..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) { @@ -622,7 +628,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 +643,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,13 +657,37 @@ class NotificationService { UILocalNotificationDateInterpretation.absoluteTime, payload: route, ); - } catch (_) {} + return true; + } catch (_) { + return false; + } } - 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. + /// 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 null; + } } /// Drop every notification this app has scheduled or posted. diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index ac698fb49..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; @@ -3013,6 +3014,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()); }()); } @@ -3310,9 +3316,22 @@ 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. + /// + /// 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() async { + Future _ensureRemindersScheduled() => _reminderRunner.run(); + + Future _ensureRemindersScheduledLocked() async { try { final prefs = await NotificationPrefs.load(); // ONE crossday read feeds every schedule that hangs off the rollup: @@ -3362,7 +3381,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(), ); @@ -3372,27 +3393,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']; @@ -3401,11 +3430,51 @@ 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(); + // 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. 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 && + dayLabelOf(DateTime.fromMillisecondsSinceEpoch(s.tsEpoch * 1000)) == + today) { + return true; + } + // 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; + } catch (e) { + _log('[notify] last-night data check skipped: $e'); + return false; } } 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/ai_morning_brief_arming_test.dart b/test/ai_morning_brief_arming_test.dart index c1c54330b..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'; @@ -15,6 +16,7 @@ void main() { const AiPrefs(), remindersEnabled: const NotificationPrefs().remindersEnabled, aiConfigured: true, + lastNightDataReady: true, journalDoneToday: false, ).where((s) => NotificationService.maySchedule(s.id)); return [ @@ -25,17 +27,51 @@ 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); 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..c3659aa1e 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,123 @@ 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('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(), + 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('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), + 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', )) 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..cef5b364d 100644 --- a/test/notification_claim_atomic_test.dart +++ b/test/notification_claim_atomic_test.dart @@ -255,5 +255,72 @@ 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); + }); + + 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); + }); + }); }