{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"event-calendar-time-grid","type":"registry:ui","title":"Shared week/day/N-days engine - hour gutter, minute-positioned events, all-day row, drag ghosts, now indicator, and configurable scrolling.","description":"Shared week/day/N-days engine - hour gutter, minute-positioned events, all-day row, drag ghosts, now indicator, and configurable scrolling.","dependencies":["date-fns","radix-ui"],"registryDependencies":["@neui/event-calendar","@neui/event-calendar-dnd","@neui/event-calendar-event","@neui/event-calendar-lib","@neui/event-calendar-types","scroll-area"],"files":[{"path":"event-calendar-time-grid.tsx","type":"registry:ui","content":"// Title: Event Calendar Time Grid\n// Description: Shared week/day/N-days engine - hour gutter, minute-positioned events, all-day row, drag ghosts, now indicator, and configurable scrolling.\n\n\"use client\"\n\nimport {\n useEffect,\n useMemo,\n useRef,\n useState,\n type CSSProperties,\n type HTMLAttributes,\n type ReactNode,\n} from \"react\"\nimport {\n EventCalendarViewContext,\n useEventCalendar,\n useEventCalendarDay,\n useEventCalendarSelector,\n useEventCalendarSettings,\n useEventCalendarViewConfig,\n useEventCalendarViewContext,\n useEventCalendarViewSettings,\n} from \"@/components/neui/event-calendar/event-calendar\"\nimport {\n useEventCalendarGestures,\n wasRecentChipPress,\n wasRecentDrag,\n} from \"@/components/neui/event-calendar/event-calendar-dnd\"\nimport {\n EVENT_CALENDAR_GHOST,\n EventCalendarEvent,\n} from \"@/components/neui/event-calendar/event-calendar-event\"\nimport {\n getDayKey,\n getDayTotalMinutes,\n getRangeKey,\n packTimedSegments,\n resolveOffDay,\n snapMinutes,\n toZoned,\n zonedStartOfDay,\n} from \"@/components/neui/event-calendar/event-calendar-lib\"\nimport type {\n CalendarView,\n EventCalendarDateRange,\n EventCalendarDragState,\n EventCalendarSegment,\n EventCalendarSlotDraft,\n} from \"@/components/neui/event-calendar/event-calendar-types\"\nimport { addDays, addMinutes, differenceInMinutes, format } from \"date-fns\"\nimport { Slot } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\nimport { ScrollArea } from \"@/components/ui/scroll-area\"\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\nconst EMPTY_ALL_DAY_SEGMENTS: EventCalendarSegment[] = []\n\ninterface EventCalendarTimeGridProps extends HTMLAttributes {\n view: Extract\n dayStartHour?: number\n dayEndHour?: number\n showAllDay?: boolean\n /** Gutter/gridline interval in minutes; defaults to the interval view config. */\n interval?: number\n asChild?: boolean\n}\n\nfunction EventCalendarTimeGrid({\n view,\n className,\n asChild = false,\n dayStartHour,\n dayEndHour,\n showAllDay = true,\n interval: intervalProp,\n style,\n ...props\n}: EventCalendarTimeGridProps) {\n const instance = useEventCalendar()\n const settings = useEventCalendarSettings()\n const viewConfig = useEventCalendarViewConfig()\n const visibleRange = useEventCalendarSelector<\n unknown,\n EventCalendarDateRange\n >((state) => state.visibleRange, {\n isEqual: (a, b) => getRangeKey(a) === getRangeKey(b),\n })\n\n const startHour = dayStartHour ?? settings.dayStartHour\n const endHour = dayEndHour ?? settings.dayEndHour\n const interval = Math.min(\n Math.max(intervalProp ?? viewConfig.interval, 5),\n 240\n )\n const contained = viewConfig.scrollMode !== \"page\"\n\n const { effective } = useEventCalendarViewSettings()\n const days = useMemo(() => {\n const result: Date[] = []\n let cursor = zonedStartOfDay(visibleRange.start, settings.timeZone)\n while (cursor < visibleRange.end) {\n result.push(cursor)\n cursor = zonedStartOfDay(\n addDays(toZoned(cursor, settings.timeZone), 1),\n settings.timeZone\n )\n }\n if (effective.weekends || view === \"day\") return result\n // Same weekend definition the month view filters on, so the toggle cannot\n // hide one set of days here and another one there.\n const filtered = result.filter(\n (day) =>\n !settings.weekendDays.includes(toZoned(day, settings.timeZone).getDay())\n )\n // A short N-days window landing entirely on the weekend would otherwise\n // filter to nothing and emit an invalid repeat(0, ...) track.\n return filtered.length ? filtered : result\n }, [\n visibleRange,\n settings.timeZone,\n settings.weekendDays,\n effective.weekends,\n view,\n ])\n\n // Initial scroll to scrollToHour + api.scrollToTime registration (contained)\n const scrollRef = useRef(null)\n useEffect(() => {\n if (!contained) return\n const el = scrollRef.current\n if (!el) return\n const viewport = el.querySelector(\n \"[data-slot=scroll-area-viewport]\"\n )\n // Measure a rendered slot row - the CSS var is in rem, rects are in px.\n const slotRow = el.querySelector(\n \"[data-slot=event-calendar-time-gutter] > div\"\n )\n const slotPx = slotRow?.getBoundingClientRect().height || 64\n const pxPerMinute = slotPx / interval\n const scrollTo = (minutes: number) => {\n // keep the hour label above the target line visible (it hangs -top-2)\n viewport?.scrollTo({\n top: Math.max(0, (minutes - startHour * 60) * pxPerMinute - 12),\n })\n }\n scrollTo(viewConfig.scrollToHour * 60)\n instance.internals.registerScrollHandler((time) => {\n const minutes =\n typeof time === \"number\"\n ? time\n : toZoned(time, settings.timeZone).getHours() * 60 +\n toZoned(time, settings.timeZone).getMinutes()\n scrollTo(minutes)\n })\n // Classic (width-consuming) scrollbars squeeze the scrolling track while\n // the header/all-day rows outside keep full width, drifting the column\n // borders. Mirror the measured gutter onto those rows via a CSS var -\n // 0px for overlay scrollbars and the custom ScrollArea, so both modes\n // lay out identically.\n const root = el.closest(\n \"[data-slot=event-calendar-time-grid], [data-slot=event-calendar-resource-view]\"\n )\n const syncScrollbarGutter = () => {\n root?.style.setProperty(\n \"--ec-scrollbar-w\",\n `${viewport ? viewport.offsetWidth - viewport.clientWidth : 0}px`\n )\n }\n syncScrollbarGutter()\n const gutterObserver = viewport\n ? new ResizeObserver(syncScrollbarGutter)\n : null\n if (viewport) gutterObserver?.observe(viewport)\n return () => {\n instance.internals.registerScrollHandler(null)\n gutterObserver?.disconnect()\n }\n }, [\n contained,\n instance,\n settings.timeZone,\n startHour,\n interval,\n viewConfig.scrollToHour,\n // scrollbars custom<->native swaps the scroller DOM: re-bind the\n // viewport, the scroll wiring, and the measured --ec-scrollbar-w\n viewConfig.scrollbars,\n ])\n\n // Gutter slots in minutes from the zoned day start\n const slots = useMemo(() => {\n const result: number[] = []\n for (let m = startHour * 60; m < endHour * 60; m += interval) {\n result.push(m)\n }\n return result\n }, [startHour, endHour, interval])\n\n // Section-level all-day segments for renderAllDaySection - reads the same\n // per-day index buckets the cells subscribe to; inert (stable empty array,\n // so the subscription never re-renders) while the override is unset.\n const allDaySegments = useEventCalendarSelector<\n unknown,\n EventCalendarSegment[]\n >(\n () => {\n if (!viewConfig.renderAllDaySection) return EMPTY_ALL_DAY_SEGMENTS\n const byDay = instance.internals.getIndex().byDay\n const result = days.flatMap(\n (day) => byDay.get(getDayKey(day, settings.timeZone))?.allDay ?? []\n )\n return result.length ? result : EMPTY_ALL_DAY_SEGMENTS\n },\n {\n isEqual: (a, b) =>\n a === b ||\n (a.length === b.length && a.every((segment, i) => segment === b[i])),\n }\n )\n\n const gridTemplateColumns = `repeat(${days.length}, minmax(var(--ec-day-col-min,0px), 1fr))`\n\n const track = (\n
\n \n
\n {days.map((day) => (\n \n ))}\n
\n {effective.nowIndicator && (\n \n )}\n
\n )\n\n const Comp = asChild ? Slot.Root : \"div\"\n\n return (\n \n \n {/* Day-header row (sticky below the nav in page scroll mode) */}\n \n
\n
\n {days.map((day) => (\n \n ))}\n
\n
\n {/* All-day row */}\n {showAllDay && (\n \n {viewConfig.renderAllDaySection?.({\n days,\n segments: allDaySegments,\n }) ?? (\n <>\n \n \n {settings.i18n.labels.allDay}\n \n \n \n \n )}\n \n )}\n {/* Time track: internal scroll (contained) or document flow (page) */}\n {contained ? (\n
\n {viewConfig.scrollbars === \"native\" ? (\n \n {track}\n
\n ) : (\n {track}\n )}\n \n ) : (\n track\n )}\n \n
\n )\n}\n\nfunction EventCalendarDayHeader({\n day,\n view,\n}: {\n day: Date\n view: CalendarView\n}) {\n const settings = useEventCalendarSettings()\n const viewConfig = useEventCalendarViewConfig()\n const { isToday } = useEventCalendarDay(day)\n return (\n \n {viewConfig.renderDayHeader?.({ day, view, isToday }) ??\n format(\n toZoned(day, settings.timeZone),\n settings.i18n.formats.timeGridDayHeader,\n { locale: settings.locale }\n )}\n \n )\n}\n\n/**\n * Continuous all-day bars for the week/N-days all-day row: consecutive-day\n * segments of one occurrence merge into a single bar spanning its columns\n * (same treatment as the month view), lane-packed. Returns CLONED segments -\n * the shared per-day segments must stay pristine (see packWeekRowLanes).\n */\nfunction useEventCalendarAllDayBars(days: Date[]): {\n bars: EventCalendarSegment[]\n laneCount: number\n} {\n const instance = useEventCalendar()\n const settings = useEventCalendarSettings()\n return useEventCalendarSelector<\n unknown,\n { bars: EventCalendarSegment[]; laneCount: number }\n >(\n () => {\n const byDay = instance.internals.getIndex().byDay\n type Bar = {\n seg: EventCalendarSegment\n colStart: number\n colEnd: number\n isStart: boolean\n isEnd: boolean\n lane: number\n }\n const merged = new Map()\n days.forEach((day, col) => {\n for (const seg of byDay.get(getDayKey(day, settings.timeZone))\n ?.allDay ?? []) {\n const key = seg.occurrence.key\n const bar = merged.get(key)\n if (bar) {\n bar.colEnd = col\n bar.isEnd = seg.isEnd\n } else {\n merged.set(key, {\n seg,\n colStart: col,\n colEnd: col,\n isStart: seg.isStart,\n isEnd: seg.isEnd,\n lane: 0,\n })\n }\n }\n })\n const packed = Array.from(merged.values()).sort(\n (a, b) =>\n a.colStart - b.colStart ||\n b.colEnd - b.colStart - (a.colEnd - a.colStart) ||\n a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)\n )\n const lanes: boolean[][] = []\n for (const bar of packed) {\n let lane = 0\n for (;;) {\n lanes[lane] ??= new Array(days.length).fill(false)\n let free = true\n for (let c = bar.colStart; c <= bar.colEnd; c++) {\n if (lanes[lane][c]) {\n free = false\n break\n }\n }\n if (free) break\n lane++\n }\n for (let c = bar.colStart; c <= bar.colEnd; c++) lanes[lane][c] = true\n bar.lane = lane\n }\n return {\n bars: packed.map((bar) => ({\n ...bar.seg,\n isStart: bar.isStart,\n isEnd: bar.isEnd,\n continuesBefore: !bar.isStart,\n continuesAfter: !bar.isEnd,\n colStart: bar.colStart,\n colSpan: bar.colEnd - bar.colStart + 1,\n lane: bar.lane,\n })),\n laneCount: lanes.length,\n }\n },\n {\n calendar: instance,\n isEqual: (a, b) =>\n a.laneCount === b.laneCount &&\n a.bars.length === b.bars.length &&\n a.bars.every((s, i) => {\n const o = b.bars[i]\n // Compare the OCCURRENCE by identity, not by key: the key encodes\n // id+start only, so a title/color/data edit compared equal and the\n // all-day row kept its stale bars. Occurrences come from the\n // memoized index (rebuilt exactly when events change), so identity\n // also subsumes the end-time check it replaces. The positional\n // fields stay because they are recomputed on every read.\n return (\n s.occurrence === o.occurrence &&\n s.colStart === o.colStart &&\n s.colSpan === o.colSpan &&\n s.lane === o.lane &&\n s.isStart === o.isStart &&\n s.isEnd === o.isEnd\n )\n }),\n }\n )\n}\n\n/**\n * The all-day row body: drop-target cells underneath, one continuous bar per\n * occurrence in an overlay grid on top (month-view treatment), plus the\n * standardized day-granular drag ghost for moves/resizes in this lane.\n */\nfunction EventCalendarAllDayBars({\n days,\n gridTemplateColumns,\n}: {\n days: Date[]\n gridTemplateColumns: string\n}) {\n const settings = useEventCalendarSettings()\n const viewConfig = useEventCalendarViewConfig()\n const { bars, laneCount } = useEventCalendarAllDayBars(days)\n\n const dragGhost = useEventCalendarSelector<\n unknown,\n | (Pick<\n EventCalendarDragState,\n \"kind\" | \"valid\" | \"occurrence\" | \"proposedStart\" | \"proposedEnd\"\n > & {\n colStart: number\n colSpan: number\n isStart: boolean\n isEnd: boolean\n color?: string\n })\n | null\n >(\n (state) => {\n const drag = state.drag\n if (!drag || !drag.proposedDayGranular) return null\n const tz = settings.timeZone\n const startMs = zonedStartOfDay(drag.proposedStart, tz).getTime()\n const lastMs = zonedStartOfDay(\n new Date(drag.proposedEnd.getTime() - 1),\n tz\n ).getTime()\n let colStart = -1\n let colEnd = -1\n days.forEach((day, i) => {\n const t = zonedStartOfDay(day, tz).getTime()\n if (t >= startMs && t <= lastMs) {\n if (colStart === -1) colStart = i\n colEnd = i\n }\n })\n if (colStart === -1) return null\n return {\n kind: drag.kind,\n valid: drag.valid,\n occurrence: drag.occurrence,\n proposedStart: drag.proposedStart,\n proposedEnd: drag.proposedEnd,\n colStart,\n colSpan: colEnd - colStart + 1,\n isStart: zonedStartOfDay(days[colStart], tz).getTime() <= startMs,\n isEnd: zonedStartOfDay(days[colEnd], tz).getTime() >= lastMs,\n color: drag.occurrence.event.color,\n }\n },\n {\n isEqual: (a, b) =>\n a === b ||\n (a !== null &&\n b !== null &&\n a.colStart === b.colStart &&\n a.colSpan === b.colSpan &&\n a.valid === b.valid &&\n a.kind === b.kind &&\n a.proposedStart.getTime() === b.proposedStart.getTime() &&\n a.proposedEnd.getTime() === b.proposedEnd.getTime()),\n }\n )\n const ghostLane = dragGhost\n ? (bars.find((s) => s.occurrence.key === dragGhost.occurrence.key)?.lane ??\n laneCount)\n : 0\n const effectiveLanes = Math.max(laneCount, dragGhost ? ghostLane + 1 : 0)\n\n return (\n
\n \n {days.map((day) => (\n \n ))}\n
\n {(bars.length > 0 || dragGhost) && (\n \n {bars.map((segment) => (\n \n \n \n ))}\n {dragGhost && (\n \n \n {dragGhost.kind !== \"move\" && (\n \n )}\n \n \n )}\n \n )}\n \n )\n}\n\nfunction EventCalendarAllDayCell({ day }: { day: Date }) {\n const settings = useEventCalendarSettings()\n const viewConfig = useEventCalendarViewConfig()\n const { view } = useEventCalendarViewContext()\n const { effective } = useEventCalendarViewSettings()\n const gestures = useEventCalendarGestures()\n const dayStart = zonedStartOfDay(day, settings.timeZone)\n const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1)\n const isOff = resolveOffDay(\n day,\n settings.timeZone,\n effective.offDays\n ? typeof viewConfig.offDays === \"object\"\n ? viewConfig.offDays\n : true\n : false,\n settings.weekendDays\n )\n const offClassName =\n (typeof viewConfig.offDays === \"object\" && viewConfig.offDays.className) ||\n \"bg-muted/25\"\n\n const isDropTarget = useEventCalendarSelector<\n unknown,\n \"valid\" | \"invalid\" | null\n >((state) => {\n const drag = state.drag\n if (!drag || !drag.proposedDayGranular) return null\n const covered = drag.proposedStart < dayEnd && drag.proposedEnd > dayStart\n if (!covered) return null\n return drag.valid ? \"valid\" : \"invalid\"\n })\n const inDraft = useEventCalendarSelector<\n unknown,\n { isStart: boolean; isEnd: boolean } | null\n >(\n (state) => {\n const draft = state.slotDraft\n if (!draft || !draft.allDay) return null\n if (draft.start >= dayEnd || draft.end <= dayStart) return null\n return {\n isStart: draft.start >= dayStart,\n isEnd: draft.end <= dayEnd,\n }\n },\n {\n isEqual: (a, b) =>\n a === b ||\n (a !== null &&\n b !== null &&\n a.isStart === b.isStart &&\n a.isEnd === b.isEnd),\n }\n )\n\n return (\n {\n if (e.target === e.currentTarget) gestures.beginCreate(e, day, true)\n }}\n onClick={(e) => {\n if (\n e.target === e.currentTarget &&\n !wasRecentDrag() &&\n !wasRecentChipPress()\n ) {\n settings.onSlotClick?.({ date: dayStart, allDay: true, view }, e)\n }\n }}\n >\n )\n}\n\nfunction EventCalendarTimeGutter({\n days,\n slots,\n startHour,\n interval,\n}: {\n days: Date[]\n slots: number[]\n startHour: number\n interval: number\n}) {\n const settings = useEventCalendarSettings()\n const viewConfig = useEventCalendarViewConfig()\n const referenceDay = days[0] ?? new Date()\n const labelFormat =\n interval % 60 === 0\n ? settings.i18n.formats.timeGutter\n : settings.i18n.formats.timeGutterMinute\n return (\n \n {slots.map((minutes) => {\n const time = addMinutes(\n zonedStartOfDay(referenceDay, settings.timeZone),\n minutes\n )\n // Always consult renderTimeGutterSlot (a consumer may label the first\n // slot); only the DEFAULT label is suppressed at the day-start edge.\n const label =\n viewConfig.renderTimeGutterSlot?.({\n time,\n hour: Math.floor(minutes / 60),\n minute: minutes % 60,\n }) ??\n (minutes > startHour * 60\n ? format(time, labelFormat, { locale: settings.locale })\n : null)\n return (\n \n {label != null && (\n \n {label}\n \n )}\n \n )\n })}\n \n )\n}\n\n/** Absolute overlay block positioned by minutes (ghosts + drafts). */\nfunction minuteBlockStyle(\n startMin: number,\n endMin: number,\n boundsStartMin: number\n): CSSProperties {\n const top = (startMin - boundsStartMin) / 60\n const height = Math.max((endMin - startMin) / 60, 0.25)\n return {\n top: `calc(var(--ec-hour-height) * ${top})`,\n height: `calc(var(--ec-hour-height) * ${height})`,\n }\n}\n\nfunction EventCalendarDayColumn({\n day,\n startHour,\n endHour,\n interval,\n}: {\n day: Date\n startHour: number\n endHour: number\n interval: number\n}) {\n const settings = useEventCalendarSettings()\n const viewConfig = useEventCalendarViewConfig()\n const { effective } = useEventCalendarViewSettings()\n const { view } = useEventCalendarViewContext()\n const gestures = useEventCalendarGestures()\n const { segments, isToday } = useEventCalendarDay(day)\n const isOff = resolveOffDay(\n day,\n settings.timeZone,\n effective.offDays\n ? typeof viewConfig.offDays === \"object\"\n ? viewConfig.offDays\n : true\n : false,\n settings.weekendDays\n )\n const offClassName =\n (typeof viewConfig.offDays === \"object\" && viewConfig.offDays.className) ||\n \"bg-muted/25\"\n\n const timeZone = settings.timeZone\n const dayStart = zonedStartOfDay(day, timeZone)\n const dayEnd = addDays(toZoned(dayStart, timeZone), 1)\n const totalMinutes = getDayTotalMinutes(day, timeZone)\n const boundsStartMin = startHour * 60\n const boundsEndMin = Math.min(endHour * 60, totalMinutes)\n const boundsMinutes = Math.max(60, boundsEndMin - boundsStartMin)\n\n // Minute window of a proposal intersecting THIS day, or null\n const windowFor = (start: Date, end: Date): [number, number] | null => {\n if (start >= dayEnd || end <= dayStart) return null\n const from = Math.max(\n differenceInMinutes(start > dayStart ? start : dayStart, dayStart),\n boundsStartMin\n )\n const to = Math.min(\n differenceInMinutes(end < dayEnd ? end : dayEnd, dayStart),\n boundsEndMin\n )\n return to > from ? [from, to] : null\n }\n\n const dragGhost = useEventCalendarSelector<\n unknown,\n | (Pick<\n EventCalendarDragState,\n \"valid\" | \"kind\" | \"occurrence\" | \"proposedStart\" | \"proposedEnd\"\n > & {\n window: [number, number]\n color?: string\n title: string\n })\n | null\n >(\n (state) => {\n const drag = state.drag\n if (!drag || drag.proposedDayGranular) return null\n const window = windowFor(drag.proposedStart, drag.proposedEnd)\n if (!window) return null\n return {\n valid: drag.valid,\n kind: drag.kind,\n occurrence: drag.occurrence,\n proposedStart: drag.proposedStart,\n proposedEnd: drag.proposedEnd,\n window,\n color: drag.occurrence.event.color,\n title: drag.occurrence.event.title,\n }\n },\n {\n isEqual: (a, b) =>\n a === b ||\n (a !== null &&\n b !== null &&\n a.window[0] === b.window[0] &&\n a.window[1] === b.window[1] &&\n a.valid === b.valid &&\n a.kind === b.kind &&\n a.proposedStart.getTime() === b.proposedStart.getTime() &&\n a.proposedEnd.getTime() === b.proposedEnd.getTime()),\n }\n )\n\n const draftWindow = useEventCalendarSelector<\n unknown,\n [number, number] | null\n >(\n (state) => {\n const draft = state.slotDraft\n if (!draft || draft.allDay) return null\n return windowFor(draft.start, draft.end)\n },\n {\n isEqual: (a, b) =>\n a === b || (a !== null && b !== null && a[0] === b[0] && a[1] === b[1]),\n }\n )\n\n // Segments the day bounds clip away still occupy a column in the shared\n // index's packing, leaving a phantom empty half beside the first in-bounds\n // chip. Repack the visible subset; clones keep the index cache untouched.\n const packedTimed = useMemo(() => {\n const visible = segments.timed.filter((segment) => {\n const startMin = Math.max(segment.startMin ?? 0, boundsStartMin)\n const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin)\n return endMin > boundsStartMin && startMin < boundsEndMin\n })\n if (visible.length === segments.timed.length) return segments.timed\n const clones = visible.map(\n (segment) => ({ ...segment }) as EventCalendarSegment\n )\n packTimedSegments(clones)\n return clones\n }, [segments.timed, boundsStartMin, boundsEndMin])\n\n const slotFromPointer = (e: React.MouseEvent): { date: Date; end: Date } => {\n const rect = e.currentTarget.getBoundingClientRect()\n const pxPerMinute = rect.height / boundsMinutes\n const minutes = snapMinutes(\n boundsStartMin + (e.clientY - rect.top) / pxPerMinute,\n settings.snapDuration\n )\n const clamped = Math.min(\n Math.max(minutes, boundsStartMin),\n boundsEndMin - settings.slotDuration\n )\n return {\n date: addMinutes(dayStart, clamped),\n end: addMinutes(dayStart, clamped + settings.slotDuration),\n }\n }\n\n return (\n {\n if (e.target === e.currentTarget) gestures.beginCreate(e, day, false)\n }}\n onClick={(e) => {\n if (\n e.target !== e.currentTarget ||\n wasRecentDrag() ||\n wasRecentChipPress()\n )\n return\n const slot = slotFromPointer(e)\n settings.onSlotClick?.({ ...slot, allDay: false, view }, e)\n }}\n >\n {viewConfig.renderDayColumnBackground && (\n
\n {viewConfig.renderDayColumnBackground({\n day,\n boundsStartMin,\n boundsEndMin,\n totalMinutes,\n })}\n
\n )}\n {packedTimed.map((segment) => {\n const startMin = Math.max(segment.startMin ?? 0, boundsStartMin)\n const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin)\n if (endMin <= boundsStartMin || startMin >= boundsEndMin) return null\n const columnCount = segment.columnCount ?? 1\n const column = segment.column ?? 0\n const span = segment.columnSpan ?? 1\n const zIndex = segment.occurrence.event.zIndex ?? 10 + column\n // Strict side-by-side columns - no cascade overlap (fade-truncate +\n // hover reveal carry the legibility); the ring separates neighbors.\n const colPct = 100 / columnCount\n return (\n \n 1 && \"ring-background ring-1\",\n // short chips: single centered row, exact-fit line height so\n // the title never slices mid-glyph\n endMin - startMin < viewConfig.compactEventMinutes\n ? \"h-full gap-1 py-0 leading-4\"\n : \"h-full flex-col items-start justify-start gap-0 py-1\",\n viewConfig.classNames?.timedChip\n )}\n />\n \n )\n })}\n {/* Drag ghost, standardized (EVENT_CALENDAR_GHOST). Move: a faint\n dashed placeholder at the snapped slot - the cursor-attached carry\n clone owns the visual. Resize: the chip clone with a dashed boundary\n at the proposed extent. Invalid: destructive marking. */}\n {dragGhost && (\n \n {dragGhost.kind !== \"move\" && (\n \n )}\n \n )}\n {/* Drag-create draft */}\n {draftWindow && (\n \n )}\n \n )\n}\n\nfunction EventCalendarNowIndicator({\n days,\n startHour,\n endHour,\n}: {\n days: Date[]\n startHour: number\n endHour: number\n}): ReactNode {\n const settings = useEventCalendarSettings()\n const viewConfig = useEventCalendarViewConfig()\n const now = useNow(viewConfig.nowIndicatorInterval)\n\n const timeZone = settings.timeZone\n const todayKey = getDayKey(now, timeZone)\n const todayIndex = days.findIndex(\n (day) => getDayKey(day, timeZone) === todayKey\n )\n if (todayIndex === -1) return null\n\n const dayStart = zonedStartOfDay(now, timeZone)\n const minutes = differenceInMinutes(now, dayStart)\n if (minutes < startHour * 60 || minutes > endHour * 60) return null\n const top = minutes / 60 - startHour\n\n if (viewConfig.renderNowIndicator) {\n return (\n \n {viewConfig.renderNowIndicator({ time: now })}\n \n )\n }\n\n const columnWidthPct = 100 / days.length\n\n return (\n \n {/* hairline across the content columns only (clear of the time gutter) */}\n
\n {/* stronger segment + dot over today's column */}\n \n
\n {/* dot leads the line at today's column-start border: pulled 1px left of\n center (-start-1 = -4px vs the 6px/size-1.5 circle) so it reads as a\n distinct bullet instead of merging into the line to its right */}\n
\n
\n
\n )\n}\n\ntype TimeGridViewProps = Omit\n\nfunction EventCalendarWeekView(props: TimeGridViewProps) {\n return \n}\n\nfunction EventCalendarDayView(props: TimeGridViewProps) {\n return \n}\n\nfunction EventCalendarDaysView(props: TimeGridViewProps) {\n return \n}\n\nexport {\n EventCalendarDayView,\n EventCalendarDaysView,\n EventCalendarNowIndicator,\n EventCalendarTimeGrid,\n EventCalendarTimeGutter,\n EventCalendarWeekView,\n minuteBlockStyle,\n useNow,\n}\nexport type { EventCalendarTimeGridProps }","target":"components/neui/event-calendar/event-calendar-time-grid.tsx"}]}