Calendar/src/managers/AllDayManager.ts
Janus C. H. Knudsen 73e284660f Adds EventId type for robust event ID handling
Introduces type-safe EventId with centralized normalization logic for clone and standard event IDs

Refactors event ID management across multiple components to use consistent ID transformation methods
Improves type safety and reduces potential ID-related bugs in drag-and-drop and event rendering
2025-12-03 14:43:25 +01:00

744 lines
No EOL
25 KiB
TypeScript

// 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';
import { EventId } from '../types/EventId';
/**
* 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<IDragMouseEnterHeaderEventPayload>).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<IDragStartEventPayload>).detail;
if (!payload.draggedClone?.hasAttribute('data-allday')) {
return;
}
this.allDayEventRenderer.handleDragStart(payload);
});
eventBus.on('drag:column-change', (event) => {
let payload: IDragColumnChangeEventPayload = (event as CustomEvent<IDragColumnChangeEventPayload>).detail;
if (!payload.draggedClone?.hasAttribute('data-allday')) {
return;
}
this.handleColumnChange(payload);
});
eventBus.on('drag:end', (event) => {
let dragEndPayload: IDragEndEventPayload = (event as CustomEvent<IDragEndEventPayload>).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<IHeaderReadyEventPayload>).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<void> {
if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return;
const clone = dragEndEvent.draggedClone as SwpAllDayEventElement;
const eventId = EventId.from(clone.eventId);
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<void> {
if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return;
const clone = dragEndEvent.draggedClone as SwpAllDayEventElement;
const eventId = EventId.from(clone.eventId);
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 = `
<svg width="12" height="8" viewBox="0 0 12 8">
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
`;
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 = `<span>+${overflowCount + 1} more</span>`;
} 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 = `<span>+${overflowCount + 1} more</span>`;
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();
});
}
}