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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user