From 423c0caca45c861878e49a42be3171e6c8ef49bb Mon Sep 17 00:00:00 2001
From: Jake
Date: Sun, 26 Apr 2026 19:45:11 +0100
Subject: [PATCH] =?UTF-8?q?feat(nudges):=20B3.2=20=E2=80=94=20notification?=
=?UTF-8?q?s=20digest=20mode?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
src/lib/pages/SettingsPage.svelte | 124 +++++++++++-
src/lib/stores/nudgeBus.svelte.ts | 308 ++++++++++++++++++++++++++++--
2 files changed, 414 insertions(+), 18 deletions(-)
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}
+