Phase 7 of the rebrand cascade. Persisted UI state + inter-window event
channels migrated from magnotia to lumotia naming, with one-shot
localStorage key migration so dogfooded UI state survives the rename.
src/lib/utils/localStorageMigration.ts (new):
- migrateLocalStorageKey(old, new): idempotent + crash-safe shim.
- If new key exists, removes old (lumotia value is authoritative).
- If only old exists, copies value to new key, removes old.
- If neither, no-op.
- migrateLocalStorageKeys(pairs): batch wrapper.
src/lib/stores/page.svelte.ts:
- 4 key constants renamed to lumotia_settings / lumotia_profiles /
lumotia_task_lists / lumotia_templates.
- BroadcastChannel name renamed to lumotia_task_lists.
- migrateLocalStorageKeys() called at module load before any read.
src/lib/stores/focusTimer.svelte.ts:
- STORAGE_KEY renamed to lumotia.focusTimer.v1.
- migrateLocalStorageKey() called at module load.
Event channels (magnotia: -> lumotia:) renamed across frontend + Rust:
- magnotia:toggle-recording (src/routes/+layout.svelte)
- magnotia:hotkey-pressed / -released (src-tauri/src/commands/hotkey.rs +
consumers)
- magnotia:open-wind-down (src-tauri/src/tray.rs + consumer)
- magnotia:llm-download-progress (src-tauri/src/commands/llm.rs)
- magnotia:preferences-changed (src/lib/stores/preferences.svelte.ts +
consumers)
- magnotia:start-timer (nudgeBus + dispatch sites)
- magnotia:focus-timer-{complete,cancelled} (focusTimer + nudgeBus)
- magnotia:microstep-generated (nudgeBus + dispatch sites)
- magnotia:step-completed (nudgeBus + dispatch sites)
- magnotia:task-{completed,uncompleted,deleted} (page.svelte.ts +
nudgeBus + consumers)
Storage-event filters in src/routes/{float,viewer,preview}/+layout@.svelte
updated to filter on lumotia_settings.
User-facing toast strings still say "Magnotia" — deferred to Phase 8
(frontend strings).
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>
261 lines
8.4 KiB
TypeScript
261 lines
8.4 KiB
TypeScript
// 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<ImplementationRule[]>([]);
|
|
|
|
let loaded = false;
|
|
let started = false;
|
|
let timePollHandle: ReturnType<typeof setInterval> | null = null;
|
|
const firingRules = new Set<string>();
|
|
|
|
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<void> {
|
|
if (!hasTauriRuntime()) {
|
|
implementationRules.length = 0;
|
|
loaded = true;
|
|
return;
|
|
}
|
|
if (loaded && !force) return;
|
|
const rows = await invoke<ImplementationRule[]>("list_implementation_rules");
|
|
implementationRules.splice(0, implementationRules.length, ...rows);
|
|
loaded = true;
|
|
}
|
|
|
|
export async function createImplementationRule(
|
|
draft: ImplementationRuleDraft,
|
|
): Promise<ImplementationRule> {
|
|
const row = await invoke<ImplementationRule>("create_implementation_rule", { request: draft });
|
|
replaceRule(row);
|
|
broadcastRulesChanged();
|
|
return row;
|
|
}
|
|
|
|
export async function setImplementationRuleEnabled(id: string, enabled: boolean): Promise<void> {
|
|
const row = await invoke<ImplementationRule>("set_implementation_rule_enabled", { id, enabled });
|
|
replaceRule(row);
|
|
broadcastRulesChanged();
|
|
}
|
|
|
|
export async function deleteImplementationRule(id: string): Promise<void> {
|
|
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<void> {
|
|
const row = await invoke<ImplementationRule>("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<ImplementationRuleAction, { kind: "surface" }>): 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|