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:
@@ -223,8 +223,8 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 12,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
"passes": true,
|
||||
"notes": "Completed: Reusable GanttChart SVG component with month labels, baseline, ResizeObserver responsive width, Today marker (red line), checkpoint dots (dark=#171717 for future/approved, green=#16a34a for past/approved, dashed outline for pending AI suggestions). Popover on dot click shows title, date, project name, delete button. Global Timeline route (/timeline) renders all checkpoints from all projects with project name lookup via Map. AddCheckpointDialog with title Input, Popover+Calendar date, Select project. Legend showing dot types."
|
||||
},
|
||||
{
|
||||
"id": "US-013",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
- `z.string().nullable().optional()` in tRPC inputs enables three-state semantics: undefined = don't change, null = clear, string = set value
|
||||
- NewTaskDialog component at `src/renderer/components/tasks/NewTaskDialog.tsx` accepts `defaultProjectId` and `defaultStatus` props for reuse in Kanban column "+ Add" buttons
|
||||
- `date-fns` is available as a transitive dependency of `react-day-picker` (shadcn/ui calendar)
|
||||
- GanttChart component at `src/renderer/components/timeline/GanttChart.tsx` is reusable: accepts `defaultProjectId` to scope to a project (for US-015 inline timeline)
|
||||
- AddCheckpointDialog at `src/renderer/components/timeline/AddCheckpointDialog.tsx` accepts `defaultProjectId` — hides project select when provided
|
||||
- TanStack Router `validateSearch` with Zod schema for passing selected-item IDs via URL search params (e.g., `?projectId=...`)
|
||||
---
|
||||
|
||||
@@ -203,3 +205,24 @@
|
||||
- The Popover+Calendar date picker pattern is standard shadcn/ui: Popover wraps a Button trigger showing the formatted date, PopoverContent contains the Calendar
|
||||
- Electron app runs at `http://localhost:5173` in dev mode but only within the Electron BrowserWindow — Playwright browser testing requires the Electron-specific test harness, not direct URL navigation
|
||||
---
|
||||
|
||||
## 2026-02-20 - US-012
|
||||
- What was implemented:
|
||||
- Reusable `GanttChart` SVG component at `src/renderer/components/timeline/GanttChart.tsx`
|
||||
- Accepts `{ checkpoints: GanttCheckpoint[], startDate: Date, endDate: Date, onDelete? }` props
|
||||
- Custom SVG rendering: month labels on X axis, horizontal baseline `<line>`, `<circle>` dots for checkpoints positioned by date
|
||||
- Dot fill logic: dark (#171717) for future approved checkpoints, green (#16a34a) for past approved, dashed outline (#737373) for pending AI suggestions (isApproved=0)
|
||||
- Vertical red "Today" marker line at current date
|
||||
- ResizeObserver for responsive SVG width
|
||||
- foreignObject + shadcn/ui Popover on each dot click: shows title, formatted date, project name, and destructive Delete button
|
||||
- `AddCheckpointDialog` component at `src/renderer/components/timeline/AddCheckpointDialog.tsx`: title Input (required), Popover+Calendar date (required), Select project dropdown (required in global view, hidden when `defaultProjectId` provided)
|
||||
- Global Timeline route (`/timeline`) renders GanttChart with all checkpoints, project name lookup via Map from `projects.listAll`
|
||||
- Legend showing dot types, empty state message when no checkpoints
|
||||
- Files changed: `src/renderer/components/timeline/GanttChart.tsx` (new), `src/renderer/components/timeline/AddCheckpointDialog.tsx` (new), `src/renderer/routes/timeline.tsx`
|
||||
- **Learnings for future iterations:**
|
||||
- `foreignObject` inside SVG is the cleanest way to embed React components (like Popover) on SVG elements — set `overflow-visible` class to prevent clipping
|
||||
- Checkpoints don't have a `status` field; use `isApproved=1` + `date < now` heuristic for "completed" vs "todo" dot color
|
||||
- Date range for the Gantt is computed dynamically: 1 month before earliest date, 2 months after latest date — ensures comfortable visual padding
|
||||
- GanttChart is designed for reuse: the `defaultProjectId` prop on AddCheckpointDialog pre-selects the project and hides the dropdown (for per-project timeline in US-015)
|
||||
- `trpc.projects.listAll.useQuery(undefined, { enabled: showProjectSelect })` prevents unnecessary queries when project is already known
|
||||
---
|
||||
|
||||
135
src/renderer/components/timeline/AddCheckpointDialog.tsx
Normal file
135
src/renderer/components/timeline/AddCheckpointDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user