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.

- + +
+

Delivery

+ +
+

+ {#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} + + + {/each} +
+
+ + +
+ {#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();