feat: US-012 — GanttChart SVG component and global Timeline view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Roberto Musso
2026-02-20 12:47:34 +01:00
parent e92d58a46e
commit ab517549a9
5 changed files with 487 additions and 4 deletions

View File

@@ -0,0 +1,228 @@
import { useRef, useState, useEffect, useCallback } from 'react';
import { format } from 'date-fns';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
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;
}
const HEADER_HEIGHT = 30;
const BASELINE_Y = 70;
const SVG_HEIGHT = 100;
const DOT_RADIUS = 7;
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 }: GanttChartProps) {
const containerRef = useRef<HTMLDivElement>(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);
return (
<div ref={containerRef} className="w-full overflow-hidden">
<svg width={width} height={SVG_HEIGHT} className="select-none">
{/* Month labels */}
{months.map((month) => {
const x = dateToX(month, startDate, endDate, width);
return (
<g key={month.toISOString()}>
<text
x={x}
y={HEADER_HEIGHT - 8}
textAnchor="middle"
className="fill-muted-foreground"
fontSize={12}
fontFamily="Geist, sans-serif"
>
{format(month, 'MMM yyyy')}
</text>
<line
x1={x}
y1={HEADER_HEIGHT}
x2={x}
y2={BASELINE_Y + 10}
stroke="#e5e5e5"
strokeWidth={1}
strokeDasharray="4 4"
/>
</g>
);
})}
{/* Baseline */}
<line
x1={PADDING_X}
y1={BASELINE_Y}
x2={width - PADDING_X}
y2={BASELINE_Y}
stroke="#d4d4d4"
strokeWidth={2}
/>
{/* Today marker */}
{todayX >= PADDING_X && todayX <= width - PADDING_X && (
<g>
<line
x1={todayX}
y1={HEADER_HEIGHT}
x2={todayX}
y2={BASELINE_Y + 10}
stroke="#ef4444"
strokeWidth={1.5}
/>
<text
x={todayX}
y={BASELINE_Y + 22}
textAnchor="middle"
fill="#ef4444"
fontSize={10}
fontFamily="Geist, sans-serif"
>
Today
</text>
</g>
)}
{/* Checkpoint dots rendered as foreignObject for Popover */}
{checkpoints.map((cp) => {
const cx = dateToX(new Date(cp.date), startDate, endDate, width);
return (
<CheckpointDot
key={cp.id}
checkpoint={cp}
cx={cx}
onDelete={onDelete}
/>
);
})}
</svg>
</div>
);
}
function CheckpointDot({
checkpoint,
cx,
onDelete,
}: {
checkpoint: GanttCheckpoint;
cx: number;
onDelete?: (id: string) => void;
}) {
const [open, setOpen] = useState(false);
const handleDelete = useCallback(() => {
onDelete?.(checkpoint.id);
setOpen(false);
}, [onDelete, checkpoint.id]);
// Determine dot style
// isApproved=0 (pending AI suggestion) → dashed outline
// isApproved=1 + date in past → green (#16a34a) = completed
// isApproved=1 + date in future → dark (#171717) = todo
const isPending = checkpoint.isApproved === 0;
const isPast = checkpoint.date < Date.now();
const fill = isPending ? 'none' : (isPast ? '#16a34a' : '#171717');
const stroke = isPending ? '#737373' : 'none';
const strokeDasharray = isPending ? '3 2' : undefined;
return (
<foreignObject
x={cx - 16}
y={BASELINE_Y - 16}
width={32}
height={32}
className="overflow-visible"
>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className="flex items-center justify-center w-full h-full focus:outline-none cursor-pointer"
type="button"
>
<svg width={DOT_RADIUS * 2 + 2} height={DOT_RADIUS * 2 + 2}>
<circle
cx={DOT_RADIUS + 1}
cy={DOT_RADIUS + 1}
r={DOT_RADIUS}
fill={fill}
stroke={stroke || '#171717'}
strokeWidth={isPending ? 1.5 : 0}
strokeDasharray={strokeDasharray}
/>
</svg>
</button>
</PopoverTrigger>
<PopoverContent className="w-60 p-3" side="top">
<div className="flex flex-col gap-2">
<div className="font-semibold text-sm">{checkpoint.title}</div>
<div className="text-xs text-muted-foreground">
{format(new Date(checkpoint.date), 'PPP')}
</div>
{checkpoint.projectName && (
<div className="text-xs text-muted-foreground">
Project: {checkpoint.projectName}
</div>
)}
{onDelete && (
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
className="mt-1"
>
Delete
</Button>
)}
</div>
</PopoverContent>
</Popover>
</foreignObject>
);
}