diff --git a/apps/web/app/(dashboard)/projects/[id]/page.tsx b/apps/web/app/(dashboard)/projects/[id]/page.tsx index 73e7f95..b984c22 100644 --- a/apps/web/app/(dashboard)/projects/[id]/page.tsx +++ b/apps/web/app/(dashboard)/projects/[id]/page.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; import { SectionDialog } from "@/components/projects/section-dialog"; +import { ProjectTimeline } from "@/components/projects/project-timeline"; import Link from "next/link"; import { toast } from "sonner"; @@ -290,6 +291,9 @@ export default function ProjectDetailPage() { + {/* Timeline */} + + = { + planned: 'bg-gray-200 dark:bg-gray-700', + in_progress: 'bg-blue-400 dark:bg-blue-600', + complete: 'bg-green-400 dark:bg-green-600', +}; + +const kindBadge: Record = { + section: 'bg-muted text-muted-foreground', + milestone: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', +}; + +export function ProjectTimeline({ sections, projectTargetDate }: ProjectTimelineProps) { + const { startDate, totalDays } = useMemo(() => { + if (sections.length === 0) return { startDate: new Date(), totalDays: 30 }; + + const dates = sections + .filter((s) => s.targetDate) + .map((s) => new Date(s.targetDate!)); + + if (projectTargetDate) dates.push(new Date(projectTargetDate)); + + if (dates.length === 0) { + // No dates at all — show a default 30-day window + const now = new Date(); + return { startDate: now, totalDays: 30 }; + } + + const minDate = new Date(Math.min(...dates.map((d) => d.getTime()))); + const maxDate = new Date(Math.max(...dates.map((d) => d.getTime()))); + const diff = Math.max((maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24), 14); + return { startDate: minDate, totalDays: Math.ceil(diff) }; + }, [sections, projectTargetDate]); + + if (sections.length === 0) return null; + + return ( +
+

Timeline

+
+ {/* Header row */} +
+ Section +
+
+ {Array.from({ length: Math.min(totalDays, 60) }).map((_, i) => ( +
+ ))} +
+
+
+ + {/* Section rows */} +
+ {sections.map((section) => { + if (!section.targetDate) return null; + + const sectionDate = new Date(section.targetDate); + const dayOffset = Math.max( + 0, + Math.round((sectionDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + ); + const barWidth = Math.max(8, Math.min(100, (1 / Math.max(totalDays, 1)) * 100)); + + return ( +
+
+ + {section.kind === 'milestone' ? 'M' : 'S'} + + {section.name} +
+
+
+
+
+ ); + })} +
+ + {/* Project target date marker */} + {projectTargetDate && ( +
+ Target date + {new Date(projectTargetDate).toLocaleDateString()} +
+ )} +
+
+ ); +}