Calendar/src/managers/AllDayManager.ts

617 lines
21 KiB
TypeScript
Raw Normal View History

// 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
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) => {
const payload = (event as CustomEvent<DragMouseEnterHeaderEventPayload>).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-10-02 23:11:26 +02:00
let payload: DragStartEventPayload = (event as CustomEvent<DragStartEventPayload>).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-10-02 23:11:26 +02:00
let payload: DragColumnChangeEventPayload = (event as CustomEvent<DragColumnChangeEventPayload>).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 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);
2025-10-01 22:38:15 +02:00
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();
2025-09-16 23:09:56 +02:00
payload.draggedClone.removeAttribute('style');
payload.draggedClone.style.gridRow = '1';
payload.draggedClone.style.gridColumn = payload.targetColumn.index.toString();
2025-10-02 23:11:26 +02:00
payload.draggedClone.dataset.allday = 'true';
allDayContainer?.appendChild(payload.draggedClone);
ColumnDetectionUtils.updateColumnBoundsCache();
}
/**
* Handle drag move for all-day events - SPECIALIZED FOR ALL-DAY CONTAINER
*/
2025-10-02 23:11:26 +02:00
private handleColumnChange(dragColumnChangeEventPayload: DragColumnChangeEventPayload): void {
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 {
element.style.transition = 'opacity 0.3s ease-out';
element.style.opacity = '0';
2025-10-02 23:11:26 +02:00
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 += '_';
2025-10-02 23:11:26 +02:00
// 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);
2025-10-02 23:11:26 +02:00
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;
2025-10-02 23:11:26 +02:00
let container = this.getAllDayContainer();
this.newLayouts.forEach((layout) => {
// Find current layout for this event
2025-10-01 22:38:15 +02:00
let currentLayout = this.currentLayouts.find(old => old.calenderEvent.id === layout.calenderEvent.id);
if (currentLayout?.gridArea !== layout.gridArea) {
changedCount++;
2025-10-02 23:11:26 +02:00
let element = container?.querySelector(`[data-event-id="${layout.calenderEvent.id}"]`) as HTMLElement;
if (element) {
2025-10-02 23:11:26 +02:00
element.classList.add('transitioning');
element.style.gridArea = layout.gridArea;
element.style.gridRow = layout.row.toString();
element.style.gridColumn = `${layout.startColumn} / ${layout.endColumn + 1}`;
2025-10-02 23:11:26 +02:00
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
2025-10-02 23:11:26 +02:00
//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();
2025-10-02 15:57:11 +02:00
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();
});
}
}