diff --git a/src-tauri/src/commands/tasks.rs b/src-tauri/src/commands/tasks.rs index 2ee7033..18c170f 100644 --- a/src-tauri/src/commands/tasks.rs +++ b/src-tauri/src/commands/tasks.rs @@ -67,7 +67,7 @@ pub struct CreateTaskRequest { pub async fn create_task_cmd( state: tauri::State<'_, AppState>, request: CreateTaskRequest, -) -> Result<(), String> { +) -> Result { db_insert_task( &state.db, &request.id, @@ -76,7 +76,15 @@ pub async fn create_task_cmd( request.source_transcript_id.as_deref(), ) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + // Fetch the freshly-inserted row so the frontend can stop doing + // client-side object construction. Mirrors list_tasks_cmd's shape. + db_get_task(&state.db, &request.id) + .await + .map_err(|e| e.to_string())? + .map(TaskDto::from) + .ok_or_else(|| format!("Task {} not found after insert", request.id)) } #[tauri::command] diff --git a/src/lib/stores/page.svelte.js b/src/lib/stores/page.svelte.js index 0ef0db5..bf0a8fe 100644 --- a/src/lib/stores/page.svelte.js +++ b/src/lib/stores/page.svelte.js @@ -221,65 +221,85 @@ export function deleteFromHistory(index) { .catch(err => console.warn("deleteFromHistory: SQLite delete failed", err)); } -// ---- Tasks (persisted to localStorage) ---- +// ---- Tasks (SQLite via Tauri = canonical; no localStorage cache) ---- +// +// The canonical store is the SQLite tasks table reachable via the +// `create_task_cmd`, `list_tasks_cmd`, `delete_task_cmd`, +// `complete_task_cmd`, `uncomplete_task_cmd` Tauri commands. The tasks +// array is seeded empty on module init and hydrated from SQLite on boot +// in the desktop runtime. Browser preview (no Tauri) shows an empty list. +// +// The previous localStorage tasks cache was removed to kill the cold- +// start flicker and the dual-write drift between localStorage and SQLite. -const TASKS_KEY = "kon_tasks"; +export const tasks = $state([]); -function loadTasks() { - try { - const raw = localStorage.getItem(TASKS_KEY); - if (raw) return JSON.parse(raw); - } catch {} - return []; -} - -export const tasks = $state(loadTasks()); - -// On boot in the desktop runtime, load canonical tasks from SQLite. -// localStorage remains the warm cache and fallback. -if (typeof window !== "undefined" && hasTauriRuntime()) { - invoke("list_tasks_cmd") - .then((sqliteTasks) => { - tasks.length = 0; - tasks.push(...sqliteTasks); - saveTasks(); - }) - .catch(() => {}); -} - -export function saveTasks() { - try { - localStorage.setItem(TASKS_KEY, JSON.stringify(tasks)); - } catch {} -} - -export function addTask(task) { - const newTask = { - id: crypto.randomUUID(), - text: task.text, - bucket: task.bucket || "inbox", - listId: task.listId || null, - context: task.context || "", - effort: task.effort || "", - done: false, - doneAt: null, - createdAt: new Date().toISOString(), - sourceTranscriptId: task.sourceTranscriptId || null, - parentTaskId: task.parentTaskId || null, - notes: "", +/** Shape a TaskDto row from the tasks commands into the UI entry shape + * that TasksPage / WipTaskList / TaskSidebar expect. Mirrors + * mapTranscriptRow's placement and field-defaulting pattern. */ +function mapTaskRow(row) { + return { + id: row.id, + text: row.text ?? "", + bucket: row.bucket ?? "inbox", + listId: row.listId ?? null, + effort: row.effort ?? "", + done: !!row.done, + doneAt: row.doneAt ?? null, + createdAt: row.createdAt ?? new Date().toISOString(), + sourceTranscriptId: row.sourceTranscriptId ?? null, + parentTaskId: row.parentTaskId ?? null, }; - tasks.unshift(newTask); - saveTasks(); - broadcastTasks(); - if (hasTauriRuntime()) { - invoke("create_task_cmd", { +} + +async function loadTasks() { + if (!hasTauriRuntime()) { + tasks.length = 0; + return; + } + try { + const rows = await invoke("list_tasks_cmd"); + const mapped = Array.isArray(rows) ? rows.map(mapTaskRow) : []; + tasks.splice(0, tasks.length, ...mapped); + } catch (err) { + console.error("loadTasks failed", err); + tasks.length = 0; + toasts.error("Failed to load tasks", err?.message ?? String(err)); + } +} + +// Hydrate tasks from SQLite on boot. Mirrors the history hydration pattern +// earlier in this file. +if (typeof window !== "undefined" && hasTauriRuntime()) { + loadTasks().catch(() => {}); +} + +// `saveTasks` is retained as a no-op for callers in the Task Lists section +// further down (removeProfileTaskList, deleteTaskList) that mutate +// `t.listId` to null when a list is deleted and previously flushed via +// localStorage. SQLite writes happen via the specific `create_task_cmd` +// / `delete_task_cmd` / `complete_task_cmd` / `uncomplete_task_cmd` +// commands in the functions below; the listId-clearing mutation is +// currently session-scoped because listId is not round-tripped through +// those commands. See report note. +export function saveTasks() {} + +export async function addTask(task) { + if (!hasTauriRuntime()) return; + const id = crypto.randomUUID(); + try { + const row = await invoke("create_task_cmd", { request: { - id: newTask.id, - text: newTask.text, - bucket: newTask.bucket, - sourceTranscriptId: newTask.sourceTranscriptId, + id, + text: task.text, + bucket: task.bucket || "inbox", + sourceTranscriptId: task.sourceTranscriptId || null, }, - }).catch(() => {}); + }); + tasks.unshift(mapTaskRow(row)); + broadcastTasks(); + } catch (err) { + toasts.error("Couldn't add task", err?.message ?? String(err)); } } @@ -287,41 +307,48 @@ export function updateTask(id, updates) { const idx = tasks.findIndex((t) => t.id === id); if (idx >= 0) { Object.assign(tasks[idx], updates); - saveTasks(); broadcastTasks(); } } -export function deleteTask(id) { - const idx = tasks.findIndex((t) => t.id === id); - if (idx >= 0) { - tasks.splice(idx, 1); - saveTasks(); - broadcastTasks(); - if (hasTauriRuntime()) { - invoke("delete_task_cmd", { id }).catch(() => {}); +export async function deleteTask(id) { + if (!hasTauriRuntime()) return; + try { + await invoke("delete_task_cmd", { id }); + const idx = tasks.findIndex((t) => t.id === id); + if (idx >= 0) { + tasks.splice(idx, 1); + broadcastTasks(); } + } catch (err) { + toasts.error("Couldn't delete task", err?.message ?? String(err)); } } -export function completeTask(id) { - updateTask(id, { done: true, doneAt: new Date().toISOString() }); - if (hasTauriRuntime()) { - invoke("complete_task_cmd", { id }).catch(() => {}); +export async function completeTask(id) { + if (!hasTauriRuntime()) return; + try { + await invoke("complete_task_cmd", { id }); + updateTask(id, { done: true, doneAt: new Date().toISOString() }); + } catch (err) { + toasts.error("Couldn't complete task", err?.message ?? String(err)); } } -export function uncompleteTask(id) { - updateTask(id, { done: false, doneAt: null }); - if (hasTauriRuntime()) { - invoke("uncomplete_task_cmd", { id }).catch(() => {}); +export async function uncompleteTask(id) { + if (!hasTauriRuntime()) return; + try { + await invoke("uncomplete_task_cmd", { id }); + updateTask(id, { done: false, doneAt: null }); + } catch (err) { + toasts.error("Couldn't uncomplete task", err?.message ?? String(err)); } } // ---- BroadcastChannel for multi-window task sync ---- const taskChannel = typeof BroadcastChannel !== "undefined" - ? new BroadcastChannel("kon_tasks") + ? new BroadcastChannel("kon_task_sync") : null; if (taskChannel) {