// All-day row height management and animations import { eventBus } from '../core/EventBus'; import { ALL_DAY_CONSTANTS } from '../configurations/CalendarConfig'; import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer'; import { AllDayLayoutEngine, IEventLayout } from '../utils/AllDayLayoutEngine'; import { IColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils'; import { IColumnDataSource } from '../types/ColumnDataSource'; import { ICalendarEvent } from '../types/CalendarTypes'; import { CalendarEventType } from '../types/BookingTypes'; import { SwpAllDayEventElement } from '../elements/SwpEventElement'; import { IDragMouseEnterHeaderEventPayload, IDragMouseEnterColumnEventPayload, IDragStartEventPayload, IDragMoveEventPayload, IDragEndEventPayload, IDragColumnChangeEventPayload, IHeaderReadyEventPayload } from '../types/EventTypes'; import { IDragOffset, IMousePosition } from '../types/DragDropTypes'; import { CoreEvents } from '../constants/CoreEvents'; import { EventManager } from './EventManager'; import { DateService } from '../utils/DateService'; /** * AllDayManager - Handles all-day row height animations and management * Uses AllDayLayoutEngine for all overlap detection and layout calculation */ export class AllDayManager { private allDayEventRenderer: AllDayEventRenderer; private eventManager: EventManager; private dateService: DateService; private dataSource: IColumnDataSource; private layoutEngine: AllDayLayoutEngine | null = null; // State tracking for layout calculation private currentAllDayEvents: ICalendarEvent[] = []; private currentColumns: IColumnBounds[] = []; // Expand/collapse state private isExpanded: boolean = false; private actualRowCount: number = 0; constructor( eventManager: EventManager, allDayEventRenderer: AllDayEventRenderer, dateService: DateService, dataSource: IColumnDataSource ) { this.eventManager = eventManager; this.allDayEventRenderer = allDayEventRenderer; this.dateService = dateService; this.dataSource = dataSource; // Sync CSS variable with TypeScript constant to ensure consistency document.documentElement.style.setProperty('--single-row-height', `${ALL_DAY_CONSTANTS.EVENT_HEIGHT}px`); this.setupEventListeners(); } /** * Setup event listeners for drag conversions */ private setupEventListeners(): void { eventBus.on('drag:mouseenter-header', (event) => { const payload = (event as CustomEvent).detail; if (payload.draggedClone.hasAttribute('data-allday')) return; console.log('🔄 AllDayManager: Received drag:mouseenter-header', { targetDate: payload.targetColumn, originalElementId: payload.originalElement?.dataset?.eventId, originalElementTag: payload.originalElement?.tagName }); this.handleConvertToAllDay(payload); }); eventBus.on('drag:mouseleave-header', (event) => { const { originalElement, cloneElement } = (event as CustomEvent).detail; console.log('🚪 AllDayManager: Received drag:mouseleave-header', { originalElementId: originalElement?.dataset?.eventId }); }); // Listen for drag operations on all-day events eventBus.on('drag:start', (event) => { let payload: IDragStartEventPayload = (event as CustomEvent).detail; if (!payload.draggedClone?.hasAttribute('data-allday')) { return; } this.allDayEventRenderer.handleDragStart(payload); }); eventBus.on('drag:column-change', (event) => { let payload: IDragColumnChangeEventPayload = (event as CustomEvent).detail; if (!payload.draggedClone?.hasAttribute('data-allday')) { return; } this.handleColumnChange(payload); }); eventBus.on('drag:end', (event) => { let dragEndPayload: IDragEndEventPayload = (event as CustomEvent).detail; console.log('🎯 AllDayManager: drag:end received', { target: dragEndPayload.target, originalElementTag: dragEndPayload.originalElement?.tagName, hasAllDayAttribute: dragEndPayload.originalElement?.hasAttribute('data-allday'), eventId: dragEndPayload.originalElement?.dataset.eventId }); // Handle all-day → all-day drops (within header) if (dragEndPayload.target === 'swp-day-header' && dragEndPayload.originalElement?.hasAttribute('data-allday')) { console.log('✅ AllDayManager: Handling all-day → all-day drop'); this.handleDragEnd(dragEndPayload); return; } // Handle timed → all-day conversion (dropped in header) if (dragEndPayload.target === 'swp-day-header' && !dragEndPayload.originalElement?.hasAttribute('data-allday')) { console.log('🔄 AllDayManager: Timed → all-day conversion on drop'); this.handleTimedToAllDayDrop(dragEndPayload); return; } // Handle all-day → timed conversion (dropped in column) if (dragEndPayload.target === 'swp-day-column' && dragEndPayload.originalElement?.hasAttribute('data-allday')) { const eventId = dragEndPayload.originalElement.dataset.eventId; console.log('🔄 AllDayManager: All-day → timed conversion', { eventId }); // Mark for removal (sets data-removing attribute) this.fadeOutAndRemove(dragEndPayload.originalElement); // Recalculate layout WITHOUT the removed event to compress gaps const remainingEvents = this.currentAllDayEvents.filter(e => e.id !== eventId); const newLayouts = this.calculateAllDayEventsLayout(remainingEvents, this.currentColumns); // Re-render all-day events with compressed layout this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts); // NOW animate height with compressed layout this.checkAndAnimateAllDayHeight(); } }); // Listen for drag cancellation to recalculate height eventBus.on('drag:cancelled', (event) => { const { draggedElement, reason } = (event as CustomEvent).detail; console.log('🚫 AllDayManager: Drag cancelled', { eventId: draggedElement?.dataset?.eventId, reason }); }); // Listen for header ready - when dates are populated with period data eventBus.on('header:ready', async (event: Event) => { let headerReadyEventPayload = (event as CustomEvent).detail; let startDate = this.dateService.parseISO(headerReadyEventPayload.headerElements.at(0)!.identifier); let endDate = this.dateService.parseISO(headerReadyEventPayload.headerElements.at(-1)!.identifier); let events: ICalendarEvent[] = await this.eventManager.getEventsForPeriod(startDate, endDate); // Filter for all-day events const allDayEvents = events.filter(event => event.allDay); const layouts = this.calculateAllDayEventsLayout(allDayEvents, headerReadyEventPayload.headerElements); this.allDayEventRenderer.renderAllDayEventsForPeriod(layouts); this.checkAndAnimateAllDayHeight(); }); eventBus.on(CoreEvents.VIEW_CHANGED, (event: Event) => { this.allDayEventRenderer.handleViewChanged(event as CustomEvent); }); } private getAllDayContainer(): HTMLElement | null { return document.querySelector('swp-calendar-header swp-allday-container'); } private getCalendarHeader(): HTMLElement | null { return document.querySelector('swp-calendar-header'); } private getHeaderSpacer(): HTMLElement | null { return document.querySelector('swp-header-spacer'); } /** * Read current max row from DOM elements * Excludes events marked as removing (data-removing attribute) */ private getMaxRowFromDOM(): number { const container = this.getAllDayContainer(); if (!container) return 0; let maxRow = 0; const allDayEvents = container.querySelectorAll('swp-allday-event:not(.max-event-indicator):not([data-removing])'); allDayEvents.forEach((element: Element) => { const htmlElement = element as HTMLElement; const row = parseInt(htmlElement.style.gridRow) || 1; maxRow = Math.max(maxRow, row); }); return maxRow; } /** * Get current gridArea for an event from DOM */ private getGridAreaFromDOM(eventId: string): string | null { const container = this.getAllDayContainer(); if (!container) return null; const element = container.querySelector(`[data-event-id="${eventId}"]`) as HTMLElement; return element?.style.gridArea || null; } /** * Count events in a specific column by reading DOM */ private countEventsInColumnFromDOM(columnIndex: number): number { const container = this.getAllDayContainer(); if (!container) return 0; let count = 0; const allDayEvents = container.querySelectorAll('swp-allday-event:not(.max-event-indicator)'); allDayEvents.forEach((element: Element) => { const htmlElement = element as HTMLElement; const gridColumn = htmlElement.style.gridColumn; // Parse "1 / 3" format const match = gridColumn.match(/(\d+)\s*\/\s*(\d+)/); if (match) { const startCol = parseInt(match[1]); const endCol = parseInt(match[2]) - 1; // End is exclusive in CSS if (startCol <= columnIndex && endCol >= columnIndex) { count++; } } }); return count; } /** * Calculate all-day height based on number of rows */ private calculateAllDayHeight(targetRows: number): { targetHeight: number; currentHeight: number; heightDifference: number; } { const root = document.documentElement; const targetHeight = targetRows * ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT; // Read CSS variable directly from style property or default to 0 const currentHeightStr = root.style.getPropertyValue('--all-day-row-height') || '0px'; const currentHeight = parseInt(currentHeightStr) || 0; const heightDifference = targetHeight - currentHeight; return { targetHeight, currentHeight, heightDifference }; } /** * Check current all-day events and animate to correct height * Reads max row directly from DOM elements */ public checkAndAnimateAllDayHeight(): void { // Read max row directly from DOM const maxRows = this.getMaxRowFromDOM(); console.log('📊 AllDayManager: Height calculation', { maxRows, isExpanded: this.isExpanded }); // Store actual row count this.actualRowCount = maxRows; // Determine what to display let displayRows = maxRows; if (maxRows > ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS) { // Show chevron button this.updateChevronButton(true); // Show 4 rows when collapsed (3 events + indicators) if (!this.isExpanded) { displayRows = ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS; this.updateOverflowIndicators(); } else { this.clearOverflowIndicators(); } } else { // Hide chevron - not needed this.updateChevronButton(false); this.clearOverflowIndicators(); } console.log('🎬 AllDayManager: Will animate to', { displayRows, maxRows, willAnimate: displayRows !== this.actualRowCount }); console.log(`🎯 AllDayManager: Animating to ${displayRows} rows`); // Animate to required rows (0 = collapse, >0 = expand) this.animateToRows(displayRows); } /** * Animate all-day container to specific number of rows */ public animateToRows(targetRows: number): void { const { targetHeight, currentHeight, heightDifference } = this.calculateAllDayHeight(targetRows); if (targetHeight === currentHeight) return; // No animation needed console.log(`🎬 All-day height animation: ${currentHeight}px → ${targetHeight}px (${Math.ceil(currentHeight / ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT)} → ${targetRows} rows)`); // Get cached elements const calendarHeader = this.getCalendarHeader(); const headerSpacer = this.getHeaderSpacer(); const allDayContainer = this.getAllDayContainer(); if (!calendarHeader || !allDayContainer) return; // Get current parent height for animation const currentParentHeight = parseFloat(getComputedStyle(calendarHeader).height); const targetParentHeight = currentParentHeight + heightDifference; const animations = [ calendarHeader.animate([ { height: `${currentParentHeight}px` }, { height: `${targetParentHeight}px` } ], { duration: 150, easing: 'ease-out', fill: 'forwards' }) ]; // Add spacer animation if spacer exists, but don't use fill: 'forwards' if (headerSpacer) { const root = document.documentElement; const headerHeightStr = root.style.getPropertyValue('--header-height'); const headerHeight = parseInt(headerHeightStr); const currentSpacerHeight = headerHeight + currentHeight; const targetSpacerHeight = headerHeight + targetHeight; animations.push( headerSpacer.animate([ { height: `${currentSpacerHeight}px` }, { height: `${targetSpacerHeight}px` } ], { duration: 150, easing: 'ease-out' // No fill: 'forwards' - let CSS calc() take over after animation }) ); } // Update CSS variable after animation Promise.all(animations.map(anim => anim.finished)).then(() => { const root = document.documentElement; root.style.setProperty('--all-day-row-height', `${targetHeight}px`); eventBus.emit('header:height-changed'); }); } /** * Calculate layout for ALL all-day events using AllDayLayoutEngine * This is the correct method that processes all events together for proper overlap detection */ private calculateAllDayEventsLayout(events: ICalendarEvent[], dayHeaders: IColumnBounds[]): IEventLayout[] { // Store current state this.currentAllDayEvents = events; this.currentColumns = dayHeaders; // Map IColumnBounds to IColumnInfo structure (identifier + groupId) const columns = dayHeaders.map(column => ({ identifier: column.identifier, groupId: column.element.dataset.groupId || column.identifier, data: new Date(), // Not used by AllDayLayoutEngine events: [] // Not used by AllDayLayoutEngine })); // Initialize layout engine with column info including groupId let layoutEngine = new AllDayLayoutEngine(columns); // Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly return layoutEngine.calculateLayout(events); } private handleConvertToAllDay(payload: IDragMouseEnterHeaderEventPayload): void { let allDayContainer = this.getAllDayContainer(); if (!allDayContainer) return; // Create SwpAllDayEventElement from ICalendarEvent const allDayElement = SwpAllDayEventElement.fromCalendarEvent(payload.calendarEvent); // Apply grid positioning allDayElement.style.gridRow = '1'; allDayElement.style.gridColumn = payload.targetColumn.index.toString(); // Remove old swp-event clone payload.draggedClone.remove(); // Call delegate to update DragDropManager's draggedClone reference payload.replaceClone(allDayElement); // Append to container allDayContainer.appendChild(allDayElement); ColumnDetectionUtils.updateColumnBoundsCache(); // Recalculate height after adding all-day event this.checkAndAnimateAllDayHeight(); } /** * Handle drag move for all-day events - SPECIALIZED FOR ALL-DAY CONTAINER */ private handleColumnChange(dragColumnChangeEventPayload: IDragColumnChangeEventPayload): void { let allDayContainer = this.getAllDayContainer(); if (!allDayContainer) return; let targetColumn = ColumnDetectionUtils.getColumnBounds(dragColumnChangeEventPayload.mousePosition); if (targetColumn == null) return; if (!dragColumnChangeEventPayload.draggedClone) return; // Calculate event span from original grid positioning const computedStyle = window.getComputedStyle(dragColumnChangeEventPayload.draggedClone); const gridColumnStart = parseInt(computedStyle.gridColumnStart) || targetColumn.index; const gridColumnEnd = parseInt(computedStyle.gridColumnEnd) || targetColumn.index + 1; const span = gridColumnEnd - gridColumnStart; // Update clone position maintaining the span const newStartColumn = targetColumn.index; const newEndColumn = newStartColumn + span; dragColumnChangeEventPayload.draggedClone.style.gridColumn = `${newStartColumn} / ${newEndColumn}`; } private fadeOutAndRemove(element: HTMLElement): void { console.log('🗑️ AllDayManager: About to remove all-day event', { eventId: element.dataset.eventId, element: element.tagName }); // Mark element as removing so it's excluded from height calculations element.setAttribute('data-removing', 'true'); element.style.transition = 'opacity 0.3s ease-out'; element.style.opacity = '0'; setTimeout(() => { element.remove(); console.log('✅ AllDayManager: All-day event removed from DOM'); }, 300); } /** * Handle timed → all-day conversion on drop */ private async handleTimedToAllDayDrop(dragEndEvent: IDragEndEventPayload): Promise { if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return; const clone = dragEndEvent.draggedClone as SwpAllDayEventElement; const eventId = clone.eventId.replace('clone-', ''); const columnIdentifier = dragEndEvent.finalPosition.column.identifier; // Determine target date based on mode let targetDate: Date; let resourceId: string | undefined; if (this.dataSource.isResource()) { // Resource mode: keep event's existing date, set resourceId targetDate = clone.start; resourceId = columnIdentifier; } else { // Date mode: parse date from column identifier targetDate = this.dateService.parseISO(columnIdentifier); } console.log('🔄 AllDayManager: Converting timed event to all-day', { eventId, targetDate, resourceId }); // Create new dates preserving time const newStart = new Date(targetDate); newStart.setHours(clone.start.getHours(), clone.start.getMinutes(), 0, 0); const newEnd = new Date(targetDate); newEnd.setHours(clone.end.getHours(), clone.end.getMinutes(), 0, 0); // Build update payload const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = { start: newStart, end: newEnd, allDay: true }; if (resourceId) { updatePayload.resourceId = resourceId; } // Update event in repository await this.eventManager.updateEvent(eventId, updatePayload); // Remove original timed event this.fadeOutAndRemove(dragEndEvent.originalElement); // Add to current all-day events and recalculate layout const newEvent: ICalendarEvent = { id: eventId, title: clone.title, start: newStart, end: newEnd, type: clone.type as CalendarEventType, allDay: true, syncStatus: 'synced' }; const updatedEvents = [...this.currentAllDayEvents, newEvent]; const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentColumns); this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts); // Animate height this.checkAndAnimateAllDayHeight(); } /** * Handle all-day → all-day drop (moving within header) */ private async handleDragEnd(dragEndEvent: IDragEndEventPayload): Promise { if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return; const clone = dragEndEvent.draggedClone as SwpAllDayEventElement; const eventId = clone.eventId.replace('clone-', ''); const columnIdentifier = dragEndEvent.finalPosition.column.identifier; // Determine target date based on mode let targetDate: Date; let resourceId: string | undefined; if (this.dataSource.isResource()) { // Resource mode: keep event's existing date, set resourceId targetDate = clone.start; resourceId = columnIdentifier; } else { // Date mode: parse date from column identifier targetDate = this.dateService.parseISO(columnIdentifier); } // Calculate duration in days const durationDays = this.dateService.differenceInCalendarDays(clone.end, clone.start); // Create new dates preserving time const newStart = new Date(targetDate); newStart.setHours(clone.start.getHours(), clone.start.getMinutes(), 0, 0); const newEnd = new Date(targetDate); newEnd.setDate(newEnd.getDate() + durationDays); newEnd.setHours(clone.end.getHours(), clone.end.getMinutes(), 0, 0); // Build update payload const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = { start: newStart, end: newEnd, allDay: true }; if (resourceId) { updatePayload.resourceId = resourceId; } // Update event in repository await this.eventManager.updateEvent(eventId, updatePayload); // Remove original and fade out this.fadeOutAndRemove(dragEndEvent.originalElement); // Recalculate and re-render ALL events const updatedEvents = this.currentAllDayEvents.map(e => e.id === eventId ? { ...e, start: newStart, end: newEnd } : e ); const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentColumns); this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts); // Animate height - this also handles overflow classes! this.checkAndAnimateAllDayHeight(); } /** * Update chevron button visibility and state */ private updateChevronButton(show: boolean): void { const headerSpacer = this.getHeaderSpacer(); if (!headerSpacer) return; let chevron = headerSpacer.querySelector('.allday-chevron') as HTMLElement; if (show && !chevron) { chevron = document.createElement('button'); chevron.className = 'allday-chevron collapsed'; chevron.innerHTML = ` `; chevron.onclick = () => this.toggleExpanded(); headerSpacer.appendChild(chevron); } else if (!show && chevron) { chevron.remove(); } else if (chevron) { chevron.classList.toggle('collapsed', !this.isExpanded); chevron.classList.toggle('expanded', this.isExpanded); } } /** * Toggle between expanded and collapsed state */ private toggleExpanded(): void { this.isExpanded = !this.isExpanded; this.checkAndAnimateAllDayHeight(); const elements = document.querySelectorAll('swp-allday-container swp-allday-event.max-event-overflow-hide, swp-allday-container swp-allday-event.max-event-overflow-show'); elements.forEach((element) => { if (this.isExpanded) { // ALTID vis når expanded=true element.classList.remove('max-event-overflow-hide'); element.classList.add('max-event-overflow-show'); } else { // ALTID skjul når expanded=false element.classList.remove('max-event-overflow-show'); element.classList.add('max-event-overflow-hide'); } }); } /** * Count number of events in a specific column using IColumnBounds * Reads directly from DOM elements */ private countEventsInColumn(columnBounds: IColumnBounds): number { return this.countEventsInColumnFromDOM(columnBounds.index); } /** * Update overflow indicators for collapsed state */ private updateOverflowIndicators(): void { const container = this.getAllDayContainer(); if (!container) return; // Create overflow indicators for each column that needs them let columns = ColumnDetectionUtils.getColumns(); columns.forEach((columnBounds) => { let totalEventsInColumn = this.countEventsInColumn(columnBounds); let overflowCount = totalEventsInColumn - ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS if (overflowCount > 0) { // Check if indicator already exists in this column let existingIndicator = container.querySelector(`.max-event-indicator[data-column="${columnBounds.index}"]`) as HTMLElement; if (existingIndicator) { // Update existing indicator existingIndicator.innerHTML = `+${overflowCount + 1} more`; } else { // Create new overflow indicator element let overflowElement = document.createElement('swp-allday-event'); overflowElement.className = 'max-event-indicator'; overflowElement.setAttribute('data-column', columnBounds.index.toString()); overflowElement.style.gridRow = ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS.toString(); overflowElement.style.gridColumn = columnBounds.index.toString(); overflowElement.innerHTML = `+${overflowCount + 1} more`; overflowElement.onclick = (e) => { e.stopPropagation(); this.toggleExpanded(); }; container.appendChild(overflowElement); } } }); } /** * Clear overflow indicators and restore normal state */ private clearOverflowIndicators(): void { const container = this.getAllDayContainer(); if (!container) return; // Remove all overflow indicator elements container.querySelectorAll('.max-event-indicator').forEach((element) => { element.remove(); }); } }