diff --git a/src/components/epics/epic-stories-sheet.tsx b/src/components/epics/epic-stories-sheet.tsx new file mode 100644 index 0000000..df361d3 --- /dev/null +++ b/src/components/epics/epic-stories-sheet.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useState } from "react"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from "@/components/ui/sheet"; +import { Card, CardContent } from "@/components/ui/card"; +import { StatusBadge } from "@/components/shared/status-badge"; +import { SegmentedProgressBar } from "@/components/shared/segmented-progress-bar"; +import { StoryDetailView } from "./story-detail-view"; +import { ArrowLeft } from "lucide-react"; +import type { Epic, StoryDetail } from "@/lib/bmad/types"; + +interface EpicStoriesSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + epic: Epic | null; + stories: StoryDetail[]; +} + +export function EpicStoriesSheet({ + open, + onOpenChange, + epic, + stories, +}: EpicStoriesSheetProps) { + const [selectedStoryId, setSelectedStoryId] = useState(null); + // Reset internal navigation when the targeted epic changes (covers both + // "open with a different epic" and "close then reopen") — derived from + // props at render time, per React 19 guidance. + const [trackedEpicId, setTrackedEpicId] = useState( + epic?.id, + ); + if (trackedEpicId !== epic?.id) { + setTrackedEpicId(epic?.id); + setSelectedStoryId(null); + } + const selectedStory = + stories.find((s) => s.id === selectedStoryId) ?? null; + + if (!epic) return null; + + const inDetailView = selectedStory !== null; + + return ( + + + + {inDetailView && selectedStory ? ( + <> + {/* SheetTitle stays mounted for the Radix Dialog accessible + name; the visible header in StoryDetailView already + displays the same information for sighted users. */} + + Story {selectedStory.id}: {selectedStory.title} + + + + ) : ( + <> +
+ + {epic.id} + + {epic.title} +
+ {epic.description && ( + + {epic.description} + + )} +
+
+ + {epic.completedStories}/{epic.totalStories} stories + + {epic.progressPercent}% +
+ = 100 + ? "bg-success" + : epic.progressPercent > 0 + ? "bg-info" + : "bg-muted-foreground" + } + className="h-1.5" + /> +
+ + )} +
+ +
+ {inDetailView && selectedStory ? ( + + ) : stories.length === 0 ? ( +
+

+ No story found for this epic +

+
+ ) : ( +
+ {stories.map((story) => ( + setSelectedStoryId(story.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelectedStoryId(story.id); + } + }} + className="glass-card cursor-pointer hover:shadow-md hover:border-primary/30 transition-all duration-200" + aria-label={`Open story ${story.id}: ${story.title}`} + > + +
+
+ + {story.id} + + + {story.title} + +
+
+ {story.totalTasks > 0 && ( + + {story.completedTasks}/{story.totalTasks} + + )} + +
+
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/epics/epics-browser.tsx b/src/components/epics/epics-browser.tsx index 944b0a4..f694a49 100644 --- a/src/components/epics/epics-browser.tsx +++ b/src/components/epics/epics-browser.tsx @@ -1,16 +1,21 @@ "use client"; import { useState, useMemo, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { StatusBadge } from "@/components/shared/status-badge"; import { ProgressRing } from "@/components/shared/progress-ring"; import { SegmentedProgressBar } from "@/components/shared/segmented-progress-bar"; import { EpicsTimeline } from "./epics-timeline"; +import { EpicsKanban } from "./epics-kanban"; +import { EpicStoriesSheet } from "./epic-stories-sheet"; import { StoryDetailView } from "./story-detail-view"; import { useBreadcrumb } from "@/contexts/breadcrumb-context"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, GanttChartSquare, Columns3 } from "lucide-react"; import type { Epic, StoryDetail } from "@/lib/bmad/types"; +type EpicsLayout = "timeline" | "kanban"; + type View = "epics" | "stories" | "story"; interface EpicsBrowserProps { @@ -31,8 +36,28 @@ export function EpicsBrowser({ const [view, setView] = useState("epics"); const [selectedEpicId, setSelectedEpicId] = useState(null); const [selectedStoryId, setSelectedStoryId] = useState(null); + const [layout, setLayout] = useState("timeline"); + const [sheetEpicId, setSheetEpicId] = useState(null); const { setExtraSegments, clearExtraSegments } = useBreadcrumb(); + const sheetEpic = useMemo( + () => epics.find((e) => e.id === sheetEpicId) ?? null, + [epics, sheetEpicId], + ); + + const sheetEpicStories = useMemo( + () => (sheetEpicId ? stories.filter((s) => s.epicId === sheetEpicId) : []), + [stories, sheetEpicId], + ); + + const openEpicSheet = useCallback((epicId: string) => { + setSheetEpicId(epicId); + }, []); + + const handleSheetOpenChange = useCallback((open: boolean) => { + if (!open) setSheetEpicId(null); + }, []); + const selectedEpic = useMemo( () => epics.find((e) => e.id === selectedEpicId) ?? null, [epics, selectedEpicId], @@ -171,11 +196,49 @@ export function EpicsBrowser({ )}
- {/* Active view */} + {/* Layout toggle (only on the epics list view) */} {view === "epics" && ( +
+
+ + +
+
+ )} + + {/* Active view */} + {view === "epics" && layout === "timeline" && ( )} + {view === "epics" && layout === "kanban" && ( + + )} + {view === "stories" && selectedEpic && (
{/* Epic header summary */} @@ -274,6 +337,13 @@ export function EpicsBrowser({ )}
+ +
); } diff --git a/src/components/epics/epics-kanban.tsx b/src/components/epics/epics-kanban.tsx new file mode 100644 index 0000000..a785c2a --- /dev/null +++ b/src/components/epics/epics-kanban.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { StatusBadge } from "@/components/shared/status-badge"; +import { SegmentedProgressBar } from "@/components/shared/segmented-progress-bar"; +import { StaggeredList, StaggeredItem } from "@/components/shared/staggered-list"; +import type { Epic, EpicStatus } from "@/lib/bmad/types"; + +const kanbanColumns: { status: EpicStatus; label: string; color: string }[] = [ + { status: "not-started", label: "Planning", color: "bg-muted-foreground" }, + { status: "in-progress", label: "In Progress", color: "bg-info" }, + { status: "done", label: "Done", color: "bg-success" }, +]; + +interface EpicsKanbanProps { + epics: Epic[]; + onSelectEpic: (epicId: string) => void; +} + +export function EpicsKanban({ epics, onSelectEpic }: EpicsKanbanProps) { + if (epics.length === 0) { + return ( +
+ No epic found in this project +
+ ); + } + + return ( + + {kanbanColumns.map((col) => { + const columnEpics = epics.filter((e) => e.status === col.status); + return ( + +
+ + +
+ {columnEpics.map((epic) => ( + onSelectEpic(epic.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelectEpic(epic.id); + } + }} + className="glass-card cursor-pointer hover:shadow-md hover:border-primary/30 transition-all duration-200" + aria-label={`Open epic ${epic.id}: ${epic.title}`} + > + +
+
+ + {epic.id} + + + {epic.title} + +
+ +
+ +
+
+ + {epic.completedStories}/{epic.totalStories} stories + + {epic.progressPercent}% +
+ = 100 + ? "bg-success" + : epic.progressPercent > 0 + ? "bg-info" + : "bg-muted-foreground" + } + className="h-1.5" + /> +
+
+
+ ))} + + {columnEpics.length === 0 && ( +
+ No epics +
+ )} +
+ + ); + })} + + ); +}