From ab517549a9d9f951de44224d92b70ec7bc515121 Mon Sep 17 00:00:00 2001 From: Roberto Musso Date: Fri, 20 Feb 2026 12:47:34 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20US-012=20=E2=80=94=20GanttChart=20SVG?= =?UTF-8?q?=20component=20and=20global=20Timeline=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- scripts/ralph/prd.json | 4 +- scripts/ralph/progress.txt | 23 ++ .../timeline/AddCheckpointDialog.tsx | 135 +++++++++++ .../components/timeline/GanttChart.tsx | 228 ++++++++++++++++++ src/renderer/routes/timeline.tsx | 101 +++++++- 5 files changed, 487 insertions(+), 4 deletions(-) create mode 100644 src/renderer/components/timeline/AddCheckpointDialog.tsx create mode 100644 src/renderer/components/timeline/GanttChart.tsx diff --git a/scripts/ralph/prd.json b/scripts/ralph/prd.json index 249b0b7..31f0674 100644 --- a/scripts/ralph/prd.json +++ b/scripts/ralph/prd.json @@ -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", diff --git a/scripts/ralph/progress.txt b/scripts/ralph/progress.txt index d6806c7..5eb333f 100644 --- a/scripts/ralph/progress.txt +++ b/scripts/ralph/progress.txt @@ -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 ``, `` 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 +--- diff --git a/src/renderer/components/timeline/AddCheckpointDialog.tsx b/src/renderer/components/timeline/AddCheckpointDialog.tsx new file mode 100644 index 0000000..7570a8b --- /dev/null +++ b/src/renderer/components/timeline/AddCheckpointDialog.tsx @@ -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(); + 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 ( + + + + Add Checkpoint + +
+ setTitle(e.target.value)} + required + autoFocus + /> + + + + + + + + + + + {showProjectSelect && ( + + )} + + + + + +
+
+
+ ); +} diff --git a/src/renderer/components/timeline/GanttChart.tsx b/src/renderer/components/timeline/GanttChart.tsx new file mode 100644 index 0000000..443a07c --- /dev/null +++ b/src/renderer/components/timeline/GanttChart.tsx @@ -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(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 ( +
+ + {/* Month labels */} + {months.map((month) => { + const x = dateToX(month, startDate, endDate, width); + return ( + + + {format(month, 'MMM yyyy')} + + + + ); + })} + + {/* Baseline */} + + + {/* Today marker */} + {todayX >= PADDING_X && todayX <= width - PADDING_X && ( + + + + Today + + + )} + + {/* Checkpoint dots rendered as foreignObject for Popover */} + {checkpoints.map((cp) => { + const cx = dateToX(new Date(cp.date), startDate, endDate, width); + return ( + + ); + })} + +
+ ); +} + +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 ( + + + + + + +
+
{checkpoint.title}
+
+ {format(new Date(checkpoint.date), 'PPP')} +
+ {checkpoint.projectName && ( +
+ Project: {checkpoint.projectName} +
+ )} + {onDelete && ( + + )} +
+
+
+
+ ); +} diff --git a/src/renderer/routes/timeline.tsx b/src/renderer/routes/timeline.tsx index f1c8b3b..c19dc47 100644 --- a/src/renderer/routes/timeline.tsx +++ b/src/renderer/routes/timeline.tsx @@ -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(); + 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 ( -
- Timeline — coming in US-008 +
+ {/* Header */} +
+

Timeline

+ +
+ + {/* Legend */} +
+
+ + To Do +
+
+ + Completed +
+
+ + AI Suggestion (Pending) +
+
+ + {/* Gantt Chart */} + {ganttCheckpoints.length === 0 ? ( +
+ No checkpoints yet. Click "+ Add" to create your first milestone. +
+ ) : ( +
+ deleteCheckpoint.mutate({ id })} + /> +
+ )} + +
); }