{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"gantt-lib","type":"registry:ui","title":"Pure, React-free calendar math: view ranges, zoned day keys, multi-day segmentation, overlap packing, lane packing, and the event index.","description":"Pure, React-free calendar math: view ranges, zoned day keys, multi-day segmentation, overlap packing, lane packing, and the event index.","dependencies":["@date-fns/tz","date-fns"],"registryDependencies":["@neui/gantt-recurrence","@neui/gantt-types"],"files":[{"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"}]}