import { useRef, useEffect, useState, useCallback } from 'react'; import { format } from 'date-fns'; import { Pencil, Trash2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from '@/components/ui/context-menu'; export interface GanttCheckpoint { id: string; title: string; date: number; // unix timestamp ms projectId: string; projectName?: string; isAiSuggested: number; isApproved: number; } interface GanttChartProps { checkpoints: GanttCheckpoint[]; startDate: Date; endDate: Date; onDelete?: (id: string) => void; onEdit?: (checkpoint: GanttCheckpoint) => void; onToggleApproval?: (id: string, currentApproved: number) => void; } const HEADER_HEIGHT = 30; const BASELINE_Y = 70; const BASELINE_HEIGHT = 8; const SVG_HEIGHT = 110; const DOT_RADIUS = 10; const PADDING_X = 40; function getMonthsBetween(start: Date, end: Date): Date[] { const months: Date[] = []; const current = new Date(start.getFullYear(), start.getMonth(), 1); while (current <= end) { months.push(new Date(current)); current.setMonth(current.getMonth() + 1); } return months; } function dateToX(date: Date, start: Date, end: Date, width: number): number { const totalMs = end.getTime() - start.getTime(); if (totalMs <= 0) return PADDING_X; const ratio = (date.getTime() - start.getTime()) / totalMs; return PADDING_X + ratio * (width - PADDING_X * 2); } export function GanttChart({ checkpoints, startDate, endDate, onDelete, onEdit, onToggleApproval, }: GanttChartProps) { const containerRef = useRef(null); const [width, setWidth] = useState(600); useEffect(() => { const el = containerRef.current; if (!el) return; const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) { setWidth(entry.contentRect.width); } }); observer.observe(el); return () => observer.disconnect(); }, []); const months = getMonthsBetween(startDate, endDate); const todayX = dateToX(new Date(), startDate, endDate, width); const todayVisible = todayX >= PADDING_X && todayX <= width - PADDING_X; return (
{/* Month labels */} {months.map((month) => { const x = dateToX(month, startDate, endDate, width); return ( {format(month, 'MMM yyyy')} ); })} {/* Baseline — thick rounded bar */} {/* Today marker — dashed line */} {todayVisible && ( )} {/* Today marker — Badge label via foreignObject */} {todayVisible && (
Today
)} {/* Checkpoint dots */} {checkpoints.map((cp) => { const cx = dateToX(new Date(cp.date), startDate, endDate, width); return ( ); })}
); } function getStatusLabel(checkpoint: GanttCheckpoint): { text: string; className: string } { if (checkpoint.isApproved === 0) { return { text: 'Pending', className: 'bg-muted text-muted-foreground' }; } if (checkpoint.date < Date.now()) { return { text: 'Completed', className: 'bg-chart-2/15 text-chart-2' }; } return { text: 'To Do', className: 'bg-primary/10 text-primary' }; } function CheckpointDot({ checkpoint, cx, onDelete, onEdit, onToggleApproval, }: { checkpoint: GanttCheckpoint; cx: number; onDelete?: (id: string) => void; onEdit?: (checkpoint: GanttCheckpoint) => void; onToggleApproval?: (id: string, currentApproved: number) => void; }) { const [hovered, setHovered] = useState(false); const hoverTimeout = useRef | null>(null); const handleMouseEnter = useCallback(() => { if (hoverTimeout.current) clearTimeout(hoverTimeout.current); hoverTimeout.current = setTimeout(() => setHovered(true), 200); }, []); const handleMouseLeave = useCallback(() => { if (hoverTimeout.current) clearTimeout(hoverTimeout.current); hoverTimeout.current = setTimeout(() => setHovered(false), 150); }, []); const isPending = checkpoint.isApproved === 0; const isPast = checkpoint.date < Date.now(); const fill = isPending ? 'none' : (isPast ? 'var(--chart-2)' : 'var(--primary)'); const stroke = isPending ? 'var(--muted-foreground)' : 'none'; const strokeDasharray = isPending ? '3 2' : undefined; const status = getStatusLabel(checkpoint); const dotSize = DOT_RADIUS * 2 + 2; const hitArea = dotSize + 8; const dotSvg = ( ); return ( e.preventDefault()} >
{checkpoint.title} {status.text}
{format(new Date(checkpoint.date), 'PPP')} {checkpoint.projectName && ( {checkpoint.projectName} )}
onEdit?.(checkpoint)}> Edit Checkpoint onDelete?.(checkpoint.id)} className="text-destructive focus:text-destructive" > Delete Checkpoint
); }