feat(nudges): B3.2 — notifications digest mode

settings.nudgeMode (Off / Immediate / Digest) replaces the binary
nudgesEnabled toggle in the Settings UI; the field stays in storage
mirrored from nudgeMode for back-compat readers. Digest mode queues
trigger events (deduped by key) into a localStorage-persisted buffer
and delivers them as a single aggregated notification at user-chosen
times of day (1–3 slots). Body shows up to 3 lines with a "...and
N more" truncation tail and honours nudgesSpeakAloud. Switching from
digest to off clears the queue; digest to immediate replays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 19:45:11 +01:00
parent 2668401104
commit 423c0caca4
2 changed files with 414 additions and 18 deletions

View File

@@ -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.
</p>
<Toggle
bind:checked={settings.nudgesEnabled}
label="Enable nudges"
description="Soft-touch notifications when a timer has been ticking while you're away, when a morning triage is waiting, or when a micro-step decomposition has sat untouched for 15 minutes."
/>
<!-- B3.2 — Off / Immediate / Digest. The chosen mode drives
nudgeBus.canNudgeNow + the digest queue/scheduler. The
legacy nudgesEnabled boolean is mirrored from this
picker for back-compat readers. -->
<div class="flex items-center justify-between gap-3 mb-2">
<p class="text-[12px] text-text-secondary">Delivery</p>
<SegmentedButton options={[...NUDGE_MODE_LABELS]} bind:value={nudgeModeLabel} size="small" />
</div>
<p class="text-[11px] text-text-tertiary mb-3">
{#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}
</p>
{#if settings.nudgesEnabled}
{#if nudgeModeLabel === "Digest"}
<div class="mt-3 mb-2 pl-1 animate-fade-in">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Digest times</p>
<div class="flex flex-wrap gap-2 mb-3">
{#each (settings.nudgeDigestTimes ?? []) as t}
<span class="inline-flex items-center gap-1.5 bg-bg-elevated rounded-full px-2.5 py-1 text-[12px] text-text">
{t}
<button
class="text-text-tertiary hover:text-text leading-none text-[14px] disabled:opacity-30 disabled:cursor-not-allowed"
onclick={() => removeDigestTime(t)}
disabled={(settings.nudgeDigestTimes?.length ?? 0) <= 1}
aria-label="Remove {t}"
>×</button>
</span>
{/each}
</div>
<div class="flex items-center gap-2">
<input
type="time"
bind:value={digestTimeDraft}
class="bg-bg-elevated border border-border-subtle rounded-md px-2 py-1 text-[12px] text-text"
/>
<button
class="text-[12px] px-3 py-1 rounded-md bg-accent text-bg disabled:opacity-40 disabled:cursor-not-allowed"
onclick={addDigestTime}
disabled={(settings.nudgeDigestTimes?.length ?? 0) >= 3 || !isValidHHMM(digestTimeDraft) || (settings.nudgeDigestTimes ?? []).includes(digestTimeDraft)}
>Add</button>
</div>
{#if (settings.nudgeDigestTimes?.length ?? 0) <= 1}
<p class="text-[11px] text-text-tertiary mt-2">At least one time required for digest.</p>
{/if}
{#if (settings.nudgeDigestTimes?.length ?? 0) >= 3}
<p class="text-[11px] text-text-tertiary mt-2">Maximum of three times. Remove one to add another.</p>
{/if}
</div>
{/if}
{#if nudgeModeLabel !== "Off"}
<div class="pl-1 py-1 animate-fade-in">
<Toggle
bind:checked={settings.nudgesMuted}

View File

@@ -9,8 +9,10 @@
// task completion, micro-step creation, app focus/visibility — cover
// the Phase 6 trigger set without the platform pain.
//
// Suppression rules:
// - nudgesEnabled && !nudgesMuted
// Suppression rules (immediate mode):
// - settings.nudgeMode === "immediate" (else: off → drop, digest → queue)
// - !nudgesMuted (mute always wins, including over digest)
// - !inFreshStartWindow (B3.1)
// - document has focus → never nudge (user is already looking)
// - rolling 1-hour cap of 3 nudges
// - timer-specific: no nudge in first 60 s of a running timer
@@ -19,6 +21,12 @@
// - inactivity_with_active_timer
// - pending_morning_triage
// - micro_step_idle
//
// B3.2 — Digest mode: triggers still fire, but instead of calling
// deliver() they push to a localStorage-persisted queue (deduped by
// key). On a 5-minute interval the bus checks the user's configured
// digest times-of-day; when one is reached for today and not yet
// fulfilled, it aggregates the queue into one notification.
import { invoke } from "@tauri-apps/api/core";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
@@ -26,12 +34,17 @@ import { settings } from "$lib/stores/page.svelte.js";
import { inFreshStartWindow } from "$lib/utils/freshStart.js";
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
const INACTIVITY_TIMER_THRESHOLD_MS = 90_000;
const TIMER_WARMUP_MS = 60_000;
const MICRO_STEP_IDLE_MS = 15 * 60_000;
const MORNING_TRIAGE_CHECK_INTERVAL_MS = 5 * 60_000;
const MORNING_TRIAGE_TRIGGER_HOUR = 10;
const DIGEST_QUEUE_KEY = "kon.nudge.digest.v1";
const DIGEST_LAST_DELIVERY_KEY = "kon.nudge.digest.lastDelivery.v1";
const DIGEST_CHECK_INTERVAL_MS = 5 * 60_000;
interface TimerStartPayload {
taskId?: string;
seconds?: number;
@@ -41,6 +54,13 @@ interface MicroStepPayload {
parentTaskId: string;
}
interface DigestQueueItem {
key: string;
title: string;
body: string;
queuedAt: number;
}
let started = false;
let permissionRequested = false;
let permissionGranted = false;
@@ -57,11 +77,23 @@ let blurredAt: number | null = null;
let blurCheckHandle: ReturnType<typeof setInterval> | null = null;
let triagePollHandle: ReturnType<typeof setInterval> | null = null;
let digestCheckHandle: ReturnType<typeof setInterval> | 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<string, ReturnType<typeof setTimeout>>();
// --- 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<boolean> {
@@ -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<void> {
const now = Date.now();
@@ -132,6 +170,241 @@ async function deliver(title: string, body: string): Promise<void> {
}
}
// --- 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:0023: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<void> {
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<void> {
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<void> {
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();