'use client' import { useEffect, useId, useRef, useState } from 'react' import { cn } from '../../lib/cn' import { ChartTooltip } from '../ChartTooltip' import { SegmentedControl } from '../SegmentedControl' import { VisuallyHidden } from '../VisuallyHidden' import { DRAW_IN_CLASS, PlotAnnouncer, PlotTip, TICK_CLASS, svgId, useChartCursor, useDrawIn } from '../internal/plot' export type GanttChartZoom = 'day' | 'week' | 'month' export interface GanttChartTask { id: string label: string /** Rows are grouped under a heading per lane — a team, a workstream. */ lane?: string start: Date /** Inclusive of the whole end day. Ignored for milestones. */ end: Date /** Share complete, 0 to 1. Drawn as a fill inside the bar. */ progress?: number /** Ids of tasks that must finish first. Drawn as arrows. */ dependsOn?: string[] /** A single moment rather than a span. Drawn as a diamond on `start`. */ milestone?: boolean } export interface GanttChartProps { tasks: GanttChartTask[] /** Accessible name for the chart. */ label: string /** Controlled zoom level. */ zoom?: GanttChartZoom /** Zoom level when uncontrolled. */ defaultZoom?: GanttChartZoom onZoomChange?: (zoom: GanttChartZoom) => void /** Show the day / week / month switch above the chart. */ showZoomControl?: boolean /** Where the today line is drawn. Pass it explicitly when rendering on a server. */ today?: Date /** Show the column of task names beside the timeline. */ showTaskList?: boolean /** Height of each row in pixels. */ rowHeight?: number /** Merged last, so it wins. */ className?: string } const DAY = 86_400_000 const UNIT: Record = { day: 30, week: 9, month: 2.6 } const HEADER = 30 const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()) const addDays = (date: Date, days: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + days) const daysBetween = (a: Date, b: Date) => Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / DAY) const short = (date: Date) => date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) /** * A plan as bars on a calendar: one row per task, grouped by lane, with progress * inside each bar, arrows for what waits on what, and a line for today. * * It is drawn in real pixels rather than a scaled viewBox, because a plan's * length is its content: stretching six months into the card's width makes day * zoom unreadable, so the timeline scrolls instead and the task names stay put. * Zoom changes pixels per day, not the data, so switching never loses your place * in the plan. One tab stop; arrows walk the tasks in row order and scroll each * into view, and the full plan is also in a hidden table. */ export function GanttChart({ tasks, label, zoom: zoomProp, defaultZoom = 'week', onZoomChange, showZoomControl = true, today: todayProp, showTaskList = true, rowHeight = 34, className, }: GanttChartProps) { const tableId = useId() const markerId = svgId(`${tableId}arrow`) const drawn = useDrawIn() const scrollRef = useRef(null) const listRef = useRef(null) const [width, setWidth] = useState(640) const [listWidth, setListWidth] = useState(0) const [scrollLeft, setScrollLeft] = useState(0) const [zoomState, setZoomState] = useState(defaultZoom) const zoom = zoomProp ?? zoomState const setZoom = (next: GanttChartZoom) => { if (zoomProp === undefined) setZoomState(next) onZoomChange?.(next) } useEffect(() => { const node = scrollRef.current if (!node) return const measure = () => { if (node.clientWidth > 0) setWidth(node.clientWidth) setListWidth(listRef.current?.offsetWidth ?? 0) } measure() const observer = new ResizeObserver(measure) observer.observe(node) return () => observer.disconnect() }, [showTaskList]) const today = startOfDay(todayProp ?? new Date()) const rows: ({ kind: 'lane'; lane: string } | { kind: 'task'; task: GanttChartTask; order: number })[] = [] const lanes = [...new Set(tasks.map((task) => task.lane ?? ''))] const ordered: GanttChartTask[] = [] for (const lane of lanes) { if (lane) rows.push({ kind: 'lane', lane }) for (const task of tasks.filter((entry) => (entry.lane ?? '') === lane)) { rows.push({ kind: 'task', task, order: ordered.length }) ordered.push(task) } } const { active, setActive, keyProps } = useChartCursor(ordered.length) let first = today let last = today for (const task of tasks) { if (task.start < first) first = task.start const end = task.milestone ? task.start : task.end if (end > last) last = end } let origin = addDays(first, zoom === 'day' ? -1 : -3) if (zoom === 'week') origin = addDays(origin, -((origin.getDay() + 6) % 7)) if (zoom === 'month') origin = new Date(origin.getFullYear(), origin.getMonth(), 1) const unit = UNIT[zoom] const span = daysBetween(origin, last) + (zoom === 'month' ? 21 : 6) const chartWidth = Math.max(width, span * unit) const height = HEADER + rows.length * rowHeight + 8 const toX = (date: Date) => daysBetween(origin, date) * unit const rowY = new Map() rows.forEach((row, index) => row.kind === 'task' && rowY.set(row.task.id, HEADER + index * rowHeight + rowHeight / 2)) const ticks: { date: Date; text: string; major: boolean }[] = [] for (let offset = 0; offset * unit <= chartWidth; offset += 1) { const date = addDays(origin, offset) if (zoom === 'day') ticks.push({ date, text: String(date.getDate()), major: date.getDate() === 1 || offset === 0 }) else if (zoom === 'week' && date.getDay() === 1) ticks.push({ date, text: short(date), major: false }) else if (zoom === 'month' && date.getDate() === 1) ticks.push({ date, text: date.toLocaleDateString(undefined, { month: 'short', year: '2-digit' }), major: false }) } const current = active === null ? null : ordered[active] useEffect(() => { const node = scrollRef.current if (!node || !current) return const x = toX(current.start) if (x < node.scrollLeft + 16 || x > node.scrollLeft + node.clientWidth - 48) node.scrollLeft = Math.max(0, x - 48) // Scroll follows the cursor only; zoom and width changes do not yank the view. // eslint-disable-next-line react-hooks/exhaustive-deps }, [current]) const describe = (task: GanttChartTask) => task.milestone ? `${task.label}: milestone on ${short(task.start)}` : `${task.label}: ${short(task.start)} to ${short(task.end)}, ${Math.round((task.progress ?? 0) * 100)}% complete${ task.dependsOn?.length ? `, after ${task.dependsOn.map((id) => tasks.find((t) => t.id === id)?.label ?? id).join(', ')}` : '' }` return (
{showZoomControl && ( )}
{showTaskList && ( {current && active !== null && ( // Outside the scrolling box, so the scroll container never clips it. )}
{['Task', 'Lane', 'Start', 'End', 'Complete', 'Depends on'].map((heading) => ( ))} {ordered.map((task) => ( ))}
{label}
{heading}
{task.milestone ? `${task.label} (milestone)` : task.label} {task.lane ?? ''} {short(task.start)} {task.milestone ? '' : short(task.end)} {task.milestone ? '' : `${Math.round((task.progress ?? 0) * 100)}%`} {(task.dependsOn ?? []).map((id) => tasks.find((t) => t.id === id)?.label ?? id).join(', ')}
{`Today is ${short(today)}.`}
) }