// Event rendering strategy interface and implementations import { CalendarEvent } from '../types/CalendarTypes'; import { calendarConfig } from '../core/CalendarConfig'; import { eventBus } from '../core/EventBus'; import { OverlapDetector, OverlapResult } from '../utils/OverlapDetector'; import { SwpEventElement } from '../elements/SwpEventElement'; import { TimeFormatter } from '../utils/TimeFormatter'; import { PositionUtils } from '../utils/PositionUtils'; import { DragOffset, StackLinkData } from '../types/DragDropTypes'; import { ColumnBounds } from '../utils/ColumnDetectionUtils'; import { DragColumnChangeEventPayload, DragMoveEventPayload, DragStartEventPayload } from '../types/EventTypes'; import { DateService } from '../utils/DateService'; import { format } from 'date-fns'; /** * Interface for event rendering strategies */ export interface EventRendererStrategy { renderEvents(events: CalendarEvent[], container: HTMLElement): void; clearEvents(container?: HTMLElement): void; handleDragStart?(payload: DragStartEventPayload): void; handleDragMove?(payload: DragMoveEventPayload): void; handleDragAutoScroll?(eventId: string, snappedY: number): void; handleDragEnd?(eventId: string, originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: ColumnBounds, finalY: number): void; handleEventClick?(eventId: string, originalElement: HTMLElement): void; handleColumnChange?(payload: DragColumnChangeEventPayload): void; handleNavigationCompleted?(): void; } // Abstract methods that subclasses must implement // private getColumns(container: HTMLElement): HTMLElement[]; // private getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[]; /** * Date-based event renderer */ export class DateEventRenderer implements EventRendererStrategy { private dateService: DateService; constructor() { const timezone = calendarConfig.getTimezone?.(); this.dateService = new DateService(timezone); this.setupDragEventListeners(); } private draggedClone: HTMLElement | null = null; private originalEvent: HTMLElement | null = null; // ============================================ // 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 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.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); } }); } private applyDragStyling(element: HTMLElement): void { element.classList.add('dragging'); element.style.removeProperty("margin-left"); } /** * Update clone timestamp based on new position */ private updateCloneTimestamp(payload: DragMoveEventPayload): void { if (payload.draggedClone.dataset.allDay === "true" || !payload.columnBounds) return; const gridSettings = calendarConfig.getGridSettings(); const { hourHeight, dayStartHour, snapInterval } = gridSettings; if (!payload.draggedClone.dataset.originalDuration) { throw new DOMException("missing clone.dataset.originalDuration"); } // Calculate snapped start minutes const minutesFromGridStart = (payload.snappedY / hourHeight) * 60; const snappedStartMinutes = this.calculateSnappedMinutes( minutesFromGridStart, dayStartHour, snapInterval ); // Calculate end minutes const originalDuration = parseInt(payload.draggedClone.dataset.originalDuration); const endTotalMinutes = snappedStartMinutes + originalDuration; // Update UI this.updateTimeDisplay(payload.draggedClone, snappedStartMinutes, endTotalMinutes); // Update data attributes this.updateDateTimeAttributes( payload.draggedClone, new Date(payload.columnBounds.date), snappedStartMinutes, endTotalMinutes ); } /** * Calculate snapped minutes from grid start */ private calculateSnappedMinutes(minutesFromGridStart: number, dayStartHour: number, snapInterval: number): number { const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart; return Math.round(actualStartMinutes / snapInterval) * snapInterval; } /** * Update time display in the UI */ private updateTimeDisplay(element: HTMLElement, startMinutes: number, endMinutes: number): void { const timeElement = element.querySelector('swp-event-time'); if (!timeElement) return; const startTime = this.formatTimeFromMinutes(startMinutes); const endTime = this.formatTimeFromMinutes(endMinutes); timeElement.textContent = `${startTime} - ${endTime}`; } /** * Update data-start and data-end attributes with ISO timestamps */ private updateDateTimeAttributes(element: HTMLElement, columnDate: Date, startMinutes: number, endMinutes: number): void { const startDate = this.dateService.createDateAtTime(columnDate, startMinutes); let endDate = this.dateService.createDateAtTime(columnDate, endMinutes); // Handle cross-midnight events if (endMinutes >= 1440) { const extraDays = Math.floor(endMinutes / 1440); endDate = this.dateService.addDays(endDate, extraDays); } // Convert to UTC before storing as ISO string element.dataset.start = this.dateService.toUTC(startDate); element.dataset.end = this.dateService.toUTC(endDate); } /** * Format minutes since midnight to time string */ private formatTimeFromMinutes(totalMinutes: number): string { return this.dateService.minutesToTime(totalMinutes); } /** * Handle drag start event */ public handleDragStart(payload: DragStartEventPayload): void { this.originalEvent = payload.draggedElement;; // Use the clone from the payload instead of creating a new one this.draggedClone = payload.draggedClone; if (this.draggedClone) { // Apply drag styling this.applyDragStyling(this.draggedClone); // Add to current column's events layer (not directly to column) const eventsLayer = payload.columnBounds?.element.querySelector('swp-events-layer'); if (eventsLayer) { eventsLayer.appendChild(this.draggedClone); } } // Make original semi-transparent this.originalEvent.style.opacity = '0.3'; this.originalEvent.style.userSelect = 'none'; } /** * Handle drag move event */ public handleDragMove(payload: DragMoveEventPayload): void { if (!this.draggedClone) return; // Update position - snappedY is already the event top position // Add +1px to match the initial positioning offset from SwpEventElement this.draggedClone.style.top = (payload.snappedY + 1) + 'px'; // Update timestamp display this.updateCloneTimestamp(payload); } /** * Handle drag auto-scroll event */ public handleDragAutoScroll(eventId: string, snappedY: number): void { 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); //TODO: Commented as, we need to move all this scroll logic til scroll manager away from eventrenderer } /** * Handle column change during drag */ public handleColumnChange(dragColumnChangeEvent: DragColumnChangeEventPayload): void { if (!this.draggedClone) return; const eventsLayer = dragColumnChangeEvent.newColumn.element.querySelector('swp-events-layer'); if (eventsLayer && this.draggedClone.parentElement !== eventsLayer) { eventsLayer.appendChild(this.draggedClone); // Recalculate timestamps with new column date const currentTop = parseFloat(this.draggedClone.style.top) || 0; const mockPayload: DragMoveEventPayload = { draggedElement: dragColumnChangeEvent.originalElement, draggedClone: this.draggedClone, mousePosition: dragColumnChangeEvent.mousePosition, mouseOffset: { x: 0, y: 0 }, columnBounds: dragColumnChangeEvent.newColumn, snappedY: currentTop }; this.updateCloneTimestamp(mockPayload); } } /** * Handle drag end event */ public handleDragEnd(eventId: string, originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: ColumnBounds, finalY: number): void { if (!draggedClone || !originalElement) { console.warn('Missing draggedClone or originalElement'); return; } // Check om original event var del af en stack const originalStackLink = originalElement.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: StackLinkData, 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 = SwpEventElement.extractCalendarEventFromElement(element); if (event) { stackEvents.push(event); } // Fjern elementet element.remove(); } }); // Re-render stack events hvis vi fandt nogle if (stackEvents.length > 0 && container) { this.handleEventOverlaps(stackEvents, container); } } catch (e) { console.warn('Failed to parse stackLink data:', e); } } // Remove original event from any existing groups first this.removeEventFromExistingGroups(originalElement); // Fade out original // TODO: this should be changed into a subscriber which only after a succesful placement is fired, not just mouseup as this can remove elements that are not placed. this.fadeOutAndRemove(originalElement); // Remove clone prefix and normalize clone to be a regular event const cloneId = draggedClone.dataset.eventId; if (cloneId && cloneId.startsWith('clone-')) { draggedClone.dataset.eventId = cloneId.replace('clone-', ''); } // Fully normalize the clone to be a regular event draggedClone.classList.remove('dragging'); // Behold z-index hvis det er et stacked event // Data attributes are already updated during drag:move, so no need to update again // The updateCloneTimestamp method keeps them synchronized throughout the drag operation // Detect overlaps with other events in the target column and reposition if needed this.handleDragDropOverlaps(draggedClone, finalColumn); // Fjern stackLink data fra dropped element if (draggedClone.dataset.stackLink) { delete draggedClone.dataset.stackLink; } // Clean up instance state (no longer needed since we get elements as parameters) this.draggedClone = null; this.originalEvent = null; } /** * Handle navigation completed event */ public handleNavigationCompleted(): void { // Default implementation - can be overridden by subclasses } /** * Handle overlap detection and re-rendering after drag-drop */ private handleDragDropOverlaps(droppedElement: HTMLElement, targetColumn: ColumnBounds): void { const eventsLayer = targetColumn.element.querySelector('swp-events-layer') as HTMLElement; if (!eventsLayer) return; // Convert dropped element to CalendarEvent with new position const droppedEvent = SwpEventElement.extractCalendarEventFromElement(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.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 = SwpEventElement.extractCalendarEventFromElement(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 } /** * 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 { // Filter out all-day events - they should be handled by AllDayEventRenderer const timedEvents = events.filter(event => !event.allDay); console.log('🎯 EventRenderer: Filtering events', { totalEvents: events.length, timedEvents: timedEvents.length, filteredOutAllDay: events.length - timedEvents.length }); // Find columns in the specific container for regular events const columns = this.getColumns(container); columns.forEach(column => { const columnEvents = this.getEventsForColumn(column, timedEvents); const eventsLayer = column.querySelector('swp-events-layer'); if (eventsLayer) { this.handleEventOverlaps(columnEvents, eventsLayer as HTMLElement); } }); } private renderEvent(event: CalendarEvent): HTMLElement { const swpEvent = SwpEventElement.fromCalendarEvent(event); const eventElement = swpEvent.getElement(); // Setup resize handles on first mouseover only eventElement.addEventListener('mouseover', () => { // TODO: This is not the correct way... we should not add eventlistener on every event if (eventElement.dataset.hasResizeHandlers !== 'true') { eventElement.dataset.hasResizeHandlers = 'true'; } }, { once: true }); return eventElement; } protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } { // Delegate to PositionUtils for centralized position calculation return PositionUtils.calculateEventPosition(event.start, event.end); } 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 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'; }); } /** * Setup drag event listeners - placeholder method */ private setupDragEventListeners(): void { // Drag event listeners are handled by EventRendererManager // This method exists for compatibility } 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 = this.dateService.formatISODate(event.start); const matches = eventDateStr === columnDate; return matches; }); return columnEvents; } }