// Phase 7 implementation intentions. Rules are persisted in SQLite via // Tauri commands; this frontend runner binds them to the Phase 6 event // bus and executes their small local actions. import { invoke } from "@tauri-apps/api/core"; import type { ImplementationRule, ImplementationRuleAction, ImplementationRuleDraft, } from "$lib/types/app"; import { hasTauriRuntime } from "$lib/utils/runtime.js"; import { page, settings, tasks } from "$lib/stores/page.svelte.js"; import { toasts } from "$lib/stores/toasts.svelte.js"; const TIME_RULE_POLL_MS = 30_000; const RULES_CHANGED_EVENT = "lumotia:implementation-rules-changed"; export const implementationRules = $state([]); let loaded = false; let started = false; let timePollHandle: ReturnType | null = null; const firingRules = new Set(); function todayLocalKey(): string { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function currentHHMM(): string { const d = new Date(); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; } function hasTimeAlreadyPassed(hhmm: string): boolean { return currentHHMM() >= hhmm; } export function initialLastFiredKeyForTimeRule(hhmm: string): string | null { return hasTimeAlreadyPassed(hhmm) ? `${todayLocalKey()}@${hhmm}` : null; } function replaceRule(next: ImplementationRule): void { const idx = implementationRules.findIndex((rule) => rule.id === next.id); if (idx >= 0) implementationRules[idx] = next; else implementationRules.unshift(next); } function broadcastRulesChanged(): void { if (typeof window === "undefined") return; window.dispatchEvent(new CustomEvent(RULES_CHANGED_EVENT)); } export async function loadImplementationRules(force = false): Promise { if (!hasTauriRuntime()) { implementationRules.length = 0; loaded = true; return; } if (loaded && !force) return; const rows = await invoke("list_implementation_rules"); implementationRules.splice(0, implementationRules.length, ...rows); loaded = true; } export async function createImplementationRule( draft: ImplementationRuleDraft, ): Promise { const row = await invoke("create_implementation_rule", { request: draft }); replaceRule(row); broadcastRulesChanged(); return row; } export async function setImplementationRuleEnabled(id: string, enabled: boolean): Promise { const row = await invoke("set_implementation_rule_enabled", { id, enabled }); replaceRule(row); broadcastRulesChanged(); } export async function deleteImplementationRule(id: string): Promise { await invoke("delete_implementation_rule", { id }); const idx = implementationRules.findIndex((rule) => rule.id === id); if (idx >= 0) implementationRules.splice(idx, 1); broadcastRulesChanged(); } async function markRuleFired(rule: ImplementationRule, firedKey: string): Promise { const row = await invoke("mark_implementation_rule_fired", { id: rule.id, firedKey, }); replaceRule(row); } function actionLabel(action: ImplementationRuleAction): string { if (action.kind === "speak_line") return `Speak "${action.text}"`; if (action.kind === "start_timer") return "Start a 5-minute timer"; if (action.kind === "surface") { if (action.target === "task") return `Surface ${action.label || "task"}`; if (action.target === "inbox") return "Surface inbox"; if (action.target === "today") return "Surface today"; return "Surface tasks"; } return "Action"; } export function ruleSummary(rule: ImplementationRule): string { const when = rule.triggerKind === "time_of_day" ? `At ${rule.triggerValue}` : rule.triggerKind === "task_completed" ? "After a task completes" : "After morning triage"; return `${when} -> ${rule.actions.map(actionLabel).join(" + ")}`; } function surfaceAction(action: Extract): void { if (action.target === "task" && action.taskId) { const task = tasks.find((candidate) => candidate.id === action.taskId); if (!task) { // The rule outlived its target. Don't fake a success toast — // surface the full list so the user can still act, and tell // them why the specific task isn't there. Cached label used // only for identification, not as a claim of surfacing. page.current = "tasks"; page.taskSidebarOpen = true; const cachedLabel = action.label?.trim(); toasts.warn( "Rule target missing", cachedLabel ? `The task "${cachedLabel}" is gone — edit the rule to point at something else.` : "That rule's task is gone — edit the rule to point at something else.", ); return; } page.current = "tasks"; page.taskSidebarOpen = true; toasts.info("Task ready", task.text); return; } page.current = "tasks"; page.taskSidebarOpen = true; if (action.target === "inbox") { toasts.info("Inbox is here", "Your task list is open."); } else if (action.target === "today") { toasts.info("Today's list is here", "Your task list is open."); } else { toasts.info("Tasks are here", "Your list is open."); } } async function executeAction(action: ImplementationRuleAction): Promise { if (action.kind === "surface") { surfaceAction(action); return; } if (action.kind === "start_timer") { window.dispatchEvent(new CustomEvent("lumotia:start-timer", { detail: { taskId: action.taskId ?? null, seconds: 300, label: action.label ?? "5-minute timer", }, })); return; } if (action.kind === "speak_line") { await invoke("tts_speak", { text: action.text, rate: settings.ttsRate, voice: settings.ttsVoice ?? null, }); } } async function fireRule(rule: ImplementationRule, firedKey?: string): Promise { if (!rule.enabled) return; if (settings.nudgesMuted) return; if (firedKey && rule.lastFiredKey === firedKey) return; if (firingRules.has(rule.id)) return; firingRules.add(rule.id); try { for (const action of rule.actions) { try { await executeAction(action); } catch { // Rule actions are assistance, not critical workflow. Keep going // so a missing TTS voice does not block surfacing a task. } } if (firedKey) { await markRuleFired(rule, firedKey); } } finally { firingRules.delete(rule.id); } } function timeRuleFiredKey(rule: ImplementationRule): string { return `${todayLocalKey()}@${rule.triggerValue}`; } async function checkTimeRules(): Promise { if (!loaded) await loadImplementationRules(); for (const rule of implementationRules) { if (rule.triggerKind !== "time_of_day") continue; if (!rule.enabled) continue; if (currentHHMM() < rule.triggerValue) continue; const firedKey = timeRuleFiredKey(rule); if (rule.lastFiredKey === firedKey) continue; await fireRule(rule, firedKey); } } function onTaskCompleted(): void { for (const rule of implementationRules) { if (rule.triggerKind === "task_completed") void fireRule(rule); } } function onMorningTriageFinished(): void { for (const rule of implementationRules) { if (rule.triggerKind === "morning_triage_finished") void fireRule(rule); } } function onRulesChanged(): void { void checkTimeRules(); } export function startImplementationIntentions(): void { if (started) return; if (typeof window === "undefined") return; started = true; void loadImplementationRules(true) .then(() => checkTimeRules()) .catch(() => {}); window.addEventListener("lumotia:task-completed", onTaskCompleted); window.addEventListener("lumotia:morning-triage-finished", onMorningTriageFinished); window.addEventListener(RULES_CHANGED_EVENT, onRulesChanged); timePollHandle = setInterval(() => { void checkTimeRules().catch(() => {}); }, TIME_RULE_POLL_MS); } export function stopImplementationIntentions(): void { if (!started) return; started = false; window.removeEventListener("lumotia:task-completed", onTaskCompleted); window.removeEventListener("lumotia:morning-triage-finished", onMorningTriageFinished); window.removeEventListener(RULES_CHANGED_EVENT, onRulesChanged); if (timePollHandle !== null) { clearInterval(timePollHandle); timePollHandle = null; } }