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