{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"event-calendar-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/event-calendar-recurrence","@neui/event-calendar-types"],"files":[{"path":"event-calendar-lib.tsx","type":"registry:ui","content":"// Title: Event Calendar 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/event-calendar/event-calendar-recurrence\"\nimport type {\n CalendarEvent,\n CalendarView,\n EventCalendarDateRange,\n EventCalendarOccurrence,\n EventCalendarOffDaysConfig,\n EventCalendarResource,\n EventCalendarSegment,\n} from \"@/components/neui/event-calendar/event-calendar-types\"\nimport { TZDate } from \"@date-fns/tz\"\nimport {\n addDays,\n addMonths,\n addWeeks,\n differenceInCalendarDays,\n differenceInMinutes,\n format,\n startOfDay,\n startOfMonth,\n startOfWeek,\n} from \"date-fns\"\n\ntype WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6\n\n/** Packing-effective minimum in minutes so tiny events do not stack invisibly. */\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 dayCount: number\n agendaDayCount: number\n fixedWeeks: boolean\n}\n\ninterface ViewDateRanges {\n visibleRange: EventCalendarDateRange\n activeRange: EventCalendarDateRange\n}\n\nfunction getViewDateRange(\n view: CalendarView,\n date: Date,\n opts: ViewRangeOptions\n): ViewDateRanges {\n const { timeZone, weekStartsOn, dayCount, agendaDayCount, fixedWeeks } = opts\n const zoned = toZoned(date, timeZone)\n\n if (view === \"month\") {\n const activeStart = startOfMonth(zoned)\n const activeEnd = startOfMonth(addMonths(zoned, 1))\n const visibleStart = startOfWeek(activeStart, { weekStartsOn })\n let visibleEnd: Date\n if (fixedWeeks) {\n visibleEnd = addDays(visibleStart, 42)\n } else {\n visibleEnd = startOfWeek(addDays(activeEnd, -1), { weekStartsOn })\n visibleEnd = addWeeks(visibleEnd, 1)\n }\n return {\n activeRange: { start: activeStart, end: activeEnd },\n visibleRange: { start: visibleStart, end: visibleEnd },\n }\n }\n\n if (view === \"week\") {\n const start = startOfWeek(zoned, { weekStartsOn })\n const range = { start, end: addWeeks(start, 1) }\n return { activeRange: range, visibleRange: range }\n }\n\n if (view === \"day\" || view === \"resource\") {\n const start = startOfDay(zoned)\n const range = { start, end: addDays(start, 1) }\n return { activeRange: range, visibleRange: range }\n }\n\n if (view === \"days\") {\n const start = startOfDay(zoned)\n const range = { start, end: addDays(start, Math.max(1, dayCount)) }\n return { activeRange: range, visibleRange: range }\n }\n\n // agenda\n const start = startOfDay(zoned)\n const range = { start, end: addDays(start, Math.max(1, agendaDayCount)) }\n return { activeRange: range, visibleRange: range }\n}\n\n/** Day of month of the last day of the month containing the zoned date. */\nfunction lastDayOfZonedMonth(date: Date): number {\n return addDays(startOfMonth(addMonths(date, 1)), -1).getDate()\n}\n\n/** The anchor date stepped one period forward or backward for the view. */\nfunction stepDate(\n view: CalendarView,\n date: Date,\n direction: 1 | -1,\n opts: Pick\n): Date {\n const zoned = toZoned(date, opts.timeZone)\n if (view === \"month\") {\n const stepped = addMonths(zoned, direction)\n // addMonths clamps the day down into a shorter month and never restores\n // it, so next-then-prev from the 31st would leave the anchor on the 28th.\n // Sticking a month end to the target month's end keeps stepping\n // invertible, which matters because the anchor is what day and week view\n // open on after a month navigation.\n if (zoned.getDate() !== lastDayOfZonedMonth(zoned)) return stepped\n return addDays(stepped, lastDayOfZonedMonth(stepped) - stepped.getDate())\n }\n if (view === \"week\") return addWeeks(zoned, direction)\n if (view === \"day\" || view === \"resource\") return addDays(zoned, direction)\n if (view === \"days\")\n return addDays(zoned, direction * Math.max(1, opts.dayCount))\n return addDays(zoned, direction * Math.max(1, opts.agendaDayCount))\n}\n\nfunction rangesIntersect(\n a: EventCalendarDateRange,\n b: EventCalendarDateRange\n): 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\n/**\n * The one canonical multi-day segmentation. Splits an occurrence into per-day\n * segments clamped to the range. Rules (unit-tested in M1): exclusive end - an\n * event ending exactly at zoned midnight emits NO segment for that day;\n * zero-duration events emit one min-height segment; allDay occurrences walk\n * the same absolute instants as timed ones and only drop startMin/endMin, so\n * their bounds have to already BE display-zone midnights (see\n * CalendarEvent.allDay) or the bar paints on the wrong days.\n */\nfunction segmentOccurrence(\n occurrence: EventCalendarOccurrence,\n range: EventCalendarDateRange,\n timeZone: string\n): EventCalendarSegment[] {\n const occStart = occurrence.start\n const occEnd = occurrence.end\n const isZeroLength = occEnd.getTime() === occStart.getTime()\n\n const clampStart = occStart > range.start ? occStart : range.start\n const clampEnd = occEnd < range.end ? occEnd : range.end\n if (clampEnd < clampStart) return []\n if (clampEnd.getTime() === clampStart.getTime() && !isZeroLength) return []\n\n const segments: EventCalendarSegment[] = []\n let cursor = zonedStartOfDay(clampStart, timeZone)\n\n while (cursor < clampEnd || (isZeroLength && segments.length === 0)) {\n const next = zonedStartOfDay(\n addDays(toZoned(cursor, timeZone), 1),\n timeZone\n )\n const segStart = clampStart > cursor ? clampStart : cursor\n const segEnd = clampEnd < next ? clampEnd : next\n\n const emptySeg = segEnd.getTime() <= segStart.getTime()\n if (!emptySeg || isZeroLength) {\n const isStart = segStart.getTime() === occStart.getTime()\n const isEnd = segEnd.getTime() === occEnd.getTime()\n segments.push({\n occurrence,\n day: cursor,\n isStart,\n isEnd,\n continuesBefore: !isStart,\n continuesAfter: !isEnd,\n startMin: occurrence.allDay\n ? undefined\n : differenceInMinutes(segStart, cursor),\n endMin: occurrence.allDay\n ? undefined\n : Math.max(\n differenceInMinutes(segEnd, cursor),\n differenceInMinutes(segStart, cursor)\n ),\n })\n }\n if (isZeroLength) break\n cursor = next\n }\n\n return segments\n}\n\n/** True when the occurrence should render as a bar (all-day row / month lanes). */\nfunction isBarOccurrence(\n occurrence: EventCalendarOccurrence,\n timeZone?: string\n): boolean {\n return occurrence.allDay || spansMultipleDays(occurrence, timeZone)\n}\n\nfunction spansMultipleDays(\n occ: { start: Date; end: Date },\n timeZone?: string\n): boolean {\n // An event ending exactly at the next midnight is still single-day\n // (exclusive end), so compare against a strictly-later instant. The\n // yardstick is the length of the day the event starts on, never a flat 24h:\n // a fall-back day is 25h long, and a 00:00-to-00:00 shift on it is still one\n // calendar day that belongs in the hour track, not in the all-day row.\n // Without a display zone the dates answer in their own frame (TZDate) or in\n // the host zone.\n const dayStart = startOfDay(\n timeZone ? toZoned(occ.start, timeZone) : occ.start\n )\n const nextDayStart = startOfDay(addDays(dayStart, 1))\n return (\n occ.end.getTime() - occ.start.getTime() >\n nextDayStart.getTime() - dayStart.getTime()\n )\n}\n\ninterface PackedPosition {\n column: number\n columnCount: number\n columnSpan: number\n}\n\n/**\n * Google-style overlap packing for one day'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: EventCalendarSegment[]\n): void {\n if (segments.length === 0) return\n\n type Working = {\n seg: EventCalendarSegment\n startMin: number\n effEnd: 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 }\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 // Greedy column assignment\n const colEnds: number[] = []\n const byColumn = new Map()\n for (const item of cluster) {\n let col = colEnds.findIndex((end) => end <= item.startMin)\n if (col === -1) {\n col = colEnds.length\n colEnds.push(0)\n }\n colEnds[col] = item.effEnd\n item.seg.column = col\n const bucket = byColumn.get(col) ?? []\n bucket.push(item)\n byColumn.set(col, bucket)\n }\n const columnCount = colEnds.length\n\n // Partial-overlap expansion: widen rightward into free columns\n for (const item of cluster) {\n let span = 1\n const col = item.seg.column ?? 0\n while (col + span < columnCount) {\n const occupants = byColumn.get(col + span) ?? []\n const blocked = occupants.some(\n (o) => o.startMin < item.effEnd && o.effEnd > item.startMin\n )\n if (blocked) break\n span++\n }\n item.seg.columnCount = columnCount\n item.seg.columnSpan = span\n }\n }\n}\n\n/**\n * Greedy lane packing for bar segments within one week row (7 columns).\n * Mutates lane/rowIndex/colStart/colSpan on the segments, in place.\n */\n/**\n * Build the laned month-row bars for one week: consecutive-day segments of\n * the same occurrence merge into ONE bar (colStart -> colSpan) stacked into\n * lanes. Returns NEW segment objects - the shared per-day segments (also\n * rendered by the all-day rows and day cells) must stay pristine: mutating\n * their isEnd/continues flags gave the first-day chip a whole-bar shape and\n * a bogus end resize handle in the week all-day row, where dragging it\n * collapsed the event to a single day.\n */\nfunction packWeekRowLanes(\n segments: EventCalendarSegment[],\n rowIndex: number,\n rowStart: Date,\n timeZone: string\n): EventCalendarSegment[] {\n type Bar = {\n seg: EventCalendarSegment\n colStart: number\n colSpan: number\n isStart: boolean\n isEnd: boolean\n lane: number\n }\n\n const bars: Bar[] = segments.map((seg) => {\n const dayIndex = Math.round(\n (zonedStartOfDay(seg.day, timeZone).getTime() -\n zonedStartOfDay(rowStart, timeZone).getTime()) /\n (24 * 60 * 60 * 1000)\n )\n return {\n seg,\n colStart: Math.max(0, Math.min(6, dayIndex)),\n colSpan: 1,\n isStart: seg.isStart,\n isEnd: seg.isEnd,\n lane: 0,\n }\n })\n\n // Merge consecutive-day segments of the same occurrence into one bar per row\n const merged = new Map()\n for (const bar of bars) {\n const key = bar.seg.occurrence.key\n const existing = merged.get(key)\n if (existing) {\n const start = Math.min(existing.colStart, bar.colStart)\n const end = Math.max(\n existing.colStart + existing.colSpan,\n bar.colStart + bar.colSpan\n )\n existing.colStart = start\n existing.colSpan = end - start\n existing.isStart = existing.isStart || bar.isStart\n existing.isEnd = existing.isEnd || bar.isEnd\n } else {\n merged.set(key, bar)\n }\n }\n\n const rowBars = Array.from(merged.values()).sort(\n (a, b) =>\n a.colStart - b.colStart ||\n b.colSpan - a.colSpan ||\n a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)\n )\n\n const lanes: boolean[][] = []\n for (const bar of rowBars) {\n let lane = 0\n for (;;) {\n lanes[lane] ??= new Array(7).fill(false)\n const row = lanes[lane]\n let free = true\n for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {\n if (row[c]) {\n free = false\n break\n }\n }\n if (free) break\n lane++\n }\n for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {\n lanes[lane][c] = true\n }\n bar.lane = lane\n }\n\n return rowBars.map((bar) => ({\n ...bar.seg,\n isStart: bar.isStart,\n isEnd: bar.isEnd,\n continuesBefore: !bar.isStart,\n continuesAfter: !bar.isEnd,\n lane: bar.lane,\n rowIndex,\n colStart: bar.colStart,\n colSpan: bar.colSpan,\n }))\n}\n\ninterface EventCalendarDayBucket {\n allDay: EventCalendarSegment[]\n timed: EventCalendarSegment[]\n}\n\ninterface EventCalendarWeekRow {\n rowIndex: number\n rowStart: Date\n /** Laned bar segments (one per occurrence per row). */\n bars: EventCalendarSegment[]\n}\n\ninterface EventCalendarIndex {\n occurrences: EventCalendarOccurrence[]\n byDay: Map>\n weekRows: EventCalendarWeekRow[]\n}\n\ninterface BuildIndexOptions {\n timeZone: string\n weekStartsOn: WeekStartsOn\n eventOrder?: (\n a: EventCalendarOccurrence,\n b: EventCalendarOccurrence\n ) => number\n getOccurrences?: (\n event: CalendarEvent,\n range: EventCalendarDateRange,\n ctx: { timeZone: string }\n ) => Array<{ start: Date; end: Date }> | null\n}\n\nfunction defaultEventOrder(\n a: EventCalendarOccurrence,\n b: EventCalendarOccurrence\n): 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\nfunction buildEventIndex(\n events: CalendarEvent[],\n visibleRange: EventCalendarDateRange,\n opts: BuildIndexOptions\n): EventCalendarIndex {\n const { timeZone, weekStartsOn } = 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: EventCalendarOccurrence[] = []\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\n const byDay = new Map>()\n const barSegmentsByRow = new Map[]>()\n const firstRowStart = startOfWeek(toZoned(visibleRange.start, timeZone), {\n weekStartsOn,\n })\n\n for (const occurrence of occurrences) {\n const segments = segmentOccurrence(occurrence, visibleRange, timeZone)\n const bar = isBarOccurrence(occurrence, timeZone)\n for (const seg of segments) {\n const key = getDayKey(seg.day, timeZone)\n let bucket = byDay.get(key)\n if (!bucket) {\n bucket = { allDay: [], timed: [] }\n byDay.set(key, bucket)\n }\n if (bar) {\n bucket.allDay.push(seg)\n // calendar-day math, not a fixed 168h divisor: DST transition weeks\n // are 167/169h long and the fixed divisor mis-buckets every later\n // Sunday one row early (which then clamps into the wrong column)\n const rowIndex = Math.floor(\n differenceInCalendarDays(toZoned(seg.day, timeZone), firstRowStart) /\n 7\n )\n const rowBucket = barSegmentsByRow.get(rowIndex) ?? []\n rowBucket.push(seg)\n barSegmentsByRow.set(rowIndex, rowBucket)\n } else {\n bucket.timed.push(seg)\n }\n }\n }\n\n for (const bucket of byDay.values()) {\n packTimedSegments(bucket.timed)\n }\n\n const weekRows: EventCalendarWeekRow[] = []\n for (const [rowIndex, segs] of barSegmentsByRow) {\n const rowStart = addWeeks(firstRowStart, rowIndex)\n weekRows.push({\n rowIndex,\n rowStart,\n bars: packWeekRowLanes(segs, rowIndex, rowStart, timeZone),\n })\n }\n weekRows.sort((a, b) => a.rowIndex - b.rowIndex)\n\n return { occurrences, byDay, weekRows }\n}\n\n/** Cache key for index memoization; cheap string compare. */\nfunction getRangeKey(range: EventCalendarDateRange): 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: EventCalendarResource[],\n depth = 0\n): Array<{ resource: EventCalendarResource; depth: number }> {\n const rows: Array<{ resource: EventCalendarResource; 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\nconst DEFAULT_WEEKEND_DAYS = [0, 6]\n\n/**\n * Resolves whether a day is an off day (non-working) in the display zone.\n * Callers pass the calendar's own weekendDays so the shading cannot contradict\n * the weekend the rest of the calendar renders; an explicit offDays.weekendDays\n * still wins over it.\n */\nfunction resolveOffDay(\n day: Date,\n timeZone: string,\n config: boolean | EventCalendarOffDaysConfig | undefined,\n defaultWeekendDays?: number[]\n): boolean {\n if (!config) return false\n const resolved: EventCalendarOffDaysConfig = config === true ? {} : config\n const weekendDays =\n resolved.weekendDays ?? defaultWeekendDays ?? 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 flattenResources,\n getDayKey,\n getDayTotalMinutes,\n getRangeKey,\n getViewDateRange,\n isBarOccurrence,\n MIN_PACK_SLOT,\n packTimedSegments,\n packWeekRowLanes,\n rangesIntersect,\n resolveOffDay,\n segmentOccurrence,\n snapMinutes,\n spansMultipleDays,\n stepDate,\n toZoned,\n zonedStartOfDay,\n}\nexport type {\n BuildIndexOptions,\n EventCalendarDayBucket,\n EventCalendarIndex,\n EventCalendarWeekRow,\n ViewDateRanges,\n ViewRangeOptions,\n WeekStartsOn,\n}","target":"components/neui/event-calendar/event-calendar-lib.tsx"}]}