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,135 @@
import { useState } from 'react';
import { format } from 'date-fns';
import { Calendar as CalendarIcon } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { cn } from '@/lib/utils';
interface AddCheckpointDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultProjectId?: string;
}
export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: AddCheckpointDialogProps) {
const [title, setTitle] = useState('');
const [date, setDate] = useState<Date | undefined>();
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
const showProjectSelect = !defaultProjectId;
const { data: projectsList } = trpc.projects.listAll.useQuery(undefined, {
enabled: showProjectSelect,
});
const utils = trpc.useUtils();
const createCheckpoint = trpc.checkpoints.create.useMutation({
onSuccess: () => {
void utils.checkpoints.list.invalidate();
resetAndClose();
},
});
function resetAndClose() {
setTitle('');
setDate(undefined);
setProjectId(defaultProjectId ?? '');
onOpenChange(false);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const pid = defaultProjectId || projectId;
if (!title.trim() || !date || !pid) return;
createCheckpoint.mutate({
title: title.trim(),
date: date.getTime(),
projectId: pid,
});
}
const canSubmit = title.trim() && date && (defaultProjectId || projectId);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Add Checkpoint</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input
placeholder="Checkpoint title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
autoFocus
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
'justify-start text-left font-normal',
!date && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, 'PPP') : 'Pick a date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={setDate}
/>
</PopoverContent>
</Popover>
{showProjectSelect && (
<Select value={projectId} onValueChange={setProjectId}>
<SelectTrigger>
<SelectValue placeholder="Select project (required)" />
</SelectTrigger>
<SelectContent>
{projectsList?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={resetAndClose}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit || createCheckpoint.isPending}>
Add Checkpoint
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

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>
);
}