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

View File

@@ -1,13 +1,110 @@
import { createFileRoute } from '@tanstack/react-router';
import { useState, useMemo } from 'react';
import { Plus } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
export const Route = createFileRoute('/timeline')({
component: TimelinePage,
});
function TimelinePage() {
const [dialogOpen, setDialogOpen] = useState(false);
const { data: checkpoints } = trpc.checkpoints.list.useQuery({});
const { data: projectsList } = trpc.projects.listAll.useQuery();
const utils = trpc.useUtils();
const deleteCheckpoint = trpc.checkpoints.delete.useMutation({
onSuccess: () => {
void utils.checkpoints.list.invalidate();
},
});
// Build project name lookup
const projectMap = useMemo(() => {
const map = new Map<string, string>();
for (const p of projectsList ?? []) {
map.set(p.id, p.name);
}
return map;
}, [projectsList]);
// Map checkpoints to GanttChart format with project names
const ganttCheckpoints: GanttCheckpoint[] = useMemo(() => {
return (checkpoints ?? []).map((cp) => ({
id: cp.id,
title: cp.title,
date: cp.date,
projectId: cp.projectId,
projectName: projectMap.get(cp.projectId),
isAiSuggested: cp.isAiSuggested,
isApproved: cp.isApproved,
}));
}, [checkpoints, projectMap]);
// Compute date range: 1 month before earliest checkpoint or today, 3 months after latest or today
const { startDate, endDate } = useMemo(() => {
const now = new Date();
if (ganttCheckpoints.length === 0) {
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const end = new Date(now.getFullYear(), now.getMonth() + 4, 0);
return { startDate: start, endDate: end };
}
const dates = ganttCheckpoints.map((cp) => cp.date);
const min = Math.min(...dates, now.getTime());
const max = Math.max(...dates, now.getTime());
const start = new Date(new Date(min).getFullYear(), new Date(min).getMonth() - 1, 1);
const end = new Date(new Date(max).getFullYear(), new Date(max).getMonth() + 2, 0);
return { startDate: start, endDate: end };
}, [ganttCheckpoints]);
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Timeline coming in US-008
<div className="flex flex-col gap-6 p-6 max-w-[1200px] mx-auto w-full">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Timeline</h1>
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{/* Legend */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="#171717" /></svg>
To Do
</div>
<div className="flex items-center gap-1.5">
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="#16a34a" /></svg>
Completed
</div>
<div className="flex items-center gap-1.5">
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="none" stroke="#737373" strokeWidth={1.5} strokeDasharray="3 2" /></svg>
AI Suggestion (Pending)
</div>
</div>
{/* Gantt Chart */}
{ganttCheckpoints.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-12 border rounded-md bg-muted/30">
No checkpoints yet. Click "+ Add" to create your first milestone.
</div>
) : (
<div className="border rounded-md p-4 bg-white">
<GanttChart
checkpoints={ganttCheckpoints}
startDate={startDate}
endDate={endDate}
onDelete={(id) => deleteCheckpoint.mutate({ id })}
/>
</div>
)}
<AddCheckpointDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</div>
);
}