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>
293 lines
9.7 KiB
TypeScript
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;
|
|
}
|