/** * EventStackManager - Manages visual stacking of overlapping calendar events * * This class handles the creation and maintenance of "stack chains" - doubly-linked * lists of overlapping events stored directly in DOM elements via data attributes. * * Implements 3-phase algorithm for flexbox + nested stacking: * Phase 1: Group events by start time proximity (±15 min threshold) * Phase 2: Decide container type (FLEXBOX vs STACKING) * Phase 3: Handle late arrivals (nested stacking) * * @see STACKING_CONCEPT.md for detailed documentation * @see stacking-visualization.html for visual examples */ import { CalendarEvent } from '../types/CalendarTypes'; export interface StackLink { prev?: string; // Event ID of previous event in stack next?: string; // Event ID of next event in stack stackLevel: number; // Position in stack (0 = base, 1 = first offset, etc.) } export interface EventGroup { events: CalendarEvent[]; containerType: 'NONE' | 'GRID' | 'STACKING'; startTime: Date; } export class EventStackManager { private static readonly FLEXBOX_START_THRESHOLD_MINUTES = 15; private static readonly STACK_OFFSET_PX = 15; // ============================================ // PHASE 1: Start Time Grouping // ============================================ /** * Group events by start time proximity (±15 min threshold) */ public groupEventsByStartTime(events: CalendarEvent[]): EventGroup[] { if (events.length === 0) return []; // Sort events by start time const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); const groups: EventGroup[] = []; for (const event of sorted) { // Find existing group within threshold const existingGroup = groups.find(group => { const groupStart = group.startTime; const diffMinutes = Math.abs(event.start.getTime() - groupStart.getTime()) / (1000 * 60); return diffMinutes <= EventStackManager.FLEXBOX_START_THRESHOLD_MINUTES; }); if (existingGroup) { existingGroup.events.push(event); } else { groups.push({ events: [event], containerType: 'NONE', startTime: event.start }); } } return groups; } /** * Check if two events should share flexbox (within ±15 min) */ public shouldShareFlexbox(event1: CalendarEvent, event2: CalendarEvent): boolean { const diffMinutes = Math.abs(event1.start.getTime() - event2.start.getTime()) / (1000 * 60); return diffMinutes <= EventStackManager.FLEXBOX_START_THRESHOLD_MINUTES; } // ============================================ // PHASE 2: Container Type Decision // ============================================ /** * Decide container type for a group of events * * Rule: Events starting simultaneously (within ±15 min) should ALWAYS use GRID, * even if they overlap each other. This provides better visual indication that * events start at the same time. */ public decideContainerType(group: EventGroup): 'NONE' | 'GRID' | 'STACKING' { if (group.events.length === 1) { return 'NONE'; } // If events are grouped together (start within ±15 min), they should share columns (GRID) // This is true EVEN if they overlap, because the visual priority is to show // that they start simultaneously. return 'GRID'; } /** * Check if events within a group overlap each other */ private hasInternalOverlaps(events: CalendarEvent[]): boolean { for (let i = 0; i < events.length; i++) { for (let j = i + 1; j < events.length; j++) { if (this.doEventsOverlap(events[i], events[j])) { return true; } } } return false; } /** * Check if two events overlap in time */ public doEventsOverlap(event1: CalendarEvent, event2: CalendarEvent): boolean { return event1.start < event2.end && event1.end > event2.start; } // ============================================ // PHASE 3: Late Arrivals (Nested Stacking) // ============================================ /** * Find events that start outside threshold (late arrivals) */ public findLateArrivals(groups: EventGroup[], allEvents: CalendarEvent[]): CalendarEvent[] { const eventsInGroups = new Set(groups.flatMap(g => g.events.map(e => e.id))); return allEvents.filter(event => !eventsInGroups.has(event.id)); } /** * Find primary parent column for a late event (longest duration or first overlapping) */ public findPrimaryParentColumn(lateEvent: CalendarEvent, flexboxGroup: CalendarEvent[]): string | null { // Find all overlapping events in the flexbox group const overlapping = flexboxGroup.filter(event => this.doEventsOverlap(lateEvent, event)); if (overlapping.length === 0) { return null; } // Sort by duration (longest first) overlapping.sort((a, b) => { const durationA = b.end.getTime() - b.start.getTime(); const durationB = a.end.getTime() - a.start.getTime(); return durationA - durationB; }); return overlapping[0].id; } /** * Calculate marginLeft for nested event (always 15px) */ public calculateNestedMarginLeft(): number { return EventStackManager.STACK_OFFSET_PX; } /** * Calculate stackLevel for nested event (parent + 1) */ public calculateNestedStackLevel(parentStackLevel: number): number { return parentStackLevel + 1; } // ============================================ // Flexbox Layout Calculations // ============================================ /** * Calculate flex width for flexbox columns */ public calculateFlexWidth(columnCount: number): string { if (columnCount === 1) return '100%'; if (columnCount === 2) return '50%'; if (columnCount === 3) return '33.33%'; if (columnCount === 4) return '25%'; // For 5+ columns, calculate percentage const percentage = (100 / columnCount).toFixed(2); return `${percentage}%`; } // ============================================ // Existing Methods (from original TDD tests) // ============================================ /** * Find all events that overlap with a given event */ public findOverlappingEvents(targetEvent: CalendarEvent, columnEvents: CalendarEvent[]): CalendarEvent[] { return columnEvents.filter(event => this.doEventsOverlap(targetEvent, event)); } /** * Create stack links for overlapping events (naive sequential stacking) */ public createStackLinks(events: CalendarEvent[]): Map { const stackLinks = new Map(); if (events.length === 0) return stackLinks; // Sort by start time (and by end time if start times are equal) const sorted = [...events].sort((a, b) => { const startDiff = a.start.getTime() - b.start.getTime(); if (startDiff !== 0) return startDiff; return a.end.getTime() - b.end.getTime(); }); // Create sequential stack sorted.forEach((event, index) => { const link: StackLink = { stackLevel: index }; if (index > 0) { link.prev = sorted[index - 1].id; } if (index < sorted.length - 1) { link.next = sorted[index + 1].id; } stackLinks.set(event.id, link); }); return stackLinks; } /** * Create optimized stack links (events share levels when possible) */ public createOptimizedStackLinks(events: CalendarEvent[]): Map { const stackLinks = new Map(); if (events.length === 0) return stackLinks; // Sort by start time const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); // Step 1: Assign stack levels for (const event of sorted) { // Find all events this event overlaps with const overlapping = sorted.filter(other => other !== event && this.doEventsOverlap(event, other) ); console.log(`[EventStackManager] Event ${event.id} overlaps with:`, overlapping.map(e => e.id)); // Find the MINIMUM required level (must be above all overlapping events) let minRequiredLevel = 0; for (const other of overlapping) { const otherLink = stackLinks.get(other.id); if (otherLink) { console.log(` ${other.id} has stackLevel ${otherLink.stackLevel}`); // Must be at least one level above the overlapping event minRequiredLevel = Math.max(minRequiredLevel, otherLink.stackLevel + 1); } } console.log(` → Assigned stackLevel ${minRequiredLevel} (must be above all overlapping events)`); stackLinks.set(event.id, { stackLevel: minRequiredLevel }); } // Step 2: Build prev/next chains for overlapping events at adjacent stack levels for (const event of sorted) { const currentLink = stackLinks.get(event.id)!; // Find overlapping events that are directly below (stackLevel - 1) const overlapping = sorted.filter(other => other !== event && this.doEventsOverlap(event, other) ); const directlyBelow = overlapping.filter(other => { const otherLink = stackLinks.get(other.id); return otherLink && otherLink.stackLevel === currentLink.stackLevel - 1; }); if (directlyBelow.length > 0) { // Use the first one in sorted order as prev currentLink.prev = directlyBelow[0].id; } // Find overlapping events that are directly above (stackLevel + 1) const directlyAbove = overlapping.filter(other => { const otherLink = stackLinks.get(other.id); return otherLink && otherLink.stackLevel === currentLink.stackLevel + 1; }); if (directlyAbove.length > 0) { // Use the first one in sorted order as next currentLink.next = directlyAbove[0].id; } } return stackLinks; } /** * Calculate marginLeft based on stack level */ public calculateMarginLeft(stackLevel: number): number { return stackLevel * EventStackManager.STACK_OFFSET_PX; } /** * Calculate zIndex based on stack level */ public calculateZIndex(stackLevel: number): number { return 100 + stackLevel; } /** * Serialize stack link to JSON string */ public serializeStackLink(stackLink: StackLink): string { return JSON.stringify(stackLink); } /** * Deserialize JSON string to stack link */ public deserializeStackLink(json: string): StackLink | null { try { return JSON.parse(json); } catch (e) { return null; } } /** * Apply stack link to DOM element */ public applyStackLinkToElement(element: HTMLElement, stackLink: StackLink): void { element.dataset.stackLink = this.serializeStackLink(stackLink); } /** * Get stack link from DOM element */ public getStackLinkFromElement(element: HTMLElement): StackLink | null { const data = element.dataset.stackLink; if (!data) return null; return this.deserializeStackLink(data); } /** * Apply visual styling to element based on stack level */ public applyVisualStyling(element: HTMLElement, stackLevel: number): void { element.style.marginLeft = `${this.calculateMarginLeft(stackLevel)}px`; element.style.zIndex = `${this.calculateZIndex(stackLevel)}`; } /** * Clear stack link from element */ public clearStackLinkFromElement(element: HTMLElement): void { delete element.dataset.stackLink; } /** * Clear visual styling from element */ public clearVisualStyling(element: HTMLElement): void { element.style.marginLeft = ''; element.style.zIndex = ''; } }