{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gantt-chart",
  "type": "registry:ui",
  "title": "Gantt chart",
  "description": "A project timeline with day and week scales, a today line, progress fills, dependency arrows, a sticky task column and focusable bars.",
  "dependencies": [],
  "registryDependencies": [
    "https://ui.minidev.pro/r/segmented-control.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/gantt-chart.tsx",
      "type": "registry:ui",
      "target": "components/ui/gantt-chart.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { SegmentedControl } from \"@/components/ui/segmented-control\"\n\n/** A project timeline with day and week scales, a today line, progress fills, dependency arrows, a sticky task column and focusable bars. */\ntype GanttTone = \"accent\" | \"info\" | \"success\" | \"warning\" | \"danger\"\n\ntype GanttTask = {\n  id: string\n  name: string\n  /** Start date: a Date or an ISO day string such as \"2026-10-14\". */\n  start: Date | string\n  /** Last day of the task, inclusive. */\n  end: Date | string\n  /** 0 to 100. */\n  progress?: number\n  /** Tasks with the same group render under one heading and share a colour. */\n  group?: string\n  /** Ids of tasks that must finish first. Drawn as elbow arrows. */\n  dependsOn?: string[]\n  /** Colour override for this bar. */\n  tone?: GanttTone\n}\n\ntype GanttScale = \"day\" | \"week\"\n\ntype GanttChartProps = {\n  /** Tasks to plot. Defaults to a six week Lumen launch plan built around today. */\n  tasks?: GanttTask[]\n  /** Controlled zoom level. */\n  scale?: GanttScale\n  defaultScale?: GanttScale\n  onScaleChange?: (scale: GanttScale) => void\n  /** The date the today line marks. Defaults to the current date. */\n  today?: Date | string\n  /** Empty days drawn before the first and after the last task. */\n  padDays?: number\n  /** Heading above the chart. Empty hides the header row. */\n  title?: string\n  /** Width of the sticky task column in px. */\n  nameWidth?: number\n  /** Called when a bar is clicked or activated with the keyboard. */\n  onTaskClick?: (task: GanttTask) => void\n  className?: string\n}\n\nconst DAY_MS = 864e5\nconst DAY_W: Record<GanttScale, number> = { day: 36, week: 16 }\nconst ROW_H = 36\nconst GROUP_H = 28\nconst HEAD_H = 28\nconst TONES: GanttTone[] = [\"accent\", \"info\", \"success\", \"warning\", \"danger\"]\nconst TONE: Record<GanttTone, { bar: string; fill: string; dot: string }> = {\n  accent: { bar: \"border-accent-line bg-accent-soft\", fill: \"bg-accent/35\", dot: \"bg-accent\" },\n  info: { bar: \"border-info/30 bg-info/10\", fill: \"bg-info/35\", dot: \"bg-info\" },\n  success: { bar: \"border-success/30 bg-success/10\", fill: \"bg-success/35\", dot: \"bg-success\" },\n  warning: { bar: \"border-warning/35 bg-warning/10\", fill: \"bg-warning/40\", dot: \"bg-warning\" },\n  danger: { bar: \"border-danger/30 bg-danger/10\", fill: \"bg-danger/35\", dot: \"bg-danger\" },\n}\n\nfunction toDate(v: Date | string): Date {\n  if (v instanceof Date) return new Date(v.getFullYear(), v.getMonth(), v.getDate())\n  const m = /^(\\d{4})-(\\d{2})-(\\d{2})/.exec(v)\n  if (m) return new Date(+m[1], +m[2] - 1, +m[3])\n  const d = new Date(v)\n  return new Date(d.getFullYear(), d.getMonth(), d.getDate())\n}\nconst addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)\nconst dayDiff = (a: Date, b: Date) => Math.round((b.getTime() - a.getTime()) / DAY_MS)\nconst fmt = (d: Date, opts: Intl.DateTimeFormatOptions = { month: \"short\", day: \"numeric\" }) => d.toLocaleDateString(\"en-US\", opts)\n/** Monday of the week containing d. */\nconst weekStart = (d: Date) => addDays(d, -((d.getDay() + 6) % 7))\n\nfunction demoTasks(today: Date): GanttTask[] {\n  const d = (n: number) => addDays(today, n)\n  return [\n    { id: \"discovery\", name: \"Discovery interviews\", start: d(-14), end: d(-8), progress: 100, group: \"Research\" },\n    { id: \"positioning\", name: \"Positioning and pricing\", start: d(-7), end: d(-3), progress: 100, group: \"Research\", dependsOn: [\"discovery\"] },\n    { id: \"audit\", name: \"Design system audit\", start: d(-5), end: d(1), progress: 70, group: \"Design\", dependsOn: [\"discovery\"] },\n    { id: \"landing\", name: \"Landing page design\", start: d(2), end: d(9), progress: 10, group: \"Design\", dependsOn: [\"audit\"] },\n    { id: \"billing\", name: \"Billing API\", start: d(-2), end: d(10), progress: 45, group: \"Engineering\", dependsOn: [\"positioning\"] },\n    { id: \"checkout\", name: \"Checkout flow\", start: d(11), end: d(19), progress: 0, group: \"Engineering\", dependsOn: [\"landing\", \"billing\"] },\n    { id: \"qa\", name: \"QA and beta\", start: d(20), end: d(24), progress: 0, group: \"Engineering\", dependsOn: [\"checkout\"] },\n    { id: \"comms\", name: \"Launch comms\", start: d(12), end: d(24), progress: 0, group: \"Launch\", dependsOn: [\"landing\"] },\n    { id: \"launch\", name: \"Public launch\", start: d(25), end: d(26), progress: 0, group: \"Launch\", dependsOn: [\"qa\", \"comms\"] },\n  ]\n}\n\n/** True once the element is narrower than `max` px. False during SSR. */\nfunction useNarrow(ref: React.RefObject<HTMLElement | null>, max: number) {\n  const [narrow, setNarrow] = React.useState(false)\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el || typeof ResizeObserver === \"undefined\") return\n    const ro = new ResizeObserver((entries) => setNarrow(entries[0].contentRect.width < max))\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [ref, max])\n  return narrow\n}\n\ntype Row = { kind: \"group\"; label: string; y: number; h: number; tone: GanttTone } | { kind: \"task\"; task: GanttTask; y: number; h: number; tone: GanttTone; s: Date; e: Date; progress: number }\n\n/**\n * A Gantt chart for launch plans and delivery schedules. Bars show progress\n * as a fill, dependencies as elbow arrows, today as an accent line, and the\n * task column stays put while the timeline scrolls sideways. Switch between\n * day and week scales; hover or focus a bar for its dates. Every bar is a\n * button with a full accessible label.\n */\nfunction GanttChart({\n  tasks,\n  scale: scaleProp,\n  defaultScale = \"day\",\n  onScaleChange,\n  today,\n  padDays = 2,\n  title = \"Launch plan\",\n  nameWidth = 176,\n  onTaskClick,\n  className,\n}: GanttChartProps) {\n  const [now] = React.useState(() => new Date())\n  const todayDate = React.useMemo(() => toDate(today ?? now), [today, now])\n  const list = React.useMemo(() => tasks ?? demoTasks(todayDate), [tasks, todayDate])\n  const [innerScale, setInnerScale] = React.useState<GanttScale>(defaultScale)\n  const scale = scaleProp ?? innerScale\n  const dayW = DAY_W[scale]\n  const scroller = React.useRef<HTMLDivElement>(null)\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const narrow = useNarrow(rootRef, 560)\n  const colW = narrow ? Math.min(nameWidth, 132) : nameWidth\n  const [hover, setHover] = React.useState<{ id: string; x: number; y: number; el: HTMLElement } | null>(null)\n\n  const setScale = (s: GanttScale) => {\n    if (scaleProp === undefined) setInnerScale(s)\n    onScaleChange?.(s)\n  }\n\n  /* Range, rows and geometry. */\n  const { start, days, rows, byId, groups } = React.useMemo(() => {\n    const parsed = list.map((t) => ({ t, s: toDate(t.start), e: toDate(t.end) }))\n    let min = todayDate\n    let max = todayDate\n    for (const p of parsed) {\n      if (p.s < min) min = p.s\n      if (p.e > max) max = p.e\n    }\n    let start = addDays(min, -padDays)\n    let end = addDays(max, padDays)\n    if (scale === \"week\") {\n      start = weekStart(start)\n      end = addDays(weekStart(end), 6)\n    }\n    const days = dayDiff(start, end) + 1\n    const groups: string[] = []\n    for (const p of parsed) {\n      const g = p.t.group ?? \"\"\n      if (!groups.includes(g)) groups.push(g)\n    }\n    const rows: Row[] = []\n    const byId = new Map<string, Extract<Row, { kind: \"task\" }>>()\n    let y = 0\n    groups.forEach((g, gi) => {\n      const tone = TONES[gi % TONES.length]\n      if (g) {\n        rows.push({ kind: \"group\", label: g, y, h: GROUP_H, tone })\n        y += GROUP_H\n      }\n      for (const p of parsed) {\n        if ((p.t.group ?? \"\") !== g) continue\n        const row: Extract<Row, { kind: \"task\" }> = { kind: \"task\", task: p.t, y, h: ROW_H, tone: p.t.tone ?? tone, s: p.s, e: p.e, progress: Math.max(0, Math.min(100, p.t.progress ?? 0)) }\n        rows.push(row)\n        byId.set(p.t.id, row)\n        y += ROW_H\n      }\n    })\n    return { start, days, rows, byId, groups }\n  }, [list, todayDate, padDays, scale])\n\n  const bodyH = rows.reduce((a, r) => a + r.h, 0)\n  const width = days * dayW\n  const todayIdx = dayDiff(start, todayDate)\n  const x = (d: Date) => dayDiff(start, d) * dayW\n\n  /* Header cells: months on top, days or weeks below. */\n  const months = React.useMemo(() => {\n    const out: { label: string; from: number; span: number }[] = []\n    for (let i = 0; i < days; i++) {\n      const d = addDays(start, i)\n      const label = fmt(d, { month: \"long\", year: \"numeric\" })\n      const last = out[out.length - 1]\n      if (last && last.label === label) last.span++\n      else out.push({ label, from: i, span: 1 })\n    }\n    return out\n  }, [start, days])\n  const units = React.useMemo(() => {\n    const out: { from: number; span: number; label: string; weekend: boolean }[] = []\n    if (scale === \"day\") for (let i = 0; i < days; i++) {\n      const d = addDays(start, i)\n      out.push({ from: i, span: 1, label: String(d.getDate()), weekend: d.getDay() === 0 || d.getDay() === 6 })\n    }\n    else for (let i = 0; i < days; i += 7) out.push({ from: i, span: Math.min(7, days - i), label: fmt(addDays(start, i)), weekend: false })\n    return out\n  }, [scale, start, days])\n\n  /* Keep today in view on mount and when the scale changes. */\n  React.useEffect(() => {\n    const el = scroller.current\n    if (!el) return\n    el.scrollLeft = Math.max(0, todayIdx * dayW - el.clientWidth / 3)\n  }, [scale, dayW, todayIdx])\n\n  /* Dependency arrows. */\n  const arrows = React.useMemo(() => {\n    const out: { key: string; d: string; tip: string }[] = []\n    for (const r of rows) {\n      if (r.kind !== \"task\" || !r.task.dependsOn) continue\n      for (const dep of r.task.dependsOn) {\n        const from = byId.get(dep)\n        if (!from) continue\n        const x1 = x(addDays(from.e, 1))\n        const y1 = from.y + from.h / 2\n        const x2 = x(r.s)\n        const y2 = r.y + r.h / 2\n        const d =\n          x2 >= x1 + 14\n            ? `M${x1} ${y1}H${x1 + 7}V${y2}H${x2 - 4}`\n            : `M${x1} ${y1}H${x1 + 7}V${y1 + (y2 > y1 ? ROW_H / 2 : -ROW_H / 2)}H${x2 - 9}V${y2}H${x2 - 4}`\n        out.push({ key: `${dep}->${r.task.id}`, d, tip: `M${x2} ${y2}l-5 -3.5v7z` })\n      }\n    }\n    return out\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [rows, byId, dayW, start])\n\n  const hovered = hover ? byId.get(hover.id) : undefined\n  const show = (id: string, el: HTMLElement) => {\n    const root = rootRef.current?.getBoundingClientRect()\n    if (!root) return\n    const r = el.getBoundingClientRect()\n    const half = 104\n    const cx = Math.min(Math.max(r.left + r.width / 2 - root.left, half), Math.max(half, root.width - half))\n    setHover({ id, x: cx, y: r.top - root.top, el })\n  }\n  const rangeLabel = `${fmt(addDays(start, padDays))} to ${fmt(addDays(start, days - 1 - padDays))}`\n\n  return (\n    <div ref={rootRef} data-slot=\"gantt-chart\" className={cn(\"relative flex w-full max-w-4xl flex-col rounded-xl border border-border bg-surface text-fg shadow-raised\", className)}>\n      {title ? (\n        <div className=\"flex items-center justify-between gap-3 border-b border-border px-4 py-3\">\n          <div className=\"min-w-0\">\n            <p className=\"truncate text-[0.9375rem] font-medium text-fg\">{title}</p>\n            <p className=\"truncate text-xs text-fg-muted\">\n              {list.length} tasks, {groups.filter(Boolean).length || 1} {groups.filter(Boolean).length === 1 ? \"group\" : \"groups\"}, {rangeLabel}\n            </p>\n          </div>\n          <SegmentedControl\n            size=\"sm\"\n            aria-label=\"Timeline scale\"\n            options={[\n              { value: \"day\", label: \"Day\" },\n              { value: \"week\", label: \"Week\" },\n            ]}\n            value={scale}\n            onChange={(v) => setScale(v as GanttScale)}\n          />\n        </div>\n      ) : null}\n      <div className=\"flex rounded-b-xl\">\n        {/* Task column: fixed beside the timeline so bars never slide under it. */}\n        <div className=\"shrink-0 border-r border-border\" style={{ width: colW }}>\n          <div className=\"flex items-end border-b border-border px-3 pb-1.5\" style={{ height: HEAD_H * 2 }}>\n            <span className=\"font-mono text-[10px] font-medium tracking-[0.08em] text-fg-subtle uppercase\">Task</span>\n          </div>\n          <div className=\"relative\" style={{ height: bodyH }}>\n            {rows.map((r) =>\n              r.kind === \"group\" ? (\n                <div key={`group-${r.label}`} className=\"absolute right-0 left-0 flex items-center gap-2 bg-sunken/60 px-3\" style={{ top: r.y, height: r.h }}>\n                  <span aria-hidden className={cn(\"size-1.5 shrink-0 rounded-full\", TONE[r.tone].dot)} />\n                  <span className=\"truncate font-mono text-[10px] font-medium tracking-[0.08em] text-fg-muted uppercase\">{r.label}</span>\n                </div>\n              ) : (\n                <div key={r.task.id} className=\"absolute right-0 left-0 flex items-center gap-2 border-b border-border/60 px-3\" style={{ top: r.y, height: r.h }}>\n                  <span className=\"min-w-0 flex-1 truncate text-[0.8125rem] text-fg\">{r.task.name}</span>\n                  {!narrow ? <span className=\"shrink-0 font-mono text-[10px] text-fg-subtle tabular-nums\">{r.progress}%</span> : null}\n                </div>\n              )\n            )}\n          </div>\n        </div>\n        {/* Timeline */}\n        <div ref={scroller} onScroll={() => hover && show(hover.id, hover.el)} className=\"relative min-w-0 flex-1 overflow-x-auto overscroll-x-contain\">\n          <div className=\"relative\" style={{ width, minWidth: \"100%\" }}>\n            {/* Header */}\n            <div className=\"relative border-b border-border\" style={{ height: HEAD_H * 2 }}>\n              {months.map((m) => (\n                <div key={m.label} className=\"absolute top-0 flex items-center border-r border-border px-2 text-[11px] font-medium whitespace-nowrap text-fg-muted\" style={{ left: m.from * dayW, width: m.span * dayW, height: HEAD_H }}>\n                  {m.span * dayW >= 112 ? <span className=\"sticky left-2\">{m.label}</span> : null}\n                </div>\n              ))}\n              {units.map((u) => (\n                <div\n                  key={u.from}\n                  className={cn(\n                    \"absolute flex items-center border-r border-border font-mono text-[10px] text-fg-subtle tabular-nums\",\n                    scale === \"day\" ? \"justify-center\" : \"px-1.5\",\n                    u.weekend && \"bg-sunken\",\n                    scale === \"day\" && u.from === todayIdx && \"font-medium text-accent-fg\"\n                  )}\n                  style={{ left: u.from * dayW, width: u.span * dayW, top: HEAD_H, height: HEAD_H }}\n                >\n                  {u.label}\n                </div>\n              ))}\n            </div>\n            {/* Body */}\n            <div className=\"relative\" style={{ height: bodyH }}>\n              <div aria-hidden className=\"absolute inset-0\">\n                {units.map((u) => (\n                  <div key={u.from} className={cn(\"absolute top-0 bottom-0 border-r border-border/70\", u.weekend && \"bg-sunken/70\")} style={{ left: u.from * dayW, width: u.span * dayW }} />\n                ))}\n                {rows.map((r) => (r.kind === \"group\" ? <div key={`g${r.y}`} className=\"absolute right-0 left-0 bg-sunken/60\" style={{ top: r.y, height: r.h }} /> : null))}\n                {rows.map((r) => (r.kind === \"task\" ? <div key={`b${r.y}`} className=\"absolute right-0 left-0 border-b border-border/60\" style={{ top: r.y, height: r.h }} /> : null))}\n                {todayIdx >= 0 && todayIdx < days ? <div className=\"absolute top-0 bottom-0 bg-accent-soft\" style={{ left: todayIdx * dayW, width: dayW }} /> : null}\n              </div>\n              {rows.map((r) =>\n                r.kind === \"task\" ? (\n                  <button\n                    key={r.task.id}\n                    type=\"button\"\n                    aria-label={`${r.task.name}, ${fmt(r.s)} to ${fmt(r.e)}, ${dayDiff(r.s, r.e) + 1} days, ${r.progress}% done${r.task.group ? `, ${r.task.group}` : \"\"}`}\n                    onClick={() => onTaskClick?.(r.task)}\n                    onPointerEnter={(e) => show(r.task.id, e.currentTarget)}\n                    onPointerLeave={() => setHover(null)}\n                    onFocus={(e) => show(r.task.id, e.currentTarget)}\n                    onBlur={() => setHover(null)}\n                    data-slot=\"gantt-bar\"\n                    className={cn(\n                      \"absolute h-6 overflow-hidden rounded-md border text-left outline-none\",\n                      \"transition-[box-shadow,border-color] duration-[140ms] ease-hairline hover:border-border-strong focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface\",\n                      TONE[r.tone].bar\n                    )}\n                    style={{ left: x(r.s), top: r.y + 6, width: (dayDiff(r.s, r.e) + 1) * dayW }}\n                  >\n                    <span aria-hidden className={cn(\"absolute inset-y-0 left-0\", TONE[r.tone].fill)} style={{ width: `${r.progress}%` }} />\n                    {(dayDiff(r.s, r.e) + 1) * dayW >= 72 ? (\n                      <span aria-hidden className=\"relative block truncate px-2 text-[11px] leading-[22px] font-medium text-fg\">{r.task.name}</span>\n                    ) : null}\n                  </button>\n                ) : null\n              )}\n              {/* Dependencies and today line */}\n              <svg aria-hidden className=\"pointer-events-none absolute top-0 left-0 z-10 overflow-visible\" style={{ width, height: bodyH }}>\n                {arrows.map((a) => (\n                  <g key={a.key} className=\"text-fg-subtle\">\n                    <path d={a.d} fill=\"none\" stroke=\"currentColor\" strokeWidth={1} strokeOpacity={0.7} />\n                    <path d={a.tip} fill=\"currentColor\" fillOpacity={0.8} />\n                  </g>\n                ))}\n                {todayIdx >= 0 && todayIdx < days ? (\n                  <line x1={(todayIdx + 0.5) * dayW} x2={(todayIdx + 0.5) * dayW} y1={0} y2={bodyH} stroke=\"var(--accent)\" strokeWidth={1.5} strokeDasharray=\"3 3\" />\n                ) : null}\n              </svg>\n            </div>\n          </div>\n        </div>\n      </div>\n      {hovered ? (\n        <div\n          role=\"tooltip\"\n          data-slot=\"gantt-tooltip\"\n          className=\"pointer-events-none absolute z-30 w-52 -translate-x-1/2 -translate-y-full rounded-lg border border-border bg-raised p-2.5 text-xs shadow-overlay\"\n          style={{ left: hover!.x, top: hover!.y - 8 }}\n        >\n          <p className=\"truncate font-medium text-fg\">{hovered.task.name}</p>\n          <p className=\"mt-0.5 text-fg-muted tabular-nums\">\n            {fmt(hovered.s)} to {fmt(hovered.e)} <span className=\"text-fg-subtle\">({dayDiff(hovered.s, hovered.e) + 1} days)</span>\n          </p>\n          <div className=\"mt-2 flex items-center gap-2\">\n            <span className=\"h-1 flex-1 overflow-hidden rounded-full bg-sunken\">\n              <span className={cn(\"block h-full rounded-full\", TONE[hovered.tone].dot)} style={{ width: `${hovered.progress}%` }} />\n            </span>\n            <span className=\"font-mono text-[10px] text-fg-subtle tabular-nums\">{hovered.progress}%</span>\n          </div>\n          {hovered.task.group ? <p className=\"mt-1.5 text-[11px] text-fg-subtle\">{hovered.task.group}</p> : null}\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\nexport { GanttChart }\nexport type { GanttChartProps, GanttTask, GanttScale, GanttTone }\n"
    }
  ]
}
