// 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'; /** * 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; constructor(config: CalendarConfig) { this.dateCalculator = new DateCalculator(config); } renderEvents(events: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void { console.log('BaseEventRenderer: renderEvents called with', events.length, 'events'); // 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); console.log(`BaseEventRenderer: Rendering ${allDayEvents.length} all-day events and ${regularEvents.length} regular events`); // 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); console.log(`BaseEventRenderer: Found ${columns.length} columns in container`); columns.forEach(column => { const columnEvents = this.getEventsForColumn(column, regularEvents); console.log(`BaseEventRenderer: Rendering ${columnEvents.length} regular events in column`); const eventsLayer = column.querySelector('swp-events-layer'); if (eventsLayer) { columnEvents.forEach(event => { console.log(`BaseEventRenderer: Rendering event "${event.title}" in events layer`); this.renderEvent(event, eventsLayer, config); }); // Debug: Verify events were actually added const renderedEvents = eventsLayer.querySelectorAll('swp-event'); console.log(`BaseEventRenderer: Events layer now has ${renderedEvents.length} events`); } else { console.warn('BaseEventRenderer: No events layer found in column'); } }); } // 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 { console.log(`BaseEventRenderer: Rendering ${allDayEvents.length} all-day events using nested grid`); // Find the calendar header const calendarHeader = container.querySelector('swp-calendar-header'); if (!calendarHeader) { console.warn('BaseEventRenderer: No calendar header found for all-day events'); return; } // Find the all-day container const allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement; if (!allDayContainer) { console.warn('BaseEventRenderer: No swp-allday-container found - HeaderRenderer should create this'); return; } // Clear existing events allDayContainer.innerHTML = ''; if (allDayEvents.length === 0) { // No events - just return this.updateAllDayHeight(1); 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; allDayEvent.setAttribute('data-event-id', event.id); allDayEvent.setAttribute('data-type', event.type || 'work'); // 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); console.log(`BaseEventRenderer: Placed "${event.title}" in row ${row}, columns ${span.startColumn} to ${span.startColumn + span.columnSpan - 1}`); }); // Update height based on max row this.updateAllDayHeight(maxRow); console.log(`BaseEventRenderer: Created ${maxRow} rows with auto-expanding grid`); } protected renderEvent(event: CalendarEvent, container: Element, config: CalendarConfig): void { const eventElement = document.createElement('swp-event'); eventElement.dataset.eventId = event.id; eventElement.dataset.type = event.type; // 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 const startTime = this.dateCalculator.formatTime(new Date(event.start)); const endTime = this.dateCalculator.formatTime(new Date(event.end)); // Create event content eventElement.innerHTML = ` ${startTime} - ${endTime} ${event.title} `; container.appendChild(eventElement); console.log(`BaseEventRenderer: Created event element for "${event.title}":`, { top: eventElement.style.top, height: eventElement.style.height, dataType: eventElement.dataset.type, position: eventElement.style.position, innerHTML: eventElement.innerHTML }); } 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; console.log('Event positioning calculation:', { eventTime: `${startDate.getHours()}:${startDate.getMinutes()}`, startMinutes, endMinutes, dayStartMinutes, dayStartHour, hourHeight, top, height }); return { top, height }; } protected formatTime(isoString: string): string { const date = new Date(isoString); const hours = date.getHours(); const minutes = date.getMinutes(); const period = hours >= 12 ? 'PM' : 'AM'; const displayHour = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours); return `${displayHour}:${minutes.toString().padStart(2, '0')} ${period}`; } /** * Update all-day row height and grid template based on number of rows */ private updateAllDayHeight(maxRows: number): void { const root = document.documentElement; const eventHeight = parseInt(getComputedStyle(root).getPropertyValue('--allday-event-height') || '26'); const calculatedHeight = maxRows * eventHeight; root.style.setProperty('--all-day-row-height', `${calculatedHeight}px`); // Update grid-template-rows for all swp-allday-containers const allDayContainers = document.querySelectorAll('swp-allday-container'); allDayContainers.forEach(container => { const gridRows = `repeat(${maxRows}, var(--allday-event-height, 26px))`; (container as HTMLElement).style.gridTemplateRows = gridRows; }); console.log(`BaseEventRenderer: Set all-day height to ${calculatedHeight}px and grid-template-rows to ${maxRows} rows`); } /** * 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 = this.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 = this.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); if (existingEvents.length > 0) { console.log(`BaseEventRenderer: Clearing ${existingEvents.length} events`, container ? 'from container' : 'globally'); } existingEvents.forEach(event => event.remove()); } } /** * Date-based event renderer */ export class DateEventRenderer extends BaseEventRenderer { protected getColumns(container: HTMLElement): HTMLElement[] { const columns = container.querySelectorAll('swp-day-column'); console.log('DateEventRenderer: Found', columns.length, 'day columns in container'); return Array.from(columns) as HTMLElement[]; } protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] { const columnDate = column.dataset.date; if (!columnDate) { console.log(`DateEventRenderer: Column has no dataset.date`); return []; } const columnEvents = events.filter(event => { const eventDate = new Date(event.start); const eventDateStr = this.dateCalculator.formatISODate(eventDate); const matches = eventDateStr === columnDate; if (!matches) { if(event.title == 'Architecture Planning') console.log(`DateEventRenderer: Event ${event.title} (${eventDateStr}) does not match column (${columnDate})`); } return matches; }); console.log(`DateEventRenderer: Column ${columnDate} has ${columnEvents.length} events from ${events.length} total`); return columnEvents; } } /** * Resource-based event renderer */ export class ResourceEventRenderer extends BaseEventRenderer { protected getColumns(container: HTMLElement): HTMLElement[] { const columns = container.querySelectorAll('swp-resource-column'); console.log('ResourceEventRenderer: Found', columns.length, 'resource columns in container'); 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; }); console.log(`ResourceEventRenderer: Resource ${resourceName} has ${columnEvents.length} events`); return columnEvents; } }