/** * SimpleEventOverlapManager - Clean, focused overlap management * Eliminates complex state tracking in favor of direct DOM manipulation */ import { CalendarEvent } from '../types/CalendarTypes'; import { calendarConfig } from '../core/CalendarConfig'; export enum OverlapType { NONE = 'none', COLUMN_SHARING = 'column_sharing', STACKING = 'stacking' } export interface OverlapGroup { type: OverlapType; events: CalendarEvent[]; position: { top: number; height: number }; } export interface StackLink { prev?: string; // Event ID of previous event in stack next?: string; // Event ID of next event in stack stackLevel: number; // 0 = base event, 1 = first stacked, etc } export class SimpleEventOverlapManager { private static readonly STACKING_TIME_THRESHOLD_MINUTES = 30; private static readonly STACKING_WIDTH_REDUCTION_PX = 15; /** * Detect overlap type between two events - simplified logic */ public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType { if (!this.eventsOverlapInTime(event1, event2)) { return OverlapType.NONE; } const timeDiffMinutes = Math.abs( new Date(event1.start).getTime() - new Date(event2.start).getTime() ) / (1000 * 60); return timeDiffMinutes > SimpleEventOverlapManager.STACKING_TIME_THRESHOLD_MINUTES ? OverlapType.STACKING : OverlapType.COLUMN_SHARING; } /** * Simple time overlap check */ private eventsOverlapInTime(event1: CalendarEvent, event2: CalendarEvent): boolean { const start1 = new Date(event1.start).getTime(); const end1 = new Date(event1.end).getTime(); const start2 = new Date(event2.start).getTime(); const end2 = new Date(event2.end).getTime(); return !(end1 <= start2 || end2 <= start1); } /** * Group overlapping events - much cleaner algorithm */ public groupOverlappingEvents(events: CalendarEvent[]): OverlapGroup[] { const groups: OverlapGroup[] = []; const processed = new Set(); for (const event of events) { if (processed.has(event.id)) continue; // Find all events that overlap with this one const overlapping = events.filter(other => { if (processed.has(other.id)) return false; return other.id === event.id || this.detectOverlap(event, other) !== OverlapType.NONE; }); // Mark all as processed overlapping.forEach(e => processed.add(e.id)); // Determine group type const overlapType = overlapping.length > 1 ? this.detectOverlap(overlapping[0], overlapping[1]) : OverlapType.NONE; groups.push({ type: overlapType, events: overlapping, position: this.calculateGroupPosition(overlapping) }); } return groups; } /** * Create flexbox container for column sharing - clean and simple */ public createEventGroup(events: CalendarEvent[], position: { top: number; height: number }): HTMLElement { const container = document.createElement('swp-event-group'); container.style.cssText = ` position: absolute; top: ${position.top}px; left: 2px; right: 2px; display: flex; gap: 2px; `; return container; } /** * Add event to flexbox group - simple relative positioning */ public addToEventGroup(container: HTMLElement, eventElement: HTMLElement): void { // Set duration-based height const duration = eventElement.dataset.duration; if (duration) { const durationMinutes = parseInt(duration); const gridSettings = calendarConfig.getGridSettings(); const height = (durationMinutes / 60) * gridSettings.hourHeight; eventElement.style.height = `${height - 3}px`; } // Flexbox styling eventElement.style.position = 'relative'; eventElement.style.flex = '1'; eventElement.style.minWidth = '50px'; container.appendChild(eventElement); } /** * Create stacked event with data-attribute tracking */ public createStackedEvent(eventElement: HTMLElement, underlyingElement: HTMLElement, stackLevel: number): void { const marginLeft = stackLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX; // Apply visual styling eventElement.style.marginLeft = `${marginLeft}px`; eventElement.style.left = '2px'; eventElement.style.right = '2px'; eventElement.style.zIndex = `${100 + stackLevel}`; // Set up stack linking via data attributes const eventId = eventElement.dataset.eventId; const underlyingId = underlyingElement.dataset.eventId; if (!eventId || !underlyingId) { console.warn('Missing event IDs for stack linking:', eventId, underlyingId); return; } // Find the last event in the stack chain let lastElement = underlyingElement; let lastLink = this.getStackLink(lastElement); // If underlying doesn't have stack link yet, create it if (!lastLink) { this.setStackLink(lastElement, { stackLevel: 0 }); lastLink = { stackLevel: 0 }; } // Traverse to find the end of the chain while (lastLink?.next) { const nextElement = this.findElementById(lastLink.next); if (!nextElement) break; lastElement = nextElement; lastLink = this.getStackLink(lastElement); } // Link the new event to the end of the chain const lastElementId = lastElement.dataset.eventId!; this.setStackLink(lastElement, { ...lastLink!, next: eventId }); this.setStackLink(eventElement, { prev: lastElementId, stackLevel: stackLevel }); } /** * Remove stacked styling with proper stack re-linking */ public removeStackedStyling(eventElement: HTMLElement): void { // Clear visual styling eventElement.style.marginLeft = ''; eventElement.style.zIndex = ''; eventElement.style.left = '2px'; eventElement.style.right = '2px'; // Handle stack chain re-linking const link = this.getStackLink(eventElement); if (link) { // Re-link prev and next events if (link.prev && link.next) { // Middle element - link prev to next const prevElement = this.findElementById(link.prev); const nextElement = this.findElementById(link.next); if (prevElement && nextElement) { const prevLink = this.getStackLink(prevElement); const nextLink = this.getStackLink(nextElement); // CRITICAL: Check if prev and next actually overlap without the middle element const actuallyOverlap = this.checkPixelOverlap(prevElement, nextElement); if (!actuallyOverlap) { // CHAIN BREAKING: prev and next don't overlap - break the chain console.log('Breaking stack chain - events do not overlap directly'); // Prev element: remove next link (becomes end of its own chain) this.setStackLink(prevElement, { ...prevLink!, next: undefined }); // Next element: becomes standalone (remove all stack links and styling) this.setStackLink(nextElement, null); nextElement.style.marginLeft = ''; nextElement.style.zIndex = ''; // If next element had subsequent events, they also become standalone if (nextLink?.next) { let subsequentId: string | undefined = nextLink.next; while (subsequentId) { const subsequentElement = this.findElementById(subsequentId); if (!subsequentElement) break; const subsequentLink = this.getStackLink(subsequentElement); this.setStackLink(subsequentElement, null); subsequentElement.style.marginLeft = ''; subsequentElement.style.zIndex = ''; subsequentId = subsequentLink?.next; } } } else { // NORMAL STACKING: they overlap, maintain the chain this.setStackLink(prevElement, { ...prevLink!, next: link.next }); const correctStackLevel = (prevLink?.stackLevel ?? 0) + 1; this.setStackLink(nextElement, { ...nextLink!, prev: link.prev, stackLevel: correctStackLevel }); // Update visual styling to match new stackLevel const marginLeft = correctStackLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX; nextElement.style.marginLeft = `${marginLeft}px`; nextElement.style.zIndex = `${100 + correctStackLevel}`; } } } else if (link.prev) { // Last element - remove next link from prev const prevElement = this.findElementById(link.prev); if (prevElement) { const prevLink = this.getStackLink(prevElement); this.setStackLink(prevElement, { ...prevLink!, next: undefined }); } } else if (link.next) { // First element - remove prev link from next const nextElement = this.findElementById(link.next); if (nextElement) { const nextLink = this.getStackLink(nextElement); this.setStackLink(nextElement, { ...nextLink!, prev: undefined, stackLevel: 0 // Next becomes the base event }); } } // Only update subsequent stack levels if we didn't break the chain if (link.prev && link.next) { const nextElement = this.findElementById(link.next); const nextLink = nextElement ? this.getStackLink(nextElement) : null; // If next element still has a stack link, the chain wasn't broken if (nextLink && nextLink.next) { this.updateSubsequentStackLevels(nextLink.next, -1); } // If nextLink is null, chain was broken - no subsequent updates needed } else { // First or last removal - update all subsequent this.updateSubsequentStackLevels(link.next, -1); } // Clear this element's stack link this.setStackLink(eventElement, null); } } /** * Update stack levels for all events following a given event ID */ private updateSubsequentStackLevels(startEventId: string | undefined, levelDelta: number): void { let currentId = startEventId; while (currentId) { const currentElement = this.findElementById(currentId); if (!currentElement) break; const currentLink = this.getStackLink(currentElement); if (!currentLink) break; // Update stack level const newLevel = Math.max(0, currentLink.stackLevel + levelDelta); this.setStackLink(currentElement, { ...currentLink, stackLevel: newLevel }); // Update visual styling const marginLeft = newLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX; currentElement.style.marginLeft = `${marginLeft}px`; currentElement.style.zIndex = `${100 + newLevel}`; currentId = currentLink.next; } } /** * Check if element is stacked - check both style and data-stack-link */ public isStackedEvent(element: HTMLElement): boolean { const marginLeft = element.style.marginLeft; const hasMarginLeft = marginLeft !== '' && marginLeft !== '0px'; const hasStackLink = this.getStackLink(element) !== null; return hasMarginLeft || hasStackLink; } /** * Remove event from group with proper cleanup */ public removeFromEventGroup(container: HTMLElement, eventId: string): boolean { const eventElement = container.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement; if (!eventElement) return false; // Calculate correct absolute position for standalone event const startTime = eventElement.dataset.start; if (startTime) { const startDate = new Date(startTime); const gridSettings = calendarConfig.getGridSettings(); const startMinutes = startDate.getHours() * 60 + startDate.getMinutes(); const dayStartMinutes = gridSettings.dayStartHour * 60; const top = ((startMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight; // Convert back to absolute positioning eventElement.style.position = 'absolute'; eventElement.style.top = `${top + 1}px`; eventElement.style.left = '2px'; eventElement.style.right = '2px'; eventElement.style.flex = ''; eventElement.style.minWidth = ''; } eventElement.remove(); // Handle remaining events const remainingEvents = container.querySelectorAll('swp-event'); const remainingCount = remainingEvents.length; if (remainingCount === 0) { container.remove(); return true; } if (remainingCount === 1) { const remainingEvent = remainingEvents[0] as HTMLElement; // Convert last event back to absolute positioning const remainingStartTime = remainingEvent.dataset.start; if (remainingStartTime) { const remainingStartDate = new Date(remainingStartTime); const gridSettings = calendarConfig.getGridSettings(); const remainingStartMinutes = remainingStartDate.getHours() * 60 + remainingStartDate.getMinutes(); const dayStartMinutes = gridSettings.dayStartHour * 60; const remainingTop = ((remainingStartMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight; remainingEvent.style.position = 'absolute'; remainingEvent.style.top = `${remainingTop + 1}px`; remainingEvent.style.left = '2px'; remainingEvent.style.right = '2px'; remainingEvent.style.flex = ''; remainingEvent.style.minWidth = ''; } container.parentElement?.insertBefore(remainingEvent, container); container.remove(); return true; } return false; } /** * Restack events in container - respects separate stack chains */ public restackEventsInContainer(container: HTMLElement): void { const stackedEvents = Array.from(container.querySelectorAll('swp-event')) .filter(el => this.isStackedEvent(el as HTMLElement)) as HTMLElement[]; if (stackedEvents.length === 0) return; // Group events by their stack chains const processedEventIds = new Set(); const stackChains: HTMLElement[][] = []; for (const element of stackedEvents) { const eventId = element.dataset.eventId; if (!eventId || processedEventIds.has(eventId)) continue; // Find the root of this stack chain (stackLevel 0 or no prev link) let rootElement = element; let rootLink = this.getStackLink(rootElement); while (rootLink?.prev) { const prevElement = this.findElementById(rootLink.prev); if (!prevElement) break; rootElement = prevElement; rootLink = this.getStackLink(rootElement); } // Collect all elements in this chain const chain: HTMLElement[] = []; let currentElement = rootElement; while (currentElement) { chain.push(currentElement); processedEventIds.add(currentElement.dataset.eventId!); const currentLink = this.getStackLink(currentElement); if (!currentLink?.next) break; const nextElement = this.findElementById(currentLink.next); if (!nextElement) break; currentElement = nextElement; } if (chain.length > 1) { // Only add chains with multiple events stackChains.push(chain); } } // Re-stack each chain separately stackChains.forEach(chain => { chain.forEach((element, index) => { const marginLeft = index * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX; element.style.marginLeft = `${marginLeft}px`; element.style.zIndex = `${100 + index}`; // Update the data-stack-link with correct stackLevel const link = this.getStackLink(element); if (link) { this.setStackLink(element, { ...link, stackLevel: index }); } }); }); } /** * Calculate position for group - simplified calculation */ private calculateGroupPosition(events: CalendarEvent[]): { top: number; height: number } { if (events.length === 0) return { top: 0, height: 0 }; const times = events.flatMap(e => [ new Date(e.start).getTime(), new Date(e.end).getTime() ]); const earliestStart = Math.min(...times); const latestEnd = Math.max(...times); const startDate = new Date(earliestStart); const endDate = new Date(latestEnd); const gridSettings = calendarConfig.getGridSettings(); const startMinutes = startDate.getHours() * 60 + startDate.getMinutes(); const endMinutes = endDate.getHours() * 60 + endDate.getMinutes(); const dayStartMinutes = gridSettings.dayStartHour * 60; const top = ((startMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight; const height = ((endMinutes - startMinutes) / 60) * gridSettings.hourHeight; return { top, height }; } /** * Utility methods - simple DOM traversal */ public getEventGroup(eventElement: HTMLElement): HTMLElement | null { return eventElement.closest('swp-event-group') as HTMLElement; } public isInEventGroup(element: HTMLElement): boolean { return this.getEventGroup(element) !== null; } /** * Helper methods for data-attribute based stack tracking */ public getStackLink(element: HTMLElement): StackLink | null { const linkData = element.dataset.stackLink; if (!linkData) return null; try { return JSON.parse(linkData); } catch (e) { console.warn('Failed to parse stack link data:', linkData, e); return null; } } private setStackLink(element: HTMLElement, link: StackLink | null): void { if (link === null) { delete element.dataset.stackLink; } else { element.dataset.stackLink = JSON.stringify(link); } } private findElementById(eventId: string): HTMLElement | null { return document.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement; } /** * Check if two elements overlap in pixel space */ private checkPixelOverlap(element1: HTMLElement, element2: HTMLElement): boolean { if (!element1 || !element2) return false; const top1 = parseFloat(element1.style.top) || 0; const height1 = parseFloat(element1.style.height) || 0; const bottom1 = top1 + height1; const top2 = parseFloat(element2.style.top) || 0; const height2 = parseFloat(element2.style.height) || 0; const bottom2 = top2 + height2; // Add tolerance for small gaps (borders, etc) const tolerance = 2; return !(bottom1 <= (top2 + tolerance) || bottom2 <= (top1 + tolerance)); } }