Calendar/src/managers/AllDayManager.ts

691 lines
24 KiB
TypeScript
Raw Normal View History

// All-day row height management and animations
import { eventBus } from '../core/EventBus';
2025-11-03 22:04:37 +01:00
import { ALL_DAY_CONSTANTS } from '../configurations/CalendarConfig';
import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer';
2025-11-03 21:30:50 +01:00
import { AllDayLayoutEngine, IEventLayout } from '../utils/AllDayLayoutEngine';
import { IColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
import { ICalendarEvent } from '../types/CalendarTypes';
import { CalendarEventType } from '../types/BookingTypes';
2025-10-04 16:20:09 +02:00
import { SwpAllDayEventElement } from '../elements/SwpEventElement';
import {
2025-11-03 21:30:50 +01:00
IDragMouseEnterHeaderEventPayload,
IDragMouseEnterColumnEventPayload,
2025-11-03 21:30:50 +01:00
IDragStartEventPayload,
IDragMoveEventPayload,
IDragEndEventPayload,
IDragColumnChangeEventPayload,
IHeaderReadyEventPayload
} from '../types/EventTypes';
2025-11-03 21:30:50 +01:00
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 layoutEngine: AllDayLayoutEngine | null = null;
// State tracking for layout calculation
2025-11-03 21:30:50 +01:00
private currentAllDayEvents: ICalendarEvent[] = [];
private currentWeekDates: IColumnBounds[] = [];
// 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
2025-10-02 23:11:26 +02:00
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) => {
2025-11-03 21:30:50 +01:00
const payload = (event as CustomEvent<IDragMouseEnterHeaderEventPayload>).detail;
2025-10-02 23:11:26 +02:00
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) => {
2025-11-03 21:30:50 +01:00
let payload: IDragStartEventPayload = (event as CustomEvent<IDragStartEventPayload>).detail;
2025-10-02 23:11:26 +02:00
if (!payload.draggedClone?.hasAttribute('data-allday')) {
return;
}
2025-10-02 23:11:26 +02:00
this.allDayEventRenderer.handleDragStart(payload);
});
eventBus.on('drag:column-change', (event) => {
2025-11-03 21:30:50 +01:00
let payload: IDragColumnChangeEventPayload = (event as CustomEvent<IDragColumnChangeEventPayload>).detail;
2025-10-02 23:11:26 +02:00
if (!payload.draggedClone?.hasAttribute('data-allday')) {
return;
}
2025-10-02 23:11:26 +02:00
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.currentWeekDates);
// 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) => {
2025-11-03 21:30:50 +01:00
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
*/
2025-11-03 21:30:50 +01:00
private calculateAllDayEventsLayout(events: ICalendarEvent[], dayHeaders: IColumnBounds[]): IEventLayout[] {
// Store current state
this.currentAllDayEvents = events;
this.currentWeekDates = dayHeaders;
// Initialize layout engine with provided week dates
let layoutEngine = new AllDayLayoutEngine(dayHeaders.map(column => column.identifier));
// Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly
return layoutEngine.calculateLayout(events);
}
2025-11-03 21:30:50 +01:00
private handleConvertToAllDay(payload: IDragMouseEnterHeaderEventPayload): void {
let allDayContainer = this.getAllDayContainer();
2025-10-04 16:20:09 +02:00
if (!allDayContainer) return;
2025-09-16 23:09:56 +02:00
2025-11-03 21:30:50 +01:00
// Create SwpAllDayEventElement from ICalendarEvent
2025-10-04 16:20:09 +02:00
const allDayElement = SwpAllDayEventElement.fromCalendarEvent(payload.calendarEvent);
2025-10-04 16:20:09 +02:00
// Apply grid positioning
allDayElement.style.gridRow = '1';
allDayElement.style.gridColumn = payload.targetColumn.index.toString();
// Remove old swp-event clone
2025-10-04 16:20:09 +02:00
payload.draggedClone.remove();
// Call delegate to update DragDropManager's draggedClone reference
payload.replaceClone(allDayElement);
// Append to container
2025-10-04 16:20:09 +02:00
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
*/
2025-11-03 21:30:50 +01:00
private handleColumnChange(dragColumnChangeEventPayload: IDragColumnChangeEventPayload): void {
2025-10-02 23:11:26 +02:00
let allDayContainer = this.getAllDayContainer();
if (!allDayContainer) return;
2025-10-02 23:11:26 +02:00
let targetColumn = ColumnDetectionUtils.getColumnBounds(dragColumnChangeEventPayload.mousePosition);
if (targetColumn == null)
return;
2025-10-02 23:11:26 +02:00
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}`;
}
2025-10-02 23:11:26 +02:00
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');
2025-10-02 23:11:26 +02:00
element.style.transition = 'opacity 0.3s ease-out';
element.style.opacity = '0';
2025-10-02 23:11:26 +02:00
setTimeout(() => {
element.remove();
console.log('✅ AllDayManager: All-day event removed from DOM');
2025-10-02 23:11:26 +02:00
}, 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 = clone.eventId.replace('clone-', '');
const targetDate = this.dateService.parseISO(dragEndEvent.finalPosition.column.identifier);
console.log('🔄 AllDayManager: Converting timed event to all-day', { eventId, targetDate });
// 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);
// Update event in repository
await this.eventManager.updateEvent(eventId, {
start: newStart,
end: newEnd,
allDay: true
});
// 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.currentWeekDates);
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 = clone.eventId.replace('clone-', '');
const targetDate = this.dateService.parseISO(dragEndEvent.finalPosition.column.identifier);
// 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);
// Update event in repository
await this.eventManager.updateEvent(eventId, {
start: newStart,
end: newEnd,
allDay: true
});
// Remove original and fade out
2025-10-02 23:11:26 +02:00
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.currentWeekDates);
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();
2025-10-02 15:57:11 +02:00
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');
}
});
}
/**
2025-11-03 21:30:50 +01:00
* Count number of events in a specific column using IColumnBounds
* Reads directly from DOM elements
*/
2025-11-03 21:30:50 +01:00
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();
});
}
}