Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions lib/ai/reminder_plan.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
}

/// 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
Expand All @@ -59,7 +100,9 @@ List<AiReminderSlot> 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
Expand All @@ -69,8 +112,17 @@ List<AiReminderSlot> aiReminderPlan(
}) {
if (!remindersEnabled) return const [];
final out = <AiReminderSlot>[];
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,
Expand Down
49 changes: 41 additions & 8 deletions lib/data/db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> pruneNotifFired(String cutoffDate) async {
const date = "'[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'";
await _guardedWrite<int>(
(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<void> releaseNotifFiredPrefix(String prefix) async {
await _guardedWrite<int>(
(db) => db.rawDelete(
'DELETE FROM notif_fired WHERE substr(key, 1, ?) = ?',
[prefix.length, prefix],
),
bestEffort: true,
);
Expand Down Expand Up @@ -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<Map<String, int>> counts() async {
Expand Down
46 changes: 45 additions & 1 deletion lib/notify/fired_keys.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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<void> 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<void> 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;
Expand Down
Loading
Loading