// All-day row height management and animations import { eventBus } from '../core/EventBus'; import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig'; import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer'; import { AllDayLayoutEngine, EventLayout } from '../utils/AllDayLayoutEngine'; import { ColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils'; import { CalendarEvent } from '../types/CalendarTypes'; import { SwpAllDayEventElement } from '../elements/SwpEventElement'; import { DragMouseEnterHeaderEventPayload, DragStartEventPayload, DragMoveEventPayload, DragEndEventPayload, DragColumnChangeEventPayload, HeaderReadyEventPayload } from '../types/EventTypes'; import { DragOffset, MousePosition } from '../types/DragDropTypes'; import { CoreEvents } from '../constants/CoreEvents'; import { EventManager } from './EventManager'; import { differenceInCalendarDays } from 'date-fns'; 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 layoutEngine: AllDayLayoutEngine | null = null; // State tracking for differential updates private currentLayouts: EventLayout[] = []; private currentAllDayEvents: CalendarEvent[] = []; private currentWeekDates: ColumnBounds[] = []; private newLayouts: EventLayout[] = []; // Expand/collapse state private isExpanded: boolean = false; private actualRowCount: number = 0; constructor( eventManager: EventManager, allDayEventRenderer: AllDayEventRenderer, dateService: DateService ) { this.eventManager = eventManager; this.allDayEventRenderer = allDayEventRenderer; this.dateService = dateService; // 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: DragStartEventPayload = (event as CustomEvent).detail; if (!payload.draggedClone?.hasAttribute('data-allday')) { return; } this.allDayEventRenderer.handleDragStart(payload); }); eventBus.on('drag:column-change', (event) => { let payload: DragColumnChangeEventPayload = (event as CustomEvent).detail; if (!payload.draggedClone?.hasAttribute('data-allday')) { return; } this.handleColumnChange(payload); }); eventBus.on('drag:end', (event) => { let draggedElement: DragEndEventPayload = (event as CustomEvent).detail; if (draggedElement.target != 'swp-day-header') // we are not inside the swp-day-header, so just ignore. return; this.handleDragEnd(draggedElement); }); // 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', (event: Event) => { let headerReadyEventPayload = (event as CustomEvent).detail; let startDate = new Date(headerReadyEventPayload.headerElements.at(0)!.date); let endDate = new Date(headerReadyEventPayload.headerElements.at(-1)!.date); let events: CalendarEvent[] = this.eventManager.getEventsForPeriod(startDate, endDate); // Filter for all-day events const allDayEvents = events.filter(event => event.allDay); this.currentLayouts = this.calculateAllDayEventsLayout(allDayEvents, headerReadyEventPayload.headerElements) this.allDayEventRenderer.renderAllDayEventsForPeriod(this.currentLayouts); 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'); } /** * 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 + 2; // 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 }; } /** * Collapse all-day row when no events */ public collapseAllDayRow(): void { this.animateToRows(0); } /** * Check current all-day events and animate to correct height */ public checkAndAnimateAllDayHeight(): void { // Calculate required rows - 0 if no events (will collapse) let maxRows = 0; if (this.currentLayouts.length > 0) { // Find the HIGHEST row number in use from currentLayouts let highestRow = 0; this.currentLayouts.forEach((layout) => { highestRow = Math.max(highestRow, layout.row); }); // Max rows = highest row number (e.g. if row 3 is used, height = 3 rows) maxRows = highestRow; } // 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(); } // 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: CalendarEvent[], dayHeaders: ColumnBounds[]): EventLayout[] { // Store current state this.currentAllDayEvents = events; this.currentWeekDates = dayHeaders; // Initialize layout engine with provided week dates let layoutEngine = new AllDayLayoutEngine(dayHeaders.map(column => column.date)); // Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly return layoutEngine.calculateLayout(events); } private handleConvertToAllDay(payload: DragMouseEnterHeaderEventPayload): void { let allDayContainer = this.getAllDayContainer(); if (!allDayContainer) return; // Create SwpAllDayEventElement from CalendarEvent 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(); } /** * Handle drag move for all-day events - SPECIALIZED FOR ALL-DAY CONTAINER */ private handleColumnChange(dragColumnChangeEventPayload: DragColumnChangeEventPayload): 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 { element.style.transition = 'opacity 0.3s ease-out'; element.style.opacity = '0'; setTimeout(() => { element.remove(); }, 300); } private handleDragEnd(dragEndEvent: DragEndEventPayload): void { const getEventDurationDays = (start: string | undefined, end: string | undefined): number => { if (!start || !end) throw new Error('Undefined start or end - date'); const startDate = new Date(start); const endDate = new Date(end); if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { throw new Error('Ugyldig start eller slut-dato i dataset'); } // Use differenceInCalendarDays for proper calendar day calculation // This correctly handles timezone differences and DST changes return differenceInCalendarDays(endDate, startDate); }; if (dragEndEvent.draggedClone == null) return; // 2. Normalize clone ID dragEndEvent.draggedClone.dataset.eventId = dragEndEvent.draggedClone.dataset.eventId?.replace('clone-', ''); dragEndEvent.draggedClone.style.pointerEvents = ''; // Re-enable pointer events dragEndEvent.originalElement.dataset.eventId += '_'; let eventId = dragEndEvent.draggedClone.dataset.eventId; let eventDate = dragEndEvent.finalPosition.column?.date; let eventType = dragEndEvent.draggedClone.dataset.type; if (eventDate == null || eventId == null || eventType == null) return; const durationDays = getEventDurationDays(dragEndEvent.draggedClone.dataset.start, dragEndEvent.draggedClone.dataset.end); // Get original dates to preserve time const originalStartDate = new Date(dragEndEvent.draggedClone.dataset.start!); const originalEndDate = new Date(dragEndEvent.draggedClone.dataset.end!); // Create new start date with the new day but preserve original time const newStartDate = new Date(eventDate); newStartDate.setHours(originalStartDate.getHours(), originalStartDate.getMinutes(), originalStartDate.getSeconds(), originalStartDate.getMilliseconds()); // Create new end date with the new day + duration, preserving original end time const newEndDate = new Date(eventDate); newEndDate.setDate(newEndDate.getDate() + durationDays); newEndDate.setHours(originalEndDate.getHours(), originalEndDate.getMinutes(), originalEndDate.getSeconds(), originalEndDate.getMilliseconds()); // Update data attributes with new dates (convert to UTC) dragEndEvent.draggedClone.dataset.start = this.dateService.toUTC(newStartDate); dragEndEvent.draggedClone.dataset.end = this.dateService.toUTC(newEndDate); const droppedEvent: CalendarEvent = { id: eventId, title: dragEndEvent.draggedClone.dataset.title || '', start: newStartDate, end: newEndDate, type: eventType, allDay: true, syncStatus: 'synced' }; // Use current events + dropped event for calculation const tempEvents = [ ...this.currentAllDayEvents.filter(event => event.id !== eventId), droppedEvent ]; // 4. Calculate new layouts for ALL events this.newLayouts = this.calculateAllDayEventsLayout(tempEvents, this.currentWeekDates); // 5. Apply differential updates - only update events that changed let changedCount = 0; let container = this.getAllDayContainer(); this.newLayouts.forEach((layout) => { // Find current layout for this event let currentLayout = this.currentLayouts.find(old => old.calenderEvent.id === layout.calenderEvent.id); if (currentLayout?.gridArea !== layout.gridArea) { changedCount++; let element = container?.querySelector(`[data-event-id="${layout.calenderEvent.id}"]`) as HTMLElement; if (element) { element.classList.add('transitioning'); element.style.gridArea = layout.gridArea; element.style.gridRow = layout.row.toString(); element.style.gridColumn = `${layout.startColumn} / ${layout.endColumn + 1}`; element.classList.remove('max-event-overflow-hide'); element.classList.remove('max-event-overflow-show'); if (layout.row > ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS) if (!this.isExpanded) element.classList.add('max-event-overflow-hide'); else element.classList.add('max-event-overflow-show'); // Remove transition class after animation setTimeout(() => element.classList.remove('transitioning'), 200); } } }); if (changedCount > 0) this.currentLayouts = this.newLayouts; // 6. Clean up drag styles from the dropped clone dragEndEvent.draggedClone.classList.remove('dragging'); dragEndEvent.draggedClone.style.zIndex = ''; dragEndEvent.draggedClone.style.cursor = ''; dragEndEvent.draggedClone.style.opacity = ''; // 7. Apply highlight class to show the dropped event with highlight color dragEndEvent.draggedClone.classList.add('highlight'); this.fadeOutAndRemove(dragEndEvent.originalElement); 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 ColumnBounds */ private countEventsInColumn(columnBounds: ColumnBounds): number { let columnIndex = columnBounds.index; let count = 0; this.currentLayouts.forEach((layout) => { // Check if event spans this column if (layout.startColumn <= columnIndex && layout.endColumn >= columnIndex) { count++; } }); return count; } /** * 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(); }); } }