import { addDays, differenceInMinutes, max as maxDate, min as minDate, startOfDay } from "date-fns"; import type { BusinessHours, CalendarEvent } from "../types"; const MINUTES_PER_HOUR = 60; // Minimum duration (in hours) a positioned event is given, so a zero/negative // span still occupies a sliver rather than collapsing to nothing. const MIN_DURATION_HOURS = 0.25; /** An event placed on a single day's time grid by {@link layoutDayEvents}, with its vertical span and overlap column. */ export type PositionedEvent = { /** The source event for this segment. */ event: CalendarEvent; /** Hours from midnight to the event's segment start on this day (fractional). */ startHours: number; /** Segment duration in hours on this day (clamped to a small minimum). */ durationHours: number; /** Zero-based column index within its overlap cluster. */ column: number; /** Total columns in this event's overlap cluster. */ columns: number; /** True when the segment is clipped because the event continues before/after this day. */ continuesBefore: boolean; continuesAfter: boolean; }; type Segment = { event: CalendarEvent; start: number; end: number; continuesBefore: boolean; continuesAfter: boolean; }; /** * Lay out a single day's events: events that overlap in time are split into * side-by-side columns. Multi-day events are clipped to the portion that falls * on `day` (e.g. a 23:00→01:00 event renders 23:00–24:00 on the start day and * 00:00–01:00 on the next). Pure — safe to call per render, never per frame. */ export function layoutDayEvents(events: CalendarEvent[], day: Date): PositionedEvent[] { const dayStart = startOfDay(day); const nextDayStart = addDays(dayStart, 1); const segments: Segment[] = events // All-day events live in the lane, not the timed columns; background events // paint as bands (see `backgroundBandsForDay`), not boxes. .filter((event) => !isAllDayEvent(event) && !isBackgroundEvent(event)) // Overlaps this day if it starts before the day ends and ends after it begins. .filter((event) => event.start < nextDayStart && event.end > dayStart) .map((event) => { const segStart = maxDate([event.start, dayStart]); const segEnd = minDate([event.end, nextDayStart]); return { event, start: differenceInMinutes(segStart, dayStart) / MINUTES_PER_HOUR, end: differenceInMinutes(segEnd, dayStart) / MINUTES_PER_HOUR, continuesBefore: event.start < dayStart, continuesAfter: event.end > nextDayStart, }; }) .sort((a, b) => a.start - b.start); const positioned: PositionedEvent[] = []; let cluster: Segment[] = []; let clusterEnd = Number.NEGATIVE_INFINITY; const flushCluster = () => { const columnEnds: number[] = []; const columnOf = new Map, number>(); for (const seg of cluster) { let column = columnEnds.findIndex((end) => end <= seg.start); if (column === -1) { column = columnEnds.length; columnEnds.push(seg.end); } else { columnEnds[column] = seg.end; } columnOf.set(seg, column); } for (const seg of cluster) { positioned.push({ event: seg.event, startHours: seg.start, durationHours: Math.max(seg.end - seg.start, MIN_DURATION_HOURS), column: columnOf.get(seg) ?? 0, columns: columnEnds.length, continuesBefore: seg.continuesBefore, continuesAfter: seg.continuesAfter, }); } cluster = []; }; for (const seg of segments) { if (cluster.length > 0 && seg.start >= clusterEnd) flushCluster(); cluster.push(seg); clusterEnd = Math.max(clusterEnd, seg.end); } if (cluster.length > 0) flushCluster(); return positioned; } const atMidnight = (date: Date): boolean => date.getHours() === 0 && date.getMinutes() === 0 && date.getSeconds() === 0 && date.getMilliseconds() === 0; /** * Whether an event belongs in the all-day lane. An explicit `allDay` flag wins; * otherwise it's inferred when the event spans whole days (both `start` and * `end` land on midnight, e.g. an iCal-style all-day event). Pure. */ export function isAllDayEvent(event: CalendarEvent): boolean { if (typeof event.allDay === "boolean") return event.allDay; return event.end > event.start && atMidnight(event.start) && atMidnight(event.end); } /** * The `startOfDay` ISO keys of every calendar day an event touches (inclusive). * An event ending exactly at midnight does not count the following day. Used to * index events by day for the month grid. Pure. */ export function eventDayKeys(event: CalendarEvent): string[] { const first = startOfDay(event.start); // The last instant the event occupies; an end of exactly midnight belongs to // the previous day. const lastInstant = event.end > event.start ? new Date(event.end.getTime() - 1) : event.start; const last = startOfDay(lastInstant); const keys: string[] = []; for (let cursor = first; cursor <= last; cursor = addDays(cursor, 1)) { keys.push(cursor.toISOString()); } return keys; } /** * Index events by the `startOfDay` ISO key of every day they touch (via * {@link eventDayKeys}), so a month grid can look up a day's events with * `startOfDay(date).toISOString()`. Built once and shared across month cells. */ export function groupEventsByDay( events: readonly CalendarEvent[], ): Map[]> { const map = new Map[]>(); for (const event of events) { for (const key of eventDayKeys(event)) { const list = map.get(key); if (list) list.push(event); else map.set(key, [event]); } } return map; } /** * Order a day's events for the month and list views: all-day events come first * (they head the day regardless of their start time), then timed events by start. * Shared by both renderers so the order is identical. Use as an `Array.sort` * comparator. */ export function compareDayEvents(a: CalendarEvent, b: CalendarEvent): number { const aAllDay = isAllDayEvent(a); const bAllDay = isAllDayEvent(b); if (aAllDay !== bAllDay) return aAllDay ? -1 : 1; return a.start.getTime() - b.start.getTime(); } /** * The closed hour-spans of a day to shade on the time grid, given a * `businessHours` callback and the visible `[minHour, maxHour]` window: the spans * before open and after close (clamped to the window), the whole window when the * day is closed (`null`) or the open hours are inverted/empty, or none when the * callback returns `undefined`. Shared by both renderers so shading stays * identical. Co-located with `groupEventsByDay`; both feed the grid layout. */ export function closedHourBands( day: Date, businessHours: BusinessHours | undefined, minHour = 0, maxHour = 24, ): { start: number; end: number }[] { const open = businessHours?.(day); if (open === undefined) return []; if (open === null) return [{ start: minHour, end: maxHour }]; // Clamp every open window to the visible range and drop empty/inverted ones. const windows = (Array.isArray(open) ? open : [open]) .map((w) => ({ start: Math.max(minHour, Math.min(maxHour, w.start)), end: Math.max(minHour, Math.min(maxHour, w.end)), })) .filter((w) => w.end > w.start) .sort((a, b) => a.start - b.start); // Nothing open: shade the whole window. if (windows.length === 0) return [{ start: minHour, end: maxHour }]; // Merge overlapping/touching windows so the closed bands are the gaps between // (and around) them: before the first, between windows (e.g. lunch), after the last. const merged: { start: number; end: number }[] = []; for (const w of windows) { const last = merged[merged.length - 1]; if (last && w.start <= last.end) last.end = Math.max(last.end, w.end); else merged.push({ ...w }); } const bands: { start: number; end: number }[] = []; let cursor = minHour; for (const w of merged) { if (w.start > cursor) bands.push({ start: cursor, end: w.start }); cursor = w.end; } if (cursor < maxHour) bands.push({ start: cursor, end: maxHour }); return bands; } /** True when the event paints as a shaded background band, not an event box. */ export function isBackgroundEvent(event: CalendarEvent): boolean { return event.display === "background"; } /** * The background events of `events` sliced to `day`, as fractional-hour bands * (an all-day or multi-day background covers the day's full window). Shared by * both renderers so the shading can't disagree. */ export function backgroundBandsForDay( events: CalendarEvent[], day: Date, ): { event: CalendarEvent; startHours: number; endHours: number }[] { const dayStart = startOfDay(day); const nextDayStart = addDays(dayStart, 1); const out: { event: CalendarEvent; startHours: number; endHours: number }[] = []; for (const event of events) { if (!isBackgroundEvent(event)) continue; if (event.start >= nextDayStart || event.end <= dayStart) continue; const from = event.start > dayStart ? event.start : dayStart; const to = event.end < nextDayStart ? event.end : nextDayStart; const startHours = (from.getTime() - dayStart.getTime()) / 3_600_000; const endHours = (to.getTime() - dayStart.getTime()) / 3_600_000; if (endHours > startHours) out.push({ event, startHours, endHours }); } return out; }