beads-kanban-ui 0.1.0

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 (154) hide show
  1. package/.designs/beads-kanban-ui-bj0.md +73 -0
  2. package/.designs/beads-kanban-ui-qxq.md +144 -0
  3. package/.designs/epic-support.md +282 -0
  4. package/.env.local.example +2 -0
  5. package/.eslintrc.json +3 -0
  6. package/.gitattributes +3 -0
  7. package/.github/workflows/release.yml +123 -0
  8. package/.history/README_20260121193710.md +227 -0
  9. package/.history/README_20260121193918.md +227 -0
  10. package/.history/README_20260121193921.md +227 -0
  11. package/.history/README_20260121193933.md +227 -0
  12. package/.history/README_20260121193934.md +227 -0
  13. package/.history/README_20260121193944.md +227 -0
  14. package/.history/README_20260121193953.md +227 -0
  15. package/.history/src/app/page_20260121133429.tsx +134 -0
  16. package/.history/src/app/page_20260121133928.tsx +134 -0
  17. package/.history/src/app/page_20260121144850.tsx +138 -0
  18. package/.history/src/app/page_20260121144854.tsx +138 -0
  19. package/.history/src/app/page_20260121144858.tsx +138 -0
  20. package/.history/src/app/page_20260121144902.tsx +138 -0
  21. package/.history/src/app/page_20260121144906.tsx +138 -0
  22. package/.history/src/app/page_20260121144911.tsx +138 -0
  23. package/.history/src/app/page_20260121144928.tsx +138 -0
  24. package/.playwright-mcp/.playwright-mcp/morphing-dialog-wheel-scroll-fix.png +0 -0
  25. package/.playwright-mcp/beams-test.png +0 -0
  26. package/.playwright-mcp/card-verification.png +0 -0
  27. package/.playwright-mcp/design-doc-dialog-fix-verification.png +0 -0
  28. package/.playwright-mcp/dialog-width-test.png +0 -0
  29. package/.playwright-mcp/homepage.png +0 -0
  30. package/.playwright-mcp/morphing-dialog-expanded.png +0 -0
  31. package/.playwright-mcp/morphing-dialog-fixes-final.png +0 -0
  32. package/.playwright-mcp/morphing-dialog-open.png +0 -0
  33. package/.playwright-mcp/page-2026-01-21T14-08-31-529Z.png +0 -0
  34. package/.playwright-mcp/page-2026-01-21T14-09-23-431Z.png +0 -0
  35. package/.playwright-mcp/page-2026-01-21T14-10-28-773Z.png +0 -0
  36. package/.playwright-mcp/page-2026-01-21T14-10-47-432Z.png +0 -0
  37. package/.playwright-mcp/page-2026-01-21T14-11-12-350Z.png +0 -0
  38. package/.playwright-mcp/screenshot-after-click.png +0 -0
  39. package/.playwright-mcp/screenshot-after-dialog-click.png +0 -0
  40. package/.playwright-mcp/sheet-restored-after-dialog-close.png +0 -0
  41. package/.playwright-mcp/test-1-sheet-open-with-overlay.png +0 -0
  42. package/.playwright-mcp/test-2-morphing-dialog-with-overlay.png +0 -0
  43. package/.playwright-mcp/test-3-sheet-open-dark-overlay.png +0 -0
  44. package/.playwright-mcp/test-4-morphing-dialog-with-dark-overlay.png +0 -0
  45. package/.playwright-mcp/test-5-morphing-dialog-scrolled.png +0 -0
  46. package/.playwright-mcp/test-6-sheet-restored-after-dialog-close.png +0 -0
  47. package/.playwright-mcp/wheel-scroll-fixed.png +0 -0
  48. package/README.md +243 -0
  49. package/Screenshots/bead-detail.png +0 -0
  50. package/Screenshots/dashboard.png +0 -0
  51. package/Screenshots/kanban-board.png +0 -0
  52. package/components.json +27 -0
  53. package/logo/logo.svg +1 -0
  54. package/next.config.js +9 -0
  55. package/npm/README.md +37 -0
  56. package/npm/bin/cli.js +107 -0
  57. package/npm/package.json +20 -0
  58. package/npm/scripts/postinstall.js +132 -0
  59. package/package.json +62 -0
  60. package/postcss.config.js +6 -0
  61. package/public/logo.svg +1 -0
  62. package/restart.sh +5 -0
  63. package/server/Cargo.lock +1685 -0
  64. package/server/Cargo.toml +24 -0
  65. package/server/src/db.rs +570 -0
  66. package/server/src/main.rs +141 -0
  67. package/server/src/routes/beads.rs +413 -0
  68. package/server/src/routes/cli.rs +150 -0
  69. package/server/src/routes/fs.rs +360 -0
  70. package/server/src/routes/git.rs +169 -0
  71. package/server/src/routes/mod.rs +107 -0
  72. package/server/src/routes/projects.rs +177 -0
  73. package/server/src/routes/watch.rs +211 -0
  74. package/src/app/globals.css +101 -0
  75. package/src/app/layout.tsx +36 -0
  76. package/src/app/page.tsx +348 -0
  77. package/src/app/project/kanban-board.tsx +356 -0
  78. package/src/app/project/page.tsx +18 -0
  79. package/src/app/settings/page.tsx +224 -0
  80. package/src/components/Beams.css +5 -0
  81. package/src/components/Beams.jsx +307 -0
  82. package/src/components/Galaxy.css +5 -0
  83. package/src/components/Galaxy.jsx +333 -0
  84. package/src/components/activity-timeline.tsx +172 -0
  85. package/src/components/add-project-dialog.tsx +219 -0
  86. package/src/components/bead-card.tsx +196 -0
  87. package/src/components/bead-detail.tsx +306 -0
  88. package/src/components/color-picker.tsx +101 -0
  89. package/src/components/comment-input.tsx +155 -0
  90. package/src/components/comment-list.tsx +147 -0
  91. package/src/components/dependency-badge.tsx +106 -0
  92. package/src/components/design-doc-dialog.tsx +58 -0
  93. package/src/components/design-doc-preview.tsx +97 -0
  94. package/src/components/design-doc-viewer.tsx +199 -0
  95. package/src/components/editable-project-name.tsx +178 -0
  96. package/src/components/epic-card.tsx +263 -0
  97. package/src/components/folder-browser.tsx +273 -0
  98. package/src/components/footer.tsx +27 -0
  99. package/src/components/kanban/default.tsx +184 -0
  100. package/src/components/kanban-column.tsx +167 -0
  101. package/src/components/project-card.tsx +191 -0
  102. package/src/components/quick-filter-bar.tsx +279 -0
  103. package/src/components/scan-directory-dialog.tsx +368 -0
  104. package/src/components/status-donut.tsx +197 -0
  105. package/src/components/subtask-list.tsx +128 -0
  106. package/src/components/tag-picker.tsx +252 -0
  107. package/src/components/ui/.gitkeep +0 -0
  108. package/src/components/ui/alert-dialog.tsx +141 -0
  109. package/src/components/ui/avatar.tsx +67 -0
  110. package/src/components/ui/badge.tsx +230 -0
  111. package/src/components/ui/button.tsx +433 -0
  112. package/src/components/ui/card/index.tsx +24 -0
  113. package/src/components/ui/card/roiui-card.module.css +197 -0
  114. package/src/components/ui/card/roiui-card.tsx +154 -0
  115. package/src/components/ui/card/shadcn-card.tsx +76 -0
  116. package/src/components/ui/chart.tsx +369 -0
  117. package/src/components/ui/dialog.tsx +122 -0
  118. package/src/components/ui/dropdown-menu.tsx +201 -0
  119. package/src/components/ui/input.tsx +22 -0
  120. package/src/components/ui/kanban.tsx +522 -0
  121. package/src/components/ui/morphing-dialog.tsx +457 -0
  122. package/src/components/ui/popover.tsx +33 -0
  123. package/src/components/ui/progress.tsx +28 -0
  124. package/src/components/ui/scroll-area.tsx +48 -0
  125. package/src/components/ui/select.tsx +159 -0
  126. package/src/components/ui/separator.tsx +31 -0
  127. package/src/components/ui/sheet.tsx +142 -0
  128. package/src/components/ui/skeleton.tsx +15 -0
  129. package/src/components/ui/toast.tsx +129 -0
  130. package/src/components/ui/toaster.tsx +35 -0
  131. package/src/components/ui/tooltip.tsx +30 -0
  132. package/src/hooks/.gitkeep +0 -0
  133. package/src/hooks/use-bead-filters.ts +261 -0
  134. package/src/hooks/use-beads.ts +162 -0
  135. package/src/hooks/use-branch-statuses.ts +161 -0
  136. package/src/hooks/use-epics.ts +173 -0
  137. package/src/hooks/use-file-watcher.ts +111 -0
  138. package/src/hooks/use-keyboard-navigation.ts +282 -0
  139. package/src/hooks/use-project.ts +61 -0
  140. package/src/hooks/use-projects.ts +93 -0
  141. package/src/hooks/use-toast.ts +194 -0
  142. package/src/hooks/useClickOutside.tsx +26 -0
  143. package/src/lib/.gitkeep +0 -0
  144. package/src/lib/api.ts +186 -0
  145. package/src/lib/beads-parser.ts +252 -0
  146. package/src/lib/cli.ts +193 -0
  147. package/src/lib/db.ts +145 -0
  148. package/src/lib/design-doc.ts +74 -0
  149. package/src/lib/epic-parser.ts +242 -0
  150. package/src/lib/git.ts +102 -0
  151. package/src/lib/utils.ts +12 -0
  152. package/src/types/index.ts +107 -0
  153. package/tailwind.config.ts +85 -0
  154. package/tsconfig.json +26 -0
@@ -0,0 +1,368 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback } from "react";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from "@/components/ui/dialog";
12
+ import {
13
+ AlertDialog,
14
+ AlertDialogAction,
15
+ AlertDialogCancel,
16
+ AlertDialogContent,
17
+ AlertDialogDescription,
18
+ AlertDialogFooter,
19
+ AlertDialogHeader,
20
+ AlertDialogTitle,
21
+ } from "@/components/ui/alert-dialog";
22
+ import { Button } from "@/components/ui/button";
23
+ import { Badge } from "@/components/ui/badge";
24
+ import { ScrollArea } from "@/components/ui/scroll-area";
25
+ import { Input } from "@/components/ui/input";
26
+ import { useToast } from "@/hooks/use-toast";
27
+ import * as api from "@/lib/api";
28
+ import { cn } from "@/lib/utils";
29
+ import { Folder, Check, Loader2 } from "lucide-react";
30
+
31
+ interface ScannedProject {
32
+ name: string;
33
+ path: string;
34
+ selected: boolean;
35
+ }
36
+
37
+ interface ScanDirectoryDialogProps {
38
+ open: boolean;
39
+ onOpenChange: (open: boolean) => void;
40
+ onAddProjects: (projects: { name: string; path: string }[]) => Promise<void>;
41
+ }
42
+
43
+ type Step = "select" | "results" | "confirm";
44
+
45
+ export function ScanDirectoryDialog({
46
+ open: isOpen,
47
+ onOpenChange,
48
+ onAddProjects,
49
+ }: ScanDirectoryDialogProps) {
50
+ const [step, setStep] = useState<Step>("select");
51
+ const [selectedDirectory, setSelectedDirectory] = useState<string>("");
52
+ const [isScanning, setIsScanning] = useState(false);
53
+ const [scannedProjects, setScannedProjects] = useState<ScannedProject[]>([]);
54
+ const [isAdding, setIsAdding] = useState(false);
55
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false);
56
+ const { toast } = useToast();
57
+
58
+ const resetState = useCallback(() => {
59
+ setStep("select");
60
+ setSelectedDirectory("");
61
+ setScannedProjects([]);
62
+ setIsScanning(false);
63
+ setIsAdding(false);
64
+ setShowConfirmDialog(false);
65
+ }, []);
66
+
67
+ const handleOpenChange = useCallback(
68
+ (open: boolean) => {
69
+ if (!open) {
70
+ resetState();
71
+ }
72
+ onOpenChange(open);
73
+ },
74
+ [onOpenChange, resetState]
75
+ );
76
+
77
+ const scanDirectory = useCallback(async () => {
78
+ if (!selectedDirectory) return;
79
+
80
+ setIsScanning(true);
81
+ try {
82
+ const { entries } = await api.fs.list(selectedDirectory);
83
+ const dirs = entries.filter((e) => e.isDirectory);
84
+
85
+ // Check each subdirectory for .beads folder in parallel
86
+ const projectResults = await Promise.all(
87
+ dirs.map(async (dir) => {
88
+ const result = await api.fs.exists(`${dir.path}/.beads`);
89
+ return {
90
+ name: dir.name,
91
+ path: dir.path,
92
+ hasBeads: result.exists,
93
+ };
94
+ })
95
+ );
96
+
97
+ // Filter to only directories with .beads
98
+ const projectsFound = projectResults
99
+ .filter((p) => p.hasBeads)
100
+ .map((p) => ({
101
+ name: p.name,
102
+ path: p.path,
103
+ selected: true, // All selected by default
104
+ }));
105
+
106
+ setScannedProjects(projectsFound);
107
+ setStep("results");
108
+
109
+ if (projectsFound.length === 0) {
110
+ toast({
111
+ title: "No projects found",
112
+ description: "No beads projects found in subdirectories.",
113
+ variant: "destructive",
114
+ });
115
+ }
116
+ } catch (err) {
117
+ console.error("Error scanning directory:", err);
118
+ toast({
119
+ title: "Scan failed",
120
+ description:
121
+ err instanceof Error ? err.message : "Failed to scan directory.",
122
+ variant: "destructive",
123
+ });
124
+ } finally {
125
+ setIsScanning(false);
126
+ }
127
+ }, [selectedDirectory, toast]);
128
+
129
+ const toggleProject = useCallback((path: string) => {
130
+ setScannedProjects((prev) =>
131
+ prev.map((p) => (p.path === path ? { ...p, selected: !p.selected } : p))
132
+ );
133
+ }, []);
134
+
135
+ const toggleAll = useCallback(() => {
136
+ const allSelected = scannedProjects.every((p) => p.selected);
137
+ setScannedProjects((prev) =>
138
+ prev.map((p) => ({ ...p, selected: !allSelected }))
139
+ );
140
+ }, [scannedProjects]);
141
+
142
+ const selectedCount = scannedProjects.filter((p) => p.selected).length;
143
+
144
+ const handleAddProjects = useCallback(async () => {
145
+ const projectsToAdd = scannedProjects
146
+ .filter((p) => p.selected)
147
+ .map(({ name, path }) => ({ name, path }));
148
+
149
+ if (projectsToAdd.length === 0) return;
150
+
151
+ setIsAdding(true);
152
+ setShowConfirmDialog(false);
153
+
154
+ try {
155
+ await onAddProjects(projectsToAdd);
156
+ toast({
157
+ title: "Projects added",
158
+ description: `Successfully added ${projectsToAdd.length} project${projectsToAdd.length > 1 ? "s" : ""}.`,
159
+ });
160
+ handleOpenChange(false);
161
+ } catch (err) {
162
+ console.error("Error adding projects:", err);
163
+ toast({
164
+ title: "Error",
165
+ description: "Failed to add some projects. Please try again.",
166
+ variant: "destructive",
167
+ });
168
+ } finally {
169
+ setIsAdding(false);
170
+ }
171
+ }, [scannedProjects, onAddProjects, toast, handleOpenChange]);
172
+
173
+ return (
174
+ <>
175
+ <Dialog open={isOpen} onOpenChange={handleOpenChange}>
176
+ <DialogContent className="sm:max-w-lg">
177
+ <DialogHeader>
178
+ <DialogTitle>Scan for Projects</DialogTitle>
179
+ <DialogDescription>
180
+ {step === "select"
181
+ ? "Select a parent directory to scan for beads projects."
182
+ : `Found ${scannedProjects.length} project${scannedProjects.length !== 1 ? "s" : ""} in subdirectories.`}
183
+ </DialogDescription>
184
+ </DialogHeader>
185
+
186
+ {step === "select" && (
187
+ <div className="flex flex-col gap-4 py-4">
188
+ <div className="space-y-2">
189
+ <label htmlFor="scan-path" className="text-sm font-medium text-zinc-300">
190
+ Parent Directory
191
+ </label>
192
+ <div className="relative">
193
+ <Folder className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" aria-hidden="true" />
194
+ <Input
195
+ id="scan-path"
196
+ value={selectedDirectory}
197
+ onChange={(e) => setSelectedDirectory(e.target.value)}
198
+ placeholder="/path/to/parent/directory"
199
+ className="pl-10"
200
+ autoFocus
201
+ />
202
+ </div>
203
+ <p className="text-xs text-zinc-500">
204
+ Enter a directory path to scan its subdirectories for beads projects.
205
+ </p>
206
+ </div>
207
+
208
+ <DialogFooter>
209
+ <Button
210
+ onClick={scanDirectory}
211
+ disabled={!selectedDirectory.trim() || isScanning}
212
+ >
213
+ {isScanning ? (
214
+ <>
215
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
216
+ Scanning...
217
+ </>
218
+ ) : (
219
+ "Scan for Projects"
220
+ )}
221
+ </Button>
222
+ </DialogFooter>
223
+ </div>
224
+ )}
225
+
226
+ {step === "results" && (
227
+ <div className="flex flex-col gap-4 py-4">
228
+ {scannedProjects.length === 0 ? (
229
+ <div className="rounded-md border border-zinc-700 bg-zinc-800/50 px-4 py-8 text-center">
230
+ <p className="text-zinc-400">
231
+ No beads projects found in subdirectories.
232
+ </p>
233
+ <p className="mt-1 text-sm text-zinc-500">
234
+ Make sure subdirectories have a .beads folder.
235
+ </p>
236
+ </div>
237
+ ) : (
238
+ <>
239
+ <div className="flex items-center justify-between">
240
+ <Badge variant="info" size="sm">
241
+ {selectedCount} of {scannedProjects.length} selected
242
+ </Badge>
243
+ <Button
244
+ variant="ghost"
245
+ size="sm"
246
+ onClick={toggleAll}
247
+ className="text-xs"
248
+ >
249
+ {scannedProjects.every((p) => p.selected)
250
+ ? "Deselect All"
251
+ : "Select All"}
252
+ </Button>
253
+ </div>
254
+
255
+ <ScrollArea className="h-[250px] rounded-md border border-zinc-700 bg-zinc-800/50">
256
+ <div className="p-2" role="listbox" aria-label="Found projects">
257
+ {scannedProjects.map((project) => (
258
+ <button
259
+ key={project.path}
260
+ type="button"
261
+ role="option"
262
+ aria-selected={project.selected}
263
+ onClick={() => toggleProject(project.path)}
264
+ className={cn(
265
+ "flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors",
266
+ project.selected
267
+ ? "bg-zinc-700 text-zinc-100"
268
+ : "text-zinc-400 hover:bg-zinc-700/50"
269
+ )}
270
+ >
271
+ <div
272
+ className={cn(
273
+ "flex size-5 shrink-0 items-center justify-center rounded border",
274
+ project.selected
275
+ ? "border-purple-500 bg-purple-500"
276
+ : "border-zinc-600 bg-transparent"
277
+ )}
278
+ >
279
+ {project.selected && (
280
+ <Check className="size-3 text-white" />
281
+ )}
282
+ </div>
283
+ <Folder
284
+ className={cn(
285
+ "size-4 shrink-0",
286
+ project.selected
287
+ ? "text-purple-400"
288
+ : "text-zinc-500"
289
+ )}
290
+ />
291
+ <div className="min-w-0 flex-1">
292
+ <p className="truncate font-medium">
293
+ {project.name}
294
+ </p>
295
+ <p className="truncate text-xs text-zinc-500">
296
+ {project.path}
297
+ </p>
298
+ </div>
299
+ </button>
300
+ ))}
301
+ </div>
302
+ </ScrollArea>
303
+ </>
304
+ )}
305
+
306
+ <DialogFooter className="gap-2 sm:gap-2">
307
+ <Button variant="outline" onClick={() => setStep("select")}>
308
+ Back
309
+ </Button>
310
+ <Button
311
+ onClick={() => setShowConfirmDialog(true)}
312
+ disabled={selectedCount === 0}
313
+ >
314
+ Add {selectedCount} Project{selectedCount !== 1 ? "s" : ""}
315
+ </Button>
316
+ </DialogFooter>
317
+ </div>
318
+ )}
319
+ </DialogContent>
320
+ </Dialog>
321
+
322
+ <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
323
+ <AlertDialogContent>
324
+ <AlertDialogHeader>
325
+ <AlertDialogTitle>
326
+ Add {selectedCount} Project{selectedCount !== 1 ? "s" : ""}?
327
+ </AlertDialogTitle>
328
+ <AlertDialogDescription asChild>
329
+ <div>
330
+ <p className="mb-3">
331
+ The following projects will be added to your dashboard:
332
+ </p>
333
+ <ScrollArea className="max-h-[150px] rounded-md border border-zinc-700 bg-zinc-800/50">
334
+ <ul className="p-2 text-sm text-zinc-300">
335
+ {scannedProjects
336
+ .filter((p) => p.selected)
337
+ .map((project) => (
338
+ <li
339
+ key={project.path}
340
+ className="flex items-center gap-2 py-1"
341
+ >
342
+ <Folder className="size-3 shrink-0 text-purple-400" />
343
+ <span className="truncate">{project.name}</span>
344
+ </li>
345
+ ))}
346
+ </ul>
347
+ </ScrollArea>
348
+ </div>
349
+ </AlertDialogDescription>
350
+ </AlertDialogHeader>
351
+ <AlertDialogFooter>
352
+ <AlertDialogCancel disabled={isAdding}>Cancel</AlertDialogCancel>
353
+ <AlertDialogAction onClick={handleAddProjects} disabled={isAdding}>
354
+ {isAdding ? (
355
+ <>
356
+ <Loader2 className="size-4 animate-spin" />
357
+ Adding...
358
+ </>
359
+ ) : (
360
+ "Add All"
361
+ )}
362
+ </AlertDialogAction>
363
+ </AlertDialogFooter>
364
+ </AlertDialogContent>
365
+ </AlertDialog>
366
+ </>
367
+ );
368
+ }
@@ -0,0 +1,197 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+
5
+ interface BeadCounts {
6
+ open: number;
7
+ in_progress: number;
8
+ inreview: number;
9
+ closed: number;
10
+ }
11
+
12
+ interface StatusDonutProps {
13
+ beadCounts: BeadCounts;
14
+ size?: number;
15
+ className?: string;
16
+ }
17
+
18
+ // Status colors matching the kanban board
19
+ const STATUS_COLORS = {
20
+ open: "#3b82f6", // blue-500
21
+ in_progress: "#f59e0b", // amber-500
22
+ inreview: "#a855f7", // purple-500
23
+ closed: "#22c55e", // green-500
24
+ };
25
+
26
+ // Custom tooltip showing all statuses
27
+ function StatusTooltip({ beadCounts, total }: { beadCounts: BeadCounts; total: number }) {
28
+ return (
29
+ <div className="rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 shadow-lg">
30
+ <div className="mb-1.5 text-xs font-medium text-zinc-300">
31
+ {total} task{total !== 1 ? "s" : ""}
32
+ </div>
33
+ <div className="space-y-1">
34
+ {beadCounts.open > 0 && (
35
+ <div className="flex items-center gap-2 text-xs">
36
+ <div className="h-2 w-2 rounded-sm" style={{ backgroundColor: STATUS_COLORS.open }} />
37
+ <span className="text-zinc-400">Open</span>
38
+ <span className="ml-auto font-mono text-zinc-100">{beadCounts.open}</span>
39
+ </div>
40
+ )}
41
+ {beadCounts.in_progress > 0 && (
42
+ <div className="flex items-center gap-2 text-xs">
43
+ <div className="h-2 w-2 rounded-sm" style={{ backgroundColor: STATUS_COLORS.in_progress }} />
44
+ <span className="text-zinc-400">In Progress</span>
45
+ <span className="ml-auto font-mono text-zinc-100">{beadCounts.in_progress}</span>
46
+ </div>
47
+ )}
48
+ {beadCounts.inreview > 0 && (
49
+ <div className="flex items-center gap-2 text-xs">
50
+ <div className="h-2 w-2 rounded-sm" style={{ backgroundColor: STATUS_COLORS.inreview }} />
51
+ <span className="text-zinc-400">In Review</span>
52
+ <span className="ml-auto font-mono text-zinc-100">{beadCounts.inreview}</span>
53
+ </div>
54
+ )}
55
+ {beadCounts.closed > 0 && (
56
+ <div className="flex items-center gap-2 text-xs">
57
+ <div className="h-2 w-2 rounded-sm" style={{ backgroundColor: STATUS_COLORS.closed }} />
58
+ <span className="text-zinc-400">Closed</span>
59
+ <span className="ml-auto font-mono text-zinc-100">{beadCounts.closed}</span>
60
+ </div>
61
+ )}
62
+ </div>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ export function StatusDonut({ beadCounts, size = 40, className }: StatusDonutProps) {
68
+ const [isHovered, setIsHovered] = useState(false);
69
+
70
+ const chartData = useMemo(() => {
71
+ return [
72
+ { status: "open", count: beadCounts.open, fill: STATUS_COLORS.open },
73
+ { status: "in_progress", count: beadCounts.in_progress, fill: STATUS_COLORS.in_progress },
74
+ { status: "inreview", count: beadCounts.inreview, fill: STATUS_COLORS.inreview },
75
+ { status: "closed", count: beadCounts.closed, fill: STATUS_COLORS.closed },
76
+ ].filter((item) => item.count > 0);
77
+ }, [beadCounts]);
78
+
79
+ const total = useMemo(() => {
80
+ return beadCounts.open + beadCounts.in_progress + beadCounts.inreview + beadCounts.closed;
81
+ }, [beadCounts]);
82
+
83
+ // If no tasks, show empty state
84
+ if (total === 0) {
85
+ return (
86
+ <div
87
+ className={className}
88
+ style={{ width: size, height: size }}
89
+ aria-label="No tasks"
90
+ >
91
+ <div
92
+ className="rounded-full border-2 border-dashed border-zinc-700 w-full h-full"
93
+ title="No tasks"
94
+ />
95
+ </div>
96
+ );
97
+ }
98
+
99
+ const innerRadius = size * 0.32;
100
+ const outerRadius = size * 0.48;
101
+ // Only add padding when there are multiple segments
102
+ const paddingAngle = chartData.length > 1 ? 3 : 0;
103
+
104
+ return (
105
+ <div
106
+ className={`relative ${className || ""}`}
107
+ style={{ width: size, height: size }}
108
+ onMouseEnter={() => setIsHovered(true)}
109
+ onMouseLeave={() => setIsHovered(false)}
110
+ >
111
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
112
+ <g transform={`translate(${size / 2}, ${size / 2})`}>
113
+ {/* Render pie segments */}
114
+ {chartData.map((entry, index) => {
115
+ // Calculate angles for each segment
116
+ const startAngle = chartData
117
+ .slice(0, index)
118
+ .reduce((acc, d) => acc + (d.count / total) * 360, 0);
119
+ const segmentAngle = (entry.count / total) * 360;
120
+ const endAngle = startAngle + segmentAngle;
121
+
122
+ // Check if this is a full circle (single segment covering 100%)
123
+ const isFullCircle = chartData.length === 1;
124
+
125
+ if (isFullCircle) {
126
+ // For full circle, use two semicircular arcs
127
+ // This avoids the issue where start and end points are the same
128
+ return (
129
+ <g key={entry.status}>
130
+ {/* First semicircle (top half) */}
131
+ <path
132
+ d={`M 0 ${-outerRadius} A ${outerRadius} ${outerRadius} 0 0 1 0 ${outerRadius} L 0 ${innerRadius} A ${innerRadius} ${innerRadius} 0 0 0 0 ${-innerRadius} Z`}
133
+ fill={entry.fill}
134
+ />
135
+ {/* Second semicircle (bottom half) */}
136
+ <path
137
+ d={`M 0 ${outerRadius} A ${outerRadius} ${outerRadius} 0 0 1 0 ${-outerRadius} L 0 ${-innerRadius} A ${innerRadius} ${innerRadius} 0 0 0 0 ${innerRadius} Z`}
138
+ fill={entry.fill}
139
+ />
140
+ </g>
141
+ );
142
+ }
143
+
144
+ // Add small padding between segments (only if multiple segments)
145
+ const adjustedStart = startAngle + paddingAngle / 2;
146
+ const adjustedEnd = endAngle - paddingAngle / 2;
147
+
148
+ // Convert to radians (SVG uses radians, start from top)
149
+ const startRad = ((adjustedStart - 90) * Math.PI) / 180;
150
+ const endRad = ((adjustedEnd - 90) * Math.PI) / 180;
151
+
152
+ // Calculate arc path
153
+ const x1 = Math.cos(startRad) * outerRadius;
154
+ const y1 = Math.sin(startRad) * outerRadius;
155
+ const x2 = Math.cos(endRad) * outerRadius;
156
+ const y2 = Math.sin(endRad) * outerRadius;
157
+ const x3 = Math.cos(endRad) * innerRadius;
158
+ const y3 = Math.sin(endRad) * innerRadius;
159
+ const x4 = Math.cos(startRad) * innerRadius;
160
+ const y4 = Math.sin(startRad) * innerRadius;
161
+
162
+ const largeArcFlag = adjustedEnd - adjustedStart > 180 ? 1 : 0;
163
+
164
+ const d = [
165
+ `M ${x1} ${y1}`,
166
+ `A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${x2} ${y2}`,
167
+ `L ${x3} ${y3}`,
168
+ `A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${x4} ${y4}`,
169
+ "Z",
170
+ ].join(" ");
171
+
172
+ return (
173
+ <path
174
+ key={entry.status}
175
+ d={d}
176
+ fill={entry.fill}
177
+ />
178
+ );
179
+ })}
180
+ {/* Invisible circle covering entire donut area for hover detection */}
181
+ <circle
182
+ r={outerRadius}
183
+ fill="transparent"
184
+ style={{ cursor: "default" }}
185
+ />
186
+ </g>
187
+ </svg>
188
+
189
+ {/* Tooltip - shown on hover anywhere in the donut area */}
190
+ {isHovered && (
191
+ <div className="absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 whitespace-nowrap">
192
+ <StatusTooltip beadCounts={beadCounts} total={total} />
193
+ </div>
194
+ )}
195
+ </div>
196
+ );
197
+ }
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import { cn } from "@/lib/utils";
4
+ import type { Bead, BeadStatus } from "@/types";
5
+ import { Check, Circle, Clock, FileCheck } from "lucide-react";
6
+
7
+ export interface SubtaskListProps {
8
+ /** Child tasks to display */
9
+ childTasks: Bead[];
10
+ /** Callback when clicking a child task */
11
+ onChildClick: (child: Bead) => void;
12
+ /** Maximum number of children to show when collapsed */
13
+ maxCollapsed?: number;
14
+ /** Whether the list is expanded */
15
+ isExpanded?: boolean;
16
+ }
17
+
18
+ /**
19
+ * Get status icon based on bead status
20
+ */
21
+ function getStatusIcon(status: BeadStatus) {
22
+ switch (status) {
23
+ case 'closed':
24
+ return <Check className="h-3.5 w-3.5 text-green-400" aria-hidden="true" />;
25
+ case 'in_progress':
26
+ return <Clock className="h-3.5 w-3.5 text-blue-400" aria-hidden="true" />;
27
+ case 'inreview':
28
+ return <FileCheck className="h-3.5 w-3.5 text-purple-400" aria-hidden="true" />;
29
+ case 'open':
30
+ default:
31
+ return <Circle className="h-3.5 w-3.5 text-zinc-500" aria-hidden="true" />;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Get status text color
37
+ */
38
+ function getStatusColor(status: BeadStatus): string {
39
+ switch (status) {
40
+ case 'closed':
41
+ return "text-green-400";
42
+ case 'in_progress':
43
+ return "text-blue-400";
44
+ case 'inreview':
45
+ return "text-purple-400";
46
+ case 'open':
47
+ default:
48
+ return "text-zinc-500";
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Truncate text to max length
54
+ */
55
+ function truncate(text: string, maxLength: number): string {
56
+ if (text.length <= maxLength) return text;
57
+ return text.slice(0, maxLength).trim() + "…";
58
+ }
59
+
60
+ /**
61
+ * Compact list of child tasks within epic card
62
+ */
63
+ export function SubtaskList({
64
+ childTasks,
65
+ onChildClick,
66
+ maxCollapsed = 3,
67
+ isExpanded = false
68
+ }: SubtaskListProps) {
69
+ if (childTasks.length === 0) {
70
+ return (
71
+ <div className="text-xs text-muted-foreground italic">
72
+ No child tasks
73
+ </div>
74
+ );
75
+ }
76
+
77
+ const displayChildren = isExpanded ? childTasks : childTasks.slice(0, maxCollapsed);
78
+ const hasMore = childTasks.length > maxCollapsed && !isExpanded;
79
+
80
+ return (
81
+ <div className="space-y-1">
82
+ {displayChildren.map((child) => (
83
+ <button
84
+ key={child.id}
85
+ onClick={(e) => {
86
+ e.stopPropagation();
87
+ onChildClick(child);
88
+ }}
89
+ aria-label={`Open task: ${child.title}`}
90
+ className={cn(
91
+ "w-full flex items-start gap-2 px-2 py-1.5 rounded-md",
92
+ "hover:bg-zinc-800 transition-colors text-left",
93
+ "group"
94
+ )}
95
+ >
96
+ <div className="flex-shrink-0 mt-0.5">
97
+ {getStatusIcon(child.status)}
98
+ </div>
99
+ <div className="flex-1 min-w-0">
100
+ <p className={cn(
101
+ "text-xs font-medium group-hover:underline",
102
+ child.status === 'closed' && "line-through text-zinc-500",
103
+ child.status !== 'closed' && "text-zinc-200"
104
+ )}>
105
+ {truncate(child.title, 50)}
106
+ </p>
107
+ {child.description && (
108
+ <p className="text-[10px] text-zinc-500 mt-0.5">
109
+ {truncate(child.description, 60)}
110
+ </p>
111
+ )}
112
+ </div>
113
+ <div className={cn(
114
+ "flex-shrink-0 text-[9px] font-medium uppercase tracking-wide",
115
+ getStatusColor(child.status)
116
+ )}>
117
+ {child.status.replace('_', ' ')}
118
+ </div>
119
+ </button>
120
+ ))}
121
+ {hasMore && (
122
+ <p className="text-[10px] text-muted-foreground text-center py-1">
123
+ +{childTasks.length - maxCollapsed} more
124
+ </p>
125
+ )}
126
+ </div>
127
+ );
128
+ }