Skip to content

Commit 4a3fa22

Browse files
committed
✨ feat(kanban): implement bulk archival and improve UI
📝 **Changes:** - Added `isTaskFromPreviousWeeks` utility and `bulkArchiveTasks` store action to handle efficient batch archival of old completed tasks. - Created `ArchiveOldTasksDialog` with task selection, custom animated circular checkboxes, and polished scroll gradients. - Updated `KanbanBoard` to include a compact "Archive old tasks" button with a hover-revealed task count badge. - Improved `ProjectMiniChart` header layout and icon styling to match Kanban board filters. - Fixed a bug in `createArchivedTaskSlice` related to pending deletion types. 🦋 **Effect:** - Users can now easily identify and bulk-archive tasks completed before the current week. - The archival UI provides a premium experience with selection capability and visual feedback. - Project mini-chart header icons are now consistently aligned and styled. 🎯 **Purpose:** - To help users keep their Kanban boards clean by efficiently archiving old tasks while maintaining a high-quality, consistent UI.
1 parent 0361e98 commit 4a3fa22

File tree

11 files changed

+437
-61
lines changed

11 files changed

+437
-61
lines changed

src/__tests__/store/slices/createTaskSlice.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,25 @@ describe("createTaskSlice", () => {
8787
expect(useStore.getState().tasks.find((t) => t.id === task.id)).toBeDefined();
8888
expect(useStore.getState().tasks.find((t) => t.id === task.id)?.isArchived).toBe(false);
8989
});
90+
91+
it("should bulk archive multiple tasks", () => {
92+
const project = useStore.getState().addProject("Test Project");
93+
const t1 = useStore.getState().addTask(project.id, "T1");
94+
const t2 = useStore.getState().addTask(project.id, "T2");
95+
const t3 = useStore.getState().addTask(project.id, "T3");
96+
97+
useStore.getState().bulkArchiveTasks([t1.id, t2.id]);
98+
99+
const state = useStore.getState();
100+
expect(state.tasks.length).toBe(1);
101+
expect(state.tasks[0].id).toBe(t3.id);
102+
expect(state.archivedTasks.length).toBe(2);
103+
expect(state.archivedTasks.some((t) => t.id === t1.id)).toBe(true);
104+
expect(state.archivedTasks.some((t) => t.id === t2.id)).toBe(true);
105+
expect(state.archivedTasks.every((t) => t.isArchived)).toBe(true);
106+
107+
// Check pending deletes
108+
expect(state.pendingDeleteTaskIds).toContain(t1.id);
109+
expect(state.pendingDeleteTaskIds).toContain(t2.id);
110+
});
90111
});

src/__tests__/store/slices/deletionTracking.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,26 @@ describe("Deletion Tracking", () => {
2828
expect(state.pendingDeleteTaskIds).toContain(taskId);
2929
expect(state.tasks.find((t) => t.id === taskId)).toBeUndefined();
3030
});
31+
32+
it("should add archived task ID to pendingDeleteArchivedTaskIds when an archived task is deleted", () => {
33+
const mockArchivedTask = {
34+
id: "archived-t1",
35+
projectId: "p1",
36+
title: "Archived Task",
37+
status: "done" as const,
38+
priority: "medium" as const,
39+
subtasks: [],
40+
createdAt: new Date().toISOString(),
41+
updatedAt: new Date().toISOString(),
42+
isArchived: true,
43+
};
44+
45+
useStore.getState().upsertArchivedTask(mockArchivedTask);
46+
useStore.getState().deleteArchivedTask(mockArchivedTask.id);
47+
48+
const state = useStore.getState();
49+
expect(state.pendingDeleteArchivedTaskIds).toContain(mockArchivedTask.id);
50+
expect(state.pendingDeleteTaskIds).not.toContain(mockArchivedTask.id);
51+
expect(state.archivedTasks).toHaveLength(0);
52+
});
3153
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2+
import { isTaskFromPreviousWeeks } from "../../utils/time";
3+
import type { Task } from "../../types/task";
4+
5+
describe("isTaskFromPreviousWeeks", () => {
6+
// Today is Saturday, Jan 24, 2026
7+
const today = new Date("2026-01-24T12:00:00Z");
8+
// Monday of this week was Jan 19, 2026
9+
const mondayThisWeek = new Date("2026-01-19T00:00:00Z");
10+
11+
beforeEach(() => {
12+
vi.useFakeTimers();
13+
vi.setSystemTime(today);
14+
});
15+
16+
afterEach(() => {
17+
vi.useRealTimers();
18+
});
19+
20+
const createMockTask = (completedAt: string | undefined, status: Task["status"] = "done"): Task => ({
21+
id: "t1",
22+
projectId: "p1",
23+
title: "Test Task",
24+
status,
25+
priority: "medium",
26+
subtasks: [],
27+
createdAt: new Date().toISOString(),
28+
completedAt,
29+
isArchived: false,
30+
});
31+
32+
it("should return true for a task completed last week (Sunday)", () => {
33+
const lastSunday = new Date("2026-01-18T23:59:59Z");
34+
const task = createMockTask(lastSunday.toISOString());
35+
expect(isTaskFromPreviousWeeks(task)).toBe(true);
36+
});
37+
38+
it("should return false for a task completed this Monday at 00:01", () => {
39+
const thisMonday = new Date("2026-01-19T00:01:00Z");
40+
const task = createMockTask(thisMonday.toISOString());
41+
expect(isTaskFromPreviousWeeks(task)).toBe(false);
42+
});
43+
44+
it("should return false for a task completed today", () => {
45+
const task = createMockTask(today.toISOString());
46+
expect(isTaskFromPreviousWeeks(task)).toBe(false);
47+
});
48+
49+
it("should return false for a task that is not done", () => {
50+
const lastSunday = new Date("2026-01-18T23:59:59Z");
51+
const task = createMockTask(lastSunday.toISOString(), "todo");
52+
expect(isTaskFromPreviousWeeks(task)).toBe(false);
53+
});
54+
55+
it("should return false for a task without a completedAt date", () => {
56+
const task = createMockTask(undefined);
57+
expect(isTaskFromPreviousWeeks(task)).toBe(false);
58+
});
59+
60+
it("should return true for a task completed weeks ago", () => {
61+
const weeksAgo = new Date("2025-12-01T12:00:00Z");
62+
const task = createMockTask(weeksAgo.toISOString());
63+
expect(isTaskFromPreviousWeeks(task)).toBe(true);
64+
});
65+
});

src/components/project-view/ProjectMiniChart.tsx

Lines changed: 52 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -328,60 +328,59 @@ export function ProjectMiniChart({ projectId }: ProjectMiniChartProps) {
328328
<TrendingUp className="w-3 h-3 text-emerald-500/70" />
329329
Week Activity
330330
</CardTitle>
331-
<div className="flex items-center gap-0.5">
332-
<TooltipProvider>
333-
{VIEW_MODES.map(({ id, icon: Icon, label }) => {
334-
const isActive = viewMode === id;
335-
return (
336-
<Tooltip key={id} delayDuration={300}>
337-
<TooltipTrigger asChild>
338-
<Button
339-
variant="ghost"
340-
size="icon"
341-
className={cn(
342-
"h-6 w-6 rounded-full transition-colors",
343-
isActive
344-
? "text-primary bg-primary/10"
345-
: "text-muted-foreground/50 hover:text-foreground",
346-
)}
347-
onClick={() => setViewMode(id)}
348-
aria-label={label}
349-
>
350-
<Icon className="h-3! w-3!" />
351-
</Button>
352-
</TooltipTrigger>
353-
<TooltipContent side="top" className="text-[10px] px-2 py-1">
354-
{label}
355-
</TooltipContent>
356-
</Tooltip>
357-
);
358-
})}
359-
</TooltipProvider>
360-
</div>
361331
</div>
362-
<TooltipProvider>
363-
<Tooltip delayDuration={300}>
364-
<TooltipTrigger asChild>
365-
<Button
366-
variant="ghost"
367-
size="icon"
368-
className={cn(
369-
"h-6 w-6 rounded-full transition-colors",
370-
showOverlay
371-
? "text-primary bg-primary/10"
372-
: "text-muted-foreground/50 hover:text-foreground",
373-
)}
374-
onClick={() => setShowOverlay(!showOverlay)}
375-
aria-label="Toggle Stats"
376-
>
377-
<Calculator className="h-3! w-3!" />
378-
</Button>
379-
</TooltipTrigger>
380-
<TooltipContent side="top" className="text-[10px] px-2 py-1">
381-
Toggle Stats
382-
</TooltipContent>
383-
</Tooltip>
384-
</TooltipProvider>
332+
<div className="flex items-center gap-0.5">
333+
<TooltipProvider>
334+
{VIEW_MODES.map(({ id, icon: Icon, label }) => {
335+
const isActive = viewMode === id;
336+
return (
337+
<Tooltip key={id} delayDuration={300}>
338+
<TooltipTrigger asChild>
339+
<Button
340+
variant="ghost"
341+
size="icon"
342+
className={cn(
343+
"h-6 w-6 rounded-md transition-colors",
344+
isActive
345+
? "text-primary bg-primary/10"
346+
: "text-muted-foreground/50 hover:text-foreground",
347+
)}
348+
onClick={() => setViewMode(id)}
349+
aria-label={label}
350+
>
351+
<Icon className="h-3! w-3!" />
352+
</Button>
353+
</TooltipTrigger>
354+
<TooltipContent side="top" className="text-[10px] px-2 py-1">
355+
{label}
356+
</TooltipContent>
357+
</Tooltip>
358+
);
359+
})}
360+
<Tooltip delayDuration={300}>
361+
<TooltipTrigger asChild>
362+
<Button
363+
variant="ghost"
364+
size="icon"
365+
className={cn(
366+
"h-6 w-6 rounded-md transition-colors",
367+
showOverlay
368+
? "text-primary bg-primary/10"
369+
: "text-muted-foreground/50 hover:text-foreground",
370+
)}
371+
onClick={() => setShowOverlay(!showOverlay)}
372+
aria-label="Toggle Stats"
373+
>
374+
<Calculator className="h-3! w-3!" />
375+
</Button>
376+
377+
</TooltipTrigger>
378+
<TooltipContent side="top" className="text-[10px] px-2 py-1">
379+
Toggle Stats
380+
</TooltipContent>
381+
</Tooltip>
382+
</TooltipProvider>
383+
</div>
385384
</CardHeader>
386385
<CardContent className="p-0 pt-1 relative">
387386
<div className="h-[80px] w-full">

0 commit comments

Comments
 (0)