/** * Calendar Helper Utilities * Shared logic for multi-day event handling across calendar views */ export interface MultiDaySegment { entity: any; startCol: number; span: number; lane: number; isStart: boolean; isEnd: boolean; } export interface LaneAssignment { segments: MultiDaySegment[]; laneCount: number; } /** * Get the start of day (midnight) for a given date */ export function startOfDay(date: Date): Date { const d = new Date(date); d.setHours(0, 0, 0, 0); return d; } /** * Calculate the difference in days between two dates */ export function daysDiff(a: Date, b: Date): number { const msPerDay = 24 * 60 * 60 * 1000; return Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / msPerDay); } /** * Check if an event spans multiple days */ export function isMultiDay(entity: any): boolean { if (!entity.properties?.startsOn) return false; const start = startOfDay(new Date(entity.properties.startsOn)); const end = entity.properties?.endsOn ? startOfDay(new Date(entity.properties.endsOn)) : start; return daysDiff(start, end) > 0; } /** * Check if an event is an all-day event (no specific time, or spans full day) */ export function isAllDay(entity: any): boolean { if (!entity.properties?.startsOn) return false; // Explicit all-day flag if (entity.properties?.timeless === true) return true; // Multi-day events are treated as all-day in the days view if (isMultiDay(entity)) return true; return false; } /** * Check if an event overlaps with a date range */ export function eventOverlapsRange(entity: any, rangeStart: Date, rangeEnd: Date): boolean { if (!entity.properties?.startsOn) return false; const eventStart = startOfDay(new Date(entity.properties.startsOn)); const eventEnd = entity.properties?.endsOn ? startOfDay(new Date(entity.properties.endsOn)) : eventStart; return eventStart <= rangeEnd && eventEnd >= rangeStart; } /** * Check if an event occurs on a specific date */ export function eventOnDate(entity: any, date: Date): boolean { if (!entity.properties?.startsOn) return false; const eventStart = startOfDay(new Date(entity.properties.startsOn)); const eventEnd = entity.properties?.endsOn ? startOfDay(new Date(entity.properties.endsOn)) : eventStart; const targetDate = startOfDay(date); return eventStart <= targetDate && eventEnd >= targetDate; } /** * Get multi-day event segments for a given date range with lane assignments * Used for rendering spanning events across columns */ export function getMultiDaySegments( events: any[], rangeStart: Date, rangeEnd: Date, columnCount: number, getColumnIndex: (date: Date) => number ): LaneAssignment { const segments: MultiDaySegment[] = []; // Filter multi-day/all-day events that overlap this range const multiDayEvents = events.filter(entity => { if (!entity.properties?.startsOn) return false; if (!isAllDay(entity)) return false; return eventOverlapsRange(entity, rangeStart, rangeEnd); }); // Sort: longer events first, then by start date multiDayEvents.sort((a, b) => { const aStart = new Date(a.properties.startsOn); const aEnd = new Date(a.properties.endsOn || a.properties.startsOn); const bStart = new Date(b.properties.startsOn); const bEnd = new Date(b.properties.endsOn || b.properties.startsOn); const aDuration = daysDiff(aStart, aEnd); const bDuration = daysDiff(bStart, bEnd); if (bDuration !== aDuration) return bDuration - aDuration; return aStart.getTime() - bStart.getTime(); }); // Lane assignment - track which days are occupied in each lane const lanes: boolean[][] = []; for (const entity of multiDayEvents) { const eventStart = startOfDay(new Date(entity.properties.startsOn)); const eventEnd = entity.properties?.endsOn ? startOfDay(new Date(entity.properties.endsOn)) : eventStart; // Clamp to visible range const segStart = eventStart < rangeStart ? rangeStart : eventStart; const segEnd = eventEnd > rangeEnd ? rangeEnd : eventEnd; const startCol = getColumnIndex(segStart); const endCol = getColumnIndex(segEnd); // Skip if outside visible columns if (startCol < 0 || startCol >= columnCount) continue; const span = Math.min(endCol, columnCount - 1) - startCol + 1; // Find available lane let lane = 0; while (true) { if (!lanes[lane]) { lanes[lane] = new Array(columnCount).fill(false); } let isFree = true; for (let d = startCol; d < startCol + span; d++) { if (lanes[lane][d]) { isFree = false; break; } } if (isFree) { for (let d = startCol; d < startCol + span; d++) { lanes[lane][d] = true; } break; } lane++; } segments.push({ entity, startCol, span, lane, isStart: eventStart >= rangeStart, isEnd: eventEnd <= rangeEnd, }); } return { segments, laneCount: lanes.length }; } /** * Get timed (non-all-day) events for a specific date */ export function getTimedEventsForDate(events: any[], date: Date): any[] { return events.filter(entity => { if (!entity.properties?.startsOn) return false; if (isAllDay(entity)) return false; const eventDate = startOfDay(new Date(entity.properties.startsOn)); return eventDate.toDateString() === date.toDateString(); }); } /** * Get single-day events for a specific date (used in month view) */ export function getSingleDayEventsForDate(events: any[], date: Date): any[] { return events.filter(entity => { if (!entity.properties?.startsOn) return false; if (isMultiDay(entity)) return false; const eventDate = startOfDay(new Date(entity.properties.startsOn)); return eventDate.toDateString() === date.toDateString(); }); }