import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../../types/CalendarTypes'; import { EventService } from '../../storage/events/EventService'; import { DateService } from '../../core/DateService'; import { IGridConfig } from '../../core/IGridConfig'; import { calculateEventPosition, snapToGrid, pixelsToMinutes } from '../../utils/PositionUtils'; import { CoreEvents } from '../../constants/CoreEvents'; import { IDragColumnChangePayload, IDragMovePayload, IDragEndPayload } from '../../types/DragTypes'; import { calculateColumnLayout } from './EventLayoutEngine'; import { IGridGroupLayout } from './EventLayoutTypes'; /** * EventRenderer - Renders calendar events to the DOM * * CLEAN approach: * - Only data-id attribute on event element * - innerHTML contains only visible content * - Event data retrieved via EventService when needed */ export class EventRenderer { private container: HTMLElement | null = null; constructor( private eventService: EventService, private dateService: DateService, private gridConfig: IGridConfig, private eventBus: IEventBus ) { this.setupListeners(); } /** * Setup listeners for drag-drop and update events */ private setupListeners(): void { this.eventBus.on(CoreEvents.EVENT_DRAG_COLUMN_CHANGE, (e) => { const payload = (e as CustomEvent).detail; this.handleColumnChange(payload); }); this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE, (e) => { const payload = (e as CustomEvent).detail; this.updateDragTimestamp(payload); }); this.eventBus.on(CoreEvents.EVENT_UPDATED, (e) => { const payload = (e as CustomEvent).detail; this.handleEventUpdated(payload); }); this.eventBus.on(CoreEvents.EVENT_DRAG_END, (e) => { const payload = (e as CustomEvent).detail; this.handleDragEnd(payload); }); } /** * Handle EVENT_DRAG_END - remove element if dropped in header */ private handleDragEnd(payload: IDragEndPayload): void { if (payload.target === 'header') { // Event was dropped in header drawer - remove from grid const element = this.container?.querySelector(`swp-content-viewport swp-event[data-event-id="${payload.swpEvent.eventId}"]`); element?.remove(); } } /** * Handle EVENT_UPDATED - re-render affected columns */ private async handleEventUpdated(payload: IEventUpdatedPayload): Promise { // Re-render source column (if different from target) if (payload.sourceDateKey !== payload.targetDateKey || payload.sourceResourceId !== payload.targetResourceId) { await this.rerenderColumn(payload.sourceDateKey, payload.sourceResourceId); } // Re-render target column await this.rerenderColumn(payload.targetDateKey, payload.targetResourceId); } /** * Re-render a single column with fresh data from IndexedDB */ private async rerenderColumn(dateKey: string, resourceId?: string): Promise { const column = this.findColumn(dateKey, resourceId); if (!column) return; // Get date range for this day const startDate = new Date(dateKey); const endDate = new Date(dateKey); endDate.setHours(23, 59, 59, 999); // Fetch events from IndexedDB const events = resourceId ? await this.eventService.getByResourceAndDateRange(resourceId, startDate, endDate) : await this.eventService.getByDateRange(startDate, endDate); // Filter to timed events and match dateKey exactly const timedEvents = events.filter(event => !event.allDay && this.dateService.getDateKey(event.start) === dateKey ); // Get or create events layer let eventsLayer = column.querySelector('swp-events-layer'); if (!eventsLayer) { eventsLayer = document.createElement('swp-events-layer'); column.appendChild(eventsLayer); } // Clear existing events eventsLayer.innerHTML = ''; // Calculate layout with stacking/grouping const layout = calculateColumnLayout(timedEvents, this.gridConfig); // Render GRID groups layout.grids.forEach(grid => { const groupEl = this.renderGridGroup(grid); eventsLayer!.appendChild(groupEl); }); // Render STACKED events layout.stacked.forEach(item => { const eventEl = this.renderStackedEvent(item.event, item.stackLevel); eventsLayer!.appendChild(eventEl); }); } /** * Find a column element by dateKey and optional resourceId */ private findColumn(dateKey: string, resourceId?: string): HTMLElement | null { if (!this.container) return null; const columns = this.container.querySelectorAll('swp-day-column'); for (const col of columns) { const colEl = col as HTMLElement; if (colEl.dataset.date !== dateKey) continue; // If resourceId specified, must match if (resourceId && colEl.dataset.resourceId !== resourceId) continue; // If no resourceId specified but column has one, skip (simple view case) if (!resourceId && colEl.dataset.resourceId) continue; return colEl; } return null; } /** * Handle event moving to a new column during drag */ private handleColumnChange(payload: IDragColumnChangePayload): void { const eventsLayer = payload.newColumn.querySelector('swp-events-layer'); if (!eventsLayer) return; // Move element to new column eventsLayer.appendChild(payload.element); // Preserve Y position payload.element.style.top = `${payload.currentY}px`; } /** * Update timestamp display during drag (snapped to grid) */ private updateDragTimestamp(payload: IDragMovePayload): void { const timeEl = payload.element.querySelector('swp-event-time'); if (!timeEl) return; // Snap position to grid interval const snappedY = snapToGrid(payload.currentY, this.gridConfig); // Calculate new start time const minutesFromGridStart = pixelsToMinutes(snappedY, this.gridConfig); const startMinutes = (this.gridConfig.dayStartHour * 60) + minutesFromGridStart; // Keep original duration (from element height) const height = parseFloat(payload.element.style.height) || this.gridConfig.hourHeight; const durationMinutes = pixelsToMinutes(height, this.gridConfig); // Create Date objects for consistent formatting via DateService const start = this.minutesToDate(startMinutes); const end = this.minutesToDate(startMinutes + durationMinutes); timeEl.textContent = this.dateService.formatTimeRange(start, end); } /** * Convert minutes since midnight to a Date object (today) */ private minutesToDate(minutes: number): Date { const date = new Date(); date.setHours(Math.floor(minutes / 60) % 24, minutes % 60, 0, 0); return date; } /** * Render events for visible dates into day columns * @param container - Calendar container element * @param filter - Filter with 'date' and optionally 'resource' arrays */ async render(container: HTMLElement, filter: Record): Promise { // Store container reference for later re-renders this.container = container; const visibleDates = filter['date'] || []; if (visibleDates.length === 0) return; // Get date range for query const startDate = new Date(visibleDates[0]); const endDate = new Date(visibleDates[visibleDates.length - 1]); endDate.setHours(23, 59, 59, 999); // Fetch events from IndexedDB const events = await this.eventService.getByDateRange(startDate, endDate); // Find day columns const dayColumns = container.querySelector('swp-day-columns'); if (!dayColumns) return; const columns = dayColumns.querySelectorAll('swp-day-column'); // Render events into each column based on data attributes columns.forEach(column => { const dateKey = (column as HTMLElement).dataset.date; const columnResourceId = (column as HTMLElement).dataset.resourceId; if (!dateKey) return; // Filter events for this column const columnEvents = events.filter(event => { // Must match date if (this.dateService.getDateKey(event.start) !== dateKey) return false; // If column has resourceId, event must match if (columnResourceId && event.resourceId !== columnResourceId) return false; // If no resourceId on column but resources in filter, show all // (this handles 'simple' view without resources) return true; }); // Get or create events layer let eventsLayer = column.querySelector('swp-events-layer'); if (!eventsLayer) { eventsLayer = document.createElement('swp-events-layer'); column.appendChild(eventsLayer); } // Clear existing events eventsLayer.innerHTML = ''; // Filter to timed events only const timedEvents = columnEvents.filter(event => !event.allDay); // Calculate layout with stacking/grouping const layout = calculateColumnLayout(timedEvents, this.gridConfig); // Render GRID groups (simultaneous events side-by-side) layout.grids.forEach(grid => { const groupEl = this.renderGridGroup(grid); eventsLayer!.appendChild(groupEl); }); // Render STACKED events (overlapping with margin offset) layout.stacked.forEach(item => { const eventEl = this.renderStackedEvent(item.event, item.stackLevel); eventsLayer!.appendChild(eventEl); }); }); } /** * Create a single event element * * CLEAN approach: * - Only data-id for lookup * - Visible content in innerHTML only */ private createEventElement(event: ICalendarEvent): HTMLElement { const element = document.createElement('swp-event'); // Data attributes for SwpEvent compatibility element.dataset.eventId = event.id; if (event.resourceId) { element.dataset.resourceId = event.resourceId; } // Calculate position const position = calculateEventPosition(event.start, event.end, this.gridConfig); element.style.top = `${position.top}px`; element.style.height = `${position.height}px`; // Color class based on event type const colorClass = this.getColorClass(event); if (colorClass) { element.classList.add(colorClass); } // Visible content only element.innerHTML = ` ${this.dateService.formatTimeRange(event.start, event.end)} ${this.escapeHtml(event.title)} ${event.description ? `${this.escapeHtml(event.description)}` : ''} `; return element; } /** * Get color class based on metadata.color or event type */ private getColorClass(event: ICalendarEvent): string { // Check metadata.color first if (event.metadata?.color) { return `is-${event.metadata.color}`; } // Fallback to type-based color const typeColors: Record = { 'customer': 'is-blue', 'vacation': 'is-green', 'break': 'is-amber', 'meeting': 'is-purple', 'blocked': 'is-red' }; return typeColors[event.type] || 'is-blue'; } /** * Escape HTML to prevent XSS */ private escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Render a GRID group with side-by-side columns * Used when multiple events start at the same time */ private renderGridGroup(layout: IGridGroupLayout): HTMLElement { const group = document.createElement('swp-event-group'); group.classList.add(`cols-${layout.columns.length}`); group.style.top = `${layout.position.top}px`; // Stack level styling for entire group (if nested in another event) if (layout.stackLevel > 0) { group.style.marginLeft = `${layout.stackLevel * 15}px`; group.style.zIndex = `${100 + layout.stackLevel}`; } // Calculate the height needed for the group (tallest event) let maxBottom = 0; for (const event of layout.events) { const pos = calculateEventPosition(event.start, event.end, this.gridConfig); const eventBottom = pos.top + pos.height; if (eventBottom > maxBottom) maxBottom = eventBottom; } const groupHeight = maxBottom - layout.position.top; group.style.height = `${groupHeight}px`; // Create wrapper div for each column layout.columns.forEach(columnEvents => { const wrapper = document.createElement('div'); wrapper.style.position = 'relative'; columnEvents.forEach(event => { const eventEl = this.createEventElement(event); // Position relative to group top const pos = calculateEventPosition(event.start, event.end, this.gridConfig); eventEl.style.top = `${pos.top - layout.position.top}px`; eventEl.style.position = 'absolute'; eventEl.style.left = '0'; eventEl.style.right = '0'; wrapper.appendChild(eventEl); }); group.appendChild(wrapper); }); return group; } /** * Render a STACKED event with margin-left offset * Used for overlapping events that don't start at the same time */ private renderStackedEvent(event: ICalendarEvent, stackLevel: number): HTMLElement { const element = this.createEventElement(event); // Add stack metadata for drag-drop and other features element.dataset.stackLink = JSON.stringify({ stackLevel }); // Visual styling based on stack level if (stackLevel > 0) { element.style.marginLeft = `${stackLevel * 15}px`; element.style.zIndex = `${100 + stackLevel}`; } return element; } }