{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"gantt","type":"registry:ui","title":"Headless-first gantt - horizontal resource timeline with day-to-year scales, external CRUD contract, and a subscribable store.","description":"Headless-first gantt - horizontal resource timeline with day-to-year scales, external CRUD contract, and a subscribable store.","dependencies":["@date-fns/tz","date-fns","radix-ui"],"registryDependencies":["button","calendar","checkbox","context-menu","dropdown-menu","@neui/gantt-bar","@neui/gantt-dnd","@neui/gantt-i18n","@neui/gantt-lib","@neui/gantt-recurrence","@neui/gantt-types","popover","scroll-area","tooltip"],"files":[{"path":"gantt-bar.tsx","type":"registry:ui","content":"// Title: Gantt Bar\n// Description: The interactive gantt bar - selection, clicks, drag + resize wiring, and the consumer render slot.\n\n\"use client\"\n\nimport {\n createContext,\n useContext,\n useMemo,\n useState,\n type ButtonHTMLAttributes,\n type CSSProperties,\n type ReactNode,\n} from \"react\"\nimport {\n useGantt,\n useGanttSelector,\n useGanttViewConfig,\n} from \"@/components/neui/gantt/gantt\"\nimport {\n useGanttGestures,\n wasRecentDrag,\n} from \"@/components/neui/gantt/gantt-dnd\"\nimport {\n flattenResources,\n toZoned,\n} from \"@/components/neui/gantt/gantt-lib\"\nimport type {\n GanttOccurrence,\n GanttSegment,\n} from \"@/components/neui/gantt/gantt-types\"\nimport { Slot } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n ContextMenu,\n ContextMenuContent,\n ContextMenuTrigger,\n} from \"@/components/ui/context-menu\"\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { IconPlaceholder } from \"@/app/(create)/components/icon-placeholder\"\n\n/**\n * Effective Tailwind palette presets for bar colors; every entry works on\n * light and dark surfaces through the bar's alpha background + accent border.\n */\nconst GANTT_COLORS: Array<{ name: string; value: string }> = [\n { name: \"Blue\", value: \"var(--color-blue-500)\" },\n { name: \"Emerald\", value: \"var(--color-emerald-500)\" },\n { name: \"Violet\", value: \"var(--color-violet-500)\" },\n { name: \"Rose\", value: \"var(--color-rose-500)\" },\n { name: \"Amber\", value: \"var(--color-amber-500)\" },\n { name: \"Cyan\", value: \"var(--color-cyan-500)\" },\n { name: \"Orange\", value: \"var(--color-orange-500)\" },\n { name: \"Pink\", value: \"var(--color-pink-500)\" },\n { name: \"Teal\", value: \"var(--color-teal-500)\" },\n { name: \"Indigo\", value: \"var(--color-indigo-500)\" },\n]\n\ninterface GanttBarContextValue {\n occurrence: GanttOccurrence\n segment: GanttSegment\n isDragging: boolean\n isSelected: boolean\n}\n\nconst GanttBarContext =\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n createContext | null>(null)\n\n/** The bar's subject; usable inside renderEvent content and bar children. */\nfunction useGanttBarContext(): GanttBarContextValue {\n const ctx = useContext(GanttBarContext)\n if (!ctx) {\n throw new Error(\"useGanttBarContext must be used within \")\n }\n return ctx as GanttBarContextValue\n}\n\ninterface GanttBarProps extends Omit<\n ButtonHTMLAttributes,\n \"children\"\n> {\n segment: GanttSegment\n /** Replaces the default bar CONTENT; the wrapper stays gantt-owned. */\n children?: ReactNode\n /**\n * The title renders beside the bar (view-owned), so the default inner\n * content is suppressed. Explicit children and renderEvent still win.\n */\n labelOutside?: boolean\n /**\n * The owning row's title for the aria-label. Pass it when the row is in\n * scope (the internal view does); omitting falls back to a tree lookup.\n */\n rowTitle?: string\n asChild?: boolean\n}\n\n/**\n * The one interactive bar element. The wrapper owns positioning hooks, a11y,\n * selection, drag/resize listeners, and data attributes; content comes from\n * children, the root renderEvent override, or the built-in default.\n */\nfunction GanttBar({\n segment,\n className,\n asChild = false,\n children,\n labelOutside,\n rowTitle: rowTitleProp,\n ...props\n}: GanttBarProps) {\n const instance = useGantt()\n const viewConfig = useGanttViewConfig()\n const gestures = useGanttGestures()\n const { settings } = instance\n const occurrence = segment.occurrence\n const event = occurrence.event\n\n const isSelected = useGanttSelector(\n (state) => state.selection.eventKeys.includes(occurrence.key),\n { calendar: instance }\n )\n const isDragging = useGanttSelector(\n (state) => state.drag?.occurrence.key === occurrence.key,\n { calendar: instance }\n )\n // Which gesture owns this bar: a move hides the original (the smooth clone\n // stands in for it); a resize keeps it as a faint placeholder behind the\n // dashed preview so you can see the original extent.\n const dragKind = useGanttSelector(\n (state) =>\n state.drag?.occurrence.key === occurrence.key ? state.drag.kind : null,\n { calendar: instance }\n )\n // Hover-only range tooltip. Focus opens are ignored (the known button+\n // tooltip flash: clicking a bar opens a dialog, focus returns, and a\n // focus-triggered tooltip would pop). Hidden while dragging/resizing.\n const [tipOpen, setTipOpen] = useState(false)\n // Gated on tipOpen: with the tooltip closed the selector returns a stable\n // false, so gesture start/end doesn't re-render every mounted bar.\n const anyInteracting = useGanttSelector(\n (state) => tipOpen && (state.drag !== null || state.slotDraft !== null),\n { calendar: instance }\n )\n\n const progress =\n typeof event.progress === \"number\"\n ? Math.min(Math.max(Math.round(event.progress), 0), 100)\n : null\n\n const defaultContent = (\n <>\n {occurrence.isRecurring && (\n \n )}\n {event.title}\n {!occurrence.allDay && segment.isStart && (\n \n )}\n \n )\n\n const renderProps = { occurrence, segment, isDragging, isSelected }\n const content =\n children ??\n viewConfig.renderEvent?.(renderProps) ??\n (labelOutside ? null : defaultContent)\n // Consumer-owned content owns the WHOLE inner visualization: the built-in\n // progress fill and done mark yield so custom bars start from a blank\n // canvas (progress stays readable via data-progress/data-completed).\n const consumerOwnsContent = children !== undefined || !!viewConfig.renderEvent\n\n const timeLabel = settings.i18n.functions.formatEventTime(\n toZoned(occurrence.start, settings.timeZone),\n toZoned(occurrence.end, settings.timeZone),\n occurrence.allDay,\n settings.locale\n )\n // name the row too: the split-pane layout carries no grid semantics.\n // The prop path is O(1); the lookup fallback is memoized so external\n // GanttBar usage never flattens the tree per render.\n const fallbackRowTitle = useMemo(\n () =>\n rowTitleProp === undefined && event.resourceId\n ? flattenResources(settings.resources).find(\n ({ resource }) => resource.id === event.resourceId\n )?.resource.title\n : undefined,\n [rowTitleProp, event.resourceId, settings.resources]\n )\n const rowTitle = rowTitleProp ?? fallbackRowTitle\n\n const showResize = gestures.canResize(segment)\n const resizeHandles = showResize && (\n <>\n {segment.isStart && (\n gestures.beginResize(e, segment, \"start\")}\n >\n \n \n )}\n {segment.isEnd && (\n gestures.beginResize(e, segment, \"end\")}\n >\n \n \n )}\n \n )\n\n const Comp = asChild ? Slot.Root : \"button\"\n\n const barButton = (\n {\n e.stopPropagation()\n gestures.beginMove(e, segment)\n }}\n onClick={(e: React.MouseEvent) => {\n e.stopPropagation()\n if (wasRecentDrag()) return\n instance.api.selectEvent(occurrence.key)\n settings.onEventClick?.(occurrence, e)\n }}\n onDoubleClick={(e: React.MouseEvent) => {\n e.stopPropagation()\n settings.onEventDoubleClick?.(occurrence, e)\n }}\n className={cn(\n \"group/gantt-bar-group text-foreground @container relative flex w-full min-w-0 cursor-pointer touch-none items-center gap-1.5 overflow-hidden rounded-sm px-1.5 py-0.5 text-start leading-normal select-none\",\n \"focus-visible:ring-ring/50 outline-none focus-visible:ring-2\",\n // the unfilled remainder has to be legible on its own - at /12 a bar\n // with a progress fill read as a floating segment with no basement\n \"bg-(--gantt-event-color)/20 hover:bg-(--gantt-event-color)/30\",\n // move: hide the original (the smooth cursor clone represents it)\n \"data-[drag-kind=move]:opacity-0\",\n // resize: keep the original event exactly, just fade it to a soft\n // placeholder behind the dashed preview - no dramatic restyle\n \"data-[drag-kind=resize-end]:opacity-40 data-[drag-kind=resize-start]:opacity-40\",\n \"data-selected:bg-(--gantt-event-color)/30\",\n segment.continuesBefore && \"rounded-s-none\",\n segment.continuesAfter && \"rounded-e-none\",\n viewConfig.classNames?.event,\n className\n )}\n {...props}\n >\n {progress !== null && (\n // Chrome, not content: it is an absolutely-positioned layer BEHIND\n // whatever the bar renders, so a consumer bar (renderEvent) keeps its\n // completion fill instead of silently losing it. The inline done-mark\n // below stays gated, because that one really is content.\n \n )}\n {progress === 100 && !consumerOwnsContent && (\n // done mark: completion chrome like the fill itself, so it shows\n // for outside-label bars too (where the inner content is empty)\n \n )}\n {content}\n {resizeHandles}\n \n )\n\n // Consumer-owned right-click menu (headless): the primitive only wires the\n // ContextMenu; the items and their handlers come entirely from the block.\n const menu = viewConfig.renderEventMenu?.(renderProps)\n\n // The bar is simultaneously the tooltip trigger and (when a menu exists)\n // the context-menu trigger; Radix composes both via asChild. The tooltip\n // opens only on hover: Radix has no open reason, so focus opens (the known\n // button+tooltip flash) are suppressed at the trigger via preventDefault,\n // and press already closes the tooltip in Radix.\n const trigger = menu ? (\n \n e.preventDefault()}>\n {barButton}\n \n \n ) : (\n e.preventDefault()}>\n {barButton}\n \n )\n\n const barTree = (\n \n setTipOpen(next)}\n >\n {trigger}\n {tipOpen && !anyInteracting && (\n \n
{event.title}
\n
{timeLabel}
\n
\n )}\n \n
\n )\n\n return (\n \n {menu ? (\n \n {barTree}\n \n {menu}\n \n \n ) : (\n barTree\n )}\n \n )\n}\n\nexport { GANTT_COLORS, GanttBar, useGanttBarContext }\nexport type { GanttBarContextValue, GanttBarProps }","target":"components/neui/gantt/gantt-bar.tsx"},{"path":"gantt-dnd.tsx","type":"registry:ui","content":"// Title: Gantt Dnd\n// Description: Custom pointer-event interaction engine - edge resize and drag-create over the horizontal axis with live validation; bars are never dragged whole.\n\n\"use client\"\n\nimport { useCallback, useEffect } from \"react\"\nimport {\n resolveScheduleMode,\n useGantt,\n useGanttViewConfig,\n type GanttInstance,\n} from \"@/components/neui/gantt/gantt\"\nimport {\n findResource,\n snapMinutes,\n toZoned,\n zonedStartOfDay,\n} from \"@/components/neui/gantt/gantt-lib\"\nimport type {\n GanttProposedUpdate,\n GanttScheduleMode,\n GanttSegment,\n} from \"@/components/neui/gantt/gantt-types\"\nimport { addDays, differenceInCalendarDays } from \"date-fns\"\n\n/**\n * Activation policy (dnd-kit parity where proven):\n * mouse move 5px before a drag starts (below = click), create 4px;\n * touch long-press 250ms with 5px tolerance (movement past tolerance\n * before the delay cancels the drag so taps stay taps).\n */\nconst GANTT_ACTIVATION = {\n moveDistancePx: 5,\n createDistancePx: 4,\n touchDelayMs: 250,\n touchTolerancePx: 5,\n} as const\n\ntype GestureKind = \"move\" | \"resize-start\" | \"resize-end\" | \"create\"\n\ninterface GanttSurface {\n rect: DOMRect\n rangeStart: number\n rangeEnd: number\n snapMin: number\n /** Mirrored axis: in RTL the range START sits at the rect's RIGHT edge. */\n isRtl: boolean\n rows: Array<{ resourceId: string; rect: DOMRect }>\n}\n\n/**\n * Pointer x (viewport px) to minutes from the range start, clamped to the\n * track. The single place the horizontal axis direction is resolved: every\n * gesture mapping (move, both resizes, create, grab offset) goes through it.\n */\nfunction surfaceMinutesAt(tl: GanttSurface, x: number): number {\n const clamped = Math.min(Math.max(x, tl.rect.left), tl.rect.right)\n const traveled = tl.isRtl ? tl.rect.right - clamped : clamped - tl.rect.left\n return (traveled / tl.rect.width) * ((tl.rangeEnd - tl.rangeStart) / 60000)\n}\n\n/** Module flag so bar onClick can ignore the click that ends a drag. */\nlet lastGestureEndedAt = 0\nfunction wasRecentDrag(): boolean {\n return performance.now() - lastGestureEndedAt < 250\n}\n\n/** Mark a non-dnd gesture (e.g. a timeline pan) so the click it ends is ignored. */\nfunction markGestureEnd(): void {\n lastGestureEndedAt = performance.now()\n}\n\n/**\n * Registry of in-flight gesture cancels. A gesture measures its surface\n * (axis + row rects) once at activation, so the VIEW - not the bar, bars\n * legitimately unmount mid-gesture - must be able to abort gestures when it\n * unmounts or when the measured geometry changes under them (zoom, scale,\n * range growth, splitter). Cancel fully reverts: listeners, overlays and the\n * body drag state all clear, and no update is committed.\n */\nconst activeGestureCancels = new Set<() => void>()\n\n/** Cancel (and fully revert) every in-flight gantt pointer gesture. */\nfunction cancelActiveGanttGestures(): void {\n for (const cancel of [...activeGestureCancels]) cancel()\n}\n\n/**\n * View-level teardown, mounted once by GanttView: aborts any in-flight\n * gesture on unmount so window listeners, body-appended overlays and the\n * gantt-dragging body class never outlive the gantt.\n */\nfunction useGanttGestureTeardown(): void {\n useEffect(() => cancelActiveGanttGestures, [])\n}\n\n/**\n * Snap a translate offset to the device pixel grid. The cursor-following\n * overlays (the move clone and the resize indicator) are their own\n * `will-change: transform` compositing layers: the GPU rasterizes their text\n * once and repositions that texture each frame, so a subpixel translate\n * (getBoundingClientRect and raw clientX/Y are routinely fractional) resamples\n * the texture and blurs the text. Rounding each offset to a whole device pixel\n * lands the layer on the grid so glyphs stay crisp, without giving up the\n * per-frame GPU transform.\n */\nfunction snapToPixel(value: number): number {\n const dpr = typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1\n return Math.round(value * dpr) / dpr\n}\n\nfunction collectSurface(root: HTMLElement | null): GanttSurface | null {\n if (!root) return null\n const axis = root.querySelector(\"[data-gantt-axis]\")\n if (!axis) return null\n return {\n rect: axis.getBoundingClientRect(),\n rangeStart: Number(axis.dataset.ganttRangeStart),\n rangeEnd: Number(axis.dataset.ganttRangeEnd),\n snapMin: Number(axis.dataset.ganttSnap) || 15,\n isRtl: getComputedStyle(axis).direction === \"rtl\",\n rows: [...root.querySelectorAll(\"[data-gantt-row]\")]\n // static rows (parents that aggregate their subtree) take no drops\n .filter((row) => row.dataset.ganttRowStatic === undefined)\n .map((row) => ({\n resourceId: row.dataset.ganttResource ?? \"\",\n rect: row.getBoundingClientRect(),\n })),\n }\n}\n\ninterface BeginGestureConfig {\n instance: GanttInstance\n kind: GestureKind\n origin: HTMLElement\n startEvent: PointerEvent\n segment?: GanttSegment\n /** Consumer renders the move preview (renderDragPreview); the engine only positions it. */\n customMoveOverlay?: boolean\n /** Consumer renders the resize indicator; the engine only positions it. */\n customResizeOverlay?: boolean\n /** View-level cardinality default; a node's own scheduleMode wins. */\n scheduleMode?: GanttScheduleMode\n}\n\nfunction beginGesture(config: BeginGestureConfig) {\n const {\n instance,\n kind,\n origin,\n startEvent,\n segment,\n customMoveOverlay,\n customResizeOverlay,\n } = config\n const { settings, internals, api } = instance\n const activation = { ...GANTT_ACTIVATION, ...settings.activation }\n const startX = startEvent.clientX\n const startY = startEvent.clientY\n const pointerId = startEvent.pointerId\n // stable ancestors: bar nodes may be replaced by re-renders mid-gesture\n const viewRoot = origin.closest(\"[data-slot=gantt-view]\")\n const ganttRoot = origin.closest(\"[data-slot=gantt]\")\n const announcer = ganttRoot?.querySelector(\n \"[data-slot=gantt-announcer]\"\n )\n\n /**\n * The cursor-following overlays are appended to document.body so no ancestor\n * transform or overflow can clip them - which also cuts them off from the\n * gantt root, and the root is what OWNS the type scale (its `text-xs` is\n * what every resting label inherits). Without this the clone's label jumps\n * to the document default and reads visibly bigger than the bar it left.\n * Copying the ROOT's resolved metrics - rather than hardcoding a size -\n * keeps the documented contract that one class on the root (e.g.\n * className=\"text-sm\") rescales the whole gantt, drag clone included.\n */\n const adoptRootTypography = (el: HTMLElement) => {\n if (!ganttRoot) return\n const rootStyle = getComputedStyle(ganttRoot)\n el.style.fontSize = rootStyle.fontSize\n el.style.lineHeight = rootStyle.lineHeight\n el.style.fontFamily = rootStyle.fontFamily\n el.style.letterSpacing = rootStyle.letterSpacing\n // physical positioning, logical content: the flex row mirrors so the\n // label lands on the same side of the bar as the resting one in RTL\n el.style.direction = rootStyle.direction\n }\n\n const isTouch = startEvent.pointerType === \"touch\"\n // resize activates immediately on precise pointers; on touch it waits for\n // the same long-press as a move, so a stray brush over a bar edge can\n // never start an accidental resize\n let active = kind.startsWith(\"resize\") && !isTouch\n let surface: GanttSurface | null = active ? collectSurface(viewRoot) : null\n let lastProposalKey = \"\"\n let touchTimer: ReturnType | null = null\n let lastPointer: PointerEvent = startEvent\n\n const occurrence = segment?.occurrence\n\n // ----- neighbour awareness: the other schedules in the SAME node -----\n // A node in \"single\" mode rejects any concurrency regardless of the\n // overlap option; otherwise the option decides. \"allow\" short-circuits\n // everything below, so the default gesture path is untouched.\n const nodeId = occurrence?.event.resourceId\n const nodeMode = resolveScheduleMode(\n nodeId === undefined ? null : findResource(settings.resources, nodeId),\n config.scheduleMode\n )\n const overlapPolicy =\n nodeMode === \"single\" ? (\"reject\" as const) : settings.overlap\n // Read once per gesture: the gantt never mutates events mid-drag, so the\n // neighbours cannot move under us.\n let neighbourCache: Array<{ start: number; end: number }> | null = null\n const getNeighbours = () => {\n if (neighbourCache) return neighbourCache\n neighbourCache =\n !occurrence || nodeId === undefined || overlapPolicy === \"allow\"\n ? []\n : api\n .getOccurrences()\n .filter(\n (other) =>\n other.event.resourceId === nodeId &&\n other.key !== occurrence.key\n )\n .map((other) => ({\n start: other.start.getTime(),\n end: other.end.getTime(),\n }))\n return neighbourCache\n }\n const overlapsNeighbour = (start: Date, end: Date) =>\n getNeighbours().some(\n (other) => other.start < end.getTime() && other.end > start.getTime()\n )\n /**\n * Stop the gesture at the neighbour's edge. Runs AFTER snapping so the\n * clamp always wins, and only against neighbours that sit clear of the\n * bar's CURRENT span - a pre-existing overlap has no edge to stop at.\n */\n const clampToNeighbours = (\n start: Date,\n end: Date\n ): { start: Date; end: Date } => {\n if (overlapPolicy !== \"clamp\" || !occurrence) return { start, end }\n const anchorStart = occurrence.start.getTime()\n const anchorEnd = occurrence.end.getTime()\n let floor = -Infinity\n let ceiling = Infinity\n for (const other of getNeighbours()) {\n if (other.end <= anchorStart) floor = Math.max(floor, other.end)\n else if (other.start >= anchorEnd)\n ceiling = Math.min(ceiling, other.start)\n }\n if (floor === -Infinity && ceiling === Infinity) return { start, end }\n let from = start.getTime()\n let to = end.getTime()\n if (kind === \"resize-start\") {\n from = Math.min(Math.max(from, floor), to)\n } else if (kind === \"resize-end\") {\n to = Math.max(Math.min(to, ceiling), from)\n } else {\n // a move keeps its duration and parks against whichever edge it meets\n const duration = to - from\n if (from < floor) {\n from = floor\n to = from + duration\n }\n if (to > ceiling) {\n to = ceiling\n from = to - duration\n }\n // window narrower than the bar itself: park at the earlier edge\n if (from < floor) {\n from = floor\n to = from + duration\n }\n }\n return { start: new Date(from), end: new Date(to) }\n }\n // Set by applyProposal when a \"reject\" policy refuses the current proposal;\n // read on pointerup so the commit is actually blocked, not merely styled.\n let overlapRejected = false\n\n // Preserve the grab offset so the bar does not jump to the pointer\n let grabOffsetMin = 0\n // Smooth cursor-following clone for a move: a real-looking bar that tracks\n // the pointer's x via transform (no per-frame React), lifted with a shadow.\n let overlay: HTMLDivElement | null = null\n let grabOffsetPx = 0\n let barTop = 0\n let barWidth = 0\n let barHeight = 0\n\n const createMoveOverlay = () => {\n if (kind !== \"move\" || !occurrence || overlay || barWidth === 0) return\n // consumer-rendered preview (renderDragPreview): the view mounts it from\n // drag state; positionOverlay adopts it lazily and only writes transforms\n if (customMoveOverlay) return\n const color = occurrence.event.color ?? \"var(--color-primary)\"\n overlay = document.createElement(\"div\")\n overlay.setAttribute(\"data-slot\", \"gantt-drag-overlay\")\n // container: the bar + its label ride together; the label stays OUTSIDE\n // the bar (to the right), matching the resting look - no in-bar text\n overlay.className =\n // physical left-0 anchor: the clone is positioned by translate3d from\n // raw clientX, which is physical - a logical start-0 anchor would pin\n // it to the RIGHT edge in RTL and fling the clone off screen\n \"pointer-events-none fixed top-0 left-0 z-100 flex items-center gap-2 will-change-transform\"\n adoptRootTypography(overlay)\n overlay.style.height = `${barHeight}px`\n const barEl = document.createElement(\"div\")\n barEl.className = \"shrink-0 rounded-sm shadow-lg\"\n barEl.style.width = `${barWidth}px`\n barEl.style.height = \"100%\"\n barEl.style.background = `color-mix(in oklab, ${color} 22%, var(--color-background))`\n barEl.style.outline = `1px solid color-mix(in oklab, ${color} 55%, transparent)`\n overlay.appendChild(barEl)\n const label = document.createElement(\"span\")\n label.className = \"text-foreground truncate font-medium whitespace-nowrap\"\n label.textContent = occurrence.event.title\n overlay.appendChild(label)\n document.body.appendChild(overlay)\n positionOverlay(lastPointer)\n }\n\n const positionOverlay = (e: PointerEvent) => {\n if (!overlay && customMoveOverlay && active && kind === \"move\") {\n overlay = document.querySelector(\n \"[data-slot=gantt-drag-overlay][data-custom]\"\n )\n if (overlay) overlay.style.visibility = \"visible\"\n }\n if (!overlay) return\n // x follows the pointer freely (smooth); y stays on the bar's own row\n overlay.style.transform = `translate3d(${snapToPixel(e.clientX - grabOffsetPx)}px, ${snapToPixel(barTop)}px, 0)`\n }\n\n // Resize status indicator: a smooth cursor-following edge line plus a live\n // range + duration chip. The dashed ghost still shows the SNAPPED landing;\n // this overlay is the continuous feedback between snap steps.\n let resizeOverlay: HTMLDivElement | null = null\n let resizeLine: HTMLDivElement | null = null\n let resizeRange: HTMLSpanElement | null = null\n let resizeDot: HTMLSpanElement | null = null\n let resizeDuration: HTMLSpanElement | null = null\n\n const positionResizeOverlay = (e: PointerEvent) => {\n if (!resizeOverlay && customResizeOverlay && kind.startsWith(\"resize\")) {\n resizeOverlay = document.querySelector(\n \"[data-slot=gantt-resize-indicator][data-custom]\"\n )\n if (resizeOverlay) resizeOverlay.style.visibility = \"visible\"\n }\n if (!resizeOverlay || !surface) return\n // x follows the pointer freely (clamped to the track); y stays on the bar\n const x = Math.min(\n Math.max(e.clientX, surface.rect.left),\n surface.rect.right\n )\n resizeOverlay.style.transform = `translate3d(${snapToPixel(x)}px, ${snapToPixel(barTop)}px, 0)`\n }\n\n const createResizeOverlay = () => {\n if (!kind.startsWith(\"resize\") || !occurrence || resizeOverlay) return\n const barEl = origin.closest(\"[data-slot=gantt-bar]\")\n const rect = (barEl ?? origin).getBoundingClientRect()\n barTop = rect.top\n barHeight = rect.height\n // consumer-rendered indicator: rect capture above still runs (the engine\n // positions the consumer's wrapper), only the default DOM is skipped\n if (customResizeOverlay) return\n const color = occurrence.event.color ?? \"var(--color-primary)\"\n resizeOverlay = document.createElement(\"div\")\n resizeOverlay.setAttribute(\"data-slot\", \"gantt-resize-indicator\")\n resizeOverlay.className =\n // physical left-0 anchor, same reason as the move clone above\n \"pointer-events-none fixed top-0 left-0 z-100 will-change-transform\"\n adoptRootTypography(resizeOverlay)\n resizeOverlay.style.height = `${barHeight}px`\n resizeLine = document.createElement(\"div\")\n resizeLine.className = \"h-full w-0.5 -translate-x-1/2 rounded-full\"\n resizeLine.style.background = color\n resizeOverlay.appendChild(resizeLine)\n const chip = document.createElement(\"div\")\n chip.className =\n // physical left-0: centered with a physical translate on a physical anchor\n // no text size of its own: it inherits the root scale adopted above, so\n // the chip tracks a consumer rescale instead of pinning itself to 12px\n \"bg-foreground text-background absolute bottom-full left-0 mb-1.5 flex -translate-x-1/2 items-center gap-1.5 rounded-md px-2 py-1 font-medium whitespace-nowrap\"\n resizeRange = document.createElement(\"span\")\n chip.appendChild(resizeRange)\n resizeDot = document.createElement(\"span\")\n resizeDot.className = \"bg-background/40 size-1 shrink-0 rounded-full\"\n resizeDot.setAttribute(\"aria-hidden\", \"true\")\n chip.appendChild(resizeDot)\n resizeDuration = document.createElement(\"span\")\n chip.appendChild(resizeDuration)\n // The arrow. Every other bubble in the gantt has one pointing at what it\n // describes; this chip had none, so a resize looked like a different\n // component from the hover hint. Physical left-1/2 to match the chip's own\n // physical anchor, and out of flow so the chip's flex gap ignores it.\n const chipArrow = document.createElement(\"span\")\n chipArrow.setAttribute(\"aria-hidden\", \"true\")\n chipArrow.className =\n \"bg-foreground absolute -bottom-1 left-1/2 size-2.5 -translate-x-1/2 rotate-45 rounded-[2px]\"\n chip.appendChild(chipArrow)\n resizeOverlay.appendChild(chip)\n document.body.appendChild(resizeOverlay)\n // seed the chip with the CURRENT range so it never flashes empty;\n // zoned so the label names the same day the grid shows\n resizeRange.textContent = settings.i18n.functions.formatEventTime(\n toZoned(occurrence.start, settings.timeZone),\n toZoned(occurrence.end, settings.timeZone),\n occurrence.allDay ?? false,\n settings.locale\n )\n const days = Math.round(\n (occurrence.end.getTime() - occurrence.start.getTime()) / 86_400_000\n )\n if (days >= 1) {\n resizeDuration.textContent = settings.i18n.labels.durationDays(days)\n } else {\n resizeDot.style.display = \"none\"\n resizeDuration.style.display = \"none\"\n }\n positionResizeOverlay(startEvent)\n }\n\n // resize activates immediately, so its indicator mounts with the gesture\n if (active) createResizeOverlay()\n\n const activationDistance =\n kind === \"create\" ? activation.createDistancePx : activation.moveDistancePx\n\n // Each gesture keeps its own cursor: a resize must stay ew-resize for the\n // whole drag (flipping to grabbing reads as a move), a move grabs.\n const gestureCursor = kind.startsWith(\"resize\") ? \"ew-resize\" : \"grabbing\"\n const setBodyDragging = (on: boolean, invalid = false) => {\n document.body.classList.toggle(\"gantt-dragging\", on)\n document.body.style.cursor = on\n ? invalid\n ? \"not-allowed\"\n : gestureCursor\n : \"\"\n document.body.style.userSelect = on ? \"none\" : \"\"\n if (!on) document.body.style.removeProperty(\"-webkit-user-select\")\n }\n\n const activate = () => {\n if (active) return\n active = true\n surface = collectSurface(viewRoot)\n // touch resize activates here (long-press) instead of at gesture start,\n // so its indicator mounts now; the guard inside makes this a no-op for\n // every other path\n createResizeOverlay()\n if (kind === \"move\" && occurrence && surface) {\n const pointerMin = surfaceMinutesAt(surface, startX)\n // TRUE start, never clamped to the range: a bar that begins before the\n // visible window (negative minutes) must keep its real grab offset, or\n // the first snapped proposal teleports its start to the range edge\n const occStartMin =\n (occurrence.start.getTime() - surface.rangeStart) / 60000\n grabOffsetMin = pointerMin - occStartMin\n const rect = origin.getBoundingClientRect()\n barTop = rect.top\n barWidth = rect.width\n barHeight = rect.height\n grabOffsetPx = startX - rect.left\n createMoveOverlay()\n }\n setBodyDragging(true)\n }\n\n const computeProposal = (\n e: PointerEvent\n ): {\n start: Date\n end: Date\n allDay: boolean\n resourceId?: string\n } | null => {\n if (!surface) return null\n const tl = surface\n const rangeMinutes = (tl.rangeEnd - tl.rangeStart) / 60000\n const minutesAt = (x: number) => surfaceMinutesAt(tl, x)\n // Day-grid scales snap to real zoned midnights, not 1440-minute\n // multiples from the range start - those drift by an hour across DST\n const snapMin = (minutes: number) => {\n if (tl.snapMin < 24 * 60) return snapMinutes(minutes, tl.snapMin)\n const ms = tl.rangeStart + minutes * 60000\n const dayStart = zonedStartOfDay(new Date(ms), settings.timeZone)\n const dayEnd = zonedStartOfDay(\n addDays(toZoned(new Date(ms), settings.timeZone), 1),\n settings.timeZone\n )\n const snapped =\n ms - dayStart.getTime() < dayEnd.getTime() - ms ? dayStart : dayEnd\n return (snapped.getTime() - tl.rangeStart) / 60000\n }\n const rowAt = (y: number) => {\n let best = tl.rows[0]\n for (const row of tl.rows) {\n if (y >= row.rect.top && y < row.rect.bottom) return row\n if (\n best &&\n Math.abs(y - (row.rect.top + row.rect.height / 2)) <\n Math.abs(y - (best.rect.top + best.rect.height / 2))\n ) {\n best = row\n }\n }\n return best\n }\n const at = (minutes: number) => new Date(tl.rangeStart + minutes * 60000)\n\n if (kind === \"create\") {\n const anchorMin = snapMin(minutesAt(startX))\n const curMin = snapMin(minutesAt(e.clientX))\n const lo = Math.min(anchorMin, curMin)\n // a bare click still yields a usable slot: at least slotDuration long\n const hi = Math.max(\n anchorMin,\n curMin,\n lo + Math.max(tl.snapMin, settings.slotDuration)\n )\n return {\n start: at(lo),\n end: at(hi),\n allDay: false,\n resourceId: rowAt(startY)?.resourceId,\n }\n }\n if (!occurrence) return null\n const midnightAligned = (d: Date) =>\n zonedStartOfDay(d, settings.timeZone).getTime() === d.getTime()\n if (kind === \"move\") {\n // x-axis only: the bar slides along its OWN row, never across rows.\n // The proposal preserves the pointer DELTA - no clamping to the visible\n // range, or bars crossing the window edge would teleport to it.\n const start = at(snapMin(minutesAt(e.clientX) - grabOffsetMin))\n // Day-snapped scales preserve the CALENDAR span for day-aligned bars:\n // a 3-day bar dragged across a DST change stays midnight-to-midnight\n // (72h +/- 1h), never drifting to a 23:00 end. Sub-day events keep\n // their exact ms duration.\n let end: Date\n if (\n tl.snapMin >= 24 * 60 &&\n (occurrence.allDay ||\n (midnightAligned(occurrence.start) &&\n midnightAligned(occurrence.end)))\n ) {\n const daySpan = Math.max(\n differenceInCalendarDays(\n toZoned(occurrence.end, settings.timeZone),\n toZoned(occurrence.start, settings.timeZone)\n ),\n 1\n )\n end = zonedStartOfDay(\n addDays(toZoned(start, settings.timeZone), daySpan),\n settings.timeZone\n )\n } else {\n end = new Date(\n start.getTime() +\n (occurrence.end.getTime() - occurrence.start.getTime())\n )\n }\n const bounded = clampToNeighbours(start, end)\n return {\n start: bounded.start,\n end: bounded.end,\n allDay: occurrence.allDay,\n resourceId: occurrence.event.resourceId,\n }\n }\n const min = snapMin(minutesAt(e.clientX))\n if (kind === \"resize-start\") {\n const endMin = (occurrence.end.getTime() - tl.rangeStart) / 60000\n // Minimum length = one snap unit; on day grids that unit is the LAST\n // zoned midnight before the end (raw 1440-minute arithmetic lands off\n // the midnight grid across DST changes).\n const maxStartMin =\n tl.snapMin >= 24 * 60\n ? (zonedStartOfDay(\n midnightAligned(occurrence.end)\n ? addDays(toZoned(occurrence.end, settings.timeZone), -1)\n : occurrence.end,\n settings.timeZone\n ).getTime() -\n tl.rangeStart) /\n 60000\n : endMin - tl.snapMin\n const clamped = Math.min(Math.max(min, 0), maxStartMin)\n const bounded = clampToNeighbours(at(clamped), occurrence.end)\n return {\n start: bounded.start,\n end: bounded.end,\n allDay: occurrence.allDay,\n resourceId: occurrence.event.resourceId,\n }\n }\n const startMin = (occurrence.start.getTime() - tl.rangeStart) / 60000\n // Mirror of the resize-start bound: the FIRST zoned midnight after the\n // start on day grids, plain snap arithmetic otherwise.\n const minEndMin =\n tl.snapMin >= 24 * 60\n ? (zonedStartOfDay(\n addDays(toZoned(occurrence.start, settings.timeZone), 1),\n settings.timeZone\n ).getTime() -\n tl.rangeStart) /\n 60000\n : startMin + tl.snapMin\n const clamped = Math.max(Math.min(min, rangeMinutes), minEndMin)\n const bounded = clampToNeighbours(occurrence.start, at(clamped))\n return {\n start: bounded.start,\n end: bounded.end,\n allDay: occurrence.allDay,\n resourceId: occurrence.event.resourceId,\n }\n }\n\n const applyProposal = (e: PointerEvent) => {\n const proposal = computeProposal(e)\n if (!proposal) return\n const key = `${proposal.start.getTime()}-${proposal.end.getTime()}-${proposal.allDay}-${proposal.resourceId ?? \"\"}`\n if (key === lastProposalKey) return\n lastProposalKey = key\n\n if (kind === \"create\") {\n const draft = { ...proposal }\n if (settings.canSelectSlot && !settings.canSelectSlot(draft)) return\n internals.setSlotDraft(draft)\n return\n }\n const update: GanttProposedUpdate = {\n event: occurrence!.event,\n occurrence: occurrence!,\n ...proposal,\n source:\n kind === \"move\" ? \"drag\" : (kind as \"resize-start\" | \"resize-end\"),\n }\n // \"reject\" is the one veto the engine owns: it both styles the ghost AND\n // blocks the commit below. canDropEvent stays advisory, as documented.\n overlapRejected =\n overlapPolicy === \"reject\" &&\n overlapsNeighbour(proposal.start, proposal.end)\n const valid =\n !overlapRejected &&\n (settings.canDropEvent ? settings.canDropEvent(update) : true)\n // live status: the indicator chip always names the CURRENT proposed\n // range; the edge line flips to destructive on an invalid drop\n if (resizeRange && resizeDot && resizeDuration) {\n resizeRange.textContent = settings.i18n.functions.formatEventTime(\n toZoned(proposal.start, settings.timeZone),\n toZoned(proposal.end, settings.timeZone),\n proposal.allDay,\n settings.locale\n )\n const days = Math.round(\n (proposal.end.getTime() - proposal.start.getTime()) / 86_400_000\n )\n const showDays = days >= 1\n resizeDot.style.display = showDays ? \"\" : \"none\"\n resizeDuration.style.display = showDays ? \"\" : \"none\"\n if (showDays) {\n resizeDuration.textContent = settings.i18n.labels.durationDays(days)\n }\n }\n if (resizeLine) {\n resizeLine.style.background = valid\n ? (occurrence!.event.color ?? \"var(--color-primary)\")\n : \"var(--color-destructive)\"\n }\n setBodyDragging(true, !valid)\n internals.setDrag({\n kind: kind === \"move\" ? \"move\" : (kind as \"resize-start\" | \"resize-end\"),\n occurrence: occurrence!,\n proposedStart: proposal.start,\n proposedEnd: proposal.end,\n proposedAllDay: proposal.allDay,\n proposedResourceId: proposal.resourceId,\n valid,\n })\n }\n\n // ----- edge auto-scroll: pan the timeline while dragging near its edge -----\n // The pointer is clamped to the visible track, so without this a bar can\n // never travel past the window. Holding the pointer inside the edge zone\n // scrolls the viewport (speed eased by proximity), refreshes the track rect\n // (the axis moved under the pointer) and re-derives the proposal from the\n // same pointer position. Programmatic scrolls never mark user intent, so\n // this can never trigger infinite-range growth mid-gesture.\n const AUTO_SCROLL_EDGE_PX = 24\n const AUTO_SCROLL_MAX_SPEED = 14\n let autoScrollRaf = 0\n const timelineViewport = viewRoot?.querySelector(\n \"[data-slot=gantt-timeline-pane] [data-slot=scroll-area-viewport]\"\n )\n const autoScrollTick = () => {\n autoScrollRaf = 0\n if (finished || !active || !surface || !timelineViewport) return\n const paneRect = timelineViewport.getBoundingClientRect()\n const x = lastPointer.clientX\n let speed = 0\n if (x < paneRect.left + AUTO_SCROLL_EDGE_PX) {\n speed =\n -((paneRect.left + AUTO_SCROLL_EDGE_PX - x) / AUTO_SCROLL_EDGE_PX) *\n AUTO_SCROLL_MAX_SPEED\n } else if (x > paneRect.right - AUTO_SCROLL_EDGE_PX) {\n speed =\n ((x - (paneRect.right - AUTO_SCROLL_EDGE_PX)) / AUTO_SCROLL_EDGE_PX) *\n AUTO_SCROLL_MAX_SPEED\n }\n if (speed === 0) return\n const before = timelineViewport.scrollLeft\n timelineViewport.scrollLeft = before + speed\n if (timelineViewport.scrollLeft === before) return // parked on the end\n const axis = viewRoot?.querySelector(\"[data-gantt-axis]\")\n if (axis) surface.rect = axis.getBoundingClientRect()\n applyProposal(lastPointer)\n positionResizeOverlay(lastPointer)\n scheduleAutoScroll()\n }\n const scheduleAutoScroll = () => {\n if (!autoScrollRaf) autoScrollRaf = requestAnimationFrame(autoScrollTick)\n }\n\n // idempotent: pointerup, pointercancel, Escape, blur and the view-level\n // teardown can race; whichever lands first wins and the rest no-op\n let finished = false\n const cleanup = () => {\n if (finished) return\n finished = true\n activeGestureCancels.delete(cancel)\n if (autoScrollRaf) cancelAnimationFrame(autoScrollRaf)\n try {\n origin.releasePointerCapture(pointerId)\n } catch {\n // capture already released (pointer gone or origin detached)\n }\n window.removeEventListener(\"pointermove\", onPointerMove)\n window.removeEventListener(\"pointerup\", onPointerUp)\n window.removeEventListener(\"pointercancel\", onCancel)\n window.removeEventListener(\"blur\", onWindowBlur)\n window.removeEventListener(\"keydown\", onKeyDown, true)\n if (touchTimer) clearTimeout(touchTimer)\n // consumer-rendered overlays are React-owned: they unmount when the drag\n // state clears, so the engine must never removeChild them itself\n if (!customMoveOverlay) overlay?.remove()\n overlay = null\n if (!customResizeOverlay) resizeOverlay?.remove()\n resizeOverlay = null\n setBodyDragging(false)\n }\n\n const cancel = () => {\n cleanup()\n if (active) {\n lastGestureEndedAt = performance.now()\n internals.setDrag(null)\n internals.setSlotDraft(null)\n }\n }\n\n const onKeyDown = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") {\n e.stopPropagation()\n cancel()\n }\n }\n\n // focus loss mid-gesture (alt-tab, OS dialogs) means the release may never\n // be delivered; treat it as a cancel so the gesture cannot get stuck\n const onWindowBlur = () => cancel()\n\n const onPointerMove = (e: PointerEvent) => {\n if (e.pointerId !== pointerId) return\n lastPointer = e\n if (!active) {\n const distance = Math.hypot(e.clientX - startX, e.clientY - startY)\n if (isTouch) {\n // Long-press pending: moving past tolerance means scroll, not drag\n if (distance > activation.touchTolerancePx) cancel()\n return\n }\n if (distance < activationDistance) return\n activate()\n }\n applyProposal(e)\n positionOverlay(e)\n positionResizeOverlay(e)\n scheduleAutoScroll()\n }\n\n const onPointerUp = (e: PointerEvent) => {\n if (e.pointerId !== pointerId) return\n cleanup()\n if (!active) return\n lastGestureEndedAt = performance.now()\n\n const state = instance.getState()\n if (kind === \"create\") {\n const draft = state.slotDraft\n internals.setSlotDraft(null)\n if (draft) {\n api.select({\n slot: { start: draft.start, end: draft.end, allDay: draft.allDay },\n })\n settings.onSelectSlot?.(draft)\n }\n return\n }\n const drag = state.drag\n internals.setDrag(null)\n if (!drag || !occurrence) return\n // the node refuses concurrency: revert instead of committing an overlap\n if (overlapRejected) return\n const unchanged =\n drag.proposedStart.getTime() === occurrence.start.getTime() &&\n drag.proposedEnd.getTime() === occurrence.end.getTime() &&\n (drag.proposedResourceId === undefined ||\n drag.proposedResourceId === occurrence.event.resourceId)\n if (unchanged) return\n // Commit through the one validation funnel; consumer reject = automatic\n // revert because the gantt never mutated during the gesture.\n const accepted = internals.applyProposedUpdate({\n event: occurrence.event,\n occurrence,\n start: drag.proposedStart,\n end: drag.proposedEnd,\n allDay: drag.proposedAllDay,\n resourceId: drag.proposedResourceId,\n source:\n kind === \"move\" ? \"drag\" : (kind as \"resize-start\" | \"resize-end\"),\n })\n if (accepted && announcer) {\n announcer.textContent = `${occurrence.event.title}, ${settings.i18n.functions.formatEventTime(\n toZoned(drag.proposedStart, settings.timeZone),\n toZoned(drag.proposedEnd, settings.timeZone),\n drag.proposedAllDay,\n settings.locale\n )}`\n }\n }\n\n const onCancel = (e: PointerEvent) => {\n if (e.pointerId !== pointerId) return\n cancel()\n }\n\n window.addEventListener(\"pointermove\", onPointerMove)\n window.addEventListener(\"pointerup\", onPointerUp)\n window.addEventListener(\"pointercancel\", onCancel)\n window.addEventListener(\"blur\", onWindowBlur)\n window.addEventListener(\"keydown\", onKeyDown, true)\n activeGestureCancels.add(cancel)\n\n // Capture the pointer so a release OUTSIDE the OS window still delivers\n // pointerup here instead of leaving the gesture stuck. Captured events keep\n // bubbling to the window listeners above, and if the origin node is removed\n // mid-gesture the capture auto-releases - behavior then degrades to plain\n // window listeners, never worse than before. Guarded: the pointer can\n // already be gone by now (fast flicks, synthetic events).\n try {\n origin.setPointerCapture(pointerId)\n } catch {\n // capture is an enhancement, never a requirement\n }\n\n // Touch: long-press activation (movement past tolerance cancels above)\n if (isTouch && !active) {\n touchTimer = setTimeout(() => {\n activate()\n applyProposal(lastPointer)\n }, activation.touchDelayMs)\n }\n}\n\n/** Per-bar / per-row pointer gesture wiring. */\nfunction useGanttGestures() {\n const instance = useGantt()\n const viewConfig = useGanttViewConfig()\n // presence flags only: the engine skips its default overlay DOM and\n // positions the consumer-rendered node instead\n const customMoveOverlay = !!viewConfig.renderDragPreview\n const customResizeOverlay = !!viewConfig.renderResizeIndicator\n\n const canDrag = useCallback(\n (segment: GanttSegment) => {\n const { interactions } = instance.getState()\n const event = segment.occurrence.event\n return interactions.drag && !event.readOnly && event.draggable !== false\n },\n [instance]\n )\n\n const beginMove = useCallback(\n (e: React.PointerEvent, segment: GanttSegment) => {\n if (e.button !== 0 || !canDrag(segment)) return\n beginGesture({\n instance,\n kind: \"move\",\n origin: e.currentTarget as HTMLElement,\n startEvent: e.nativeEvent,\n segment,\n customMoveOverlay,\n scheduleMode: viewConfig.scheduleMode,\n })\n },\n [instance, canDrag, customMoveOverlay, viewConfig.scheduleMode]\n )\n\n const canResize = useCallback(\n (segment: GanttSegment) => {\n const { interactions } = instance.getState()\n const event = segment.occurrence.event\n return interactions.resize && !event.readOnly && event.resizable !== false\n },\n [instance]\n )\n\n const beginResize = useCallback(\n (\n e: React.PointerEvent,\n segment: GanttSegment,\n edge: \"start\" | \"end\"\n ) => {\n if (e.button !== 0 || !canResize(segment)) return\n e.stopPropagation()\n e.preventDefault()\n beginGesture({\n instance,\n kind: edge === \"start\" ? \"resize-start\" : \"resize-end\",\n origin: e.currentTarget as HTMLElement,\n startEvent: e.nativeEvent,\n segment,\n customResizeOverlay,\n scheduleMode: viewConfig.scheduleMode,\n })\n },\n [instance, canResize, customResizeOverlay, viewConfig.scheduleMode]\n )\n\n const beginCreate = useCallback(\n (e: React.PointerEvent) => {\n if (e.button !== 0) return\n if (!instance.getState().interactions.selectSlot) return\n beginGesture({\n instance,\n kind: \"create\",\n origin: e.currentTarget as HTMLElement,\n startEvent: e.nativeEvent,\n })\n },\n [instance]\n )\n\n return { beginMove, beginResize, beginCreate, canDrag, canResize }\n}\n\nexport {\n cancelActiveGanttGestures,\n GANTT_ACTIVATION,\n markGestureEnd,\n useGanttGestures,\n useGanttGestureTeardown,\n wasRecentDrag,\n}","target":"components/neui/gantt/gantt-dnd.tsx"},{"path":"gantt-i18n.tsx","type":"registry:ui","content":"// Title: Gantt I18n\n// Description: Default UI texts, date-format strings, and formatter functions for the gantt, fully overridable per key.\n\nimport type {\n GanttDateRange,\n GanttScale,\n} from \"@/components/neui/gantt/gantt-types\"\nimport {\n format,\n isSameMonth,\n isSameYear,\n subMilliseconds,\n type Locale,\n} from \"date-fns\"\n\ninterface GanttI18nConfig {\n labels: {\n today: string\n previous: string\n next: string\n addEvent: string\n /** \"Add task\" hint at the foot of the tree. */\n addTask: string\n allDay: string\n loading: string\n event: string\n events: (count: number) => string\n week: (weekNumber: number) => string\n resources: string\n goToDate: string\n /** Hover hint over empty row space, click-only create. */\n scheduleHint: string\n /** Same hint where dragCreate is on and a drag paints a range. */\n scheduleHintDrag: string\n reorder: string\n /** Scale switcher label (\"Timeline scale\"). */\n selectView: string\n zoomIn: string\n zoomOut: string\n /** Aria-label of the tree/timeline splitter. */\n resizePanel: string\n /** Aria-label of the off-screen bar chips. */\n jumpToBar: (title: string) => string\n /** Read to screen readers as part of the bar label. */\n progress: (percent: number) => string\n /** Live duration readout on the resize indicator. */\n durationDays: (days: number) => string\n /** Appended to the bar aria-label when its segment is clipped by the range. */\n continues: string\n scales: {\n day: string\n week: string\n month: string\n quarter: string\n year: string\n }\n }\n /** date-fns format strings, applied with the gantt `locale`. */\n formats: {\n monthTitle: string\n dayTitle: string\n timeGutter: string\n eventTime: string\n }\n functions: {\n formatTitle: (\n scale: GanttScale,\n ctx: {\n date: Date\n activeRange: GanttDateRange\n visibleRange: GanttDateRange\n locale?: Locale\n }\n ) => string\n formatEventTime: (\n start: Date,\n end: Date,\n allDay: boolean,\n locale?: Locale\n ) => string\n formatDayRange: (range: GanttDateRange, locale?: Locale) => string\n /** Composes the bar's screen-reader label from its localized parts. */\n formatEventAriaLabel: (parts: {\n title: string\n timeLabel: string\n rowTitle?: string\n progressLabel?: string\n continues: boolean\n }) => string\n }\n}\n\nconst DEFAULT_LABELS: GanttI18nConfig[\"labels\"] = {\n today: \"Today\",\n previous: \"Previous\",\n next: \"Next\",\n addEvent: \"Add event\",\n addTask: \"Add task\",\n allDay: \"All day\",\n loading: \"Loading events\",\n event: \"event\",\n events: (count) => (count === 1 ? \"1 event\" : `${count} events`),\n week: (weekNumber) => `W${weekNumber}`,\n resources: \"Resources\",\n goToDate: \"Go to date\",\n scheduleHint: \"Click to add a schedule\",\n scheduleHintDrag: \"Click or drag to add a schedule\",\n reorder: \"Reorder\",\n selectView: \"Select view\",\n zoomIn: \"Zoom in\",\n zoomOut: \"Zoom out\",\n resizePanel: \"Resize panel\",\n jumpToBar: (title) => `Scroll to \"${title}\"`,\n progress: (percent) => `${percent}% complete`,\n durationDays: (days) => (days === 1 ? \"1 day\" : `${days} days`),\n continues: \"continues\",\n scales: {\n day: \"Day\",\n week: \"Week\",\n month: \"Month\",\n quarter: \"Quarter\",\n year: \"Year\",\n },\n}\n\nconst DEFAULT_FORMATS: GanttI18nConfig[\"formats\"] = {\n monthTitle: \"MMMM yyyy\",\n dayTitle: \"EEEE, MMMM d, yyyy\",\n timeGutter: \"h a\",\n eventTime: \"h:mm a\",\n}\n\n/**\n * Default formatting functions BOUND to a config's labels/formats, so that\n * `formats` overrides flow into the default renderers (a consumer overriding\n * formats.eventTime without replacing formatEventTime still sees it applied).\n */\nfunction makeDefaultGanttFunctions(\n cfg: Pick\n): GanttI18nConfig[\"functions\"] {\n return {\n formatTitle: (scale, { date, activeRange, locale }) => {\n const opts = { locale }\n if (scale === \"day\") {\n return format(date, cfg.formats.dayTitle, opts)\n }\n if (scale === \"month\") {\n return format(date, cfg.formats.monthTitle, opts)\n }\n if (scale === \"quarter\") {\n return format(date, \"QQQ yyyy\", opts)\n }\n if (scale === \"year\") {\n return format(date, \"yyyy\", opts)\n }\n // week: smart range label, last day is activeRange.end - 1ms.\n // subMilliseconds keeps the zoned date type (a plain new Date(ms)\n // would flip the label to the machine zone near midnight)\n const rangeEnd = subMilliseconds(activeRange.end, 1)\n const start = activeRange.start\n if (isSameMonth(start, rangeEnd)) {\n return `${format(start, \"MMMM d\", opts)} - ${format(rangeEnd, \"d, yyyy\", opts)}`\n }\n if (isSameYear(start, rangeEnd)) {\n return `${format(start, \"MMM d\", opts)} - ${format(rangeEnd, \"MMM d, yyyy\", opts)}`\n }\n return `${format(start, \"MMM d, yyyy\", opts)} - ${format(rangeEnd, \"MMM d, yyyy\", opts)}`\n },\n formatEventTime: (start, end, allDay, locale) => {\n const opts = { locale }\n if (allDay) {\n // a gantt bar is a DATE RANGE: show it, never a bare \"All day\".\n // Ends are exclusive midnights, so the last shown day is end - 1ms;\n // subMilliseconds keeps the caller's zoned date type intact.\n const last =\n end.getTime() - 1 >= start.getTime() ? subMilliseconds(end, 1) : start\n const sameDay =\n format(start, \"yyyy-MM-dd\") === format(last, \"yyyy-MM-dd\")\n if (sameDay) return format(start, \"MMM d, yyyy\", opts)\n if (isSameYear(start, last)) {\n return `${format(start, \"MMM d\", opts)} - ${format(last, \"MMM d, yyyy\", opts)}`\n }\n return `${format(start, \"MMM d, yyyy\", opts)} - ${format(last, \"MMM d, yyyy\", opts)}`\n }\n const fmt = cfg.formats.eventTime\n // Multi-day timed events carry the date on both sides. Compare calendar\n // days off the last rendered instant (end is exclusive, so a 14:00 to\n // midnight bar still ends on the start day). Elapsed ms would miss an\n // exactly-24h bar and a DST day that only runs 23 hours.\n const lastInstant =\n end.getTime() - 1 >= start.getTime() ? subMilliseconds(end, 1) : start\n if (format(start, \"yyyy-MM-dd\") !== format(lastInstant, \"yyyy-MM-dd\")) {\n return `${format(start, `MMM d, ${fmt}`, opts)} - ${format(end, `MMM d, ${fmt}`, opts)}`\n }\n return `${format(start, fmt, opts)} - ${format(end, fmt, opts)}`\n },\n formatDayRange: (range, locale) => {\n const opts = { locale }\n const rangeEnd = subMilliseconds(range.end, 1)\n return `${format(range.start, \"MMM d\", opts)} - ${format(rangeEnd, \"MMM d\", opts)}`\n },\n formatEventAriaLabel: ({\n title,\n timeLabel,\n rowTitle,\n progressLabel,\n continues,\n }) =>\n [\n title,\n timeLabel,\n rowTitle,\n progressLabel,\n continues ? cfg.labels.continues : undefined,\n ]\n .filter(Boolean)\n .join(\", \"),\n }\n}\n\nconst DEFAULT_GANTT_I18N: GanttI18nConfig = {\n labels: DEFAULT_LABELS,\n formats: DEFAULT_FORMATS,\n functions: makeDefaultGanttFunctions({\n labels: DEFAULT_LABELS,\n formats: DEFAULT_FORMATS,\n }),\n}\n\n/** Deep-partial override shape: replace individual keys, never sections. */\ninterface GanttI18nOverrides {\n labels?: Partial> & {\n scales?: Partial\n }\n formats?: Partial\n functions?: Partial\n}\n\n/**\n * Shallow merge per nested object, matching the filters.tsx i18n contract:\n * a partial override replaces individual keys, never whole sections. Default\n * functions are re-bound to the MERGED labels/formats so a `formats` (or\n * `labels.continues`) override reaches the default renderers; explicit\n * `functions` overrides still win.\n */\nfunction mergeGanttI18n(overrides?: GanttI18nOverrides): GanttI18nConfig {\n if (!overrides) return DEFAULT_GANTT_I18N\n const labels = {\n ...DEFAULT_LABELS,\n ...overrides.labels,\n // nested section: replace individual scale names, never the whole set\n scales: {\n ...DEFAULT_LABELS.scales,\n ...overrides.labels?.scales,\n },\n }\n const formats = { ...DEFAULT_FORMATS, ...overrides.formats }\n return {\n labels,\n formats,\n functions: {\n ...makeDefaultGanttFunctions({ labels, formats }),\n ...overrides.functions,\n },\n }\n}\n\nexport { DEFAULT_GANTT_I18N, mergeGanttI18n }\nexport type { GanttI18nConfig, GanttI18nOverrides }","target":"components/neui/gantt/gantt-i18n.tsx"},{"path":"gantt-lib.tsx","type":"registry:ui","content":"// Title: Gantt Lib\n// Description: Pure, React-free calendar math: view ranges, zoned day keys, multi-day segmentation, overlap packing, lane packing, and the event index.\n\nimport { expandRecurrence } from \"@/components/neui/gantt/gantt-recurrence\"\nimport type {\n GanttDateRange,\n GanttEvent,\n GanttOccurrence,\n GanttOffDaysConfig,\n GanttResource,\n GanttScale,\n GanttSegment,\n} from \"@/components/neui/gantt/gantt-types\"\nimport { TZDate } from \"@date-fns/tz\"\nimport {\n addDays,\n addMonths,\n addWeeks,\n addYears,\n differenceInMinutes,\n format,\n startOfDay,\n startOfMonth,\n startOfQuarter,\n startOfWeek,\n startOfYear,\n} from \"date-fns\"\n\ntype WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6\n\n/**\n * Packing-effective minimum in minutes so tiny events do not stack invisibly.\n * It is a packing FOOTPRINT, not a render size: two schedules less than this\n * apart are treated as concurrent and split into separate lanes even though\n * their real ranges do not touch.\n */\nconst MIN_PACK_SLOT = 30\n\n/** The instant re-expressed in the display time zone (TZDate extends Date). */\nfunction toZoned(date: Date, timeZone: string): TZDate {\n return new TZDate(date.getTime(), timeZone)\n}\n\n/** Zoned midnight of the day containing the instant. */\nfunction zonedStartOfDay(date: Date, timeZone: string): TZDate {\n return startOfDay(toZoned(date, timeZone))\n}\n\n/** Stable per-day key in the display time zone. */\nfunction getDayKey(date: Date, timeZone: string): string {\n return format(toZoned(date, timeZone), \"yyyy-MM-dd\")\n}\n\n/** Day length in minutes; 1380/1500 on DST transition days - never assume 1440. */\nfunction getDayTotalMinutes(dayStart: Date, timeZone: string): number {\n const next = zonedStartOfDay(\n addDays(toZoned(dayStart, timeZone), 1),\n timeZone\n )\n return differenceInMinutes(next, dayStart)\n}\n\nfunction snapMinutes(minutes: number, snap: number): number {\n return Math.round(minutes / snap) * snap\n}\n\ninterface ViewRangeOptions {\n timeZone: string\n weekStartsOn: WeekStartsOn\n}\n\ninterface ViewDateRanges {\n visibleRange: GanttDateRange\n activeRange: GanttDateRange\n}\n\n/** Axis range for the anchor date at the given scale. */\nfunction getGanttDateRange(\n scale: GanttScale,\n date: Date,\n opts: ViewRangeOptions\n): ViewDateRanges {\n const { timeZone, weekStartsOn } = opts\n const zoned = toZoned(date, timeZone)\n\n if (scale === \"week\") {\n const start = startOfWeek(zoned, { weekStartsOn })\n const range = { start, end: addWeeks(start, 1) }\n return { activeRange: range, visibleRange: range }\n }\n if (scale === \"month\") {\n // exact month: no outside days on the horizontal axis\n const start = startOfMonth(zoned)\n const range = { start, end: startOfMonth(addMonths(zoned, 1)) }\n return { activeRange: range, visibleRange: range }\n }\n if (scale === \"quarter\") {\n // week-aligned so the axis partitions into uniform week units\n const quarterStart = startOfQuarter(zoned)\n const quarterEnd = startOfQuarter(addMonths(zoned, 3))\n const start = startOfWeek(quarterStart, { weekStartsOn })\n let end = startOfWeek(quarterEnd, { weekStartsOn })\n if (end < quarterEnd) end = addWeeks(end, 1)\n return {\n activeRange: { start: quarterStart, end: quarterEnd },\n visibleRange: { start, end },\n }\n }\n if (scale === \"year\") {\n const start = startOfYear(zoned)\n const range = { start, end: startOfYear(addYears(zoned, 1)) }\n return { activeRange: range, visibleRange: range }\n }\n const start = startOfDay(zoned)\n const range = { start, end: addDays(start, 1) }\n return { activeRange: range, visibleRange: range }\n}\n\n/** The anchor date stepped one period forward or backward for the scale. */\nfunction stepGanttDate(\n scale: GanttScale,\n date: Date,\n direction: 1 | -1,\n opts: Pick\n): Date {\n const zoned = toZoned(date, opts.timeZone)\n if (scale === \"week\") return addWeeks(zoned, direction)\n if (scale === \"month\") return addMonths(zoned, direction)\n if (scale === \"quarter\") return addMonths(zoned, direction * 3)\n if (scale === \"year\") return addYears(zoned, direction)\n return addDays(zoned, direction)\n}\n\nfunction rangesIntersect(a: GanttDateRange, b: GanttDateRange): boolean {\n return a.start < b.end && a.end > b.start\n}\n\nfunction eventsOverlap(\n a: { start: Date; end: Date },\n b: { start: Date; end: Date }\n): boolean {\n return a.start < b.end && a.end > b.start\n}\n\nfunction spansMultipleDays(occ: { start: Date; end: Date }): boolean {\n // An event ending exactly at the next midnight is still single-day\n // (exclusive end), so compare against a strictly-later instant.\n return occ.end.getTime() - occ.start.getTime() > 24 * 60 * 60 * 1000\n}\n\ninterface PackedPosition {\n column: number\n columnCount: number\n columnSpan: number\n}\n\n/**\n * Identity of a schedule ACROSS time edits. `occurrence.key` embeds the start\n * instant, so it changes the moment a schedule is moved or start-resized -\n * useless as lane memory. This key survives the edit: the event id plus, for a\n * recurring series, the occurrence's position in it.\n */\nfunction getLaneKey(occurrence: {\n eventId: string\n recurrenceIndex?: number\n}): string {\n return `${occurrence.eventId}::${occurrence.recurrenceIndex ?? 0}`\n}\n\n/**\n * What one schedule held on the previous layout pass. The TIMES are what make\n * this more than a lane number: they are how the packer tells the schedule the\n * user just edited apart from the ones that merely sat still.\n */\ninterface GanttLaneMemo {\n lane: number\n startMs: number\n endMs: number\n}\n\ninterface PackOptions {\n /**\n * Where each schedule sat on the previous pass, by getLaneKey.\n *\n * A schedule whose times are UNCHANGED keeps its lane if that lane is still\n * free, so editing one schedule never re-indexes the ones around it. A\n * schedule whose times CHANGED - the one the user just dragged or resized -\n * deliberately forfeits its pin and re-seeks the lowest free lane. That is\n * what makes the arrangement live rather than frozen: a schedule dragged\n * onto its neighbours stacks DOWN into the first free lane, and one dragged\n * clear of them comes back UP inline. Only the edited schedule moves.\n */\n preferredLanes?: Map\n /** \"single\" collapses the row to one track; see GanttScheduleMode. */\n mode?: \"single\" | \"multiple\"\n}\n\n/**\n * Overlap packing for one row's timed segments.\n * Mutates column/columnCount/columnSpan on the segments, in place.\n * z resolution happens at render: event.zIndex verbatim, else 10 + column.\n */\nfunction packTimedSegments(\n segments: GanttSegment[],\n options: PackOptions = {}\n): void {\n if (segments.length === 0) return\n\n if (options.mode === \"single\") {\n // one track: every schedule shares lane 0 and the row never grows\n for (const seg of segments) {\n seg.column = 0\n seg.columnCount = 1\n seg.columnSpan = 1\n }\n return\n }\n\n const preferredLanes = options.preferredLanes\n\n type Working = {\n seg: GanttSegment\n startMin: number\n effEnd: number\n lane: number\n /** The occupancy entry this item added, so a settle can take it back. */\n interval?: { from: number; to: number }\n }\n\n const items: Working[] = segments\n .map((seg) => {\n const startMin = seg.startMin ?? 0\n const endMin = seg.endMin ?? startMin\n return {\n seg,\n startMin,\n effEnd: Math.max(endMin, startMin + MIN_PACK_SLOT),\n lane: -1,\n }\n })\n .sort(\n (a, b) =>\n a.startMin - b.startMin ||\n b.effEnd - b.startMin - (a.effEnd - a.startMin) ||\n a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)\n )\n\n // Sweep into connected clusters\n const clusters: Working[][] = []\n let current: Working[] = []\n let clusterEnd = -Infinity\n for (const item of items) {\n if (item.startMin >= clusterEnd) {\n current = []\n clusters.push(current)\n clusterEnd = -Infinity\n }\n current.push(item)\n clusterEnd = Math.max(clusterEnd, item.effEnd)\n }\n\n for (const cluster of clusters) {\n // Per-lane occupancy INTERVALS, not a single running end: pass 1 claims\n // remembered lanes out of time order, so a lane can be free before an\n // occupant and busy after it.\n const laneIntervals: Array> = []\n const isFree = (lane: number, item: Working) =>\n !(laneIntervals[lane] ?? []).some(\n (iv) => iv.from < item.effEnd && iv.to > item.startMin\n )\n const claim = (lane: number, item: Working) => {\n while (laneIntervals.length <= lane) laneIntervals.push([])\n const interval = { from: item.startMin, to: item.effEnd }\n laneIntervals[lane].push(interval)\n item.lane = lane\n item.interval = interval\n }\n const release = (item: Working) => {\n const occupants = laneIntervals[item.lane] ?? []\n const at = occupants.indexOf(item.interval!)\n if (at >= 0) occupants.splice(at, 1)\n }\n\n // pass 1: schedules that did not move keep the lane they had. The one the\n // user just edited is NOT pinned - its times differ from the memo, so it\n // falls through to pass 2 and re-seeks a lane against its new span.\n const pending: Working[] = []\n for (const item of cluster) {\n const memo = preferredLanes?.get(getLaneKey(item.seg.occurrence))\n const untouched =\n memo !== undefined &&\n memo.startMs === item.seg.occurrence.start.getTime() &&\n memo.endMs === item.seg.occurrence.end.getTime()\n if (untouched && memo.lane >= 0 && isFree(memo.lane, item)) {\n claim(memo.lane, item)\n } else {\n pending.push(item)\n }\n }\n // pass 2: the rest take the lowest free lane - overlapping goes DOWN into\n // the first lane with room, fitting comes back UP to lane 0\n for (const item of pending) {\n let lane = 0\n while (!isFree(lane, item)) lane++\n claim(lane, item)\n }\n\n // pass 3: nothing floats above an empty lane. A pin only survives while\n // something above it still needs the space - once the schedule that was\n // there moves away or is deleted, its neighbour settles down into the\n // gap. Without this a row keeps a permanently blank top lane and never\n // shrinks back. Settling in lane order, and only ever DOWNWARD into space\n // that is genuinely free, means two schedules can never trade places -\n // so an edit still moves at most the schedule it touched.\n const byLane = [...cluster].sort(\n (a, b) => a.lane - b.lane || a.startMin - b.startMin\n )\n for (const item of byLane) {\n if (item.lane === 0) continue\n let lane = 0\n while (lane < item.lane && !isFree(lane, item)) lane++\n if (lane < item.lane) {\n release(item)\n claim(lane, item)\n }\n }\n }\n\n // Lane memory can leave holes (the schedule that held lane 0 was deleted or\n // moved away). Collapse the row's USED lanes onto 0..n-1: relative stacking\n // order survives, so nothing reshuffles, but the row cannot creep taller\n // than the lanes it actually needs.\n const used = [...new Set(items.map((item) => item.lane))].sort(\n (a, b) => a - b\n )\n const compacted = new Map(used.map((lane, index) => [lane, index]))\n const columnCount = used.length\n for (const item of items) {\n item.lane = compacted.get(item.lane) ?? 0\n item.seg.column = item.lane\n item.seg.columnCount = columnCount\n }\n\n // Partial-overlap expansion: widen rightward into free lanes\n for (const cluster of clusters) {\n for (const item of cluster) {\n let span = 1\n while (item.lane + span < columnCount) {\n const blocked = cluster.some(\n (other) =>\n other !== item &&\n other.lane === item.lane + span &&\n other.startMin < item.effEnd &&\n other.effEnd > item.startMin\n )\n if (blocked) break\n span++\n }\n item.seg.columnSpan = span\n }\n }\n}\n\nfunction defaultEventOrder(a: GanttOccurrence, b: GanttOccurrence): number {\n return (\n a.start.getTime() - b.start.getTime() ||\n b.end.getTime() -\n b.start.getTime() -\n (a.end.getTime() - a.start.getTime()) ||\n a.key.localeCompare(b.key)\n )\n}\n\ninterface BuildIndexOptions {\n timeZone: string\n /** Escape hatch for exotic recurrence: return the expanded occurrences. */\n getOccurrences?: (\n event: GanttEvent,\n range: GanttDateRange,\n ctx: { timeZone: string }\n ) => Array<{ start: Date; end: Date }> | null | undefined\n eventOrder?: (a: GanttOccurrence, b: GanttOccurrence) => number\n}\n\ninterface GanttIndex {\n occurrences: GanttOccurrence[]\n}\n\nfunction buildEventIndex(\n events: GanttEvent[],\n visibleRange: GanttDateRange,\n opts: BuildIndexOptions\n): GanttIndex {\n const { timeZone } = opts\n const order = opts.eventOrder ?? defaultEventOrder\n\n // RECURRENCE-ID override replacement: an event carrying recurringEventId +\n // originalStart is an edited single occurrence of that series. The parent's\n // expansion drops the replaced instant; the override renders as its own\n // occurrence through the normal path below.\n const overrideTimes = new Map>()\n for (const event of events) {\n if (!event.recurringEventId || !event.originalStart) continue\n let times = overrideTimes.get(event.recurringEventId)\n if (!times) overrideTimes.set(event.recurringEventId, (times = new Set()))\n times.add(event.originalStart.getTime())\n }\n\n const occurrences: GanttOccurrence[] = []\n for (const event of events) {\n const replaced = overrideTimes.get(event.id)\n const custom = opts.getOccurrences?.(event, visibleRange, { timeZone })\n if (custom) {\n custom.forEach((occ, i) => {\n if (replaced?.has(occ.start.getTime())) return\n if (!rangesIntersect({ start: occ.start, end: occ.end }, visibleRange))\n return\n occurrences.push({\n key: `${event.id}::${occ.start.toISOString()}`,\n eventId: event.id,\n event,\n start: occ.start,\n end: occ.end,\n allDay: event.allDay ?? false,\n isRecurring: true,\n recurrenceIndex: i,\n })\n })\n continue\n }\n const expanded = expandRecurrence(event, visibleRange, { timeZone })\n occurrences.push(\n ...(replaced\n ? expanded.filter((occ) => !replaced.has(occ.start.getTime()))\n : expanded)\n )\n }\n occurrences.sort(order)\n return { occurrences }\n}\n\n/** Cache key for index memoization; cheap string compare. */\nfunction getRangeKey(range: GanttDateRange): string {\n return `${range.start.getTime()}-${range.end.getTime()}`\n}\n\n/** Depth-first flatten of the resource tree (parents included). */\nfunction flattenResources(\n resources: GanttResource[],\n depth = 0\n): Array<{ resource: GanttResource; depth: number }> {\n const rows: Array<{ resource: GanttResource; depth: number }> = []\n for (const resource of resources) {\n rows.push({ resource, depth })\n if (resource.children?.length) {\n rows.push(...flattenResources(resource.children, depth + 1))\n }\n }\n return rows\n}\n\n/** Depth-first lookup of one node in the tree. */\nfunction findResource(\n resources: GanttResource[],\n id: string\n): GanttResource | null {\n for (const resource of resources) {\n if (resource.id === id) return resource\n const found = resource.children?.length\n ? findResource(resource.children, id)\n : null\n if (found) return found\n }\n return null\n}\n\n/**\n * Pure tree move: removes `resourceId` from wherever it sits and reinserts it\n * under `parentId` (null = root) at `index`. Returns a new tree; the original\n * is untouched. Returns null for impossible moves (unknown ids, or dropping a\n * node into its own subtree).\n */\nfunction reorderResources(\n resources: GanttResource[],\n resourceId: string,\n parentId: string | null,\n index: number\n): GanttResource[] | null {\n let moved: GanttResource | null = null\n\n const strip = (nodes: GanttResource[]): GanttResource[] =>\n nodes.flatMap((node) => {\n if (node.id === resourceId) {\n moved = node\n return []\n }\n if (!node.children?.length) return [node]\n return [{ ...node, children: strip(node.children) }]\n })\n\n const stripped = strip(resources)\n if (!moved) return null\n\n const contains = (node: GanttResource, id: string): boolean =>\n node.id === id || !!node.children?.some((child) => contains(child, id))\n if (parentId !== null && contains(moved, parentId)) return null\n\n const insert = (nodes: GanttResource[]): GanttResource[] => {\n if (parentId === null) {\n const next = [...nodes]\n next.splice(Math.min(Math.max(index, 0), next.length), 0, moved!)\n return next\n }\n return nodes.map((node) => {\n if (node.id === parentId) {\n const children = [...(node.children ?? [])]\n children.splice(\n Math.min(Math.max(index, 0), children.length),\n 0,\n moved!\n )\n return { ...node, children }\n }\n if (!node.children?.length) return node\n return { ...node, children: insert(node.children) }\n })\n }\n\n const next = insert(stripped)\n // unknown parentId: the node vanished - reject\n if (parentId !== null) {\n const flat = flattenResources(next)\n if (!flat.some(({ resource }) => resource.id === resourceId)) return null\n }\n return next\n}\n\nconst DEFAULT_WEEKEND_DAYS = [0, 6]\n\n/** Resolves whether a day is an off day (non-working) in the display zone. */\nfunction resolveOffDay(\n day: Date,\n timeZone: string,\n config: boolean | GanttOffDaysConfig | undefined\n): boolean {\n if (!config) return false\n const resolved: GanttOffDaysConfig = config === true ? {} : config\n const weekendDays = resolved.weekendDays ?? DEFAULT_WEEKEND_DAYS\n const zoned = toZoned(day, timeZone)\n if (weekendDays.includes(zoned.getDay())) return true\n if (resolved.dates?.length) {\n const key = getDayKey(day, timeZone)\n if (resolved.dates.some((date) => getDayKey(date, timeZone) === key)) {\n return true\n }\n }\n return resolved.isOffDay?.(day) ?? false\n}\n\nexport {\n buildEventIndex,\n defaultEventOrder,\n eventsOverlap,\n findResource,\n flattenResources,\n getDayKey,\n getDayTotalMinutes,\n getGanttDateRange,\n getLaneKey,\n getRangeKey,\n MIN_PACK_SLOT,\n packTimedSegments,\n rangesIntersect,\n reorderResources,\n resolveOffDay,\n snapMinutes,\n spansMultipleDays,\n stepGanttDate,\n toZoned,\n zonedStartOfDay,\n}\nexport type {\n BuildIndexOptions,\n GanttIndex,\n GanttLaneMemo,\n PackOptions,\n ViewDateRanges,\n ViewRangeOptions,\n WeekStartsOn,\n}","target":"components/neui/gantt/gantt-lib.tsx"},{"path":"gantt-nav.tsx","type":"registry:ui","content":"// Title: Gantt Nav\n// Description: Composable navigation - Today, view selector, prev/next, go-to-date, period title, and a free toolbar slot.\n\n\"use client\"\n\nimport {\n useState,\n type ButtonHTMLAttributes,\n type HTMLAttributes,\n type ReactNode,\n} from \"react\"\nimport {\n useGanttNavigation,\n useGanttScale,\n useGanttSettings,\n useGanttViewConfig,\n} from \"@/components/neui/gantt/gantt\"\nimport { toZoned } from \"@/components/neui/gantt/gantt-lib\"\nimport type { GanttScale } from \"@/components/neui/gantt/gantt-types\"\nimport { format } from \"date-fns\"\nimport { Slot } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Calendar } from \"@/components/ui/calendar\"\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { IconPlaceholder } from \"@/app/(create)/components/icon-placeholder\"\n\nconst GANTT_SCALES: GanttScale[] = [\"day\", \"week\", \"month\", \"quarter\", \"year\"]\n\n/** Configured nav button variant/size (viewConfig.navButtonVariant/Size). */\nfunction useNavButtonProps(): {\n variant: \"ghost\" | \"outline\" | \"secondary\" | \"default\"\n size: \"sm\" | \"default\"\n iconSize: \"icon-sm\" | \"icon\"\n} {\n const viewConfig = useGanttViewConfig()\n return {\n variant: viewConfig.navButtonVariant,\n size: viewConfig.navButtonSize,\n iconSize: viewConfig.navButtonSize === \"sm\" ? \"icon-sm\" : \"icon\",\n }\n}\n\ntype NavButtonProps = Omit<\n ButtonHTMLAttributes,\n \"children\"\n> & {\n children?: ReactNode\n /**\n * Tooltip policy (the part that usually goes wrong on clickable elements):\n * tooltips appear ONLY on hover or keyboard focus-visible - a pointer click\n * never re-triggers them, and buttons that open overlays (the period\n * selector, consumer dialog buttons) get NO tooltip at all so nothing\n * flashes when focus returns. Icon-only buttons default to their accessible\n * label; Today defaults to the actual current date. Pass null to disable.\n */\n tooltip?: ReactNode | null\n asChild?: boolean\n}\n\n/** Hover/focus-visible tooltip wrapper; renders the bare button when disabled. */\nfunction NavTooltip({\n content,\n children,\n}: {\n content: ReactNode | null\n children: React.ReactElement\n}) {\n if (content === null || content === undefined) return children\n return (\n \n {children}\n {content}\n \n )\n}\n\nfunction GanttNavToday({\n className,\n asChild = false,\n children,\n tooltip,\n ...props\n}: NavButtonProps) {\n const { today, isToday } = useGanttNavigation()\n const settings = useGanttSettings()\n const nav = useNavButtonProps()\n // zoned: a system-zone new Date() can name a different day than Today opens\n const defaultTooltip = format(\n toZoned(new Date(), settings.timeZone),\n settings.i18n.formats.dayTitle,\n { locale: settings.locale }\n )\n return (\n \n \n {children ?? settings.i18n.labels.today}\n \n \n )\n}\n\nfunction GanttNavPrev({\n className,\n asChild = false,\n children,\n tooltip,\n ...props\n}: NavButtonProps) {\n const { prev } = useGanttNavigation()\n const settings = useGanttSettings()\n const nav = useNavButtonProps()\n return (\n \n \n {children ?? (\n \n )}\n \n \n )\n}\n\nfunction GanttNavNext({\n className,\n asChild = false,\n children,\n tooltip,\n ...props\n}: NavButtonProps) {\n const { next } = useGanttNavigation()\n const settings = useGanttSettings()\n const nav = useNavButtonProps()\n return (\n \n \n {children ?? (\n \n )}\n \n \n )\n}\n\ninterface GanttTitleProps extends HTMLAttributes {\n format?: (ctx: { title: string }) => ReactNode\n asChild?: boolean\n}\n\nfunction GanttTitle({\n className,\n asChild = false,\n children,\n format: formatTitle,\n ...props\n}: GanttTitleProps) {\n const { title } = useGanttNavigation()\n const Comp = asChild ? Slot.Root : \"div\"\n return (\n \n {children ?? formatTitle?.({ title }) ?? title}\n \n )\n}\n\ninterface GanttScaleSwitcherProps extends Omit<\n ButtonHTMLAttributes,\n \"children\"\n> {\n children?: ReactNode\n /** Hover/focus-visible hint; defaults to the \"Select view\" label. Pass\n * null to disable (overlay-opener policy). */\n tooltip?: ReactNode | null\n /** The offered scales, in menu order. Default: all five. */\n scales?: GanttScale[]\n asChild?: boolean\n}\n\n/**\n * Scale switcher (\"Select view\"): Day / Week / Month / Quarter / Year, a ghost\n * dropdown button (same shape as the event-calendar view switcher).\n */\nfunction GanttScaleSwitcher({\n className,\n asChild,\n children,\n tooltip,\n scales = GANTT_SCALES,\n ...props\n}: GanttScaleSwitcherProps) {\n const { scale, setScale } = useGanttScale()\n const settings = useGanttSettings()\n const nav = useNavButtonProps()\n const labels = settings.i18n.labels\n // Controlled open: selecting a scale swaps the whole track subtree in the\n // same click, so closing must not depend on the menu's internal handler.\n const [open, setOpen] = useState(false)\n // Hover-only tooltip: when the menu closes, Radix focuses the trigger\n // again and a focus-opened tooltip would flash - Radix has no open reason,\n // so focus opens are suppressed at the trigger via preventDefault.\n const [tipOpen, setTipOpen] = useState(false)\n\n const selectScale = (next: GanttScale) => {\n setOpen(false)\n setScale(next)\n }\n\n return (\n {\n setOpen(next)\n if (next) setTipOpen(false)\n }}\n >\n {/* Tooltip on an overlay-opener: hover-only (focus opens ignored) and\n force-closed while the menu is up, so it never lingers or flashes\n when focus returns on close. */}\n setTipOpen(next)}\n >\n \n e.preventDefault()}>\n \n {children ?? (\n <>\n {labels.scales[scale]}\n \n \n )}\n \n \n \n {tipOpen && !open && tooltip !== null && (\n \n {tooltip ?? labels.selectView}\n \n )}\n \n \n {/* Keep the label inside the group so it stays associated with its items */}\n \n \n {labels.selectView}\n \n {scales.map((value) => (\n selectScale(value)}\n >\n {labels.scales[value]}\n \n ))}\n \n \n \n )\n}\n\ninterface GanttDatePickerProps {\n className?: string\n}\n\n/**\n * Compact go-to-date picker (shadcn Calendar in a popover). No tooltip by\n * design: it opens an overlay (see the NavButtonProps tooltip policy).\n */\nfunction GanttDatePicker({ className }: GanttDatePickerProps) {\n const { date, goTo } = useGanttNavigation()\n const settings = useGanttSettings()\n const nav = useNavButtonProps()\n const [open, setOpen] = useState(false)\n const zoned = toZoned(date, settings.timeZone)\n\n return (\n \n \n \n \n \n \n \n {\n if (next) {\n goTo(next)\n setOpen(false)\n }\n }}\n />\n \n \n )\n}\n\ninterface GanttToolbarProps extends HTMLAttributes {\n asChild?: boolean\n}\n\n/** Free slot for consumer toolbar buttons; pure layout shell. */\nfunction GanttToolbar({\n className,\n asChild = false,\n children,\n ...props\n}: GanttToolbarProps) {\n const viewConfig = useGanttViewConfig()\n const Comp = asChild ? Slot.Root : \"div\"\n return (\n \n {children}\n \n )\n}\n\ninterface GanttNavProps extends HTMLAttributes {\n asChild?: boolean\n}\n\n/**\n * Default composed nav (event-calendar parity): Today, time-period switcher,\n * prev/next, title, spacer. GanttDatePicker stays available for custom\n * compositions. Pass children to use it as a pure layout shell instead.\n */\nfunction GanttNav({\n className,\n asChild = false,\n children,\n ...props\n}: GanttNavProps) {\n const viewConfig = useGanttViewConfig()\n const Comp = asChild ? Slot.Root : \"div\"\n return (\n \n {children ?? (\n // Shared provider: first tooltip waits, moving between buttons is instant\n \n \n \n
\n \n \n
\n \n
\n \n )}\n \n )\n}\n\nexport {\n GANTT_SCALES,\n GanttDatePicker,\n GanttNav,\n GanttNavNext,\n GanttNavPrev,\n GanttNavToday,\n GanttScaleSwitcher,\n GanttTitle,\n GanttToolbar,\n}\nexport type {\n GanttNavProps,\n GanttScaleSwitcherProps,\n GanttTitleProps,\n GanttToolbarProps,\n}","target":"components/neui/gantt/gantt-nav.tsx"},{"path":"gantt-recurrence.tsx","type":"registry:ui","content":"// Title: Gantt Recurrence\n// Description: RFC 5545 subset recurrence expansion for the event calendar - structured rules or raw RRULE strings, with a hard occurrence cap.\n\nimport type {\n GanttDateRange,\n GanttEvent,\n GanttOccurrence,\n GanttRecurrenceRule,\n GanttWeekday,\n} from \"@/components/neui/gantt/gantt-types\"\nimport { TZDate } from \"@date-fns/tz\"\nimport { addDays, addMonths, addWeeks, addYears } from \"date-fns\"\n\n/** Guard: max occurrences per event per expansion. */\nconst MAX_OCCURRENCES = 1000\n\nconst WEEKDAYS: GanttWeekday[] = [\"SU\", \"MO\", \"TU\", \"WE\", \"TH\", \"FR\", \"SA\"]\n\nclass GanttRecurrenceError extends Error {\n constructor(part: string) {\n super(\n `Unsupported recurrence part: ${part}. Use the getOccurrences prop to plug a full RRULE engine for exotic rules.`\n )\n this.name = \"GanttRecurrenceError\"\n }\n}\n\n/**\n * Parses a raw RRULE line (with or without the \"RRULE:\" prefix) into the\n * structured subset. Pass the display time zone so a floating UNTIL\n * (no trailing Z) resolves there instead of in the runtime's local zone.\n */\nfunction parseRRuleString(\n input: string,\n timeZone?: string\n): GanttRecurrenceRule {\n const body = input.trim().replace(/^RRULE:/i, \"\")\n const rule: Partial = {}\n\n for (const pair of body.split(\";\")) {\n if (!pair) continue\n const [rawKey, rawValue] = pair.split(\"=\")\n const key = rawKey?.toUpperCase()\n const value = rawValue ?? \"\"\n\n switch (key) {\n case \"FREQ\": {\n const freq = value.toLowerCase()\n if (\n freq !== \"daily\" &&\n freq !== \"weekly\" &&\n freq !== \"monthly\" &&\n freq !== \"yearly\"\n ) {\n throw new GanttRecurrenceError(`FREQ=${value}`)\n }\n rule.freq = freq\n break\n }\n case \"INTERVAL\":\n rule.interval = Math.max(1, parseInt(value, 10) || 1)\n break\n case \"COUNT\":\n rule.count = Math.max(1, parseInt(value, 10) || 1)\n break\n case \"UNTIL\":\n rule.until = parseRRuleDate(value, timeZone)\n break\n case \"BYDAY\":\n rule.byWeekday = value.split(\",\").map((token) => {\n const match = /^(-?\\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(token.trim())\n if (!match) throw new GanttRecurrenceError(`BYDAY=${token}`)\n const day = match[2] as GanttWeekday\n return match[1] ? { day, ordinal: parseInt(match[1], 10) } : day\n })\n break\n case \"BYMONTHDAY\":\n rule.byMonthDay = value.split(\",\").map((v) => parseInt(v, 10))\n break\n case \"BYMONTH\":\n rule.byMonth = value.split(\",\").map((v) => parseInt(v, 10))\n break\n case \"WKST\": {\n if (!WEEKDAYS.includes(value as GanttWeekday)) {\n throw new GanttRecurrenceError(`WKST=${value}`)\n }\n rule.weekStart = value as GanttWeekday\n break\n }\n default:\n throw new GanttRecurrenceError(key ?? pair)\n }\n }\n\n if (!rule.freq) throw new GanttRecurrenceError(\"missing FREQ\")\n return rule as GanttRecurrenceRule\n}\n\nfunction parseRRuleDate(value: string, timeZone?: string): Date {\n // RFC 5545 basic formats: YYYYMMDD or YYYYMMDDTHHMMSS(Z)\n const match = /^(\\d{4})(\\d{2})(\\d{2})(?:T(\\d{2})(\\d{2})(\\d{2})(Z)?)?$/.exec(\n value\n )\n if (!match) throw new GanttRecurrenceError(`UNTIL=${value}`)\n const [, y, m, d, hh = \"23\", mm = \"59\", ss = \"59\", z] = match\n // Floating (non-Z) boundaries resolve in the DISPLAY zone when known -\n // local-zone parsing would shift the series end per visitor machine.\n const date =\n !z && timeZone\n ? new TZDate(+y, +m - 1, +d, +hh, +mm, +ss, timeZone)\n : new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}${z ? \"Z\" : \"\"}`)\n if (Number.isNaN(date.getTime())) {\n throw new GanttRecurrenceError(`UNTIL=${value}`)\n }\n return new Date(date.getTime())\n}\n\n/** Serializes the structured subset back to an RRULE line (without prefix). */\nfunction formatRRuleString(rule: GanttRecurrenceRule): string {\n const parts: string[] = [`FREQ=${rule.freq.toUpperCase()}`]\n if (rule.interval && rule.interval > 1)\n parts.push(`INTERVAL=${rule.interval}`)\n if (rule.count) parts.push(`COUNT=${rule.count}`)\n if (rule.until) {\n const u = rule.until\n const pad = (n: number) => String(n).padStart(2, \"0\")\n parts.push(\n `UNTIL=${u.getUTCFullYear()}${pad(u.getUTCMonth() + 1)}${pad(u.getUTCDate())}T${pad(u.getUTCHours())}${pad(u.getUTCMinutes())}${pad(u.getUTCSeconds())}Z`\n )\n }\n if (rule.byWeekday?.length) {\n parts.push(\n `BYDAY=${rule.byWeekday\n .map((d) => (typeof d === \"string\" ? d : `${d.ordinal}${d.day}`))\n .join(\",\")}`\n )\n }\n if (rule.byMonthDay?.length)\n parts.push(`BYMONTHDAY=${rule.byMonthDay.join(\",\")}`)\n if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(\",\")}`)\n if (rule.weekStart) parts.push(`WKST=${rule.weekStart}`)\n return parts.join(\";\")\n}\n\nfunction resolveRule(\n recurrence: GanttRecurrenceRule | string,\n timeZone?: string\n): GanttRecurrenceRule {\n return typeof recurrence === \"string\"\n ? parseRRuleString(recurrence, timeZone)\n : recurrence\n}\n\n/**\n * Expands one event into its occurrences intersecting the range.\n * Non-recurring events yield at most one occurrence. Recurrence iteration is\n * wall-time based in the display zone (DST-safe day/week/month steps).\n *\n * Supported subset: FREQ daily/weekly/monthly/yearly, INTERVAL, COUNT, UNTIL,\n * weekly BYDAY (no ordinals). Parsed-but-unimplemented filters (BYMONTHDAY,\n * BYMONTH, BYDAY outside weekly) throw a GanttRecurrenceError instead of\n * silently mis-expanding; plug the getOccurrences prop for a full engine.\n * WKST parses and round-trips; week emission is Sunday-anchored.\n *\n * exDates remove exactly-matching instants (after COUNT numbering,\n * Google-style: an exception still consumes its COUNT slot); rDates add extra\n * instants with the same duration. RECURRENCE-ID override replacement lives\n * in buildEventIndex, where the override event and its parent series meet.\n */\nfunction expandRecurrence(\n event: GanttEvent,\n range: GanttDateRange,\n ctx: { timeZone: string }\n): GanttOccurrence[] {\n const allDay = event.allDay ?? false\n\n if (!event.recurrence) {\n if (event.start < range.end && event.end > range.start) {\n return [\n {\n key: `${event.id}::${event.start.toISOString()}`,\n eventId: event.id,\n event,\n start: event.start,\n end: event.end,\n allDay,\n isRecurring: false,\n },\n ]\n }\n return []\n }\n\n const rule = resolveRule(event.recurrence, ctx.timeZone)\n // Loud contract: silently ignoring a filter would emit WRONG occurrences.\n if (rule.byMonthDay?.length) throw new GanttRecurrenceError(\"BYMONTHDAY\")\n if (rule.byMonth?.length) throw new GanttRecurrenceError(\"BYMONTH\")\n if (rule.byWeekday?.length && rule.freq !== \"weekly\") {\n throw new GanttRecurrenceError(\"BYDAY outside FREQ=WEEKLY\")\n }\n const interval = Math.max(1, rule.interval ?? 1)\n const durationMs = event.end.getTime() - event.start.getTime()\n const zonedStart = new TZDate(event.start.getTime(), ctx.timeZone)\n // Excluded instants matched exactly; filtering happens at push time so an\n // exception still consumes its COUNT slot (Google-style numbering).\n const exTimes = new Set((rule.exDates ?? []).map((d) => d.getTime()))\n\n const weeklyDays: number[] | null =\n rule.freq === \"weekly\" && rule.byWeekday?.length\n ? rule.byWeekday.map((d) => {\n if (typeof d !== \"string\") {\n throw new GanttRecurrenceError(\n \"BYDAY ordinal outside monthly/yearly\"\n )\n }\n return WEEKDAYS.indexOf(d)\n })\n : null\n\n const occurrences: GanttOccurrence[] = []\n let produced = 0\n let index = 0\n let cursor = zonedStart\n\n const advance = (from: TZDate, steps: number): TZDate =>\n rule.freq === \"daily\"\n ? addDays(from, steps * interval)\n : rule.freq === \"weekly\"\n ? addWeeks(from, steps * interval)\n : rule.freq === \"monthly\"\n ? addMonths(from, steps * interval)\n : addYears(from, steps * interval)\n\n // Fast-forward past periods entirely before the range: they produce\n // nothing and must not consume the occurrence cap (an old-enough daily\n // series would otherwise exhaust MAX_OCCURRENCES before reaching the\n // window and silently vanish). COUNT rules jump too: the skipped periods\n // are credited to `index`, which is what terminates the series, so the\n // count still ends it on exactly the right instant. Leaving them on full\n // iteration would hide any series whose count exceeds MAX_OCCURRENCES.\n //\n // Daily and weekly ONLY. Their step is a fixed wall-time length, so one jump\n // of N steps lands exactly where N single steps land. addMonths/addYears\n // CLAMP instead: a Jan 31 monthly anchor steps to Feb 28 and never returns to\n // the 31st, while a single jump from the anchor clamps at most once. Jumping\n // those would make the same occurrence render on a different day depending on\n // which window the viewer scrolled in from, so they always iterate.\n const canFastForward = rule.freq === \"daily\" || rule.freq === \"weekly\"\n // weekly BYDAY emits across the cursor's whole Sunday week\n const weekSlackMs = weeklyDays ? 6 * 86_400_000 : 0\n // divide by the LONGEST possible step so the jump can never overshoot\n const maxStepMs =\n (rule.freq === \"daily\"\n ? 24\n : rule.freq === \"weekly\"\n ? 7 * 24\n : rule.freq === \"monthly\"\n ? 31 * 24\n : 366 * 24) *\n 3_600_000 *\n interval +\n 3_600_000\n for (let pass = 0; canFastForward && pass < 2; pass++) {\n const gap =\n range.start.getTime() - durationMs - weekSlackMs - cursor.getTime()\n const skip = Math.floor(gap / maxStepMs)\n if (skip <= 0) break\n cursor = advance(cursor, skip)\n index += skip * (weeklyDays ? weeklyDays.length : 1)\n }\n // close the remainder step by step (bounded by the jump math)\n let guard = 0\n while (\n guard++ < 10_000 &&\n !(rule.until && cursor.getTime() > rule.until.getTime()) &&\n cursor.getTime() + durationMs + weekSlackMs < range.start.getTime()\n ) {\n cursor = advance(cursor, 1)\n index += weeklyDays ? weeklyDays.length : 1\n }\n // The jump credits a whole week of selected days per skipped week, but full\n // iteration never counts the selected days that fall BEFORE the anchor\n // inside the anchor's own week. Drop them once so both paths number the\n // same instant identically (index counts occurrences at or after the\n // anchor, and only those).\n if (weeklyDays && index > 0) {\n index -= weeklyDays.filter((day) => day < zonedStart.getDay()).length\n }\n\n const pushIfVisible = (rawStart: Date) => {\n // normalize to a plain instant so consumers never receive zone-carrying\n // TZDate instances (mixed-zone formatting bugs)\n const start = new Date(rawStart.getTime())\n if (exTimes.has(start.getTime())) return\n const end = new Date(start.getTime() + durationMs)\n if (start < range.end && end > range.start) {\n occurrences.push({\n key: `${event.id}::${start.toISOString()}`,\n eventId: event.id,\n event,\n start,\n end,\n allDay,\n isRecurring: true,\n recurrenceIndex: index,\n })\n }\n }\n\n while (produced < MAX_OCCURRENCES) {\n if (rule.until && cursor.getTime() > rule.until.getTime()) break\n // COUNT is series-absolute, so it reads `index` (the position in the\n // series, fast-forward included) rather than `produced` (emissions in\n // this loop, which MAX_OCCURRENCES caps).\n if (rule.count !== undefined && index >= rule.count) break\n // Past the visible window with no count to honor - stop iterating. For\n // weekly BYDAY the WEEK START decides: selected days earlier in the\n // anchor's week can still fall before range.end.\n const horizonMs = weeklyDays\n ? addDays(cursor, -cursor.getDay()).getTime()\n : cursor.getTime()\n if (horizonMs >= range.end.getTime() && rule.count === undefined) {\n break\n }\n\n if (rule.freq === \"weekly\" && weeklyDays) {\n // Emit each selected weekday within the cursor's week\n for (let d = 0; d < 7; d++) {\n const candidate = addDays(cursor, d - cursor.getDay())\n if (!weeklyDays.includes(candidate.getDay())) continue\n if (candidate.getTime() < zonedStart.getTime()) continue\n if (rule.until && candidate.getTime() > rule.until.getTime()) continue\n // the cap is checked here too, or a week that crosses it mid-loop\n // still emits its remaining selected days\n if (produced >= MAX_OCCURRENCES) break\n if (rule.count !== undefined && index >= rule.count) break\n pushIfVisible(candidate)\n produced++\n index++\n }\n } else {\n pushIfVisible(cursor)\n produced++\n index++\n }\n\n cursor = advance(cursor, 1)\n }\n\n // RDATE: extra instants join the set (deduped against generated starts and\n // exclusions) with the same wall-time duration. Sorted so direct consumers\n // still receive chronological order (buildEventIndex re-sorts regardless).\n if (rule.rDates?.length) {\n const seen = new Set(occurrences.map((o) => o.start.getTime()))\n for (const rDate of rule.rDates) {\n const start = new Date(rDate.getTime())\n if (seen.has(start.getTime()) || exTimes.has(start.getTime())) continue\n const end = new Date(start.getTime() + durationMs)\n if (start >= range.end || end <= range.start) continue\n seen.add(start.getTime())\n occurrences.push({\n key: `${event.id}::${start.toISOString()}`,\n eventId: event.id,\n event,\n start,\n end,\n allDay,\n isRecurring: true,\n // keep counting past the generated instants: an RDATE with no index\n // would fall back to 0 and collide with the series' first occurrence\n // in any consumer that identifies an instance by its position\n recurrenceIndex: index++,\n })\n }\n occurrences.sort((a, b) => a.start.getTime() - b.start.getTime())\n }\n\n return occurrences\n}\n\nexport {\n GanttRecurrenceError,\n expandRecurrence,\n formatRRuleString,\n MAX_OCCURRENCES,\n parseRRuleString,\n}","target":"components/neui/gantt/gantt-recurrence.tsx"},{"path":"gantt-types.tsx","type":"registry:ui","content":"// Title: Gantt Types\n// Description: Public TypeScript contract for the headless gantt: events, occurrences, segments, state, and callbacks.\n\ntype GanttBarId = string\n\n/** Horizontal time scale of the gantt axis. */\ntype GanttScale = \"day\" | \"week\" | \"month\" | \"quarter\" | \"year\"\n\n/** Proposal emitted when a timeline resource row is drag-reordered. */\ninterface GanttResourceReorder {\n /** The dragged resource id. */\n resourceId: string\n /** New parent id, or null for the root level. */\n parentId: string | null\n /** Insertion index among the new parent's children. */\n index: number\n /** The full resource tree with the move applied (convenience). */\n resources: GanttResource[]\n}\n\n/**\n * How many schedules one tree node may hold at once.\n * - \"single\": one track. The node never grows a second lane and a gesture that\n * would create a concurrent schedule is refused.\n * - \"multiple\": concurrent schedules stack into stable lanes and the row grows.\n */\ntype GanttScheduleMode = \"single\" | \"multiple\"\n\n/**\n * Drop policy for a gesture that would overlap another schedule in the SAME\n * node. Policy only - overlapping data always renders.\n * - \"allow\" (default): the gesture commits as proposed.\n * - \"clamp\": the gesture stops at the neighbour's edge.\n * - \"reject\": the gesture is marked invalid and never commits.\n */\ntype GanttOverlapPolicy = \"allow\" | \"reject\" | \"clamp\"\n\n/** Vertical placement of a row's content when the node holds several lanes. */\ntype GanttRowAlign = \"start\" | \"center\"\n\n/**\n * One node of the gantt tree: a generic item that carries a title, consumer\n * columns, and zero or more schedules. It is not domain-bound - the same node\n * expresses a task (one schedule) or a resource lane (many). Nesting via\n * children renders as collapsible groups.\n */\ninterface GanttResource {\n id: string\n title: string\n /** Token or css color used for subtle row/column accents. */\n color?: string\n /** Per-node cardinality override; falls back to the view-level default. */\n scheduleMode?: GanttScheduleMode\n children?: GanttResource[]\n}\n\n/** Preferred name for a tree node; `GanttResource` is the legacy alias. */\ntype GanttNode = GanttResource\n\ninterface GanttDateRange {\n /** Inclusive instant. */\n start: Date\n /** Exclusive instant. */\n end: Date\n}\n\ntype GanttWeekday = \"MO\" | \"TU\" | \"WE\" | \"TH\" | \"FR\" | \"SA\" | \"SU\"\n\ninterface GanttRecurrenceRule {\n freq: \"daily\" | \"weekly\" | \"monthly\" | \"yearly\"\n interval?: number\n count?: number\n /** Inclusive instant. */\n until?: Date\n byWeekday?: Array\n byMonthDay?: number[]\n byMonth?: number[]\n weekStart?: GanttWeekday\n exDates?: Date[]\n rDates?: Date[]\n}\n\ninterface GanttEvent {\n id: GanttBarId\n title: string\n /** Plain instant; consumers parse ISO strings themselves. */\n start: Date\n /** Exclusive; must be >= start. */\n end: Date\n allDay?: boolean\n /** Structured rule or a raw \"RRULE:...\" line. */\n recurrence?: GanttRecurrenceRule | string\n /** This event is an edited single occurrence of that series. */\n recurringEventId?: GanttBarId\n /** Which occurrence it replaces (RECURRENCE-ID semantics). */\n originalStart?: Date\n /** Token or css color; flows to the --gantt-event-color css var. */\n color?: string\n /** Excluded from drag and resize regardless of interactions state. */\n readOnly?: boolean\n /** Per-event override; default comes from interactions.drag. */\n draggable?: boolean\n /** Per-event override; default comes from interactions.resize. */\n resizable?: boolean\n /** Packing prominence; feeds getEventPriority ordering. */\n priority?: number\n /** Completion 0-100; renders as a subtle fill inside the bar. */\n progress?: number\n /** Explicit stacking override; wins over the computed z. */\n zIndex?: number\n /** Resource row this bar belongs to. */\n resourceId?: string\n /** Consumer payload, fully generic. */\n data?: TData\n}\n\ninterface GanttOccurrence {\n /** Stable per instance: `${event.id}::${startISO}`. */\n key: string\n eventId: GanttBarId\n event: GanttEvent\n start: Date\n end: Date\n allDay: boolean\n isRecurring: boolean\n recurrenceIndex?: number\n}\n\ninterface GanttSegment {\n occurrence: GanttOccurrence\n /** Range-start reference instant of the segment's timeline slice. */\n day: Date\n isStart: boolean\n isEnd: boolean\n continuesBefore: boolean\n continuesAfter: boolean\n /** Minutes from the visible range start, clamped to the range. */\n startMin?: number\n endMin?: number\n /** Row lane packing: 0-based lane index within the node's row. */\n column?: number\n /** Lanes the node's row resolved to. */\n columnCount?: number\n columnSpan?: number\n}\n\ninterface GanttSelection {\n eventKeys: string[]\n /** Committed slot selection; see GanttSlotDraft for the in-gesture value. */\n slot: { start: Date; end: Date; allDay: boolean } | null\n}\n\ninterface GanttInteractions {\n /** Horizontal move within the bar's own row; never across rows. */\n drag: boolean\n resize: boolean\n selectSlot: boolean\n}\n\ninterface GanttDragState {\n kind: \"move\" | \"resize-start\" | \"resize-end\"\n occurrence: GanttOccurrence\n proposedStart: Date\n proposedEnd: Date\n proposedAllDay: boolean\n /** The bar's own resource; moves are x-axis only and never cross rows. */\n proposedResourceId?: string\n /** Last canDropEvent verdict; drives data-drop-invalid styling. */\n valid: boolean\n}\n\n/**\n * The in-progress drag-create rectangle ONLY, cleared on commit or cancel.\n * The committed slot selection lives in GanttSelection.slot.\n */\ninterface GanttSlotDraft {\n start: Date\n end: Date\n allDay: boolean\n /** Present when the slot was selected inside a resource row. */\n resourceId?: string\n}\n\ninterface GanttState {\n /** Horizontal axis scale. */\n scale: GanttScale\n /** Anchor date. */\n date: Date\n /** Full rendered axis range - fetch remote data for THIS. */\n visibleRange: GanttDateRange\n /** The logical period (the month/week itself). */\n activeRange: GanttDateRange\n events: GanttEvent[]\n selection: GanttSelection\n interactions: GanttInteractions\n loading: boolean\n drag: GanttDragState | null\n slotDraft: GanttSlotDraft | null\n /**\n * Instant at the center of the scrolled viewport; the nav title follows it\n * so the header always names what you are looking at. null before the view\n * reports a position (falls back to the anchor date).\n */\n viewportCenter: Date | null\n}\n\ninterface GanttRangeInfo {\n range: GanttDateRange\n activeRange: GanttDateRange\n scale: GanttScale\n date: Date\n timeZone: string\n}\n\ninterface GanttProposedUpdate {\n event: GanttEvent\n /** null when source === \"api\". */\n occurrence: GanttOccurrence | null\n start: Date\n end: Date\n allDay: boolean\n /** The bar's own resource (moves stay in-row); set on create/api. */\n resourceId?: string\n source: \"drag\" | \"resize-start\" | \"resize-end\" | \"keyboard\" | \"api\"\n}\n\n/** false = reject/revert; void or true = accept; object = accept with adjustment. */\ntype GanttUpdateResult =\n | boolean\n | void\n | { start?: Date; end?: Date; allDay?: boolean }\n\n/** A click is a point, not a range; `end` is reserved for future gestures. */\ninterface GanttSlotInfo {\n date: Date\n end?: Date\n allDay: boolean\n /** Present when the click happened inside a resource row. */\n resourceId?: string\n}\n\n/**\n * Off-day marking (non-working days). `true` uses the defaults: weekends\n * with a muted background. Custom weekday sets, explicit dates, a predicate,\n * and a custom class are all supported; marked cells carry `data-off` for\n * CSS-selector customization.\n */\ninterface GanttOffDaysConfig {\n /** Weekday numbers treated as off (0 = Sunday). Default [0, 6]. */\n weekendDays?: number[]\n /** Additional explicit off dates (compared by day in the display zone). */\n dates?: Date[]\n /** Full custom predicate; runs in addition to weekendDays/dates. */\n isOffDay?: (day: Date) => boolean\n /** Marker classes; default \"bg-muted/40\". */\n className?: string\n}\n\n/**\n * External-data contract. v1 ships the type plus docs recipes (Google\n * events.list / MS Graph calendarView map to GanttEvent in ~15 lines);\n * OAuth, tokens, and sync loops are application backend territory.\n */\ninterface GanttDataAdapter {\n getEvents(\n range: GanttDateRange,\n signal?: AbortSignal\n ): Promise[]>\n}\n\nexport type {\n GanttEvent,\n GanttDataAdapter,\n GanttDateRange,\n GanttDragState,\n GanttBarId,\n GanttInteractions,\n GanttNode,\n GanttOccurrence,\n GanttOffDaysConfig,\n GanttOverlapPolicy,\n GanttProposedUpdate,\n GanttRangeInfo,\n GanttRecurrenceRule,\n GanttResource,\n GanttRowAlign,\n GanttScheduleMode,\n GanttSegment,\n GanttSelection,\n GanttSlotDraft,\n GanttSlotInfo,\n GanttState,\n GanttResourceReorder,\n GanttScale,\n GanttUpdateResult,\n GanttWeekday,\n}","target":"components/neui/gantt/gantt-types.tsx"},{"path":"gantt-view.tsx","type":"registry:ui","content":"// Title: Gantt View\n// Description: The gantt body - split resizable tree/timeline panes with synced scrolling, multi-column tree, grouped two-row header, day through year scales, zoom, drag-to-pan, lanes, drag and resize.\n\n\"use client\"\n\nimport {\n memo,\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type CSSProperties,\n type HTMLAttributes,\n type RefObject,\n} from \"react\"\nimport {\n DEFAULT_ROW_ALIGN,\n resolveScheduleMode,\n resolveTimelineLines,\n useGantt,\n useGanttSelector,\n useGanttSettings,\n useGanttViewConfig,\n type GanttColumn,\n} from \"@/components/neui/gantt/gantt\"\nimport { GanttBar } from \"@/components/neui/gantt/gantt-bar\"\nimport {\n cancelActiveGanttGestures,\n markGestureEnd,\n useGanttGestures,\n useGanttGestureTeardown,\n wasRecentDrag,\n} from \"@/components/neui/gantt/gantt-dnd\"\nimport {\n getDayKey,\n getLaneKey,\n getRangeKey,\n MIN_PACK_SLOT,\n packTimedSegments,\n reorderResources,\n resolveOffDay,\n toZoned,\n zonedStartOfDay,\n type GanttLaneMemo,\n} from \"@/components/neui/gantt/gantt-lib\"\nimport type {\n GanttDateRange,\n GanttEvent,\n GanttOccurrence,\n GanttResource,\n GanttResourceReorder,\n GanttSegment,\n} from \"@/components/neui/gantt/gantt-types\"\nimport {\n addDays,\n addMinutes,\n addMonths,\n format,\n getWeek,\n startOfMonth,\n startOfQuarter,\n startOfWeek,\n type Locale,\n} from \"date-fns\"\nimport { Slot } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Checkbox } from \"@/components/ui/checkbox\"\nimport {\n ContextMenu,\n ContextMenuContent,\n ContextMenuTrigger,\n} from \"@/components/ui/context-menu\"\nimport { ScrollArea, ScrollBar } from \"@/components/ui/scroll-area\"\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { IconPlaceholder } from \"@/app/(create)/components/icon-placeholder\"\n\n/** Current time, refreshed on an interval and on tab focus. */\nfunction useNow(intervalMs = 30_000): Date {\n const [now, setNow] = useState(() => new Date())\n useEffect(() => {\n const tick = () => setNow(new Date())\n const id = setInterval(tick, intervalMs)\n document.addEventListener(\"visibilitychange\", tick)\n window.addEventListener(\"focus\", tick)\n return () => {\n clearInterval(id)\n document.removeEventListener(\"visibilitychange\", tick)\n window.removeEventListener(\"focus\", tick)\n }\n }, [intervalMs])\n return now\n}\n\n/**\n * Today's zoned day key, re-rendering only at the midnight rollover (and on\n * focus/visibility) - the grid needs day granularity, not the 30s now tick.\n */\nfunction useTodayKey(timeZone: string): string {\n const [key, setKey] = useState(() => getDayKey(new Date(), timeZone))\n useEffect(() => {\n const tick = () =>\n setKey((prev) => {\n const next = getDayKey(new Date(), timeZone)\n return next === prev ? prev : next\n })\n tick()\n const id = setInterval(tick, 60_000)\n document.addEventListener(\"visibilitychange\", tick)\n window.addEventListener(\"focus\", tick)\n return () => {\n clearInterval(id)\n document.removeEventListener(\"visibilitychange\", tick)\n window.removeEventListener(\"focus\", tick)\n }\n }, [timeZone])\n return key\n}\n\n/**\n * Lowest lane free for [startMs, endMs) among a row's segments, padded by\n * MIN_PACK_SLOT exactly as packTimedSegments pads its own occupancy test.\n * Comparing raw instants instead lets a sub-slot bar read as clear, so an\n * affordance would promise a lane the packer then refuses.\n *\n * Shared by the hover hint and the drag placeholder on purpose: two copies of\n * this is how the ring and the range it paints end up on different tracks.\n */\nfunction lowestFreeLane(\n segments: GanttSegment[],\n startMs: number,\n endMs: number\n): number {\n const padMs = MIN_PACK_SLOT * 60000\n const to = Math.max(endMs, startMs + padMs)\n const busy = new Set()\n for (const segment of segments) {\n const segStart = segment.occurrence.start.getTime()\n const segEnd = Math.max(segment.occurrence.end.getTime(), segStart + padMs)\n if (segStart < to && segEnd > startMs) busy.add(segment.column ?? 0)\n }\n let lane = 0\n while (busy.has(lane)) lane += 1\n return lane\n}\n\n/**\n * Pointer x resolved against the element's TIME axis: the 0..1 fraction and\n * the same measurement in CSS pixels from the axis start. Mirrored in RTL,\n * where the range start renders at the element's right edge. One rect read\n * and one style read, because this runs on every pointer move.\n */\nfunction trackPoint(\n el: HTMLElement,\n clientX: number\n): { fraction: number; offset: number } {\n const rect = el.getBoundingClientRect()\n const rtl = getComputedStyle(el).direction === \"rtl\"\n const offset = rtl ? rect.right - clientX : clientX - rect.left\n // `offset` is exact and drives the time maths. `snapped` is the same value\n // biased so that rect start + snapped lands on a WHOLE viewport pixel: the\n // row's own edge routinely sits on a half pixel, so rounding the offset\n // alone still puts anything placed at it between two pixels.\n const snapped = rtl\n ? rect.right - Math.round(clientX)\n : Math.round(clientX) - rect.left\n return {\n fraction: rect.width > 0 ? offset / rect.width : 0,\n offset: snapped,\n }\n}\n\n/** Fraction only, for the call sites that do not place anything. */\nfunction trackFraction(el: HTMLElement, clientX: number): number {\n return trackPoint(el, clientX).fraction\n}\n\n/**\n * Row geometry is three numbers: a bar is LANE_HEIGHT_REM tall, stacked bars\n * are separated by LANE_GAP_REM, and the block as a whole is inset from the\n * row's edges by ROW_PADDING_REM. Padding and gap are deliberately NOT the\n * same value - schedules in one node belong together, so they sit tight, while\n * the row still needs real breathing room above and below. Every inter-lane\n * gap is identical, which is what keeps a stacked row reading evenly.\n */\nconst LANE_HEIGHT_REM = 1.25\nconst LANE_GAP_REM = 0.1875\nconst ROW_PADDING_REM = 0.5\n/** Drop-indicator height (h-5); it is centered inside its lane band. */\nconst GHOST_HEIGHT_REM = 1.25\n/** Bars narrower than this flip their title outside in barLabel \"auto\". */\nconst AUTO_LABEL_MIN_REM = 7\nconst DEFAULT_TREE_PANEL = {\n width: 288,\n minWidth: 180,\n maxWidth: 640,\n resizable: true,\n nameColumnWidth: 208,\n}\nconst DEFAULT_COLUMN_WIDTH = 96\nconst DEFAULT_ZOOM_RANGE = { min: 0.5, max: 3 }\n/** Timeline pane never shrinks below this so it stays usable on narrow screens. */\nconst MIN_TIMELINE_WIDTH = 200\n/** Scroll distance from an edge that triggers infinite-range growth. */\nconst INFINITE_EDGE_PX = 160\n\ninterface TimelineUnit {\n key: string\n label: string\n ms: number\n /** Relative width share; uniform scales use 1 (year: days per month). */\n weight: number\n isToday?: boolean\n isOff?: boolean\n}\n\ninterface TimelineGroup {\n key: string\n label: string\n span: number\n}\n\ninterface TimelineRow {\n resource: GanttResource\n parentId: string | null\n depth: number\n isGroup: boolean\n collapsed: boolean\n}\n\n/** Per-row packed bars plus the extents the off-screen chips need. */\ninterface TimelineRowBars {\n segments: GanttSegment[]\n laneCount: number\n /**\n * Lane a drag-create in flight would land on, or null when none is aimed at\n * this row. The row reserves the track, so it grows exactly as it will on\n * commit and the placeholder never has to overlap the bar it is going under.\n * Computed here, once, because BOTH panes size themselves from this object -\n * deriving it a second time in the timeline row is how the two would drift.\n */\n draftLane: number | null\n /** Mode this row was packed under; the draft overlay must honour it. */\n scheduleMode: \"single\" | \"multiple\"\n heightRem: number\n /** Gap above the first bar; equal to every other gap in the row. */\n laneOffsetRem: number\n /**\n * The band the FIRST schedule occupies, its gaps included. The tree cell\n * sizes its label box to exactly this, so the label and the first bar share\n * a centerline however many lanes the node grew.\n */\n bandRem: number\n /** Envelope of all bars, as track fractions; null when the row is empty. */\n extent: {\n from: number\n to: number\n color?: string\n label: string\n /** First bar start, for the jump-chip tooltip. */\n startMs: number\n } | null\n /**\n * Parent rollup: descendant-bar envelope + duration-weighted progress,\n * present only on group rows without bars of their own.\n */\n summary: { from: number; to: number; progress: number | null } | null\n}\n\ninterface TimelineReorderState {\n resourceId: string\n /** Insertion offset (px) within the tree pane. */\n top: number\n valid: boolean\n proposal: GanttResourceReorder | null\n}\n\ninterface GanttViewProps extends HTMLAttributes {\n /** Day-scale unit interval in minutes; defaults to the interval view config. */\n interval?: number\n asChild?: boolean\n}\n\n/** The pane's scrollable viewport (custom ScrollArea or native host). */\nfunction getPaneViewport(pane: HTMLElement | null): HTMLElement | null {\n return (\n pane?.querySelector(\"[data-slot=scroll-area-viewport]\") ?? null\n )\n}\n\n/** Distance scrolled from the inline-start edge (RTL reports negative). */\nfunction getScrollStart(viewport: HTMLElement): number {\n return Math.abs(viewport.scrollLeft)\n}\n\n/** Write a distance-from-inline-start back as a signed scrollLeft. */\nfunction setScrollStart(viewport: HTMLElement, value: number) {\n viewport.scrollLeft =\n getComputedStyle(viewport).direction === \"rtl\" ? -value : value\n}\n\nfunction GanttView({\n className,\n asChild = false,\n interval: intervalProp,\n ...props\n}: GanttViewProps) {\n const instance = useGantt()\n const settings = useGanttSettings()\n const viewConfig = useGanttViewConfig()\n const range = useGanttSelector(\n (state) => state.visibleRange,\n { isEqual: (a, b) => getRangeKey(a) === getRangeKey(b) }\n )\n const occurrences = useGanttSelector(\n () => instance.api.getOccurrences(),\n {\n calendar: instance,\n isEqual: (a, b) =>\n a.length === b.length &&\n a.every(\n (occ, i) =>\n occ.key === b[i]?.key &&\n occ.start.getTime() === b[i]?.start.getTime() &&\n occ.end.getTime() === b[i]?.end.getTime() &&\n occ.event === b[i]?.event\n ),\n }\n )\n\n // Tree expand/collapse: controlled (collapsedGroups/onCollapsedGroupsChange)\n // or uncontrolled (defaultCollapsedGroups) - same pattern as selectedRows.\n const [internalCollapsed, setInternalCollapsed] = useState(\n () => viewConfig.defaultCollapsedGroups ?? []\n )\n const collapsedIds = viewConfig.collapsedGroups ?? internalCollapsed\n const collapsedGroups = useMemo(() => new Set(collapsedIds), [collapsedIds])\n\n const scale = useGanttSelector((state) => state.scale)\n const interval = Math.min(\n Math.max(intervalProp ?? viewConfig.interval, 15),\n 240\n )\n // Every layout metric is consumer-overridable; unset keys keep defaults.\n const metrics = viewConfig.metrics\n const laneHeightRem = metrics?.laneHeight ?? LANE_HEIGHT_REM\n const rowPaddingRem = metrics?.rowPadding ?? ROW_PADDING_REM\n const laneGapRem = metrics?.laneGap ?? LANE_GAP_REM\n const minRowRem = metrics?.minRowHeight ?? 2.5\n const minTimelineWidth = metrics?.minTimelineWidth ?? MIN_TIMELINE_WIDTH\n const infiniteEdgePx = metrics?.infiniteScrollEdge ?? INFINITE_EDGE_PX\n const timeZone = settings.timeZone\n const rangeStartMs = range.start.getTime()\n const rangeEndMs = range.end.getTime()\n const rangeKey = getRangeKey(range)\n const snapMin = scale === \"day\" ? settings.snapDuration : 24 * 60\n // day-granular time input so the today highlight rolls over at midnight\n // without the whole grid re-rendering on the 30s now tick\n const todayDayKey = useTodayKey(timeZone)\n\n const rows = useMemo(() => {\n const result: TimelineRow[] = []\n const walk = (\n resources: GanttResource[],\n depth: number,\n parentId: string | null\n ) => {\n for (const resource of resources) {\n const isGroup = !!resource.children?.length\n const collapsed = collapsedGroups.has(resource.id)\n result.push({ resource, parentId, depth, isGroup, collapsed })\n if (isGroup && !collapsed)\n walk(resource.children!, depth + 1, resource.id)\n }\n }\n walk(settings.resources, 0, null)\n return result\n }, [settings.resources, collapsedGroups])\n\n // Header model: bottom row = units, top row = grouping sectors.\n // Weights are proportional to REAL duration (a 23h/25h DST day differs from\n // its siblings), so weight-driven gridlines, ms-fraction bar geometry, and\n // the dnd pointer math all share one coordinate system.\n const { units, groups, unitWidthRem } = useMemo(() => {\n const units: TimelineUnit[] = []\n const groups: TimelineGroup[] = []\n // day-start ms for the today window checks; recomputed when the day key\n // rolls over (this memo depends on todayDayKey)\n const todayStartMs = zonedStartOfDay(new Date(), timeZone).getTime()\n if (scale === \"day\") {\n const labelFormat =\n interval % 60 === 0 ? settings.i18n.formats.timeGutter : \"h:mm\"\n // walk whole days: infinite scroll can extend the range past one day\n let dayCursor = zonedStartOfDay(range.start, timeZone)\n while (dayCursor.getTime() < rangeEndMs) {\n const zonedDay = toZoned(dayCursor, timeZone)\n const nextDay = zonedStartOfDay(addDays(zonedDay, 1), timeZone)\n const dayMinutes = (nextDay.getTime() - dayCursor.getTime()) / 60000\n const dayOff = resolveOffDay(\n dayCursor,\n timeZone,\n viewConfig.offDays ?? true\n )\n let span = 0\n for (let m = 0; m < dayMinutes; m += interval) {\n const time = addMinutes(zonedDay, m)\n // a DST day whose minutes don't divide evenly leaves a short\n // final unit; its weight must be its REAL share or bars drift\n const weight = Math.min(interval, dayMinutes - m) / interval\n units.push({\n key: `${getDayKey(dayCursor, timeZone)}-m${m}`,\n label: format(time, labelFormat, { locale: settings.locale }),\n ms: time.getTime(),\n weight,\n isOff: dayOff,\n })\n span += weight\n }\n groups.push({\n key: getDayKey(dayCursor, timeZone),\n label: format(zonedDay, settings.i18n.formats.dayTitle, {\n locale: settings.locale,\n }),\n span,\n })\n dayCursor = nextDay\n }\n return {\n units,\n groups,\n unitWidthRem:\n metrics?.unitWidths?.day ?? Math.max(2.5, 5 * (interval / 60)),\n }\n }\n if (scale === \"quarter\") {\n // units are week-aligned weeks (lib aligns the range), groups are months\n let cursor = zonedStartOfDay(range.start, timeZone)\n while (cursor.getTime() < rangeEndMs) {\n const zoned = toZoned(cursor, timeZone)\n const next = zonedStartOfDay(addDays(zoned, 7), timeZone)\n // real week duration / nominal week: 1 except across DST changes\n const weight =\n (next.getTime() - cursor.getTime()) / (7 * 24 * 60 * 60000)\n units.push({\n key: getDayKey(cursor, timeZone),\n label: format(zoned, \"MMM d\", { locale: settings.locale }),\n ms: cursor.getTime(),\n weight,\n isToday:\n todayStartMs >= cursor.getTime() && todayStartMs < next.getTime(),\n })\n const monthKey = format(zoned, \"yyyy-MM\")\n const lastGroup = groups[groups.length - 1]\n if (lastGroup && lastGroup.key === monthKey) {\n lastGroup.span += weight\n } else {\n groups.push({\n key: monthKey,\n label: format(zoned, \"MMMM\", { locale: settings.locale }),\n span: weight,\n })\n }\n cursor = next\n }\n return { units, groups, unitWidthRem: metrics?.unitWidths?.quarter ?? 8 }\n }\n if (scale === \"year\") {\n // units are calendar months (weight = real duration), groups are quarters\n let cursor: Date = startOfMonth(toZoned(range.start, timeZone))\n while (cursor.getTime() < rangeEndMs) {\n const next = startOfMonth(addMonths(cursor, 1))\n // nominal-day units so a month reads ~30 wide; real ms keeps DST months true\n const weight = (next.getTime() - cursor.getTime()) / (24 * 60 * 60000)\n units.push({\n key: format(cursor, \"yyyy-MM\"),\n label: format(cursor, \"MMM\", { locale: settings.locale }),\n ms: cursor.getTime(),\n weight,\n isToday:\n todayStartMs >= cursor.getTime() && todayStartMs < next.getTime(),\n })\n const quarterStart = startOfQuarter(cursor)\n const quarterKey = format(quarterStart, \"yyyy-QQQ\")\n const lastGroup = groups[groups.length - 1]\n if (lastGroup && lastGroup.key === quarterKey) {\n lastGroup.span += weight\n } else {\n groups.push({\n key: quarterKey,\n label: format(quarterStart, \"QQQ yyyy\", {\n locale: settings.locale,\n }),\n span: weight,\n })\n }\n cursor = next\n }\n return { units, groups, unitWidthRem: metrics?.unitWidths?.year ?? 10 }\n }\n // week/month: units are days, groups are ISO-ish weeks\n let cursor = zonedStartOfDay(range.start, timeZone)\n while (cursor.getTime() < rangeEndMs) {\n const zoned = toZoned(cursor, timeZone)\n const nextDay = zonedStartOfDay(addDays(zoned, 1), timeZone)\n // real day duration / 24h: 1 except the 23h/25h DST days\n const weight = (nextDay.getTime() - cursor.getTime()) / (24 * 60 * 60000)\n units.push({\n key: getDayKey(cursor, timeZone),\n label: format(zoned, \"EEE d\", { locale: settings.locale }),\n ms: cursor.getTime(),\n weight,\n isToday: getDayKey(cursor, timeZone) === todayDayKey,\n isOff: resolveOffDay(cursor, timeZone, viewConfig.offDays ?? true),\n })\n // locale supplies firstWeekContainsDate so W-numbers match the locale's\n // week numbering (ISO in de/fr, US-style otherwise); the explicit\n // weekStartsOn keeps the number aligned with the rendered grid\n const weekNumber = getWeek(zoned, {\n locale: settings.locale,\n weekStartsOn: settings.weekStartsOn,\n })\n // key + label from the true week start: a range that begins midweek\n // must not split or mislabel its first group (incl. the Jan 1 week)\n const weekStart = startOfWeek(zoned, {\n weekStartsOn: settings.weekStartsOn,\n })\n const weekKey = `w-${format(weekStart, \"yyyy-MM-dd\")}`\n const lastGroup = groups[groups.length - 1]\n if (lastGroup && lastGroup.key === weekKey) {\n lastGroup.span += weight\n } else {\n groups.push({\n key: weekKey,\n label: `${settings.i18n.labels.week(weekNumber)} ${format(weekStart, \"MMM d\", { locale: settings.locale })} - ${format(addDays(weekStart, 6), \"d\", { locale: settings.locale })}`,\n span: weight,\n })\n }\n cursor = nextDay\n }\n return {\n units,\n groups,\n unitWidthRem: metrics?.unitWidths?.[scale] ?? (scale === \"week\" ? 10 : 4),\n }\n }, [\n scale,\n interval,\n range.start,\n rangeEndMs,\n timeZone,\n settings.i18n,\n settings.locale,\n settings.weekStartsOn,\n viewConfig.offDays,\n todayDayKey,\n metrics,\n ])\n\n // Zoom multiplies the minimum unit width; the flex track still fills when\n // the zoomed width is narrower than the pane. Controlled (zoom/onZoomChange)\n // or uncontrolled (defaultZoom) - same pattern as selectedRows.\n const zoomRange = { ...DEFAULT_ZOOM_RANGE, ...viewConfig.zoomRange }\n const clampZoom = (value: number) =>\n Math.min(Math.max(value, zoomRange.min), zoomRange.max)\n const [internalZoom, setInternalZoom] = useState(\n () => viewConfig.defaultZoom ?? 1\n )\n const zoom = clampZoom(viewConfig.zoom ?? internalZoom)\n const setZoomValue = (next: number) => {\n const clamped = clampZoom(next)\n if (viewConfig.zoom === undefined) setInternalZoom(clamped)\n viewConfig.onZoomChange?.(clamped)\n }\n const canZoomIn = zoom < zoomRange.max - 1e-9\n const canZoomOut = zoom > zoomRange.min + 1e-9\n const trackRemWidth = units.length * unitWidthRem * zoom\n const trackWidth = `${trackRemWidth}rem`\n // unequal weights (year months, DST-containing day/week ranges) draw\n // boundaries from weight fractions instead of the uniform gradient\n const uniform = units.every(\n (unit) => Math.abs(unit.weight - units[0].weight) < 1e-9\n )\n const totalWeight = units.reduce((sum, unit) => sum + unit.weight, 0)\n /** Cumulative start/width fractions per unit, for backdrop stripes/lines. */\n const unitFractions = useMemo(() => {\n let acc = 0\n return units.map((unit) => {\n const start = acc / totalWeight\n acc += unit.weight\n return { unit, start, width: unit.weight / totalWeight }\n })\n }, [units, totalWeight])\n /** Snap a track fraction to its unit (hint preview target). */\n const resolveHintStop = useMemo(() => {\n return (\n fraction: number\n ): { index: number; center: number; ms: number; endMs: number } | null => {\n for (let i = 0; i < unitFractions.length; i++) {\n const { unit, start, width } = unitFractions[i]\n if (fraction < start + width || i === unitFractions.length - 1) {\n return {\n index: i,\n center: start + width / 2,\n ms: unit.ms,\n endMs: unitFractions[i + 1]?.unit.ms ?? unit.ms,\n }\n }\n }\n return null\n }\n }, [unitFractions])\n\n /** Group boundary fractions; spans are in unit-weight terms everywhere. */\n const groupBoundaries = useMemo(() => {\n const fractions: number[] = []\n let acc = 0\n for (let i = 0; i < groups.length - 1; i++) {\n acc += groups[i].span\n fractions.push(acc / totalWeight)\n }\n return fractions\n }, [groups, totalWeight])\n /** Resource id -> every descendant id, for parent rollups. */\n const descendantIds = useMemo(() => {\n const map = new Map()\n const walk = (resource: GanttResource): string[] => {\n const ids = (resource.children ?? []).flatMap((child) => [\n child.id,\n ...walk(child),\n ])\n map.set(resource.id, ids)\n return ids\n }\n settings.resources.forEach(walk)\n return map\n }, [settings.resources])\n\n // All events (not just visible occurrences) so parent rollup progress is\n // all-time and matches a consumer's own tree rollup, independent of scroll.\n const allEvents = useGanttSelector(\n (state) => state.events\n )\n const subtreeProgress = useMemo(() => {\n // consumer-owned rollup math: hand each group its descendant events\n if (viewConfig.getSummaryProgress) {\n const byResource = new Map()\n for (const ev of allEvents) {\n if (!ev.resourceId) continue\n const list = byResource.get(ev.resourceId)\n if (list) list.push(ev)\n else byResource.set(ev.resourceId, [ev])\n }\n const map = new Map()\n for (const row of rows) {\n if (!row.isGroup) continue\n const events: GanttEvent[] = []\n for (const id of descendantIds.get(row.resource.id) ?? []) {\n const list = byResource.get(id)\n if (list) events.push(...list)\n }\n map.set(\n row.resource.id,\n viewConfig.getSummaryProgress({ resource: row.resource, events })\n )\n }\n return map\n }\n // default: one pass over events -> per-resource aggregates, then a cheap\n // descendant sum per group; never O(rows x events)\n const perResource = new Map<\n string,\n { weighted: number; total: number; saw: boolean }\n >()\n for (const ev of allEvents) {\n if (!ev.resourceId) continue\n let agg = perResource.get(ev.resourceId)\n if (!agg) {\n agg = { weighted: 0, total: 0, saw: false }\n perResource.set(ev.resourceId, agg)\n }\n const dur = Math.max(ev.end.getTime() - ev.start.getTime(), 1)\n agg.total += dur\n if (typeof ev.progress === \"number\") {\n agg.saw = true\n agg.weighted += ev.progress * dur\n }\n }\n const map = new Map()\n for (const row of rows) {\n if (!row.isGroup) continue\n let weighted = 0\n let weightTotal = 0\n let saw = false\n for (const id of descendantIds.get(row.resource.id) ?? []) {\n const agg = perResource.get(id)\n if (!agg) continue\n weightTotal += agg.total\n if (agg.saw) {\n saw = true\n weighted += agg.weighted\n }\n }\n map.set(\n row.resource.id,\n saw && weightTotal > 0\n ? Math.min(Math.max(Math.round(weighted / weightTotal), 0), 100)\n : null\n )\n }\n return map\n }, [rows, allEvents, descendantIds, viewConfig.getSummaryProgress])\n\n // Lane memory across layout passes, keyed by getLaneKey (event identity,\n // NOT the time-stamped occurrence key). It stores the TIMES alongside the\n // lane so the packer can tell the schedule the user just edited apart from\n // the ones that sat still: untouched schedules keep their lane, the edited\n // one re-seeks. Written during the memo below on purpose: the pass is\n // idempotent - feeding its own output back in produces the same assignment -\n // so a StrictMode double render is a no-op.\n const laneMemory = useRef(new Map())\n const scheduleMode = viewConfig.scheduleMode\n\n // Per-row packed bars, hoisted so the tree and timeline rows share heights\n // A drag-create in flight. Reduced to the three fields the layout needs, and\n // compared by value, so this re-runs only when the SNAPPED range moves - the\n // gesture engine already gates setSlotDraft on exactly that, so it is a\n // handful of recomputes per drag rather than one per frame.\n const draftLayout = useGanttSelector<\n unknown,\n { resourceId: string; startMs: number; endMs: number } | null\n >(\n (state) => {\n const slotDraft = state.slotDraft\n if (!slotDraft?.resourceId) return null\n return {\n resourceId: slotDraft.resourceId,\n startMs: slotDraft.start.getTime(),\n endMs: slotDraft.end.getTime(),\n }\n },\n {\n isEqual: (a, b) =>\n a === b ||\n (a !== null &&\n b !== null &&\n a.resourceId === b.resourceId &&\n a.startMs === b.startMs &&\n a.endMs === b.endMs),\n }\n )\n\n const baseRowBars = useMemo(() => {\n const map = new Map()\n // read the lanes the previous pass settled on, write the ones this pass\n // settles on; rebuilding (not mutating) prunes schedules that are gone\n const previousLanes = laneMemory.current\n const nextLanes = new Map()\n const totalMin = (rangeEndMs - rangeStartMs) / 60000\n // one pass: occurrences grouped by resource, plus per-resource envelopes\n // for the parent rollups (never O(rows x occurrences))\n const byResource = new Map()\n const envelopes = new Map()\n for (const occ of occurrences) {\n const rid = occ.event.resourceId\n if (!rid) continue\n const list = byResource.get(rid)\n if (list) list.push(occ)\n else byResource.set(rid, [occ])\n const fromMin = Math.max((occ.start.getTime() - rangeStartMs) / 60000, 0)\n const toMin = Math.min(\n (occ.end.getTime() - rangeStartMs) / 60000,\n totalMin\n )\n const env = envelopes.get(rid)\n if (!env) {\n envelopes.set(rid, { fromMin, toMin })\n } else {\n env.fromMin = Math.min(env.fromMin, fromMin)\n env.toMin = Math.max(env.toMin, toMin)\n }\n }\n for (const row of rows) {\n const mine = byResource.get(row.resource.id) ?? []\n const segments: GanttSegment[] = mine.map((occurrence) => ({\n occurrence,\n day: new Date(rangeStartMs),\n isStart: occurrence.start.getTime() >= rangeStartMs,\n isEnd: occurrence.end.getTime() <= rangeEndMs,\n continuesBefore: occurrence.start.getTime() < rangeStartMs,\n continuesAfter: occurrence.end.getTime() > rangeEndMs,\n startMin: Math.max(\n (occurrence.start.getTime() - rangeStartMs) / 60000,\n 0\n ),\n endMin: Math.min(\n (occurrence.end.getTime() - rangeStartMs) / 60000,\n totalMin\n ),\n }))\n const mode = resolveScheduleMode(row.resource, scheduleMode)\n packTimedSegments(segments, { mode, preferredLanes: previousLanes })\n for (const segment of segments) {\n nextLanes.set(getLaneKey(segment.occurrence), {\n lane: segment.column ?? 0,\n startMs: segment.occurrence.start.getTime(),\n endMs: segment.occurrence.end.getTime(),\n })\n }\n const laneCount = segments.reduce(\n (max, segment) => Math.max(max, (segment.column ?? 0) + 1),\n 1\n )\n let from = Infinity\n let to = -Infinity\n for (const segment of segments) {\n from = Math.min(from, (segment.startMin ?? 0) / totalMin)\n to = Math.max(to, (segment.endMin ?? 0) / totalMin)\n }\n\n // Parent rollup from the subtree's bars: envelope clamped to the range,\n // progress weighted by each bar's full duration\n let summary: TimelineRowBars[\"summary\"] = null\n if (row.isGroup && segments.length === 0 && viewConfig.summaryBars) {\n let sumFrom = Infinity\n let sumTo = -Infinity\n for (const id of descendantIds.get(row.resource.id) ?? []) {\n const env = envelopes.get(id)\n if (!env) continue\n sumFrom = Math.min(sumFrom, env.fromMin / totalMin)\n sumTo = Math.max(sumTo, env.toMin / totalMin)\n }\n if (sumTo > sumFrom) {\n summary = {\n from: sumFrom,\n to: sumTo,\n // all-time completion (matches a consumer's tree rollup), not the\n // visible-range slice - task progress is independent of scroll\n progress: subtreeProgress.get(row.resource.id) ?? null,\n }\n }\n }\n\n // The stack, then the row's own padding around it. Centering the block\n // in the resulting height gives an equal inset top and bottom, and it\n // is also what keeps a lone bar on the tree label's centerline when a\n // short row is held open by minRowHeight.\n const blockRem = laneCount * laneHeightRem + (laneCount - 1) * laneGapRem\n const heightRem = Math.max(minRowRem, blockRem + 2 * rowPaddingRem)\n const laneOffsetRem = (heightRem - blockRem) / 2\n\n map.set(row.resource.id, {\n segments,\n laneCount,\n draftLane: null,\n scheduleMode: mode,\n heightRem,\n laneOffsetRem,\n bandRem: laneHeightRem + laneOffsetRem * 2,\n extent:\n segments.length > 0\n ? {\n from,\n to,\n color: segments[0].occurrence.event.color,\n label:\n segments.length === 1\n ? segments[0].occurrence.event.title\n : settings.i18n.labels.events(segments.length),\n startMs: Math.min(\n ...segments.map((s) => s.occurrence.start.getTime())\n ),\n }\n : summary\n ? {\n from: summary.from,\n to: summary.to,\n label: row.resource.title,\n startMs:\n rangeStartMs + summary.from * (rangeEndMs - rangeStartMs),\n }\n : null,\n summary,\n })\n }\n laneMemory.current = nextLanes\n return map\n }, [\n rows,\n occurrences,\n rangeStartMs,\n rangeEndMs,\n settings.i18n,\n viewConfig.summaryBars,\n descendantIds,\n subtreeProgress,\n laneHeightRem,\n rowPaddingRem,\n laneGapRem,\n minRowRem,\n scheduleMode,\n ])\n\n // ----- split panes: width state, splitter drag/keyboard, scroll sync -----\n const treeConfig = { ...DEFAULT_TREE_PANEL, ...viewConfig.treePanel }\n const clampTree = (width: number) =>\n Math.min(Math.max(width, treeConfig.minWidth), treeConfig.maxWidth)\n const [treeWidth, setTreeWidth] = useState(treeConfig.width)\n const configuredTreeWidth = clampTree(treeWidth)\n const columns = viewConfig.columns ?? []\n\n // \"Add task\" hint at the foot of the tree, gated by validation\n /**\n * Reserve the track a drag-create in flight will land on - as a THIN overlay\n * over the layout above, never as an input to it. Rebuilding the whole map\n * per snapped step would hand every row a new `bars` object and defeat\n * GanttTimelineRow's memo across the entire grid, so only the drafted row's\n * entry is replaced; every other row keeps its identity and never re-renders.\n *\n * `laneOffsetRem` and `bandRem` are deliberately carried over UNCHANGED.\n * Deriving them from the grown track count re-centres the row, which drags\n * the settled bars and the tree label box with it - a 2px wobble by default\n * and, under a consumer `metrics.minRowHeight` big enough to swallow the\n * growth, an 11.5px jerk that snaps back on release. Growing a row must add\n * space BELOW what is already there and move nothing.\n */\n const rowBars = useMemo(() => {\n if (!draftLayout) return baseRowBars\n const base = baseRowBars.get(draftLayout.resourceId)\n if (!base) return baseRowBars\n const next = new Map(baseRowBars)\n // \"single\" packs every bar onto lane 0, so that is where the draft goes\n // too and the row never grows. Recorded rather than left null so the\n // placeholder's data-lane still names a real destination.\n if (base.scheduleMode === \"single\") {\n next.set(draftLayout.resourceId, { ...base, draftLane: 0 })\n return next\n }\n const draftLane = lowestFreeLane(\n base.segments,\n draftLayout.startMs,\n draftLayout.endMs\n )\n const trackCount = Math.max(base.laneCount, draftLane + 1)\n const draftBlockRem =\n trackCount * laneHeightRem + (trackCount - 1) * laneGapRem\n next.set(draftLayout.resourceId, {\n ...base,\n draftLane,\n heightRem: Math.max(\n base.heightRem,\n base.laneOffsetRem * 2 + draftBlockRem\n ),\n })\n return next\n }, [baseRowBars, draftLayout, laneHeightRem, laneGapRem])\n\n const showCreateTask =\n viewConfig.displayCreateTaskHint &&\n !!settings.onCreateTask &&\n (settings.canCreateTask?.({ parentId: null }) ?? true)\n\n // Responsive guard: the timeline must always keep a usable width, so on\n // narrow containers the tree pane yields down toward its minWidth. Measured\n // (not media-queried) because the gantt can live in any column. The -1\n // reserves the splitter hairline so the timeline truly keeps MIN width.\n const [containerWidth, setContainerWidth] = useState(0)\n const clampContainer = (width: number, container: number) => {\n if (container <= 0) return width\n const ceiling = container - minTimelineWidth - 1\n // The tree's own minWidth is a PREFERENCE, not a licence to squeeze the\n // timeline out of existence: cap it by what the container can actually\n // spare. Without this cap a consumer minWidth wider than the container\n // wins outright and the timeline collapses below minTimelineWidth with\n // the splitter already pinned, so the space cannot be dragged back.\n const floor = Math.min(treeConfig.minWidth, Math.max(ceiling, 0))\n return Math.max(Math.min(width, ceiling), Math.min(floor, container - 1))\n }\n const clampedTreeWidth = clampContainer(configuredTreeWidth, containerWidth)\n /** Live width while the splitter is dragging; render reads it so a\n mid-drag re-render can't snap the pane back to stale state. */\n const liveTreeWidthRef = useRef(null)\n\n // In-flight gestures must never outlive the view (leaked window listeners,\n // body overlays and the drag cursor), and must never keep running against\n // geometry they measured before it changed - a gesture snapshots the axis\n // and row rects once at activation, so any of these invalidates it.\n // Cancel-and-revert is the safe contract; all of this is a no-op in normal\n // flows (none of these values can change during an ordinary pointer drag).\n useGanttGestureTeardown()\n useEffect(() => {\n cancelActiveGanttGestures()\n }, [zoom, scale, rangeKey, clampedTreeWidth, rows.length])\n\n // Leaf-row checkbox selection: uncontrolled unless selectedRows is passed\n const [internalSelected, setInternalSelected] = useState([])\n const selectedRows = viewConfig.selectedRows ?? internalSelected\n const selectedSet = useMemo(() => new Set(selectedRows), [selectedRows])\n // Latest-value refs so the row handlers keep ONE identity across renders -\n // the row components are memoized and must not re-render per state change\n const viewConfigRef = useRef(viewConfig)\n viewConfigRef.current = viewConfig\n const selectedRowsRef = useRef(selectedRows)\n selectedRowsRef.current = selectedRows\n const toggleRowSelected = useCallback((id: string, checked: boolean) => {\n const current = selectedRowsRef.current\n const next = checked\n ? [...current.filter((rowId) => rowId !== id), id]\n : current.filter((rowId) => rowId !== id)\n if (viewConfigRef.current.selectedRows === undefined) {\n setInternalSelected(next)\n }\n viewConfigRef.current.onSelectedRowsChange?.(next)\n }, [])\n\n const bodyRef = useRef(null)\n const treePaneRef = useRef(null)\n const timelinePaneRef = useRef(null)\n const treeRowsRef = useRef(null)\n\n // Track the body width so the tree pane can yield on narrow containers.\n // Layout effect, not effect: the first measure must flush BEFORE the first\n // paint so a clamped tree width never paints wide for a frame and then\n // snaps - the panes are laid out final from the very first displayed frame.\n useLayoutEffect(() => {\n const body = bodyRef.current\n if (!body) return\n const update = () => setContainerWidth(body.clientWidth)\n update()\n const observer = new ResizeObserver(update)\n observer.observe(body)\n return () => observer.disconnect()\n }, [])\n\n const beginSplit = (e: React.PointerEvent) => {\n if (e.button !== 0) return\n e.preventDefault()\n const pointerId = e.pointerId\n const startX = e.clientX\n const startWidth = clampedTreeWidth\n const splitter = e.currentTarget as HTMLElement\n // in RTL the tree pane sits on the right: pointer deltas invert\n const dir = getComputedStyle(splitter).direction === \"rtl\" ? -1 : 1\n splitter.setAttribute(\"data-resizing\", \"\")\n document.body.style.cursor = \"col-resize\"\n document.body.style.userSelect = \"none\"\n // Live width goes straight to the DOM: a React state write here would\n // re-render every row and bar per pointermove. State commits on release.\n // The container clamp applies live too - the timeline must not collapse\n // below its minimum mid-drag only to snap back on release.\n let liveWidth = startWidth\n const onMove = (ev: PointerEvent) => {\n if (ev.pointerId !== pointerId) return\n liveWidth = clampContainer(\n clampTree(startWidth + (ev.clientX - startX) * dir),\n bodyRef.current?.clientWidth ?? 0\n )\n liveTreeWidthRef.current = liveWidth\n if (treePaneRef.current) {\n treePaneRef.current.style.width = `${liveWidth}px`\n }\n }\n const finish = (ev?: PointerEvent) => {\n if (ev && ev.pointerId !== pointerId) return\n window.removeEventListener(\"pointermove\", onMove)\n window.removeEventListener(\"pointerup\", finish)\n window.removeEventListener(\"pointercancel\", finish)\n splitter.removeAttribute(\"data-resizing\")\n document.body.style.cursor = \"\"\n document.body.style.userSelect = \"\"\n liveTreeWidthRef.current = null\n if (liveWidth !== startWidth) {\n setTreeWidth(liveWidth)\n viewConfigRef.current.treePanel?.onWidthChange?.(liveWidth)\n }\n }\n window.addEventListener(\"pointermove\", onMove)\n window.addEventListener(\"pointerup\", finish)\n window.addEventListener(\"pointercancel\", finish)\n }\n\n // Both panes scroll vertically; whichever moves drives the other.\n useEffect(() => {\n const treeViewport = getPaneViewport(treePaneRef.current)\n const timelineViewport = getPaneViewport(timelinePaneRef.current)\n if (!treeViewport || !timelineViewport) return\n const link = (source: HTMLElement, target: HTMLElement) => {\n // Mirror only when the source's own vertical position changed -\n // horizontal-only scroll events must not replay a stale scrollTop over\n // the other pane. Assign only on drift: the mirrored handler then\n // no-ops, so no loop.\n let lastTop = source.scrollTop\n const onScroll = () => {\n if (source.scrollTop === lastTop) return\n lastTop = source.scrollTop\n if (target.scrollTop !== source.scrollTop) {\n target.scrollTop = source.scrollTop\n }\n }\n source.addEventListener(\"scroll\", onScroll)\n return () => source.removeEventListener(\"scroll\", onScroll)\n }\n const unlinkTree = link(treeViewport, timelineViewport)\n const unlinkTimeline = link(timelineViewport, treeViewport)\n let unforward: (() => void) | null = null\n if (treeViewport.hasAttribute(\"data-gantt-native-scroll\")) {\n // Native mode: the tree's vertical axis is overflow-hidden (its bar\n // would duplicate the timeline's), so vertical wheel intent forwards to\n // the timeline, which mirrors back through the link above. Horizontal\n // wheel intent stays native for the tree's own columns.\n const onWheel = (e: WheelEvent) => {\n // ctrl/cmd (and trackpad pinch, which sets ctrlKey) is the zoom\n // gesture; scrolling as well would move the rows out from under it\n if (e.ctrlKey || e.metaKey) return\n if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return\n const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY\n timelineViewport.scrollTop += dy\n e.preventDefault()\n }\n treeViewport.addEventListener(\"wheel\", onWheel, { passive: false })\n unforward = () => treeViewport.removeEventListener(\"wheel\", onWheel)\n } else {\n // Custom scrollbars: both panes are real vertical scrollers. The links\n // above mirror on the scroll event, which fires only AFTER the source\n // has already painted - so with compositor momentum (wheel/trackpad) the\n // active pane runs a frame ahead of the mirror and the two visibly drift\n // (the flicker). Fix: drive BOTH viewports from one wheel handler so they\n // move in the same frame, perfectly locked. Horizontal intent stays\n // native for each pane's own axis; the links still cover scrollbar drags,\n // keyboard, touch and programmatic scrolls; touch has no wheel events,\n // so flick-scrolling syncs through the (frame-lagged) link - accepted,\n // pointer drags are the gantt's primary touch interaction.\n const onWheel = (e: WheelEvent) => {\n // ctrl/cmd (and trackpad pinch, which sets ctrlKey) is the zoom\n // gesture; scrolling as well would move the rows out from under it\n if (e.ctrlKey || e.metaKey) return\n if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return\n const max =\n timelineViewport.scrollHeight - timelineViewport.clientHeight\n if (max <= 0) return\n const unit =\n e.deltaMode === 1\n ? 16\n : e.deltaMode === 2\n ? timelineViewport.clientHeight\n : 1\n const next = Math.max(\n 0,\n Math.min(max, timelineViewport.scrollTop + e.deltaY * unit)\n )\n e.preventDefault()\n timelineViewport.scrollTop = next\n treeViewport.scrollTop = next\n }\n treeViewport.addEventListener(\"wheel\", onWheel, { passive: false })\n timelineViewport.addEventListener(\"wheel\", onWheel, { passive: false })\n unforward = () => {\n treeViewport.removeEventListener(\"wheel\", onWheel)\n timelineViewport.removeEventListener(\"wheel\", onWheel)\n }\n }\n return () => {\n unlinkTree()\n unlinkTimeline()\n unforward?.()\n }\n }, [scale, viewConfig.scrollbars])\n\n // Linked row hover: mirror data-hover onto the row's twin in the other pane\n useEffect(() => {\n const body = bodyRef.current\n if (!body) return\n let current: string | null = null\n const apply = (id: string | null) => {\n if (id === current) return\n if (current) {\n body\n .querySelectorAll(`[data-gantt-row-id=\"${CSS.escape(current)}\"]`)\n .forEach((el) => el.removeAttribute(\"data-hover\"))\n }\n if (id) {\n body\n .querySelectorAll(`[data-gantt-row-id=\"${CSS.escape(id)}\"]`)\n .forEach((el) => el.setAttribute(\"data-hover\", \"\"))\n }\n current = id\n }\n const onOver = (e: PointerEvent) => {\n const row = (e.target as HTMLElement | null)?.closest?.(\n \"[data-gantt-row-id]\"\n )\n apply(row?.getAttribute(\"data-gantt-row-id\") ?? null)\n }\n const onLeave = () => apply(null)\n body.addEventListener(\"pointerover\", onOver)\n body.addEventListener(\"pointerleave\", onLeave)\n return () => {\n body.removeEventListener(\"pointerover\", onOver)\n body.removeEventListener(\"pointerleave\", onLeave)\n apply(null)\n }\n }, [])\n\n // Auto-manage the horizontal position for the current anchor until the user\n // scrolls: pre-buffer one period per side (so infinite scroll never resizes\n // the scrollbar on the first gesture), then center the target instant.\n // Idempotent - re-running just re-centers the same instant - so React\n // StrictMode's double-invoke and range-growth re-renders are both safe.\n const anchorMs = useGanttSelector((state) => state.date.getTime())\n // flattened to a primitive so an inline `initialCenter={new Date(...)}`\n // cannot re-run the centring effect on every render\n const initialCenter =\n viewConfig.initialCenter instanceof Date\n ? viewConfig.initialCenter.getTime()\n : viewConfig.initialCenter\n const manageRef = useRef({ key: \"\", buffered: false, userTook: false })\n useLayoutEffect(() => {\n const key = `${scale}:${anchorMs}:${viewConfig.scrollbars}`\n if (manageRef.current.key !== key) {\n // An anchor change from an extendRange window SLIDE continues the\n // user's own travel: the guard survives, or the pre-buffer branch\n // would re-extend and cascade further slides at the window cap.\n const slideContinuation =\n manageRef.current.userTook && instance.internals.didAnchorSlide()\n manageRef.current = slideContinuation\n ? { key, buffered: manageRef.current.buffered, userTook: true }\n : { key, buffered: false, userTook: false }\n }\n if (manageRef.current.userTook) return\n // Deferred-mount wait: when the viewport is not measurable yet (hidden\n // tab, display:none ancestor), a ResizeObserver resumes positioning on\n // the exact frame it gains a size - RO callbacks run in the rendering\n // steps BEFORE that frame paints, so no uncentered frame is ever shown.\n // (An rAF retry here would paint the range start first and then snap.)\n let waiter: ResizeObserver | null = null\n const run = () => {\n waiter?.disconnect()\n waiter = null\n const viewport = getPaneViewport(timelinePaneRef.current)\n const axis = viewport?.querySelector(\"[data-gantt-axis]\")\n if (!viewport || !axis) return\n if (viewport.clientWidth === 0) {\n waiter = new ResizeObserver(() => {\n if (viewport.clientWidth > 0) run()\n })\n waiter.observe(viewport)\n return\n }\n // pre-buffer once; the re-run after the range grows lands the center\n if (viewConfig.infiniteScroll && !manageRef.current.buffered) {\n manageRef.current.buffered = true\n extendLockRef.current = true\n instance.internals.extendRange(\"before\")\n instance.internals.extendRange(\"after\")\n return\n }\n extendLockRef.current = false\n if (viewport.scrollWidth <= viewport.clientWidth) return\n // read the clock at run time - the effect must not depend on a\n // reactive now that re-runs it (and the whole grid) every 30s.\n // Target now ONLY when the anchor period itself contains it: keying\n // on the whole (buffered) visible range would re-center prev/next\n // navigation right back onto today.\n const active = instance.getState().activeRange\n let target: number\n if (typeof initialCenter === \"number\") {\n target = initialCenter\n } else if (initialCenter === \"anchor\") {\n target = anchorMs\n } else {\n const nowMs = Date.now()\n target =\n nowMs >= active.start.getTime() && nowMs < active.end.getTime()\n ? nowMs\n : anchorMs\n }\n const fraction = Math.min(\n Math.max((target - rangeStartMs) / (rangeEndMs - rangeStartMs), 0),\n 1\n )\n setScrollStart(\n viewport,\n Math.max(0, fraction * viewport.scrollWidth - viewport.clientWidth / 2)\n )\n }\n run()\n return () => {\n waiter?.disconnect()\n }\n }, [\n scale,\n anchorMs,\n initialCenter,\n viewConfig.scrollbars,\n viewConfig.infiniteScroll,\n rangeKey,\n rangeStartMs,\n rangeEndMs,\n instance,\n ])\n\n // ----- infinite scroll: grow the range near an edge, keep the position -----\n // Restoration is anchored to a TIMESTAMP, not pixel deltas: it survives\n // growth, window slides, and zoom changes alike.\n const pendingRestoreRef = useRef<{\n ms: number\n align: \"start\" | \"center\"\n /**\n * Park the anchored instant this many pixels from the viewport's inline\n * start instead of at the edge or the middle. Wheel zoom needs it so the\n * instant under the cursor stays under the cursor.\n */\n offsetPx?: number\n } | null>(null)\n const extendLockRef = useRef(false)\n const lastUserScrollRef = useRef(0)\n /** Fine-grained viewport-center instant, for controlled-zoom anchoring. */\n const fineCenterRef = useRef(null)\n const lastZoomRef = useRef(null)\n\n // Only user gestures may extend the range - programmatic scrolls (chip\n // jumps, auto-center, zoom clamping) must never grow it.\n useEffect(() => {\n const pane = timelinePaneRef.current\n if (!pane) return\n const markIntent = () => {\n lastUserScrollRef.current = performance.now()\n }\n // zooming is not scroll intent - the re-seat it triggers must not be\n // mistaken for the user reaching an edge and asking to grow the range\n const markWheelIntent = (e: WheelEvent) => {\n if (e.ctrlKey || e.metaKey) return\n markIntent()\n }\n // pointerdown counts only where pressing can scroll: the scrollbars,\n // the pan header, or a native-scroll host - NOT bars, chips, or zoom\n const onPointerDown = (e: PointerEvent) => {\n const target = e.target as HTMLElement | null\n if (\n target?.closest(\n \"[data-slot=scroll-area-scrollbar], [data-slot=gantt-timeline-header], [data-gantt-native-scroll]\"\n )\n ) {\n markIntent()\n }\n }\n pane.addEventListener(\"wheel\", markWheelIntent, { passive: true })\n pane.addEventListener(\"pointerdown\", onPointerDown)\n pane.addEventListener(\"touchstart\", markIntent, { passive: true })\n pane.addEventListener(\"keydown\", markIntent)\n return () => {\n pane.removeEventListener(\"wheel\", markWheelIntent)\n pane.removeEventListener(\"pointerdown\", onPointerDown)\n pane.removeEventListener(\"touchstart\", markIntent)\n pane.removeEventListener(\"keydown\", markIntent)\n }\n }, [])\n\n useEffect(() => {\n if (!viewConfig.infiniteScroll) return\n const viewport = getPaneViewport(timelinePaneRef.current)\n if (!viewport) return\n const tryExtend = (direction: \"before\" | \"after\") => {\n // anchor the left edge as an instant, from the LIVE axis range\n const axis = viewport.querySelector(\"[data-gantt-axis]\")\n const liveStart = Number(axis?.dataset.ganttRangeStart)\n const liveEnd = Number(axis?.dataset.ganttRangeEnd)\n if (!axis || Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return\n extendLockRef.current = true\n manageRef.current.userTook = true\n pendingRestoreRef.current = {\n ms:\n liveStart +\n (getScrollStart(viewport) / viewport.scrollWidth) *\n (liveEnd - liveStart),\n align: \"start\",\n }\n if (!instance.internals.extendRange(direction)) {\n pendingRestoreRef.current = null\n extendLockRef.current = false\n }\n }\n // scrollLeft is signed by direction; all edge math runs on the\n // distance-from-inline-start so RTL panes behave identically\n const isRtl = getComputedStyle(viewport).direction === \"rtl\"\n const onScroll = () => {\n if (extendLockRef.current) return\n if (performance.now() - lastUserScrollRef.current > 1200) return\n // a track that fits the pane has no scroll gesture to extend from\n if (viewport.scrollWidth <= viewport.clientWidth + 8) return\n const fromStart = getScrollStart(viewport)\n const fromEnd = viewport.scrollWidth - fromStart - viewport.clientWidth\n const direction =\n fromStart < infiniteEdgePx\n ? (\"before\" as const)\n : fromEnd < infiniteEdgePx\n ? (\"after\" as const)\n : null\n if (!direction) return\n tryExtend(direction)\n }\n // parked exactly on an edge, further wheeling emits no scroll event -\n // the wheel itself is the growth gesture then\n const onWheel = (e: WheelEvent) => {\n // a zoom gesture must never grow the range: the track is rescaling\n // under the pointer, so an edge reading mid-gesture is meaningless\n if (e.ctrlKey || e.metaKey) return\n if (extendLockRef.current || e.deltaX === 0) return\n if (viewport.scrollWidth <= viewport.clientWidth + 8) return\n const towardStart = isRtl ? e.deltaX > 0 : e.deltaX < 0\n if (towardStart && getScrollStart(viewport) <= 0) {\n tryExtend(\"before\")\n } else if (\n !towardStart &&\n getScrollStart(viewport) + viewport.clientWidth >=\n viewport.scrollWidth - 1\n ) {\n tryExtend(\"after\")\n }\n }\n viewport.addEventListener(\"scroll\", onScroll)\n viewport.addEventListener(\"wheel\", onWheel, { passive: true })\n return () => {\n viewport.removeEventListener(\"scroll\", onScroll)\n viewport.removeEventListener(\"wheel\", onWheel)\n }\n }, [\n instance,\n viewConfig.infiniteScroll,\n scale,\n viewConfig.scrollbars,\n infiniteEdgePx,\n ])\n\n // Report the visible-center instant so the nav title names what you are\n // looking at. Throttled to period boundaries (a coarse key) so scrolling\n // within a period never re-renders the grid.\n useEffect(() => {\n const viewport = getPaneViewport(timelinePaneRef.current)\n if (!viewport) return\n const keyFmt =\n scale === \"day\"\n ? \"yyyy-MM-dd\"\n : scale === \"week\"\n ? \"RRRR-'W'II\"\n : scale === \"month\"\n ? \"yyyy-MM\"\n : scale === \"quarter\"\n ? \"yyyy-qqq\"\n : \"yyyy\"\n let raf = 0\n let lastKey = \"\"\n const measure = () => {\n raf = 0\n const axis = viewport.querySelector(\"[data-gantt-axis]\")\n const liveStart = Number(axis?.dataset.ganttRangeStart)\n const liveEnd = Number(axis?.dataset.ganttRangeEnd)\n if (!axis || Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return\n const fraction =\n (getScrollStart(viewport) + viewport.clientWidth / 2) /\n Math.max(1, viewport.scrollWidth)\n const centerMs = liveStart + fraction * (liveEnd - liveStart)\n // fine center first (controlled-zoom anchor), then the coarse-keyed\n // store report that drives the nav title\n fineCenterRef.current = centerMs\n const center = new Date(centerMs)\n const key = format(toZoned(center, timeZone), keyFmt)\n if (key === lastKey) return\n lastKey = key\n instance.internals.setViewportCenter(center)\n }\n const schedule = () => {\n if (!raf) raf = requestAnimationFrame(measure)\n }\n viewport.addEventListener(\"scroll\", schedule)\n schedule()\n return () => {\n viewport.removeEventListener(\"scroll\", schedule)\n if (raf) cancelAnimationFrame(raf)\n }\n }, [instance, scale, timeZone, viewConfig.scrollbars, rangeKey])\n\n // Re-seat the viewport on its anchored instant before paint. Runs for range\n // growth, window slides, and zoom changes; a slide moves the anchor date,\n // so pre-mark auto-centering as done for the new key.\n useLayoutEffect(() => {\n // Consumer-driven (controlled) zoom changes carry no anchorZoomCenter\n // call; anchor them to the last known viewport center so the view does\n // not drift. Built-in buttons set pendingRestore first and win.\n if (\n lastZoomRef.current !== null &&\n lastZoomRef.current !== zoom &&\n !pendingRestoreRef.current &&\n fineCenterRef.current !== null\n ) {\n pendingRestoreRef.current = { ms: fineCenterRef.current, align: \"center\" }\n }\n lastZoomRef.current = zoom\n let raf: number | null = null\n let attempts = 0\n const seat = () => {\n raf = null\n const viewport = getPaneViewport(timelinePaneRef.current)\n if (viewport && pendingRestoreRef.current) {\n // Not laid out yet (0-width on first mount): centering with a 0 offset\n // parks the view a half-pane off. Defer until the pane is measured so\n // \"center\" lands the anchored instant in the middle on initial load.\n if (viewport.clientWidth === 0 && attempts++ < 20) {\n raf = requestAnimationFrame(seat)\n return\n }\n const { ms, align, offsetPx } = pendingRestoreRef.current\n pendingRestoreRef.current = null\n // clamp: a stale anchor (e.g. an ignored controlled-zoom proposal)\n // must never park the view outside the track\n const fraction = Math.min(\n Math.max((ms - rangeStartMs) / (rangeEndMs - rangeStartMs), 0),\n 1\n )\n const offset =\n offsetPx ?? (align === \"center\" ? viewport.clientWidth / 2 : 0)\n setScrollStart(\n viewport,\n Math.max(0, fraction * viewport.scrollWidth - offset)\n )\n }\n extendLockRef.current = false\n }\n seat()\n return () => {\n if (raf !== null) cancelAnimationFrame(raf)\n }\n }, [\n rangeKey,\n zoom,\n rangeStartMs,\n rangeEndMs,\n scale,\n viewConfig.scrollbars,\n instance,\n ])\n\n /** Keep the view centered on the same instant across a zoom step. */\n const anchorZoomCenter = () => {\n const viewport = getPaneViewport(timelinePaneRef.current)\n const axis = viewport?.querySelector(\"[data-gantt-axis]\")\n if (!viewport || !axis) return\n const liveStart = Number(axis.dataset.ganttRangeStart)\n const liveEnd = Number(axis.dataset.ganttRangeEnd)\n if (Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return\n pendingRestoreRef.current = {\n ms:\n liveStart +\n ((getScrollStart(viewport) + viewport.clientWidth / 2) /\n viewport.scrollWidth) *\n (liveEnd - liveStart),\n align: \"center\",\n }\n }\n\n /**\n * Keep the instant under the POINTER pinned across a zoom step. The buttons\n * anchor the viewport center, but a wheel or pinch gesture points at\n * something - zooming away from it reads as the content sliding out from\n * under the cursor.\n */\n const anchorZoomPointer = (clientX: number) => {\n const viewport = getPaneViewport(timelinePaneRef.current)\n const axis = viewport?.querySelector(\"[data-gantt-axis]\")\n if (!viewport || !axis) return\n const liveStart = Number(axis.dataset.ganttRangeStart)\n const liveEnd = Number(axis.dataset.ganttRangeEnd)\n if (Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return\n // trackPoint mirrors in RTL, where the range start is the right edge\n const offsetPx = trackPoint(viewport, clientX).offset\n pendingRestoreRef.current = {\n ms:\n liveStart +\n ((getScrollStart(viewport) + offsetPx) / viewport.scrollWidth) *\n (liveEnd - liveStart),\n align: \"start\",\n offsetPx,\n }\n }\n\n // ----- ctrl/cmd + wheel (and trackpad pinch) zooms the time range -----\n // The listener is manual and non-passive because it must preventDefault:\n // React's synthetic wheel handler cannot. It attaches once and reads the\n // live logic through a ref, so a zoom step never re-binds mid-gesture.\n const wheelZoomRef = useRef<((e: WheelEvent) => void) | null>(null)\n useEffect(() => {\n wheelZoomRef.current = (e: WheelEvent) => {\n if (!viewConfig.wheelZoom) return\n // Browsers deliver a trackpad pinch as wheel + ctrlKey on every\n // platform; metaKey is the Mac keyboard idiom the same gesture implies.\n if (!e.ctrlKey && !e.metaKey) return\n const lines = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1\n // Continuous, not stepped: a pinch emits dozens of small deltas per\n // second, so the button's 0.25 step would slam to a limit instantly.\n // Exponential keeps each notch proportional at any zoom level.\n const next = clampZoom(zoom * Math.exp(-e.deltaY * lines * 0.002))\n // Already clamped: hand the gesture back so the browser's own page\n // zoom still works for anyone who relies on it.\n if (Math.abs(next - zoom) < 1e-4) return\n e.preventDefault()\n // Controlled zoom anchors via fineCenterRef when the parent adopts;\n // pre-setting an anchor would leak stale if the parent ignores it.\n if (viewConfig.zoom === undefined) anchorZoomPointer(e.clientX)\n setZoomValue(+next.toFixed(4))\n }\n })\n useEffect(() => {\n const viewport = getPaneViewport(timelinePaneRef.current)\n if (!viewport) return\n const onWheel = (e: WheelEvent) => wheelZoomRef.current?.(e)\n viewport.addEventListener(\"wheel\", onWheel, { passive: false })\n return () => viewport.removeEventListener(\"wheel\", onWheel)\n }, [viewConfig.scrollbars, scale])\n\n // Drag-to-pan from the header (intent-based: activates after 4px)\n const beginHeaderPan = (e: React.PointerEvent) => {\n if (e.button !== 0) return\n const viewport = getPaneViewport(timelinePaneRef.current)\n if (!viewport) return\n const pointerId = e.pointerId\n const startX = e.clientX\n const startLeft = viewport.scrollLeft\n let active = false\n const onMove = (ev: PointerEvent) => {\n if (ev.pointerId !== pointerId) return\n const dx = ev.clientX - startX\n if (!active && Math.abs(dx) < 4) return\n if (!active) {\n markGestureEnd()\n setIsPanning(true)\n }\n active = true\n // panning is a user scroll: keep the infinite-scroll gate open\n lastUserScrollRef.current = performance.now()\n document.body.style.cursor = \"grabbing\"\n document.body.style.userSelect = \"none\"\n viewport.scrollLeft = startLeft - dx\n }\n const finish = (ev?: PointerEvent) => {\n if (ev && ev.pointerId !== pointerId) return\n window.removeEventListener(\"pointermove\", onMove)\n window.removeEventListener(\"pointerup\", finish)\n window.removeEventListener(\"pointercancel\", finish)\n document.body.style.cursor = \"\"\n document.body.style.userSelect = \"\"\n if (active) setIsPanning(false)\n }\n window.addEventListener(\"pointermove\", onMove)\n window.addEventListener(\"pointerup\", finish)\n window.addEventListener(\"pointercancel\", finish)\n }\n\n // Panning suppresses the placement hints (scroll intent, not create intent)\n const [isPanning, setIsPanning] = useState(false)\n\n // ----- tree-row drag reorder (mirrors the event engine: live validity,\n // destructive styling when invalid, Esc cancel, commit via callback) -----\n const [reorder, setReorder] = useState(null)\n const reorderEnabled = !!settings.onResourceReorder\n const settingsRef = useRef(settings)\n settingsRef.current = settings\n const rowsRef = useRef(rows)\n rowsRef.current = rows\n\n const beginRowReorder = useCallback(\n (e: React.PointerEvent, dragRow: TimelineRow) => {\n const settings = settingsRef.current\n const rows = rowsRef.current\n if (e.button !== 0 || !settings.onResourceReorder) return\n e.preventDefault()\n e.stopPropagation()\n const container = treeRowsRef.current\n const pane = treePaneRef.current\n if (!container || !pane) return\n const rowEls = Array.from(\n container.querySelectorAll(\"[data-slot=gantt-row-group]\")\n )\n document.body.style.cursor = \"grabbing\"\n document.body.style.userSelect = \"none\"\n let current: TimelineReorderState | null = null\n let lastBoundary = -1\n\n // Carry overlay: clone the WHOLE row (name + detail columns), flat -\n // it reads as the row itself moving, not a separate card\n const pointerId = e.pointerId\n const rowEl = (e.currentTarget as HTMLElement).closest(\n \"[data-slot=gantt-row-group]\"\n )\n const overlay = document.createElement(\"div\")\n overlay.setAttribute(\"data-slot\", \"gantt-drag-overlay\")\n overlay.className =\n \"bg-background pointer-events-none fixed overflow-hidden opacity-95\"\n overlay.style.zIndex = \"100\"\n // body-appended, so it is outside the gantt root that owns the type\n // scale: without adopting the root's resolved metrics the carried row\n // renders at the document default and reads bigger than the row it left\n const ganttRoot = pane.closest(\"[data-slot=gantt]\")\n if (ganttRoot) {\n const rootStyle = getComputedStyle(ganttRoot)\n overlay.style.fontSize = rootStyle.fontSize\n overlay.style.lineHeight = rootStyle.lineHeight\n overlay.style.fontFamily = rootStyle.fontFamily\n overlay.style.letterSpacing = rootStyle.letterSpacing\n overlay.style.direction = rootStyle.direction\n }\n if (rowEl) {\n const rowRect = rowEl.getBoundingClientRect()\n overlay.style.width = `${rowRect.width}px`\n overlay.style.height = `${rowRect.height}px`\n const clone = rowEl.cloneNode(true) as HTMLElement\n clone.removeAttribute(\"data-gantt-row-id\")\n clone.classList.remove(\"border-b\")\n clone.style.height = \"100%\"\n overlay.appendChild(clone)\n }\n document.body.appendChild(overlay)\n const grabRect = rowEl?.getBoundingClientRect()\n const grabDY = grabRect ? e.clientY - grabRect.top : 8\n // vertical-only carry, locked to the tree panel like a list row drag\n const lockedX = grabRect?.left ?? pane.getBoundingClientRect().left\n const paneRect = pane.getBoundingClientRect()\n const rowH = grabRect?.height ?? 40\n // Rows do not move during the gesture (the carry is a fixed overlay), so\n // rects are measured ONCE - per-move full-row rect scans forced a\n // synchronous reflow after every overlay style write.\n const rects = rowEls.map((el) => el.getBoundingClientRect())\n const place = (y: number) => {\n const top = Math.min(\n Math.max(y - grabDY, paneRect.top),\n paneRect.bottom - rowH\n )\n overlay.style.left = `${lockedX}px`\n overlay.style.top = `${top}px`\n }\n place(e.clientY)\n\n const propose = (boundary: number): TimelineReorderState => {\n const below = rows[boundary]\n const parentId =\n below?.parentId ?? rows[rows.length - 1]?.parentId ?? null\n let index = 0\n for (let i = 0; i < boundary; i++) {\n if (\n rows[i].parentId === parentId &&\n rows[i].resource.id !== dragRow.resource.id\n ) {\n index++\n }\n }\n const next = reorderResources(\n settings.resources,\n dragRow.resource.id,\n parentId,\n index\n )\n const proposal: GanttResourceReorder | null = next\n ? {\n resourceId: dragRow.resource.id,\n parentId,\n index,\n resources: next,\n }\n : null\n const valid =\n !!proposal && (settings.canReorderResource?.(proposal) ?? true)\n const top =\n boundary < rects.length\n ? rects[boundary].top - paneRect.top\n : (rects[rects.length - 1]?.bottom ?? paneRect.top) - paneRect.top\n return { resourceId: dragRow.resource.id, top, valid, proposal }\n }\n\n const onMove = (ev: PointerEvent) => {\n if (ev.pointerId !== pointerId) return\n place(ev.clientY)\n let boundary = rects.length\n for (let i = 0; i < rects.length; i++) {\n if (ev.clientY < rects[i].top + rects[i].height / 2) {\n boundary = i\n break\n }\n }\n // the immutable tree clone + validity check run only when the pointer\n // crosses into another slot, not per pointermove\n if (boundary === lastBoundary) return\n lastBoundary = boundary\n const nextState = propose(boundary)\n if (\n current &&\n current.top === nextState.top &&\n current.valid === nextState.valid\n ) {\n return\n }\n current = nextState\n document.body.style.cursor = nextState.valid\n ? \"grabbing\"\n : \"not-allowed\"\n setReorder(nextState)\n }\n const finish = (commit: boolean) => {\n window.removeEventListener(\"pointermove\", onMove)\n window.removeEventListener(\"pointerup\", onUp)\n window.removeEventListener(\"pointercancel\", onCancelEvent)\n window.removeEventListener(\"keydown\", onKey)\n overlay.remove()\n document.body.style.cursor = \"\"\n document.body.style.userSelect = \"\"\n if (commit && current?.proposal && !current.valid) {\n // released on a rejected position (e.g. a pinned row): let the\n // consumer explain it - the destructive indicator already showed live\n settings.onResourceReorderReject?.(current.proposal)\n } else if (commit && current?.valid && current.proposal) {\n settings.onResourceReorder?.(current.proposal)\n const announcer = pane\n .closest(\"[data-slot=gantt]\")\n ?.querySelector(\"[data-slot=gantt-announcer]\")\n if (announcer) {\n announcer.textContent = `${dragRow.resource.title}: ${settings.i18n.labels.reorder}`\n }\n }\n setReorder(null)\n }\n const onUp = (ev: PointerEvent) => {\n if (ev.pointerId !== pointerId) return\n finish(true)\n }\n const onCancelEvent = (ev: PointerEvent) => {\n if (ev.pointerId !== pointerId) return\n finish(false)\n }\n const onKey = (ev: KeyboardEvent) => {\n if (ev.key === \"Escape\") finish(false)\n }\n window.addEventListener(\"pointermove\", onMove)\n window.addEventListener(\"pointerup\", onUp)\n window.addEventListener(\"pointercancel\", onCancelEvent)\n window.addEventListener(\"keydown\", onKey)\n },\n []\n )\n\n const collapsedIdsRef = useRef(collapsedIds)\n collapsedIdsRef.current = collapsedIds\n // Stable identity (reads through refs) so memoized rows survive re-renders\n const onToggleRow = useCallback((row: TimelineRow) => {\n const current = collapsedIdsRef.current\n const next = current.includes(row.resource.id)\n ? current.filter((id) => id !== row.resource.id)\n : [...current, row.resource.id]\n if (viewConfigRef.current.collapsedGroups === undefined) {\n setInternalCollapsed(next)\n }\n viewConfigRef.current.onCollapsedGroupsChange?.(next)\n }, [])\n\n const loading = useGanttSelector((state) => state.loading)\n const customScrollbars = viewConfig.scrollbars !== \"native\"\n const gridLines = resolveTimelineLines(viewConfig.timelineLines)\n const showVerticalLines = gridLines.vertical !== null\n const offDayClassName =\n (typeof viewConfig.offDays === \"object\" && viewConfig.offDays.className) ||\n \"bg-muted/40\"\n // Body texture: default off-days carry a whisper-faint diagonal hatch over\n // a lighter wash (header cells stay flat). A custom offDays.className\n // replaces both surfaces verbatim.\n const offDayBodyClassName =\n (typeof viewConfig.offDays === \"object\" && viewConfig.offDays.className) ||\n \"bg-muted/25 bg-[repeating-linear-gradient(135deg,transparent,transparent_5px,color-mix(in_oklab,var(--color-border)_35%,transparent)_5px,color-mix(in_oklab,var(--color-border)_35%,transparent)_6px)]\"\n\n // Header-only unit lines, and ONE mechanism for every vertical line in the\n // header: positioned spans at calc(fraction% - 1px). A background gradient\n // rasterizes stripe positions differently from element layout at fractional\n // unit widths, which shifted the group-row boundaries 1px off the unit\n // lines below them mid-track - identical span formulas snap identically.\n // The body stays bare - rows separate by whitespace, never vertical borders.\n const showUnitLines = !uniform || showVerticalLines\n\n // ----- tree pane content -----\n // Header label offset = the row cell's ps-3 (0.75rem) left gutter + the\n // toggle/checkbox gutter (w-5 + me-1 = 1.5rem) + the reorder grip (0.875rem)\n // when present, so \"Resources\" lines up with the row titles below it.\n const namePaddingStart = reorderEnabled ? \"3.125rem\" : \"2.25rem\"\n const treeContent = (\n \n {/* Same 65px height as the two-row timeline header so the panes align.\n The head keeps its own bottom rule (under the Resources label), but\n the header/body boundary line is transparent so the first tree node\n has no rule directly above it - the 1px is kept only to preserve the\n 65px height, matching the timeline. */}\n \n
\n
\n \n \n {settings.i18n.labels.resources}\n \n
\n {columns.map((column) => (\n \n {column.title ?? column.id}\n
\n ))}\n
\n
\n {viewConfig.columnsMenu && (\n \n {viewConfig.columnsMenu}\n
\n )}\n \n \n
\n {rows.map((row) => (\n \n ))}\n {showCreateTask && (\n \n settings.onCreateTask?.({\n parentId: null,\n index: settings.resources.length,\n })\n }\n >\n \n {reorderEnabled && (\n \n )}\n {/* group chevrons sit centered in a size-5 button that fills\n this w-5 gutter, so the + must center here too */}\n \n \n \n {settings.i18n.labels.addTask}\n \n \n )}\n
\n \n )\n\n // ----- timeline pane content -----\n const timelineContent = (\n \n {/* Two-row grouped header; also the drag-to-pan surface */}\n \n {/* group sectors; boundaries painted like the body lines */}\n
\n
\n {groups.map((group) => (\n \n {/* Pure-CSS sticky: the label rides the leading edge for as\n long as its own band is on screen, then the next band pushes\n it out - so the day you are looking at always names itself.\n The browser composites this; a scroll listener would run JS\n on every frame to do worse. start-3 matches the cell's ps-3\n so the gutter is identical parked or pinned. */}\n \n {group.label}\n \n
\n ))}\n
\n {/* same span formula as the unit lines below: equal fractions get\n equal layout rounding, so the two rows' lines never drift apart */}\n {groupBoundaries.map((fraction) => (\n \n ))}\n \n {/* units, engine axis = this row */}\n \n {/* chrome underlay: off-day washes under the label layer */}\n
\n {unitFractions.map(\n ({ unit, start, width }) =>\n unit.isOff && (\n \n )\n )}\n
\n
\n {units.map((unit) => (\n \n {/* today reads as a soft pill, not just tinted text */}\n {unit.isToday ? (\n \n {unit.label}\n \n ) : (\n unit.label\n )}\n
\n ))}\n \n {showUnitLines &&\n unitFractions.slice(1).map(({ unit, start }) => (\n \n ))}\n {viewConfig.nowIndicator && (\n \n )}\n \n \n {/* Rows over a shared backdrop (off days, today, boundaries, now);\n grows so the columns run to the bottom of the pane. Pressing anywhere\n here that is not a bar or a hint tile begins a scroll pan - the whole\n panel is a draggable canvas, not just the rows. */}\n \n {/* off-day / today / now backdrop only; vertical gridlines are\n per-row and reveal on selection, not painted here */}\n \n {unitFractions.map(({ unit, start, width }) => (\n \n {unit.isOff && (\n \n )}\n {unit.isToday && scale !== \"day\" && (\n \n )}\n \n ))}\n {/* one layer for the whole body, not per row: the unit boundaries\n have to line up with the header's spans exactly, so both use the\n same fraction formula */}\n {gridLines.vertical !== null &&\n unitFractions\n .slice(1)\n .map(({ unit, start }) => (\n \n ))}\n {viewConfig.nowIndicator && (\n \n )}\n \n {rows.map((row, rowIndex) => (\n \n ))}\n {rows.length === 0 && viewConfig.renderNoResources && (\n \n {viewConfig.renderNoResources()}\n \n )}\n {showCreateTask && (\n \n )}\n \n \n )\n\n const horizontalScrollbar = (\n \n )\n\n const Comp = asChild ? Slot.Root : \"div\"\n\n return (\n \n
\n {/* Tree pane */}\n \n {customScrollbars ? (\n // keyed by scale: Base UI measures overflow once per mount, and a\n // scale switch changes content without resizing the viewport\n [data-orientation=vertical]]:hidden\"\n >\n {treeContent}\n {horizontalScrollbar}\n \n ) : (\n \n {treeContent}\n
\n )}\n {/* Reserved horizontal-scrollbar rail: the tree usually has no\n horizontal overflow, so its real scrollbar never mounts and its\n bottom edge would sit higher than the timeline's pinned strip.\n This static rail fills that gutter (same 1rem height + top border)\n so the bottom strip reads as one continuous band across both\n panes; a real tree scrollbar (with columns) draws over it. */}\n {customScrollbars && (\n \n )}\n {/* Reorder insertion indicator, pinned to the visible pane */}\n {reorder && (\n \n {/* caret head pointing along the insertion line; the whole\n indicator sits above the row carry overlay (z 100) */}\n \n \n \n )}\n \n {/* Splitter */}\n {treeConfig.resizable ? (\n {\n setTreeWidth(treeConfig.width)\n treeConfig.onWidthChange?.(treeConfig.width)\n }}\n onKeyDown={(e) => {\n if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\") {\n e.preventDefault()\n const dir =\n getComputedStyle(e.currentTarget).direction === \"rtl\" ? -1 : 1\n const delta = (e.key === \"ArrowLeft\" ? -16 : 16) * dir\n const next = clampTree(clampedTreeWidth + delta)\n setTreeWidth(next)\n treeConfig.onWidthChange?.(next)\n }\n }}\n >\n {/* grip pill: makes the hairline read as draggable on approach */}\n \n \n ) : (\n
\n )}\n {/* Timeline pane */}\n \n {viewConfig.zoomControl && (\n \n {/* aria-disabled instead of disabled: the not-allowed cursor\n must still show at the zoom limits */}\n \n \n \n {\n if (!canZoomIn) return\n // controlled zoom anchors via fineCenterRef when\n // the parent adopts; a pre-set anchor would leak\n // stale if the parent ignores the proposal\n if (viewConfig.zoom === undefined) anchorZoomCenter()\n setZoomValue(\n +(zoom + (zoomRange.step ?? 0.25)).toFixed(2)\n )\n }}\n >\n \n \n \n \n {settings.i18n.labels.zoomIn}\n \n \n \n \n {\n if (!canZoomOut) return\n if (viewConfig.zoom === undefined) anchorZoomCenter()\n setZoomValue(\n +(zoom - (zoomRange.step ?? 0.25)).toFixed(2)\n )\n }}\n >\n \n \n \n \n {settings.i18n.labels.zoomOut}\n \n \n \n
\n )}\n {customScrollbars ? (\n // The vertical scrollbar is inset into the body lane: it starts\n // below the sticky 65px two-row header (otherwise its top slides\n // behind the header and the thumb is clipped) and stops above the\n // pinned 16px horizontal strip. `!` overrides the primitive's\n // inline top/bottom; h-auto lets top+bottom define the track\n // height so the thumb is measured against the visible lane.\n // The last selector is the radix twin of base's\n // ScrollAreaPrimitive.Content min-h-full: radix's Viewport wraps\n // children in its own display:table div, and without a height on\n // it the content's min-h-full collapses - the columns then stop at\n // the last row and leave the rest of the pane dead and unpannable.\n [data-orientation=vertical]]:top-[65px]! [&>[data-orientation=vertical]]:bottom-4! [&>[data-orientation=vertical]]:h-auto! [&>[data-slot=scroll-area-viewport]>div]:flex! [&>[data-slot=scroll-area-viewport]>div]:min-h-full [&>[data-slot=scroll-area-viewport]>div]:flex-col\"\n >\n {timelineContent}\n {horizontalScrollbar}\n \n ) : (\n \n {timelineContent}\n \n )}\n {/* Reserved scrollbar rail - the twin of the tree rail. Keeps the\n bottom gutter present even when the real horizontal scrollbar is\n hidden (e.g. hover-reveal scrollbars at rest), so the strip reads\n as one continuous reserved band across both panes; the real\n scrollbar (z-40) draws over it when active. */}\n {customScrollbars && (\n \n )}\n {viewConfig.offscreenIndicators && (\n \n )}\n \n {loading && (\n \n \n {settings.i18n.labels.loading}\n \n \n )}\n {(viewConfig.renderDragPreview || viewConfig.renderResizeIndicator) && (\n \n )}\n \n \n )\n}\n\n/**\n * The red now-line, self-ticking: only this component re-renders on the 30s\n * clock, never the grid around it. z-10 keeps it above row content but UNDER\n * the sticky header (z-30) - vertical scrolling slides it beneath, never over.\n */\nfunction GanttNowLine({\n rangeStartMs,\n rangeEndMs,\n}: {\n rangeStartMs: number\n rangeEndMs: number\n}) {\n const now = useNow()\n const ms = now.getTime()\n if (ms < rangeStartMs || ms >= rangeEndMs) return null\n const fraction = (ms - rangeStartMs) / (rangeEndMs - rangeStartMs)\n return (\n \n )\n}\n\n/**\n * The now-line's dot cap, pinned INSIDE the sticky header at the header/body\n * boundary: it stays put while the line scrolls beneath the header.\n */\nfunction GanttNowDot({\n rangeStartMs,\n rangeEndMs,\n}: {\n rangeStartMs: number\n rangeEndMs: number\n}) {\n const now = useNow()\n const ms = now.getTime()\n if (ms < rangeStartMs || ms >= rangeEndMs) return null\n const fraction = (ms - rangeStartMs) / (rangeEndMs - rangeStartMs)\n return (\n \n )\n}\n\n/**\n * Consumer-owned drag/resize indicators (renderDragPreview /\n * renderResizeIndicator): content is React and re-renders per snap step from\n * drag state; the dnd engine adopts this wrapper and writes its\n * cursor-tracking transform imperatively, flipping visibility on the first\n * positioned frame so nothing flashes at the viewport origin.\n */\nfunction GanttCustomDragLayer() {\n const viewConfig = useGanttViewConfig()\n const drag = useGanttSelector((state) => state.drag)\n if (!drag) return null\n const render =\n drag.kind === \"move\"\n ? viewConfig.renderDragPreview\n : viewConfig.renderResizeIndicator\n if (!render) return null\n return (\n \n {render({\n occurrence: drag.occurrence,\n kind: drag.kind,\n start: drag.proposedStart,\n end: drag.proposedEnd,\n valid: drag.valid,\n })}\n \n )\n}\n\n/** Memoized: only rows whose props actually changed re-render. */\nconst GanttTreeRow = memo(function GanttTreeRow({\n row,\n heightRem,\n bandRem,\n columns,\n nameWidth,\n dimmed,\n selected,\n onSelectedChange,\n onGripPointerDown,\n onToggle,\n}: {\n row: TimelineRow\n heightRem: number\n bandRem: number\n columns: GanttColumn[]\n nameWidth: number\n dimmed: boolean\n selected: boolean\n onSelectedChange?: (id: string, checked: boolean) => void\n onGripPointerDown?: (e: React.PointerEvent, row: TimelineRow) => void\n onToggle: (row: TimelineRow) => void\n}) {\n const settings = useGanttSettings()\n const viewConfig = useGanttViewConfig()\n const ctx = {\n resource: row.resource,\n depth: row.depth,\n isGroup: row.isGroup,\n collapsed: row.collapsed,\n }\n\n // consumer-owned right-click menu, same contract as the bar menu\n const menu = viewConfig.renderResourceMenu?.(ctx)\n\n // A node with several schedules grows its row; \"start\" keeps the label and\n // its columns on the FIRST schedule's baseline instead of floating them to\n // the middle of a tall row. Every cell's inner box is minRowHeight tall, so\n // a single-lane row renders identically either way.\n const alignStart = (viewConfig.rowAlign ?? DEFAULT_ROW_ALIGN) === \"start\"\n\n const rowNode = (\n {\n // chrome clicks (chevron, checkbox, grip) are their own actions\n if ((e.target as HTMLElement).closest(\"button, [role=checkbox]\"))\n return\n settings.onResourceClick?.(ctx, e)\n }\n : undefined\n }\n onDoubleClick={\n settings.onResourceDoubleClick\n ? (e: React.MouseEvent) => {\n if ((e.target as HTMLElement).closest(\"button, [role=checkbox]\"))\n return\n settings.onResourceDoubleClick?.(ctx, e)\n }\n : undefined\n }\n >\n
\n {/* In-flow name cell: the whole tree row scrolls horizontally as one;\n row-level hover/selected tints show through the transparent cell */}\n \n {/* exactly the band the first schedule occupies: same height, both\n top-anchored, so the label and that schedule share a centerline */}\n \n {onGripPointerDown && (\n onGripPointerDown(e, row)}\n onClick={(e) => e.stopPropagation()}\n >\n \n \n )}\n {/* per-level indent keeps sibling titles on one x */}\n \n {/* fixed gutter: groups toggle here, leaves carry the checkbox -\n titles of one level share the same x either way */}\n \n {row.isGroup ? (\n onToggle(row)}\n >\n \n \n ) : (\n viewConfig.rowCheckboxes &&\n onSelectedChange && (\n \n onSelectedChange(row.resource.id, checked)\n }\n aria-label={row.resource.title}\n className={cn(\n \"size-3.5 opacity-0 transition-opacity group-hover/gantt-row:opacity-100 group-data-hover/gantt-row:opacity-100 focus-visible:opacity-100\",\n selected && \"opacity-100\"\n )}\n />\n )\n )}\n \n {viewConfig.renderResourceLabel?.(ctx) ?? (\n {row.resource.title}\n )}\n
\n \n {columns.map((column) => (\n \n \n {column.render?.(ctx)}\n \n \n ))}\n
\n
\n \n )\n\n if (!menu) return rowNode\n return (\n \n {rowNode}\n \n {menu}\n \n \n )\n})\n\nconst GanttTimelineRow = memo(function GanttTimelineRow({\n row,\n rowIndex,\n bars,\n rangeStartMs,\n rangeEndMs,\n trackWidth,\n trackRemWidth,\n rowBorder,\n selected,\n resolveHintStop,\n isPanning,\n laneHeightRem,\n laneGapRem,\n minRowRem,\n}: {\n row: TimelineRow\n rowIndex: number\n bars: TimelineRowBars | undefined\n rangeStartMs: number\n rangeEndMs: number\n trackWidth: string\n trackRemWidth: number\n rowBorder: \"solid\" | \"dashed\" | null\n selected: boolean\n resolveHintStop: (\n fraction: number\n ) => { index: number; center: number; ms: number; endMs: number } | null\n isPanning: boolean\n laneHeightRem: number\n laneGapRem: number\n minRowRem: number\n}) {\n const instance = useGantt()\n const settings = useGanttSettings()\n const viewConfig = useGanttViewConfig()\n const gestures = useGanttGestures()\n const segments = bars?.segments ?? []\n // parents aggregate their subtree; they take no direct scheduling gestures\n const schedulable = !row.isGroup || viewConfig.parentScheduling\n const heightRem = bars?.heightRem ?? minRowRem\n const laneCount = bars?.laneCount ?? 1\n const singleTrack =\n resolveScheduleMode(row.resource, viewConfig.scheduleMode) === \"single\"\n // shared with the tree pane so the two can never drift apart\n const laneOffsetRem = bars?.laneOffsetRem ?? (minRowRem - laneHeightRem) / 2\n\n // Hover affordance over empty track space: the ghost tile snaps to the\n // unit under the cursor, so the (real) tooltip re-anchors per unit\n const [hintStop, setHintStop] = useState<{\n index: number\n center: number\n ms: number\n endMs: number\n } | null>(null)\n // Gated on an active hint: rows without one read a stable false, so a\n // gesture starting/ending anywhere doesn't re-render every row.\n const hintSuppressed = useGanttSelector(\n (state) =>\n hintStop !== null && (state.drag !== null || state.slotDraft !== null)\n )\n const canSchedule = useGanttSelector(\n (state) => state.interactions.selectSlot\n )\n // The affordance is offered ANYWHERE on a schedulable row - over bare track\n // and over existing bars alike - because a row can always take another\n // schedule on a free lane. The primitive deliberately owns NO opinion about\n // what may land where: that is `canSelectSlot`, the consumer's call. (It\n // used to withhold the hint on a one-track row that already held a\n // schedule, which also blocked adding a second NON-overlapping one.)\n // The lowest track FREE at the hovered time. Two jobs, kept separate on\n // purpose:\n // - VISIBILITY: `hintFreeLane < laneCount` is what proves an empty track\n // actually exists to draw on. It is computed regardless of mode, because\n // a \"single\" row whose only track is booked has nowhere free either, and\n // pinning the placement to 0 there would put the ring straight on the bar.\n // - PLACEMENT: \"single\" packs everything onto track 0, so that is where its\n // ring belongs; otherwise the ring sits on the track the schedule will\n // actually land on. Lane is a function of TIME only, so moving the pointer\n // down onto the ring never moves it away from you.\n const hintFreeLane = hintStop\n ? lowestFreeLane(segments, hintStop.ms, hintStop.endMs)\n : 0\n const hintLane = bars?.scheduleMode === \"single\" ? 0 : hintFreeLane\n const hintTopRem = Math.min(\n laneOffsetRem + hintLane * (laneHeightRem + laneGapRem) + laneHeightRem / 2,\n Math.max(heightRem - laneHeightRem / 2, laneHeightRem / 2)\n )\n // Single source of truth for \"the add affordance is live at this spot\". The\n // ring renders on it AND the row takes its cursor from it, so the pointer can\n // never promise something the ring is not offering.\n const hintVisible =\n hintStop !== null &&\n hintFreeLane < laneCount &&\n !hintSuppressed &&\n !isPanning\n // name the gesture that is actually wired, not a generic one\n const hintLabel = viewConfig.dragCreate\n ? settings.i18n.labels.scheduleHintDrag\n : settings.i18n.labels.scheduleHint\n const showHint =\n viewConfig.displayScheduleHint &&\n schedulable &&\n canSchedule &&\n !isPanning &&\n !!(settings.onSelectSlot || settings.onSlotClick)\n\n const fractionOf = (ms: number) =>\n Math.min(Math.max((ms - rangeStartMs) / (rangeEndMs - rangeStartMs), 0), 1)\n\n const dragTarget = useGanttSelector(\n (state) => {\n const drag = state.drag\n if (!drag || drag.proposedResourceId !== row.resource.id) return null\n return drag.valid ? \"valid\" : \"invalid\"\n }\n )\n const ghost = useGanttSelector<\n unknown,\n {\n from: number\n to: number\n color?: string\n valid: boolean\n title: string\n kind: string\n occurrenceKey: string\n } | null\n >(\n (state) => {\n const drag = state.drag\n if (!drag || drag.proposedResourceId !== row.resource.id) return null\n return {\n from: fractionOf(drag.proposedStart.getTime()),\n to: fractionOf(drag.proposedEnd.getTime()),\n color: drag.occurrence.event.color,\n valid: drag.valid,\n title: drag.occurrence.event.title,\n kind: drag.kind,\n occurrenceKey: drag.occurrence.key,\n }\n },\n {\n isEqual: (a, b) =>\n a === b ||\n (a !== null &&\n b !== null &&\n a.from === b.from &&\n a.to === b.to &&\n a.valid === b.valid),\n }\n )\n const draft = useGanttSelector<\n unknown,\n {\n from: number\n to: number\n startMs: number\n endMs: number\n } | null\n >(\n (state) => {\n const slotDraft = state.slotDraft\n if (!slotDraft || slotDraft.resourceId !== row.resource.id) return null\n const startMs = slotDraft.start.getTime()\n const endMs = slotDraft.end.getTime()\n return {\n from: fractionOf(startMs),\n to: fractionOf(endMs),\n startMs,\n endMs,\n }\n },\n {\n // from/to as well as the instants: they are derived through fractionOf,\n // which closes over the range. An extendRange mid-gesture moves every\n // bar while an instants-only compare serves the cached (stale) fractions,\n // leaving the placeholder pinned to where the range used to be.\n isEqual: (a, b) =>\n a === b ||\n (a !== null &&\n b !== null &&\n a.startMs === b.startMs &&\n a.endMs === b.endMs &&\n a.from === b.from &&\n a.to === b.to),\n }\n )\n\n /**\n * Where the schedule being painted will land - resolved in the shared layout\n * memo, which also RESERVES that track, so the row has already grown to hold\n * it. The clamp is only a backstop for the frame between a draft appearing\n * and the layout catching up.\n */\n const draftLane = bars?.draftLane ?? 0\n const draftTopRem = Math.min(\n laneOffsetRem + draftLane * (laneHeightRem + laneGapRem),\n Math.max(heightRem - laneHeightRem, 0)\n )\n // the range you are painting, named while you paint it - a drag that shows\n // no times asks you to guess where you let go\n const draftLabel = draft\n ? settings.i18n.functions.formatEventTime(\n toZoned(new Date(draft.startMs), settings.timeZone),\n toZoned(new Date(draft.endMs), settings.timeZone),\n false,\n settings.locale\n )\n : \"\"\n\n /**\n * The row's create contract, in one place: onSlotClick if the consumer\n * wired it, else a ready-made slot draft. Both the hint tile and a plain\n * click on bare track go through this, so clicking anywhere placeable\n * behaves the same as clicking the tile.\n */\n const createAt = (\n stop: { ms: number; endMs: number },\n e: React.MouseEvent\n ) => {\n if (settings.onSlotClick) {\n settings.onSlotClick(\n { date: new Date(stop.ms), allDay: false, resourceId: row.resource.id },\n e\n )\n } else {\n settings.onSelectSlot?.({\n start: new Date(stop.ms),\n end: new Date(Math.max(stop.endMs, stop.ms + 1)),\n allDay: false,\n resourceId: row.resource.id,\n })\n }\n }\n\n // The drop indicator belongs on the lane the dragged schedule actually\n // occupies. The gesture has not committed, so the segment is still packed\n // under its pre-drag key - no second packing pass, just a lookup.\n const ghostLane = ghost\n ? (segments.find(\n (segment) => segment.occurrence.key === ghost.occurrenceKey\n )?.column ?? 0)\n : 0\n const ghostLaneOffsetRem =\n laneOffsetRem +\n ghostLane * (laneHeightRem + laneGapRem) +\n (laneHeightRem - GHOST_HEIGHT_REM) / 2\n\n return (\n {\n // Opt-in drag-create owns presses on empty schedulable track (the\n // onSelectSlot contract); otherwise the press bubbles to the\n // container and pans the timeline.\n if (\n viewConfig.dragCreate &&\n e.button === 0 &&\n schedulable &&\n canSchedule &&\n // the bare track, or the hint tile that spawns under the cursor -\n // a sub-threshold press still ends as the tile's own click\n (e.target === e.currentTarget ||\n !!(e.target as HTMLElement).closest?.(\n \"[data-slot=gantt-schedule-hint]\"\n )) &&\n !!settings.onSelectSlot\n ) {\n // validate BEFORE claiming the press: on a vetoed slot (e.g. the\n // one-schedule-per-task rule) the press falls through to the pan\n const stop = resolveHintStop(\n trackFraction(e.currentTarget, e.clientX)\n )\n const allowed =\n stop !== null &&\n (settings.canSelectSlot?.({\n start: new Date(stop.ms),\n end: new Date(Math.max(stop.endMs, stop.ms + 1)),\n allDay: false,\n resourceId: row.resource.id,\n }) ??\n true)\n if (!allowed) return\n e.stopPropagation()\n gestures.beginCreate(e)\n }\n }}\n onPointerMove={(e) => {\n // read interaction state imperatively - a subscription here would\n // re-render the row for every gesture anywhere on the grid\n const interacting =\n instance.getState().drag !== null ||\n instance.getState().slotDraft !== null\n if (!showHint || e.pointerType !== \"mouse\" || interacting) {\n if (hintStop) setHintStop(null)\n return\n }\n // EMPTY TRACK ONLY. A bar under the pointer means the pointer is not\n // on an empty slot, so the affordance goes away entirely rather than\n // hovering over booked time - and the bar is left completely alone,\n // hover and click both. (No \"pointer is over the tile\" bail: the ring\n // is pointer-events-none, so it is never the target; a bail on it would\n // freeze it on the spot, since it rides under the cursor.)\n if (e.target !== e.currentTarget) {\n if (hintStop) setHintStop(null)\n return\n }\n const { fraction, offset } = trackPoint(e.currentTarget, e.clientX)\n // The dot follows the pointer FREELY - it is a cursor, not a cell, so\n // it is never quantised to the interval grid. Its position is written\n // as a CSS custom property straight onto the row: the cursor moves\n // every frame and a state update per frame would re-render the row and\n // every bar in it. React state still owns WHICH slot is offered, and\n // that changes only once per interval.\n //\n // In pixels rather than a percentage, pre-snapped by trackPoint to the\n // viewport pixel grid: a fractional inset makes the browser antialias\n // the ring and the glyph across two device pixels, which reads as a\n // furry, smudged dot. -translate-x-1/2 of an even-sized box keeps it on\n // the grid, so the result is crisp.\n e.currentTarget.style.setProperty(\"--gantt-hint-x\", `${offset}px`)\n const stop = resolveHintStop(fraction)\n // validate placement before offering it: a consumer canSelectSlot\n // veto (e.g. a locked span) hides the hint entirely\n const allowed =\n stop !== null &&\n (settings.canSelectSlot?.({\n start: new Date(stop.ms),\n end: new Date(Math.max(stop.endMs, stop.ms + 1)),\n allDay: false,\n resourceId: row.resource.id,\n }) ??\n true)\n const next = allowed ? stop : null\n // state changes only when the cursor crosses into another unit\n if (next?.index !== hintStop?.index) setHintStop(next)\n }}\n onPointerLeave={() => {\n if (hintStop) setHintStop(null)\n }}\n onClick={(e) => {\n // With dragCreate the press belongs to the create GESTURE, which only\n // activates past its movement threshold - so a click that never moved\n // committed nothing and the row felt dead. Treat it as a create at the\n // hovered slot, using the same validation the hint tile uses.\n if (!viewConfig.dragCreate || e.target !== e.currentTarget) return\n if (!schedulable || !canSchedule) return\n if (wasRecentDrag()) return\n if (!settings.onSlotClick && !settings.onSelectSlot) return\n const stop = resolveHintStop(trackFraction(e.currentTarget, e.clientX))\n if (!stop) return\n const allowed =\n settings.canSelectSlot?.({\n start: new Date(stop.ms),\n end: new Date(Math.max(stop.endMs, stop.ms + 1)),\n allDay: false,\n resourceId: row.resource.id,\n }) ?? true\n if (!allowed) return\n createAt(stop, e)\n setHintStop(null)\n }}\n >\n {/* row CONTENT (bars, labels, ghosts). Pointer-transparent so\n empty-track presses still hit the row itself. */}\n {/* content-visibility lets the browser skip rendering this layer for\n rows scrolled out of view (large trees stay cheap on low-end\n devices). Safe here: the layer is absolute inset-0 (geometry comes\n from the row, never from content), its paint containment keeps it\n a stacking context, and nothing inside escapes the row box - the\n schedule hint deliberately lives OUTSIDE this layer. Browsers\n without support simply ignore it. */}\n \n {segments.map((segment, segmentIndex) => {\n const from = fractionOf(\n rangeStartMs + (segment.startMin ?? 0) * 60000\n )\n const to = fractionOf(rangeStartMs + (segment.endMin ?? 0) * 60000)\n if (to <= from) return null\n const lane = segment.column ?? 0\n // Title placement: outside beside the bar when configured (or too\n // short in \"auto\"), flipped before the bar near the range end, and\n // back inside when the bar spans the whole view.\n const barRemWidth = (to - from) * trackRemWidth\n const wantsOutside =\n viewConfig.barLabel === \"outside\" ||\n (viewConfig.barLabel === \"auto\" &&\n barRemWidth <\n (viewConfig.metrics?.autoLabelMin ?? AUTO_LABEL_MIN_REM))\n const placement = !wantsOutside\n ? \"inside\"\n : to <= 0.92\n ? \"after\"\n : from >= 0.08\n ? \"before\"\n : \"inside\"\n // the wrapper carries the drag kind itself (from the row's ghost\n // state, same notify as the bar's own attribute) so the hide rules\n // below use plain attribute selectors instead of :has(), which\n // older Firefox (<121) does not support\n const segDragKind =\n ghost && ghost.occurrenceKey === segment.occurrence.key\n ? ghost.kind\n : undefined\n return (\n \n \n {placement !== \"inside\" && (\n \n {segment.occurrence.event.title}\n \n )}\n \n )\n })}\n {bars?.summary && segments.length === 0 && (\n \n {viewConfig.renderSummary ? (\n // consumer-owned rollup: the positioned envelope wrapper stays\n viewConfig.renderSummary({\n resource: row.resource,\n start: new Date(\n rangeStartMs + bars.summary.from * (rangeEndMs - rangeStartMs)\n ),\n end: new Date(\n rangeStartMs + bars.summary.to * (rangeEndMs - rangeStartMs)\n ),\n progress: bars.summary.progress,\n })\n ) : (\n <>\n {/* envelope end caps: the classic PM rollup silhouette, muted */}\n \n \n
\n {bars.summary.progress !== null && (\n \n )}\n
\n {bars.summary.progress !== null && (\n \n {bars.summary.progress}%\n \n )}\n \n )}\n \n )}\n {ghost && (\n \n {ghost.kind !== \"move\" && (\n // label rides OUTSIDE after the bar, exactly like the resting\n // outside placement - never inside the schedule\n \n {ghost.title}\n \n )}\n \n )}\n {draft && (\n \n {/* the accent tint, over the opaque base rather than over whatever\n happens to be beneath the row */}\n \n \n )}\n \n {/* OUTSIDE the content layer for the same reason as the hint bubble: the\n layer's paint containment would CLIP a chip that overhangs the row.\n Centred on the range being painted, so the times track the drag. */}\n {draft && draftLabel && (\n \n \n {draftLabel}\n {/* Same arrow as every other bubble, one size down. A 45-degree\n square is symmetric, so size-2.5 spans ~14px whichever edge it\n straddles: a small nub under a ~120px-wide chip, but most of the\n short edge of a ~26px-TALL one. size-1.5 spans ~8.5px and\n protrudes ~4px, the usual tooltip-arrow proportion, so the side\n arrow reads the same weight as the ones above and below. */}\n \n \n \n )}\n {/* OUTSIDE the content layer: its paint containment creates a stacking\n context that would trap the bubble's z-index under neighboring rows.\n As a direct row child its z-30 stacks above every row in the pane. */}\n {/* `hintLane < laneCount` is the second half of \"empty slots only\": the\n pointer being on bare track can still mean the row's own padding above\n a column that is booked on every track. There is no free track to\n draw on there, so the clamp would park the ring on top of a bar -\n exactly what must never happen. Booking such an instant is a drag from\n a free point, not a click. */}\n {hintVisible && hintStop && (\n \n {viewConfig.renderScheduleHint ? (\n // consumer-owned hint: the wrapper stays snapped + validated;\n // the content drives its own create flow\n viewConfig.renderScheduleHint({\n start: new Date(hintStop.ms),\n end: new Date(Math.max(hintStop.endMs, hintStop.ms + 1)),\n resource: row.resource,\n })\n ) : (\n <>\n {/* the band IS the placement affordance: it covers the interval\n under the pointer, so what you see is the slot you get. Clicking\n books it; pressing and dragging paints a longer range. The bubble\n mirrors the tooltip theme, arrow included. */}\n {/* The dot IS the cursor: an open ring, so whatever it is standing\n on stays readable through it. The outer background ring is what\n keeps it visible on a dark bar - an outline, not a fill.\n The GLYPH is the native cursor rather than an icon: `cell` is the\n range-select cursor every spreadsheet uses, which is exactly the\n drag-a-range gesture, and `copy` carries a plus for the\n click-to-add case. Clicking books the interval it is standing in;\n pressing and dragging paints a range whose ends snap to the\n nearest boundary. */}\n {\n // with drag-create on the press belongs to the row's create\n // gesture; a sub-threshold press still ends as this click\n if (!viewConfig.dragCreate) e.stopPropagation()\n }}\n onClick={(e) => {\n e.stopPropagation()\n // a completed drag-create already committed via onSelectSlot\n if (wasRecentDrag()) return\n createAt(hintStop, e)\n setHintStop(null)\n }}\n />\n \n {hintLabel}\n \n \n \n )}\n \n )}\n \n )\n})\n\ninterface OffscreenChip {\n id: string\n side: \"start\" | \"end\"\n top: number\n color?: string\n label: string\n /** First bar start, shown in the chip tooltip. */\n startMs: number | null\n /** scrollLeft that brings the bar back into view. */\n target: number\n /** End-chip inset in px, widened to clear the zoom control when they overlap. */\n insetEnd: number\n}\n\nfunction sameChips(a: OffscreenChip[], b: OffscreenChip[]): boolean {\n return (\n a.length === b.length &&\n a.every(\n (chip, i) =>\n chip.id === b[i].id &&\n chip.side === b[i].side &&\n chip.top === b[i].top &&\n chip.target === b[i].target &&\n chip.insetEnd === b[i].insetEnd\n )\n )\n}\n\n/**\n * Edge chips for rows whose bars sit entirely outside the visible timeline;\n * clicking scrolls the bar back into view. Reads geometry straight from the\n * DOM (row data attributes), so scrolling never re-renders the grid.\n */\nfunction GanttOffscreenChips({\n paneRef,\n occurrences,\n locale,\n refreshKey,\n}: {\n paneRef: RefObject\n occurrences: GanttOccurrence[]\n locale?: Locale\n refreshKey: string\n}) {\n const settings = useGanttSettings()\n const [chips, setChips] = useState([])\n\n useEffect(() => {\n const pane = paneRef.current\n const viewport = getPaneViewport(pane)\n if (!pane || !viewport) return\n let raf = 0\n const measure = () => {\n raf = 0\n const paneRect = pane.getBoundingClientRect()\n const header = viewport.querySelector(\n \"[data-slot=gantt-timeline-header]\"\n )\n const headerBottom = header\n ? header.getBoundingClientRect().bottom - paneRect.top\n : 0\n const trackW = viewport.scrollWidth\n const visibleStart = getScrollStart(viewport)\n const visibleEnd = visibleStart + viewport.clientWidth\n // the floating zoom control shares the right edge (higher z); end chips\n // whose row center falls in its band shift left so they stay clickable\n const zoomEl = pane.querySelector(\"[data-slot=gantt-zoom]\")\n const zoom = zoomEl\n ? {\n top: zoomEl.getBoundingClientRect().top - paneRect.top - 8,\n bottom: zoomEl.getBoundingClientRect().bottom - paneRect.top + 8,\n inset: paneRect.right - zoomEl.getBoundingClientRect().left + 8,\n }\n : null\n const next: OffscreenChip[] = []\n for (const rowEl of viewport.querySelectorAll(\n \"[data-gantt-row]\"\n )) {\n const from = parseFloat(rowEl.dataset.ganttBarMin ?? \"\")\n const to = parseFloat(rowEl.dataset.ganttBarMax ?? \"\")\n if (Number.isNaN(from) || Number.isNaN(to)) continue\n const rect = rowEl.getBoundingClientRect()\n const top = rect.top - paneRect.top + rect.height / 2\n if (top < headerBottom + 10 || top > paneRect.height - 16) continue\n const startPx = from * trackW\n const endPx = to * trackW\n const startMs = parseFloat(rowEl.dataset.ganttBarStartMs ?? \"\")\n const base = {\n id: rowEl.dataset.ganttRowId ?? \"\",\n top: Math.round(top),\n color: rowEl.dataset.ganttBarColor,\n label: rowEl.dataset.ganttBarLabel ?? \"\",\n startMs: Number.isNaN(startMs) ? null : startMs,\n }\n if (endPx <= visibleStart + 2) {\n next.push({\n ...base,\n side: \"start\",\n target: startPx - 24,\n insetEnd: 14,\n })\n } else if (startPx >= visibleEnd - 2) {\n const overlapsZoom = zoom && top >= zoom.top && top <= zoom.bottom\n next.push({\n ...base,\n side: \"end\",\n target: endPx - viewport.clientWidth + 24,\n insetEnd: overlapsZoom ? Math.max(14, zoom.inset) : 14,\n })\n }\n }\n setChips((prev) => (sameChips(prev, next) ? prev : next))\n }\n const schedule = () => {\n if (!raf) raf = requestAnimationFrame(measure)\n }\n viewport.addEventListener(\"scroll\", schedule)\n const observer = new ResizeObserver(schedule)\n observer.observe(viewport)\n schedule()\n return () => {\n viewport.removeEventListener(\"scroll\", schedule)\n observer.disconnect()\n if (raf) cancelAnimationFrame(raf)\n }\n }, [paneRef, occurrences, refreshKey])\n\n if (chips.length === 0) return null\n\n const scrollTo = (chip: OffscreenChip) => {\n const viewport = getPaneViewport(paneRef.current)\n if (!viewport) return\n const target = Math.max(0, chip.target)\n viewport.scrollTo({\n // chip targets are distances from the inline start; RTL signs them\n left: getComputedStyle(viewport).direction === \"rtl\" ? -target : target,\n behavior: \"smooth\",\n })\n // hand keyboard focus to the bar the chip promised (the chip unmounts)\n const bar = viewport.querySelector(\n `[data-gantt-row-id=\"${CSS.escape(chip.id)}\"] [data-slot=gantt-bar]`\n )\n bar?.focus({ preventScroll: true })\n }\n\n return (\n \n \n {chips.map((chip) => (\n \n \n scrollTo(chip)}\n >\n {chip.side === \"start\" ? (\n \n ) : (\n \n )}\n \n \n \n \n
{chip.label}
\n {chip.startMs !== null && (\n
\n {/* zoned: the chip must name the same day the grid shows */}\n {format(\n toZoned(new Date(chip.startMs), settings.timeZone),\n \"MMM d, yyyy\",\n { locale }\n )}\n
\n )}\n
\n
\n ))}\n
\n \n )\n}\n\nexport { GanttView }\nexport type { GanttViewProps }","target":"components/neui/gantt/gantt-view.tsx"},{"path":"gantt.tsx","type":"registry:ui","content":"// Title: Gantt\n// Description: Headless-first gantt - horizontal resource timeline with day-to-year scales, external CRUD contract, and a subscribable store.\n\n\"use client\"\n\nimport {\n createContext,\n useContext,\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ComponentType,\n type HTMLAttributes,\n type ReactNode,\n type RefObject,\n} from \"react\"\nimport {\n mergeGanttI18n,\n type GanttI18nConfig,\n type GanttI18nOverrides,\n} from \"@/components/neui/gantt/gantt-i18n\"\nimport {\n buildEventIndex,\n defaultEventOrder,\n eventsOverlap,\n findResource,\n getGanttDateRange,\n getRangeKey,\n stepGanttDate,\n toZoned,\n type GanttIndex,\n type WeekStartsOn,\n} from \"@/components/neui/gantt/gantt-lib\"\nimport type {\n GanttBarId,\n GanttDateRange,\n GanttDragState,\n GanttEvent,\n GanttInteractions,\n GanttOccurrence,\n GanttOffDaysConfig,\n GanttOverlapPolicy,\n GanttProposedUpdate,\n GanttRangeInfo,\n GanttResource,\n GanttResourceReorder,\n GanttRowAlign,\n GanttScale,\n GanttScheduleMode,\n GanttSegment,\n GanttSelection,\n GanttSlotDraft,\n GanttSlotInfo,\n GanttState,\n GanttUpdateResult,\n} from \"@/components/neui/gantt/gantt-types\"\nimport type { Locale } from \"date-fns\"\nimport { Slot } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst DEFAULT_INTERACTIONS: GanttInteractions = {\n drag: true,\n resize: true,\n selectSlot: true,\n}\n\n/** Infinite-scroll growth cap, in whole periods per side. */\nconst MAX_RANGE_WINDOW = 12\n\n/** A node holds as many concurrent schedules as it needs unless told otherwise. */\nconst DEFAULT_SCHEDULE_MODE: GanttScheduleMode = \"multiple\"\n\n/** Tree label sits on the first schedule's baseline, not the grown row's middle. */\nconst DEFAULT_ROW_ALIGN: GanttRowAlign = \"start\"\n\n/**\n * A node's cardinality: its own override wins over the view-level default.\n * Shared by the layout pass and the gesture engine so both read one rule.\n */\nfunction resolveScheduleMode(\n node: GanttResource | null | undefined,\n scheduleMode: GanttScheduleMode | undefined\n): GanttScheduleMode {\n return node?.scheduleMode ?? scheduleMode ?? DEFAULT_SCHEDULE_MODE\n}\n\nconst EMPTY_SELECTION: GanttSelection = { eventKeys: [], slot: null }\n\ninterface GanttCallbacks {\n onEventClick?: (\n occurrence: GanttOccurrence,\n e: React.MouseEvent\n ) => void\n onEventDoubleClick?: (\n occurrence: GanttOccurrence,\n e: React.MouseEvent\n ) => void\n onEventUpdate?: (update: GanttProposedUpdate) => GanttUpdateResult\n canDropEvent?: (update: GanttProposedUpdate) => boolean\n onSlotClick?: (slot: GanttSlotInfo, e: React.MouseEvent) => void\n onSelectSlot?: (slot: GanttSlotDraft) => void\n canSelectSlot?: (slot: GanttSlotDraft) => boolean\n /** Fires when the \"add task\" hint is activated; create a new tree row. */\n onCreateTask?: (ctx: { parentId: string | null; index: number }) => void\n /**\n * Gates the \"add task\" hint. The shipped view offers root-level creation\n * only (parentId = null); parentId stays in the contract for group-level\n * affordances a consumer builds via its own UI + onCreateTask.\n */\n canCreateTask?: (ctx: { parentId: string | null }) => boolean\n /** Click on a tree row's surface (chevron/checkbox/grip clicks excluded). */\n onResourceClick?: (ctx: GanttColumnContext, e: React.MouseEvent) => void\n onResourceDoubleClick?: (ctx: GanttColumnContext, e: React.MouseEvent) => void\n onRangeChange?: (info: GanttRangeInfo) => void\n onScaleChange?: (scale: GanttScale) => void\n onDateChange?: (date: Date) => void\n onSelectionChange?: (selection: GanttSelection) => void\n onInteractionsChange?: (interactions: GanttInteractions) => void\n onEventsChange?: (events: GanttEvent[]) => void\n /**\n * Commit gate for timeline resource-row drag reorder. Return false to\n * reject; apply the move by adopting proposal.resources into your\n * `resources` state (controlled - the calendar never self-mutates).\n */\n onResourceReorder?: (proposal: GanttResourceReorder) => void | false\n /** Live validity predicate while a resource row is being dragged. */\n canReorderResource?: (proposal: GanttResourceReorder) => boolean\n /**\n * Fires when a reorder gesture is released on a position rejected by\n * `canReorderResource` (e.g. a pinned row). Use it to explain the rejection\n * (a toast) - the destructive drop indicator already shows it live.\n */\n onResourceReorderReject?: (proposal: GanttResourceReorder) => void\n}\n\ninterface UseGanttStateOptions extends GanttCallbacks {\n events?: GanttEvent[]\n defaultEvents?: GanttEvent[]\n scale?: GanttScale\n defaultScale?: GanttScale\n date?: Date\n defaultDate?: Date\n selection?: GanttSelection\n defaultSelection?: GanttSelection\n interactions?: Partial\n defaultInteractions?: Partial\n loading?: boolean\n timeZone?: string\n locale?: Locale\n weekStartsOn?: WeekStartsOn\n slotDuration?: number\n snapDuration?: number\n i18n?: GanttI18nOverrides\n /**\n * Hard travel bounds for infinite scrolling; either side may be omitted\n * for unlimited travel in that direction.\n */\n rangeBounds?: { min?: Date; max?: Date }\n /** Pointer-activation threshold overrides for drag/resize/create. */\n activation?: GanttActivationConfig\n /**\n * Infinite-scroll growth cap in whole periods per side; past it the\n * anchor slides instead (DOM stays bounded). Default 12.\n */\n maxRangeWindow?: number\n /** Tree nodes of the gantt (GanttNode is the preferred type name). */\n resources?: GanttResource[]\n /**\n * What a gesture may do when it would overlap another schedule in the SAME\n * node: \"allow\" (default), \"clamp\" to the neighbour's edge, or \"reject\".\n * Policy only - overlapping data always renders. A node in \"single\"\n * scheduleMode rejects regardless.\n */\n overlap?: GanttOverlapPolicy\n getEventPriority?: (event: GanttEvent) => number\n eventOrder?: (a: GanttOccurrence, b: GanttOccurrence) => number\n getOccurrences?: (\n event: GanttEvent,\n range: GanttDateRange,\n ctx: { timeZone: string }\n ) => Array<{ start: Date; end: Date }> | null\n}\n\n/**\n * Resolved configuration: every UseGanttStateOptions field except the\n * controlled/uncontrolled state pairs, with defaults applied and i18n merged.\n * Read via ref semantics - callback identity changes never re-render the grid.\n */\ninterface GanttSettings extends GanttCallbacks {\n timeZone: string\n locale?: Locale\n weekStartsOn: WeekStartsOn\n slotDuration: number\n snapDuration: number\n i18n: GanttI18nConfig\n rangeBounds?: { min?: Date; max?: Date }\n activation?: GanttActivationConfig\n maxRangeWindow?: number\n resources: GanttResource[]\n overlap: GanttOverlapPolicy\n getEventPriority: (event: GanttEvent) => number\n eventOrder: (a: GanttOccurrence, b: GanttOccurrence) => number\n getOccurrences?: (\n event: GanttEvent,\n range: GanttDateRange,\n ctx: { timeZone: string }\n ) => Array<{ start: Date; end: Date }> | null\n}\n\ninterface GanttApi {\n next(): void\n prev(): void\n today(): void\n goTo(date: Date): void\n setScale(scale: GanttScale): void\n getEvents(): GanttEvent[]\n getEvent(id: GanttBarId): GanttEvent | undefined\n setEvents(events: GanttEvent[]): void\n addEvent(event: GanttEvent): void\n updateEvent(id: GanttBarId, patch: Partial>): void\n removeEvent(id: GanttBarId): void\n getOccurrences(range?: GanttDateRange): GanttOccurrence[]\n findOverlapping(candidate: {\n start: Date\n end: Date\n excludeEventId?: string\n }): GanttOccurrence[]\n select(selection: Partial): void\n selectEvent(key: string, opts?: { additive?: boolean }): void\n clearSelection(): void\n setInteractions(patch: Partial): void\n getVisibleRange(): GanttDateRange\n getActiveRange(): GanttDateRange\n /** TZDate in the gantt's display time zone. */\n toZoned(date: Date): Date\n}\n\n/** Cross-file plumbing for sibling view/interaction modules; not public API. */\ninterface GanttInternals {\n getIndex(): GanttIndex\n setDrag(drag: GanttDragState | null): void\n setSlotDraft(draft: GanttSlotDraft | null): void\n applyProposedUpdate(update: GanttProposedUpdate): boolean\n getSettingsVersion(): number\n /**\n * Grow visibleRange by whole periods for infinite scrolling; resets on\n * date/scale changes. Returns false once the growth cap is reached.\n */\n extendRange(direction: \"before\" | \"after\"): boolean\n /**\n * True when the LAST anchor-date change was an extendRange window slide\n * (not a navigation) - the view keeps its scroll guard across slides.\n */\n didAnchorSlide(): boolean\n /** View reports the visible-center instant (or null) for the nav title. */\n setViewportCenter(date: Date | null): void\n}\n\ninterface GanttInstance {\n getState(): GanttState\n subscribe(listener: () => void): () => void\n api: GanttApi\n settings: GanttSettings\n internals: GanttInternals\n}\n\nfunction resolveSettings(\n options: UseGanttStateOptions\n): GanttSettings {\n const {\n // strip state pairs; the rest flows into settings\n events: _e,\n defaultEvents: _de,\n scale: _v,\n defaultScale: _dv,\n date: _d,\n defaultDate: _dd,\n selection: _s,\n defaultSelection: _ds,\n interactions: _i,\n defaultInteractions: _di,\n loading: _l,\n ...rest\n } = options\n const getEventPriority =\n options.getEventPriority ??\n ((event: GanttEvent) => event.priority ?? 0)\n return {\n ...rest,\n timeZone:\n options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,\n locale: options.locale,\n // locale-first default: a de/fr locale gets Monday weeks without also\n // having to set weekStartsOn; an explicit weekStartsOn always wins\n weekStartsOn:\n options.weekStartsOn ?? options.locale?.options?.weekStartsOn ?? 0,\n slotDuration: options.slotDuration ?? 30,\n snapDuration: options.snapDuration ?? 15,\n i18n: mergeGanttI18n(options.i18n),\n rangeBounds: options.rangeBounds,\n resources: options.resources ?? [],\n overlap: options.overlap ?? \"allow\",\n getEventPriority,\n // priority-aware default: higher getEventPriority packs/orders first\n eventOrder:\n options.eventOrder ??\n ((a, b) =>\n getEventPriority(b.event) - getEventPriority(a.event) ||\n defaultEventOrder(a, b)),\n getOccurrences: options.getOccurrences,\n }\n}\n\nconst warned = new Set()\nfunction warnOnce(key: string, message: string) {\n if (process.env.NODE_ENV !== \"production\" && !warned.has(key)) {\n warned.add(key)\n console.warn(`[gantt] ${message}`)\n }\n}\n\ninterface GanttStore {\n instance: GanttInstance\n setOptions(next: UseGanttStateOptions): boolean\n notify(): void\n emitRangeIfChanged(): void\n}\n\nfunction createGanttStore(\n initial: UseGanttStateOptions\n): GanttStore {\n let options = initial\n let settings = resolveSettings(initial)\n let settingsVersion = 0\n\n const listeners = new Set<() => void>()\n\n const internal = {\n scale: initial.defaultScale ?? \"day\",\n date: initial.defaultDate ?? new Date(),\n events: initial.defaultEvents ?? [],\n selection: initial.defaultSelection ?? EMPTY_SELECTION,\n interactions: { ...DEFAULT_INTERACTIONS, ...initial.defaultInteractions },\n drag: null as GanttDragState | null,\n slotDraft: null as GanttSlotDraft | null,\n /** Whole extra periods rendered on each side (infinite scroll). */\n rangeWindow: { before: 0, after: 0 },\n /** Visible-center instant reported by the view; drives the nav title. */\n viewportCenter: null as Date | null,\n }\n\n let snapshot: GanttState | null = null\n let indexCache: {\n events: GanttEvent[]\n rangeKey: string\n timeZone: string\n index: GanttIndex\n } | null = null\n let lastEmittedRangeKey: string | null = null\n /** Whether the last anchor change came from an extendRange window slide. */\n let lastAnchorChangeWasSlide = false\n\n const invalidate = () => {\n snapshot = null\n }\n\n const notify = () => {\n listeners.forEach((listener) => listener())\n emitRangeIfChanged()\n }\n\n const getState = (): GanttState => {\n if (snapshot) return snapshot\n const scale = options.scale ?? internal.scale\n const date = options.date ?? internal.date\n const rangeOpts = {\n timeZone: settings.timeZone,\n weekStartsOn: settings.weekStartsOn,\n }\n const { visibleRange: baseRange, activeRange } = getGanttDateRange(\n scale,\n date,\n rangeOpts\n )\n // Infinite scroll: widen by whole periods; the anchor period stays put\n const { before, after } = internal.rangeWindow\n let visibleRange = baseRange\n if (before > 0 || after > 0) {\n let earlier = date\n for (let i = 0; i < before; i++) {\n earlier = stepGanttDate(scale, earlier, -1, rangeOpts)\n }\n let later = date\n for (let i = 0; i < after; i++) {\n later = stepGanttDate(scale, later, 1, rangeOpts)\n }\n visibleRange = {\n start: getGanttDateRange(scale, earlier, rangeOpts).visibleRange.start,\n end: getGanttDateRange(scale, later, rangeOpts).visibleRange.end,\n }\n }\n snapshot = {\n scale,\n date,\n visibleRange,\n activeRange,\n events: options.events ?? internal.events,\n selection: options.selection ?? internal.selection,\n interactions: options.interactions\n ? { ...DEFAULT_INTERACTIONS, ...options.interactions }\n : internal.interactions,\n loading: options.loading ?? false,\n drag: internal.drag,\n slotDraft: internal.slotDraft,\n viewportCenter: internal.viewportCenter,\n }\n return snapshot\n }\n\n const emitRangeIfChanged = () => {\n if (!settings.onRangeChange) return\n const state = getState()\n const key = `${state.scale}:${getRangeKey(state.visibleRange)}:${settings.timeZone}`\n if (key === lastEmittedRangeKey) return\n lastEmittedRangeKey = key\n settings.onRangeChange({\n range: state.visibleRange,\n activeRange: state.activeRange,\n scale: state.scale,\n date: state.date,\n timeZone: settings.timeZone,\n })\n }\n\n type ControlledKey =\n | \"scale\"\n | \"date\"\n | \"events\"\n | \"selection\"\n | \"interactions\"\n\n const setField = (\n key: K,\n value: GanttState[K extends \"events\" ? \"events\" : K]\n ) => {\n const controlled = options[key] !== undefined\n if (key === \"date\" || key === \"scale\") {\n // value-equal sets are no-ops: they must not touch store state (the\n // controlled path would mutate without notify) nor drop infinite-\n // scroll growth for a navigation that never happened\n const current = getState()[key]\n const same =\n key === \"date\"\n ? (current as Date).getTime() === (value as Date).getTime()\n : current === value\n if (same) return\n // navigating re-anchors the axis; drop any infinite-scroll growth and\n // let the title follow the anchor again until the user scrolls\n internal.rangeWindow = { before: 0, after: 0 }\n internal.viewportCenter = null\n lastAnchorChangeWasSlide = false\n invalidate()\n }\n if (!controlled) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n ;(internal as any)[key] = value\n invalidate()\n }\n const callbacks: Record void) | undefined> = {\n scale: settings.onScaleChange as never,\n date: settings.onDateChange as never,\n events: settings.onEventsChange as never,\n selection: settings.onSelectionChange as never,\n interactions: settings.onInteractionsChange as never,\n }\n callbacks[key]?.(value as never)\n if (!controlled) notify()\n }\n\n const applyProposedUpdate = (\n update: GanttProposedUpdate,\n // extra non-timing fields committed in the SAME events emission: a second\n // setField pass would read stale controlled options.events and emit an\n // array without the timing change\n extra?: Partial>\n ): boolean => {\n const result = settings.onEventUpdate?.(update)\n if (result === false) return false\n const adjusted: Partial> =\n result && typeof result === \"object\"\n ? {\n start: result.start ?? update.start,\n end: result.end ?? update.end,\n allDay: result.allDay ?? update.allDay,\n }\n : { start: update.start, end: update.end, allDay: update.allDay }\n if (update.resourceId !== undefined) adjusted.resourceId = update.resourceId\n const merged = extra ? { ...extra, ...adjusted } : adjusted\n const events = getState().events\n const next = events.map((event) =>\n event.id === update.event.id ? { ...event, ...merged } : event\n )\n setField(\"events\", next)\n return true\n }\n\n const getIndex = (): GanttIndex => {\n const state = getState()\n const rangeKey = getRangeKey(state.visibleRange)\n if (\n indexCache &&\n indexCache.events === state.events &&\n indexCache.rangeKey === rangeKey &&\n indexCache.timeZone === settings.timeZone\n ) {\n return indexCache.index\n }\n const index = buildEventIndex(state.events, state.visibleRange, {\n timeZone: settings.timeZone,\n eventOrder: settings.eventOrder,\n getOccurrences: settings.getOccurrences,\n })\n indexCache = {\n events: state.events,\n rangeKey,\n timeZone: settings.timeZone,\n index,\n }\n return index\n }\n\n /** Anchor clamp: navigation may never leave the configured bounds. */\n const clampToBounds = (date: Date): Date => {\n const bounds = settings.rangeBounds\n if (!bounds) return date\n if (bounds.min && date.getTime() < bounds.min.getTime()) return bounds.min\n if (bounds.max && date.getTime() > bounds.max.getTime()) return bounds.max\n return date\n }\n\n const api: GanttApi = {\n next() {\n const state = getState()\n setField(\n \"date\",\n clampToBounds(\n stepGanttDate(state.scale, state.date, 1, {\n timeZone: settings.timeZone,\n })\n )\n )\n },\n prev() {\n const state = getState()\n setField(\n \"date\",\n clampToBounds(\n stepGanttDate(state.scale, state.date, -1, {\n timeZone: settings.timeZone,\n })\n )\n )\n },\n today() {\n setField(\"date\", clampToBounds(new Date()))\n },\n goTo(date) {\n setField(\"date\", clampToBounds(date))\n },\n setScale(scale) {\n setField(\"scale\", scale)\n },\n getEvents() {\n return getState().events\n },\n getEvent(id) {\n return getState().events.find((event) => event.id === id)\n },\n setEvents(events) {\n setField(\"events\", events)\n },\n addEvent(event) {\n setField(\"events\", [...getState().events, event])\n },\n updateEvent(id, patch) {\n const event = api.getEvent(id)\n if (!event) return\n const merged = { ...event, ...patch }\n const timingChanged =\n patch.start !== undefined ||\n patch.end !== undefined ||\n patch.allDay !== undefined\n if (timingChanged && settings.onEventUpdate) {\n // timing + rest commit as ONE events emission (a rejected update\n // drops the whole patch, same as before)\n const rest = { ...patch }\n delete rest.start\n delete rest.end\n delete rest.allDay\n applyProposedUpdate(\n {\n event: merged,\n occurrence: null,\n start: merged.start,\n end: merged.end,\n allDay: merged.allDay ?? false,\n source: \"api\",\n },\n Object.keys(rest).length > 0 ? rest : undefined\n )\n return\n }\n setField(\n \"events\",\n getState().events.map((e) => (e.id === id ? merged : e))\n )\n },\n removeEvent(id) {\n setField(\n \"events\",\n getState().events.filter((event) => event.id !== id)\n )\n },\n getOccurrences(range) {\n if (!range) return getIndex().occurrences\n const state = getState()\n const within =\n range.start >= state.visibleRange.start &&\n range.end <= state.visibleRange.end\n if (within) {\n return getIndex().occurrences.filter((occ) => eventsOverlap(occ, range))\n }\n return buildEventIndex(state.events, range, {\n timeZone: settings.timeZone,\n eventOrder: settings.eventOrder,\n getOccurrences: settings.getOccurrences,\n }).occurrences\n },\n findOverlapping({ start, end, excludeEventId }) {\n return api\n .getOccurrences({ start, end })\n .filter((occ) => occ.eventId !== excludeEventId)\n },\n select(partial) {\n const current = getState().selection\n setField(\"selection\", {\n eventKeys: partial.eventKeys ?? current.eventKeys,\n slot: partial.slot !== undefined ? partial.slot : current.slot,\n })\n },\n selectEvent(key, opts) {\n const current = getState().selection\n const eventKeys = opts?.additive\n ? current.eventKeys.includes(key)\n ? current.eventKeys.filter((k) => k !== key)\n : [...current.eventKeys, key]\n : [key]\n setField(\"selection\", { ...current, eventKeys })\n },\n clearSelection() {\n setField(\"selection\", EMPTY_SELECTION)\n },\n setInteractions(patch) {\n setField(\"interactions\", { ...getState().interactions, ...patch })\n },\n getVisibleRange() {\n return getState().visibleRange\n },\n getActiveRange() {\n return getState().activeRange\n },\n toZoned(date) {\n return toZoned(date, settings.timeZone)\n },\n }\n\n const internals: GanttInternals = {\n getIndex,\n setDrag(drag) {\n internal.drag = drag\n invalidate()\n notify()\n },\n setSlotDraft(draft) {\n internal.slotDraft = draft\n invalidate()\n notify()\n },\n setViewportCenter(date) {\n const prev = internal.viewportCenter\n if (prev?.getTime() === date?.getTime()) return\n internal.viewportCenter = date\n invalidate()\n notify()\n },\n applyProposedUpdate,\n getSettingsVersion() {\n return settingsVersion\n },\n extendRange(direction) {\n const state = getState()\n const bounds = settings.rangeBounds\n if (\n direction === \"before\" &&\n bounds?.min &&\n state.visibleRange.start.getTime() <= bounds.min.getTime()\n ) {\n return false\n }\n if (\n direction === \"after\" &&\n bounds?.max &&\n state.visibleRange.end.getTime() >= bounds.max.getTime()\n ) {\n return false\n }\n const cap = Math.max(1, settings.maxRangeWindow ?? MAX_RANGE_WINDOW)\n const { before, after } = internal.rangeWindow\n const grow = direction === \"before\" ? before < cap : after < cap\n if (grow) {\n internal.rangeWindow =\n direction === \"before\"\n ? { before: before + 1, after }\n : { before, after: after + 1 }\n } else {\n // window is at capacity: SLIDE the anchor one period instead, so\n // travel stays unbounded while the DOM stays bounded\n const next = stepGanttDate(\n state.scale,\n state.date,\n direction === \"before\" ? -1 : 1,\n {\n timeZone: settings.timeZone,\n }\n )\n if (options.date !== undefined) {\n // controlled anchor: propose the slide; nothing changes until the\n // parent adopts it\n settings.onDateChange?.(next)\n return false\n }\n internal.date = next\n lastAnchorChangeWasSlide = true\n settings.onDateChange?.(next)\n }\n invalidate()\n notify()\n return true\n },\n didAnchorSlide() {\n return lastAnchorChangeWasSlide\n },\n }\n\n const instance: GanttInstance = {\n getState,\n subscribe(listener) {\n listeners.add(listener)\n return () => listeners.delete(listener)\n },\n api,\n get settings() {\n return settings\n },\n internals,\n }\n\n const STATE_KEYS = [\n \"events\",\n \"scale\",\n \"date\",\n \"selection\",\n \"interactions\",\n \"loading\",\n ] as const\n const SETTINGS_KEYS = [\n \"timeZone\",\n \"locale\",\n \"weekStartsOn\",\n \"slotDuration\",\n \"snapDuration\",\n \"i18n\",\n \"rangeBounds\",\n \"activation\",\n \"maxRangeWindow\",\n \"resources\",\n \"overlap\",\n \"getEventPriority\",\n \"eventOrder\",\n \"getOccurrences\",\n ] as const\n\n return {\n instance,\n setOptions(next) {\n const prev = options\n options = next\n // compare by value: a freshly constructed but equal controlled date\n // must not wipe infinite-scroll growth on every parent re-render\n if (\n prev.date?.getTime() !== next.date?.getTime() ||\n prev.scale !== next.scale\n ) {\n internal.rangeWindow = { before: 0, after: 0 }\n lastAnchorChangeWasSlide = false\n }\n let changed = false\n for (const key of STATE_KEYS) {\n if (prev[key] !== next[key]) {\n changed = true\n break\n }\n }\n let settingsChanged = false\n for (const key of SETTINGS_KEYS) {\n if (prev[key] !== next[key]) {\n settingsChanged = true\n break\n }\n }\n settings = resolveSettings(next)\n if (settingsChanged) {\n settingsVersion++\n changed = true\n }\n if (changed) invalidate()\n return changed\n },\n notify,\n emitRangeIfChanged,\n }\n}\n\n/**\n * Headless root hook - the full calendar engine without any markup.\n * Pass the returned instance to or drive\n * fully custom UI from instance.getState()/subscribe/api.\n */\nfunction useGanttState(\n options: UseGanttStateOptions = {}\n): GanttInstance {\n const [store] = useState(() => createGanttStore(options))\n const changed = store.setOptions(options)\n const changedRef = useRef(false)\n if (changed) changedRef.current = true\n useLayoutEffect(() => {\n if (changedRef.current) {\n changedRef.current = false\n store.notify()\n }\n })\n useEffect(() => {\n store.emitRangeIfChanged()\n // mount-only: onRangeChange fires once for the initial range\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n return store.instance\n}\n\nconst GanttContext =\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n createContext | null>(null)\n\n/** The stable calendar instance; throws outside . */\nfunction useGantt(): GanttInstance {\n const instance = useContext(GanttContext)\n if (!instance) {\n throw new Error(\"useGantt must be used within \")\n }\n return instance as GanttInstance\n}\n\ninterface UseGanttSelectorOptions {\n calendar?: GanttInstance\n isEqual?: (a: TSelected, b: TSelected) => boolean\n}\n\n/** Fine-grained subscription with equality memoization (Object.is default). */\nfunction useGanttSelector(\n selector: (state: GanttState) => TSelected,\n options?: UseGanttSelectorOptions\n): TSelected {\n const contextInstance = useContext(GanttContext)\n const instance = options?.calendar ?? contextInstance\n if (!instance) {\n throw new Error(\n \"useGanttSelector needs an ancestor or an explicit `calendar` option\"\n )\n }\n const isEqual = options?.isEqual ?? Object.is\n const lastRef = useRef<{ value: TSelected } | null>(null)\n const selectorRef = useRef(selector)\n selectorRef.current = selector\n\n const getSnapshot = () => {\n const next = selectorRef.current(instance.getState() as GanttState)\n if (lastRef.current && isEqual(lastRef.current.value, next)) {\n return lastRef.current.value\n }\n lastRef.current = { value: next }\n return next\n }\n\n return useSyncExternalStore(instance.subscribe, getSnapshot, getSnapshot)\n}\n\nfunction useGanttScale(): {\n scale: GanttScale\n setScale: (scale: GanttScale) => void\n} {\n const instance = useGantt()\n const scale = useGanttSelector((state) => state.scale)\n return { scale, setScale: instance.api.setScale }\n}\n\nfunction useGanttNavigation(): {\n date: Date\n /** i18n.functions.formatTitle output for the current view. */\n title: string\n visibleRange: GanttDateRange\n activeRange: GanttDateRange\n next: () => void\n prev: () => void\n today: () => void\n goTo: (date: Date) => void\n /** True when the anchor period contains now in the display time zone. */\n isToday: boolean\n} {\n const instance = useGantt()\n const { settings } = instance\n const slice = useGanttSelector(\n (state) => ({\n date: state.date,\n scale: state.scale,\n visibleRange: state.visibleRange,\n activeRange: state.activeRange,\n viewportCenter: state.viewportCenter,\n }),\n {\n isEqual: (a, b) =>\n a.date.getTime() === b.date.getTime() &&\n a.scale === b.scale &&\n a.viewportCenter?.getTime() === b.viewportCenter?.getTime() &&\n getRangeKey(a.visibleRange) === getRangeKey(b.visibleRange),\n }\n )\n useGanttSettingsVersion(instance)\n const now = new Date()\n // The title names what you are LOOKING at: the visible-center period when\n // the view reports one, otherwise the anchor period.\n const titleDate = slice.viewportCenter ?? slice.date\n const titleActive = slice.viewportCenter\n ? getGanttDateRange(slice.scale, slice.viewportCenter, {\n timeZone: settings.timeZone,\n weekStartsOn: settings.weekStartsOn,\n }).activeRange\n : slice.activeRange\n return {\n date: slice.date,\n title: settings.i18n.functions.formatTitle(slice.scale, {\n date: toZoned(titleDate, settings.timeZone),\n activeRange: titleActive,\n visibleRange: slice.visibleRange,\n locale: settings.locale,\n }),\n visibleRange: slice.visibleRange,\n activeRange: slice.activeRange,\n next: instance.api.next,\n prev: instance.api.prev,\n today: instance.api.today,\n goTo: instance.api.goTo,\n isToday: now >= slice.activeRange.start && now < slice.activeRange.end,\n }\n}\n\nfunction useGanttSelection(): {\n selection: GanttSelection\n select: (selection: Partial) => void\n selectEvent: (key: string, opts?: { additive?: boolean }) => void\n clearSelection: () => void\n} {\n const instance = useGantt()\n const selection = useGanttSelector((state) => state.selection)\n return {\n selection,\n select: instance.api.select,\n selectEvent: instance.api.selectEvent,\n clearSelection: instance.api.clearSelection,\n }\n}\n\nfunction useGanttInteractions(): {\n interactions: GanttInteractions\n setInteractions: (patch: Partial) => void\n} {\n const instance = useGantt()\n const interactions = useGanttSelector((state) => state.interactions)\n return { interactions, setInteractions: instance.api.setInteractions }\n}\n\n/** Expanded, sorted occurrences; defaults to the visible range. */\nfunction useGanttOccurrences(\n range?: GanttDateRange\n): GanttOccurrence[] {\n const instance = useGantt()\n return useGanttSelector[]>(\n () => instance.api.getOccurrences(range),\n {\n calendar: instance,\n // keys encode id + start only, so end edits (resize-end) and payload\n // changes (title, color, progress) must be compared explicitly\n isEqual: (a, b) =>\n a.length === b.length &&\n a.every(\n (occ, i) =>\n occ.key === b[i]?.key &&\n occ.end.getTime() === b[i].end.getTime() &&\n occ.event === b[i].event\n ),\n }\n )\n}\n\ninterface GanttNodeSchedules {\n /** The node itself, or null when the id is not in the tree. */\n node: GanttResource | null\n /** Cardinality in force for this node (its own override, else the default). */\n scheduleMode: GanttScheduleMode\n /** The node's occurrences in the visible range, in axis order. */\n schedules: GanttOccurrence[]\n /** Pairs of the node's schedules that overlap in time. */\n conflicts: Array<[GanttOccurrence, GanttOccurrence]>\n}\n\n/**\n * Everything a consumer needs to MANAGE one node's schedules without\n * re-deriving layout: the node, its resolved cardinality, its schedules in\n * order, and the pairs that collide. Pure state - it renders nothing, so a\n * \"manage schedules\" panel is entirely the consumer's design.\n */\nfunction useGanttNodeSchedules(\n nodeId: string\n): GanttNodeSchedules {\n const settings = useGanttSettings()\n const viewConfig = useGanttViewConfig()\n const occurrences = useGanttOccurrences()\n\n const node = findResource(settings.resources, nodeId)\n const schedules = occurrences.filter(\n (occurrence) => occurrence.event.resourceId === nodeId\n )\n const conflicts: Array<[GanttOccurrence, GanttOccurrence]> = []\n for (let i = 0; i < schedules.length; i++) {\n for (let j = i + 1; j < schedules.length; j++) {\n if (eventsOverlap(schedules[i], schedules[j])) {\n conflicts.push([schedules[i], schedules[j]])\n }\n }\n }\n return {\n node,\n scheduleMode: resolveScheduleMode(node, viewConfig.scheduleMode),\n schedules,\n conflicts,\n }\n}\n\n/** Subscribes to settings changes only (version counter, not state). */\nfunction useGanttSettingsVersion(\n instance: GanttInstance\n): number {\n return useSyncExternalStore(\n instance.subscribe,\n instance.internals.getSettingsVersion,\n instance.internals.getSettingsVersion\n )\n}\n\n/** Resolved settings incl. merged i18n; re-renders only when settings change. */\nfunction useGanttSettings(): GanttSettings {\n const instance = useGantt()\n useGanttSettingsVersion(instance)\n return instance.settings\n}\n\ninterface GanttClassNames {\n nav?: string\n toolbar?: string\n /** The gantt body (tree + track). */\n view?: string\n event?: string\n}\n\n/** Row context handed to tree-panel column and label renderers. */\ninterface GanttColumnContext {\n resource: GanttResource\n depth: number\n isGroup: boolean\n collapsed: boolean\n}\n\n/** One extra tree-panel column after the built-in name column. */\ninterface GanttColumn {\n /** Stable id; doubles as the default header label. */\n id: string\n /** Header label. */\n title?: ReactNode\n /** Fixed column width in px. Default 96. */\n width?: number\n /** Cell content alignment. Default \"start\". */\n align?: \"start\" | \"center\" | \"end\"\n /** Cell content per row; omit or return null for an empty cell. */\n render?: (ctx: GanttColumnContext) => ReactNode\n /** Extra classes on every cell of this column (header included). */\n className?: string\n}\n\n/** Pointer-activation thresholds; unset keys keep the dnd-kit parity defaults. */\ninterface GanttActivationConfig {\n /** Mouse travel (px) before a bar move starts. Default 5. */\n moveDistancePx?: number\n /** Mouse travel (px) before a drag-create starts. Default 4. */\n createDistancePx?: number\n /** Touch long-press delay in ms. Default 250. */\n touchDelayMs?: number\n /** Touch movement tolerance (px) during the long-press. Default 5. */\n touchTolerancePx?: number\n}\n\n/** Layout metrics (rem unless noted); every knob falls back to its default. */\n/** A gridline: false to hide it, true for the default solid stroke, or a style. */\ntype GanttGridLine = boolean | \"solid\" | \"dashed\"\n\ninterface GanttTimelineLines {\n /** Unit boundary lines running down the timeline. Default solid. */\n vertical?: GanttGridLine\n /** Row separator lines running across the timeline. Default solid. */\n horizontal?: GanttGridLine\n}\n\n/** Resolved stroke per axis; null means the axis draws nothing. */\ninterface GanttResolvedLines {\n vertical: \"solid\" | \"dashed\" | null\n horizontal: \"solid\" | \"dashed\" | null\n}\n\n/**\n * One place decides what the grid draws, so the header lines, the body lines\n * and the row separators can never disagree.\n */\nfunction resolveTimelineLines(\n value: GanttTimelineLines | \"vertical\" | \"both\" | \"none\" | undefined\n): GanttResolvedLines {\n if (value === \"none\") return { vertical: null, horizontal: null }\n if (value === \"vertical\") return { vertical: \"solid\", horizontal: null }\n if (value === \"both\" || value === undefined) {\n return { vertical: \"solid\", horizontal: \"solid\" }\n }\n const stroke = (line: GanttGridLine | undefined) =>\n line === false ? null : line === true || line === undefined ? \"solid\" : line\n return {\n vertical: stroke(value.vertical),\n horizontal: stroke(value.horizontal),\n }\n}\n\ninterface GanttMetrics {\n /** Height of one schedule bar. Default 1.25. */\n laneHeight?: number\n /** Gap between stacked schedules in one node. Default 0.1875. */\n laneGap?: number\n /**\n * Vertical inset between the row's edges and its block of schedules - the\n * breathing room around the stack, kept separate from laneGap so schedules\n * in one node can sit tight without cramping the row. Default 0.5.\n */\n rowPadding?: number\n /** Minimum row height. Default 2.5. */\n minRowHeight?: number\n /** barLabel \"auto\" flips the title outside below this bar width. Default 7. */\n autoLabelMin?: number\n /** Unit width at zoom 1, per scale. Day scale = width per interval unit. */\n unitWidths?: Partial>\n /** Minimum timeline pane width in px. Default 200. */\n minTimelineWidth?: number\n /** Scroll distance (px) from an edge that grows the range. Default 160. */\n infiniteScrollEdge?: number\n}\n\n/** Live gesture snapshot handed to the drag/resize indicator render props. */\ninterface GanttDragIndicatorProps {\n occurrence: GanttOccurrence\n kind: \"move\" | \"resize-start\" | \"resize-end\"\n /** Proposed (snapped) range of the current gesture step. */\n start: Date\n end: Date\n valid: boolean\n}\n\n/** Slot handed to a custom schedule-hint renderer. */\ninterface GanttScheduleHintProps {\n start: Date\n end: Date\n resource: GanttResource\n}\n\n/** Parent rollup handed to a custom summary renderer. */\ninterface GanttSummaryProps {\n resource: GanttResource\n start: Date\n end: Date\n progress: number | null\n}\n\n/** Left tree-panel sizing and splitter behavior. */\ninterface GanttTreePanelConfig {\n /** Initial panel width in px. Default 288. */\n width?: number\n /** Splitter lower bound in px. Default 180. */\n minWidth?: number\n /** Splitter upper bound in px. Default 640. */\n maxWidth?: number\n /** Drag/keyboard splitter between the panels. Default true. */\n resizable?: boolean\n /** Width of the sticky name column in px. Default 208. */\n nameColumnWidth?: number\n /** Fires after any user resize (drag release, keyboard, double-click reset). */\n onWidthChange?: (width: number) => void\n}\n\ninterface GanttRenderEventProps {\n occurrence: GanttOccurrence\n segment: GanttSegment\n isDragging: boolean\n isSelected: boolean\n}\n\n/**\n * View-layer configuration: display props and render overrides. These live on\n * (and per-view components), never in the headless options.\n */\ninterface GanttViewConfig {\n /** Red now-line on the axis. */\n nowIndicator: boolean\n /**\n * Day-scale unit interval in minutes: axis units and gridlines follow it.\n */\n interval: number\n /**\n * Scroll implementation for the gantt body: \"custom\" (default, shadcn\n * ScrollArea) or \"native\" (browser scrollbars via overflow auto).\n */\n scrollbars: \"custom\" | \"native\"\n /**\n * Placement hint over empty timeline track: a validated, snapped tile that\n * opens the schedule flow (onSlotClick, else onSelectSlot) at that day.\n * Works on every scale. Default off.\n */\n displayScheduleHint: boolean\n /**\n * Where the viewport opens. `\"now\"` (default) centres the current instant\n * when the anchor period contains it and falls back to the anchor; `\"anchor\"`\n * always centres the anchor; a Date centres that instant.\n *\n * Only `\"now\"` follows the wall clock - which is right for a live board and\n * wrong for a demo or a report, whose opening composition must not depend on\n * the hour it is viewed at. Those pass an explicit instant.\n */\n initialCenter: \"now\" | \"anchor\" | Date\n /**\n * Empty-track presses on schedulable rows start a drag-create gesture that\n * commits through onSelectSlot. Default off: the whole panel drags-to-pan\n * instead, and scheduling flows through the hint tile / onSlotClick.\n */\n dragCreate: boolean\n /**\n * \"Add task\" affordance at the foot of the tree that opens the create-task\n * flow (onCreateTask). Shown only when canCreateTask allows it. Default off.\n */\n displayCreateTaskHint: boolean\n /** Floating zoom in/out control over the track. Default on. */\n zoomControl: boolean\n /**\n * Ctrl/Cmd + wheel over the timeline zooms the time range, anchored on the\n * pointer. Trackpad pinch arrives as the same event (browsers set ctrlKey\n * on it), so this is also the pinch-to-zoom switch. Default on. The gesture\n * is handed back to the browser at the zoom limits, so page zoom still\n * works there.\n */\n wheelZoom: boolean\n /** Nav button variant; all nav buttons follow it. Default \"ghost\". */\n navButtonVariant: \"ghost\" | \"outline\" | \"secondary\" | \"default\"\n /** Nav button size; icon buttons use the icon twin. Default \"sm\". */\n navButtonSize: \"sm\" | \"default\"\n /**\n * Off-day (non-working day) marking on day/week/month scales. true =\n * weekends with a muted background; a config object customizes weekdays,\n * explicit dates, a predicate, and the marker class.\n */\n offDays?: boolean | GanttOffDaysConfig\n /**\n * Extra tree-panel columns after the built-in name column. The tree panel\n * scrolls horizontally when the columns outgrow it; the name column stays\n * pinned.\n */\n columns?: GanttColumn[]\n /**\n * Consumer slot pinned at the end of the tree-panel header - the intended\n * home for an add/remove-columns dropdown menu.\n */\n columnsMenu?: ReactNode\n /** Tree-panel width, splitter bounds, and resizability. */\n treePanel?: GanttTreePanelConfig\n /**\n * Timeline gridlines. The object form controls the two axes independently\n * and gives each its own stroke: `{ vertical: \"dashed\", horizontal: true }`.\n * An omitted axis stays on and solid. `true` means solid.\n *\n * The three legacy shorthands still work: \"none\" (bare), \"vertical\" (unit\n * boundaries only, rows separated by whitespace) and \"both\" (adds row\n * separators).\n */\n timelineLines: GanttTimelineLines | \"vertical\" | \"both\" | \"none\"\n /**\n * Bar title placement: \"inside\" (default) renders it in the bar, \"outside\"\n * beside the bar, \"auto\" moves it outside only when the bar is too short.\n */\n barLabel: \"inside\" | \"outside\" | \"auto\"\n /** Edge chips that scroll to bars outside the visible timeline. Default true. */\n offscreenIndicators: boolean\n /**\n * Extend the timeline into the past/future while scrolling near an edge\n * (the anchor period stays the nav title). Default true.\n */\n infiniteScroll: boolean\n /** Zoom bounds and button step for the floating control. Default 0.5 - 3, step 0.25. */\n zoomRange?: { min?: number; max?: number; step?: number }\n /** Layout metric overrides (row/lane/unit geometry, thresholds). */\n metrics?: GanttMetrics\n /** Sticky nav bar (same contract as the event calendar). Default false. */\n stickyNav: boolean\n /**\n * Leaf-row selection checkboxes in the tree panel. Default true;\n * uncontrolled unless selectedRows is passed.\n */\n rowCheckboxes: boolean\n /** Controlled selected row ids; pairs with onSelectedRowsChange. */\n selectedRows?: string[]\n onSelectedRowsChange?: (ids: string[]) => void\n /** Controlled collapsed group ids; pairs with onCollapsedGroupsChange. */\n collapsedGroups?: string[]\n /** Initial collapsed group ids (uncontrolled). */\n defaultCollapsedGroups?: string[]\n onCollapsedGroupsChange?: (ids: string[]) => void\n /** Controlled zoom multiplier; pairs with onZoomChange. */\n zoom?: number\n /** Initial zoom multiplier (uncontrolled). Default 1. */\n defaultZoom?: number\n onZoomChange?: (zoom: number) => void\n /**\n * Allow drag-create and slot clicks on rows that have children. Default\n * false: parents aggregate their subtree instead of owning bars.\n */\n parentScheduling: boolean\n /**\n * Rollup strips on parent rows without bars of their own: the envelope of\n * descendant bars with duration-weighted progress. Default true.\n */\n summaryBars: boolean\n /**\n * How many schedules a tree node may hold. \"multiple\" (default) stacks\n * concurrent schedules into stable lanes and grows the row; \"single\" keeps\n * one track per node - the task-gantt shape. Any node can override it with\n * its own `scheduleMode`.\n */\n scheduleMode: GanttScheduleMode\n /**\n * Vertical placement of a row's content once a node holds several lanes.\n * \"start\" (default) keeps the tree label on the baseline of the FIRST\n * schedule; \"center\" centers both against the grown row.\n */\n rowAlign: GanttRowAlign\n classNames?: GanttClassNames\n renderEvent?: (props: GanttRenderEventProps) => ReactNode\n /**\n * Right-click menu for a bar: return shadcn ContextMenu items (the primitive\n * wraps every bar in a ContextMenu and renders this as its content). Read\n * the occurrence for the subject and drive actions through the gantt api\n * (useGantt) or your own state - fully headless. Omit for no menu.\n */\n renderEventMenu?: (props: GanttRenderEventProps) => ReactNode\n /**\n * Tree-node label. Receives the resource with its tree position; return\n * any rich content (icons, badges). Default is the plain title.\n */\n renderResourceLabel?: (props: {\n resource: GanttResource\n depth: number\n isGroup: boolean\n collapsed: boolean\n }) => ReactNode\n /**\n * Right-click menu for a tree row (same contract as renderEventMenu):\n * return shadcn ContextMenu items and drive actions through your own state.\n */\n renderResourceMenu?: (ctx: GanttColumnContext) => ReactNode\n /** Rendered in the timeline body when there are no resources. */\n renderNoResources?: () => ReactNode\n /**\n * Replaces the smooth cursor-following MOVE clone. Content is React and\n * re-renders per snap step; the gantt owns the fixed wrapper and writes\n * its position imperatively per pointermove (no per-frame React).\n */\n renderDragPreview?: (props: GanttDragIndicatorProps) => ReactNode\n /**\n * Replaces the RESIZE edge line + status chip. Same positioning contract\n * as renderDragPreview: your content, gantt-owned cursor tracking.\n */\n renderResizeIndicator?: (props: GanttDragIndicatorProps) => ReactNode\n /**\n * Replaces the schedule-hint tile + bubble. Rendered inside the snapped,\n * validated, pointer-transparent wrapper: set pointer-events-auto on your\n * clickable parts and drive your own create flow from the slot.\n */\n renderScheduleHint?: (props: GanttScheduleHintProps) => ReactNode\n /** Replaces the parent rollup strip (the positioned wrapper stays gantt-owned). */\n renderSummary?: (props: GanttSummaryProps) => ReactNode\n /**\n * Replaces the rollup MATH: return 0-100 (or null to hide) for a group\n * from its descendant events. Default: duration-weighted mean progress.\n */\n getSummaryProgress?: (ctx: {\n resource: GanttResource\n events: GanttEvent[]\n }) => number | null\n}\n\nconst DEFAULT_VIEW_CONFIG: GanttViewConfig = {\n nowIndicator: true,\n interval: 60,\n scrollbars: \"custom\",\n displayScheduleHint: false,\n initialCenter: \"now\",\n displayCreateTaskHint: false,\n dragCreate: false,\n zoomControl: true,\n wheelZoom: true,\n navButtonVariant: \"ghost\",\n navButtonSize: \"sm\",\n timelineLines: \"vertical\",\n barLabel: \"inside\",\n offscreenIndicators: true,\n infiniteScroll: true,\n stickyNav: false,\n rowCheckboxes: true,\n parentScheduling: false,\n summaryBars: true,\n scheduleMode: \"multiple\",\n rowAlign: \"start\",\n}\n\nconst GanttViewConfigContext = createContext<\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n GanttViewConfig\n>(DEFAULT_VIEW_CONFIG)\n\n/** Root-level display props + render overrides, for view components. */\nfunction useGanttViewConfig(): GanttViewConfig {\n return useContext(GanttViewConfigContext)\n}\n\nconst VIEW_CONFIG_KEYS: Array = [\n \"nowIndicator\",\n \"interval\",\n \"scrollbars\",\n \"displayScheduleHint\",\n \"initialCenter\",\n \"displayCreateTaskHint\",\n \"dragCreate\",\n \"zoomControl\",\n \"wheelZoom\",\n \"navButtonVariant\",\n \"navButtonSize\",\n \"offDays\",\n \"columns\",\n \"columnsMenu\",\n \"treePanel\",\n \"metrics\",\n \"timelineLines\",\n \"barLabel\",\n \"offscreenIndicators\",\n \"infiniteScroll\",\n \"zoomRange\",\n \"stickyNav\",\n \"rowCheckboxes\",\n \"selectedRows\",\n \"onSelectedRowsChange\",\n \"collapsedGroups\",\n \"defaultCollapsedGroups\",\n \"onCollapsedGroupsChange\",\n \"zoom\",\n \"defaultZoom\",\n \"onZoomChange\",\n \"parentScheduling\",\n \"summaryBars\",\n \"scheduleMode\",\n \"rowAlign\",\n \"classNames\",\n \"renderEvent\",\n \"renderEventMenu\",\n \"renderResourceLabel\",\n \"renderResourceMenu\",\n \"renderNoResources\",\n \"renderDragPreview\",\n \"renderResizeIndicator\",\n \"renderScheduleHint\",\n \"renderSummary\",\n \"getSummaryProgress\",\n]\n\ninterface GanttProps\n extends\n UseGanttStateOptions,\n Partial>,\n Omit, \"children\" | \"defaultValue\"> {\n /** Adopt a hoisted useGanttState instance; option props are then ignored. */\n calendar?: GanttInstance\n /** Imperative escape hatch usable from outside the tree. */\n apiRef?: RefObject | null>\n asChild?: boolean\n children?: ReactNode\n}\n\nconst OPTION_KEYS: Array = [\n \"events\",\n \"defaultEvents\",\n \"scale\",\n \"defaultScale\",\n \"date\",\n \"defaultDate\",\n \"selection\",\n \"defaultSelection\",\n \"interactions\",\n \"defaultInteractions\",\n \"loading\",\n \"timeZone\",\n \"locale\",\n \"weekStartsOn\",\n \"slotDuration\",\n \"snapDuration\",\n \"i18n\",\n \"rangeBounds\",\n \"activation\",\n \"maxRangeWindow\",\n \"resources\",\n \"overlap\",\n \"getEventPriority\",\n \"eventOrder\",\n \"getOccurrences\",\n \"onEventClick\",\n \"onEventDoubleClick\",\n \"onEventUpdate\",\n \"canDropEvent\",\n \"onSlotClick\",\n \"onSelectSlot\",\n \"canSelectSlot\",\n \"onCreateTask\",\n \"canCreateTask\",\n \"onResourceClick\",\n \"onResourceDoubleClick\",\n \"onRangeChange\",\n \"onScaleChange\",\n \"onDateChange\",\n \"onSelectionChange\",\n \"onInteractionsChange\",\n \"onEventsChange\",\n \"onResourceReorder\",\n \"onResourceReorderReject\",\n \"canReorderResource\",\n]\n\nfunction shallowEqualRecord(\n a: Record,\n b: Record\n): boolean {\n const aKeys = Object.keys(a)\n if (aKeys.length !== Object.keys(b).length) return false\n for (const key of aKeys) {\n if (!Object.is(a[key], b[key])) return false\n }\n return true\n}\n\nfunction splitOptions(props: Record): {\n options: UseGanttStateOptions\n viewConfig: GanttViewConfig\n rest: Record\n} {\n const options: Record = {}\n const viewConfig: Record = { ...DEFAULT_VIEW_CONFIG }\n const rest: Record = {}\n for (const [key, value] of Object.entries(props)) {\n if ((OPTION_KEYS as string[]).includes(key)) options[key] = value\n else if ((VIEW_CONFIG_KEYS as string[]).includes(key)) {\n if (value !== undefined) viewConfig[key] = value\n } else rest[key] = value\n }\n return {\n options: options as UseGanttStateOptions,\n viewConfig: viewConfig as unknown as GanttViewConfig,\n rest,\n }\n}\n\n/**\n * Root provider + container. Composition contract:\n * \n */\nfunction Gantt({\n calendar,\n apiRef,\n className,\n asChild = false,\n children,\n ...props\n}: GanttProps) {\n const { options, viewConfig, rest } = splitOptions(\n props as Record\n )\n\n // Stable context identity: splitOptions builds a fresh object per render,\n // and every row subscribes to this context - hand out the previous object\n // unless a config value actually changed.\n const viewConfigRef = useRef(viewConfig)\n if (\n !shallowEqualRecord(\n viewConfigRef.current as unknown as Record,\n viewConfig as unknown as Record\n )\n ) {\n viewConfigRef.current = viewConfig\n }\n const stableViewConfig = viewConfigRef.current\n\n if (calendar && Object.keys(options).length > 0) {\n warnOnce(\n \"calendar-and-options\",\n \"both `calendar` and option props were passed; option props are ignored when adopting an instance.\"\n )\n }\n\n const own = useGanttState(calendar ? {} : options)\n const instance = calendar ?? own\n\n useEffect(() => {\n if (apiRef) apiRef.current = instance.api\n }, [apiRef, instance])\n\n const Comp = asChild ? Slot.Root : \"div\"\n\n return (\n \n \n \n {/* Slottable keeps the announcer sibling legal when asChild slots\n the consumer element (Slot.Root allows one non-Slottable child). */}\n {children}\n \n \n \n \n )\n}\n\nexport {\n DEFAULT_ROW_ALIGN,\n DEFAULT_SCHEDULE_MODE,\n DEFAULT_VIEW_CONFIG,\n Gantt,\n GanttContext,\n GanttViewConfigContext,\n resolveScheduleMode,\n resolveTimelineLines,\n useGantt,\n useGanttInteractions,\n useGanttNavigation,\n useGanttNodeSchedules,\n useGanttOccurrences,\n useGanttScale,\n useGanttSelection,\n useGanttSelector,\n useGanttSettings,\n useGanttSettingsVersion,\n useGanttState,\n useGanttViewConfig,\n}\nexport type {\n GanttActivationConfig,\n GanttApi,\n GanttCallbacks,\n GanttClassNames,\n GanttColumn,\n GanttColumnContext,\n GanttDragIndicatorProps,\n GanttGridLine,\n GanttInstance,\n GanttInternals,\n GanttMetrics,\n GanttNodeSchedules,\n GanttProps,\n GanttRenderEventProps,\n GanttResolvedLines,\n GanttScheduleHintProps,\n GanttSettings,\n GanttSummaryProps,\n GanttTimelineLines,\n GanttTreePanelConfig,\n GanttViewConfig,\n UseGanttStateOptions,\n}","target":"components/neui/gantt/gantt.tsx"}]}