feat(tasks): B2a part 2 — task_lists CRUD + archive cmds + frontend wire

Storage: 5 task_lists CRUD functions (list/create/update/delete/
import) and 4 archive functions (list_archived_tasks/archive_task/
unarchive_task/archive_inbox_older_than). 8 new tests cover CRUD,
import idempotency, atomic-failure rollback, dependent-task null-out,
archive scope, archive_inbox_older_than Inbox-only invariant, and
unarchive round-trip.

Tauri: 9 new commands (5 task_lists + 4 archive) registered in the
invoke_handler. New file commands/task_lists.rs mirroring tasks.rs
structure. TaskDto extended with archived/archivedAt.

Frontend: loadTaskLists rewired to read from SQLite via
list_task_lists_cmd on Tauri runtime, with a one-time migration
that imports kon_task_lists localStorage into SQLite via
import_task_lists_cmd and clears localStorage only on success.
mapTaskRow handles the new archived fields. TasksPage gets an
Archived filter pill (hidden when empty) and a per-row archive
icon. moveTaskList/sortTaskLists kept local pending a future
order-column migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 19:04:17 +01:00
parent 954ff22840
commit 0111fc9e08
9 changed files with 1133 additions and 85 deletions

View File

@@ -403,6 +403,8 @@ function mapTaskRow(row: TaskDto): TaskEntry {
sourceTranscriptId: row.sourceTranscriptId ?? null,
parentTaskId: row.parentTaskId ?? null,
energy: row.energy ?? null,
archived: !!row.archived,
archivedAt: row.archivedAt ?? null,
};
}
@@ -426,8 +428,6 @@ if (typeof window !== "undefined" && hasTauriRuntime()) {
loadTasks().catch(() => {});
}
export function saveTasks() {}
export async function addTask(task: TaskDraft) {
if (!hasTauriRuntime()) return;
@@ -599,6 +599,11 @@ function broadcastTasks() {
} satisfies TaskChannelMessage);
}
// B2a: task_lists are now persisted in SQLite (migration v1 created
// the table; the actual CRUD wiring happens via the *_task_list_cmd
// handlers). The frontend keeps the two synthetic built-in entries
// (`all`, `inbox`) in-memory so the sidebar always shows them — neither
// has a corresponding DB row.
function defaultTaskLists(): TaskList[] {
return [
{ id: "all", name: "All Tasks", builtIn: true, createdAt: null },
@@ -606,104 +611,245 @@ function defaultTaskLists(): TaskList[] {
];
}
function loadTaskLists(): TaskList[] {
if (!canUseStorage()) return defaultTaskLists();
return parseStoredJson<TaskList[]>(localStorage.getItem(TASK_LISTS_KEY)) ?? defaultTaskLists();
interface TaskListDto {
id: string;
name: string;
builtIn: boolean;
profileId: string | null;
createdAt: string;
}
export const taskLists = $state<TaskList[]>(loadTaskLists());
function mapTaskListRow(row: TaskListDto): TaskList {
return {
id: row.id,
name: row.name,
builtIn: !!row.builtIn,
profileId: row.profileId ?? null,
createdAt: row.createdAt ?? null,
};
}
export function saveTaskLists() {
if (canUseStorage()) {
try {
localStorage.setItem(TASK_LISTS_KEY, JSON.stringify(taskLists));
} catch {}
export const taskLists = $state<TaskList[]>(defaultTaskLists());
async function loadTaskListsFromSqlite() {
if (!hasTauriRuntime()) return;
try {
const rows = await invoke<TaskListDto[]>("list_task_lists_cmd");
const mapped = Array.isArray(rows) ? rows.map(mapTaskListRow) : [];
taskLists.length = 0;
taskLists.push(...defaultTaskLists(), ...mapped);
} catch (err) {
console.error("loadTaskListsFromSqlite failed", err);
toasts.error("Failed to load task lists", errorMessage(err));
}
broadcastTaskLists();
}
export function addTaskList(name: string, profileId: string | null = null) {
taskLists.push({
id: crypto.randomUUID(),
name,
builtIn: false,
profileId,
createdAt: new Date().toISOString(),
});
saveTaskLists();
// One-time migration: pull the legacy `kon_task_lists` blob into SQLite
// via import_task_lists_cmd. Idempotent because the import command
// skips ids that already exist; we still only run it when the
// localStorage key is present so we don't pay a no-op round trip on
// every launch. The localStorage key is removed only on a successful
// import so a transient backend error doesn't lose data.
async function migrateTaskListsFromLocalStorage() {
if (!canUseStorage()) return;
const raw = localStorage.getItem(TASK_LISTS_KEY);
if (!raw) return;
const parsed = parseStoredJson<TaskList[]>(raw);
if (!parsed || parsed.length === 0) {
// Empty or corrupt — clear and skip so we don't keep retrying.
localStorage.removeItem(TASK_LISTS_KEY);
return;
}
// Drop the synthetic built-ins (`all`, `inbox`) — they're recreated
// in-memory each launch and have no DB row, so importing them would
// collide with the next launch's defaults.
const importable = parsed.filter((l) => l.id !== "all" && l.id !== "inbox");
if (importable.length === 0) {
localStorage.removeItem(TASK_LISTS_KEY);
return;
}
try {
const summary = await invoke<{ imported: number; skipped: number }>(
"import_task_lists_cmd",
{
items: importable.map((l) => ({
id: l.id,
name: l.name,
builtIn: l.builtIn,
profileId: l.profileId ?? null,
createdAt: l.createdAt ?? null,
})),
},
);
// Only clear localStorage AFTER the import succeeds.
localStorage.removeItem(TASK_LISTS_KEY);
// Re-load from SQLite so the store reflects the imported rows.
await loadTaskListsFromSqlite();
toasts.info(
"Task lists migrated",
`${summary.imported} list${summary.imported === 1 ? "" : "s"} moved to local database.`,
);
} catch (err) {
console.error("migrateTaskListsFromLocalStorage failed", err);
toasts.error(
"Couldn't migrate task lists",
"Your data is safe — Kon will retry on next launch.",
);
}
}
export function addProfileTaskList(profileName: string) {
if (typeof window !== "undefined" && hasTauriRuntime()) {
// Sequencing matters: load first so the store reflects existing DB
// state, then run the import which is idempotent vs. that state. The
// import re-loads on success so the toasted summary matches what's
// visible.
(async () => {
await loadTaskListsFromSqlite();
await migrateTaskListsFromLocalStorage();
})().catch(() => {});
}
export async function addTaskList(name: string, profileId: string | null = null) {
if (!hasTauriRuntime()) return;
const id = crypto.randomUUID();
try {
const row = await invoke<TaskListDto>("create_task_list_cmd", {
request: {
id,
name,
builtIn: false,
profileId,
},
});
taskLists.push(mapTaskListRow(row));
broadcastTaskLists();
} catch (err) {
toasts.error("Couldn't add list", errorMessage(err));
}
}
export async function addProfileTaskList(profileName: string) {
const existing = taskLists.find((list) => list.profileId === profileName);
if (existing) return existing.id;
if (!hasTauriRuntime()) return null;
const id = crypto.randomUUID();
taskLists.push({
id,
name: profileName,
builtIn: false,
profileId: profileName,
createdAt: new Date().toISOString(),
});
saveTaskLists();
return id;
try {
const row = await invoke<TaskListDto>("create_task_list_cmd", {
request: {
id,
name: profileName,
builtIn: false,
profileId: profileName,
},
});
taskLists.push(mapTaskListRow(row));
broadcastTaskLists();
return id;
} catch (err) {
toasts.error("Couldn't add profile list", errorMessage(err));
return null;
}
}
export function removeProfileTaskList(profileName: string) {
export async function removeProfileTaskList(profileName: string) {
const idx = taskLists.findIndex((list) => list.profileId === profileName);
if (idx < 0) return;
const listId = taskLists[idx].id;
for (const task of tasks) {
if (task.listId === listId) task.listId = null;
if (!hasTauriRuntime()) return;
try {
await invoke("delete_task_list_cmd", { id: listId });
// delete_task_list nulls dependent tasks server-side; mirror that
// locally so the UI doesn't show stale list pointers.
for (const task of tasks) {
if (task.listId === listId) task.listId = null;
}
broadcastTasks();
taskLists.splice(idx, 1);
broadcastTaskLists();
} catch (err) {
toasts.error("Couldn't remove profile list", errorMessage(err));
}
saveTasks();
broadcastTasks();
taskLists.splice(idx, 1);
saveTaskLists();
}
export function moveTaskToList(taskId: string, listId: string) {
updateTask(taskId, { listId: listId === "inbox" || listId === "all" ? null : listId });
}
export function moveTaskListToProfile(listId: string, profileId: string | null) {
export async function moveTaskListToProfile(listId: string, profileId: string | null) {
const list = taskLists.find((entry) => entry.id === listId);
if (list && !list.builtIn) {
list.profileId = profileId || null;
if (profileId && !list.name) list.name = profileId;
saveTaskLists();
if (!list || list.builtIn) return;
if (!hasTauriRuntime()) return;
try {
const row = await invoke<TaskListDto>("update_task_list_cmd", {
id: listId,
patch: {
// Only patch profile_id; leave name alone.
profileId: profileId || null,
},
});
const idx = taskLists.findIndex((l) => l.id === listId);
if (idx >= 0) {
taskLists[idx] = mapTaskListRow(row);
broadcastTaskLists();
}
} catch (err) {
toasts.error("Couldn't move list", errorMessage(err));
}
}
export function renameTaskList(id: string, name: string) {
export async function renameTaskList(id: string, name: string) {
const list = taskLists.find((entry) => entry.id === id);
if (list && !list.builtIn) {
list.name = name;
saveTaskLists();
if (!list || list.builtIn) return;
if (!hasTauriRuntime()) return;
try {
const row = await invoke<TaskListDto>("update_task_list_cmd", {
id,
patch: { name },
});
const idx = taskLists.findIndex((l) => l.id === id);
if (idx >= 0) {
taskLists[idx] = mapTaskListRow(row);
broadcastTaskLists();
}
} catch (err) {
toasts.error("Couldn't rename list", errorMessage(err));
}
}
export function deleteTaskList(id: string) {
export async function deleteTaskList(id: string) {
const idx = taskLists.findIndex((list) => list.id === id);
if (idx < 0 || taskLists[idx].builtIn) return;
for (const task of tasks) {
if (task.listId === id) task.listId = null;
if (!hasTauriRuntime()) return;
try {
await invoke("delete_task_list_cmd", { id });
for (const task of tasks) {
if (task.listId === id) task.listId = null;
}
broadcastTasks();
taskLists.splice(idx, 1);
broadcastTaskLists();
} catch (err) {
toasts.error("Couldn't delete list", errorMessage(err));
}
saveTasks();
broadcastTasks();
taskLists.splice(idx, 1);
saveTaskLists();
}
// NOTE: `moveTaskList` (manual reorder) and `sortTaskLists` mutate
// the in-memory array only. Persisting display order requires a new
// `display_order` column on `task_lists`; until then, the order survives
// only for the current session. This is a known, scoped limitation
// called out in B2a part 2.
export function moveTaskList(id: string, direction: number) {
const idx = taskLists.findIndex((list) => list.id === id);
const targetIdx = idx + direction;
if (idx < 0 || targetIdx < 0 || targetIdx >= taskLists.length) return;
if (taskLists[targetIdx].builtIn) return;
[taskLists[idx], taskLists[targetIdx]] = [taskLists[targetIdx], taskLists[idx]];
saveTaskLists();
broadcastTaskLists();
}
export function sortTaskLists(mode: "alpha" | "date") {
@@ -720,7 +866,7 @@ export function sortTaskLists(mode: "alpha" | "date") {
taskLists.length = 0;
taskLists.push(...builtIn, ...custom);
saveTaskLists();
broadcastTaskLists();
}
interface TaskListChannelMessage {