{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"gantt","type":"registry:ui","title":"gantt","description":"The Gantt chart is a powerful tool for visualizing project schedules and tracking the progress of tasks. It provides a clear, hierarchical view of tasks, allowing you to easily identify manage project timelines.","author":"Hayden Bleasel <hello@haydenbleasel.com>","dependencies":["@dnd-kit/core","@dnd-kit/modifiers","@uidotdev/usehooks","date-fns","jotai","lodash.throttle","lucide-react"],"devDependencies":["@types/lodash.throttle"],"registryDependencies":["card","context-menu"],"files":[{"type":"registry:ui","path":"index.tsx","content":"\"use client\";\n\nimport {\n  DndContext,\n  MouseSensor,\n  useDraggable,\n  useSensor,\n} from \"@dnd-kit/core\";\nimport { restrictToHorizontalAxis } from \"@dnd-kit/modifiers\";\nimport { useMouse, useThrottle, useWindowScroll } from \"@uidotdev/usehooks\";\nimport {\n  addDays,\n  addMonths,\n  differenceInDays,\n  differenceInHours,\n  differenceInMonths,\n  endOfDay,\n  endOfMonth,\n  format,\n  formatDate,\n  formatDistance,\n  getDate,\n  getDaysInMonth,\n  isSameDay,\n  startOfDay,\n  startOfMonth,\n} from \"date-fns\";\nimport { atom, useAtom } from \"jotai\";\nimport throttle from \"lodash.throttle\";\nimport { PlusIcon, TrashIcon } from \"lucide-react\";\nimport type {\n  CSSProperties,\n  FC,\n  KeyboardEventHandler,\n  MouseEventHandler,\n  ReactNode,\n  RefObject,\n} from \"react\";\nimport {\n  createContext,\n  memo,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Card } from \"@/components/ui/card\";\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuTrigger,\n} from \"@/components/ui/context-menu\";\nimport { cn } from \"@/lib/utils\";\n\nconst draggingAtom = atom(false);\nconst scrollXAtom = atom(0);\n\nexport const useGanttDragging = () => useAtom(draggingAtom);\nexport const useGanttScrollX = () => useAtom(scrollXAtom);\n\nexport type GanttStatus = {\n  id: string;\n  name: string;\n  color: string;\n};\n\nexport type GanttFeature = {\n  id: string;\n  name: string;\n  startAt: Date;\n  endAt: Date;\n  status: GanttStatus;\n  lane?: string; // Optional: features with the same lane will share a row\n};\n\nexport type GanttMarkerProps = {\n  id: string;\n  date: Date;\n  label: string;\n};\n\nexport type Range = \"daily\" | \"monthly\" | \"quarterly\";\n\nexport type TimelineData = {\n  year: number;\n  quarters: {\n    months: {\n      days: number;\n    }[];\n  }[];\n}[];\n\nexport type GanttContextProps = {\n  zoom: number;\n  range: Range;\n  columnWidth: number;\n  sidebarWidth: number;\n  headerHeight: number;\n  rowHeight: number;\n  onAddItem: ((date: Date) => void) | undefined;\n  placeholderLength: number;\n  timelineData: TimelineData;\n  ref: RefObject<HTMLDivElement | null> | null;\n  scrollToFeature?: (feature: GanttFeature) => void;\n};\n\nconst getsDaysIn = (range: Range) => {\n  // For when range is daily\n  let fn = (_date: Date) => 1;\n\n  if (range === \"monthly\" || range === \"quarterly\") {\n    fn = getDaysInMonth;\n  }\n\n  return fn;\n};\n\nconst getDifferenceIn = (range: Range) => {\n  let fn = differenceInDays;\n\n  if (range === \"monthly\" || range === \"quarterly\") {\n    fn = differenceInMonths;\n  }\n\n  return fn;\n};\n\nconst getInnerDifferenceIn = (range: Range) => {\n  let fn = differenceInHours;\n\n  if (range === \"monthly\" || range === \"quarterly\") {\n    fn = differenceInDays;\n  }\n\n  return fn;\n};\n\nconst getStartOf = (range: Range) => {\n  let fn = startOfDay;\n\n  if (range === \"monthly\" || range === \"quarterly\") {\n    fn = startOfMonth;\n  }\n\n  return fn;\n};\n\nconst getEndOf = (range: Range) => {\n  let fn = endOfDay;\n\n  if (range === \"monthly\" || range === \"quarterly\") {\n    fn = endOfMonth;\n  }\n\n  return fn;\n};\n\nconst getAddRange = (range: Range) => {\n  let fn = addDays;\n\n  if (range === \"monthly\" || range === \"quarterly\") {\n    fn = addMonths;\n  }\n\n  return fn;\n};\n\nconst getDateByMousePosition = (context: GanttContextProps, mouseX: number) => {\n  const timelineStartDate = new Date(context.timelineData[0].year, 0, 1);\n  const columnWidth = (context.columnWidth * context.zoom) / 100;\n  const offset = Math.floor(mouseX / columnWidth);\n  const daysIn = getsDaysIn(context.range);\n  const addRange = getAddRange(context.range);\n  const month = addRange(timelineStartDate, offset);\n  const daysInMonth = daysIn(month);\n  const pixelsPerDay = Math.round(columnWidth / daysInMonth);\n  const dayOffset = Math.floor((mouseX % columnWidth) / pixelsPerDay);\n  const actualDate = addDays(month, dayOffset);\n\n  return actualDate;\n};\n\nconst createInitialTimelineData = (today: Date) => {\n  const data: TimelineData = [];\n\n  data.push(\n    { year: today.getFullYear() - 1, quarters: new Array(4).fill(null) },\n    { year: today.getFullYear(), quarters: new Array(4).fill(null) },\n    { year: today.getFullYear() + 1, quarters: new Array(4).fill(null) }\n  );\n\n  for (const yearObj of data) {\n    yearObj.quarters = new Array(4).fill(null).map((_, quarterIndex) => ({\n      months: new Array(3).fill(null).map((_, monthIndex) => {\n        const month = quarterIndex * 3 + monthIndex;\n        return {\n          days: getDaysInMonth(new Date(yearObj.year, month, 1)),\n        };\n      }),\n    }));\n  }\n\n  return data;\n};\n\nconst getOffset = (\n  date: Date,\n  timelineStartDate: Date,\n  context: GanttContextProps\n) => {\n  const parsedColumnWidth = (context.columnWidth * context.zoom) / 100;\n  const differenceIn = getDifferenceIn(context.range);\n  const startOf = getStartOf(context.range);\n  const fullColumns = differenceIn(startOf(date), timelineStartDate);\n\n  if (context.range === \"daily\") {\n    return parsedColumnWidth * fullColumns;\n  }\n\n  const partialColumns = date.getDate();\n  const daysInMonth = getDaysInMonth(date);\n  const pixelsPerDay = parsedColumnWidth / daysInMonth;\n\n  return fullColumns * parsedColumnWidth + partialColumns * pixelsPerDay;\n};\n\nconst getWidth = (\n  startAt: Date,\n  endAt: Date | null,\n  context: GanttContextProps\n) => {\n  const parsedColumnWidth = (context.columnWidth * context.zoom) / 100;\n\n  if (!endAt) {\n    return parsedColumnWidth * 2;\n  }\n\n  const differenceIn = getDifferenceIn(context.range);\n\n  if (context.range === \"daily\") {\n    const delta = differenceIn(endAt, startAt);\n\n    return parsedColumnWidth * (delta ? delta : 1);\n  }\n\n  const daysInStartMonth = getDaysInMonth(startAt);\n  const pixelsPerDayInStartMonth = parsedColumnWidth / daysInStartMonth;\n\n  if (isSameDay(startAt, endAt)) {\n    return pixelsPerDayInStartMonth;\n  }\n\n  const innerDifferenceIn = getInnerDifferenceIn(context.range);\n  const startOf = getStartOf(context.range);\n\n  if (isSameDay(startOf(startAt), startOf(endAt))) {\n    return innerDifferenceIn(endAt, startAt) * pixelsPerDayInStartMonth;\n  }\n\n  const startRangeOffset = daysInStartMonth - getDate(startAt);\n  const endRangeOffset = getDate(endAt);\n  const fullRangeOffset = differenceIn(startOf(endAt), startOf(startAt));\n  const daysInEndMonth = getDaysInMonth(endAt);\n  const pixelsPerDayInEndMonth = parsedColumnWidth / daysInEndMonth;\n\n  return (\n    (fullRangeOffset - 1) * parsedColumnWidth +\n    startRangeOffset * pixelsPerDayInStartMonth +\n    endRangeOffset * pixelsPerDayInEndMonth\n  );\n};\n\nconst calculateInnerOffset = (\n  date: Date,\n  range: Range,\n  columnWidth: number\n) => {\n  const startOf = getStartOf(range);\n  const endOf = getEndOf(range);\n  const differenceIn = getInnerDifferenceIn(range);\n  const startOfRange = startOf(date);\n  const endOfRange = endOf(date);\n  const totalRangeDays = differenceIn(endOfRange, startOfRange);\n  const dayOfMonth = date.getDate();\n\n  return (dayOfMonth / totalRangeDays) * columnWidth;\n};\n\nconst GanttContext = createContext<GanttContextProps>({\n  zoom: 100,\n  range: \"monthly\",\n  columnWidth: 50,\n  headerHeight: 60,\n  sidebarWidth: 300,\n  rowHeight: 36,\n  onAddItem: undefined,\n  placeholderLength: 2,\n  timelineData: [],\n  ref: null,\n  scrollToFeature: undefined,\n});\n\nexport type GanttContentHeaderProps = {\n  renderHeaderItem: (index: number) => ReactNode;\n  title: string;\n  columns: number;\n};\n\nexport const GanttContentHeader: FC<GanttContentHeaderProps> = ({\n  title,\n  columns,\n  renderHeaderItem,\n}) => {\n  const id = useId();\n\n  return (\n    <div\n      className=\"sticky top-0 z-20 grid w-full shrink-0 bg-backdrop/90 backdrop-blur-sm\"\n      style={{ height: \"var(--gantt-header-height)\" }}\n    >\n      <div>\n        <div\n          className=\"sticky inline-flex whitespace-nowrap px-3 py-2 text-muted-foreground text-xs\"\n          style={{\n            left: \"var(--gantt-sidebar-width)\",\n          }}\n        >\n          <p>{title}</p>\n        </div>\n      </div>\n      <div\n        className=\"grid w-full\"\n        style={{\n          gridTemplateColumns: `repeat(${columns}, var(--gantt-column-width))`,\n        }}\n      >\n        {Array.from({ length: columns }).map((_, index) => (\n          <div\n            className=\"shrink-0 border-border/50 border-b py-1 text-center text-xs\"\n            key={`${id}-${index}`}\n          >\n            {renderHeaderItem(index)}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n};\n\nconst DailyHeader: FC = () => {\n  const gantt = useContext(GanttContext);\n\n  return gantt.timelineData.map((year) =>\n    year.quarters\n      .flatMap((quarter) => quarter.months)\n      .map((month, index) => (\n        <div className=\"relative flex flex-col\" key={`${year.year}-${index}`}>\n          <GanttContentHeader\n            columns={month.days}\n            renderHeaderItem={(item: number) => (\n              <div className=\"flex items-center justify-center gap-1\">\n                <p>\n                  {format(addDays(new Date(year.year, index, 1), item), \"d\")}\n                </p>\n                <p className=\"text-muted-foreground\">\n                  {format(\n                    addDays(new Date(year.year, index, 1), item),\n                    \"EEEEE\"\n                  )}\n                </p>\n              </div>\n            )}\n            title={format(new Date(year.year, index, 1), \"MMMM yyyy\")}\n          />\n          <GanttColumns\n            columns={month.days}\n            isColumnSecondary={(item: number) =>\n              [0, 6].includes(\n                addDays(new Date(year.year, index, 1), item).getDay()\n              )\n            }\n          />\n        </div>\n      ))\n  );\n};\n\nconst MonthlyHeader: FC = () => {\n  const gantt = useContext(GanttContext);\n\n  return gantt.timelineData.map((year) => (\n    <div className=\"relative flex flex-col\" key={year.year}>\n      <GanttContentHeader\n        columns={year.quarters.flatMap((quarter) => quarter.months).length}\n        renderHeaderItem={(item: number) => (\n          <p>{format(new Date(year.year, item, 1), \"MMM\")}</p>\n        )}\n        title={`${year.year}`}\n      />\n      <GanttColumns\n        columns={year.quarters.flatMap((quarter) => quarter.months).length}\n      />\n    </div>\n  ));\n};\n\nconst QuarterlyHeader: FC = () => {\n  const gantt = useContext(GanttContext);\n\n  return gantt.timelineData.map((year) =>\n    year.quarters.map((quarter, quarterIndex) => (\n      <div\n        className=\"relative flex flex-col\"\n        key={`${year.year}-${quarterIndex}`}\n      >\n        <GanttContentHeader\n          columns={quarter.months.length}\n          renderHeaderItem={(item: number) => (\n            <p>\n              {format(new Date(year.year, quarterIndex * 3 + item, 1), \"MMM\")}\n            </p>\n          )}\n          title={`Q${quarterIndex + 1} ${year.year}`}\n        />\n        <GanttColumns columns={quarter.months.length} />\n      </div>\n    ))\n  );\n};\n\nconst headers: Record<Range, FC> = {\n  daily: DailyHeader,\n  monthly: MonthlyHeader,\n  quarterly: QuarterlyHeader,\n};\n\nexport type GanttHeaderProps = {\n  className?: string;\n};\n\nexport const GanttHeader: FC<GanttHeaderProps> = ({ className }) => {\n  const gantt = useContext(GanttContext);\n  const Header = headers[gantt.range];\n\n  return (\n    <div\n      className={cn(\n        \"-space-x-px flex h-full w-max divide-x divide-border/50\",\n        className\n      )}\n    >\n      <Header />\n    </div>\n  );\n};\n\nexport type GanttSidebarItemProps = {\n  feature: GanttFeature;\n  onSelectItem?: (id: string) => void;\n  className?: string;\n};\n\nexport const GanttSidebarItem: FC<GanttSidebarItemProps> = ({\n  feature,\n  onSelectItem,\n  className,\n}) => {\n  const gantt = useContext(GanttContext);\n  const tempEndAt =\n    feature.endAt && isSameDay(feature.startAt, feature.endAt)\n      ? addDays(feature.endAt, 1)\n      : feature.endAt;\n  const duration = tempEndAt\n    ? formatDistance(feature.startAt, tempEndAt)\n    : `${formatDistance(feature.startAt, new Date())} so far`;\n\n  const handleClick: MouseEventHandler<HTMLDivElement> = (event) => {\n    if (event.target === event.currentTarget) {\n      // Scroll to the feature in the timeline\n      gantt.scrollToFeature?.(feature);\n      // Call the original onSelectItem callback\n      onSelectItem?.(feature.id);\n    }\n  };\n\n  const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {\n    if (event.key === \"Enter\") {\n      // Scroll to the feature in the timeline\n      gantt.scrollToFeature?.(feature);\n      // Call the original onSelectItem callback\n      onSelectItem?.(feature.id);\n    }\n  };\n\n  return (\n    <div\n      className={cn(\n        \"relative flex items-center gap-2.5 p-2.5 text-xs hover:bg-secondary\",\n        className\n      )}\n      key={feature.id}\n      onClick={handleClick}\n      onKeyDown={handleKeyDown}\n      // biome-ignore lint/a11y/useSemanticElements: \"This is a clickable item\"\n      role=\"button\"\n      style={{\n        height: \"var(--gantt-row-height)\",\n      }}\n      tabIndex={0}\n    >\n      {/* <Checkbox onCheckedChange={handleCheck} className=\"shrink-0\" /> */}\n      <div\n        className=\"pointer-events-none h-2 w-2 shrink-0 rounded-full\"\n        style={{\n          backgroundColor: feature.status.color,\n        }}\n      />\n      <p className=\"pointer-events-none flex-1 truncate text-left font-medium\">\n        {feature.name}\n      </p>\n      <p className=\"pointer-events-none text-muted-foreground\">{duration}</p>\n    </div>\n  );\n};\n\nexport const GanttSidebarHeader: FC = () => (\n  <div\n    className=\"sticky top-0 z-10 flex shrink-0 items-end justify-between gap-2.5 border-border/50 border-b bg-backdrop/90 p-2.5 font-medium text-muted-foreground text-xs backdrop-blur-sm\"\n    style={{ height: \"var(--gantt-header-height)\" }}\n  >\n    {/* <Checkbox className=\"shrink-0\" /> */}\n    <p className=\"flex-1 truncate text-left\">Issues</p>\n    <p className=\"shrink-0\">Duration</p>\n  </div>\n);\n\nexport type GanttSidebarGroupProps = {\n  children: ReactNode;\n  name: string;\n  className?: string;\n};\n\nexport const GanttSidebarGroup: FC<GanttSidebarGroupProps> = ({\n  children,\n  name,\n  className,\n}) => (\n  <div className={className}>\n    <p\n      className=\"w-full truncate p-2.5 text-left font-medium text-muted-foreground text-xs\"\n      style={{ height: \"var(--gantt-row-height)\" }}\n    >\n      {name}\n    </p>\n    <div className=\"divide-y divide-border/50\">{children}</div>\n  </div>\n);\n\nexport type GanttSidebarProps = {\n  children: ReactNode;\n  className?: string;\n};\n\nexport const GanttSidebar: FC<GanttSidebarProps> = ({\n  children,\n  className,\n}) => (\n  <div\n    className={cn(\n      \"sticky left-0 z-30 h-max min-h-full overflow-clip border-border/50 border-r bg-background/90 backdrop-blur-md\",\n      className\n    )}\n    data-roadmap-ui=\"gantt-sidebar\"\n  >\n    <GanttSidebarHeader />\n    <div className=\"space-y-4\">{children}</div>\n  </div>\n);\n\nexport type GanttAddFeatureHelperProps = {\n  top: number;\n  className?: string;\n};\n\nexport const GanttAddFeatureHelper: FC<GanttAddFeatureHelperProps> = ({\n  top,\n  className,\n}) => {\n  const [scrollX] = useGanttScrollX();\n  const gantt = useContext(GanttContext);\n  const [mousePosition, mouseRef] = useMouse<HTMLDivElement>();\n\n  const handleClick = () => {\n    const ganttRect = gantt.ref?.current?.getBoundingClientRect();\n    const x =\n      mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth;\n    const currentDate = getDateByMousePosition(gantt, x);\n\n    gantt.onAddItem?.(currentDate);\n  };\n\n  return (\n    <div\n      className={cn(\"absolute top-0 w-full px-0.5\", className)}\n      ref={mouseRef}\n      style={{\n        marginTop: -gantt.rowHeight / 2,\n        transform: `translateY(${top}px)`,\n      }}\n    >\n      <button\n        className=\"flex h-full w-full items-center justify-center rounded-md border border-dashed p-2\"\n        onClick={handleClick}\n        type=\"button\"\n      >\n        <PlusIcon\n          className=\"pointer-events-none select-none text-muted-foreground\"\n          size={16}\n        />\n      </button>\n    </div>\n  );\n};\n\nexport type GanttColumnProps = {\n  index: number;\n  isColumnSecondary?: (item: number) => boolean;\n};\n\nexport const GanttColumn: FC<GanttColumnProps> = ({\n  index,\n  isColumnSecondary,\n}) => {\n  const gantt = useContext(GanttContext);\n  const [dragging] = useGanttDragging();\n  const [mousePosition, mouseRef] = useMouse<HTMLDivElement>();\n  const [hovering, setHovering] = useState(false);\n  const [windowScroll] = useWindowScroll();\n\n  const handleMouseEnter = () => setHovering(true);\n  const handleMouseLeave = () => setHovering(false);\n\n  const top = useThrottle(\n    mousePosition.y -\n      (mouseRef.current?.getBoundingClientRect().y ?? 0) -\n      (windowScroll.y ?? 0),\n    10\n  );\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: \"This is a clickable column\"\n    // biome-ignore lint/nursery/noNoninteractiveElementInteractions: \"This is a clickable column\"\n    <div\n      className={cn(\n        \"group relative h-full overflow-hidden\",\n        isColumnSecondary?.(index) ? \"bg-secondary\" : \"\"\n      )}\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n      ref={mouseRef}\n    >\n      {!dragging && hovering && gantt.onAddItem ? (\n        <GanttAddFeatureHelper top={top} />\n      ) : null}\n    </div>\n  );\n};\n\nexport type GanttColumnsProps = {\n  columns: number;\n  isColumnSecondary?: (item: number) => boolean;\n};\n\nexport const GanttColumns: FC<GanttColumnsProps> = ({\n  columns,\n  isColumnSecondary,\n}) => {\n  const id = useId();\n\n  return (\n    <div\n      className=\"divide grid h-full w-full divide-x divide-border/50\"\n      style={{\n        gridTemplateColumns: `repeat(${columns}, var(--gantt-column-width))`,\n      }}\n    >\n      {Array.from({ length: columns }).map((_, index) => (\n        <GanttColumn\n          index={index}\n          isColumnSecondary={isColumnSecondary}\n          key={`${id}-${index}`}\n        />\n      ))}\n    </div>\n  );\n};\n\nexport type GanttCreateMarkerTriggerProps = {\n  onCreateMarker: (date: Date) => void;\n  className?: string;\n};\n\nexport const GanttCreateMarkerTrigger: FC<GanttCreateMarkerTriggerProps> = ({\n  onCreateMarker,\n  className,\n}) => {\n  const gantt = useContext(GanttContext);\n  const [mousePosition, mouseRef] = useMouse<HTMLDivElement>();\n  const [windowScroll] = useWindowScroll();\n  const x = useThrottle(\n    mousePosition.x -\n      (mouseRef.current?.getBoundingClientRect().x ?? 0) -\n      (windowScroll.x ?? 0),\n    10\n  );\n\n  const date = getDateByMousePosition(gantt, x);\n\n  const handleClick = () => onCreateMarker(date);\n\n  return (\n    <div\n      className={cn(\n        \"group pointer-events-none absolute top-0 left-0 h-full w-full select-none overflow-visible\",\n        className\n      )}\n      ref={mouseRef}\n    >\n      <div\n        className=\"-ml-2 pointer-events-auto sticky top-6 z-20 flex w-4 flex-col items-center justify-center gap-1 overflow-visible opacity-0 group-hover:opacity-100\"\n        style={{ transform: `translateX(${x}px)` }}\n      >\n        <button\n          className=\"z-50 inline-flex h-4 w-4 items-center justify-center rounded-full bg-card\"\n          onClick={handleClick}\n          type=\"button\"\n        >\n          <PlusIcon className=\"text-muted-foreground\" size={12} />\n        </button>\n        <div className=\"whitespace-nowrap rounded-full border border-border/50 bg-background/90 px-2 py-1 text-foreground text-xs backdrop-blur-lg\">\n          {formatDate(date, \"MMM dd, yyyy\")}\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport type GanttFeatureDragHelperProps = {\n  featureId: GanttFeature[\"id\"];\n  direction: \"left\" | \"right\";\n  date: Date | null;\n};\n\nexport const GanttFeatureDragHelper: FC<GanttFeatureDragHelperProps> = ({\n  direction,\n  featureId,\n  date,\n}) => {\n  const [, setDragging] = useGanttDragging();\n  const { attributes, listeners, setNodeRef } = useDraggable({\n    id: `feature-drag-helper-${featureId}`,\n  });\n\n  const isPressed = Boolean(attributes[\"aria-pressed\"]);\n\n  useEffect(() => setDragging(isPressed), [isPressed, setDragging]);\n\n  return (\n    <div\n      className={cn(\n        \"group -translate-y-1/2 !cursor-col-resize absolute top-1/2 z-[3] h-full w-6 rounded-md outline-none\",\n        direction === \"left\" ? \"-left-2.5\" : \"-right-2.5\"\n      )}\n      ref={setNodeRef}\n      {...attributes}\n      {...listeners}\n    >\n      <div\n        className={cn(\n          \"-translate-y-1/2 absolute top-1/2 h-[80%] w-1 rounded-sm bg-muted-foreground opacity-0 transition-all\",\n          direction === \"left\" ? \"left-2.5\" : \"right-2.5\",\n          direction === \"left\" ? \"group-hover:left-0\" : \"group-hover:right-0\",\n          isPressed && (direction === \"left\" ? \"left-0\" : \"right-0\"),\n          \"group-hover:opacity-100\",\n          isPressed && \"opacity-100\"\n        )}\n      />\n      {date && (\n        <div\n          className={cn(\n            \"-translate-x-1/2 absolute top-10 hidden whitespace-nowrap rounded-lg border border-border/50 bg-background/90 px-2 py-1 text-foreground text-xs backdrop-blur-lg group-hover:block\",\n            isPressed && \"block\"\n          )}\n        >\n          {format(date, \"MMM dd, yyyy\")}\n        </div>\n      )}\n    </div>\n  );\n};\n\nexport type GanttFeatureItemCardProps = Pick<GanttFeature, \"id\"> & {\n  children?: ReactNode;\n};\n\nexport const GanttFeatureItemCard: FC<GanttFeatureItemCardProps> = ({\n  id,\n  children,\n}) => {\n  const [, setDragging] = useGanttDragging();\n  const { attributes, listeners, setNodeRef } = useDraggable({ id });\n  const isPressed = Boolean(attributes[\"aria-pressed\"]);\n\n  useEffect(() => setDragging(isPressed), [isPressed, setDragging]);\n\n  return (\n    <Card className=\"h-full w-full rounded-md bg-background p-2 text-xs shadow-sm\">\n      <div\n        className={cn(\n          \"flex h-full w-full items-center justify-between gap-2 text-left\",\n          isPressed && \"cursor-grabbing\"\n        )}\n        {...attributes}\n        {...listeners}\n        ref={setNodeRef}\n      >\n        {children}\n      </div>\n    </Card>\n  );\n};\n\nexport type GanttFeatureItemProps = GanttFeature & {\n  onMove?: (id: string, startDate: Date, endDate: Date | null) => void;\n  children?: ReactNode;\n  className?: string;\n};\n\nexport const GanttFeatureItem: FC<GanttFeatureItemProps> = ({\n  onMove,\n  children,\n  className,\n  ...feature\n}) => {\n  const [scrollX] = useGanttScrollX();\n  const gantt = useContext(GanttContext);\n  const timelineStartDate = useMemo(\n    () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1),\n    [gantt.timelineData]\n  );\n  const [startAt, setStartAt] = useState<Date>(feature.startAt);\n  const [endAt, setEndAt] = useState<Date | null>(feature.endAt);\n\n  // Memoize expensive calculations\n  const width = useMemo(\n    () => getWidth(startAt, endAt, gantt),\n    [startAt, endAt, gantt]\n  );\n  const offset = useMemo(\n    () => getOffset(startAt, timelineStartDate, gantt),\n    [startAt, timelineStartDate, gantt]\n  );\n\n  const addRange = useMemo(() => getAddRange(gantt.range), [gantt.range]);\n  const [mousePosition] = useMouse<HTMLDivElement>();\n\n  const [previousMouseX, setPreviousMouseX] = useState(0);\n  const [previousStartAt, setPreviousStartAt] = useState(startAt);\n  const [previousEndAt, setPreviousEndAt] = useState(endAt);\n\n  const mouseSensor = useSensor(MouseSensor, {\n    activationConstraint: {\n      distance: 10,\n    },\n  });\n\n  const handleItemDragStart = useCallback(() => {\n    setPreviousMouseX(mousePosition.x);\n    setPreviousStartAt(startAt);\n    setPreviousEndAt(endAt);\n  }, [mousePosition.x, startAt, endAt]);\n\n  const handleItemDragMove = useCallback(() => {\n    const currentDate = getDateByMousePosition(gantt, mousePosition.x);\n    const originalDate = getDateByMousePosition(gantt, previousMouseX);\n    const delta =\n      gantt.range === \"daily\"\n        ? getDifferenceIn(gantt.range)(currentDate, originalDate)\n        : getInnerDifferenceIn(gantt.range)(currentDate, originalDate);\n    const newStartDate = addDays(previousStartAt, delta);\n    const newEndDate = previousEndAt ? addDays(previousEndAt, delta) : null;\n\n    setStartAt(newStartDate);\n    setEndAt(newEndDate);\n  }, [gantt, mousePosition.x, previousMouseX, previousStartAt, previousEndAt]);\n\n  const onDragEnd = useCallback(\n    () => onMove?.(feature.id, startAt, endAt),\n    [onMove, feature.id, startAt, endAt]\n  );\n\n  const handleLeftDragMove = useCallback(() => {\n    const ganttRect = gantt.ref?.current?.getBoundingClientRect();\n    const x =\n      mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth;\n    const newStartAt = getDateByMousePosition(gantt, x);\n\n    setStartAt(newStartAt);\n  }, [gantt, mousePosition.x, scrollX]);\n\n  const handleRightDragMove = useCallback(() => {\n    const ganttRect = gantt.ref?.current?.getBoundingClientRect();\n    const x =\n      mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth;\n    const newEndAt = getDateByMousePosition(gantt, x);\n\n    setEndAt(newEndAt);\n  }, [gantt, mousePosition.x, scrollX]);\n\n  return (\n    <div\n      className={cn(\"relative flex w-max min-w-full py-0.5\", className)}\n      style={{ height: \"var(--gantt-row-height)\" }}\n    >\n      <div\n        className=\"pointer-events-auto absolute top-0.5\"\n        style={{\n          height: \"calc(var(--gantt-row-height) - 4px)\",\n          width: Math.round(width),\n          left: Math.round(offset),\n        }}\n      >\n        {onMove && (\n          <DndContext\n            modifiers={[restrictToHorizontalAxis]}\n            onDragEnd={onDragEnd}\n            onDragMove={handleLeftDragMove}\n            sensors={[mouseSensor]}\n          >\n            <GanttFeatureDragHelper\n              date={startAt}\n              direction=\"left\"\n              featureId={feature.id}\n            />\n          </DndContext>\n        )}\n        <DndContext\n          modifiers={[restrictToHorizontalAxis]}\n          onDragEnd={onDragEnd}\n          onDragMove={handleItemDragMove}\n          onDragStart={handleItemDragStart}\n          sensors={[mouseSensor]}\n        >\n          <GanttFeatureItemCard id={feature.id}>\n            {children ?? (\n              <p className=\"flex-1 truncate text-xs\">{feature.name}</p>\n            )}\n          </GanttFeatureItemCard>\n        </DndContext>\n        {onMove && (\n          <DndContext\n            modifiers={[restrictToHorizontalAxis]}\n            onDragEnd={onDragEnd}\n            onDragMove={handleRightDragMove}\n            sensors={[mouseSensor]}\n          >\n            <GanttFeatureDragHelper\n              date={endAt ?? addRange(startAt, 2)}\n              direction=\"right\"\n              featureId={feature.id}\n            />\n          </DndContext>\n        )}\n      </div>\n    </div>\n  );\n};\n\nexport type GanttFeatureListGroupProps = {\n  children: ReactNode;\n  className?: string;\n};\n\nexport const GanttFeatureListGroup: FC<GanttFeatureListGroupProps> = ({\n  children,\n  className,\n}) => (\n  <div className={className} style={{ paddingTop: \"var(--gantt-row-height)\" }}>\n    {children}\n  </div>\n);\n\nexport type GanttFeatureRowProps = {\n  features: GanttFeature[];\n  onMove?: (id: string, startAt: Date, endAt: Date | null) => void;\n  children?: (feature: GanttFeature) => ReactNode;\n  className?: string;\n};\n\nexport const GanttFeatureRow: FC<GanttFeatureRowProps> = ({\n  features,\n  onMove,\n  children,\n  className,\n}) => {\n  // Sort features by start date to handle potential overlaps\n  const sortedFeatures = [...features].sort(\n    (a, b) => a.startAt.getTime() - b.startAt.getTime()\n  );\n\n  // Calculate sub-row positions for overlapping features using a proper algorithm\n  const featureWithPositions = [];\n  const subRowEndTimes: Date[] = []; // Track when each sub-row becomes free\n\n  for (const feature of sortedFeatures) {\n    let subRow = 0;\n\n    // Find the first sub-row that's free (doesn't overlap)\n    while (\n      subRow < subRowEndTimes.length &&\n      subRowEndTimes[subRow] > feature.startAt\n    ) {\n      subRow++;\n    }\n\n    // Update the end time for this sub-row\n    if (subRow === subRowEndTimes.length) {\n      subRowEndTimes.push(feature.endAt);\n    } else {\n      subRowEndTimes[subRow] = feature.endAt;\n    }\n\n    featureWithPositions.push({ ...feature, subRow });\n  }\n\n  const maxSubRows = Math.max(1, subRowEndTimes.length);\n  const subRowHeight = 36; // Base row height\n\n  return (\n    <div\n      className={cn(\"relative\", className)}\n      style={{\n        height: `${maxSubRows * subRowHeight}px`,\n        minHeight: \"var(--gantt-row-height)\",\n      }}\n    >\n      {featureWithPositions.map((feature) => (\n        <div\n          className=\"absolute w-full\"\n          key={feature.id}\n          style={{\n            top: `${feature.subRow * subRowHeight}px`,\n            height: `${subRowHeight}px`,\n          }}\n        >\n          <GanttFeatureItem {...feature} onMove={onMove}>\n            {children ? (\n              children(feature)\n            ) : (\n              <p className=\"flex-1 truncate text-xs\">{feature.name}</p>\n            )}\n          </GanttFeatureItem>\n        </div>\n      ))}\n    </div>\n  );\n};\n\nexport type GanttFeatureListProps = {\n  className?: string;\n  children: ReactNode;\n};\n\nexport const GanttFeatureList: FC<GanttFeatureListProps> = ({\n  className,\n  children,\n}) => (\n  <div\n    className={cn(\"absolute top-0 left-0 h-full w-max space-y-4\", className)}\n    style={{ marginTop: \"var(--gantt-header-height)\" }}\n  >\n    {children}\n  </div>\n);\n\nexport const GanttMarker: FC<\n  GanttMarkerProps & {\n    onRemove?: (id: string) => void;\n    className?: string;\n  }\n> = memo(({ label, date, id, onRemove, className }) => {\n  const gantt = useContext(GanttContext);\n  const differenceIn = useMemo(\n    () => getDifferenceIn(gantt.range),\n    [gantt.range]\n  );\n  const timelineStartDate = useMemo(\n    () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1),\n    [gantt.timelineData]\n  );\n\n  // Memoize expensive calculations\n  const offset = useMemo(\n    () => differenceIn(date, timelineStartDate),\n    [differenceIn, date, timelineStartDate]\n  );\n  const innerOffset = useMemo(\n    () =>\n      calculateInnerOffset(\n        date,\n        gantt.range,\n        (gantt.columnWidth * gantt.zoom) / 100\n      ),\n    [date, gantt.range, gantt.columnWidth, gantt.zoom]\n  );\n\n  const handleRemove = useCallback(() => onRemove?.(id), [onRemove, id]);\n\n  return (\n    <div\n      className=\"pointer-events-none absolute top-0 left-0 z-20 flex h-full select-none flex-col items-center justify-center overflow-visible\"\n      style={{\n        width: 0,\n        transform: `translateX(calc(var(--gantt-column-width) * ${offset} + ${innerOffset}px))`,\n      }}\n    >\n      <ContextMenu>\n        <ContextMenuTrigger asChild>\n          <div\n            className={cn(\n              \"group pointer-events-auto sticky top-0 flex select-auto flex-col flex-nowrap items-center justify-center whitespace-nowrap rounded-b-md bg-card px-2 py-1 text-foreground text-xs\",\n              className\n            )}\n          >\n            {label}\n            <span className=\"max-h-[0] overflow-hidden opacity-80 transition-all group-hover:max-h-[2rem]\">\n              {formatDate(date, \"MMM dd, yyyy\")}\n            </span>\n          </div>\n        </ContextMenuTrigger>\n        <ContextMenuContent>\n          {onRemove ? (\n            <ContextMenuItem\n              className=\"flex items-center gap-2 text-destructive\"\n              onClick={handleRemove}\n            >\n              <TrashIcon size={16} />\n              Remove marker\n            </ContextMenuItem>\n          ) : null}\n        </ContextMenuContent>\n      </ContextMenu>\n      <div className={cn(\"h-full w-px bg-card\", className)} />\n    </div>\n  );\n});\n\nGanttMarker.displayName = \"GanttMarker\";\n\nexport type GanttProviderProps = {\n  range?: Range;\n  zoom?: number;\n  onAddItem?: (date: Date) => void;\n  children: ReactNode;\n  className?: string;\n};\n\nexport const GanttProvider: FC<GanttProviderProps> = ({\n  zoom = 100,\n  range = \"monthly\",\n  onAddItem,\n  children,\n  className,\n}) => {\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const [timelineData, setTimelineData] = useState<TimelineData>(\n    createInitialTimelineData(new Date())\n  );\n  const [, setScrollX] = useGanttScrollX();\n  const [sidebarWidth, setSidebarWidth] = useState(0);\n\n  const headerHeight = 60;\n  const rowHeight = 36;\n  let columnWidth = 50;\n\n  if (range === \"monthly\") {\n    columnWidth = 150;\n  } else if (range === \"quarterly\") {\n    columnWidth = 100;\n  }\n\n  // Memoize CSS variables to prevent unnecessary re-renders\n  const cssVariables = useMemo(\n    () =>\n      ({\n        \"--gantt-zoom\": `${zoom}`,\n        \"--gantt-column-width\": `${(zoom / 100) * columnWidth}px`,\n        \"--gantt-header-height\": `${headerHeight}px`,\n        \"--gantt-row-height\": `${rowHeight}px`,\n        \"--gantt-sidebar-width\": `${sidebarWidth}px`,\n      }) as CSSProperties,\n    [zoom, columnWidth, sidebarWidth]\n  );\n\n  useEffect(() => {\n    if (scrollRef.current) {\n      scrollRef.current.scrollLeft =\n        scrollRef.current.scrollWidth / 2 - scrollRef.current.clientWidth / 2;\n      setScrollX(scrollRef.current.scrollLeft);\n    }\n  }, [setScrollX]);\n\n  // Update sidebar width when DOM is ready\n  useEffect(() => {\n    const updateSidebarWidth = () => {\n      const sidebarElement = scrollRef.current?.querySelector(\n        '[data-roadmap-ui=\"gantt-sidebar\"]'\n      );\n      const newWidth = sidebarElement ? 300 : 0;\n      setSidebarWidth(newWidth);\n    };\n\n    // Update immediately\n    updateSidebarWidth();\n\n    // Also update on resize or when children change\n    const observer = new MutationObserver(updateSidebarWidth);\n    if (scrollRef.current) {\n      observer.observe(scrollRef.current, {\n        childList: true,\n        subtree: true,\n      });\n    }\n\n    return () => {\n      observer.disconnect();\n    };\n  }, []);\n\n  // Fix the useCallback to include all dependencies\n  const handleScroll = useCallback(\n    throttle(() => {\n      const scrollElement = scrollRef.current;\n      if (!scrollElement) {\n        return;\n      }\n\n      const { scrollLeft, scrollWidth, clientWidth } = scrollElement;\n      setScrollX(scrollLeft);\n\n      if (scrollLeft === 0) {\n        // Extend timelineData to the past\n        const firstYear = timelineData[0]?.year;\n\n        if (!firstYear) {\n          return;\n        }\n\n        const newTimelineData: TimelineData = [...timelineData];\n        newTimelineData.unshift({\n          year: firstYear - 1,\n          quarters: new Array(4).fill(null).map((_, quarterIndex) => ({\n            months: new Array(3).fill(null).map((_, monthIndex) => {\n              const month = quarterIndex * 3 + monthIndex;\n              return {\n                days: getDaysInMonth(new Date(firstYear, month, 1)),\n              };\n            }),\n          })),\n        });\n\n        setTimelineData(newTimelineData);\n\n        // Scroll a bit forward so it's not at the very start\n        scrollElement.scrollLeft = scrollElement.clientWidth;\n        setScrollX(scrollElement.scrollLeft);\n      } else if (scrollLeft + clientWidth >= scrollWidth) {\n        // Extend timelineData to the future\n        const lastYear = timelineData.at(-1)?.year;\n\n        if (!lastYear) {\n          return;\n        }\n\n        const newTimelineData: TimelineData = [...timelineData];\n        newTimelineData.push({\n          year: lastYear + 1,\n          quarters: new Array(4).fill(null).map((_, quarterIndex) => ({\n            months: new Array(3).fill(null).map((_, monthIndex) => {\n              const month = quarterIndex * 3 + monthIndex;\n              return {\n                days: getDaysInMonth(new Date(lastYear, month, 1)),\n              };\n            }),\n          })),\n        });\n\n        setTimelineData(newTimelineData);\n\n        // Scroll a bit back so it's not at the very end\n        scrollElement.scrollLeft =\n          scrollElement.scrollWidth - scrollElement.clientWidth;\n        setScrollX(scrollElement.scrollLeft);\n      }\n    }, 100),\n    []\n  );\n\n  useEffect(() => {\n    const scrollElement = scrollRef.current;\n    if (scrollElement) {\n      scrollElement.addEventListener(\"scroll\", handleScroll);\n    }\n\n    return () => {\n      // Fix memory leak by properly referencing the scroll element\n      if (scrollElement) {\n        scrollElement.removeEventListener(\"scroll\", handleScroll);\n      }\n    };\n  }, [handleScroll]);\n\n  const scrollToFeature = useCallback(\n    (feature: GanttFeature) => {\n      const scrollElement = scrollRef.current;\n      if (!scrollElement) {\n        return;\n      }\n\n      // Calculate timeline start date from timelineData\n      const timelineStartDate = new Date(timelineData[0].year, 0, 1);\n\n      // Calculate the horizontal offset for the feature's start date\n      const offset = getOffset(feature.startAt, timelineStartDate, {\n        zoom,\n        range,\n        columnWidth,\n        sidebarWidth,\n        headerHeight,\n        rowHeight,\n        onAddItem,\n        placeholderLength: 2,\n        timelineData,\n        ref: scrollRef,\n      });\n\n      // Scroll to align the feature's start with the right side of the sidebar\n      const targetScrollLeft = Math.max(0, offset);\n\n      scrollElement.scrollTo({\n        left: targetScrollLeft,\n        behavior: \"smooth\",\n      });\n    },\n    [timelineData, zoom, range, columnWidth, sidebarWidth, onAddItem]\n  );\n\n  return (\n    <GanttContext.Provider\n      value={{\n        zoom,\n        range,\n        headerHeight,\n        columnWidth,\n        sidebarWidth,\n        rowHeight,\n        onAddItem,\n        timelineData,\n        placeholderLength: 2,\n        ref: scrollRef,\n        scrollToFeature,\n      }}\n    >\n      <div\n        className={cn(\n          \"gantt relative isolate grid h-full w-full flex-none select-none overflow-auto rounded-sm bg-secondary\",\n          range,\n          className\n        )}\n        ref={scrollRef}\n        style={{\n          ...cssVariables,\n          gridTemplateColumns: \"var(--gantt-sidebar-width) 1fr\",\n        }}\n      >\n        {children}\n      </div>\n    </GanttContext.Provider>\n  );\n};\n\nexport type GanttTimelineProps = {\n  children: ReactNode;\n  className?: string;\n};\n\nexport const GanttTimeline: FC<GanttTimelineProps> = ({\n  children,\n  className,\n}) => (\n  <div\n    className={cn(\n      \"relative flex h-full w-max flex-none overflow-clip\",\n      className\n    )}\n  >\n    {children}\n  </div>\n);\n\nexport type GanttTodayProps = {\n  className?: string;\n};\n\nexport const GanttToday: FC<GanttTodayProps> = ({ className }) => {\n  const label = \"Today\";\n  const date = useMemo(() => new Date(), []);\n  const gantt = useContext(GanttContext);\n  const differenceIn = useMemo(\n    () => getDifferenceIn(gantt.range),\n    [gantt.range]\n  );\n  const timelineStartDate = useMemo(\n    () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1),\n    [gantt.timelineData]\n  );\n\n  // Memoize expensive calculations\n  const offset = useMemo(\n    () => differenceIn(date, timelineStartDate),\n    [differenceIn, date, timelineStartDate]\n  );\n  const innerOffset = useMemo(\n    () =>\n      calculateInnerOffset(\n        date,\n        gantt.range,\n        (gantt.columnWidth * gantt.zoom) / 100\n      ),\n    [date, gantt.range, gantt.columnWidth, gantt.zoom]\n  );\n\n  return (\n    <div\n      className=\"pointer-events-none absolute top-0 left-0 z-20 flex h-full select-none flex-col items-center justify-center overflow-visible\"\n      style={{\n        width: 0,\n        transform: `translateX(calc(var(--gantt-column-width) * ${offset} + ${innerOffset}px))`,\n      }}\n    >\n      <div\n        className={cn(\n          \"group pointer-events-auto sticky top-0 flex select-auto flex-col flex-nowrap items-center justify-center whitespace-nowrap rounded-b-md bg-card px-2 py-1 text-foreground text-xs\",\n          className\n        )}\n      >\n        {label}\n        <span className=\"max-h-[0] overflow-hidden opacity-80 transition-all group-hover:max-h-[2rem]\">\n          {formatDate(date, \"MMM dd, yyyy\")}\n        </span>\n      </div>\n      <div className={cn(\"h-full w-px bg-card\", className)} />\n    </div>\n  );\n};\n","target":"components/kibo-ui/gantt/index.tsx"}],"css":{}}