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
|
// that just mutates the page-local llmLoaded bool. The store
|
||||||
// version drives the sidebar chip (brief item #31).
|
// 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 { 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();
|
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) {
|
async function downloadModel(size) {
|
||||||
downloadingModel = size;
|
downloadingModel = size;
|
||||||
downloadProgress = 0;
|
downloadProgress = 0;
|
||||||
@@ -1732,13 +1795,62 @@
|
|||||||
Gentle, anticipatory reminders. Capped at 3 per hour. Never fires while you're looking at Corbie.
|
Gentle, anticipatory reminders. Capped at 3 per hour. Never fires while you're looking at Corbie.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Toggle
|
<!-- B3.2 — Off / Immediate / Digest. The chosen mode drives
|
||||||
bind:checked={settings.nudgesEnabled}
|
nudgeBus.canNudgeNow + the digest queue/scheduler. The
|
||||||
label="Enable nudges"
|
legacy nudgesEnabled boolean is mirrored from this
|
||||||
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."
|
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">
|
<div class="pl-1 py-1 animate-fade-in">
|
||||||
<Toggle
|
<Toggle
|
||||||
bind:checked={settings.nudgesMuted}
|
bind:checked={settings.nudgesMuted}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
// task completion, micro-step creation, app focus/visibility — cover
|
// task completion, micro-step creation, app focus/visibility — cover
|
||||||
// the Phase 6 trigger set without the platform pain.
|
// the Phase 6 trigger set without the platform pain.
|
||||||
//
|
//
|
||||||
// Suppression rules:
|
// Suppression rules (immediate mode):
|
||||||
// - nudgesEnabled && !nudgesMuted
|
// - 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)
|
// - document has focus → never nudge (user is already looking)
|
||||||
// - rolling 1-hour cap of 3 nudges
|
// - rolling 1-hour cap of 3 nudges
|
||||||
// - timer-specific: no nudge in first 60 s of a running timer
|
// - timer-specific: no nudge in first 60 s of a running timer
|
||||||
@@ -19,6 +21,12 @@
|
|||||||
// - inactivity_with_active_timer
|
// - inactivity_with_active_timer
|
||||||
// - pending_morning_triage
|
// - pending_morning_triage
|
||||||
// - micro_step_idle
|
// - 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 { invoke } from "@tauri-apps/api/core";
|
||||||
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
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";
|
import { inFreshStartWindow } from "$lib/utils/freshStart.js";
|
||||||
|
|
||||||
const HOUR_MS = 60 * 60 * 1000;
|
const HOUR_MS = 60 * 60 * 1000;
|
||||||
|
const DAY_MS = 24 * HOUR_MS;
|
||||||
const INACTIVITY_TIMER_THRESHOLD_MS = 90_000;
|
const INACTIVITY_TIMER_THRESHOLD_MS = 90_000;
|
||||||
const TIMER_WARMUP_MS = 60_000;
|
const TIMER_WARMUP_MS = 60_000;
|
||||||
const MICRO_STEP_IDLE_MS = 15 * 60_000;
|
const MICRO_STEP_IDLE_MS = 15 * 60_000;
|
||||||
const MORNING_TRIAGE_CHECK_INTERVAL_MS = 5 * 60_000;
|
const MORNING_TRIAGE_CHECK_INTERVAL_MS = 5 * 60_000;
|
||||||
const MORNING_TRIAGE_TRIGGER_HOUR = 10;
|
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 {
|
interface TimerStartPayload {
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
seconds?: number;
|
seconds?: number;
|
||||||
@@ -41,6 +54,13 @@ interface MicroStepPayload {
|
|||||||
parentTaskId: string;
|
parentTaskId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DigestQueueItem {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
queuedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
let started = false;
|
let started = false;
|
||||||
let permissionRequested = false;
|
let permissionRequested = false;
|
||||||
let permissionGranted = false;
|
let permissionGranted = false;
|
||||||
@@ -57,11 +77,23 @@ let blurredAt: number | null = null;
|
|||||||
let blurCheckHandle: ReturnType<typeof setInterval> | null = null;
|
let blurCheckHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
let triagePollHandle: 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".
|
// parentTaskId → scheduled timeout handle for "idle since step created".
|
||||||
// Cleared when a step or parent task gets marked done in the same session.
|
// Cleared when a step or parent task gets marked done in the same session.
|
||||||
const microStepTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
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 ------------------------------------------------------
|
// --- Permission ------------------------------------------------------
|
||||||
|
|
||||||
async function ensurePermission(): Promise<boolean> {
|
async function ensurePermission(): Promise<boolean> {
|
||||||
@@ -93,8 +125,14 @@ function pruneRecentNudges(now: number): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canNudgeNow(now: number): boolean {
|
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;
|
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
|
// B3.1 — fresh-start window stays quiet on every trigger. The window
|
||||||
// is the user explicitly being told "this week starts fresh"; an OS
|
// is the user explicitly being told "this week starts fresh"; an OS
|
||||||
// notification an hour later would directly contradict that frame.
|
// notification an hour later would directly contradict that frame.
|
||||||
@@ -105,7 +143,7 @@ function canNudgeNow(now: number): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Delivery --------------------------------------------------------
|
// --- Delivery (immediate) -------------------------------------------
|
||||||
|
|
||||||
async function deliver(title: string, body: string): Promise<void> {
|
async function deliver(title: string, body: string): Promise<void> {
|
||||||
const now = Date.now();
|
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 ---------------------------
|
// --- Trigger: inactivity with active timer ---------------------------
|
||||||
|
|
||||||
function onTimerStart(event: Event) {
|
function onTimerStart(event: Event) {
|
||||||
@@ -164,7 +437,8 @@ function checkInactivityWithActiveTimer(now: number) {
|
|||||||
if (blurredAt === null) return;
|
if (blurredAt === null) return;
|
||||||
if (now - blurredAt < INACTIVITY_TIMER_THRESHOLD_MS) return;
|
if (now - blurredAt < INACTIVITY_TIMER_THRESHOLD_MS) return;
|
||||||
timerNudgeFiredThisSession = true;
|
timerNudgeFiredThisSession = true;
|
||||||
void deliver(
|
enqueueOrDeliver(
|
||||||
|
"timer-inactivity",
|
||||||
"Timer's still running.",
|
"Timer's still running.",
|
||||||
"It's been ticking while you've been away. Pick up where you left off, or stop it.",
|
"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 ---------------------------------
|
// --- 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;
|
let triageNudgeFiredOnKey: string | null = null;
|
||||||
|
|
||||||
async function checkPendingMorningTriage() {
|
async function checkPendingMorningTriage() {
|
||||||
@@ -194,7 +463,8 @@ async function checkPendingMorningTriage() {
|
|||||||
}
|
}
|
||||||
if (lastShown === today) return;
|
if (lastShown === today) return;
|
||||||
triageNudgeFiredOnKey = today;
|
triageNudgeFiredOnKey = today;
|
||||||
void deliver(
|
enqueueOrDeliver(
|
||||||
|
"morning-triage",
|
||||||
"A few things waiting",
|
"A few things waiting",
|
||||||
"When you're ready, Corbie has your morning list. No rush.",
|
"When you're ready, Corbie has your morning list. No rush.",
|
||||||
);
|
);
|
||||||
@@ -209,7 +479,8 @@ function onMicroStepGenerated(event: Event) {
|
|||||||
clearMicroStepTimer(parentTaskId);
|
clearMicroStepTimer(parentTaskId);
|
||||||
const handle = setTimeout(() => {
|
const handle = setTimeout(() => {
|
||||||
microStepTimers.delete(parentTaskId);
|
microStepTimers.delete(parentTaskId);
|
||||||
void deliver(
|
enqueueOrDeliver(
|
||||||
|
`microstep-idle:${parentTaskId}`,
|
||||||
"Still with that one?",
|
"Still with that one?",
|
||||||
"Your breakdown is here when you want to pick the first step.",
|
"Your breakdown is here when you want to pick the first step.",
|
||||||
);
|
);
|
||||||
@@ -264,6 +535,15 @@ export function startNudgeBus(): void {
|
|||||||
triagePollHandle = setInterval(() => {
|
triagePollHandle = setInterval(() => {
|
||||||
void checkPendingMorningTriage();
|
void checkPendingMorningTriage();
|
||||||
}, MORNING_TRIAGE_CHECK_INTERVAL_MS);
|
}, 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 {
|
export function stopNudgeBus(): void {
|
||||||
@@ -287,6 +567,10 @@ export function stopNudgeBus(): void {
|
|||||||
clearInterval(triagePollHandle);
|
clearInterval(triagePollHandle);
|
||||||
triagePollHandle = null;
|
triagePollHandle = null;
|
||||||
}
|
}
|
||||||
|
if (digestCheckHandle !== null) {
|
||||||
|
clearInterval(digestCheckHandle);
|
||||||
|
digestCheckHandle = null;
|
||||||
|
}
|
||||||
for (const handle of microStepTimers.values()) clearTimeout(handle);
|
for (const handle of microStepTimers.values()) clearTimeout(handle);
|
||||||
microStepTimers.clear();
|
microStepTimers.clear();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user