feat(tasks): migrate task store from localStorage to SQLite backend

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-21 13:00:52 +00:00
parent 7a9a034e3a
commit 6d8597e9ea
8 changed files with 230 additions and 22 deletions

View File

@@ -1,7 +1,9 @@
<script> <script>
import { page, settings, tasks, saveSettings } from "$lib/stores/page.svelte.js"; import { page, settings, saveSettings } from "$lib/stores/page.svelte.js";
import { getTasks } from "$lib/stores/tasks.svelte.js";
import { Mic, FileText, SquareCheck, Clock, Settings, ChevronRight, ChevronLeft } from 'lucide-svelte'; import { Mic, FileText, SquareCheck, Clock, Settings, ChevronRight, ChevronLeft } from 'lucide-svelte';
const tasks = getTasks();
let taskCount = $derived(tasks.filter((t) => !t.done).length); let taskCount = $derived(tasks.filter((t) => !t.done).length);
const navItems = [ const navItems = [

View File

@@ -1,16 +1,19 @@
<script> <script>
import { page, tasks, addTask, completeTask, uncompleteTask } from "$lib/stores/page.svelte.js"; import { page } from "$lib/stores/page.svelte.js";
import { getTasks, createTask, completeTask, uncompleteTask } from "$lib/stores/tasks.svelte.js";
import { BUCKET_DOT_COLORS, SIDEBAR_MAX_TASKS } from "$lib/utils/constants.js"; import { BUCKET_DOT_COLORS, SIDEBAR_MAX_TASKS } from "$lib/utils/constants.js";
const tasks = getTasks();
let quickInput = $state(""); let quickInput = $state("");
let recentlyAdded = $state(new Set()); let recentlyAdded = $state(new Set());
let activeTasks = $derived(tasks.filter((t) => !t.done).slice(0, SIDEBAR_MAX_TASKS)); let activeTasks = $derived(tasks.filter((t) => !t.done).slice(0, SIDEBAR_MAX_TASKS));
let taskCount = $derived(tasks.filter((t) => !t.done).length); let taskCount = $derived(tasks.filter((t) => !t.done).length);
function handleQuickAdd(e) { async function handleQuickAdd(e) {
if (e.key === "Enter" && quickInput.trim()) { if (e.key === "Enter" && quickInput.trim()) {
addTask({ text: quickInput.trim(), bucket: "inbox" }); await createTask(quickInput.trim(), { bucket: "inbox" });
quickInput = ""; quickInput = "";
} }
} }

View File

@@ -1,5 +1,6 @@
<script> <script>
import { tasks, addTask, completeTask, uncompleteTask, deleteTask } from '$lib/stores/page.svelte.js'; import { getTasks, createTask, completeTask, deleteTask } from '$lib/stores/tasks.svelte.js';
const tasks = getTasks();
import { ChevronDown } from 'lucide-svelte'; import { ChevronDown } from 'lucide-svelte';
let { wipLimit = 3 } = $props(); let { wipLimit = 3 } = $props();
@@ -12,10 +13,10 @@
let overflowTasks = $derived(activeTasks.slice(wipLimit)); let overflowTasks = $derived(activeTasks.slice(wipLimit));
let hasOverflow = $derived(overflowTasks.length > 0); let hasOverflow = $derived(overflowTasks.length > 0);
function handleAdd() { async function handleAdd() {
const text = newTaskText.trim(); const text = newTaskText.trim();
if (!text) return; if (!text) return;
addTask({ text, bucket: 'inbox' }); await createTask(text, { bucket: 'inbox' });
newTaskText = ''; newTaskText = '';
} }

View File

@@ -2,7 +2,9 @@
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { page, settings, templates, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js"; import { page, settings, templates, addToHistory } from "$lib/stores/page.svelte.js";
import { getTasks, createTask } from "$lib/stores/tasks.svelte.js";
const tasks = getTasks();
import Card from "$lib/components/Card.svelte"; import Card from "$lib/components/Card.svelte";
import ModelDownloader from "$lib/components/ModelDownloader.svelte"; import ModelDownloader from "$lib/components/ModelDownloader.svelte";
import { exportTranscript } from "$lib/utils/export.js"; import { exportTranscript } from "$lib/utils/export.js";
@@ -393,8 +395,7 @@
const extracted = extractTasks(transcript); const extracted = extractTasks(transcript);
extractedCount = extracted.length; extractedCount = extracted.length;
for (const item of extracted) { for (const item of extracted) {
addTask({ createTask(item.text, {
text: item.text,
bucket: "inbox", bucket: "inbox",
sourceTranscriptId: historyId, sourceTranscriptId: historyId,
}); });
@@ -470,7 +471,7 @@
try { try {
const extracted = extractTasks(transcript); const extracted = extractTasks(transcript);
for (const item of extracted) { for (const item of extracted) {
addTask({ text: item.text, bucket: "inbox" }); createTask(item.text, { bucket: "inbox" });
} }
aiStatus = `${extracted.length} task${extracted.length === 1 ? '' : 's'} extracted`; aiStatus = `${extracted.length} task${extracted.length === 1 ? '' : 's'} extracted`;
} catch (err) { } catch (err) {

View File

@@ -1,9 +1,16 @@
<script> <script>
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
import {
getTasks, loadTasks, createTask, completeTask, uncompleteTask, deleteTask, updateTask,
} from "$lib/stores/tasks.svelte.js";
import { import {
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
taskLists, addTaskList, renameTaskList, deleteTaskList, taskLists, addTaskList, renameTaskList, deleteTaskList,
} from "$lib/stores/page.svelte.js"; } from "$lib/stores/page.svelte.js";
const tasks = getTasks();
onMount(() => { loadTasks(); });
import WipTaskList from '$lib/components/WipTaskList.svelte'; import WipTaskList from '$lib/components/WipTaskList.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight } from 'lucide-svelte'; import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight } from 'lucide-svelte';
@@ -96,13 +103,10 @@
activeListId === "all" ? "All Lists" : (taskLists.find((l) => l.id === activeListId)?.name || "Tasks") activeListId === "all" ? "All Lists" : (taskLists.find((l) => l.id === activeListId)?.name || "Tasks")
); );
function handleQuickAdd(e) { async function handleQuickAdd(e) {
if (e.key === "Enter" && quickInput.trim()) { if (e.key === "Enter" && quickInput.trim()) {
const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId; await createTask(quickInput.trim(), {
addTask({
text: quickInput.trim(),
bucket: activeBucket === "all" ? "inbox" : activeBucket, bucket: activeBucket === "all" ? "inbox" : activeBucket,
listId,
}); });
quickInput = ""; quickInput = "";
} }

View File

@@ -103,7 +103,9 @@ export function deleteFromHistory(index) {
saveHistory(); saveHistory();
} }
// ---- Tasks (persisted to localStorage) ---- // ---- Tasks (DEPRECATED — migrated to tasks.svelte.js backed by SQLite) ----
// TODO: Remove localStorage task code after full migration verified.
// Kept temporarily for backwards compatibility with any unmigrated consumers.
const TASKS_KEY = "kon_tasks"; const TASKS_KEY = "kon_tasks";

View File

@@ -0,0 +1,190 @@
/**
* Task store backed by SQLite via Tauri commands.
* Replaces the localStorage-based task management in page.svelte.js.
*
* All mutations call the backend first, then update the reactive state
* on success. The backend enforces the WIP limit server-side.
*/
import { invoke } from '@tauri-apps/api/core';
// Reactive state
let tasks = $state([]);
let loading = $state(false);
// BroadcastChannel for multi-window sync (float window, task sidebar)
const taskChannel = typeof BroadcastChannel !== 'undefined'
? new BroadcastChannel('kon_tasks_v2')
: null;
if (taskChannel) {
taskChannel.onmessage = (event) => {
if (event.data.type === 'tasks_updated') {
tasks.length = 0;
tasks.push(...event.data.tasks);
}
};
}
function broadcastTasks() {
if (taskChannel) {
taskChannel.postMessage({ type: 'tasks_updated', tasks: $state.snapshot(tasks) });
}
}
/**
* Load tasks from the SQLite backend.
* @param {string} [status] - Optional status filter ('pending', 'active', 'done')
* @param {number} [limit] - Maximum number of results
*/
export async function loadTasks(status = null, limit = null) {
loading = true;
try {
const result = await invoke('list_tasks_cmd', {
status: status || null,
limit: limit || null,
});
tasks.length = 0;
tasks.push(...result);
} catch (e) {
console.error('Failed to load tasks:', e);
} finally {
loading = false;
}
}
/**
* Create a new task via the backend.
* @param {string} text - Task text
* @param {object} [opts] - Optional fields: priority, project, bucket, effort, sourceTranscriptId, status
* @returns {Promise<object|null>} The created task, or null on failure
*/
export async function createTask(text, opts = {}) {
try {
const created = await invoke('create_task', {
text,
priority: opts.priority || null,
project: opts.project || null,
bucket: opts.bucket || null,
effort: opts.effort || null,
sourceTranscriptId: opts.sourceTranscriptId || null,
status: opts.status || null,
});
tasks.unshift(created);
broadcastTasks();
return created;
} catch (e) {
console.error('Failed to create task:', e);
return null;
}
}
/**
* Update a task's mutable fields.
* @param {string} id - Task ID
* @param {object} updates - Fields to update
*/
export async function updateTask(id, updates) {
// Merge with current task data for fields not provided
const current = tasks.find(t => t.id === id);
if (!current) return;
try {
await invoke('update_task_cmd', {
id,
text: updates.text ?? current.text,
priority: updates.priority ?? current.priority ?? 'medium',
project: updates.project !== undefined ? updates.project : (current.project || null),
status: updates.status ?? current.status ?? 'pending',
bucket: updates.bucket ?? current.bucket ?? 'inbox',
effort: updates.effort !== undefined ? updates.effort : (current.effort || null),
notes: updates.notes ?? current.notes ?? '',
});
// Update local state
const idx = tasks.findIndex(t => t.id === id);
if (idx >= 0) {
Object.assign(tasks[idx], updates);
}
broadcastTasks();
} catch (e) {
console.error('Failed to update task:', e);
}
}
/**
* Delete a task.
* @param {string} id - Task ID
*/
export async function deleteTask(id) {
try {
await invoke('delete_task_cmd', { id });
const idx = tasks.findIndex(t => t.id === id);
if (idx >= 0) tasks.splice(idx, 1);
broadcastTasks();
} catch (e) {
console.error('Failed to delete task:', e);
}
}
/**
* Mark a task as complete.
* @param {string} id - Task ID
*/
export async function completeTask(id) {
try {
await invoke('complete_task_cmd', { id });
const idx = tasks.findIndex(t => t.id === id);
if (idx >= 0) {
tasks[idx].done = true;
tasks[idx].status = 'done';
tasks[idx].doneAt = new Date().toISOString();
}
broadcastTasks();
} catch (e) {
console.error('Failed to complete task:', e);
}
}
/**
* Revert a completed task to pending.
* @param {string} id - Task ID
*/
export async function uncompleteTask(id) {
try {
await invoke('uncomplete_task_cmd', { id });
const idx = tasks.findIndex(t => t.id === id);
if (idx >= 0) {
tasks[idx].done = false;
tasks[idx].status = 'pending';
tasks[idx].doneAt = null;
}
broadcastTasks();
} catch (e) {
console.error('Failed to uncomplete task:', e);
}
}
/**
* Reorder tasks by ID list.
* @param {string[]} ids - Task IDs in desired order
*/
export async function reorderTasks(ids) {
try {
await invoke('reorder_tasks_cmd', { taskIds: ids });
// Re-sort local state to match
const idOrder = new Map(ids.map((id, i) => [id, i]));
tasks.sort((a, b) => (idOrder.get(a.id) ?? 999) - (idOrder.get(b.id) ?? 999));
broadcastTasks();
} catch (e) {
console.error('Failed to reorder tasks:', e);
}
}
/** Get the reactive tasks array. */
export function getTasks() {
return tasks;
}
/** Whether tasks are currently loading from the backend. */
export function getLoading() {
return loading;
}

View File

@@ -1,11 +1,17 @@
<script> <script>
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { onMount } from "svelte";
import {
getTasks, loadTasks, createTask, completeTask, uncompleteTask, deleteTask,
} from "$lib/stores/tasks.svelte.js";
import { import {
tasks, addTask, completeTask, uncompleteTask, deleteTask,
taskLists, addTaskList, renameTaskList, deleteTaskList, moveTaskList, sortTaskLists, taskLists, addTaskList, renameTaskList, deleteTaskList, moveTaskList, sortTaskLists,
moveTaskToList, moveTaskListToProfile, profiles, moveTaskToList, moveTaskListToProfile, profiles,
} from "$lib/stores/page.svelte.js"; } from "$lib/stores/page.svelte.js";
const tasks = getTasks();
onMount(() => { loadTasks(); });
let quickInput = $state(""); let quickInput = $state("");
let pinned = $state(true); let pinned = $state(true);
let activeListId = $state("all"); let activeListId = $state("all");
@@ -48,10 +54,9 @@
let builtInLists = $derived(taskLists.filter((l) => l.builtIn)); let builtInLists = $derived(taskLists.filter((l) => l.builtIn));
let customLists = $derived(taskLists.filter((l) => !l.builtIn)); let customLists = $derived(taskLists.filter((l) => !l.builtIn));
function handleQuickAdd(e) { async function handleQuickAdd(e) {
if (e.key === "Enter" && quickInput.trim()) { if (e.key === "Enter" && quickInput.trim()) {
const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId; await createTask(quickInput.trim(), { bucket: "inbox" });
addTask({ text: quickInput.trim(), bucket: "inbox", listId });
quickInput = ""; quickInput = "";
} }
} }