@kmgeon/taskflow 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +374 -0
  2. package/bin/task-mcp.mjs +19 -0
  3. package/bin/task.mjs +19 -0
  4. package/docs/clean-code.md +29 -0
  5. package/docs/git.md +36 -0
  6. package/docs/guideline.md +25 -0
  7. package/docs/security.md +32 -0
  8. package/docs/step-by-step.md +29 -0
  9. package/docs/superpowers/specs/2026-03-21-cli-advisor-design.md +383 -0
  10. package/docs/superpowers/specs/2026-03-21-init-redesign-design.md +429 -0
  11. package/docs/superpowers/specs/2026-03-21-skill-architecture-design.md +362 -0
  12. package/docs/superpowers/specs/2026-03-23-t-create-task-run-design.md +40 -0
  13. package/docs/superpowers/specs/2026-03-23-task-run-design.md +44 -0
  14. package/docs/tdd.md +41 -0
  15. package/package.json +114 -0
  16. package/src/app/(protected)/dashboard/page.tsx +7 -0
  17. package/src/app/(protected)/layout.tsx +10 -0
  18. package/src/app/api/[[...hono]]/route.ts +13 -0
  19. package/src/app/example/page.tsx +11 -0
  20. package/src/app/favicon.ico +0 -0
  21. package/src/app/globals.css +168 -0
  22. package/src/app/layout.tsx +35 -0
  23. package/src/app/page.tsx +5 -0
  24. package/src/app/providers.tsx +57 -0
  25. package/src/backend/config/index.ts +36 -0
  26. package/src/backend/hono/app.ts +32 -0
  27. package/src/backend/hono/context.ts +38 -0
  28. package/src/backend/http/response.ts +64 -0
  29. package/src/backend/middleware/context.ts +23 -0
  30. package/src/backend/middleware/error.ts +31 -0
  31. package/src/backend/middleware/supabase.ts +23 -0
  32. package/src/backend/supabase/client.ts +17 -0
  33. package/src/cli/commands/__tests__/task-commands.test.ts +170 -0
  34. package/src/cli/commands/advisor.ts +45 -0
  35. package/src/cli/commands/ask.ts +50 -0
  36. package/src/cli/commands/board.ts +72 -0
  37. package/src/cli/commands/init.ts +184 -0
  38. package/src/cli/commands/list.ts +138 -0
  39. package/src/cli/commands/run.ts +143 -0
  40. package/src/cli/commands/set-status.ts +50 -0
  41. package/src/cli/commands/show.ts +28 -0
  42. package/src/cli/commands/tree.ts +72 -0
  43. package/src/cli/index.ts +38 -0
  44. package/src/cli/lib/__tests__/formatter.test.ts +123 -0
  45. package/src/cli/lib/error-boundary.test.ts +135 -0
  46. package/src/cli/lib/error-boundary.ts +70 -0
  47. package/src/cli/lib/formatter.ts +764 -0
  48. package/src/cli/lib/trd.ts +33 -0
  49. package/src/cli/lib/validate.test.ts +89 -0
  50. package/src/cli/lib/validate.ts +43 -0
  51. package/src/cli/prompts/task-run.md +25 -0
  52. package/src/components/layout/AppLayout.tsx +15 -0
  53. package/src/components/layout/Sidebar.tsx +124 -0
  54. package/src/components/ui/accordion.tsx +58 -0
  55. package/src/components/ui/avatar.tsx +50 -0
  56. package/src/components/ui/badge.tsx +36 -0
  57. package/src/components/ui/button.tsx +56 -0
  58. package/src/components/ui/card.tsx +79 -0
  59. package/src/components/ui/checkbox.tsx +30 -0
  60. package/src/components/ui/dialog.tsx +122 -0
  61. package/src/components/ui/dropdown-menu.tsx +200 -0
  62. package/src/components/ui/file-upload.tsx +50 -0
  63. package/src/components/ui/form.tsx +179 -0
  64. package/src/components/ui/input.tsx +25 -0
  65. package/src/components/ui/label.tsx +26 -0
  66. package/src/components/ui/scroll-area.tsx +48 -0
  67. package/src/components/ui/select.tsx +160 -0
  68. package/src/components/ui/separator.tsx +31 -0
  69. package/src/components/ui/sheet.tsx +140 -0
  70. package/src/components/ui/textarea.tsx +22 -0
  71. package/src/components/ui/toast.tsx +129 -0
  72. package/src/components/ui/toaster.tsx +35 -0
  73. package/src/core/ai/claude-client.ts +79 -0
  74. package/src/core/claude-runner/flag-builder.ts +57 -0
  75. package/src/core/claude-runner/index.ts +2 -0
  76. package/src/core/claude-runner/spawner.ts +86 -0
  77. package/src/core/prd/__tests__/auto-analyzer.test.ts +35 -0
  78. package/src/core/prd/__tests__/generator.test.ts +26 -0
  79. package/src/core/prd/__tests__/scanner.test.ts +35 -0
  80. package/src/core/prd/auto-analyzer.ts +9 -0
  81. package/src/core/prd/generator.ts +8 -0
  82. package/src/core/prd/scanner.ts +117 -0
  83. package/src/core/project/__tests__/claude-setup.test.ts +133 -0
  84. package/src/core/project/__tests__/config.test.ts +30 -0
  85. package/src/core/project/__tests__/init.test.ts +37 -0
  86. package/src/core/project/__tests__/skill-setup.test.ts +62 -0
  87. package/src/core/project/claude-setup.ts +224 -0
  88. package/src/core/project/config.ts +34 -0
  89. package/src/core/project/docs-setup.ts +26 -0
  90. package/src/core/project/docs-templates.ts +205 -0
  91. package/src/core/project/init.ts +40 -0
  92. package/src/core/project/skill-setup.ts +32 -0
  93. package/src/core/project/skill-templates.ts +277 -0
  94. package/src/core/task/index.ts +16 -0
  95. package/src/core/types.ts +58 -0
  96. package/src/features/example/backend/error.ts +9 -0
  97. package/src/features/example/backend/route.ts +52 -0
  98. package/src/features/example/backend/schema.ts +25 -0
  99. package/src/features/example/backend/service.ts +73 -0
  100. package/src/features/example/components/example-status.test.tsx +97 -0
  101. package/src/features/example/components/example-status.tsx +160 -0
  102. package/src/features/example/hooks/useExampleQuery.ts +23 -0
  103. package/src/features/example/lib/dto.test.ts +57 -0
  104. package/src/features/example/lib/dto.ts +5 -0
  105. package/src/features/kanban/backend/__tests__/sse-broadcaster.test.ts +137 -0
  106. package/src/features/kanban/backend/__tests__/sse-event-format.test.ts +55 -0
  107. package/src/features/kanban/backend/route.ts +55 -0
  108. package/src/features/kanban/backend/sse-broadcaster.ts +142 -0
  109. package/src/features/kanban/backend/sse-route.ts +43 -0
  110. package/src/features/kanban/components/KanbanBoard.tsx +105 -0
  111. package/src/features/kanban/components/KanbanColumn.tsx +51 -0
  112. package/src/features/kanban/components/KanbanError.tsx +29 -0
  113. package/src/features/kanban/components/KanbanSkeleton.tsx +46 -0
  114. package/src/features/kanban/components/ProgressCard.tsx +42 -0
  115. package/src/features/kanban/components/TaskCard.tsx +76 -0
  116. package/src/features/kanban/components/__tests__/kanban-components.test.tsx +86 -0
  117. package/src/features/kanban/hooks/useTaskSse.ts +66 -0
  118. package/src/features/kanban/hooks/useTasksQuery.ts +52 -0
  119. package/src/features/kanban/lib/__tests__/kanban-utils.test.ts +97 -0
  120. package/src/features/kanban/lib/kanban-utils.ts +37 -0
  121. package/src/features/taskflow/constants.ts +54 -0
  122. package/src/features/taskflow/index.ts +27 -0
  123. package/src/features/taskflow/lib/__tests__/filter.test.ts +89 -0
  124. package/src/features/taskflow/lib/__tests__/graph.test.ts +247 -0
  125. package/src/features/taskflow/lib/__tests__/repository.test.ts +233 -0
  126. package/src/features/taskflow/lib/__tests__/serializer.test.ts +98 -0
  127. package/src/features/taskflow/lib/advisor/__tests__/advisor-integration.test.ts +98 -0
  128. package/src/features/taskflow/lib/advisor/ai-advisor.test.ts +40 -0
  129. package/src/features/taskflow/lib/advisor/ai-advisor.ts +20 -0
  130. package/src/features/taskflow/lib/advisor/context-builder.test.ts +73 -0
  131. package/src/features/taskflow/lib/advisor/context-builder.ts +151 -0
  132. package/src/features/taskflow/lib/advisor/db.test.ts +106 -0
  133. package/src/features/taskflow/lib/advisor/db.ts +185 -0
  134. package/src/features/taskflow/lib/advisor/local-summary.test.ts +53 -0
  135. package/src/features/taskflow/lib/advisor/local-summary.ts +72 -0
  136. package/src/features/taskflow/lib/advisor/prompts.ts +86 -0
  137. package/src/features/taskflow/lib/filter.ts +54 -0
  138. package/src/features/taskflow/lib/fs-utils.ts +50 -0
  139. package/src/features/taskflow/lib/graph.ts +148 -0
  140. package/src/features/taskflow/lib/index-builder.ts +42 -0
  141. package/src/features/taskflow/lib/repository.ts +168 -0
  142. package/src/features/taskflow/lib/serializer.ts +62 -0
  143. package/src/features/taskflow/lib/watcher.ts +40 -0
  144. package/src/features/taskflow/types.ts +71 -0
  145. package/src/hooks/use-toast.ts +194 -0
  146. package/src/lib/remote/api-client.ts +40 -0
  147. package/src/lib/supabase/client.ts +8 -0
  148. package/src/lib/supabase/server.ts +46 -0
  149. package/src/lib/supabase/types.ts +3 -0
  150. package/src/lib/utils.ts +6 -0
  151. package/src/mcp/index.ts +7 -0
  152. package/src/mcp/server.ts +21 -0
  153. package/src/mcp/tools/brainstorm.ts +48 -0
  154. package/src/mcp/tools/prd.ts +71 -0
  155. package/src/mcp/tools/project.ts +39 -0
  156. package/src/mcp/tools/task-status.ts +40 -0
  157. package/src/mcp/tools/task.ts +82 -0
  158. package/src/mcp/util.ts +6 -0
@@ -0,0 +1,105 @@
1
+ "use client";
2
+
3
+ import {
4
+ DndContext,
5
+ DragOverlay,
6
+ PointerSensor,
7
+ KeyboardSensor,
8
+ useSensor,
9
+ useSensors,
10
+ type DragEndEvent,
11
+ type DragStartEvent,
12
+ } from "@dnd-kit/core";
13
+ import { useState } from "react";
14
+ import { KanbanColumn } from "./KanbanColumn";
15
+ import { TaskCard } from "./TaskCard";
16
+ import { ProgressCard } from "./ProgressCard";
17
+ import { KanbanSkeleton } from "./KanbanSkeleton";
18
+ import { KanbanError } from "./KanbanError";
19
+ import { useTasksQuery, useSetStatusMutation } from "../hooks/useTasksQuery";
20
+ import { useTaskSse } from "../hooks/useTaskSse";
21
+ import { groupByStatus, COLUMN_ORDER } from "../lib/kanban-utils";
22
+ import type { Task, TaskStatus } from "@/features/taskflow/types";
23
+ import { TASK_STATUSES } from "@/features/taskflow/types";
24
+
25
+ export function KanbanBoard() {
26
+ const { data: tasks, isLoading, isError, error, refetch } = useTasksQuery();
27
+ const mutation = useSetStatusMutation();
28
+ const [activeTask, setActiveTask] = useState<Task | null>(null);
29
+ useTaskSse();
30
+
31
+ const sensors = useSensors(
32
+ useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
33
+ useSensor(KeyboardSensor),
34
+ );
35
+
36
+ if (isLoading) return <KanbanSkeleton />;
37
+ if (isError) return <KanbanError message={error?.message} onRetry={() => refetch()} />;
38
+
39
+ const allTasks = tasks ?? [];
40
+ const columns = groupByStatus(allTasks);
41
+
42
+ function handleDragStart(event: DragStartEvent) {
43
+ const task = event.active.data.current?.task as Task | undefined;
44
+ setActiveTask(task ?? null);
45
+ }
46
+
47
+ function handleDragEnd(event: DragEndEvent) {
48
+ setActiveTask(null);
49
+
50
+ const { active, over } = event;
51
+ if (!over) return;
52
+
53
+ const taskId = String(active.id);
54
+ const newStatus = String(over.id) as TaskStatus;
55
+
56
+ if (!(TASK_STATUSES as readonly string[]).includes(newStatus)) return;
57
+
58
+ const task = allTasks.find((t) => t.id === taskId);
59
+ if (!task || task.status === newStatus) return;
60
+
61
+ mutation.mutate({ id: taskId, to: newStatus });
62
+ }
63
+
64
+ return (
65
+ <div className="flex h-full flex-col">
66
+ {/* Board header */}
67
+ <div className="flex items-center justify-between border-b border-border px-6 py-4">
68
+ <div>
69
+ <h1 className="text-xl font-semibold text-foreground">칸반 보드</h1>
70
+ <p className="mt-0.5 text-sm text-muted-foreground">
71
+ {allTasks.length}개의 태스크
72
+ </p>
73
+ </div>
74
+ <div className="w-64">
75
+ <ProgressCard tasks={allTasks} />
76
+ </div>
77
+ </div>
78
+
79
+ {/* Board columns */}
80
+ <DndContext
81
+ sensors={sensors}
82
+ onDragStart={handleDragStart}
83
+ onDragEnd={handleDragEnd}
84
+ >
85
+ <div className="flex flex-1 gap-4 overflow-x-auto p-6">
86
+ {COLUMN_ORDER.map((status) => (
87
+ <KanbanColumn
88
+ key={status}
89
+ status={status}
90
+ tasks={columns[status]}
91
+ />
92
+ ))}
93
+ </div>
94
+
95
+ <DragOverlay>
96
+ {activeTask ? (
97
+ <div className="w-[264px]">
98
+ <TaskCard task={activeTask} />
99
+ </div>
100
+ ) : null}
101
+ </DragOverlay>
102
+ </DndContext>
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,51 @@
1
+ "use client";
2
+
3
+ import { useDroppable } from "@dnd-kit/core";
4
+ import { cn } from "@/lib/utils";
5
+ import { TaskCard } from "./TaskCard";
6
+ import { STATUS_CONFIG } from "../lib/kanban-utils";
7
+ import type { Task, TaskStatus } from "@/features/taskflow/types";
8
+
9
+ type KanbanColumnProps = {
10
+ status: TaskStatus;
11
+ tasks: Task[];
12
+ };
13
+
14
+ export function KanbanColumn({ status, tasks }: KanbanColumnProps) {
15
+ const { setNodeRef, isOver } = useDroppable({ id: status });
16
+ const config = STATUS_CONFIG[status];
17
+
18
+ return (
19
+ <div className="flex w-[280px] shrink-0 flex-col" role="group" aria-label={`${config.label} 컬럼`}>
20
+ {/* Column header */}
21
+ <div className="flex items-center gap-2 px-1 pb-3">
22
+ <div className={cn("h-2.5 w-2.5 rounded-full", config.dotColor)} />
23
+ <span className="text-sm font-semibold text-foreground">
24
+ {config.label}
25
+ </span>
26
+ <span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1.5 text-xs text-muted-foreground">
27
+ {tasks.length}
28
+ </span>
29
+ </div>
30
+
31
+ {/* Task list — droppable zone */}
32
+ <div
33
+ ref={setNodeRef}
34
+ className={cn(
35
+ "flex flex-1 flex-col gap-2 rounded-lg p-2 transition-colors",
36
+ isOver ? "bg-accent/20 ring-2 ring-accent/40" : "bg-muted/30",
37
+ )}
38
+ role="list"
39
+ aria-label={`${config.label} 태스크 목록`}
40
+ >
41
+ {tasks.length === 0 ? (
42
+ <p className="py-8 text-center text-xs text-muted-foreground">
43
+ 태스크 없음
44
+ </p>
45
+ ) : (
46
+ tasks.map((task) => <TaskCard key={task.id} task={task} />)
47
+ )}
48
+ </div>
49
+ </div>
50
+ );
51
+ }
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { AlertTriangle, RefreshCw } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+
6
+ type KanbanErrorProps = {
7
+ message?: string;
8
+ onRetry?: () => void;
9
+ };
10
+
11
+ export function KanbanError({ message, onRetry }: KanbanErrorProps) {
12
+ return (
13
+ <div className="flex h-full flex-col items-center justify-center gap-4 p-8">
14
+ <AlertTriangle className="h-12 w-12 text-destructive" />
15
+ <div className="text-center">
16
+ <h2 className="text-lg font-semibold text-foreground">오류가 발생했습니다</h2>
17
+ <p className="mt-1 text-sm text-muted-foreground">
18
+ {message ?? "태스크를 불러오는 중 문제가 발생했습니다."}
19
+ </p>
20
+ </div>
21
+ {onRetry && (
22
+ <Button variant="outline" size="sm" onClick={onRetry}>
23
+ <RefreshCw className="mr-2 h-4 w-4" />
24
+ 다시 시도
25
+ </Button>
26
+ )}
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,46 @@
1
+ "use client";
2
+
3
+ import { COLUMN_ORDER, STATUS_CONFIG } from "../lib/kanban-utils";
4
+
5
+ function SkeletonCard() {
6
+ return (
7
+ <div className="animate-pulse rounded-lg border border-border/40 bg-card p-3">
8
+ <div className="h-4 w-3/4 rounded bg-muted" />
9
+ <div className="mt-2 flex gap-2">
10
+ <div className="h-3 w-12 rounded bg-muted" />
11
+ <div className="h-3 w-8 rounded bg-muted" />
12
+ </div>
13
+ </div>
14
+ );
15
+ }
16
+
17
+ export function KanbanSkeleton() {
18
+ return (
19
+ <div className="flex h-full flex-col">
20
+ <div className="flex items-center justify-between border-b border-border px-6 py-4">
21
+ <div>
22
+ <div className="h-6 w-32 animate-pulse rounded bg-muted" />
23
+ <div className="mt-1 h-4 w-24 animate-pulse rounded bg-muted" />
24
+ </div>
25
+ </div>
26
+
27
+ <div className="flex flex-1 gap-4 overflow-x-auto p-6">
28
+ {COLUMN_ORDER.map((status) => (
29
+ <div key={status} className="flex w-[280px] shrink-0 flex-col">
30
+ <div className="flex items-center gap-2 px-1 pb-3">
31
+ <div className={`h-2.5 w-2.5 rounded-full ${STATUS_CONFIG[status].dotColor}`} />
32
+ <span className="text-sm font-semibold text-foreground">
33
+ {STATUS_CONFIG[status].label}
34
+ </span>
35
+ </div>
36
+ <div className="flex flex-1 flex-col gap-2 rounded-lg bg-muted/30 p-2">
37
+ <SkeletonCard />
38
+ <SkeletonCard />
39
+ <SkeletonCard />
40
+ </div>
41
+ </div>
42
+ ))}
43
+ </div>
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import { Card } from "@/components/ui/card";
4
+ import { CheckCircle2, ListTodo } from "lucide-react";
5
+ import { computeProgress, groupByStatus } from "../lib/kanban-utils";
6
+ import type { Task } from "@/features/taskflow/types";
7
+
8
+ type ProgressCardProps = {
9
+ tasks: Task[];
10
+ };
11
+
12
+ export function ProgressCard({ tasks }: ProgressCardProps) {
13
+ const progress = computeProgress(tasks);
14
+ const columns = groupByStatus(tasks);
15
+ const doneCount = columns.Done.length;
16
+
17
+ return (
18
+ <Card className="flex items-center gap-4 border-border/60 px-4 py-3">
19
+ <div className="flex items-center gap-2">
20
+ <CheckCircle2 className="h-4 w-4 text-green-500" />
21
+ <span className="text-sm font-medium text-foreground">{progress}%</span>
22
+ </div>
23
+
24
+ <div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
25
+ <div
26
+ className="h-full rounded-full bg-green-500 transition-all duration-300"
27
+ style={{ width: `${progress}%` }}
28
+ role="progressbar"
29
+ aria-valuenow={progress}
30
+ aria-valuemin={0}
31
+ aria-valuemax={100}
32
+ aria-label={`완료율 ${progress}%`}
33
+ />
34
+ </div>
35
+
36
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
37
+ <ListTodo className="h-3.5 w-3.5" />
38
+ <span>{doneCount}/{tasks.length}</span>
39
+ </div>
40
+ </Card>
41
+ );
42
+ }
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import { useDraggable } from "@dnd-kit/core";
4
+ import { Card } from "@/components/ui/card";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { GripVertical } from "lucide-react";
7
+ import { cn } from "@/lib/utils";
8
+ import type { Task } from "@/features/taskflow/types";
9
+
10
+ type TaskCardProps = {
11
+ task: Task;
12
+ };
13
+
14
+ function priorityBadge(priority: number) {
15
+ if (priority >= 8) return { label: "High", className: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20" };
16
+ if (priority >= 5) return { label: "Med", className: "bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 border-yellow-500/20" };
17
+ return { label: "Low", className: "bg-green-500/15 text-green-600 dark:text-green-400 border-green-500/20" };
18
+ }
19
+
20
+ export function TaskCard({ task }: TaskCardProps) {
21
+ const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
22
+ id: task.id,
23
+ data: { task },
24
+ });
25
+
26
+ const style = transform
27
+ ? { transform: `translate(${transform.x}px, ${transform.y}px)` }
28
+ : undefined;
29
+
30
+ const badge = priorityBadge(task.priority);
31
+
32
+ return (
33
+ <Card
34
+ ref={setNodeRef}
35
+ style={style}
36
+ className={cn(
37
+ "border-border/60 p-3 transition-colors hover:border-accent/40 hover:bg-muted/50",
38
+ isDragging && "opacity-50 shadow-lg ring-2 ring-accent",
39
+ )}
40
+ role="listitem"
41
+ aria-label={`태스크: ${task.title}`}
42
+ >
43
+ <div className="flex items-start gap-2">
44
+ <button
45
+ className="mt-0.5 cursor-grab touch-none text-muted-foreground hover:text-foreground"
46
+ aria-label="드래그하여 상태 변경"
47
+ {...listeners}
48
+ {...attributes}
49
+ >
50
+ <GripVertical className="h-4 w-4" />
51
+ </button>
52
+ <div className="flex-1 min-w-0">
53
+ <p className="text-sm font-medium leading-snug text-foreground truncate">
54
+ {task.title}
55
+ </p>
56
+ <div className="mt-2 flex items-center gap-2">
57
+ <span className="text-[11px] font-mono text-muted-foreground">
58
+ #{task.id}
59
+ </span>
60
+ <Badge
61
+ variant="outline"
62
+ className={cn("px-1.5 py-0 text-[10px]", badge.className)}
63
+ >
64
+ {badge.label}
65
+ </Badge>
66
+ {task.dependencies.length > 0 && (
67
+ <span className="text-[10px] text-muted-foreground">
68
+ dep: {task.dependencies.length}
69
+ </span>
70
+ )}
71
+ </div>
72
+ </div>
73
+ </div>
74
+ </Card>
75
+ );
76
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { KanbanError } from "../KanbanError";
4
+ import { ProgressCard } from "../ProgressCard";
5
+ import { KanbanSkeleton } from "../KanbanSkeleton";
6
+ import type { Task } from "@/features/taskflow/types";
7
+
8
+ const now = "2026-03-21T00:00:00.000Z";
9
+
10
+ function makeTask(overrides: Partial<Task> = {}): Task {
11
+ return {
12
+ id: "001",
13
+ title: "Test task",
14
+ status: "Todo",
15
+ priority: 5,
16
+ dependencies: [],
17
+ createdAt: now,
18
+ updatedAt: now,
19
+ description: "",
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ describe("KanbanError", () => {
25
+ it("should render error message", () => {
26
+ render(<KanbanError message="테스트 오류" />);
27
+ expect(screen.getByText("오류가 발생했습니다")).toBeInTheDocument();
28
+ expect(screen.getByText("테스트 오류")).toBeInTheDocument();
29
+ });
30
+
31
+ it("should render default message when none provided", () => {
32
+ render(<KanbanError />);
33
+ expect(screen.getByText("태스크를 불러오는 중 문제가 발생했습니다.")).toBeInTheDocument();
34
+ });
35
+
36
+ it("should render retry button when onRetry provided", () => {
37
+ const onRetry = vi.fn();
38
+ render(<KanbanError onRetry={onRetry} />);
39
+ const button = screen.getByText("다시 시도");
40
+ expect(button).toBeInTheDocument();
41
+ button.click();
42
+ expect(onRetry).toHaveBeenCalledOnce();
43
+ });
44
+
45
+ it("should not render retry button without onRetry", () => {
46
+ render(<KanbanError />);
47
+ expect(screen.queryByText("다시 시도")).not.toBeInTheDocument();
48
+ });
49
+ });
50
+
51
+ describe("ProgressCard", () => {
52
+ it("should show 0% for no tasks", () => {
53
+ render(<ProgressCard tasks={[]} />);
54
+ expect(screen.getByText("0%")).toBeInTheDocument();
55
+ expect(screen.getByText("0/0")).toBeInTheDocument();
56
+ });
57
+
58
+ it("should show correct progress", () => {
59
+ const tasks = [
60
+ makeTask({ id: "1", status: "Done" }),
61
+ makeTask({ id: "2", status: "Todo" }),
62
+ makeTask({ id: "3", status: "Done" }),
63
+ makeTask({ id: "4", status: "InProgress" }),
64
+ ];
65
+ render(<ProgressCard tasks={tasks} />);
66
+ expect(screen.getByText("50%")).toBeInTheDocument();
67
+ expect(screen.getByText("2/4")).toBeInTheDocument();
68
+ });
69
+
70
+ it("should have accessible progressbar", () => {
71
+ const tasks = [makeTask({ id: "1", status: "Done" })];
72
+ render(<ProgressCard tasks={tasks} />);
73
+ const progressbar = screen.getByRole("progressbar");
74
+ expect(progressbar).toHaveAttribute("aria-valuenow", "100");
75
+ });
76
+ });
77
+
78
+ describe("KanbanSkeleton", () => {
79
+ it("should render four column skeletons", () => {
80
+ render(<KanbanSkeleton />);
81
+ expect(screen.getByText("Todo")).toBeInTheDocument();
82
+ expect(screen.getByText("In Progress")).toBeInTheDocument();
83
+ expect(screen.getByText("Blocked")).toBeInTheDocument();
84
+ expect(screen.getByText("Done")).toBeInTheDocument();
85
+ });
86
+ });
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { useQueryClient } from "@tanstack/react-query";
5
+
6
+ const TASKS_KEY = ["tasks"];
7
+ const RECONNECT_DELAY_MS = 3_000;
8
+ const MAX_RECONNECT_DELAY_MS = 30_000;
9
+
10
+ export function useTaskSse() {
11
+ const queryClient = useQueryClient();
12
+ const retryCount = useRef(0);
13
+
14
+ useEffect(() => {
15
+ let es: EventSource | null = null;
16
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
17
+ let mounted = true;
18
+
19
+ function connect() {
20
+ if (!mounted) return;
21
+
22
+ es = new EventSource("/api/sse");
23
+
24
+ es.addEventListener("connected", () => {
25
+ retryCount.current = 0;
26
+ });
27
+
28
+ es.addEventListener("task-change", (e) => {
29
+ try {
30
+ const event = JSON.parse(e.data);
31
+ if (event.type === "created" || event.type === "deleted" || event.type === "index") {
32
+ queryClient.invalidateQueries({ queryKey: TASKS_KEY });
33
+ } else if (event.type === "updated" && event.id) {
34
+ queryClient.invalidateQueries({ queryKey: TASKS_KEY });
35
+ }
36
+ } catch {
37
+ // Malformed event — skip
38
+ }
39
+ });
40
+
41
+ es.onerror = () => {
42
+ es?.close();
43
+ es = null;
44
+ scheduleReconnect();
45
+ };
46
+ }
47
+
48
+ function scheduleReconnect() {
49
+ if (!mounted) return;
50
+ const delay = Math.min(
51
+ RECONNECT_DELAY_MS * 2 ** retryCount.current,
52
+ MAX_RECONNECT_DELAY_MS,
53
+ );
54
+ retryCount.current++;
55
+ reconnectTimer = setTimeout(connect, delay);
56
+ }
57
+
58
+ connect();
59
+
60
+ return () => {
61
+ mounted = false;
62
+ es?.close();
63
+ if (reconnectTimer) clearTimeout(reconnectTimer);
64
+ };
65
+ }, [queryClient]);
66
+ }
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
4
+ import { apiClient } from "@/lib/remote/api-client";
5
+ import type { Task, TaskStatus } from "@/features/taskflow/types";
6
+
7
+ const TASKS_KEY = ["tasks"] as const;
8
+
9
+ async function fetchTasks(): Promise<Task[]> {
10
+ const { data } = await apiClient.get<Task[]>("/api/tasks");
11
+ return data;
12
+ }
13
+
14
+ async function setTaskStatus(id: string, to: TaskStatus): Promise<Task> {
15
+ const { data } = await apiClient.post<Task>(`/api/tasks/${id}/status`, { to });
16
+ return data;
17
+ }
18
+
19
+ export function useTasksQuery() {
20
+ return useQuery({
21
+ queryKey: TASKS_KEY,
22
+ queryFn: fetchTasks,
23
+ });
24
+ }
25
+
26
+ export function useSetStatusMutation() {
27
+ const queryClient = useQueryClient();
28
+
29
+ return useMutation({
30
+ mutationFn: ({ id, to }: { id: string; to: TaskStatus }) => setTaskStatus(id, to),
31
+ onMutate: async ({ id, to }) => {
32
+ await queryClient.cancelQueries({ queryKey: TASKS_KEY });
33
+ const previous = queryClient.getQueryData<Task[]>(TASKS_KEY);
34
+
35
+ queryClient.setQueryData<Task[]>(TASKS_KEY, (old) =>
36
+ old?.map((task) =>
37
+ task.id === id ? { ...task, status: to, updatedAt: new Date().toISOString() } : task,
38
+ ),
39
+ );
40
+
41
+ return { previous };
42
+ },
43
+ onError: (_err, _vars, context) => {
44
+ if (context?.previous) {
45
+ queryClient.setQueryData(TASKS_KEY, context.previous);
46
+ }
47
+ },
48
+ onSettled: () => {
49
+ queryClient.invalidateQueries({ queryKey: TASKS_KEY });
50
+ },
51
+ });
52
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { groupByStatus, computeProgress } from "../kanban-utils";
3
+ import type { Task } from "@/features/taskflow/types";
4
+
5
+ const now = "2026-03-21T00:00:00.000Z";
6
+
7
+ function makeTask(overrides: Partial<Task> = {}): Task {
8
+ return {
9
+ id: "001",
10
+ title: "Test",
11
+ status: "Todo",
12
+ priority: 5,
13
+ dependencies: [],
14
+ createdAt: now,
15
+ updatedAt: now,
16
+ description: "",
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ describe("groupByStatus", () => {
22
+ it("should group tasks into columns", () => {
23
+ const tasks = [
24
+ makeTask({ id: "1", status: "Todo" }),
25
+ makeTask({ id: "2", status: "InProgress" }),
26
+ makeTask({ id: "3", status: "Done" }),
27
+ makeTask({ id: "4", status: "Todo" }),
28
+ makeTask({ id: "5", status: "Blocked" }),
29
+ ];
30
+
31
+ const columns = groupByStatus(tasks);
32
+
33
+ expect(columns.Todo).toHaveLength(2);
34
+ expect(columns.InProgress).toHaveLength(1);
35
+ expect(columns.Blocked).toHaveLength(1);
36
+ expect(columns.Done).toHaveLength(1);
37
+ });
38
+
39
+ it("should return empty arrays for empty input", () => {
40
+ const columns = groupByStatus([]);
41
+ expect(columns.Todo).toHaveLength(0);
42
+ expect(columns.InProgress).toHaveLength(0);
43
+ expect(columns.Blocked).toHaveLength(0);
44
+ expect(columns.Done).toHaveLength(0);
45
+ });
46
+
47
+ it("should handle all tasks in one column", () => {
48
+ const tasks = [
49
+ makeTask({ id: "1", status: "Done" }),
50
+ makeTask({ id: "2", status: "Done" }),
51
+ ];
52
+ const columns = groupByStatus(tasks);
53
+ expect(columns.Done).toHaveLength(2);
54
+ expect(columns.Todo).toHaveLength(0);
55
+ });
56
+ });
57
+
58
+ describe("computeProgress", () => {
59
+ it("should return 0 for empty tasks", () => {
60
+ expect(computeProgress([])).toBe(0);
61
+ });
62
+
63
+ it("should return 100 when all done", () => {
64
+ const tasks = [
65
+ makeTask({ status: "Done" }),
66
+ makeTask({ status: "Done" }),
67
+ ];
68
+ expect(computeProgress(tasks)).toBe(100);
69
+ });
70
+
71
+ it("should return 0 when none done", () => {
72
+ const tasks = [
73
+ makeTask({ status: "Todo" }),
74
+ makeTask({ status: "InProgress" }),
75
+ ];
76
+ expect(computeProgress(tasks)).toBe(0);
77
+ });
78
+
79
+ it("should calculate percentage correctly", () => {
80
+ const tasks = [
81
+ makeTask({ status: "Done" }),
82
+ makeTask({ status: "Todo" }),
83
+ makeTask({ status: "InProgress" }),
84
+ makeTask({ status: "Done" }),
85
+ ];
86
+ expect(computeProgress(tasks)).toBe(50);
87
+ });
88
+
89
+ it("should round to nearest integer", () => {
90
+ const tasks = [
91
+ makeTask({ status: "Done" }),
92
+ makeTask({ status: "Todo" }),
93
+ makeTask({ status: "Todo" }),
94
+ ];
95
+ expect(computeProgress(tasks)).toBe(33);
96
+ });
97
+ });