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:
228
src/renderer/components/timeline/GanttChart.tsx
Normal file
228
src/renderer/components/timeline/GanttChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user