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}