/** * AllDayLayoutEngine - Pure data-driven layout calculation for all-day events */ import { CalendarEvent } from '../types/CalendarTypes'; export interface EventLayout { id: string; gridArea: string; // "row-start / col-start / row-end / col-end" startColumn: number; endColumn: number; row: number; columnSpan: number; } export class AllDayLayoutEngine { private weekDates: string[]; constructor(weekDates: string[]) { this.weekDates = weekDates; } /** * Calculate layout for all events with proper overlap detection */ public calculateLayout(events: CalendarEvent[]): Map { const layouts = new Map(); // Sort by event duration (longest first), then by start date const sortedEvents = [...events].sort((a, b) => { const durationA = this.calculateEventDuration(a); const durationB = this.calculateEventDuration(b); // Primary sort: longest duration first if (durationA !== durationB) { return durationB - durationA; } // Secondary sort: earliest start date first const startA = a.start.toISOString().split('T')[0]; const startB = b.start.toISOString().split('T')[0]; return startA.localeCompare(startB); }); sortedEvents.forEach(event => { const layout = this.calculateEventLayout(event, layouts); layouts.set(event.id, layout); }); return layouts; } /** * Calculate event duration in days */ private calculateEventDuration(event: CalendarEvent): number { const startDate = event.start; const endDate = event.end; const diffTime = endDate.getTime() - startDate.getTime(); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; // +1 because same day = 1 day return diffDays; } /** * Calculate layout for single event considering existing events */ private calculateEventLayout(event: CalendarEvent, existingLayouts: Map): EventLayout { // Calculate column span const { startColumn, endColumn, columnSpan } = this.calculateColumnSpan(event); // Find available row using overlap detection const availableRow = this.findAvailableRow(startColumn, endColumn, existingLayouts); // Generate grid-area string: "row-start / col-start / row-end / col-end" const gridArea = `${availableRow} / ${startColumn} / ${availableRow + 1} / ${endColumn + 1}`; return { id: event.id, gridArea, startColumn, endColumn, row: availableRow, columnSpan }; } /** * Calculate column span based on event start and end dates */ private calculateColumnSpan(event: CalendarEvent): { startColumn: number; endColumn: number; columnSpan: number } { // Convert CalendarEvent dates to YYYY-MM-DD format const startDate = event.start.toISOString().split('T')[0]; const endDate = event.end.toISOString().split('T')[0]; // Find start and end column indices (1-based) let startColumn = -1; let endColumn = -1; this.weekDates.forEach((dateStr, index) => { if (dateStr === startDate) { startColumn = index + 1; } if (dateStr === endDate) { endColumn = index + 1; } }); // Handle events that start before or end after the week if (startColumn === -1) { startColumn = 1; // Event starts before this week } if (endColumn === -1) { endColumn = this.weekDates.length; // Event ends after this week } // Ensure end column is at least start column if (endColumn < startColumn) { endColumn = startColumn; } const columnSpan = endColumn - startColumn + 1; return { startColumn, endColumn, columnSpan }; } /** * Find available row using overlap detection */ private findAvailableRow( newStartColumn: number, newEndColumn: number, existingLayouts: Map ): number { const occupiedRows = new Set(); // Check all existing events for overlaps existingLayouts.forEach(layout => { const overlaps = this.columnsOverlap( newStartColumn, newEndColumn, layout.startColumn, layout.endColumn ); if (overlaps) { occupiedRows.add(layout.row); } }); // Find first available row let targetRow = 1; while (occupiedRows.has(targetRow)) { targetRow++; } return targetRow; } /** * Check if two column ranges overlap */ private columnsOverlap( startA: number, endA: number, startB: number, endB: number ): boolean { // Two ranges overlap if one doesn't end before the other starts return !(endA < startB || endB < startA); } }