// Event rendering strategy interface and implementations import { CalendarEvent } from '../types/CalendarTypes'; import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig'; import { calendarConfig } from '../core/CalendarConfig'; import { DateCalculator } from '../utils/DateCalculator'; import { eventBus } from '../core/EventBus'; import { CoreEvents } from '../constants/CoreEvents'; import { EventOverlapManager, OverlapType } from '../managers/EventOverlapManager'; /** * 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; protected overlapManager: EventOverlapManager; // Drag and drop state private draggedClone: HTMLElement | null = null; private originalEvent: HTMLElement | null = null; constructor(dateCalculator?: DateCalculator) { if (!dateCalculator) { DateCalculator.initialize(calendarConfig); } this.dateCalculator = dateCalculator || new DateCalculator(); this.overlapManager = new EventOverlapManager(); } /** * 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 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.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 = parseFloat(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 { this.originalEvent = originalElement; // Remove stacking styling from original event before creating clone if (this.overlapManager.isStackedEvent(originalElement)) { this.overlapManager.removeStackedStyling(originalElement); } // 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) { 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 // Detect overlaps with other events in the target column and reposition if needed this.detectAndHandleOverlaps(this.draggedClone, finalColumn); // Clean up this.draggedClone = null; this.originalEvent = null; } /** * Remove event from any existing groups and cleanup empty containers */ private removeEventFromExistingGroups(eventElement: HTMLElement): void { const eventGroup = this.overlapManager.getEventGroup(eventElement); if (eventGroup) { const eventId = eventElement.dataset.eventId; if (eventId) { this.overlapManager.removeFromEventGroup(eventGroup, eventId); // Gendan normal kolonne bredde efter fjernelse fra group this.restoreNormalEventStyling(eventElement); } } else if (this.overlapManager.isStackedEvent(eventElement)) { // Remove stacking styling if it's a stacked event this.overlapManager.removeStackedStyling(eventElement); } } /** * 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 } /** * Detect overlaps with other events in target column and handle repositioning */ private detectAndHandleOverlaps(droppedElement: HTMLElement, targetColumn: string): void { // Find target column element const columnElement = document.querySelector(`swp-day-column[data-date="${targetColumn}"]`); if (!columnElement) return; const eventsLayer = columnElement.querySelector('swp-events-layer'); if (!eventsLayer) return; // Convert dropped element to CalendarEvent using its NEW position const droppedEvent = this.elementToCalendarEventWithNewPosition(droppedElement, targetColumn); if (!droppedEvent) return; // Check if there's already an existing swp-event-group in the column const existingGroup = eventsLayer.querySelector('swp-event-group') as HTMLElement; if (existingGroup) { // Check if dropped event overlaps with the group's events const groupEvents = Array.from(existingGroup.querySelectorAll('swp-event')) as HTMLElement[]; let overlapsWithGroup = false; for (const groupEvent of groupEvents) { const existingEvent = this.elementToCalendarEvent(groupEvent); if (!existingEvent) continue; const overlapType = this.overlapManager.detectOverlap(droppedEvent, existingEvent); if (overlapType === OverlapType.COLUMN_SHARING) { overlapsWithGroup = true; break; } } if (overlapsWithGroup) { // Simply add the dropped event to the existing group this.updateElementDataset(droppedElement, droppedEvent); this.overlapManager.addToEventGroup(existingGroup, droppedElement); return; } } // No existing group or no overlap with existing group - run full overlap detection const existingEvents = Array.from(eventsLayer.querySelectorAll('swp-event')) .filter(el => el !== droppedElement) as HTMLElement[]; // Check if dropped event overlaps with any existing events let hasOverlaps = false; const overlappingEvents: CalendarEvent[] = []; for (const existingElement of existingEvents) { const existingEvent = this.elementToCalendarEvent(existingElement); if (!existingEvent) continue; // Skip if it's the same event (comparing IDs) if (existingEvent.id === droppedEvent.id) continue; const overlapType = this.overlapManager.detectOverlap(droppedEvent, existingEvent); if (overlapType !== OverlapType.NONE) { hasOverlaps = true; overlappingEvents.push(existingEvent); } } // Add dropped event LAST so it appears rightmost in flexbox overlappingEvents.push(droppedEvent); // Only re-render if there are actual overlaps if (!hasOverlaps) { // No overlaps - just update the dropped element's dataset with new times this.updateElementDataset(droppedElement, droppedEvent); return; } // There are overlaps - group and re-render overlapping events const overlapGroups = this.overlapManager.groupOverlappingEvents(overlappingEvents); // Remove overlapping events from DOM const overlappingEventIds = new Set(overlappingEvents.map(e => e.id)); existingEvents .filter(el => overlappingEventIds.has(el.dataset.eventId || '')) .forEach(el => el.remove()); droppedElement.remove(); // Re-render overlapping events with proper grouping overlapGroups.forEach(group => { if (group.type === OverlapType.COLUMN_SHARING && group.events.length > 1) { this.renderColumnSharingGroup(group, eventsLayer); } else if (group.type === OverlapType.STACKING && group.events.length > 1) { this.renderStackedEvents(group, eventsLayer); } else { group.events.forEach(event => { const eventElement = this.createEventElement(event); this.positionEvent(eventElement, event); eventsLayer.appendChild(eventElement); }); } }); } /** * Update element's dataset with new times after successful drop */ private updateElementDataset(element: HTMLElement, event: CalendarEvent): void { element.dataset.start = event.start; element.dataset.end = event.end; // 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 = parseFloat(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.toISOString(), end: endDate.toISOString(), 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: start, end: 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) { // Group events by overlap type const overlapGroups = this.overlapManager.groupOverlappingEvents(columnEvents); overlapGroups.forEach(group => { if (group.type === OverlapType.COLUMN_SHARING && group.events.length > 1) { // Create flexbox container for column sharing this.renderColumnSharingGroup(group, eventsLayer); } else if (group.type === OverlapType.STACKING && group.events.length > 1) { // Render stacked events this.renderStackedEvents(group, eventsLayer); } else { // Render normal single events group.events.forEach(event => { this.renderEvent(event, eventsLayer); }); } }); // Debug: Verify events were actually added const renderedEvents = eventsLayer.querySelectorAll('swp-event, swp-event-group'); } else { } }); } // 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.eventsOverlap(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; allDayEvent.dataset.end = event.end; 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, container: Element): void { const eventElement = document.createElement('swp-event'); eventElement.dataset.eventId = event.id; eventElement.dataset.title = event.title; eventElement.dataset.start = event.start; eventElement.dataset.end = event.end; 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 startDate = new Date(event.start); const endDate = new Date(event.end); const durationMinutes = (endDate.getTime() - startDate.getTime()) / (1000 * 60); // Create event content eventElement.innerHTML = ` ${startTime} - ${endTime} ${event.title} `; container.appendChild(eventElement); } protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } { const startDate = new Date(event.start); const endDate = new Date(event.end); const gridSettings = calendarConfig.getGridSettings(); const dayStartHour = gridSettings.dayStartHour; const hourHeight = gridSettings.hourHeight; // Calculate minutes from midnight const startMinutes = startDate.getHours() * 60 + startDate.getMinutes(); const endMinutes = endDate.getHours() * 60 + endDate.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 startDate = new Date(event.start); const endDate = new Date(event.end); const startDateKey = DateCalculator.formatISODate(startDate); 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(startDate); while (currentDate <= endDate) { 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 events overlap in columns */ private eventsOverlap(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); } /** * Render column sharing group with flexbox container */ protected renderColumnSharingGroup(group: any, container: Element): void { const groupContainer = this.overlapManager.createEventGroup(group.events, group.position); // Render each event in the group group.events.forEach((event: CalendarEvent) => { const eventElement = this.createEventElement(event); this.overlapManager.addToEventGroup(groupContainer, eventElement); }); container.appendChild(groupContainer); // Emit event for debugging/logging eventBus.emit('overlap:group-created', { type: 'column_sharing', eventCount: group.events.length, events: group.events.map((e: CalendarEvent) => e.id) }); } /** * Render stacked events with margin-left offset */ protected renderStackedEvents(group: any, container: Element): void { // Sort events by duration - longer events render first (background), shorter events on top // This way shorter events are more visible and get higher z-index const sortedEvents = [...group.events].sort((a, b) => { const durationA = new Date(a.end).getTime() - new Date(a.start).getTime(); const durationB = new Date(b.end).getTime() - new Date(b.start).getTime(); return durationB - durationA; // Longer duration first (background) }); let underlyingElement: HTMLElement | null = null; sortedEvents.forEach((event: CalendarEvent, index: number) => { const eventElement = this.createEventElement(event); this.positionEvent(eventElement, event); if (index === 0) { // First (longest duration) event renders normally at full width - UNCHANGED container.appendChild(eventElement); underlyingElement = eventElement; } else { // Shorter events are stacked with margin-left offset and higher z-index // Each subsequent event gets more margin: 15px, 30px, 45px, etc. if (underlyingElement) { this.overlapManager.createStackedEvent(eventElement, underlyingElement, index); } container.appendChild(eventElement); // DO NOT update underlyingElement - keep it as the longest event } }); // Emit event for debugging/logging eventBus.emit('overlap:events-stacked', { type: 'stacking', eventCount: group.events.length, events: group.events.map((e: CalendarEvent) => e.id) }); } /** * Create event element without positioning */ protected createEventElement(event: CalendarEvent): HTMLElement { const eventElement = document.createElement('swp-event'); eventElement.dataset.eventId = event.id; eventElement.dataset.title = event.title; eventElement.dataset.start = event.start; eventElement.dataset.end = event.end; eventElement.dataset.type = event.type; eventElement.dataset.duration = event.metadata?.duration?.toString() || '60'; // Format time for display using unified method const startTime = this.formatTime(event.start); const endTime = this.formatTime(event.end); // Calculate duration in minutes const startDate = new Date(event.start); const endDate = new Date(event.end); const durationMinutes = (endDate.getTime() - startDate.getTime()) / (1000 * 60); // Create event content eventElement.innerHTML = ` ${startTime} - ${endTime} ${event.title} `; return eventElement; } /** * Position event element */ protected positionEvent(eventElement: HTMLElement, event: CalendarEvent): void { const position = this.calculateEventPosition(event); eventElement.style.position = 'absolute'; eventElement.style.top = `${position.top + 1}px`; eventElement.style.height = `${position.height - 3}px`; eventElement.style.left = '2px'; eventElement.style.right = '2px'; } 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()); } } /** * 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 eventDate = new Date(event.start); const eventDateStr = DateCalculator.formatISODate(eventDate); 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; } }