// Phase 6 Margot nudge bus. Frontend-owned, subscribes to in-app // signals Lumotia already produces, applies suppression, and fans out // to OS notification (via Rust's deliver_nudge) + optional TTS. // // Why frontend-only: OS-wide keyboard/window detection is fragile // across Wayland/macOS/Windows (no sanctioned global-keyboard API on // Wayland, accessibility permission on macOS, message-loop hook on // Windows). The app-local signals we already have — timer state, // task completion, micro-step creation, app focus/visibility — cover // the Phase 6 trigger set without the platform pain. // // Suppression rules: // - nudgesEnabled && !nudgesMuted // - 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 // // Trigger set v1 (in-app only): // - inactivity_with_active_timer // - pending_morning_triage // - micro_step_idle import { invoke } from "@tauri-apps/api/core"; import { hasTauriRuntime } from "$lib/utils/runtime.js"; import { settings } from "$lib/stores/page.svelte.js"; const HOUR_MS = 60 * 60 * 1000; 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; interface TimerStartPayload { taskId?: string; seconds?: number; } interface MicroStepPayload { parentTaskId: string; } let started = false; let permissionRequested = false; let permissionGranted = false; // Rolling timestamps of recent nudge deliveries (ms since epoch). // Older-than-one-hour entries are pruned lazily on each check. const recentNudges: number[] = []; // Per-trigger state. let timerRunning = false; let timerStartedAt = 0; let timerNudgeFiredThisSession = false; let blurredAt: number | null = null; let blurCheckHandle: ReturnType | null = null; let triagePollHandle: ReturnType | 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>(); // --- Permission ------------------------------------------------------ async function ensurePermission(): Promise { if (!hasTauriRuntime()) return false; if (permissionRequested) return permissionGranted; permissionRequested = true; try { const plugin = await import("@tauri-apps/plugin-notification"); const already = await plugin.isPermissionGranted(); if (already) { permissionGranted = true; return true; } const result = await plugin.requestPermission(); permissionGranted = result === "granted"; return permissionGranted; } catch { permissionGranted = false; return false; } } // --- Suppression ----------------------------------------------------- function pruneRecentNudges(now: number): void { while (recentNudges.length > 0 && now - recentNudges[0] > HOUR_MS) { recentNudges.shift(); } } function canNudgeNow(now: number): boolean { if (!settings.nudgesEnabled) return false; if (settings.nudgesMuted) return false; if (typeof document !== "undefined" && document.hasFocus()) return false; pruneRecentNudges(now); if (recentNudges.length >= 3) return false; return true; } // --- Delivery -------------------------------------------------------- async function deliver(title: string, body: string): Promise { const now = Date.now(); if (!canNudgeNow(now)) return; const ok = await ensurePermission(); if (!ok) return; recentNudges.push(now); try { await invoke("deliver_nudge", { input: { title, body } }); } catch { // Nudges are fire-and-forget — surfacing an error to the user // would defeat the "anticipatory, not push-notification" framing. } if (settings.nudgesSpeakAloud) { try { await invoke("tts_speak", { text: body || title, rate: settings.ttsRate, voice: settings.ttsVoice ?? null, }); } catch { // Same reasoning — never surface TTS failures through a nudge. } } } // --- Trigger: inactivity with active timer --------------------------- function onTimerStart(event: Event) { const detail = (event as CustomEvent).detail; timerRunning = true; timerStartedAt = Date.now(); timerNudgeFiredThisSession = false; // If the user switched away before starting the timer, the next // blur check (below) will pick it up. void detail; } function resetTimerState() { timerRunning = false; timerStartedAt = 0; timerNudgeFiredThisSession = false; } function onFocus() { blurredAt = null; } function onBlur() { blurredAt = Date.now(); } function checkInactivityWithActiveTimer(now: number) { if (!timerRunning || timerNudgeFiredThisSession) return; if (now - timerStartedAt < TIMER_WARMUP_MS) return; if (blurredAt === null) return; if (now - blurredAt < INACTIVITY_TIMER_THRESHOLD_MS) return; timerNudgeFiredThisSession = true; void deliver( "Still on that timer?", "It's been ticking while you've been away. Pick up where you left off, or stop it.", ); } // --- 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() { if (!settings.ritualsMorning) return; if (!hasTauriRuntime()) return; const now = new Date(); if (now.getHours() < MORNING_TRIAGE_TRIGGER_HOUR) return; const today = todayLocalKey(); if (triageNudgeFiredOnKey === today) return; let lastShown: string | null = null; try { lastShown = await invoke("get_last_morning_triage"); } catch { return; } if (lastShown === today) return; triageNudgeFiredOnKey = today; void deliver( "A few things waiting", "When you're ready, Lumotia has your morning list. No rush.", ); } // --- Trigger: micro-step idle ---------------------------------------- function onMicroStepGenerated(event: Event) { const detail = (event as CustomEvent).detail; if (!detail?.parentTaskId) return; const parentTaskId = detail.parentTaskId; clearMicroStepTimer(parentTaskId); const handle = setTimeout(() => { microStepTimers.delete(parentTaskId); void deliver( "Still with that one?", "Your breakdown is here when you want to pick the first step.", ); }, MICRO_STEP_IDLE_MS); microStepTimers.set(parentTaskId, handle); } function clearMicroStepTimer(parentTaskId: string) { const handle = microStepTimers.get(parentTaskId); if (handle !== undefined) { clearTimeout(handle); microStepTimers.delete(parentTaskId); } } function onStepOrTaskCompleted(event: Event) { const detail = (event as CustomEvent<{ id?: string; parentTaskId?: string }>).detail; // A completed subtask clears its parent's idle timer; a completed // parent task clears its own idle timer (the step decomposition // attached to that task is no longer waiting on anyone). if (detail?.parentTaskId) clearMicroStepTimer(detail.parentTaskId); if (detail?.id) clearMicroStepTimer(detail.id); } // --- Lifecycle ------------------------------------------------------- export function startNudgeBus(): void { if (started) return; if (typeof window === "undefined") return; started = true; window.addEventListener("lumotia:start-timer", onTimerStart); window.addEventListener("lumotia:focus-timer-complete", resetTimerState); window.addEventListener("lumotia:focus-timer-cancelled", resetTimerState); window.addEventListener("lumotia:microstep-generated", onMicroStepGenerated); window.addEventListener("lumotia:step-completed", onStepOrTaskCompleted); window.addEventListener("lumotia:task-completed", onStepOrTaskCompleted); window.addEventListener("focus", onFocus); window.addEventListener("blur", onBlur); // Inactivity check runs while a timer is live. Low frequency is fine // — the nudge itself only fires once the 90 s threshold is crossed. blurCheckHandle = setInterval(() => { checkInactivityWithActiveTimer(Date.now()); }, 10_000); // Morning triage check runs on an interval + one immediate probe on // start. The Rust `get_last_morning_triage` call is cheap (one // SQLite read), so polling every 5 min costs nothing and means we // catch the 10:00 threshold without a scheduler. void checkPendingMorningTriage(); triagePollHandle = setInterval(() => { void checkPendingMorningTriage(); }, MORNING_TRIAGE_CHECK_INTERVAL_MS); } export function stopNudgeBus(): void { if (!started) return; started = false; window.removeEventListener("lumotia:start-timer", onTimerStart); window.removeEventListener("lumotia:focus-timer-complete", resetTimerState); window.removeEventListener("lumotia:focus-timer-cancelled", resetTimerState); window.removeEventListener("lumotia:microstep-generated", onMicroStepGenerated); window.removeEventListener("lumotia:step-completed", onStepOrTaskCompleted); window.removeEventListener("lumotia:task-completed", onStepOrTaskCompleted); window.removeEventListener("focus", onFocus); window.removeEventListener("blur", onBlur); if (blurCheckHandle !== null) { clearInterval(blurCheckHandle); blurCheckHandle = null; } if (triagePollHandle !== null) { clearInterval(triagePollHandle); triagePollHandle = null; } for (const handle of microStepTimers.values()) clearTimeout(handle); microStepTimers.clear(); resetTimerState(); blurredAt = null; triageNudgeFiredOnKey = null; permissionRequested = false; }