{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gantt-chart",
  "type": "registry:component",
  "title": "Gantt Chart",
  "description": "Project timeline with task bars, progress overlays, milestones, and a today indicator across day/week/month/quarter scales.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/gantt-chart/gantt-chart.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  forwardRef,\n  type ReactNode,\n  useMemo,\n} from \"react\";\n\nimport { cn } from \"@vllnt/ui\";\n\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\nconst DEFAULT_LOCALE = \"en-US\";\n\n/**\n * Color theme for a {@link GanttTask}'s bar.\n *\n * @public\n */\nexport type GanttColor =\n  | \"amber\"\n  | \"blue\"\n  | \"emerald\"\n  | \"neutral\"\n  | \"purple\"\n  | \"red\"\n  | \"rose\";\n\nconst COLOR_CLASSES: Record<GanttColor, { bar: string; progress: string }> = {\n  amber: {\n    bar: \"bg-amber-500/30\",\n    progress: \"bg-amber-600 dark:bg-amber-500\",\n  },\n  blue: {\n    bar: \"bg-blue-500/30\",\n    progress: \"bg-blue-600 dark:bg-blue-500\",\n  },\n  emerald: {\n    bar: \"bg-emerald-500/30\",\n    progress: \"bg-emerald-600 dark:bg-emerald-500\",\n  },\n  neutral: {\n    bar: \"bg-muted\",\n    progress: \"bg-muted-foreground\",\n  },\n  purple: {\n    bar: \"bg-purple-500/30\",\n    progress: \"bg-purple-600 dark:bg-purple-500\",\n  },\n  red: {\n    bar: \"bg-red-500/30\",\n    progress: \"bg-red-600 dark:bg-red-500\",\n  },\n  rose: {\n    bar: \"bg-rose-500/30\",\n    progress: \"bg-rose-600 dark:bg-rose-500\",\n  },\n};\n\n/**\n * Time-axis scale.\n *\n * @public\n */\nexport type GanttScale = \"day\" | \"month\" | \"quarter\" | \"week\";\n\n/**\n * Localizable strings.\n *\n * @public\n */\nexport type GanttChartLabels = {\n  /** Aria-label prefix for milestone diamonds. Defaults to `\"Milestone\"`. */\n  milestone?: string;\n  /** Caption for the today line. Defaults to `\"Today\"`. */\n  today?: string;\n};\n\nconst DEFAULT_LABELS = {\n  milestone: \"Milestone\",\n  today: \"Today\",\n} as const satisfies Required<GanttChartLabels>;\n\n/**\n * One task bar inside a {@link GanttGroup}.\n *\n * @public\n */\nexport type GanttTask = {\n  /** Optional assignee label rendered next to the task title. */\n  assignee?: ReactNode;\n  /** Optional color theme. Defaults to `\"blue\"`. */\n  color?: GanttColor;\n  /** End date (inclusive). ISO string or `Date`. */\n  end: Date | string;\n  /** Stable identifier. */\n  id: string;\n  /** Optional progress percentage 0–100. */\n  progress?: number;\n  /** Start date (inclusive). ISO string or `Date`. */\n  start: Date | string;\n  /** Task title. */\n  title: ReactNode;\n};\n\n/**\n * Group of related {@link GanttTask}s.\n *\n * @public\n */\nexport type GanttGroup = {\n  /** Stable identifier. */\n  id: string;\n  /** Group display name. */\n  name: ReactNode;\n  /** Tasks rendered in this group. */\n  tasks: GanttTask[];\n};\n\n/**\n * Vertical milestone marker rendered on the timeline.\n *\n * @public\n */\nexport type GanttMilestone = {\n  /** Date the milestone falls on. */\n  date: Date | string;\n  /** Stable identifier. */\n  id: string;\n  /** Milestone title. */\n  title: ReactNode;\n};\n\n/**\n * Props for {@link GanttChart}.\n *\n * @public\n */\nexport type GanttChartProps = {\n  /** End of the visible time window. */\n  endDate: Date | string;\n  /** Task groups, rendered in order. */\n  groups: GanttGroup[];\n  /** Localizable strings. */\n  labels?: GanttChartLabels;\n  /** BCP-47 locale tag. Defaults to `\"en-US\"`. */\n  locale?: string;\n  /** Optional milestone markers. */\n  milestones?: GanttMilestone[];\n  /** Optional override for the \"today\" date. Defaults to the current date. */\n  now?: Date | string;\n  /** Time-axis scale. Drives the tick interval and label format. Defaults to `\"month\"`. */\n  scale?: GanttScale;\n  /** Start of the visible time window. */\n  startDate: Date | string;\n  /** Width allocated to the task name column (left side). Defaults to `200`. */\n  taskColumnWidth?: number;\n} & ComponentPropsWithoutRef<\"div\">;\n\nfunction toDate(value: Date | string): Date {\n  return value instanceof Date ? new Date(value.getTime()) : new Date(value);\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction diffInDays(later: Date, earlier: Date): number {\n  return (later.getTime() - earlier.getTime()) / MS_PER_DAY;\n}\n\nconst TICK_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();\nfunction getTickDateTimeFormatter(\n  locale: string,\n  scale: \"day\" | \"month\" | \"week\",\n): Intl.DateTimeFormat {\n  const key = `${locale}|${scale}`;\n  let formatter = TICK_FORMATTER_CACHE.get(key);\n  if (!formatter) {\n    const options: Intl.DateTimeFormatOptions =\n      scale === \"month\"\n        ? { month: \"short\", year: \"numeric\" }\n        : { day: \"2-digit\", month: \"short\" };\n    formatter = new Intl.DateTimeFormat(locale, options);\n    TICK_FORMATTER_CACHE.set(key, formatter);\n  }\n  return formatter;\n}\n\nfunction buildTickFormatter(\n  scale: GanttScale,\n  locale: string,\n): (date: Date) => string {\n  switch (scale) {\n    case \"day\":\n      return getTickDateTimeFormatter(locale, \"day\").format;\n    case \"week\":\n      return getTickDateTimeFormatter(locale, \"week\").format;\n    case \"month\":\n      return getTickDateTimeFormatter(locale, \"month\").format;\n    case \"quarter\":\n      return (date: Date) => {\n        const quarter = Math.floor(date.getMonth() / 3) + 1;\n        return `Q${quarter.toString()} ${date.getFullYear().toString()}`;\n      };\n  }\n}\n\nfunction getTickStep(scale: GanttScale): number {\n  switch (scale) {\n    case \"day\":\n      return 1;\n    case \"week\":\n      return 7;\n    case \"month\":\n      return 30;\n    case \"quarter\":\n      return 91;\n  }\n}\n\ntype ChartGeometry = {\n  end: Date;\n  pxPerDay: number;\n  start: Date;\n  ticks: { label: string; offset: number }[];\n  totalDays: number;\n};\n\ntype TicksInput = {\n  end: Date;\n  locale: string;\n  scale: GanttScale;\n  start: Date;\n  totalDays: number;\n};\n\nfunction buildTicks(input: TicksInput): { label: string; offset: number }[] {\n  const { end, locale, scale, start, totalDays } = input;\n  const formatter = buildTickFormatter(scale, locale);\n  const stepDays = getTickStep(scale);\n  const tickCount = Math.floor(totalDays / stepDays);\n  return Array.from({ length: tickCount + 1 })\n    .map((_, index) => {\n      const day = index * stepDays;\n      return {\n        date: new Date(start.getTime() + day * MS_PER_DAY),\n        offset: day,\n      };\n    })\n    .filter((tick) => tick.date.getTime() <= end.getTime())\n    .map((tick) => ({ label: formatter(tick.date), offset: tick.offset }));\n}\n\ntype GeometryOptions = {\n  endDate: Date | string;\n  locale: string;\n  scale: GanttScale;\n  startDate: Date | string;\n};\n\nfunction useChartGeometry(options: GeometryOptions): ChartGeometry {\n  const { endDate, locale, scale, startDate } = options;\n  return useMemo<ChartGeometry>(() => {\n    const start = toDate(startDate);\n    const end = toDate(endDate);\n    const totalDays = Math.max(1, diffInDays(end, start));\n    const ticks = buildTicks({ end, locale, scale, start, totalDays });\n    return {\n      end,\n      pxPerDay: 1 / totalDays,\n      start,\n      ticks,\n      totalDays,\n    };\n  }, [endDate, locale, scale, startDate]);\n}\n\ntype TaskBarProps = {\n  geometry: ChartGeometry;\n  task: GanttTask;\n};\n\nfunction TaskBar({ geometry, task }: TaskBarProps): ReactNode {\n  const start = toDate(task.start);\n  const end = toDate(task.end);\n  const offsetDays = diffInDays(start, geometry.start);\n  const durationDays = Math.max(0.5, diffInDays(end, start));\n  const leftRatio = clamp(offsetDays / geometry.totalDays, 0, 1);\n  const widthRatio = clamp(durationDays / geometry.totalDays, 0, 1 - leftRatio);\n  const palette = COLOR_CLASSES[task.color ?? \"blue\"];\n  const progress = clamp(task.progress ?? 0, 0, 100);\n  const ariaLabel =\n    typeof task.title === \"string\"\n      ? `${task.title} from ${start.toISOString().slice(0, 10)} to ${end.toISOString().slice(0, 10)}, ${progress.toString()} percent complete`\n      : undefined;\n  return (\n    <div\n      aria-label={ariaLabel}\n      aria-valuemax={100}\n      aria-valuemin={0}\n      aria-valuenow={progress}\n      className={cn(\n        \"absolute top-1.5 flex h-5 items-center overflow-hidden rounded-md ring-1 ring-border\",\n        palette.bar,\n      )}\n      data-task-id={task.id}\n      role=\"progressbar\"\n      style={{\n        left: `${(leftRatio * 100).toString()}%`,\n        width: `${(widthRatio * 100).toString()}%`,\n      }}\n    >\n      <span\n        aria-hidden=\"true\"\n        className={cn(\"h-full rounded-md\", palette.progress)}\n        style={{ width: `${progress.toString()}%` }}\n      />\n    </div>\n  );\n}\n\ntype MilestoneMarkerProps = {\n  geometry: ChartGeometry;\n  label: string;\n  milestone: GanttMilestone;\n};\n\nfunction MilestoneMarker({\n  geometry,\n  label,\n  milestone,\n}: MilestoneMarkerProps): ReactNode {\n  const date = toDate(milestone.date);\n  const offsetDays = diffInDays(date, geometry.start);\n  if (offsetDays < 0 || offsetDays > geometry.totalDays) return null;\n  const leftRatio = offsetDays / geometry.totalDays;\n  const titleText = typeof milestone.title === \"string\" ? milestone.title : \"\";\n  return (\n    <div\n      aria-label={`${label}: ${titleText}`}\n      className=\"absolute top-0 z-10 -ml-1.5 flex flex-col items-center\"\n      data-milestone-id={milestone.id}\n      style={{ left: `${(leftRatio * 100).toString()}%` }}\n    >\n      <div\n        aria-hidden=\"true\"\n        className=\"size-3 rotate-45 bg-amber-500 ring-2 ring-background\"\n      />\n      {titleText ? (\n        <span className=\"mt-0.5 whitespace-nowrap rounded bg-amber-500/20 px-1 text-[10px] font-medium text-amber-900 dark:text-amber-200\">\n          {titleText}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n\ntype TodayLineProps = {\n  geometry: ChartGeometry;\n  label: string;\n  now: Date;\n};\n\nfunction TodayLine({ geometry, label, now }: TodayLineProps): ReactNode {\n  const offsetDays = diffInDays(now, geometry.start);\n  if (offsetDays < 0 || offsetDays > geometry.totalDays) return null;\n  const leftRatio = offsetDays / geometry.totalDays;\n  return (\n    <div\n      aria-label={label}\n      className=\"pointer-events-none absolute inset-y-0 z-10\"\n      style={{ left: `${(leftRatio * 100).toString()}%` }}\n    >\n      <div\n        aria-hidden=\"true\"\n        className=\"absolute inset-y-0 w-0.5 -translate-x-1/2 bg-destructive\"\n      />\n      <span className=\"absolute -top-5 -translate-x-1/2 whitespace-nowrap rounded bg-destructive/15 px-1 text-[10px] font-semibold uppercase tracking-wide text-destructive\">\n        {label}\n      </span>\n    </div>\n  );\n}\n\ntype AxisProps = {\n  geometry: ChartGeometry;\n};\n\nfunction Axis({ geometry }: AxisProps): ReactNode {\n  return (\n    <div className=\"relative flex h-7 items-end border-b border-border text-[10px] font-medium uppercase tracking-wide text-muted-foreground\">\n      {geometry.ticks.map((tick) => (\n        <span\n          className=\"absolute -translate-x-1/2 px-1\"\n          key={tick.offset}\n          style={{\n            left: `${((tick.offset / geometry.totalDays) * 100).toString()}%`,\n          }}\n        >\n          {tick.label}\n        </span>\n      ))}\n    </div>\n  );\n}\n\ntype TaskRowProps = {\n  geometry: ChartGeometry;\n  task: GanttTask;\n};\n\nfunction TaskRow({ geometry, task }: TaskRowProps): ReactNode {\n  return (\n    <div className=\"relative flex h-8 items-center border-b border-border/40\">\n      <TaskBar geometry={geometry} task={task} />\n    </div>\n  );\n}\n\ntype LeftColumnProps = {\n  groups: GanttGroup[];\n  taskColumnWidth: number;\n};\n\nfunction LeftColumn({ groups, taskColumnWidth }: LeftColumnProps): ReactNode {\n  return (\n    <div\n      className=\"flex flex-col border-r border-border bg-background\"\n      style={{\n        minWidth: `${taskColumnWidth.toString()}px`,\n        width: `${taskColumnWidth.toString()}px`,\n      }}\n    >\n      <div className=\"h-7 border-b border-border\" />\n      {groups.map((group) => (\n        <div className=\"flex flex-col\" key={group.id}>\n          <div className=\"flex h-7 items-center border-b border-border/60 px-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n            {group.name}\n          </div>\n          {group.tasks.map((task) => (\n            <div\n              className=\"flex h-8 items-center justify-between gap-2 border-b border-border/40 px-3 text-sm text-foreground\"\n              key={task.id}\n            >\n              <span className=\"truncate\">{task.title}</span>\n              {task.assignee ? (\n                <span className=\"text-xs text-muted-foreground\">\n                  {task.assignee}\n                </span>\n              ) : null}\n            </div>\n          ))}\n        </div>\n      ))}\n    </div>\n  );\n}\n\ntype TimelineColumnProps = {\n  geometry: ChartGeometry;\n  groups: GanttGroup[];\n  labels: Required<GanttChartLabels>;\n  milestones: GanttMilestone[];\n  now: Date;\n};\n\nfunction TimelineColumn({\n  geometry,\n  groups,\n  labels,\n  milestones,\n  now,\n}: TimelineColumnProps): ReactNode {\n  return (\n    <div className=\"relative min-w-0 flex-1\">\n      <Axis geometry={geometry} />\n      <div className=\"relative\">\n        {groups.map((group) => (\n          <div className=\"flex flex-col\" key={group.id}>\n            <div className=\"h-7 border-b border-border/60 bg-muted/20\" />\n            {group.tasks.map((task) => (\n              <TaskRow geometry={geometry} key={task.id} task={task} />\n            ))}\n          </div>\n        ))}\n        {milestones.map((milestone) => (\n          <MilestoneMarker\n            geometry={geometry}\n            key={milestone.id}\n            label={labels.milestone}\n            milestone={milestone}\n          />\n        ))}\n        <TodayLine geometry={geometry} label={labels.today} now={now} />\n      </div>\n    </div>\n  );\n}\n\n/**\n * Pure-SVG-free Gantt chart for project planning. Renders task bars with\n * progress overlays, milestone diamonds, and a today indicator across a\n * configurable time scale (day / week / month / quarter). The left column\n * shows group headers and task names; the right column is the timeline.\n *\n * Drag-to-edit, dependency arrows, critical-path highlighting, and\n * virtualization for large datasets are intentionally **out of scope** —\n * the consumer drives those externally and feeds data through `groups`.\n *\n * @example\n * ```tsx\n * <GanttChart\n *   startDate=\"2026-01-01\"\n *   endDate=\"2026-12-31\"\n *   scale=\"month\"\n *   groups={[\n *     {\n *       id: \"phase-1\",\n *       name: \"Phase 1\",\n *       tasks: [\n *         { id: \"design\", title: \"Design system\", start: \"2026-01-15\", end: \"2026-02-28\", progress: 100, color: \"blue\" },\n *         { id: \"core\", title: \"Core components\", start: \"2026-02-01\", end: \"2026-04-15\", progress: 65, color: \"emerald\" },\n *       ],\n *     },\n *   ]}\n *   milestones={[{ id: \"v1\", date: \"2026-04-15\", title: \"v1.0\" }]}\n * />\n * ```\n *\n * @public\n */\nexport const GanttChart = forwardRef<HTMLDivElement, GanttChartProps>(\n  (props, ref) => {\n    const {\n      className,\n      endDate,\n      groups,\n      labels,\n      locale = DEFAULT_LOCALE,\n      milestones = [],\n      now,\n      scale = \"month\",\n      startDate,\n      taskColumnWidth = 200,\n      ...rest\n    } = props;\n    const resolvedLabels = useMemo(\n      () => ({ ...DEFAULT_LABELS, ...labels }),\n      [labels],\n    );\n    const geometry = useChartGeometry({ endDate, locale, scale, startDate });\n    const nowDate = useMemo(() => (now ? toDate(now) : new Date()), [now]);\n\n    return (\n      <div\n        className={cn(\n          \"flex w-full overflow-x-auto rounded-2xl border bg-background text-foreground\",\n          className,\n        )}\n        ref={ref}\n        {...rest}\n      >\n        <LeftColumn groups={groups} taskColumnWidth={taskColumnWidth} />\n        <TimelineColumn\n          geometry={geometry}\n          groups={groups}\n          labels={resolvedLabels}\n          milestones={milestones}\n          now={nowDate}\n        />\n      </div>\n    );\n  },\n);\nGanttChart.displayName = \"GanttChart\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
