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"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 12,
|
"priority": 12,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": ""
|
"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",
|
"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
|
- `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
|
- 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)
|
- `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=...`)
|
- 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
|
- 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
|
- 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 { 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')({
|
export const Route = createFileRoute('/timeline')({
|
||||||
component: TimelinePage,
|
component: TimelinePage,
|
||||||
});
|
});
|
||||||
|
|
||||||
function 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 (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
<div className="flex flex-col gap-6 p-6 max-w-[1200px] mx-auto w-full">
|
||||||
Timeline — coming in US-008
|
{/* 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user