// Event rendering strategy interface and implementations import { CalendarEvent } from '../types/CalendarTypes'; import { calendarConfig } from '../core/CalendarConfig'; import { DateCalculator } from '../utils/DateCalculator'; import { eventBus } from '../core/EventBus'; import { CoreEvents } from '../constants/CoreEvents'; import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector'; import { SwpEventElement, SwpAllDayEventElement } from '../elements/SwpEventElement'; /** * Interface for event rendering strategies */ export interface EventRendererStrategy { renderEvents(events: CalendarEvent[], container: HTMLElement): void; clearEvents(container?: HTMLElement): void; } /** * Base class for event renderers with common functionality */ export abstract class BaseEventRenderer implements EventRendererStrategy { protected dateCalculator: DateCalculator; // Drag and drop state private draggedClone: HTMLElement | null = null; private originalEvent: HTMLElement | null = null; // Resize manager constructor(dateCalculator?: DateCalculator) { if (!dateCalculator) { DateCalculator.initialize(calendarConfig); } this.dateCalculator = dateCalculator || new DateCalculator(); } // ============================================ // NEW OVERLAP DETECTION SYSTEM // All new functions prefixed with new_ // ============================================ protected overlapDetector = new OverlapDetector(); /** * Ny hovedfunktion til at håndtere event overlaps * @param events - Events der skal renderes i kolonnen * @param container - Container element at rendere i */ protected new_handleEventOverlaps(events: CalendarEvent[], container: HTMLElement): void { if (events.length === 0) return; if (events.length === 1) { const element = this.renderEvent(events[0]); container.appendChild(element); return; } // Track hvilke events der allerede er blevet processeret const processedEvents = new Set(); // Gå gennem hvert event og find overlaps events.forEach((currentEvent, index) => { // Skip events der allerede er processeret som del af en overlap gruppe if (processedEvents.has(currentEvent.id)) { return; } const remainingEvents = events.slice(index + 1); const overlappingEvents = this.overlapDetector.resolveOverlap(currentEvent, remainingEvents); if (overlappingEvents.length > 0) { // Der er overlaps - opret stack links const result = this.overlapDetector.decorateWithStackLinks(currentEvent, overlappingEvents); this.new_renderOverlappingEvents(result, container); // Marker alle events i overlap gruppen som processeret overlappingEvents.forEach(event => processedEvents.add(event.id)); } else { // Intet overlap - render normalt const element = this.renderEvent(currentEvent); container.appendChild(element); processedEvents.add(currentEvent.id); } }); } /** * Setup listeners for drag events from DragDropManager */ protected setupDragEventListeners(): void { // Handle drag start eventBus.on('drag:start', (event) => { const { originalElement, eventId, mouseOffset, column } = (event as CustomEvent).detail; this.handleDragStart(originalElement, eventId, mouseOffset, column); }); // Handle drag move eventBus.on('drag:move', (event) => { const { eventId, snappedY, column, mouseOffset } = (event as CustomEvent).detail; this.handleDragMove(eventId, snappedY, column, mouseOffset); }); // Handle drag auto-scroll (when dragging near edges triggers scroll) eventBus.on('drag:auto-scroll', (event) => { const { eventId, snappedY } = (event as CustomEvent).detail; if (!this.draggedClone) return; // Update position directly using the calculated snapped position this.draggedClone.style.top = snappedY + 'px'; // Update timestamp display this.updateCloneTimestamp(this.draggedClone, snappedY); }); // Handle drag end eventBus.on('drag:end', (event) => { const { eventId, originalElement, finalColumn, finalY } = (event as CustomEvent).detail; this.handleDragEnd(eventId, originalElement, finalColumn, finalY); }); // Handle click (when drag threshold not reached) eventBus.on('event:click', (event) => { const { eventId, originalElement } = (event as CustomEvent).detail; this.handleEventClick(eventId, originalElement); }); // Handle column change eventBus.on('drag:column-change', (event) => { const { eventId, newColumn } = (event as CustomEvent).detail; this.handleColumnChange(eventId, newColumn); }); // Handle navigation period change (when slide animation completes) eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => { // Animate all-day height after navigation completes }); } /** * Cleanup method for proper resource management */ public destroy(): void { this.draggedClone = null; this.originalEvent = null; } /** * Get original event duration from data-duration attribute */ private getOriginalEventDuration(originalEvent: HTMLElement): number { // Find the swp-event-time element with data-duration attribute const timeElement = originalEvent.querySelector('swp-event-time'); if (timeElement) { const duration = timeElement.getAttribute('data-duration'); if (duration) { const durationMinutes = parseInt(duration); return durationMinutes; } } // Fallback to 60 minutes if attribute not found return 60; } /** * Apply common drag styling to an element */ private applyDragStyling(element: HTMLElement): void { element.style.position = 'absolute'; element.style.zIndex = '999999'; element.style.pointerEvents = 'none'; element.style.opacity = '0.8'; element.style.left = '2px'; element.style.right = '2px'; element.style.marginLeft = '0px'; element.style.width = ''; } /** * Create event inner structure (swp-event-time and swp-event-title) */ private createEventInnerStructure(event: CalendarEvent): string { const startTime = this.formatTime(event.start); const endTime = this.formatTime(event.end); const durationMinutes = (event.end.getTime() - event.start.getTime()) / (1000 * 60); return ` ${startTime} - ${endTime} ${event.title} `; } /** * Apply standard event positioning */ private applyEventPositioning(element: HTMLElement, top: number, height: number): void { element.style.position = 'absolute'; element.style.top = `${top}px`; element.style.height = `${height}px`; element.style.left = '2px'; element.style.right = '2px'; } /** * Create a clone of an event for dragging */ private createEventClone(originalEvent: HTMLElement): HTMLElement { const clone = originalEvent.cloneNode(true) as HTMLElement; // Prefix ID with "clone-" const originalId = originalEvent.dataset.eventId; if (originalId) { clone.dataset.eventId = `clone-${originalId}`; } // Get and cache original duration from data-duration attribute const originalDurationMinutes = this.getOriginalEventDuration(originalEvent); clone.dataset.originalDuration = originalDurationMinutes.toString(); // Apply common drag styling this.applyDragStyling(clone); // Set height from original event clone.style.height = originalEvent.style.height || `${originalEvent.getBoundingClientRect().height}px`; return clone; } /** * Update clone timestamp based on new position */ private updateCloneTimestamp(clone: HTMLElement, snappedY: number): void { //important as events can pile up, so they will still fire after event has been converted to another rendered type if(clone.dataset.allDay == "true") return; const gridSettings = calendarConfig.getGridSettings(); const hourHeight = gridSettings.hourHeight; const dayStartHour = gridSettings.dayStartHour; const snapInterval = gridSettings.snapInterval; // Calculate minutes from grid start (not from midnight) const minutesFromGridStart = (snappedY / hourHeight) * 60; // Add dayStartHour offset to get actual time const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart; // Snap to interval const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval; // Use cached original duration (no recalculation) const cachedDuration = parseInt(clone.dataset.originalDuration || '60'); const endTotalMinutes = snappedStartMinutes + cachedDuration; // Update dataset with reference date for performance const referenceDate = new Date('1970-01-01T00:00:00'); const startDate = new Date(referenceDate); startDate.setMinutes(startDate.getMinutes() + snappedStartMinutes); const endDate = new Date(referenceDate); endDate.setMinutes(endDate.getMinutes() + endTotalMinutes); clone.dataset.start = startDate.toISOString(); clone.dataset.end = endDate.toISOString(); // Update display const timeElement = clone.querySelector('swp-event-time'); if (timeElement) { const newTimeText = `${this.formatTime(snappedStartMinutes)} - ${this.formatTime(endTotalMinutes)}`; timeElement.textContent = newTimeText; } } /** * Unified time formatting method - handles both total minutes and Date objects */ private formatTime(input: number | Date | string): string { let hours: number, minutes: number; if (typeof input === 'number') { // Total minutes input hours = Math.floor(input / 60) % 24; minutes = input % 60; } else { // Date or ISO string input const date = typeof input === 'string' ? new Date(input) : input; hours = date.getHours(); minutes = date.getMinutes(); } const period = hours >= 12 ? 'PM' : 'AM'; const displayHours = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours); return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`; } /** * Handle drag start event */ private handleDragStart(originalElement: HTMLElement, eventId: string, mouseOffset: any, column: string): void { this.originalEvent = originalElement; // Remove stacking styling during drag will be handled by new system // Create clone this.draggedClone = this.createEventClone(originalElement); // Add to current column's events layer (not directly to column) const columnElement = document.querySelector(`swp-day-column[data-date="${column}"]`); if (columnElement) { const eventsLayer = columnElement.querySelector('swp-events-layer'); if (eventsLayer) { eventsLayer.appendChild(this.draggedClone); } else { // Fallback to column if events layer not found columnElement.appendChild(this.draggedClone); } } // Make original semi-transparent originalElement.style.opacity = '0.3'; originalElement.style.userSelect = 'none'; } /** * Handle drag move event */ private handleDragMove(eventId: string, snappedY: number, column: string, mouseOffset: any): void { if (!this.draggedClone) return; // Update position this.draggedClone.style.top = snappedY + 'px'; // Update timestamp display this.updateCloneTimestamp(this.draggedClone, snappedY); } /** * Handle column change during drag */ private handleColumnChange(eventId: string, newColumn: string): void { if (!this.draggedClone) return; // Move clone to new column's events layer const newColumnElement = document.querySelector(`swp-day-column[data-date="${newColumn}"]`); if (newColumnElement) { const eventsLayer = newColumnElement.querySelector('swp-events-layer'); if (eventsLayer && this.draggedClone.parentElement !== eventsLayer) { eventsLayer.appendChild(this.draggedClone); } else if (!eventsLayer && this.draggedClone.parentElement !== newColumnElement) { // Fallback to column if events layer not found newColumnElement.appendChild(this.draggedClone); } } } /** * Handle drag end event */ private handleDragEnd(eventId: string, originalElement: HTMLElement, finalColumn: string, finalY: number): void { if (!this.draggedClone || !this.originalEvent) { console.warn('Missing draggedClone or originalEvent'); return; } // Check om original event var del af en stack const originalStackLink = this.originalEvent.dataset.stackLink; if (originalStackLink) { try { const stackData = JSON.parse(originalStackLink); // Saml ALLE event IDs fra hele stack chain const allStackEventIds: Set = new Set(); // Recursive funktion til at traversere stack chain const traverseStack = (linkData: any, visitedIds: Set) => { if (linkData.prev && !visitedIds.has(linkData.prev)) { visitedIds.add(linkData.prev); const prevElement = document.querySelector(`swp-time-grid [data-event-id="${linkData.prev}"]`) as HTMLElement; if (prevElement?.dataset.stackLink) { try { const prevLinkData = JSON.parse(prevElement.dataset.stackLink); traverseStack(prevLinkData, visitedIds); } catch (e) {} } } if (linkData.next && !visitedIds.has(linkData.next)) { visitedIds.add(linkData.next); const nextElement = document.querySelector(`swp-time-grid [data-event-id="${linkData.next}"]`) as HTMLElement; if (nextElement?.dataset.stackLink) { try { const nextLinkData = JSON.parse(nextElement.dataset.stackLink); traverseStack(nextLinkData, visitedIds); } catch (e) {} } } }; // Start traversering fra original event's stackLink traverseStack(stackData, allStackEventIds); // Fjern original eventId da det bliver flyttet allStackEventIds.delete(eventId); // Find alle stack events og fjern dem const stackEvents: CalendarEvent[] = []; let container: HTMLElement | null = null; allStackEventIds.forEach(id => { const element = document.querySelector(`swp-time-grid [data-event-id="${id}"]`) as HTMLElement; if (element) { // Gem container reference fra første element if (!container) { container = element.closest('swp-events-layer') as HTMLElement; } const event = this.elementToCalendarEvent(element); if (event) { stackEvents.push(event); } // Fjern elementet element.remove(); } }); // Re-render stack events hvis vi fandt nogle if (stackEvents.length > 0 && container) { this.new_handleEventOverlaps(stackEvents, container); } } catch (e) { console.warn('Failed to parse stackLink data:', e); } } // Remove original event from any existing groups first this.removeEventFromExistingGroups(this.originalEvent); // Fade out original this.fadeOutAndRemove(this.originalEvent); // Remove clone prefix and normalize clone to be a regular event const cloneId = this.draggedClone.dataset.eventId; if (cloneId && cloneId.startsWith('clone-')) { this.draggedClone.dataset.eventId = cloneId.replace('clone-', ''); } // Fully normalize the clone to be a regular event this.draggedClone.style.pointerEvents = ''; this.draggedClone.style.opacity = ''; this.draggedClone.style.userSelect = ''; // Behold z-index hvis det er et stacked event // Update dataset with new times after successful drop (only for timed events) if (this.draggedClone.dataset.displayType !== 'allday') { const newEvent = this.elementToCalendarEvent(this.draggedClone); if (newEvent) { this.draggedClone.dataset.start = newEvent.start.toISOString(); this.draggedClone.dataset.end = newEvent.end.toISOString(); } } // Detect overlaps with other events in the target column and reposition if needed this.handleDragDropOverlaps(this.draggedClone, finalColumn); // Fjern stackLink data fra dropped element if (this.draggedClone.dataset.stackLink) { delete this.draggedClone.dataset.stackLink; } // Clean up this.draggedClone = null; this.originalEvent = null; } /** * Handle event click (when drag threshold not reached) */ private handleEventClick(eventId: string, originalElement: HTMLElement): void { console.log('handleEventClick:', eventId); // Clean up any drag artifacts from failed drag attempt if (this.draggedClone) { this.draggedClone.remove(); this.draggedClone = null; } // Restore original element styling if it was modified if (this.originalEvent) { this.originalEvent.style.opacity = ''; this.originalEvent.style.userSelect = ''; this.originalEvent = null; } // Emit a clean click event for other components to handle eventBus.emit('event:clicked', { eventId: eventId, element: originalElement }); } /** * Handle overlap detection and re-rendering after drag-drop */ private handleDragDropOverlaps(droppedElement: HTMLElement, targetColumn: string): void { const targetColumnElement = document.querySelector(`swp-day-column[data-date="${targetColumn}"]`); if (!targetColumnElement) return; const eventsLayer = targetColumnElement.querySelector('swp-events-layer') as HTMLElement; if (!eventsLayer) return; // Convert dropped element to CalendarEvent with new position const droppedEvent = this.elementToCalendarEvent(droppedElement); if (!droppedEvent) return; // Get existing events in the column (excluding the dropped element) const existingEvents = this.getEventsInColumn(eventsLayer, droppedElement.dataset.eventId); // Find overlaps with the dropped event const overlappingEvents = this.overlapDetector.resolveOverlap(droppedEvent, existingEvents); if (overlappingEvents.length > 0) { // Remove only affected events from DOM const affectedEventIds = [droppedEvent.id, ...overlappingEvents.map(e => e.id)]; eventsLayer.querySelectorAll('swp-event').forEach(el => { const eventId = (el as HTMLElement).dataset.eventId; if (eventId && affectedEventIds.includes(eventId)) { el.remove(); } }); // Re-render affected events with overlap handling const affectedEvents = [droppedEvent, ...overlappingEvents]; this.new_handleEventOverlaps(affectedEvents, eventsLayer); } else { // Reset z-index for non-overlapping events droppedElement.style.zIndex = ''; } } /** * Get all events in a column as CalendarEvent objects */ private getEventsInColumn(eventsLayer: HTMLElement, excludeEventId?: string): CalendarEvent[] { const eventElements = eventsLayer.querySelectorAll('swp-event'); const events: CalendarEvent[] = []; eventElements.forEach(el => { const element = el as HTMLElement; const eventId = element.dataset.eventId; // Skip the excluded event (e.g., the dropped event) if (excludeEventId && eventId === excludeEventId) { return; } const event = this.elementToCalendarEvent(element); if (event) { events.push(event); } }); return events; } /** * Remove event from any existing groups and cleanup empty containers * In the new system, this is handled automatically by re-rendering overlaps */ private removeEventFromExistingGroups(eventElement: HTMLElement): void { // With the new system, overlap relationships are recalculated on drop // No need to manually track and remove from groups } /** * Update element's dataset with new times after successful drop */ private updateElementDataset(element: HTMLElement, event: CalendarEvent): void { element.dataset.start = event.start.toISOString(); element.dataset.end = event.end.toISOString(); // Update the time display const timeElement = element.querySelector('swp-event-time'); if (timeElement) { const startTime = this.formatTime(event.start); const endTime = this.formatTime(event.end); timeElement.textContent = `${startTime} - ${endTime}`; } } /** * Convert DOM element to CalendarEvent - handles both normal and 1970 reference dates */ private elementToCalendarEvent(element: HTMLElement): CalendarEvent | null { const eventId = element.dataset.eventId; const title = element.dataset.title; const type = element.dataset.type; const start = element.dataset.start; const end = element.dataset.end; if (!eventId || !title || !type || !start || !end) { return null; } let startDate = new Date(start); let endDate = new Date(end); // Check if we have 1970 reference date (from drag operations) if (startDate.getFullYear() === 1970) { // Find the parent column to get the actual date const columnElement = element.closest('swp-day-column') as HTMLElement; if (columnElement && columnElement.dataset.date) { const columnDate = new Date(columnElement.dataset.date); // Keep the time portion from the 1970 dates, but use the column's date startDate = new Date( columnDate.getFullYear(), columnDate.getMonth(), columnDate.getDate(), startDate.getHours(), startDate.getMinutes() ); endDate = new Date( columnDate.getFullYear(), columnDate.getMonth(), columnDate.getDate(), endDate.getHours(), endDate.getMinutes() ); } } return { id: eventId, title: title, start: startDate, end: endDate, type: type, allDay: false, syncStatus: 'synced', metadata: { duration: element.dataset.duration } }; } /** * Handle conversion to all-day event */ /** * Fade out and remove element */ private fadeOutAndRemove(element: HTMLElement): void { element.style.transition = 'opacity 0.3s ease-out'; element.style.opacity = '0'; setTimeout(() => { element.remove(); }, 300); } renderEvents(events: CalendarEvent[], container: HTMLElement): void { // NOTE: Removed clearEvents() to support sliding animation // With sliding animation, multiple grid containers exist simultaneously // clearEvents() would remove events from all containers, breaking the animation // Events are now rendered directly into the new container without clearing // Only handle regular (non-all-day) events // Find columns in the specific container for regular events const columns = this.getColumns(container); columns.forEach(column => { const columnEvents = this.getEventsForColumn(column, events); const eventsLayer = column.querySelector('swp-events-layer'); if (eventsLayer) { // NY TILGANG: Kald vores nye overlap handling this.new_handleEventOverlaps(columnEvents, eventsLayer as HTMLElement); } }); } // Abstract methods that subclasses must implement protected abstract getColumns(container: HTMLElement): HTMLElement[]; protected abstract getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[]; protected renderEvent(event: CalendarEvent): HTMLElement { const swpEvent = SwpEventElement.fromCalendarEvent(event); const eventElement = swpEvent.getElement(); // Setup resize handles on first mouseover only eventElement.addEventListener('mouseover', () => { if (eventElement.dataset.hasResizeHandlers !== 'true') { eventElement.dataset.hasResizeHandlers = 'true'; } }, { once: true }); return eventElement; } protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } { const gridSettings = calendarConfig.getGridSettings(); const dayStartHour = gridSettings.dayStartHour; const hourHeight = gridSettings.hourHeight; // Calculate minutes from midnight const startMinutes = event.start.getHours() * 60 + event.start.getMinutes(); const endMinutes = event.end.getHours() * 60 + event.end.getMinutes(); const dayStartMinutes = dayStartHour * 60; // Calculate top position relative to visible grid start // If dayStartHour=6 and event starts at 09:00 (540 min), then: // top = ((540 - 360) / 60) * hourHeight = 3 * hourHeight (3 hours from grid start) const top = ((startMinutes - dayStartMinutes) / 60) * hourHeight; // Calculate height based on event duration const durationMinutes = endMinutes - startMinutes; const height = (durationMinutes / 60) * hourHeight; return { top, height }; } clearEvents(container?: HTMLElement): void { const selector = 'swp-event, swp-event-group'; const existingEvents = container ? container.querySelectorAll(selector) : document.querySelectorAll(selector); existingEvents.forEach(event => event.remove()); } /** * Renderer overlappende events baseret på OverlapResult * @param result - OverlapResult med events og stack links * @param container - Container at rendere i */ protected new_renderOverlappingEvents(result: OverlapResult, container: HTMLElement): void { // Iterate direkte gennem stackLinks - allerede sorteret fra decorateWithStackLinks for (const [eventId, stackLink] of result.stackLinks.entries()) { const event = result.overlappingEvents.find(e => e.id === eventId); if (!event) continue; const element = this.renderEvent(event); // Gem stack link information på DOM elementet element.dataset.stackLink = JSON.stringify({ prev: stackLink.prev, next: stackLink.next, stackLevel: stackLink.stackLevel }); // Check om dette event deler kolonne med foregående (samme start tid) if (stackLink.prev) { const prevEvent = result.overlappingEvents.find(e => e.id === stackLink.prev); if (prevEvent && prevEvent.start.getTime() === event.start.getTime()) { // Samme start tid - del kolonne (side by side) this.new_applyColumnSharingStyling([element]); } else { // Forskellige start tider - stack vertikalt this.new_applyStackStyling(element, stackLink.stackLevel); } } else { // Første event i stack this.new_applyStackStyling(element, stackLink.stackLevel); } container.appendChild(element); } } /** * Applicerer stack styling (margin-left og z-index) * @param element - Event element * @param stackLevel - Stack niveau */ protected new_applyStackStyling(element: HTMLElement, stackLevel: number): void { element.style.marginLeft = `${stackLevel * 15}px`; element.style.zIndex = `${100 + stackLevel}`; } /** * Applicerer column sharing styling (flexbox) * @param elements - Event elements der skal dele plads */ protected new_applyColumnSharingStyling(elements: HTMLElement[]): void { elements.forEach(element => { element.style.flex = '1'; element.style.minWidth = '50px'; }); } } /** * Date-based event renderer */ export class DateEventRenderer extends BaseEventRenderer { constructor(dateCalculator?: DateCalculator) { super(dateCalculator); this.setupDragEventListeners(); } protected getColumns(container: HTMLElement): HTMLElement[] { const columns = container.querySelectorAll('swp-day-column'); return Array.from(columns) as HTMLElement[]; } protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] { const columnDate = column.dataset.date; if (!columnDate) { return []; } const columnEvents = events.filter(event => { const eventDateStr = DateCalculator.formatISODate(event.start); const matches = eventDateStr === columnDate; return matches; }); return columnEvents; } } /** * Resource-based event renderer */ export class ResourceEventRenderer extends BaseEventRenderer { protected getColumns(container: HTMLElement): HTMLElement[] { const columns = container.querySelectorAll('swp-resource-column'); return Array.from(columns) as HTMLElement[]; } protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] { const resourceName = column.dataset.resource; if (!resourceName) return []; const columnEvents = events.filter(event => { return event.resource?.name === resourceName; }); return columnEvents; } // ============================================ // NEW OVERLAP DETECTION SYSTEM // All new functions prefixed with new_ // ============================================ protected overlapDetector = new OverlapDetector(); }