beads-kanban-ui 0.1.0 → 0.1.2

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/README.md +16 -222
  2. package/package.json +18 -55
  3. package/.designs/beads-kanban-ui-bj0.md +0 -73
  4. package/.designs/beads-kanban-ui-qxq.md +0 -144
  5. package/.designs/epic-support.md +0 -282
  6. package/.env.local.example +0 -2
  7. package/.eslintrc.json +0 -3
  8. package/.gitattributes +0 -3
  9. package/.github/workflows/release.yml +0 -123
  10. package/.history/README_20260121193710.md +0 -227
  11. package/.history/README_20260121193918.md +0 -227
  12. package/.history/README_20260121193921.md +0 -227
  13. package/.history/README_20260121193933.md +0 -227
  14. package/.history/README_20260121193934.md +0 -227
  15. package/.history/README_20260121193944.md +0 -227
  16. package/.history/README_20260121193953.md +0 -227
  17. package/.history/src/app/page_20260121133429.tsx +0 -134
  18. package/.history/src/app/page_20260121133928.tsx +0 -134
  19. package/.history/src/app/page_20260121144850.tsx +0 -138
  20. package/.history/src/app/page_20260121144854.tsx +0 -138
  21. package/.history/src/app/page_20260121144858.tsx +0 -138
  22. package/.history/src/app/page_20260121144902.tsx +0 -138
  23. package/.history/src/app/page_20260121144906.tsx +0 -138
  24. package/.history/src/app/page_20260121144911.tsx +0 -138
  25. package/.history/src/app/page_20260121144928.tsx +0 -138
  26. package/.playwright-mcp/.playwright-mcp/morphing-dialog-wheel-scroll-fix.png +0 -0
  27. package/.playwright-mcp/beams-test.png +0 -0
  28. package/.playwright-mcp/card-verification.png +0 -0
  29. package/.playwright-mcp/design-doc-dialog-fix-verification.png +0 -0
  30. package/.playwright-mcp/dialog-width-test.png +0 -0
  31. package/.playwright-mcp/homepage.png +0 -0
  32. package/.playwright-mcp/morphing-dialog-expanded.png +0 -0
  33. package/.playwright-mcp/morphing-dialog-fixes-final.png +0 -0
  34. package/.playwright-mcp/morphing-dialog-open.png +0 -0
  35. package/.playwright-mcp/page-2026-01-21T14-08-31-529Z.png +0 -0
  36. package/.playwright-mcp/page-2026-01-21T14-09-23-431Z.png +0 -0
  37. package/.playwright-mcp/page-2026-01-21T14-10-28-773Z.png +0 -0
  38. package/.playwright-mcp/page-2026-01-21T14-10-47-432Z.png +0 -0
  39. package/.playwright-mcp/page-2026-01-21T14-11-12-350Z.png +0 -0
  40. package/.playwright-mcp/screenshot-after-click.png +0 -0
  41. package/.playwright-mcp/screenshot-after-dialog-click.png +0 -0
  42. package/.playwright-mcp/sheet-restored-after-dialog-close.png +0 -0
  43. package/.playwright-mcp/test-1-sheet-open-with-overlay.png +0 -0
  44. package/.playwright-mcp/test-2-morphing-dialog-with-overlay.png +0 -0
  45. package/.playwright-mcp/test-3-sheet-open-dark-overlay.png +0 -0
  46. package/.playwright-mcp/test-4-morphing-dialog-with-dark-overlay.png +0 -0
  47. package/.playwright-mcp/test-5-morphing-dialog-scrolled.png +0 -0
  48. package/.playwright-mcp/test-6-sheet-restored-after-dialog-close.png +0 -0
  49. package/.playwright-mcp/wheel-scroll-fixed.png +0 -0
  50. package/Screenshots/bead-detail.png +0 -0
  51. package/Screenshots/dashboard.png +0 -0
  52. package/Screenshots/kanban-board.png +0 -0
  53. package/components.json +0 -27
  54. package/logo/logo.svg +0 -1
  55. package/next.config.js +0 -9
  56. package/npm/README.md +0 -37
  57. package/npm/package.json +0 -20
  58. package/postcss.config.js +0 -6
  59. package/public/logo.svg +0 -1
  60. package/restart.sh +0 -5
  61. package/server/Cargo.lock +0 -1685
  62. package/server/Cargo.toml +0 -24
  63. package/server/src/db.rs +0 -570
  64. package/server/src/main.rs +0 -141
  65. package/server/src/routes/beads.rs +0 -413
  66. package/server/src/routes/cli.rs +0 -150
  67. package/server/src/routes/fs.rs +0 -360
  68. package/server/src/routes/git.rs +0 -169
  69. package/server/src/routes/mod.rs +0 -107
  70. package/server/src/routes/projects.rs +0 -177
  71. package/server/src/routes/watch.rs +0 -211
  72. package/src/app/globals.css +0 -101
  73. package/src/app/layout.tsx +0 -36
  74. package/src/app/page.tsx +0 -348
  75. package/src/app/project/kanban-board.tsx +0 -356
  76. package/src/app/project/page.tsx +0 -18
  77. package/src/app/settings/page.tsx +0 -224
  78. package/src/components/Beams.css +0 -5
  79. package/src/components/Beams.jsx +0 -307
  80. package/src/components/Galaxy.css +0 -5
  81. package/src/components/Galaxy.jsx +0 -333
  82. package/src/components/activity-timeline.tsx +0 -172
  83. package/src/components/add-project-dialog.tsx +0 -219
  84. package/src/components/bead-card.tsx +0 -196
  85. package/src/components/bead-detail.tsx +0 -306
  86. package/src/components/color-picker.tsx +0 -101
  87. package/src/components/comment-input.tsx +0 -155
  88. package/src/components/comment-list.tsx +0 -147
  89. package/src/components/dependency-badge.tsx +0 -106
  90. package/src/components/design-doc-dialog.tsx +0 -58
  91. package/src/components/design-doc-preview.tsx +0 -97
  92. package/src/components/design-doc-viewer.tsx +0 -199
  93. package/src/components/editable-project-name.tsx +0 -178
  94. package/src/components/epic-card.tsx +0 -263
  95. package/src/components/folder-browser.tsx +0 -273
  96. package/src/components/footer.tsx +0 -27
  97. package/src/components/kanban/default.tsx +0 -184
  98. package/src/components/kanban-column.tsx +0 -167
  99. package/src/components/project-card.tsx +0 -191
  100. package/src/components/quick-filter-bar.tsx +0 -279
  101. package/src/components/scan-directory-dialog.tsx +0 -368
  102. package/src/components/status-donut.tsx +0 -197
  103. package/src/components/subtask-list.tsx +0 -128
  104. package/src/components/tag-picker.tsx +0 -252
  105. package/src/components/ui/.gitkeep +0 -0
  106. package/src/components/ui/alert-dialog.tsx +0 -141
  107. package/src/components/ui/avatar.tsx +0 -67
  108. package/src/components/ui/badge.tsx +0 -230
  109. package/src/components/ui/button.tsx +0 -433
  110. package/src/components/ui/card/index.tsx +0 -24
  111. package/src/components/ui/card/roiui-card.module.css +0 -197
  112. package/src/components/ui/card/roiui-card.tsx +0 -154
  113. package/src/components/ui/card/shadcn-card.tsx +0 -76
  114. package/src/components/ui/chart.tsx +0 -369
  115. package/src/components/ui/dialog.tsx +0 -122
  116. package/src/components/ui/dropdown-menu.tsx +0 -201
  117. package/src/components/ui/input.tsx +0 -22
  118. package/src/components/ui/kanban.tsx +0 -522
  119. package/src/components/ui/morphing-dialog.tsx +0 -457
  120. package/src/components/ui/popover.tsx +0 -33
  121. package/src/components/ui/progress.tsx +0 -28
  122. package/src/components/ui/scroll-area.tsx +0 -48
  123. package/src/components/ui/select.tsx +0 -159
  124. package/src/components/ui/separator.tsx +0 -31
  125. package/src/components/ui/sheet.tsx +0 -142
  126. package/src/components/ui/skeleton.tsx +0 -15
  127. package/src/components/ui/toast.tsx +0 -129
  128. package/src/components/ui/toaster.tsx +0 -35
  129. package/src/components/ui/tooltip.tsx +0 -30
  130. package/src/hooks/.gitkeep +0 -0
  131. package/src/hooks/use-bead-filters.ts +0 -261
  132. package/src/hooks/use-beads.ts +0 -162
  133. package/src/hooks/use-branch-statuses.ts +0 -161
  134. package/src/hooks/use-epics.ts +0 -173
  135. package/src/hooks/use-file-watcher.ts +0 -111
  136. package/src/hooks/use-keyboard-navigation.ts +0 -282
  137. package/src/hooks/use-project.ts +0 -61
  138. package/src/hooks/use-projects.ts +0 -93
  139. package/src/hooks/use-toast.ts +0 -194
  140. package/src/hooks/useClickOutside.tsx +0 -26
  141. package/src/lib/.gitkeep +0 -0
  142. package/src/lib/api.ts +0 -186
  143. package/src/lib/beads-parser.ts +0 -252
  144. package/src/lib/cli.ts +0 -193
  145. package/src/lib/db.ts +0 -145
  146. package/src/lib/design-doc.ts +0 -74
  147. package/src/lib/epic-parser.ts +0 -242
  148. package/src/lib/git.ts +0 -102
  149. package/src/lib/utils.ts +0 -12
  150. package/src/types/index.ts +0 -107
  151. package/tailwind.config.ts +0 -85
  152. package/tsconfig.json +0 -26
  153. /package/{npm/bin → bin}/cli.js +0 -0
  154. /package/{npm/scripts → scripts}/postinstall.js +0 -0
@@ -1,368 +0,0 @@
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
- }
@@ -1,197 +0,0 @@
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
- }
@@ -1,128 +0,0 @@
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
- }