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

@@ -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">&middot; {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>