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:
@@ -9,12 +9,13 @@
|
||||
taskLists, addTaskList, renameTaskList, deleteTaskList,
|
||||
settings, saveSettings,
|
||||
} from "$lib/stores/page.svelte.js";
|
||||
import type { TaskDto } from "$lib/types/app";
|
||||
import { recentCompletions, todayCount } from "$lib/stores/completionStats.svelte";
|
||||
import WipTaskList from '$lib/components/WipTaskList.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import CompletionSparkline from "$lib/components/CompletionSparkline.svelte";
|
||||
import EnergyChip from '$lib/components/EnergyChip.svelte';
|
||||
import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight, Zap } from 'lucide-svelte';
|
||||
import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight, Zap, Archive, ArchiveRestore } from 'lucide-svelte';
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
import { formatTimestamp } from "$lib/utils/time.js";
|
||||
import { BUCKET_COLORS, EFFORT_LABELS, EFFORT_ORDER } from "$lib/utils/constants.js";
|
||||
@@ -38,6 +39,79 @@
|
||||
let editingInputEl = $state<HTMLInputElement | null>(null);
|
||||
let newListInputEl = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// B2a: Archived view. The pill is hidden when there are no archived
|
||||
// rows. The list is fetched on demand (and refreshed each time the
|
||||
// user toggles into the view) so it doesn't block the main task load.
|
||||
let showArchived = $state(false);
|
||||
let archivedTasks = $state<TaskDto[]>([]);
|
||||
let archivedTasksCount = $state(0);
|
||||
|
||||
async function refreshArchivedTasks() {
|
||||
try {
|
||||
const rows = await invoke<TaskDto[]>("list_archived_tasks_cmd");
|
||||
archivedTasks = Array.isArray(rows) ? rows : [];
|
||||
archivedTasksCount = archivedTasks.length;
|
||||
} catch (err) {
|
||||
console.error("list_archived_tasks_cmd failed", err);
|
||||
archivedTasks = [];
|
||||
archivedTasksCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial archived count so the pill knows whether to render.
|
||||
$effect(() => {
|
||||
refreshArchivedTasks();
|
||||
});
|
||||
|
||||
async function archiveTaskRow(id: string) {
|
||||
try {
|
||||
await invoke("archive_task_cmd", { id });
|
||||
// Optimistic local removal — the backend has already moved the
|
||||
// row out of list_tasks, so the next loadTasks would do this
|
||||
// anyway; doing it here avoids a refetch round-trip.
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx >= 0) tasks.splice(idx, 1);
|
||||
archivedTasksCount++;
|
||||
} catch (err) {
|
||||
console.error("archive_task_cmd failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function unarchiveTaskRow(id: string) {
|
||||
try {
|
||||
const restored = await invoke<TaskDto>("unarchive_task_cmd", { id });
|
||||
// Drop from the archived list so the view updates without a
|
||||
// round-trip; refresh the count to stay in sync.
|
||||
const aidx = archivedTasks.findIndex((t) => t.id === id);
|
||||
if (aidx >= 0) {
|
||||
archivedTasks = archivedTasks.filter((_, i) => i !== aidx);
|
||||
archivedTasksCount = archivedTasks.length;
|
||||
}
|
||||
// Push the restored row into the in-memory tasks store so it
|
||||
// shows up in the active bucket without a full reload. The DTO
|
||||
// shape matches what mapTaskRow expects in the store; we mirror
|
||||
// its defaults inline since mapTaskRow isn't exported.
|
||||
tasks.unshift({
|
||||
id: restored.id,
|
||||
text: restored.text ?? "",
|
||||
bucket: (restored.bucket ?? "inbox") as TaskBucket,
|
||||
listId: restored.listId ?? null,
|
||||
effort: restored.effort ?? "",
|
||||
notes: restored.notes ?? "",
|
||||
done: !!restored.done,
|
||||
doneAt: restored.doneAt ?? null,
|
||||
createdAt: restored.createdAt ?? new Date().toISOString(),
|
||||
sourceTranscriptId: restored.sourceTranscriptId ?? null,
|
||||
parentTaskId: restored.parentTaskId ?? null,
|
||||
energy: restored.energy ?? null,
|
||||
archived: false,
|
||||
archivedAt: null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("unarchive_task_cmd failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
const buckets: Array<{ id: "all" | TaskBucket; label: string }> = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "inbox", label: "Inbox" },
|
||||
@@ -442,11 +516,11 @@
|
||||
{#each buckets as bucket}
|
||||
<button
|
||||
class="flex items-center gap-1.5 btn-md rounded-lg
|
||||
{activeBucket === bucket.id
|
||||
{activeBucket === bucket.id && !showArchived
|
||||
? 'bg-nav-active text-text font-medium'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text'}"
|
||||
style="transition-duration: var(--duration-ui)"
|
||||
onclick={() => activeBucket = bucket.id}
|
||||
onclick={() => { showArchived = false; activeBucket = bucket.id; }}
|
||||
>
|
||||
{bucket.label}
|
||||
{#if bucketCounts[bucket.id] > 0}
|
||||
@@ -456,6 +530,29 @@
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<!-- B2a: Archived pill. Hidden until at least one row is archived
|
||||
so we don't add visual noise for users who never use the
|
||||
feature. Toggling refreshes the list to catch other-window
|
||||
archive activity. -->
|
||||
{#if archivedTasksCount > 0}
|
||||
<button
|
||||
class="flex items-center gap-1.5 btn-md rounded-lg
|
||||
{showArchived
|
||||
? 'bg-nav-active text-text font-medium'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text'}"
|
||||
style="transition-duration: var(--duration-ui)"
|
||||
onclick={async () => {
|
||||
showArchived = !showArchived;
|
||||
if (showArchived) await refreshArchivedTasks();
|
||||
}}
|
||||
>
|
||||
<Archive size={12} aria-hidden="true" />
|
||||
Archived
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{archivedTasksCount}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
@@ -597,6 +694,46 @@
|
||||
|
||||
<!-- Task list -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
{#if showArchived}
|
||||
<!-- B2a: Archived view. Read-only-ish list with an Unarchive
|
||||
control per row. Hides the Now lane / completed sections
|
||||
entirely so the surface stays focused. -->
|
||||
{#if archivedTasks.length === 0}
|
||||
<EmptyState
|
||||
icon={Archive}
|
||||
message="No archived tasks. Use the archive button on a task to hide it without losing it."
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each archivedTasks as task (task.id)}
|
||||
<div class="group flex items-start gap-3 p-3 rounded-xl bg-bg-card border border-border-subtle hover:border-border opacity-80 hover:opacity-100"
|
||||
style="transition-duration: var(--duration-ui)">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[13px] text-text leading-relaxed">{task.text}</p>
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
{#if task.archivedAt}
|
||||
<span class="text-[10px] text-text-tertiary">Archived {formatTimestamp(task.archivedAt)}</span>
|
||||
{/if}
|
||||
{#if task.bucket}
|
||||
<span class="text-[10px] text-text-tertiary">· {task.bucket}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Unarchive task"
|
||||
title="Restore to its bucket"
|
||||
class="mt-0.5 flex items-center gap-1 px-2 py-1 rounded text-[11px] text-text-secondary hover:text-text hover:bg-hover"
|
||||
style="transition: background var(--duration-ui)"
|
||||
onclick={() => unarchiveTaskRow(task.id)}
|
||||
>
|
||||
<ArchiveRestore size={14} aria-hidden="true" />
|
||||
Unarchive
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- PR 1.2: Now lane sits above the bucket list and shares its
|
||||
filtered set. The bucket list excludes whatever IDs the lane
|
||||
rendered, so a task is never shown in both. The lane is only
|
||||
@@ -672,15 +809,29 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete -->
|
||||
<button
|
||||
aria-label="Delete task"
|
||||
class="mt-0.5 text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
style="transition: opacity var(--duration-ui)"
|
||||
onclick={() => deleteTask(task.id)}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<!-- Row controls (PR 1.3 baseline-opacity affordances). -->
|
||||
<div class="flex items-start gap-1">
|
||||
<!-- B2a: per-row archive. Lower contrast than delete
|
||||
because archive is the calmer, recoverable action. -->
|
||||
<button
|
||||
aria-label="Archive task"
|
||||
title="Archive (hide from list, recoverable)"
|
||||
class="mt-0.5 text-text-tertiary hover:text-text-secondary opacity-30 group-hover:opacity-100 focus-visible:opacity-100"
|
||||
style="transition: opacity var(--duration-ui)"
|
||||
onclick={() => archiveTaskRow(task.id)}
|
||||
>
|
||||
<Archive size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<!-- Delete -->
|
||||
<button
|
||||
aria-label="Delete task"
|
||||
class="mt-0.5 text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
style="transition: opacity var(--duration-ui)"
|
||||
onclick={() => deleteTask(task.id)}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -729,6 +880,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -264,6 +264,10 @@ export interface TaskDto {
|
||||
sourceTranscriptId: string | null;
|
||||
parentTaskId: string | null;
|
||||
energy: EnergyLevel | null;
|
||||
// B2a: archive flag (migration v16). Tasks with archived = true are
|
||||
// hidden from list_tasks_cmd and surfaced through list_archived_tasks_cmd.
|
||||
archived: boolean;
|
||||
archivedAt: string | null;
|
||||
}
|
||||
|
||||
export interface TaskEntry {
|
||||
@@ -279,6 +283,8 @@ export interface TaskEntry {
|
||||
sourceTranscriptId: string | null;
|
||||
parentTaskId: string | null;
|
||||
energy: EnergyLevel | null;
|
||||
archived: boolean;
|
||||
archivedAt: string | null;
|
||||
}
|
||||
|
||||
export type ImplementationRuleAction =
|
||||
|
||||
Reference in New Issue
Block a user