feat(KB-069): add Archive All Done feature

- Add archiveAllDone method to TaskStore with filtering and batch archive
- Add POST /tasks/archive-all-done API endpoint with tests
- Add useTasks hook support and API function for archiveAllDone
- Add Archive All button to done column header in Board UI
- Wire up onArchiveAllDone through Board component hierarchy
This commit is contained in:
gsxdsm
2026-03-30 16:50:34 -07:00
parent 107fbe17dc
commit 304506b237
11 changed files with 293 additions and 5 deletions

View File

@@ -2113,6 +2113,96 @@ describe("TaskStore", () => {
});
});
describe("archiveAllDone", () => {
it("archives multiple done tasks", async () => {
const task1 = await store.createTask({ description: "Test task 1" });
const task2 = await store.createTask({ description: "Test task 2" });
const task3 = await store.createTask({ description: "Test task 3" });
// Move all to done
for (const task of [task1, task2, task3]) {
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
}
const archived = await store.archiveAllDone();
expect(archived).toHaveLength(3);
expect(archived.every((t) => t.column === "archived")).toBe(true);
});
it("returns empty array when no done tasks exist", async () => {
const result = await store.archiveAllDone();
expect(result).toEqual([]);
});
it("emits task:moved event for each archived task", async () => {
const task1 = await store.createTask({ description: "Test task 1" });
const task2 = await store.createTask({ description: "Test task 2" });
for (const task of [task1, task2]) {
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
}
const events: any[] = [];
store.on("task:moved", (data) => events.push(data));
await store.archiveAllDone();
expect(events).toHaveLength(2);
expect(events.every((e) => e.from === "done" && e.to === "archived")).toBe(true);
});
it("does not affect tasks in other columns", async () => {
const doneTask = await store.createTask({ description: "Done task" });
await store.moveTask(doneTask.id, "todo");
await store.moveTask(doneTask.id, "in-progress");
await store.moveTask(doneTask.id, "in-review");
await store.moveTask(doneTask.id, "done");
const todoTask = await store.createTask({ description: "Todo task" });
await store.moveTask(todoTask.id, "todo");
const inProgressTask = await store.createTask({ description: "In progress task" });
await store.moveTask(inProgressTask.id, "todo");
await store.moveTask(inProgressTask.id, "in-progress");
await store.archiveAllDone();
const fetchedTodo = await store.getTask(todoTask.id);
const fetchedInProgress = await store.getTask(inProgressTask.id);
expect(fetchedTodo.column).toBe("todo");
expect(fetchedInProgress.column).toBe("in-progress");
});
it("archives only done tasks when mixed columns exist", async () => {
const doneTask1 = await store.createTask({ description: "Done task 1" });
const doneTask2 = await store.createTask({ description: "Done task 2" });
const todoTask = await store.createTask({ description: "Todo task" });
for (const task of [doneTask1, doneTask2]) {
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
}
await store.moveTask(todoTask.id, "todo");
const archived = await store.archiveAllDone();
expect(archived).toHaveLength(2);
expect(archived.map((t) => t.id).sort()).toEqual([doneTask1.id, doneTask2.id].sort());
});
});
describe("VALID_TRANSITIONS — invalid archived transitions via moveTask", () => {
it("moveTask from archived → in-progress should fail", async () => {
const task = await store.createTask({ description: "Test task" });

View File

@@ -875,6 +875,26 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
/**
* Archive all tasks currently in the "done" column.
* Returns an array of archived tasks.
*/
async archiveAllDone(): Promise<Task[]> {
const tasks = await this.listTasks();
const doneTasks = tasks.filter((t) => t.column === "done");
if (doneTasks.length === 0) {
return [];
}
// Archive all done tasks concurrently
const archivedTasks = await Promise.all(
doneTasks.map((task) => this.archiveTask(task.id))
);
return archivedTasks;
}
/**
* Archive a done task (move from done → archived).
* Logs the action and emits `task:moved` event.

View File

@@ -46,7 +46,7 @@ function AppInner() {
});
const [searchQuery, setSearchQuery] = useState("");
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask } = useTasks();
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks();
// Theme management
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
@@ -216,6 +216,7 @@ function AppInner() {
onUpdateTask={updateTask}
onArchiveTask={archiveTask}
onUnarchiveTask={unarchiveTask}
onArchiveAllDone={archiveAllDone}
searchQuery={searchQuery}
/>
) : (

View File

@@ -123,6 +123,12 @@ export function unarchiveTask(id: string): Promise<Task> {
return api<Task>(`/tasks/${id}/unarchive`, { method: "POST" });
}
export function archiveAllDone(): Promise<Task[]> {
return api<{ archived: Task[] }>("/tasks/archive-all-done", { method: "POST" }).then(
(response) => response.archived
);
}
export function approvePlan(id: string): Promise<Task> {
return api<Task>(`/tasks/${id}/approve-plan`, { method: "POST" });
}

View File

@@ -22,6 +22,7 @@ interface BoardProps {
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onArchiveAllDone?: () => Promise<Task[]>;
searchQuery?: string;
}
@@ -41,7 +42,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
return previous.every((task, index) => task === next[index]);
}
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, searchQuery = "" }: BoardProps) {
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "" }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const { fetchBatch } = useBatchBadgeFetch();
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -147,6 +148,7 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
onUnarchiveTask={onUnarchiveTask}
{...(col === "triage" ? { onQuickCreate, onNewTask } : {})}
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone } : {})}
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
/>
))}

View File

@@ -7,7 +7,7 @@ import { WorktreeGroup } from "./WorktreeGroup";
import { QuickEntryBox } from "./QuickEntryBox";
import { groupByWorktree } from "../utils/worktreeGrouping";
import type { ToastType } from "../hooks/useToast";
import { ChevronDown, ChevronUp } from "lucide-react";
import { ChevronDown, ChevronUp, Archive } from "lucide-react";
const PAGINATED_COLUMN_THRESHOLD = 100;
const VISIBLE_TASKS_INITIAL = 50;
@@ -31,11 +31,12 @@ interface ColumnProps {
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onArchiveAllDone?: () => Promise<Task[]>;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, collapsed, onToggleCollapse }: ColumnProps) {
function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse }: ColumnProps) {
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const countFlashing = useFlashOnIncrease(tasks.length);
@@ -99,6 +100,21 @@ function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetai
setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length));
}, [tasks.length]);
const handleArchiveAll = useCallback(async () => {
if (!onArchiveAllDone) return;
if (tasks.length === 0) return;
const confirmed = window.confirm(`Archive all ${tasks.length} done tasks?`);
if (!confirmed) return;
try {
const archived = await onArchiveAllDone();
addToast(`Archived ${archived.length} tasks`, "success");
} catch (err: any) {
addToast(err.message || "Failed to archive tasks", "error");
}
}, [onArchiveAllDone, tasks.length, addToast]);
return (
<div
className={`column${dragOver ? " drag-over" : ""}${isArchived ? " column-archived" : ""}${isCollapsed ? " column-collapsed" : ""}`}
@@ -127,6 +143,17 @@ function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetai
+ New Task
</button>
)}
{column === "done" && onArchiveAllDone && (
<button
className="btn btn-icon btn-sm"
onClick={handleArchiveAll}
disabled={tasks.length === 0}
title="Archive all done tasks"
aria-label="Archive all done tasks"
>
<Archive size={16} />
</button>
)}
{isArchived && onToggleCollapse && (
<button
className="btn btn-icon btn-sm"

View File

@@ -30,10 +30,14 @@ vi.mock("../../api", () => ({
mergeTask: vi.fn(),
retryTask: vi.fn(),
updateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
}));
const mockFetchTasks = vi.mocked(api.fetchTasks);
const mockUpdateTask = vi.mocked(api.updateTask);
const mockArchiveAllDone = vi.mocked(api.archiveAllDone);
// Mock EventSource
class MockEventSource {
@@ -643,4 +647,57 @@ describe("useTasks", () => {
expect(result.current.tasks[0].dependencies).toEqual(["KB-002", "KB-003"]);
});
});
describe("archiveAllDone", () => {
it("archives all done tasks and updates local state", async () => {
const doneTasks = [
createMockTask({ id: "KB-001", column: "done" as Column }),
createMockTask({ id: "KB-002", column: "done" as Column }),
];
const todoTask = createMockTask({ id: "KB-003", column: "todo" as Column });
mockFetchTasks.mockResolvedValueOnce([...doneTasks, todoTask]);
const archivedTasks = [
createMockTask({ id: "KB-001", column: "archived" as Column }),
createMockTask({ id: "KB-002", column: "archived" as Column }),
];
mockArchiveAllDone.mockResolvedValueOnce(archivedTasks);
const { result } = renderHook(() => useTasks());
await waitFor(() => {
expect(result.current.tasks).toHaveLength(3);
});
await act(async () => {
await result.current.archiveAllDone();
});
expect(mockArchiveAllDone).toHaveBeenCalled();
// Done tasks should be archived
expect(result.current.tasks.find((t) => t.id === "KB-001")?.column).toBe("archived");
expect(result.current.tasks.find((t) => t.id === "KB-002")?.column).toBe("archived");
// Todo task should remain unchanged
expect(result.current.tasks.find((t) => t.id === "KB-003")?.column).toBe("todo");
});
it("returns empty array when no done tasks exist", async () => {
const todoTask = createMockTask({ id: "KB-001", column: "todo" as Column });
mockFetchTasks.mockResolvedValueOnce([todoTask]);
mockArchiveAllDone.mockResolvedValueOnce([]);
const { result } = renderHook(() => useTasks());
await waitFor(() => {
expect(result.current.tasks).toHaveLength(1);
});
const archived = await act(async () => {
return await result.current.archiveAllDone();
});
expect(archived).toEqual([]);
expect(result.current.tasks[0].column).toBe("todo");
});
});
});

View File

@@ -214,5 +214,18 @@ export function useTasks() {
return task;
}, []);
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask };
const archiveAllDone = useCallback(async (): Promise<Task[]> => {
const archived = await api.archiveAllDone();
const normalized = archived.map(normalizeTask);
// Update local state by mapping over tasks and updating archived ones
setTasks((prev) =>
prev.map((t) => {
const updated = normalized.find((archived) => archived.id === t.id);
return updated || t;
})
);
return normalized;
}, []);
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone };
}

View File

@@ -705,6 +705,63 @@ describe("POST /tasks/:id/unarchive", () => {
});
});
describe("POST /tasks/archive-all-done", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore({
archiveAllDone: vi.fn(),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("archives all done tasks and returns the archived array", async () => {
const archivedTasks = [
{ ...FAKE_TASK_DETAIL, id: "KB-001", column: "archived" },
{ ...FAKE_TASK_DETAIL, id: "KB-002", column: "archived" },
];
(store.archiveAllDone as ReturnType<typeof vi.fn>).mockResolvedValue(archivedTasks);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/archive-all-done", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.archived).toHaveLength(2);
expect(res.body.archived[0].column).toBe("archived");
expect(res.body.archived[1].column).toBe("archived");
expect(store.archiveAllDone).toHaveBeenCalled();
});
it("returns empty array when no done tasks exist", async () => {
(store.archiveAllDone as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/archive-all-done", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.archived).toEqual([]);
});
it("returns 500 on unexpected errors", async () => {
(store.archiveAllDone as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
const res = await REQUEST(buildApp(), "POST", "/api/tasks/archive-all-done", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(500);
expect(res.body.error).toContain("Database error");
});
});
describe("PATCH /tasks/:id", () => {
let store: TaskStore;

View File

@@ -790,6 +790,16 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// Archive all done tasks
router.post("/tasks/archive-all-done", async (req, res) => {
try {
const archived = await store.archiveAllDone();
res.json({ archived });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Upload attachment
router.post("/tasks/:id/attachments", upload.single("file"), async (req, res) => {
try {