Calendar/src/managers/AllDayManager.ts
Janus C. H. Knudsen 9bc082eed4 Improves date handling and event stacking
Enhances date validation and timezone handling using DateService, ensuring data integrity and consistency.

Refactors event rendering and dragging to correctly handle date transformations.

Adds a test plan for event stacking and z-index management.

Fixes edge cases in navigation and date calculations for week/year boundaries and DST transitions.
2025-10-04 00:32:26 +02:00

617 lines
No EOL
21 KiB
TypeScript

// All-day row height management and animations
import { eventBus } from '../core/EventBus';
import { ALL_DAY_CONSTANTS, calendarConfig } 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 {
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) {
this.eventManager = eventManager;
this.allDayEventRenderer = new AllDayEventRenderer();
const timezone = calendarConfig.getTimezone?.();
this.dateService = new DateService(timezone);
// 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<DragMouseEnterHeaderEventPayload>).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<DragStartEventPayload>).detail;
if (!payload.draggedClone?.hasAttribute('data-allday')) {
return;
}
this.allDayEventRenderer.handleDragStart(payload);
});
eventBus.on('drag:column-change', (event) => {
let payload: DragColumnChangeEventPayload = (event as CustomEvent<DragColumnChangeEventPayload>).detail;
if (!payload.draggedClone?.hasAttribute('data-allday')) {
return;
}
this.handleColumnChange(payload);
});
eventBus.on('drag:end', (event) => {
let draggedElement: DragEndEventPayload = (event as CustomEvent<DragEndEventPayload>).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<HeaderReadyEventPayload>).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);
}
/**
* Handle conversion of timed event to all-day event - SIMPLIFIED
* During drag: Place in row 1 only, calculate column from targetDate
*/
private handleConvertToAllDay(payload: DragMouseEnterHeaderEventPayload): void {
let allDayContainer = this.getAllDayContainer();
payload.draggedClone.removeAttribute('style');
payload.draggedClone.style.gridRow = '1';
payload.draggedClone.style.gridColumn = payload.targetColumn.index.toString();
payload.draggedClone.dataset.allday = 'true';
allDayContainer?.appendChild(payload.draggedClone);
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.originalElement.dataset.eventId += '_';
// 3. Create temporary array with existing events + the dropped event
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;
// Calculate original event duration
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}`;
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. Restore original element opacity
//dragEndEvent.originalElement.remove(); //TODO: this should be an event that only fade and remove if confirmed dragdrop
this.fadeOutAndRemove(dragEndEvent.originalElement);
// 8. Check if height adjustment is needed
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) {
// Create chevron button
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) {
// Remove chevron button
chevron.remove();
} else if (chevron) {
// Update chevron state
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();
let elements = document.querySelectorAll('swp-allday-container swp-event.max-event-overflow-hide, swp-allday-container swp-event.max-event-overflow-show');
elements.forEach((element) => {
if (element.classList.contains('max-event-overflow-hide')) {
element.classList.remove('max-event-overflow-hide');
element.classList.add('max-event-overflow-show');
} else if (element.classList.contains('max-event-overflow-show')) {
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 = `<span>+${overflowCount + 1} more</span>`;
} else {
// Create new overflow indicator element
let overflowElement = document.createElement('swp-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();
});
}
}