Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions src/components/epics/epic-stories-sheet.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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<string | null | undefined>(
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-full sm:max-w-2xl overflow-y-auto"
>
<SheetHeader className="border-b">
{inDetailView && selectedStory ? (
<>
{/* SheetTitle stays mounted for the Radix Dialog accessible
name; the visible header in StoryDetailView already
displays the same information for sighted users. */}
<SheetTitle className="sr-only">
Story {selectedStory.id}: {selectedStory.title}
</SheetTitle>
<button
type="button"
onClick={() => setSelectedStoryId(null)}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
aria-label="Back to stories list"
>
<ArrowLeft className="h-4 w-4" />
Back to stories
</button>
</>
) : (
<>
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary shrink-0">
{epic.id}
</span>
<SheetTitle className="text-lg">{epic.title}</SheetTitle>
</div>
{epic.description && (
<SheetDescription className="ml-11">
{epic.description}
</SheetDescription>
)}
<div className="ml-11 mt-2 space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{epic.completedStories}/{epic.totalStories} stories
</span>
<span>{epic.progressPercent}%</span>
</div>
<SegmentedProgressBar
percent={epic.progressPercent}
color={
epic.progressPercent >= 100
? "bg-success"
: epic.progressPercent > 0
? "bg-info"
: "bg-muted-foreground"
}
className="h-1.5"
/>
</div>
</>
)}
</SheetHeader>

<div className="px-4 pb-4">
{inDetailView && selectedStory ? (
<StoryDetailView story={selectedStory} />
) : stories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-sm text-muted-foreground">
No story found for this epic
</p>
</div>
) : (
<div className="space-y-2">
{stories.map((story) => (
<Card
key={story.id}
role="button"
tabIndex={0}
onClick={() => 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}`}
>
<CardContent className="p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-[10px] font-bold text-primary shrink-0">
{story.id}
</span>
<span className="font-medium text-sm truncate">
{story.title}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{story.totalTasks > 0 && (
<span className="text-xs text-muted-foreground">
{story.completedTasks}/{story.totalTasks}
</span>
)}
<StatusBadge status={story.status} />
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
74 changes: 72 additions & 2 deletions src/components/epics/epics-browser.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -31,8 +36,28 @@ export function EpicsBrowser({
const [view, setView] = useState<View>("epics");
const [selectedEpicId, setSelectedEpicId] = useState<string | null>(null);
const [selectedStoryId, setSelectedStoryId] = useState<string | null>(null);
const [layout, setLayout] = useState<EpicsLayout>("timeline");
const [sheetEpicId, setSheetEpicId] = useState<string | null>(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],
Expand Down Expand Up @@ -171,11 +196,49 @@ export function EpicsBrowser({
)}

<div className="space-y-4">
{/* Active view */}
{/* Layout toggle (only on the epics list view) */}
{view === "epics" && (
<div className="flex justify-end">
<div
className="flex gap-1 border rounded-lg p-1"
role="group"
aria-label="Epics display mode"
>
<Button
variant={layout === "timeline" ? "secondary" : "ghost"}
size="sm"
onClick={() => setLayout("timeline")}
className="gap-1.5"
aria-label="Timeline view"
aria-pressed={layout === "timeline"}
>
<GanttChartSquare className="h-4 w-4" />
<span className="hidden sm:inline">Timeline</span>
</Button>
<Button
variant={layout === "kanban" ? "secondary" : "ghost"}
size="sm"
onClick={() => setLayout("kanban")}
className="gap-1.5"
aria-label="Kanban view"
aria-pressed={layout === "kanban"}
>
<Columns3 className="h-4 w-4" />
<span className="hidden sm:inline">Board</span>
</Button>
</div>
</div>
)}

{/* Active view */}
{view === "epics" && layout === "timeline" && (
<EpicsTimeline epics={epics} onSelectEpic={goToStories} />
)}

{view === "epics" && layout === "kanban" && (
<EpicsKanban epics={epics} onSelectEpic={openEpicSheet} />
)}

{view === "stories" && selectedEpic && (
<div className="space-y-4">
{/* Epic header summary */}
Expand Down Expand Up @@ -274,6 +337,13 @@ export function EpicsBrowser({
<StoryDetailView story={selectedStory} />
)}
</div>

<EpicStoriesSheet
open={sheetEpicId !== null}
onOpenChange={handleSheetOpenChange}
epic={sheetEpic}
stories={sheetEpicStories}
/>
</div>
);
}
110 changes: 110 additions & 0 deletions src/components/epics/epics-kanban.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center justify-center h-32 rounded-lg border border-dashed border-border/50 text-muted-foreground">
No epic found in this project
</div>
);
}

return (
<StaggeredList className="grid grid-cols-1 gap-4 md:grid-cols-3">
{kanbanColumns.map((col) => {
const columnEpics = epics.filter((e) => e.status === col.status);
return (
<StaggeredItem key={col.status} className="space-y-3">
<div className="flex items-center gap-2">
<div
className={`h-2.5 w-2.5 rounded-full ${col.color}`}
aria-hidden="true"
/>
<h3 className="text-sm font-semibold">{col.label}</h3>
<Badge variant="secondary" className="ml-auto text-xs">
{columnEpics.length}
</Badge>
</div>

<div className="space-y-2 min-h-25">
{columnEpics.map((epic) => (
<Card
key={epic.id}
role="button"
tabIndex={0}
onClick={() => 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}`}
>
<CardContent className="p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary shrink-0">
{epic.id}
</span>
<span className="font-medium text-sm leading-tight truncate">
{epic.title}
</span>
</div>
<StatusBadge status={epic.status} />
</div>

<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{epic.completedStories}/{epic.totalStories} stories
</span>
<span>{epic.progressPercent}%</span>
</div>
<SegmentedProgressBar
percent={epic.progressPercent}
color={
epic.progressPercent >= 100
? "bg-success"
: epic.progressPercent > 0
? "bg-info"
: "bg-muted-foreground"
}
className="h-1.5"
/>
</div>
</CardContent>
</Card>
))}

{columnEpics.length === 0 && (
<div className="flex items-center justify-center h-20 rounded-lg border border-dashed border-border/50 text-xs text-muted-foreground">
No epics
</div>
)}
</div>
</StaggeredItem>
);
})}
</StaggeredList>
);
}
Loading