// 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'; /** * Interface for event rendering strategies */ export interface EventRendererStrategy { renderEvents(events: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void; clearEvents(container?: HTMLElement): void; } /** * Base class for event renderers with common functionality */ export abstract class BaseEventRenderer implements EventRendererStrategy { protected dateCalculator: DateCalculator; protected config: CalendarConfig; // Drag and drop state private draggedClone: HTMLElement | null = null; private originalEvent: HTMLElement | null = null; constructor(config: CalendarConfig, dateCalculator?: DateCalculator) { this.config = config; if (!dateCalculator) { DateCalculator.initialize(config); } this.dateCalculator = dateCalculator || new DateCalculator(); } /** * 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 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.PERIOD_CHANGED, () => { // 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'; // Keep original dimensions (height stays the same) const rect = originalEvent.getBoundingClientRect(); clone.style.width = rect.width + 'px'; clone.style.height = rect.height + 'px'; return clone; } /** * Update clone timestamp based on new position */ private updateCloneTimestamp(clone: HTMLElement, snappedY: number): void { const gridSettings = this.config.getGridSettings(); const hourHeight = gridSettings.hourHeight; const dayStartHour = gridSettings.dayStartHour; const snapInterval = 15; // TODO: Get from config // Calculate total minutes from top const totalMinutesFromTop = (snappedY / hourHeight) * 60; const startTotalMinutes = Math.max( dayStartHour * 60, Math.round((dayStartHour * 60 + totalMinutesFromTop) / snapInterval) * snapInterval ); // Use cached original duration (no recalculation) const cachedDuration = parseInt(clone.dataset.originalDuration || '60'); const endTotalMinutes = startTotalMinutes + cachedDuration; // Update display const timeElement = clone.querySelector('swp-event-time'); if (timeElement) { const newTimeText = `${this.formatTime(startTotalMinutes)} - ${this.formatTime(endTotalMinutes)}`; timeElement.textContent = newTimeText; } } /** * Calculate event duration in minutes from element height */ private getEventDuration(element: HTMLElement): number { const gridSettings = this.config.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; // Create clone this.draggedClone = this.createEventClone(originalElement); // Add to current column const columnElement = document.querySelector(`swp-day-column[data-date="${column}"]`); if (columnElement) { 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 const newColumnElement = document.querySelector(`swp-day-column[data-date="${newColumn}"]`); if (newColumnElement && this.draggedClone.parentElement !== newColumnElement) { 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; } // 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 = ''; this.draggedClone.style.zIndex = ''; // Clean up this.draggedClone = null; this.originalEvent = null; } /** * 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, config: CalendarConfig): 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, config); // 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) { columnEvents.forEach(event => { this.renderEvent(event, eventsLayer, config); }); // Debug: Verify events were actually added const renderedEvents = eventsLayer.querySelectorAll('swp-event'); } 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, config: CalendarConfig): 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, config: CalendarConfig): 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, config); 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, config: CalendarConfig): { top: number; height: number } { const startDate = new Date(event.start); const endDate = new Date(event.end); const gridSettings = config.getGridSettings(); const dayStartHour = gridSettings.dayStartHour; const hourHeight = gridSettings.hourHeight; // Calculate minutes from visible day start const startMinutes = startDate.getHours() * 60 + startDate.getMinutes(); const endMinutes = endDate.getHours() * 60 + endDate.getMinutes(); const dayStartMinutes = dayStartHour * 60; // Calculate top position (subtract day start to align with time axis) const top = ((startMinutes - dayStartMinutes) / 60) * hourHeight; // Calculate height 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); } clearEvents(container?: HTMLElement): void { const selector = 'swp-event'; const existingEvents = container ? container.querySelectorAll(selector) : document.querySelectorAll(selector); existingEvents.forEach(event => event.remove()); } } /** * Date-based event renderer */ export class DateEventRenderer extends BaseEventRenderer { constructor(config: CalendarConfig, dateCalculator?: DateCalculator) { super(config, 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; } }