Files
Lumotia/src/lib/stores/nudgeBus.svelte.ts
Jake 681a9b26dc
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
agent: lumotia-rebrand — frontend strings (svelte + i18n + design-system)
Phase 8 of the rebrand cascade. Every rendered string is now Lumotia;
no Magnotia surface visible in the UI.

Sweep replaced \bmagnotia\b -> lumotia and \bMagnotia\b -> Lumotia
across all .svelte / .ts / .js / .css / .html / .json (excluding
package-lock.json which regenerates, target/, build/, node_modules/).

Surfaces touched:
- src/app.css — design-token comment header and .magnotia-rh-* CSS
  resize-handle class selectors (also the consuming elements in
  components/ResizeHandles.svelte and src/routes/*/+layout.svelte).
- src/lib/i18n/locales/{en,de,es}.json — brand name in translations.
- src/lib/i18n/index.ts — header comment.
- src/lib/Sidebar.svelte and most pages under src/lib/pages/ +
  src/lib/components/ — title bars, document titles, default
  filenames (lumotia-YYYY-MM-DD.* etc), toast strings, error
  messages, dialog headers.
- src/routes/+layout.svelte, +page.svelte, viewer/, float/, preview/.
- src/app.html page <title>.
- src/lib/utils/settingsMigrations.ts — fallback toast copy.
- src/design-system/{colors_and_type.css,SKILL.md,README.md,
  ui_kits/{Sidebar.jsx,index.html}} — design-tokens, doc strings,
  preview wordmark in the kit.
- package.json — name + description.

NOT touched (deferred / immutable):
- package-lock.json — regenerates on next npm install.
- The two migration-call sites in stores reference the legacy magnotia
  keys deliberately; restored after the sweep clobbered them.
- docs/, README.md, HANDOVER.md — Phase 9 scope.

npm run check: 0 errors / 0 warnings (3958 files).
cargo test --workspace: 339 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:29:37 +01:00

293 lines
9.7 KiB
TypeScript

// 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<typeof setInterval> | null = null;
let triagePollHandle: 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>>();
// --- Permission ------------------------------------------------------
async function ensurePermission(): Promise<boolean> {
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<void> {
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<TimerStartPayload>).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<string | null>("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<MicroStepPayload>).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;
}