diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte
index 97aeb5f..5f23a0d 100644
--- a/src/lib/pages/SettingsPage.svelte
+++ b/src/lib/pages/SettingsPage.svelte
@@ -24,6 +24,7 @@
// that just mutates the page-local llmLoaded bool. The store
// version drives the sidebar chip (brief item #31).
import { refreshLlmStatus as refreshGlobalLlmStatus, markError as markGlobalLlmError, markLoading as markGlobalLlmLoading } from "$lib/stores/llmStatus.svelte.js";
+ import { handleNudgeModeChange } from "$lib/stores/nudgeBus.svelte.ts";
const prefs = getPreferences();
@@ -841,6 +842,68 @@
}
});
+ // B3.2 — nudge mode picker. SegmentedButton works on string options;
+ // settings.nudgeMode is the lowercase enum. Mirror via display labels
+ // and call the bus' mode-switch hygiene hook on each transition.
+ const NUDGE_MODE_LABELS = ["Off", "Immediate", "Digest"] as const;
+ function modeFromLabel(label: string): "off" | "immediate" | "digest" {
+ if (label === "Immediate") return "immediate";
+ if (label === "Digest") return "digest";
+ return "off";
+ }
+ function labelFromMode(mode: "off" | "immediate" | "digest"): string {
+ if (mode === "immediate") return "Immediate";
+ if (mode === "digest") return "Digest";
+ return "Off";
+ }
+ let nudgeModeLabel = $state(labelFromMode(settings.nudgeMode ?? "off"));
+ $effect(() => {
+ const next = modeFromLabel(nudgeModeLabel);
+ const prev = settings.nudgeMode;
+ if (prev !== next) {
+ // Update both fields together: the back-compat boolean stays in
+ // lockstep so older readers (and the legacy fallback inside
+ // nudgeBus) see a consistent value.
+ settings.nudgeMode = next;
+ settings.nudgesEnabled = next !== "off";
+ saveSettings();
+ // Run mode-switch hygiene (clear queue / replay queue) only
+ // when prev was a real value, not the initial undefined that
+ // can hit during the very first $effect tick.
+ if (prev === "off" || prev === "immediate" || prev === "digest") {
+ void handleNudgeModeChange(prev, next);
+ }
+ }
+ });
+ $effect(() => {
+ const label = labelFromMode(settings.nudgeMode ?? "off");
+ if (nudgeModeLabel !== label) nudgeModeLabel = label;
+ });
+
+ // B3.2 — digest-times editor. The user picks 1-3 HH:MM slots; we
+ // validate strictly (no "8:00", no "24:00") before writing.
+ let digestTimeDraft = $state("08:00");
+ function isValidHHMM(value: string): boolean {
+ return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value);
+ }
+ function addDigestTime() {
+ const v = digestTimeDraft;
+ if (!isValidHHMM(v)) return;
+ const list = Array.isArray(settings.nudgeDigestTimes) ? [...settings.nudgeDigestTimes] : [];
+ if (list.length >= 3) return;
+ if (list.includes(v)) return;
+ list.push(v);
+ list.sort();
+ settings.nudgeDigestTimes = list;
+ saveSettings();
+ }
+ function removeDigestTime(t: string) {
+ const list = Array.isArray(settings.nudgeDigestTimes) ? [...settings.nudgeDigestTimes] : [];
+ if (list.length <= 1) return;
+ settings.nudgeDigestTimes = list.filter((x) => x !== t);
+ saveSettings();
+ }
+
async function downloadModel(size) {
downloadingModel = size;
downloadProgress = 0;
@@ -1732,13 +1795,62 @@
Gentle, anticipatory reminders. Capped at 3 per hour. Never fires while you're looking at Corbie.
-
+
+
+
+ {#if nudgeModeLabel === "Off"}
+ Nudges are off. Triggers won't fire and nothing is queued.
+ {:else if nudgeModeLabel === "Immediate"}
+ Nudges fire when the trigger happens — capped at 3 per hour, never while Corbie has focus.
+ {:else}
+ Triggers are batched into a single notification at your chosen time(s) below.
+ {/if}
+
- {#if settings.nudgesEnabled}
+ {#if nudgeModeLabel === "Digest"}
+
+
Digest times
+
+ {#each (settings.nudgeDigestTimes ?? []) as t}
+
+ {t}
+ removeDigestTime(t)}
+ disabled={(settings.nudgeDigestTimes?.length ?? 0) <= 1}
+ aria-label="Remove {t}"
+ >×
+
+ {/each}
+
+
+
+ = 3 || !isValidHHMM(digestTimeDraft) || (settings.nudgeDigestTimes ?? []).includes(digestTimeDraft)}
+ >Add
+
+ {#if (settings.nudgeDigestTimes?.length ?? 0) <= 1}
+
At least one time required for digest.
+ {/if}
+ {#if (settings.nudgeDigestTimes?.length ?? 0) >= 3}
+
Maximum of three times. Remove one to add another.
+ {/if}
+
+ {/if}
+
+ {#if nudgeModeLabel !== "Off"}
| null = null;
let triagePollHandle: ReturnType | null = null;
+let digestCheckHandle: ReturnType | null = null;
// parentTaskId → scheduled timeout handle for "idle since step created".
// Cleared when a step or parent task gets marked done in the same session.
const microStepTimers = new Map>();
+// --- Mode helpers ----------------------------------------------------
+
+// `nudgeMode` is the source of truth; if for any reason it's missing
+// (older blob that hasn't passed through loadSettings yet) fall back
+// to the legacy boolean. This mirrors the migration in page.svelte.ts.
+function getNudgeMode(): "off" | "immediate" | "digest" {
+ const mode = settings.nudgeMode;
+ if (mode === "off" || mode === "immediate" || mode === "digest") return mode;
+ return settings.nudgesEnabled ? "immediate" : "off";
+}
+
// --- Permission ------------------------------------------------------
async function ensurePermission(): Promise {
@@ -93,8 +125,14 @@ function pruneRecentNudges(now: number): void {
}
function canNudgeNow(now: number): boolean {
- if (!settings.nudgesEnabled) return false;
+ const mode = getNudgeMode();
+ // Off path: no nudges, ever (including from a digest replay).
+ if (mode === "off") return false;
+ // Mute always wins, even over digest delivery.
if (settings.nudgesMuted) return false;
+ // Digest mode never fires the immediate path; the digest scheduler
+ // calls deliverDigestPayload() directly which bypasses canNudgeNow.
+ if (mode === "digest") return false;
// B3.1 — fresh-start window stays quiet on every trigger. The window
// is the user explicitly being told "this week starts fresh"; an OS
// notification an hour later would directly contradict that frame.
@@ -105,7 +143,7 @@ function canNudgeNow(now: number): boolean {
return true;
}
-// --- Delivery --------------------------------------------------------
+// --- Delivery (immediate) -------------------------------------------
async function deliver(title: string, body: string): Promise {
const now = Date.now();
@@ -132,6 +170,241 @@ async function deliver(title: string, body: string): Promise {
}
}
+// --- Digest queue (B3.2) --------------------------------------------
+
+function loadDigestQueue(): DigestQueueItem[] {
+ if (typeof localStorage === "undefined") return [];
+ const raw = localStorage.getItem(DIGEST_QUEUE_KEY);
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed.filter(
+ (item): item is DigestQueueItem =>
+ item
+ && typeof item.key === "string"
+ && typeof item.title === "string"
+ && typeof item.body === "string"
+ && typeof item.queuedAt === "number",
+ );
+ } catch {
+ return [];
+ }
+}
+
+function saveDigestQueue(queue: DigestQueueItem[]): void {
+ if (typeof localStorage === "undefined") return;
+ try {
+ if (queue.length === 0) {
+ localStorage.removeItem(DIGEST_QUEUE_KEY);
+ } else {
+ localStorage.setItem(DIGEST_QUEUE_KEY, JSON.stringify(queue));
+ }
+ } catch {
+ // Storage full / private mode — silently drop. The next trigger
+ // will try again; we don't want to surface an error here.
+ }
+}
+
+function pruneDigestQueue(queue: DigestQueueItem[], now: number): DigestQueueItem[] {
+ return queue.filter((item) => now - item.queuedAt < DAY_MS);
+}
+
+function enqueueDigestItem(key: string, title: string, body: string): void {
+ const now = Date.now();
+ const queue = pruneDigestQueue(loadDigestQueue(), now);
+ // Dedupe by key: most recent wins (replace the prior entry's text +
+ // timestamp). Keeps the digest body free of the same trigger fired
+ // twice between delivery slots.
+ const filtered = queue.filter((item) => item.key !== key);
+ filtered.push({ key, title, body, queuedAt: now });
+ saveDigestQueue(filtered);
+}
+
+/**
+ * Trigger entry point. Inspects the current nudge mode and either
+ * delivers immediately (preserving the existing suppression chain) or
+ * pushes the item onto the digest queue. Off-mode drops silently.
+ */
+function enqueueOrDeliver(key: string, title: string, body: string): void {
+ const mode = getNudgeMode();
+ if (mode === "off") return;
+ if (mode === "digest") {
+ enqueueDigestItem(key, title, body);
+ return;
+ }
+ void deliver(title, body);
+}
+
+// --- Digest delivery ------------------------------------------------
+
+interface LastDeliveryMap {
+ [slotKey: string]: true;
+}
+
+function loadLastDelivery(): LastDeliveryMap {
+ if (typeof localStorage === "undefined") return {};
+ const raw = localStorage.getItem(DIGEST_LAST_DELIVERY_KEY);
+ if (!raw) return {};
+ try {
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ return parsed as LastDeliveryMap;
+ }
+ return {};
+ } catch {
+ return {};
+ }
+}
+
+function saveLastDelivery(map: LastDeliveryMap): void {
+ if (typeof localStorage === "undefined") return;
+ try {
+ localStorage.setItem(DIGEST_LAST_DELIVERY_KEY, JSON.stringify(map));
+ } catch {
+ // Same reasoning as saveDigestQueue.
+ }
+}
+
+// Drop entries whose date prefix is older than 7 days. Keeps the map
+// from growing unbounded across long-running installs.
+function pruneLastDelivery(map: LastDeliveryMap, now: number): LastDeliveryMap {
+ const cutoff = now - 7 * DAY_MS;
+ const out: LastDeliveryMap = {};
+ for (const key of Object.keys(map)) {
+ // key shape: "YYYY-MM-DD@HH:MM"
+ const datePart = key.split("@")[0];
+ if (!datePart) continue;
+ const parsed = Date.parse(datePart + "T00:00:00");
+ if (Number.isNaN(parsed)) continue;
+ if (parsed >= cutoff) out[key] = true;
+ }
+ return out;
+}
+
+function todayLocalKey(): string {
+ const d = new Date();
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+}
+
+function isValidHHMM(value: string): boolean {
+ // Strict HH:MM, 24-hour, with leading zeros required. 00:00–23:59.
+ // Anti-requirement: never accept 24:00 or non-padded forms like "8:00".
+ const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
+ return m !== null;
+}
+
+function scheduledTimeForToday(hhmm: string, now: Date): Date | null {
+ if (!isValidHHMM(hhmm)) return null;
+ const [hh, mm] = hhmm.split(":").map(Number);
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate(), hh, mm, 0, 0);
+}
+
+function buildDigestBody(items: DigestQueueItem[]): { title: string; body: string } {
+ const N = items.length;
+ const title = `Kon — ${N} signal${N === 1 ? "" : "s"}`;
+ const lines = items.slice(0, 3).map((item) => `• ${item.title}`);
+ if (N > 3) lines.push(`…and ${N - 3} more.`);
+ return { title, body: lines.join("\n") };
+}
+
+async function deliverDigestPayload(items: DigestQueueItem[]): Promise {
+ if (items.length === 0) return;
+ // Mute is the only suppression that applies to digest delivery —
+ // focus and the hourly cap don't apply because the user explicitly
+ // chose this delivery time. Mode-off is enforced by the caller.
+ if (settings.nudgesMuted) return;
+ const ok = await ensurePermission();
+ if (!ok) return;
+ const { title, body } = buildDigestBody(items);
+ try {
+ await invoke("deliver_nudge", { input: { title, body } });
+ } catch {
+ // fire-and-forget
+ }
+ if (settings.nudgesSpeakAloud) {
+ try {
+ await invoke("tts_speak", {
+ text: body || title,
+ rate: settings.ttsRate,
+ voice: settings.ttsVoice ?? null,
+ });
+ } catch {
+ // fire-and-forget
+ }
+ }
+}
+
+async function checkAndDeliverDigests(): Promise {
+ if (getNudgeMode() !== "digest") return;
+ const slots = Array.isArray(settings.nudgeDigestTimes) ? settings.nudgeDigestTimes : [];
+ if (slots.length === 0) return;
+ const now = new Date();
+ const nowMs = now.getTime();
+ const dayKey = todayLocalKey();
+ let delivery = pruneLastDelivery(loadLastDelivery(), nowMs);
+ let dirty = false;
+ for (const slot of slots) {
+ if (!isValidHHMM(slot)) continue;
+ const scheduled = scheduledTimeForToday(slot, now);
+ if (!scheduled) continue;
+ if (now < scheduled) continue;
+ const slotKey = `${dayKey}@${slot}`;
+ if (delivery[slotKey]) continue;
+ // Refresh the queue inside the loop in case an earlier slot in
+ // the same tick already consumed it (unlikely with 5-min cadence
+ // but cheap to be safe).
+ const queue = pruneDigestQueue(loadDigestQueue(), nowMs);
+ if (queue.length === 0) {
+ // Stamp anyway so we don't keep re-checking an empty slot all
+ // day — the user picked the time, not the content.
+ delivery[slotKey] = true;
+ dirty = true;
+ continue;
+ }
+ await deliverDigestPayload(queue);
+ saveDigestQueue([]);
+ delivery[slotKey] = true;
+ dirty = true;
+ }
+ if (dirty) saveLastDelivery(delivery);
+}
+
+// --- Mode-switch hygiene (B3.2) -------------------------------------
+
+/**
+ * Called from the Settings UI when the user changes `nudgeMode`. The
+ * Settings page is responsible for writing both `nudgeMode` and the
+ * back-compat `nudgesEnabled` boolean; this function only handles the
+ * queue / replay side-effects.
+ */
+export async function handleNudgeModeChange(
+ prev: "off" | "immediate" | "digest",
+ next: "off" | "immediate" | "digest",
+): Promise {
+ if (prev === next) return;
+ if (prev === "digest" && next === "off") {
+ // Bin the queue — the user explicitly turned nudges off. Anything
+ // still in there would be a surprise next time digest is re-enabled.
+ saveDigestQueue([]);
+ return;
+ }
+ if (prev === "digest" && next === "immediate") {
+ // Replay each queued item through the immediate path. Each call
+ // re-checks the suppression chain (focus, hourly cap, fresh-start),
+ // so a flood of replays still respects the cap.
+ const queue = pruneDigestQueue(loadDigestQueue(), Date.now());
+ saveDigestQueue([]);
+ for (const item of queue) {
+ // eslint-disable-next-line no-await-in-loop
+ await deliver(item.title, item.body);
+ }
+ return;
+ }
+ // immediate→digest, off→*, immediate→off: nothing to clean up here.
+ // (off→immediate / off→digest don't have a queue worth replaying.)
+}
+
// --- Trigger: inactivity with active timer ---------------------------
function onTimerStart(event: Event) {
@@ -164,7 +437,8 @@ function checkInactivityWithActiveTimer(now: number) {
if (blurredAt === null) return;
if (now - blurredAt < INACTIVITY_TIMER_THRESHOLD_MS) return;
timerNudgeFiredThisSession = true;
- void deliver(
+ enqueueOrDeliver(
+ "timer-inactivity",
"Timer's still running.",
"It's been ticking while you've been away. Pick up where you left off, or stop it.",
);
@@ -172,11 +446,6 @@ function checkInactivityWithActiveTimer(now: number) {
// --- Trigger: pending morning triage ---------------------------------
-function todayLocalKey(): string {
- const d = new Date();
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
-}
-
let triageNudgeFiredOnKey: string | null = null;
async function checkPendingMorningTriage() {
@@ -194,7 +463,8 @@ async function checkPendingMorningTriage() {
}
if (lastShown === today) return;
triageNudgeFiredOnKey = today;
- void deliver(
+ enqueueOrDeliver(
+ "morning-triage",
"A few things waiting",
"When you're ready, Corbie has your morning list. No rush.",
);
@@ -209,7 +479,8 @@ function onMicroStepGenerated(event: Event) {
clearMicroStepTimer(parentTaskId);
const handle = setTimeout(() => {
microStepTimers.delete(parentTaskId);
- void deliver(
+ enqueueOrDeliver(
+ `microstep-idle:${parentTaskId}`,
"Still with that one?",
"Your breakdown is here when you want to pick the first step.",
);
@@ -264,6 +535,15 @@ export function startNudgeBus(): void {
triagePollHandle = setInterval(() => {
void checkPendingMorningTriage();
}, MORNING_TRIAGE_CHECK_INTERVAL_MS);
+
+ // B3.2 — digest scheduling. Prune stale items at boot, then check
+ // immediately and on a 5-min cadence. The check is a no-op outside
+ // digest mode, so leaving the interval running is cheap.
+ saveDigestQueue(pruneDigestQueue(loadDigestQueue(), Date.now()));
+ void checkAndDeliverDigests();
+ digestCheckHandle = setInterval(() => {
+ void checkAndDeliverDigests();
+ }, DIGEST_CHECK_INTERVAL_MS);
}
export function stopNudgeBus(): void {
@@ -287,6 +567,10 @@ export function stopNudgeBus(): void {
clearInterval(triagePollHandle);
triagePollHandle = null;
}
+ if (digestCheckHandle !== null) {
+ clearInterval(digestCheckHandle);
+ digestCheckHandle = null;
+ }
for (const handle of microStepTimers.values()) clearTimeout(handle);
microStepTimers.clear();