{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"gantt","type":"registry:ui","title":"Gantt","description":"Tailark Base UI Gantt component","dependencies":["@dnd-kit/core","@dnd-kit/modifiers","@uidotdev/usehooks","date-fns","jotai","lodash.throttle"],"registryDependencies":["@tailark/core-utils","@tailark/card","@tailark/context-menu"],"files":[{"path":"components/ui/gantt.tsx","type":"registry:ui","target":"@ui/gantt.tsx","content":"\"use client\"\n\nimport { Card } from \"@/components/ui/card\"\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuTrigger,\n} from \"./context-menu\"\nimport { cn } from \"@/lib/utils\"\nimport { DndContext, MouseSensor, useDraggable, useSensor } from \"@dnd-kit/core\"\nimport { restrictToHorizontalAxis } from \"@dnd-kit/modifiers\"\nimport { useMouse, useThrottle, useWindowScroll } from \"@uidotdev/usehooks\"\nimport { formatDate, getDate } from \"date-fns\"\nimport { formatDistance, isSameDay } from \"date-fns\"\nimport { format } from \"date-fns\"\nimport {\n  addDays,\n  addMonths,\n  differenceInDays,\n  differenceInHours,\n  differenceInMonths,\n  endOfDay,\n  endOfMonth,\n  getDaysInMonth,\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 {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\"\nimport type {\n  CSSProperties,\n  FC,\n  KeyboardEventHandler,\n  MouseEventHandler,\n  ReactNode,\n  RefObject,\n} from \"react\"\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}\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}\n\nconst getsDaysIn = (range: Range) => {\n  // For when range is daily\n  let fn: (date: Date) => number = () => 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})\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=\"bg-backdrop/90 sticky top-0 z-20 grid w-full shrink-0 backdrop-blur-sm\"\n      style={{ height: \"var(--gantt-header-height)\" }}\n    >\n      <div>\n        <div\n          className=\"sticky inline-flex px-3 py-2 text-xs whitespace-nowrap text-muted-foreground\"\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            key={`${id}-${index}`}\n            className=\"shrink-0 border-b border-border/50 py-1 text-center text-xs\"\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    const headers = []\n    let calendarMonth = 0\n    for (const quarter of year.quarters) {\n      for (const month of quarter.months) {\n        const monthNumber = calendarMonth\n        headers.push(\n          <div\n            className=\"relative flex flex-col\"\n            key={`${year.year}-${monthNumber}`}\n          >\n            <GanttContentHeader\n              title={format(new Date(year.year, monthNumber, 1), \"MMMM yyyy\")}\n              columns={month.days}\n              renderHeaderItem={(item: number) => (\n                <div className=\"flex items-center justify-center gap-1\">\n                  <p>\n                    {format(\n                      addDays(new Date(year.year, monthNumber, 1), item),\n                      \"d\"\n                    )}\n                  </p>\n                  <p className=\"text-muted-foreground\">\n                    {format(\n                      addDays(new Date(year.year, monthNumber, 1), item),\n                      \"EEEEE\"\n                    )}\n                  </p>\n                </div>\n              )}\n            />\n            <GanttColumns\n              columns={month.days}\n              isColumnSecondary={(item: number) =>\n                [0, 6].includes(\n                  addDays(new Date(year.year, monthNumber, 1), item).getDay()\n                )\n              }\n            />\n          </div>\n        )\n        calendarMonth++\n      }\n    }\n    return headers\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        title={`${year.year}`}\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      />\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          title={`Q${quarterIndex + 1} ${year.year}`}\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        />\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        \"flex h-full w-max -space-x-px 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 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, typeof window !== \"undefined\" ? new Date() : new Date(\"2024-10-25\"))} so far`\n\n  const handleClick: MouseEventHandler<HTMLDivElement> = (event) => {\n    if (event.target === event.currentTarget) {\n      onSelectItem?.(feature.id)\n    }\n  }\n\n  const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {\n    if (event.key === \"Enter\") {\n      onSelectItem?.(feature.id)\n    }\n  }\n\n  return (\n    <div\n      // biome-ignore lint/a11y/useSemanticElements: <explanation>\n      role=\"button\"\n      onClick={handleClick}\n      onKeyDown={handleKeyDown}\n      tabIndex={0}\n      key={feature.id}\n      className={cn(\n        \"relative flex items-center gap-2.5 p-2.5 text-xs\",\n        className\n      )}\n      style={{\n        height: \"var(--gantt-row-height)\",\n      }}\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=\"bg-backdrop/90 sticky top-0 z-10 flex shrink-0 items-end justify-between gap-2.5 border-b border-border/50 p-2.5 text-xs font-medium text-muted-foreground 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      style={{ height: \"var(--gantt-row-height)\" }}\n      className=\"w-full truncate p-2.5 text-left text-xs font-medium text-muted-foreground\"\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    data-roadmap-ui=\"gantt-sidebar\"\n    className={cn(\n      \"sticky left-0 z-30 h-max min-h-full overflow-clip border-r border-border/50 bg-background/90 backdrop-blur-md\",\n      className\n    )}\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      style={{\n        marginTop: -gantt.rowHeight / 2,\n        transform: `translateY(${top}px)`,\n      }}\n      ref={mouseRef}\n    >\n      <button\n        onClick={handleClick}\n        type=\"button\"\n        aria-label=\"Add item\"\n        className=\"flex h-full w-full items-center justify-center rounded-md border border-dashed p-2\"\n      >\n        <PlusIcon\n          size={16}\n          className=\"pointer-events-none text-muted-foreground select-none\"\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  const [offsetY, setOffsetY] = useState(0)\n\n  const handleMouseEnter = () => setHovering(true)\n  const handleMouseLeave = () => setHovering(false)\n\n  useEffect(() => {\n    if (mouseRef.current) {\n      setOffsetY(mouseRef.current.getBoundingClientRect().y)\n    }\n  }, [mouseRef])\n\n  const top = useThrottle(mousePosition.y - offsetY - (windowScroll.y ?? 0), 10)\n\n  return (\n    // biome-ignore lint/nursery/noStaticElementInteractions: <explanation>\n    <div\n      className={cn(\n        \"group relative h-full overflow-hidden\",\n        isColumnSecondary?.(index) ? \"bg-secondary\" : \"\"\n      )}\n      ref={mouseRef}\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\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          key={`${id}-${index}`}\n          index={index}\n          isColumnSecondary={isColumnSecondary}\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 [offsetX, setOffsetX] = useState(0)\n\n  useEffect(() => {\n    if (mouseRef.current) {\n      setOffsetX(mouseRef.current.getBoundingClientRect().x)\n    }\n  }, [mouseRef])\n\n  const x = useThrottle(mousePosition.x - offsetX - (windowScroll.x ?? 0), 10)\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 overflow-visible select-none\",\n        className\n      )}\n      ref={mouseRef}\n    >\n      <div\n        className=\"pointer-events-auto sticky top-6 z-20 -ml-2 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          type=\"button\"\n          aria-label=\"Add marker\"\n          className=\"z-50 inline-flex h-4 w-4 items-center justify-center rounded-full bg-card\"\n          onClick={handleClick}\n        >\n          <PlusIcon size={12} className=\"text-muted-foreground\" />\n        </button>\n        <div className=\"rounded-full border border-border/50 bg-background/90 px-2 py-1 text-xs whitespace-nowrap text-foreground 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 absolute top-1/2 z-3 h-full w-6 -translate-y-1/2 cursor-col-resize! 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          \"absolute top-1/2 h-[80%] w-1 -translate-y-1/2 rounded-sm bg-muted-foreground opacity-0 transition-opacity\",\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            \"absolute top-10 hidden -translate-x-1/2 rounded-lg border border-border/50 bg-background/90 px-2 py-1 text-xs whitespace-nowrap text-foreground 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 border-transparent bg-card p-2 text-xs shadow-sm ring-1 ring-foreground/10\">\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 = new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1)\n  const [startAt, setStartAt] = useState<Date>(feature.startAt)\n  const [endAt, setEndAt] = useState<Date | null>(feature.endAt)\n  const width = getWidth(startAt, endAt, gantt)\n  const offset = getOffset(startAt, timelineStartDate, gantt)\n  const addRange = getAddRange(gantt.range)\n  const [mousePosition] = useMouse<HTMLDivElement>()\n\n  const previousMouseXRef = useRef(0)\n  const previousStartAtRef = useRef(startAt)\n  const previousEndAtRef = useRef(endAt)\n\n  const mouseSensor = useSensor(MouseSensor, {\n    activationConstraint: {\n      distance: 10,\n    },\n  })\n\n  const handleItemDragStart = () => {\n    previousMouseXRef.current = mousePosition.x\n    previousStartAtRef.current = startAt\n    previousEndAtRef.current = endAt\n  }\n\n  const handleItemDragMove = () => {\n    const currentDate = getDateByMousePosition(gantt, mousePosition.x)\n    const originalDate = getDateByMousePosition(gantt, previousMouseXRef.current)\n    const delta =\n      gantt.range === \"daily\"\n        ? getDifferenceIn(gantt.range)(currentDate, originalDate)\n        : getInnerDifferenceIn(gantt.range)(currentDate, originalDate)\n    const newStartDate = addDays(previousStartAtRef.current, delta)\n    const newEndDate = previousEndAtRef.current\n      ? addDays(previousEndAtRef.current, delta)\n      : null\n\n    setStartAt(newStartDate)\n    setEndAt(newEndDate)\n  }\n\n  const onDragEnd = () => onMove?.(feature.id, startAt, endAt)\n  const handleLeftDragMove = () => {\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  }\n  const handleRightDragMove = () => {\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  }\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            sensors={[mouseSensor]}\n            modifiers={[restrictToHorizontalAxis]}\n            onDragMove={handleLeftDragMove}\n            onDragEnd={onDragEnd}\n          >\n            <GanttFeatureDragHelper\n              direction=\"left\"\n              featureId={feature.id}\n              date={startAt}\n            />\n          </DndContext>\n        )}\n        <DndContext\n          sensors={[mouseSensor]}\n          modifiers={[restrictToHorizontalAxis]}\n          onDragStart={handleItemDragStart}\n          onDragMove={handleItemDragMove}\n          onDragEnd={onDragEnd}\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            sensors={[mouseSensor]}\n            modifiers={[restrictToHorizontalAxis]}\n            onDragMove={handleRightDragMove}\n            onDragEnd={onDragEnd}\n          >\n            <GanttFeatureDragHelper\n              direction=\"right\"\n              featureId={feature.id}\n              date={endAt ?? addRange(startAt, 2)}\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 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> = ({ label, date, id, onRemove, className }) => {\n  const gantt = useContext(GanttContext)\n  const differenceIn = getDifferenceIn(gantt.range)\n  const timelineStartDate = new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1)\n  const offset = differenceIn(date, timelineStartDate)\n  const innerOffset = calculateInnerOffset(\n    date,\n    gantt.range,\n    (gantt.columnWidth * gantt.zoom) / 100\n  )\n  const handleRemove = () => onRemove?.(id)\n\n  return (\n    <div\n      className=\"pointer-events-none absolute top-0 left-0 z-20 flex h-full flex-col items-center justify-center overflow-visible select-none\"\n      style={{\n        width: 0,\n        transform: `translateX(calc(var(--gantt-column-width) * ${offset} + ${innerOffset}px))`,\n      }}\n    >\n      <ContextMenu>\n        <ContextMenuTrigger\n          render={\n            <div\n              className={cn(\n                \"group pointer-events-auto sticky top-0 flex flex-col flex-nowrap items-center justify-center rounded-b-md bg-card px-2 py-1 text-xs whitespace-nowrap text-foreground select-auto\",\n                className\n              )}\n            >\n              {label}\n              <span className=\"max-h-0 overflow-hidden opacity-80 transition-[max-height] group-hover:max-h-[2rem]\">\n                {formatDate(date, \"MMM dd, yyyy\")}\n              </span>\n            </div>\n          }\n        />\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\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(\n      typeof window !== \"undefined\" ? new Date() : new Date(\"2024-10-25\")\n    )\n  )\n  const [, setScrollX] = useGanttScrollX()\n  const [sidebarWidth, setSidebarWidth] = useState(0)\n\n  const scrollRefCallback = useCallback((node: HTMLDivElement | null) => {\n    if (node) {\n      const sidebarElement = node.querySelector(\n        '[data-roadmap-ui=\"gantt-sidebar\"]'\n      )\n      setSidebarWidth(sidebarElement ? 300 : 0)\n      scrollRef.current = node\n    }\n  }, [])\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  const cssVariables = {\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\n  // biome-ignore lint/correctness/useExhaustiveDependencies: Re-render when props change\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  }, [range, zoom, setScrollX])\n\n  useEffect(() => {\n    const el = scrollRef.current\n    if (!el) return\n\n    const handler = throttle(() => {\n      const { scrollLeft, scrollWidth, clientWidth } = el\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        el.scrollLeft = el.clientWidth\n        setScrollX(el.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        el.scrollLeft = el.scrollWidth - el.clientWidth\n        setScrollX(el.scrollLeft)\n      }\n    }, 100)\n\n    el.addEventListener(\"scroll\", handler)\n    return () => {\n      el.removeEventListener(\"scroll\", handler)\n    }\n  }, [timelineData, setScrollX])\n\n  const contextValue = useMemo<GanttContextProps>(\n    () => ({\n      zoom,\n      range,\n      headerHeight,\n      columnWidth,\n      sidebarWidth,\n      rowHeight,\n      onAddItem,\n      timelineData,\n      placeholderLength: 2,\n      ref: scrollRef,\n    }),\n    [\n      zoom,\n      range,\n      headerHeight,\n      columnWidth,\n      sidebarWidth,\n      rowHeight,\n      onAddItem,\n      timelineData,\n    ]\n  )\n\n  return (\n    <GanttContext.Provider value={contextValue}>\n      <div\n        className={cn(\n          \"gantt relative grid h-full w-full flex-none overflow-auto rounded-sm bg-secondary select-none\",\n          range,\n          className\n        )}\n        style={{\n          ...cssVariables,\n          gridTemplateColumns: \"var(--gantt-sidebar-width) 1fr\",\n        }}\n        ref={scrollRefCallback}\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 =\n    typeof window !== \"undefined\" ? new Date() : new Date(\"2024-10-25\")\n  const gantt = useContext(GanttContext)\n  const differenceIn = getDifferenceIn(gantt.range)\n  const timelineStartDate = new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1)\n  const offset = differenceIn(date, timelineStartDate)\n  const innerOffset = calculateInnerOffset(\n    date,\n    gantt.range,\n    (gantt.columnWidth * gantt.zoom) / 100\n  )\n\n  return (\n    <div\n      className=\"pointer-events-none absolute top-0 left-0 z-20 flex h-full flex-col items-center justify-center overflow-visible select-none\"\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 flex-col flex-nowrap items-center justify-center rounded-b-md bg-card px-2 py-1 text-xs whitespace-nowrap text-foreground select-auto\",\n          className\n        )}\n      >\n        {label}\n        <span className=\"max-h-0 overflow-hidden opacity-80 transition-[max-height] 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"}]}