Calendar/src/renderers/EventRenderer.ts
Janus C. H. Knudsen 6bbf2d8adb Refactors date handling with DateService
Replaces DateCalculator with DateService for improved date and time operations, including timezone handling.

This change enhances the calendar's accuracy and flexibility in managing dates, especially concerning timezone configurations.
It also corrects a typo in the `allDay` dataset attribute.
2025-10-03 20:50:40 +02:00

639 lines
22 KiB
TypeScript

// Event rendering strategy interface and implementations
import { CalendarEvent } from '../types/CalendarTypes';
import { calendarConfig } from '../core/CalendarConfig';
import { eventBus } from '../core/EventBus';
import { OverlapDetector, OverlapResult } from '../utils/OverlapDetector';
import { SwpEventElement } from '../elements/SwpEventElement';
import { TimeFormatter } from '../utils/TimeFormatter';
import { PositionUtils } from '../utils/PositionUtils';
import { DragOffset, StackLinkData } from '../types/DragDropTypes';
import { ColumnBounds } from '../utils/ColumnDetectionUtils';
import { DragColumnChangeEventPayload, DragMoveEventPayload, DragStartEventPayload } from '../types/EventTypes';
import { DateService } from '../utils/DateService';
import { format, setHours, setMinutes, setSeconds, addDays } from 'date-fns';
/**
* Interface for event rendering strategies
*/
export interface EventRendererStrategy {
renderEvents(events: CalendarEvent[], container: HTMLElement): void;
clearEvents(container?: HTMLElement): void;
handleDragStart?(payload: DragStartEventPayload): void;
handleDragMove?(payload: DragMoveEventPayload): void;
handleDragAutoScroll?(eventId: string, snappedY: number): void;
handleDragEnd?(eventId: string, originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: ColumnBounds, finalY: number): void;
handleEventClick?(eventId: string, originalElement: HTMLElement): void;
handleColumnChange?(payload: DragColumnChangeEventPayload): void;
handleNavigationCompleted?(): void;
}
// Abstract methods that subclasses must implement
// private getColumns(container: HTMLElement): HTMLElement[];
// private getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[];
/**
* Date-based event renderer
*/
export class DateEventRenderer implements EventRendererStrategy {
private dateService: DateService;
constructor() {
const timezone = calendarConfig.getTimezone?.() || 'Europe/Copenhagen';
this.dateService = new DateService(timezone);
this.setupDragEventListeners();
}
private draggedClone: HTMLElement | null = null;
private originalEvent: HTMLElement | null = null;
// ============================================
// NEW OVERLAP DETECTION SYSTEM
// All new functions prefixed with new_
// ============================================
protected overlapDetector = new OverlapDetector();
/**
* Ny hovedfunktion til at håndtere event overlaps
* @param events - Events der skal renderes i kolonnen
* @param container - Container element at rendere i
*/
protected handleEventOverlaps(events: CalendarEvent[], container: HTMLElement): void {
if (events.length === 0) return;
if (events.length === 1) {
const element = this.renderEvent(events[0]);
container.appendChild(element);
return;
}
// Track hvilke events der allerede er blevet processeret
const processedEvents = new Set<string>();
// Gå gennem hvert event og find overlaps
events.forEach((currentEvent, index) => {
// Skip events der allerede er processeret som del af en overlap gruppe
if (processedEvents.has(currentEvent.id)) {
return;
}
const remainingEvents = events.slice(index + 1);
const overlappingEvents = this.overlapDetector.resolveOverlap(currentEvent, remainingEvents);
if (overlappingEvents.length > 0) {
// Der er overlaps - opret stack links
const result = this.overlapDetector.decorateWithStackLinks(currentEvent, overlappingEvents);
this.renderOverlappingEvents(result, container);
// Marker alle events i overlap gruppen som processeret
overlappingEvents.forEach(event => processedEvents.add(event.id));
} else {
// Intet overlap - render normalt
const element = this.renderEvent(currentEvent);
container.appendChild(element);
processedEvents.add(currentEvent.id);
}
});
}
private applyDragStyling(element: HTMLElement): void {
element.classList.add('dragging');
}
/**
* Update clone timestamp based on new position
*/
private updateCloneTimestamp(payload: DragMoveEventPayload): void {
if (payload.draggedClone.dataset.allDay === "true" || !payload.columnBounds) return;
const gridSettings = calendarConfig.getGridSettings();
const { hourHeight, dayStartHour, snapInterval } = gridSettings;
if (!payload.draggedClone.dataset.originalDuration) {
throw new DOMException("missing clone.dataset.originalDuration");
}
// Calculate snapped start minutes
const minutesFromGridStart = (payload.snappedY / hourHeight) * 60;
const snappedStartMinutes = this.calculateSnappedMinutes(
minutesFromGridStart, dayStartHour, snapInterval
);
// Calculate end minutes
const originalDuration = parseInt(payload.draggedClone.dataset.originalDuration);
const endTotalMinutes = snappedStartMinutes + originalDuration;
// Update UI
this.updateTimeDisplay(payload.draggedClone, snappedStartMinutes, endTotalMinutes);
// Update data attributes
this.updateDateTimeAttributes(
payload.draggedClone,
new Date(payload.columnBounds.date),
snappedStartMinutes,
endTotalMinutes
);
}
/**
* Calculate snapped minutes from grid start
*/
private calculateSnappedMinutes(minutesFromGridStart: number, dayStartHour: number, snapInterval: number): number {
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
return Math.round(actualStartMinutes / snapInterval) * snapInterval;
}
/**
* Update time display in the UI
*/
private updateTimeDisplay(element: HTMLElement, startMinutes: number, endMinutes: number): void {
const timeElement = element.querySelector('swp-event-time');
if (!timeElement) return;
const startTime = this.formatTimeFromMinutes(startMinutes);
const endTime = this.formatTimeFromMinutes(endMinutes);
timeElement.textContent = `${startTime} - ${endTime}`;
}
/**
* Update data-start and data-end attributes with ISO timestamps
*/
private updateDateTimeAttributes(element: HTMLElement, columnDate: Date, startMinutes: number, endMinutes: number): void {
const startDate = this.createDateWithMinutes(columnDate, startMinutes);
let endDate = this.createDateWithMinutes(columnDate, endMinutes);
// Handle cross-midnight events
if (endMinutes >= 1440) {
const extraDays = Math.floor(endMinutes / 1440);
endDate = addDays(endDate, extraDays);
}
element.dataset.start = startDate.toISOString();
element.dataset.end = endDate.toISOString();
}
/**
* Create a date with specific minutes since midnight
*/
private createDateWithMinutes(baseDate: Date, totalMinutes: number): Date {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return setSeconds(setMinutes(setHours(baseDate, hours), minutes), 0);
}
/**
* Format minutes since midnight to time string
*/
private formatTimeFromMinutes(totalMinutes: number): string {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const date = new Date();
date.setHours(hours, minutes, 0, 0);
return format(date, 'HH:mm');
}
/**
* Handle drag start event
*/
public handleDragStart(payload: DragStartEventPayload): void {
this.originalEvent = payload.draggedElement;;
// Use the clone from the payload instead of creating a new one
this.draggedClone = payload.draggedClone;
if (this.draggedClone) {
// Apply drag styling
this.applyDragStyling(this.draggedClone);
// Add to current column's events layer (not directly to column)
const eventsLayer = payload.columnBounds?.element.querySelector('swp-events-layer');
if (eventsLayer) {
eventsLayer.appendChild(this.draggedClone);
}
}
// Make original semi-transparent
this.originalEvent.style.opacity = '0.3';
this.originalEvent.style.userSelect = 'none';
}
/**
* Handle drag move event
*/
public handleDragMove(payload: DragMoveEventPayload): void {
if (!this.draggedClone) return;
// Update position - snappedY is already the event top position
// Add +1px to match the initial positioning offset from SwpEventElement
this.draggedClone.style.top = (payload.snappedY + 1) + 'px';
// Update timestamp display
this.updateCloneTimestamp(payload);
}
/**
* Handle drag auto-scroll event
*/
public handleDragAutoScroll(eventId: string, snappedY: number): void {
if (!this.draggedClone) return;
// Update position directly using the calculated snapped position
this.draggedClone.style.top = snappedY + 'px';
// Update timestamp display
//this.updateCloneTimestamp(this.draggedClone, snappedY); //TODO: Commented as, we need to move all this scroll logic til scroll manager away from eventrenderer
}
/**
* Handle column change during drag
*/
public handleColumnChange(dragColumnChangeEvent: DragColumnChangeEventPayload): void {
if (!this.draggedClone) return;
const eventsLayer = dragColumnChangeEvent.newColumn.element.querySelector('swp-events-layer');
if (eventsLayer && this.draggedClone.parentElement !== eventsLayer) {
eventsLayer.appendChild(this.draggedClone);
// Recalculate timestamps with new column date
const currentTop = parseFloat(this.draggedClone.style.top) || 0;
const mockPayload: DragMoveEventPayload = {
draggedElement: dragColumnChangeEvent.originalElement,
draggedClone: this.draggedClone,
mousePosition: dragColumnChangeEvent.mousePosition,
mouseOffset: { x: 0, y: 0 },
columnBounds: dragColumnChangeEvent.newColumn,
snappedY: currentTop
};
this.updateCloneTimestamp(mockPayload);
}
}
/**
* Handle drag end event
*/
public handleDragEnd(eventId: string, originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: ColumnBounds, finalY: number): void {
if (!draggedClone || !originalElement) {
console.warn('Missing draggedClone or originalElement');
return;
}
// Check om original event var del af en stack
const originalStackLink = originalElement.dataset.stackLink;
if (originalStackLink) {
try {
const stackData = JSON.parse(originalStackLink);
// Saml ALLE event IDs fra hele stack chain
const allStackEventIds: Set<string> = new Set();
// Recursive funktion til at traversere stack chain
const traverseStack = (linkData: StackLinkData, visitedIds: Set<string>) => {
if (linkData.prev && !visitedIds.has(linkData.prev)) {
visitedIds.add(linkData.prev);
const prevElement = document.querySelector(`swp-time-grid [data-event-id="${linkData.prev}"]`) as HTMLElement;
if (prevElement?.dataset.stackLink) {
try {
const prevLinkData = JSON.parse(prevElement.dataset.stackLink);
traverseStack(prevLinkData, visitedIds);
} catch (e) { }
}
}
if (linkData.next && !visitedIds.has(linkData.next)) {
visitedIds.add(linkData.next);
const nextElement = document.querySelector(`swp-time-grid [data-event-id="${linkData.next}"]`) as HTMLElement;
if (nextElement?.dataset.stackLink) {
try {
const nextLinkData = JSON.parse(nextElement.dataset.stackLink);
traverseStack(nextLinkData, visitedIds);
} catch (e) { }
}
}
};
// Start traversering fra original event's stackLink
traverseStack(stackData, allStackEventIds);
// Fjern original eventId da det bliver flyttet
allStackEventIds.delete(eventId);
// Find alle stack events og fjern dem
const stackEvents: CalendarEvent[] = [];
let container: HTMLElement | null = null;
allStackEventIds.forEach(id => {
const element = document.querySelector(`swp-time-grid [data-event-id="${id}"]`) as HTMLElement;
if (element) {
// Gem container reference fra første element
if (!container) {
container = element.closest('swp-events-layer') as HTMLElement;
}
const event = SwpEventElement.extractCalendarEventFromElement(element);
if (event) {
stackEvents.push(event);
}
// Fjern elementet
element.remove();
}
});
// Re-render stack events hvis vi fandt nogle
if (stackEvents.length > 0 && container) {
this.handleEventOverlaps(stackEvents, container);
}
} catch (e) {
console.warn('Failed to parse stackLink data:', e);
}
}
// Remove original event from any existing groups first
this.removeEventFromExistingGroups(originalElement);
// Fade out original
// TODO: this should be changed into a subscriber which only after a succesful placement is fired, not just mouseup as this can remove elements that are not placed.
this.fadeOutAndRemove(originalElement);
// Remove clone prefix and normalize clone to be a regular event
const cloneId = draggedClone.dataset.eventId;
if (cloneId && cloneId.startsWith('clone-')) {
draggedClone.dataset.eventId = cloneId.replace('clone-', '');
}
// Fully normalize the clone to be a regular event
draggedClone.classList.remove('dragging');
// Behold z-index hvis det er et stacked event
// Data attributes are already updated during drag:move, so no need to update again
// The updateCloneTimestamp method keeps them synchronized throughout the drag operation
// Detect overlaps with other events in the target column and reposition if needed
this.handleDragDropOverlaps(draggedClone, finalColumn);
// Fjern stackLink data fra dropped element
if (draggedClone.dataset.stackLink) {
delete draggedClone.dataset.stackLink;
}
// Clean up instance state (no longer needed since we get elements as parameters)
this.draggedClone = null;
this.originalEvent = null;
}
/**
* Handle navigation completed event
*/
public handleNavigationCompleted(): void {
// Default implementation - can be overridden by subclasses
}
/**
* Handle overlap detection and re-rendering after drag-drop
*/
private handleDragDropOverlaps(droppedElement: HTMLElement, targetColumn: ColumnBounds): void {
const eventsLayer = targetColumn.element.querySelector('swp-events-layer') as HTMLElement;
if (!eventsLayer) return;
// Convert dropped element to CalendarEvent with new position
const droppedEvent = SwpEventElement.extractCalendarEventFromElement(droppedElement);
if (!droppedEvent) return;
// Get existing events in the column (excluding the dropped element)
const existingEvents = this.getEventsInColumn(eventsLayer, droppedElement.dataset.eventId);
// Find overlaps with the dropped event
const overlappingEvents = this.overlapDetector.resolveOverlap(droppedEvent, existingEvents);
if (overlappingEvents.length > 0) {
// Remove only affected events from DOM
const affectedEventIds = [droppedEvent.id, ...overlappingEvents.map(e => e.id)];
eventsLayer.querySelectorAll('swp-event').forEach(el => {
const eventId = (el as HTMLElement).dataset.eventId;
if (eventId && affectedEventIds.includes(eventId)) {
el.remove();
}
});
// Re-render affected events with overlap handling
const affectedEvents = [droppedEvent, ...overlappingEvents];
this.handleEventOverlaps(affectedEvents, eventsLayer);
} else {
// Reset z-index for non-overlapping events
droppedElement.style.zIndex = '';
}
}
/**
* Get all events in a column as CalendarEvent objects
*/
private getEventsInColumn(eventsLayer: HTMLElement, excludeEventId?: string): CalendarEvent[] {
const eventElements = eventsLayer.querySelectorAll('swp-event');
const events: CalendarEvent[] = [];
eventElements.forEach(el => {
const element = el as HTMLElement;
const eventId = element.dataset.eventId;
// Skip the excluded event (e.g., the dropped event)
if (excludeEventId && eventId === excludeEventId) {
return;
}
const event = SwpEventElement.extractCalendarEventFromElement(element);
if (event) {
events.push(event);
}
});
return events;
}
/**
* Remove event from any existing groups and cleanup empty containers
* In the new system, this is handled automatically by re-rendering overlaps
*/
private removeEventFromExistingGroups(eventElement: HTMLElement): void {
// With the new system, overlap relationships are recalculated on drop
// No need to manually track and remove from groups
}
/**
* Handle conversion to all-day event
*/
/**
* Fade out and remove element
*/
private fadeOutAndRemove(element: HTMLElement): void {
element.style.transition = 'opacity 0.3s ease-out';
element.style.opacity = '0';
setTimeout(() => {
element.remove();
}, 300);
}
renderEvents(events: CalendarEvent[], container: HTMLElement): void {
// Filter out all-day events - they should be handled by AllDayEventRenderer
const timedEvents = events.filter(event => !event.allDay);
console.log('🎯 EventRenderer: Filtering events', {
totalEvents: events.length,
timedEvents: timedEvents.length,
filteredOutAllDay: events.length - timedEvents.length
});
// Find columns in the specific container for regular events
const columns = this.getColumns(container);
columns.forEach(column => {
const columnEvents = this.getEventsForColumn(column, timedEvents);
const eventsLayer = column.querySelector('swp-events-layer');
if (eventsLayer) {
this.handleEventOverlaps(columnEvents, eventsLayer as HTMLElement);
}
});
}
private renderEvent(event: CalendarEvent): HTMLElement {
const swpEvent = SwpEventElement.fromCalendarEvent(event);
const eventElement = swpEvent.getElement();
// Setup resize handles on first mouseover only
eventElement.addEventListener('mouseover', () => { // TODO: This is not the correct way... we should not add eventlistener on every event
if (eventElement.dataset.hasResizeHandlers !== 'true') {
eventElement.dataset.hasResizeHandlers = 'true';
}
}, { once: true });
return eventElement;
}
protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } {
// Delegate to PositionUtils for centralized position calculation
return PositionUtils.calculateEventPosition(event.start, event.end);
}
clearEvents(container?: HTMLElement): void {
const selector = 'swp-event, swp-event-group';
const existingEvents = container
? container.querySelectorAll(selector)
: document.querySelectorAll(selector);
existingEvents.forEach(event => event.remove());
}
/**
* Renderer overlappende events baseret på OverlapResult
* @param result - OverlapResult med events og stack links
* @param container - Container at rendere i
*/
protected renderOverlappingEvents(result: OverlapResult, container: HTMLElement): void {
// Iterate direkte gennem stackLinks - allerede sorteret fra decorateWithStackLinks
for (const [eventId, stackLink] of result.stackLinks.entries()) {
const event = result.overlappingEvents.find(e => e.id === eventId);
if (!event) continue;
const element = this.renderEvent(event);
// Gem stack link information på DOM elementet
element.dataset.stackLink = JSON.stringify({
prev: stackLink.prev,
next: stackLink.next,
stackLevel: stackLink.stackLevel
});
// Check om dette event deler kolonne med foregående (samme start tid)
if (stackLink.prev) {
const prevEvent = result.overlappingEvents.find(e => e.id === stackLink.prev);
if (prevEvent && prevEvent.start.getTime() === event.start.getTime()) {
// Samme start tid - del kolonne (side by side)
this.new_applyColumnSharingStyling([element]);
} else {
// Forskellige start tider - stack vertikalt
this.new_applyStackStyling(element, stackLink.stackLevel);
}
} else {
// Første event i stack
this.new_applyStackStyling(element, stackLink.stackLevel);
}
container.appendChild(element);
}
}
/**
* Applicerer stack styling (margin-left og z-index)
* @param element - Event element
* @param stackLevel - Stack niveau
*/
protected new_applyStackStyling(element: HTMLElement, stackLevel: number): void {
element.style.marginLeft = `${stackLevel * 15}px`;
element.style.zIndex = `${100 + stackLevel}`;
}
/**
* Applicerer column sharing styling (flexbox)
* @param elements - Event elements der skal dele plads
*/
protected new_applyColumnSharingStyling(elements: HTMLElement[]): void {
elements.forEach(element => {
element.style.flex = '1';
element.style.minWidth = '50px';
});
}
/**
* Setup drag event listeners - placeholder method
*/
private setupDragEventListeners(): void {
// Drag event listeners are handled by EventRendererManager
// This method exists for compatibility
}
protected getColumns(container: HTMLElement): HTMLElement[] {
const columns = container.querySelectorAll('swp-day-column');
return Array.from(columns) as HTMLElement[];
}
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
const columnDate = column.dataset.date;
if (!columnDate) {
return [];
}
const columnEvents = events.filter(event => {
const eventDateStr = this.dateService.formatISODate(event.start);
const matches = eventDateStr === columnDate;
return matches;
});
return columnEvents;
}
}