// 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'; /** * Resize state interface */ interface ResizeState { element: HTMLElement; handle: 'top' | 'bottom'; startY: number; originalTop: number; originalHeight: number; originalStartTime: Date; originalEndTime: Date; minHeightPx: number; } /** * 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 state private resizeState: ResizeState | null = null; private readonly MIN_EVENT_DURATION_MINUTES = 30; 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 convert to all-day eventBus.on('drag:convert-to-allday', (event) => { const { eventId, targetDate, headerRenderer } = (event as CustomEvent).detail; this.handleConvertToAllDay(eventId, targetDate, headerRenderer); }); // Handle navigation period change (when slide animation completes) eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => { // Animate all-day height after navigation completes this.triggerAllDayHeightAnimation(); }); } /** * Trigger all-day height animation without creating new renderer instance */ private triggerAllDayHeightAnimation(): void { import('./HeaderRenderer').then(({ DateHeaderRenderer }) => { const headerRenderer = new DateHeaderRenderer(); headerRenderer.checkAndAnimateAllDayHeight(); }); } /** * 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; } /** * 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(); // Style for dragging clone.style.position = 'absolute'; clone.style.zIndex = '999999'; clone.style.pointerEvents = 'none'; clone.style.opacity = '0.8'; // Dragged event skal have fuld kolonne bredde clone.style.left = '2px'; clone.style.right = '2px'; clone.style.marginLeft = '0px'; clone.style.width = ''; 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 { 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 display const timeElement = clone.querySelector('swp-event-time'); if (timeElement) { const newTimeText = `${this.formatTime(snappedStartMinutes)} - ${this.formatTime(endTotalMinutes)}`; timeElement.textContent = newTimeText; } } /** * Calculate event duration in minutes from element height */ private getEventDuration(element: HTMLElement): number { const gridSettings = calendarConfig.getGridSettings(); const hourHeight = gridSettings.hourHeight; // Get height from style or computed let heightPx = parseInt(element.style.height) || 0; if (!heightPx) { const rect = element.getBoundingClientRect(); heightPx = rect.height; } return Math.round((heightPx / hourHeight) * 60); } /** * 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 { console.log('handleDragStart:', eventId); 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 { console.log('handleDragEnd:', eventId); if (!this.draggedClone || !this.originalEvent) { console.log('Missing draggedClone or originalEvent'); return; } // 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 const newEvent = this.elementToCalendarEventWithNewPosition(this.draggedClone, finalColumn); 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); // 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 event double-click for text selection */ private handleEventDoubleClick(eventElement: HTMLElement): void { console.log('handleEventDoubleClick:', eventElement.dataset.eventId); // Enable text selection temporarily eventElement.classList.add('text-selectable'); // Auto-select the event text const selection = window.getSelection(); if (selection) { const range = document.createRange(); range.selectNodeContents(eventElement); selection.removeAllRanges(); selection.addRange(range); } // Remove text selection mode when clicking outside const removeSelectable = (e: Event) => { // Don't remove if clicking within the same event if (e.target && eventElement.contains(e.target as Node)) { return; } eventElement.classList.remove('text-selectable'); document.removeEventListener('click', removeSelectable); // Clear selection if (selection) { selection.removeAllRanges(); } }; // Add click outside listener after a short delay setTimeout(() => { document.addEventListener('click', removeSelectable); }, 100); } /** * 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.elementToCalendarEventWithNewPosition(droppedElement, targetColumn); 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 } /** * Restore normal event styling (full column width) */ private restoreNormalEventStyling(eventElement: HTMLElement): void { eventElement.style.position = 'absolute'; eventElement.style.left = '2px'; eventElement.style.right = '2px'; eventElement.style.width = ''; // Behold z-index for stacked events } /** * 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 using its NEW position after drag */ private elementToCalendarEventWithNewPosition(element: HTMLElement, targetColumn: string): CalendarEvent | null { const eventId = element.dataset.eventId; const title = element.dataset.title; const type = element.dataset.type; const originalDuration = element.dataset.originalDuration; if (!eventId || !title || !type) { return null; } // Calculate new start/end times based on current position const currentTop = parseInt(element.style.top) || 0; const durationMinutes = originalDuration ? parseInt(originalDuration) : 60; // Convert position to time const gridSettings = calendarConfig.getGridSettings(); const hourHeight = gridSettings.hourHeight; const dayStartHour = gridSettings.dayStartHour; // Calculate minutes from grid start const minutesFromGridStart = (currentTop / hourHeight) * 60; const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart; const actualEndMinutes = actualStartMinutes + durationMinutes; // Create ISO date strings for the target column const targetDate = new Date(targetColumn + 'T00:00:00'); const startDate = new Date(targetDate); startDate.setMinutes(startDate.getMinutes() + actualStartMinutes); const endDate = new Date(targetDate); endDate.setMinutes(endDate.getMinutes() + actualEndMinutes); return { id: eventId, title: title, start: startDate, end: endDate, type: type, allDay: false, syncStatus: 'synced', metadata: { duration: durationMinutes } }; } /** * Convert DOM element to CalendarEvent for overlap detection */ private elementToCalendarEvent(element: HTMLElement): CalendarEvent | null { const eventId = element.dataset.eventId; const title = element.dataset.title; const start = element.dataset.start; const end = element.dataset.end; const type = element.dataset.type; const duration = element.dataset.duration; if (!eventId || !title || !start || !end || !type) { return null; } return { id: eventId, title: title, start: new Date(start), end: new Date(end), type: type, allDay: false, syncStatus: 'synced', // Default to synced for existing events metadata: { duration: duration ? parseInt(duration) : 60 } }; } /** * Handle conversion to all-day event */ private handleConvertToAllDay(eventId: string, targetDate: string, headerRenderer: any): void { if (!this.draggedClone) return; // Only convert once if (this.draggedClone.tagName === 'SWP-ALLDAY-EVENT') return; // Transform clone to all-day format this.transformCloneToAllDay(this.draggedClone, targetDate); // Expand header if needed headerRenderer.addToAllDay(this.draggedClone.parentElement); } /** * Transform clone from timed to all-day event */ private transformCloneToAllDay(clone: HTMLElement, targetDate: string): void { const calendarHeader = document.querySelector('swp-calendar-header'); if (!calendarHeader) return; // Find all-day container const allDayContainer = calendarHeader.querySelector('swp-allday-container'); if (!allDayContainer) return; // Extract all original event data const titleElement = clone.querySelector('swp-event-title'); const eventTitle = titleElement ? titleElement.textContent || 'Untitled' : 'Untitled'; const timeElement = clone.querySelector('swp-event-time'); const eventTime = timeElement ? timeElement.textContent || '' : ''; const eventDuration = timeElement ? timeElement.getAttribute('data-duration') || '' : ''; // Calculate column index const dayHeaders = document.querySelectorAll('swp-day-header'); let columnIndex = 1; dayHeaders.forEach((header, index) => { if ((header as HTMLElement).dataset.date === targetDate) { columnIndex = index + 1; } }); // Create all-day event with standardized data attributes const allDayEvent = document.createElement('swp-allday-event'); allDayEvent.dataset.eventId = clone.dataset.eventId || ''; allDayEvent.dataset.title = eventTitle; allDayEvent.dataset.start = `${targetDate}T${eventTime.split(' - ')[0]}:00`; allDayEvent.dataset.end = `${targetDate}T${eventTime.split(' - ')[1]}:00`; allDayEvent.dataset.type = clone.dataset.type || 'work'; allDayEvent.dataset.duration = eventDuration; allDayEvent.textContent = eventTitle; // Position in grid (allDayEvent as HTMLElement).style.gridColumn = columnIndex.toString(); // grid-row will be set by checkAndAnimateAllDayHeight() based on actual position // Remove original clone if (clone.parentElement) { clone.parentElement.removeChild(clone); } // Add to all-day container allDayContainer.appendChild(allDayEvent); // Update reference this.draggedClone = allDayEvent; // Check if height animation is needed this.triggerAllDayHeightAnimation(); } /** * 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); } /** * Convert dragged clone to all-day event preview */ private convertToAllDayPreview(targetDate: string): void { if (!this.draggedClone) return; // Only convert once if (this.draggedClone.tagName === 'SWP-ALLDAY-EVENT') { return; } // Transform clone to all-day format this.transformCloneToAllDay(this.draggedClone, targetDate); } /** * Move all-day event to a new date container */ private moveAllDayToNewDate(targetDate: string): void { if (!this.draggedClone) return; const calendarHeader = document.querySelector('swp-calendar-header'); if (!calendarHeader) return; // Find the all-day container const allDayContainer = calendarHeader.querySelector('swp-allday-container'); if (!allDayContainer) return; // Calculate new column index const dayHeaders = document.querySelectorAll('swp-day-header'); let columnIndex = 1; dayHeaders.forEach((header, index) => { if ((header as HTMLElement).dataset.date === targetDate) { columnIndex = index + 1; } }); // Update grid column position (this.draggedClone as HTMLElement).style.gridColumn = columnIndex.toString(); // Move to all-day container if not already there if (this.draggedClone.parentElement !== allDayContainer) { allDayContainer.appendChild(this.draggedClone); } } 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 // Separate all-day events from regular events const allDayEvents = events.filter(event => event.allDay); const regularEvents = events.filter(event => !event.allDay); // Always call renderAllDayEvents to ensure height is set correctly (even to 0) this.renderAllDayEvents(allDayEvents, container); // Find columns in the specific container for regular events const columns = this.getColumns(container); columns.forEach(column => { const columnEvents = this.getEventsForColumn(column, regularEvents); 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[]; /** * Render all-day events in the header row 2 */ protected renderAllDayEvents(allDayEvents: CalendarEvent[], container: HTMLElement): void { // Find the calendar header const calendarHeader = container.querySelector('swp-calendar-header'); if (!calendarHeader) { return; } // Find the all-day container (should always exist now) const allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement; if (!allDayContainer) { console.warn('All-day container not found - this should not happen'); return; } // Clear existing events allDayContainer.innerHTML = ''; if (allDayEvents.length === 0) { // No events - container exists but is empty and hidden return; } // Build date to column mapping const dayHeaders = calendarHeader.querySelectorAll('swp-day-header'); const dateToColumnMap = new Map(); dayHeaders.forEach((header, index) => { const dateStr = (header as any).dataset.date; if (dateStr) { dateToColumnMap.set(dateStr, index + 1); // 1-based column index } }); // Calculate grid spans for all events const eventSpans = allDayEvents.map(event => ({ event, span: this.calculateEventGridSpan(event, dateToColumnMap) })).filter(item => item.span.columnSpan > 0); // Remove events outside visible range // Simple row assignment using overlap detection const eventPlacements: Array<{ event: CalendarEvent, span: { startColumn: number, columnSpan: number }, row: number }> = []; eventSpans.forEach(eventItem => { let assignedRow = 1; // Find first row where this event doesn't overlap with any existing event while (true) { const rowEvents = eventPlacements.filter(item => item.row === assignedRow); const hasOverlap = rowEvents.some(rowEvent => this.spansOverlap(eventItem.span, rowEvent.span) ); if (!hasOverlap) { break; // Found available row } assignedRow++; } eventPlacements.push({ event: eventItem.event, span: eventItem.span, row: assignedRow }); }); // Get max row needed const maxRow = Math.max(...eventPlacements.map(item => item.row), 1); // Place events directly in the single container eventPlacements.forEach(({ event, span, row }) => { // Create the all-day event element const allDayEvent = document.createElement('swp-allday-event'); allDayEvent.textContent = event.title; // Set data attributes directly from CalendarEvent allDayEvent.dataset.eventId = event.id; allDayEvent.dataset.title = event.title; allDayEvent.dataset.start = event.start.toISOString(); allDayEvent.dataset.end = event.end.toISOString(); allDayEvent.dataset.type = event.type; allDayEvent.dataset.duration = event.metadata?.duration?.toString() || '60'; // Set grid position (column and row) (allDayEvent as HTMLElement).style.gridColumn = span.columnSpan > 1 ? `${span.startColumn} / span ${span.columnSpan}` : `${span.startColumn}`; (allDayEvent as HTMLElement).style.gridRow = row.toString(); // Use event metadata for color if available if (event.metadata?.color) { (allDayEvent as HTMLElement).style.backgroundColor = event.metadata.color; } allDayContainer.appendChild(allDayEvent); }); } protected renderEvent(event: CalendarEvent): HTMLElement { const eventElement = document.createElement('swp-event'); eventElement.dataset.eventId = event.id; eventElement.dataset.title = event.title; eventElement.dataset.start = event.start.toISOString(); eventElement.dataset.end = event.end.toISOString(); eventElement.dataset.type = event.type; eventElement.dataset.duration = event.metadata?.duration?.toString() || '60'; // Calculate position based on time const position = this.calculateEventPosition(event); eventElement.style.position = 'absolute'; eventElement.style.top = `${position.top + 1}px`; eventElement.style.height = `${position.height - 3}px`; //adjusted so bottom does not cover horizontal time lines. // Color is now handled by CSS classes based on data-type attribute // Format time for display using unified method const startTime = this.formatTime(event.start); const endTime = this.formatTime(event.end); // Calculate duration in minutes const durationMinutes = (event.end.getTime() - event.start.getTime()) / (1000 * 60); // Create event content eventElement.innerHTML = ` ${startTime} - ${endTime} ${event.title} `; // Setup resize handles on first mouseover only eventElement.addEventListener('mouseover', () => { if (eventElement.dataset.hasResizeHandlers !== 'true') { this.setupDynamicResizeHandles(eventElement); eventElement.dataset.hasResizeHandlers = 'true'; } }, { once: true }); // Setup double-click for text selection eventElement.addEventListener('dblclick', (e) => { e.stopPropagation(); this.handleEventDoubleClick(eventElement); }); 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 }; } /** * Calculate grid column span for event */ private calculateEventGridSpan(event: CalendarEvent, dateToColumnMap: Map): { startColumn: number, columnSpan: number } { const startDateKey = DateCalculator.formatISODate(event.start); const startColumn = dateToColumnMap.get(startDateKey); if (!startColumn) { return { startColumn: 0, columnSpan: 0 }; // Event outside visible range } // Calculate span by checking each day let endColumn = startColumn; const currentDate = new Date(event.start); while (currentDate <= event.end) { currentDate.setDate(currentDate.getDate() + 1); const dateKey = DateCalculator.formatISODate(currentDate); const col = dateToColumnMap.get(dateKey); if (col) { endColumn = col; } else { break; // Event extends beyond visible range } } const columnSpan = endColumn - startColumn + 1; return { startColumn, columnSpan }; } /** * Check if two column spans overlap (for all-day events) */ private spansOverlap(event1Span: { startColumn: number, columnSpan: number }, event2Span: { startColumn: number, columnSpan: number }): boolean { const event1End = event1Span.startColumn + event1Span.columnSpan - 1; const event2End = event2Span.startColumn + event2Span.columnSpan - 1; return !(event1End < event2Span.startColumn || event2End < event1Span.startColumn); } /** * Setup dynamic resize handles that are only created when needed */ private setupDynamicResizeHandles(eventElement: HTMLElement): void { let topHandle: HTMLElement | null = null; let bottomHandle: HTMLElement | null = null; console.log('Setting up dynamic resize handles for event:', eventElement.dataset.eventId); // Create handles on mouse enter eventElement.addEventListener('mouseenter', () => { console.log('Mouse ENTER event:', eventElement.dataset.eventId); // Only create if they don't already exist if (!topHandle || !bottomHandle) { topHandle = document.createElement('swp-resize-handle'); topHandle.setAttribute('data-position', 'top'); topHandle.style.opacity = '0'; bottomHandle = document.createElement('swp-resize-handle'); bottomHandle.setAttribute('data-position', 'bottom'); bottomHandle.style.opacity = '0'; // Add mousedown listeners for resize functionality topHandle.addEventListener('mousedown', (e: MouseEvent) => { e.stopPropagation(); // Forhindre normal drag e.preventDefault(); this.startResize(eventElement, 'top', e); }); bottomHandle.addEventListener('mousedown', (e: MouseEvent) => { e.stopPropagation(); // Forhindre normal drag e.preventDefault(); this.startResize(eventElement, 'bottom', e); }); // Insert handles at beginning and end eventElement.insertBefore(topHandle, eventElement.firstChild); eventElement.appendChild(bottomHandle); console.log('Created resize handles for event:', eventElement.dataset.eventId); } }); // Mouse move handler for smart visibility eventElement.addEventListener('mousemove', (e: MouseEvent) => { if (!topHandle || !bottomHandle) return; const rect = eventElement.getBoundingClientRect(); const y = e.clientY - rect.top; const height = rect.height; // Show top handle if mouse is in top 12px if (y <= 12) { topHandle.style.opacity = '1'; bottomHandle.style.opacity = '0'; } // Show bottom handle if mouse is in bottom 12px else if (y >= height - 12) { topHandle.style.opacity = '0'; bottomHandle.style.opacity = '1'; } // Hide both if mouse is in middle else { topHandle.style.opacity = '0'; bottomHandle.style.opacity = '0'; } }); // Hide handles when mouse leaves event (men kun hvis ikke i resize mode) eventElement.addEventListener('mouseleave', () => { console.log('Mouse LEAVE event:', eventElement.dataset.eventId); if (!this.resizeState && topHandle && bottomHandle) { topHandle.style.opacity = '0'; bottomHandle.style.opacity = '0'; console.log('Hidden resize handles for event:', eventElement.dataset.eventId); } }); } /** * Start resize operation */ private startResize(eventElement: HTMLElement, handle: 'top' | 'bottom', e: MouseEvent): void { const gridSettings = calendarConfig.getGridSettings(); const minHeightPx = (this.MIN_EVENT_DURATION_MINUTES / 60) * gridSettings.hourHeight; this.resizeState = { element: eventElement, handle: handle, startY: e.clientY, originalTop: parseFloat(eventElement.style.top), originalHeight: parseFloat(eventElement.style.height), originalStartTime: new Date(eventElement.dataset.start || ''), originalEndTime: new Date(eventElement.dataset.end || ''), minHeightPx: minHeightPx }; // Global listeners for resize document.addEventListener('mousemove', this.handleResize); document.addEventListener('mouseup', this.endResize); // Add resize cursor to body document.body.style.cursor = handle === 'top' ? 'n-resize' : 's-resize'; console.log('Starting resize:', handle, 'element:', eventElement.dataset.eventId); } /** * Handle resize drag */ private handleResize = (e: MouseEvent): void => { if (!this.resizeState) return; const deltaY = e.clientY - this.resizeState.startY; const snappedDelta = this.snapToGrid(deltaY); const gridSettings = calendarConfig.getGridSettings(); if (this.resizeState.handle === 'top') { // Resize fra toppen const newTop = this.resizeState.originalTop + snappedDelta; const newHeight = this.resizeState.originalHeight - snappedDelta; // Check minimum højde if (newHeight >= this.resizeState.minHeightPx && newTop >= 0) { this.resizeState.element.style.top = newTop + 'px'; this.resizeState.element.style.height = newHeight + 'px'; // Opdater tidspunkter const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60; const newStartTime = this.addMinutes(this.resizeState.originalStartTime, minutesDelta); this.updateEventDisplay(this.resizeState.element, newStartTime, this.resizeState.originalEndTime); } } else { // Resize fra bunden const newHeight = this.resizeState.originalHeight + snappedDelta; // Check minimum højde if (newHeight >= this.resizeState.minHeightPx) { this.resizeState.element.style.height = newHeight + 'px'; // Opdater tidspunkter const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60; const newEndTime = this.addMinutes(this.resizeState.originalEndTime, minutesDelta); this.updateEventDisplay(this.resizeState.element, this.resizeState.originalStartTime, newEndTime); } } } /** * End resize operation */ private endResize = (): void => { if (!this.resizeState) return; // Få finale tider fra element const finalStart = this.resizeState.element.dataset.start; const finalEnd = this.resizeState.element.dataset.end; console.log('Ending resize:', this.resizeState.element.dataset.eventId, 'New times:', finalStart, finalEnd); // Emit event med nye tider eventBus.emit('event:resized', { eventId: this.resizeState.element.dataset.eventId, newStart: finalStart, newEnd: finalEnd }); // Cleanup document.removeEventListener('mousemove', this.handleResize); document.removeEventListener('mouseup', this.endResize); document.body.style.cursor = ''; this.resizeState = null; } /** * Snap delta to grid intervals */ private snapToGrid(deltaY: number): number { const gridSettings = calendarConfig.getGridSettings(); const snapInterval = gridSettings.snapInterval; const hourHeight = gridSettings.hourHeight; const snapDistancePx = (snapInterval / 60) * hourHeight; return Math.round(deltaY / snapDistancePx) * snapDistancePx; } /** * Update event display during resize */ private updateEventDisplay(element: HTMLElement, startTime: Date, endTime: Date): void { // Beregn ny duration i minutter const durationMinutes = (endTime.getTime() - startTime.getTime()) / (1000 * 60); // Opdater dataset element.dataset.start = startTime.toISOString(); element.dataset.end = endTime.toISOString(); element.dataset.duration = durationMinutes.toString(); // Opdater visual tid const timeElement = element.querySelector('swp-event-time'); if (timeElement) { const startStr = this.formatTime(startTime.toISOString()); const endStr = this.formatTime(endTime.toISOString()); timeElement.textContent = `${startStr} - ${endStr}`; // Opdater også data-duration attribut på time elementet timeElement.setAttribute('data-duration', durationMinutes.toString()); } } /** * Add minutes to a date */ private addMinutes(date: Date, minutes: number): Date { return new Date(date.getTime() + minutes * 60000); } 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); // 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(); }