Calendar/src/renderers/EventRenderer.ts
Janus Knudsen 5cffc233c5 Updates event data after drag and drop
Updates event start and end times in the dataset after a successful drag and drop operation. This ensures the event element reflects the new time position.

Also resets the z-index of the dropped element if no overlaps are detected, keeping the element's original appearance.
2025-09-09 18:03:37 +02:00

1376 lines
No EOL
47 KiB
TypeScript

// Event rendering strategy interface and implementations
import { CalendarEvent } from '../types/CalendarTypes';
import { calendarConfig } from '../core/CalendarConfig';
import { DateCalculator } from '../utils/DateCalculator';
import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector';
/**
* Resize state interface
*/
interface ResizeState {
element: HTMLElement;
handle: 'top' | 'bottom';
startY: number;
originalTop: number;
originalHeight: number;
originalStartTime: Date;
originalEndTime: Date;
minHeightPx: number;
}
/**
* Interface for event rendering strategies
*/
export interface EventRendererStrategy {
renderEvents(events: CalendarEvent[], container: HTMLElement): void;
clearEvents(container?: HTMLElement): void;
}
/**
* Base class for event renderers with common functionality
*/
export abstract class BaseEventRenderer implements EventRendererStrategy {
protected dateCalculator: DateCalculator;
// Drag and drop state
private draggedClone: HTMLElement | null = null;
private originalEvent: HTMLElement | null = null;
// Resize state
private resizeState: ResizeState | null = null;
private readonly MIN_EVENT_DURATION_MINUTES = 30;
constructor(dateCalculator?: DateCalculator) {
if (!dateCalculator) {
DateCalculator.initialize(calendarConfig);
}
this.dateCalculator = dateCalculator || new DateCalculator();
}
// ============================================
// 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 new_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.new_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);
}
});
}
/**
* Setup listeners for drag events from DragDropManager
*/
protected setupDragEventListeners(): void {
// Handle drag start
eventBus.on('drag:start', (event) => {
const { originalElement, eventId, mouseOffset, column } = (event as CustomEvent).detail;
this.handleDragStart(originalElement, eventId, mouseOffset, column);
});
// Handle drag move
eventBus.on('drag:move', (event) => {
const { eventId, snappedY, column, mouseOffset } = (event as CustomEvent).detail;
this.handleDragMove(eventId, snappedY, column, mouseOffset);
});
// Handle drag auto-scroll (when dragging near edges triggers scroll)
eventBus.on('drag:auto-scroll', (event) => {
const { eventId, snappedY } = (event as CustomEvent).detail;
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);
});
// Handle drag end
eventBus.on('drag:end', (event) => {
const { eventId, originalElement, finalColumn, finalY } = (event as CustomEvent).detail;
this.handleDragEnd(eventId, originalElement, finalColumn, finalY);
});
// Handle click (when drag threshold not reached)
eventBus.on('event:click', (event) => {
const { eventId, originalElement } = (event as CustomEvent).detail;
this.handleEventClick(eventId, originalElement);
});
// Handle column change
eventBus.on('drag:column-change', (event) => {
const { eventId, newColumn } = (event as CustomEvent).detail;
this.handleColumnChange(eventId, newColumn);
});
// Handle convert to all-day
eventBus.on('drag:convert-to-allday', (event) => {
const { eventId, targetDate, headerRenderer } = (event as CustomEvent).detail;
this.handleConvertToAllDay(eventId, targetDate, headerRenderer);
});
// Handle navigation period change (when slide animation completes)
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => {
// Animate all-day height after navigation completes
this.triggerAllDayHeightAnimation();
});
}
/**
* Trigger all-day height animation without creating new renderer instance
*/
private triggerAllDayHeightAnimation(): void {
import('./HeaderRenderer').then(({ DateHeaderRenderer }) => {
const headerRenderer = new DateHeaderRenderer();
headerRenderer.checkAndAnimateAllDayHeight();
});
}
/**
* Cleanup method for proper resource management
*/
public destroy(): void {
this.draggedClone = null;
this.originalEvent = null;
}
/**
* Get original event duration from data-duration attribute
*/
private getOriginalEventDuration(originalEvent: HTMLElement): number {
// Find the swp-event-time element with data-duration attribute
const timeElement = originalEvent.querySelector('swp-event-time');
if (timeElement) {
const duration = timeElement.getAttribute('data-duration');
if (duration) {
const durationMinutes = parseInt(duration);
return durationMinutes;
}
}
// Fallback to 60 minutes if attribute not found
return 60;
}
/**
* Create a clone of an event for dragging
*/
private createEventClone(originalEvent: HTMLElement): HTMLElement {
const clone = originalEvent.cloneNode(true) as HTMLElement;
// Prefix ID with "clone-"
const originalId = originalEvent.dataset.eventId;
if (originalId) {
clone.dataset.eventId = `clone-${originalId}`;
}
// Get and cache original duration from data-duration attribute
const originalDurationMinutes = this.getOriginalEventDuration(originalEvent);
clone.dataset.originalDuration = originalDurationMinutes.toString();
// Style for dragging
clone.style.position = 'absolute';
clone.style.zIndex = '999999';
clone.style.pointerEvents = 'none';
clone.style.opacity = '0.8';
// Dragged event skal have fuld kolonne bredde
clone.style.left = '2px';
clone.style.right = '2px';
clone.style.marginLeft = '0px';
clone.style.width = '';
clone.style.height = originalEvent.style.height || `${originalEvent.getBoundingClientRect().height}px`;
return clone;
}
/**
* Update clone timestamp based on new position
*/
private updateCloneTimestamp(clone: HTMLElement, snappedY: number): void {
const gridSettings = calendarConfig.getGridSettings();
const hourHeight = gridSettings.hourHeight;
const dayStartHour = gridSettings.dayStartHour;
const snapInterval = gridSettings.snapInterval;
// Calculate minutes from grid start (not from midnight)
const minutesFromGridStart = (snappedY / hourHeight) * 60;
// Add dayStartHour offset to get actual time
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
// Snap to interval
const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval;
// Use cached original duration (no recalculation)
const cachedDuration = parseInt(clone.dataset.originalDuration || '60');
const endTotalMinutes = snappedStartMinutes + cachedDuration;
// Update display
const timeElement = clone.querySelector('swp-event-time');
if (timeElement) {
const newTimeText = `${this.formatTime(snappedStartMinutes)} - ${this.formatTime(endTotalMinutes)}`;
timeElement.textContent = newTimeText;
}
}
/**
* Calculate event duration in minutes from element height
*/
private getEventDuration(element: HTMLElement): number {
const gridSettings = calendarConfig.getGridSettings();
const hourHeight = gridSettings.hourHeight;
// Get height from style or computed
let heightPx = parseInt(element.style.height) || 0;
if (!heightPx) {
const rect = element.getBoundingClientRect();
heightPx = rect.height;
}
return Math.round((heightPx / hourHeight) * 60);
}
/**
* Unified time formatting method - handles both total minutes and Date objects
*/
private formatTime(input: number | Date | string): string {
let hours: number, minutes: number;
if (typeof input === 'number') {
// Total minutes input
hours = Math.floor(input / 60) % 24;
minutes = input % 60;
} else {
// Date or ISO string input
const date = typeof input === 'string' ? new Date(input) : input;
hours = date.getHours();
minutes = date.getMinutes();
}
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
}
/**
* Handle drag start event
*/
private handleDragStart(originalElement: HTMLElement, eventId: string, mouseOffset: any, column: string): void {
console.log('handleDragStart:', eventId);
this.originalEvent = originalElement;
// Remove stacking styling during drag will be handled by new system
// Create clone
this.draggedClone = this.createEventClone(originalElement);
// Add to current column's events layer (not directly to column)
const columnElement = document.querySelector(`swp-day-column[data-date="${column}"]`);
if (columnElement) {
const eventsLayer = columnElement.querySelector('swp-events-layer');
if (eventsLayer) {
eventsLayer.appendChild(this.draggedClone);
} else {
// Fallback to column if events layer not found
columnElement.appendChild(this.draggedClone);
}
}
// Make original semi-transparent
originalElement.style.opacity = '0.3';
originalElement.style.userSelect = 'none';
}
/**
* Handle drag move event
*/
private handleDragMove(eventId: string, snappedY: number, column: string, mouseOffset: any): void {
if (!this.draggedClone) return;
// Update position
this.draggedClone.style.top = snappedY + 'px';
// Update timestamp display
this.updateCloneTimestamp(this.draggedClone, snappedY);
}
/**
* Handle column change during drag
*/
private handleColumnChange(eventId: string, newColumn: string): void {
if (!this.draggedClone) return;
// Move clone to new column's events layer
const newColumnElement = document.querySelector(`swp-day-column[data-date="${newColumn}"]`);
if (newColumnElement) {
const eventsLayer = newColumnElement.querySelector('swp-events-layer');
if (eventsLayer && this.draggedClone.parentElement !== eventsLayer) {
eventsLayer.appendChild(this.draggedClone);
} else if (!eventsLayer && this.draggedClone.parentElement !== newColumnElement) {
// Fallback to column if events layer not found
newColumnElement.appendChild(this.draggedClone);
}
}
}
/**
* Handle drag end event
*/
private handleDragEnd(eventId: string, originalElement: HTMLElement, finalColumn: string, finalY: number): void {
console.log('handleDragEnd:', eventId);
if (!this.draggedClone || !this.originalEvent) {
console.log('Missing draggedClone or originalEvent');
return;
}
// Remove original event from any existing groups first
this.removeEventFromExistingGroups(this.originalEvent);
// Fade out original
this.fadeOutAndRemove(this.originalEvent);
// Remove clone prefix and normalize clone to be a regular event
const cloneId = this.draggedClone.dataset.eventId;
if (cloneId && cloneId.startsWith('clone-')) {
this.draggedClone.dataset.eventId = cloneId.replace('clone-', '');
}
// Fully normalize the clone to be a regular event
this.draggedClone.style.pointerEvents = '';
this.draggedClone.style.opacity = '';
this.draggedClone.style.userSelect = '';
// Behold z-index hvis det er et stacked event
// Update dataset with new times after successful drop
const newEvent = this.elementToCalendarEventWithNewPosition(this.draggedClone, finalColumn);
if (newEvent) {
this.draggedClone.dataset.start = newEvent.start.toISOString();
this.draggedClone.dataset.end = newEvent.end.toISOString();
}
// Detect overlaps with other events in the target column and reposition if needed
this.handleDragDropOverlaps(this.draggedClone, finalColumn);
// Clean up
this.draggedClone = null;
this.originalEvent = null;
}
/**
* Handle event click (when drag threshold not reached)
*/
private handleEventClick(eventId: string, originalElement: HTMLElement): void {
console.log('handleEventClick:', eventId);
// Clean up any drag artifacts from failed drag attempt
if (this.draggedClone) {
this.draggedClone.remove();
this.draggedClone = null;
}
// Restore original element styling if it was modified
if (this.originalEvent) {
this.originalEvent.style.opacity = '';
this.originalEvent.style.userSelect = '';
this.originalEvent = null;
}
// Emit a clean click event for other components to handle
eventBus.emit('event:clicked', {
eventId: eventId,
element: originalElement
});
}
/**
* Handle event double-click for text selection
*/
private handleEventDoubleClick(eventElement: HTMLElement): void {
console.log('handleEventDoubleClick:', eventElement.dataset.eventId);
// Enable text selection temporarily
eventElement.classList.add('text-selectable');
// Auto-select the event text
const selection = window.getSelection();
if (selection) {
const range = document.createRange();
range.selectNodeContents(eventElement);
selection.removeAllRanges();
selection.addRange(range);
}
// Remove text selection mode when clicking outside
const removeSelectable = (e: Event) => {
// Don't remove if clicking within the same event
if (e.target && eventElement.contains(e.target as Node)) {
return;
}
eventElement.classList.remove('text-selectable');
document.removeEventListener('click', removeSelectable);
// Clear selection
if (selection) {
selection.removeAllRanges();
}
};
// Add click outside listener after a short delay
setTimeout(() => {
document.addEventListener('click', removeSelectable);
}, 100);
}
/**
* Handle overlap detection and re-rendering after drag-drop
*/
private handleDragDropOverlaps(droppedElement: HTMLElement, targetColumn: string): void {
const targetColumnElement = document.querySelector(`swp-day-column[data-date="${targetColumn}"]`);
if (!targetColumnElement) return;
const eventsLayer = targetColumnElement.querySelector('swp-events-layer') as HTMLElement;
if (!eventsLayer) return;
// Convert dropped element to CalendarEvent with new position
const droppedEvent = this.elementToCalendarEventWithNewPosition(droppedElement, targetColumn);
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.new_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 = this.elementToCalendarEvent(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
}
/**
* Restore normal event styling (full column width)
*/
private restoreNormalEventStyling(eventElement: HTMLElement): void {
eventElement.style.position = 'absolute';
eventElement.style.left = '2px';
eventElement.style.right = '2px';
eventElement.style.width = '';
// Behold z-index for stacked events
}
/**
* Update element's dataset with new times after successful drop
*/
private updateElementDataset(element: HTMLElement, event: CalendarEvent): void {
element.dataset.start = event.start.toISOString();
element.dataset.end = event.end.toISOString();
// Update the time display
const timeElement = element.querySelector('swp-event-time');
if (timeElement) {
const startTime = this.formatTime(event.start);
const endTime = this.formatTime(event.end);
timeElement.textContent = `${startTime} - ${endTime}`;
}
}
/**
* Convert DOM element to CalendarEvent using its NEW position after drag
*/
private elementToCalendarEventWithNewPosition(element: HTMLElement, targetColumn: string): CalendarEvent | null {
const eventId = element.dataset.eventId;
const title = element.dataset.title;
const type = element.dataset.type;
const originalDuration = element.dataset.originalDuration;
if (!eventId || !title || !type) {
return null;
}
// Calculate new start/end times based on current position
const currentTop = parseInt(element.style.top) || 0;
const durationMinutes = originalDuration ? parseInt(originalDuration) : 60;
// Convert position to time
const gridSettings = calendarConfig.getGridSettings();
const hourHeight = gridSettings.hourHeight;
const dayStartHour = gridSettings.dayStartHour;
// Calculate minutes from grid start
const minutesFromGridStart = (currentTop / hourHeight) * 60;
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
const actualEndMinutes = actualStartMinutes + durationMinutes;
// Create ISO date strings for the target column
const targetDate = new Date(targetColumn + 'T00:00:00');
const startDate = new Date(targetDate);
startDate.setMinutes(startDate.getMinutes() + actualStartMinutes);
const endDate = new Date(targetDate);
endDate.setMinutes(endDate.getMinutes() + actualEndMinutes);
return {
id: eventId,
title: title,
start: startDate,
end: endDate,
type: type,
allDay: false,
syncStatus: 'synced',
metadata: {
duration: durationMinutes
}
};
}
/**
* Convert DOM element to CalendarEvent for overlap detection
*/
private elementToCalendarEvent(element: HTMLElement): CalendarEvent | null {
const eventId = element.dataset.eventId;
const title = element.dataset.title;
const start = element.dataset.start;
const end = element.dataset.end;
const type = element.dataset.type;
const duration = element.dataset.duration;
if (!eventId || !title || !start || !end || !type) {
return null;
}
return {
id: eventId,
title: title,
start: new Date(start),
end: new Date(end),
type: type,
allDay: false,
syncStatus: 'synced', // Default to synced for existing events
metadata: {
duration: duration ? parseInt(duration) : 60
}
};
}
/**
* Handle conversion to all-day event
*/
private handleConvertToAllDay(eventId: string, targetDate: string, headerRenderer: any): void {
if (!this.draggedClone) return;
// Only convert once
if (this.draggedClone.tagName === 'SWP-ALLDAY-EVENT') return;
// Transform clone to all-day format
this.transformCloneToAllDay(this.draggedClone, targetDate);
// Expand header if needed
headerRenderer.addToAllDay(this.draggedClone.parentElement);
}
/**
* Transform clone from timed to all-day event
*/
private transformCloneToAllDay(clone: HTMLElement, targetDate: string): void {
const calendarHeader = document.querySelector('swp-calendar-header');
if (!calendarHeader) return;
// Find all-day container
const allDayContainer = calendarHeader.querySelector('swp-allday-container');
if (!allDayContainer) return;
// Extract all original event data
const titleElement = clone.querySelector('swp-event-title');
const eventTitle = titleElement ? titleElement.textContent || 'Untitled' : 'Untitled';
const timeElement = clone.querySelector('swp-event-time');
const eventTime = timeElement ? timeElement.textContent || '' : '';
const eventDuration = timeElement ? timeElement.getAttribute('data-duration') || '' : '';
// Calculate column index
const dayHeaders = document.querySelectorAll('swp-day-header');
let columnIndex = 1;
dayHeaders.forEach((header, index) => {
if ((header as HTMLElement).dataset.date === targetDate) {
columnIndex = index + 1;
}
});
// Create all-day event with standardized data attributes
const allDayEvent = document.createElement('swp-allday-event');
allDayEvent.dataset.eventId = clone.dataset.eventId || '';
allDayEvent.dataset.title = eventTitle;
allDayEvent.dataset.start = `${targetDate}T${eventTime.split(' - ')[0]}:00`;
allDayEvent.dataset.end = `${targetDate}T${eventTime.split(' - ')[1]}:00`;
allDayEvent.dataset.type = clone.dataset.type || 'work';
allDayEvent.dataset.duration = eventDuration;
allDayEvent.textContent = eventTitle;
// Position in grid
(allDayEvent as HTMLElement).style.gridColumn = columnIndex.toString();
// grid-row will be set by checkAndAnimateAllDayHeight() based on actual position
// Remove original clone
if (clone.parentElement) {
clone.parentElement.removeChild(clone);
}
// Add to all-day container
allDayContainer.appendChild(allDayEvent);
// Update reference
this.draggedClone = allDayEvent;
// Check if height animation is needed
this.triggerAllDayHeightAnimation();
}
/**
* 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);
}
/**
* Convert dragged clone to all-day event preview
*/
private convertToAllDayPreview(targetDate: string): void {
if (!this.draggedClone) return;
// Only convert once
if (this.draggedClone.tagName === 'SWP-ALLDAY-EVENT') {
return;
}
// Transform clone to all-day format
this.transformCloneToAllDay(this.draggedClone, targetDate);
}
/**
* Move all-day event to a new date container
*/
private moveAllDayToNewDate(targetDate: string): void {
if (!this.draggedClone) return;
const calendarHeader = document.querySelector('swp-calendar-header');
if (!calendarHeader) return;
// Find the all-day container
const allDayContainer = calendarHeader.querySelector('swp-allday-container');
if (!allDayContainer) return;
// Calculate new column index
const dayHeaders = document.querySelectorAll('swp-day-header');
let columnIndex = 1;
dayHeaders.forEach((header, index) => {
if ((header as HTMLElement).dataset.date === targetDate) {
columnIndex = index + 1;
}
});
// Update grid column position
(this.draggedClone as HTMLElement).style.gridColumn = columnIndex.toString();
// Move to all-day container if not already there
if (this.draggedClone.parentElement !== allDayContainer) {
allDayContainer.appendChild(this.draggedClone);
}
}
renderEvents(events: CalendarEvent[], container: HTMLElement): void {
// NOTE: Removed clearEvents() to support sliding animation
// With sliding animation, multiple grid containers exist simultaneously
// clearEvents() would remove events from all containers, breaking the animation
// Events are now rendered directly into the new container without clearing
// Separate all-day events from regular events
const allDayEvents = events.filter(event => event.allDay);
const regularEvents = events.filter(event => !event.allDay);
// Always call renderAllDayEvents to ensure height is set correctly (even to 0)
this.renderAllDayEvents(allDayEvents, container);
// Find columns in the specific container for regular events
const columns = this.getColumns(container);
columns.forEach(column => {
const columnEvents = this.getEventsForColumn(column, regularEvents);
const eventsLayer = column.querySelector('swp-events-layer');
if (eventsLayer) {
// NY TILGANG: Kald vores nye overlap handling
this.new_handleEventOverlaps(columnEvents, eventsLayer as HTMLElement);
}
});
}
// Abstract methods that subclasses must implement
protected abstract getColumns(container: HTMLElement): HTMLElement[];
protected abstract getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[];
/**
* Render all-day events in the header row 2
*/
protected renderAllDayEvents(allDayEvents: CalendarEvent[], container: HTMLElement): void {
// Find the calendar header
const calendarHeader = container.querySelector('swp-calendar-header');
if (!calendarHeader) {
return;
}
// Find the all-day container (should always exist now)
const allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement;
if (!allDayContainer) {
console.warn('All-day container not found - this should not happen');
return;
}
// Clear existing events
allDayContainer.innerHTML = '';
if (allDayEvents.length === 0) {
// No events - container exists but is empty and hidden
return;
}
// Build date to column mapping
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
const dateToColumnMap = new Map<string, number>();
dayHeaders.forEach((header, index) => {
const dateStr = (header as any).dataset.date;
if (dateStr) {
dateToColumnMap.set(dateStr, index + 1); // 1-based column index
}
});
// Calculate grid spans for all events
const eventSpans = allDayEvents.map(event => ({
event,
span: this.calculateEventGridSpan(event, dateToColumnMap)
})).filter(item => item.span.columnSpan > 0); // Remove events outside visible range
// Simple row assignment using overlap detection
const eventPlacements: Array<{ event: CalendarEvent, span: { startColumn: number, columnSpan: number }, row: number }> = [];
eventSpans.forEach(eventItem => {
let assignedRow = 1;
// Find first row where this event doesn't overlap with any existing event
while (true) {
const rowEvents = eventPlacements.filter(item => item.row === assignedRow);
const hasOverlap = rowEvents.some(rowEvent =>
this.spansOverlap(eventItem.span, rowEvent.span)
);
if (!hasOverlap) {
break; // Found available row
}
assignedRow++;
}
eventPlacements.push({
event: eventItem.event,
span: eventItem.span,
row: assignedRow
});
});
// Get max row needed
const maxRow = Math.max(...eventPlacements.map(item => item.row), 1);
// Place events directly in the single container
eventPlacements.forEach(({ event, span, row }) => {
// Create the all-day event element
const allDayEvent = document.createElement('swp-allday-event');
allDayEvent.textContent = event.title;
// Set data attributes directly from CalendarEvent
allDayEvent.dataset.eventId = event.id;
allDayEvent.dataset.title = event.title;
allDayEvent.dataset.start = event.start.toISOString();
allDayEvent.dataset.end = event.end.toISOString();
allDayEvent.dataset.type = event.type;
allDayEvent.dataset.duration = event.metadata?.duration?.toString() || '60';
// Set grid position (column and row)
(allDayEvent as HTMLElement).style.gridColumn = span.columnSpan > 1
? `${span.startColumn} / span ${span.columnSpan}`
: `${span.startColumn}`;
(allDayEvent as HTMLElement).style.gridRow = row.toString();
// Use event metadata for color if available
if (event.metadata?.color) {
(allDayEvent as HTMLElement).style.backgroundColor = event.metadata.color;
}
allDayContainer.appendChild(allDayEvent);
});
}
protected renderEvent(event: CalendarEvent): HTMLElement {
const eventElement = document.createElement('swp-event');
eventElement.dataset.eventId = event.id;
eventElement.dataset.title = event.title;
eventElement.dataset.start = event.start.toISOString();
eventElement.dataset.end = event.end.toISOString();
eventElement.dataset.type = event.type;
eventElement.dataset.duration = event.metadata?.duration?.toString() || '60';
// Calculate position based on time
const position = this.calculateEventPosition(event);
eventElement.style.position = 'absolute';
eventElement.style.top = `${position.top + 1}px`;
eventElement.style.height = `${position.height - 3}px`; //adjusted so bottom does not cover horizontal time lines.
// Color is now handled by CSS classes based on data-type attribute
// Format time for display using unified method
const startTime = this.formatTime(event.start);
const endTime = this.formatTime(event.end);
// Calculate duration in minutes
const durationMinutes = (event.end.getTime() - event.start.getTime()) / (1000 * 60);
// Create event content
eventElement.innerHTML = `
<swp-event-time data-duration="${durationMinutes}">${startTime} - ${endTime}</swp-event-time>
<swp-event-title>${event.title}</swp-event-title>
`;
// Setup resize handles on first mouseover only
eventElement.addEventListener('mouseover', () => {
if (eventElement.dataset.hasResizeHandlers !== 'true') {
this.setupDynamicResizeHandles(eventElement);
eventElement.dataset.hasResizeHandlers = 'true';
}
}, { once: true });
// Setup double-click for text selection
eventElement.addEventListener('dblclick', (e) => {
e.stopPropagation();
this.handleEventDoubleClick(eventElement);
});
return eventElement;
}
protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } {
const gridSettings = calendarConfig.getGridSettings();
const dayStartHour = gridSettings.dayStartHour;
const hourHeight = gridSettings.hourHeight;
// Calculate minutes from midnight
const startMinutes = event.start.getHours() * 60 + event.start.getMinutes();
const endMinutes = event.end.getHours() * 60 + event.end.getMinutes();
const dayStartMinutes = dayStartHour * 60;
// Calculate top position relative to visible grid start
// If dayStartHour=6 and event starts at 09:00 (540 min), then:
// top = ((540 - 360) / 60) * hourHeight = 3 * hourHeight (3 hours from grid start)
const top = ((startMinutes - dayStartMinutes) / 60) * hourHeight;
// Calculate height based on event duration
const durationMinutes = endMinutes - startMinutes;
const height = (durationMinutes / 60) * hourHeight;
return { top, height };
}
/**
* Calculate grid column span for event
*/
private calculateEventGridSpan(event: CalendarEvent, dateToColumnMap: Map<string, number>): { startColumn: number, columnSpan: number } {
const startDateKey = DateCalculator.formatISODate(event.start);
const startColumn = dateToColumnMap.get(startDateKey);
if (!startColumn) {
return { startColumn: 0, columnSpan: 0 }; // Event outside visible range
}
// Calculate span by checking each day
let endColumn = startColumn;
const currentDate = new Date(event.start);
while (currentDate <= event.end) {
currentDate.setDate(currentDate.getDate() + 1);
const dateKey = DateCalculator.formatISODate(currentDate);
const col = dateToColumnMap.get(dateKey);
if (col) {
endColumn = col;
} else {
break; // Event extends beyond visible range
}
}
const columnSpan = endColumn - startColumn + 1;
return { startColumn, columnSpan };
}
/**
* Check if two column spans overlap (for all-day events)
*/
private spansOverlap(event1Span: { startColumn: number, columnSpan: number }, event2Span: { startColumn: number, columnSpan: number }): boolean {
const event1End = event1Span.startColumn + event1Span.columnSpan - 1;
const event2End = event2Span.startColumn + event2Span.columnSpan - 1;
return !(event1End < event2Span.startColumn || event2End < event1Span.startColumn);
}
/**
* Setup dynamic resize handles that are only created when needed
*/
private setupDynamicResizeHandles(eventElement: HTMLElement): void {
let topHandle: HTMLElement | null = null;
let bottomHandle: HTMLElement | null = null;
console.log('Setting up dynamic resize handles for event:', eventElement.dataset.eventId);
// Create handles on mouse enter
eventElement.addEventListener('mouseenter', () => {
console.log('Mouse ENTER event:', eventElement.dataset.eventId);
// Only create if they don't already exist
if (!topHandle || !bottomHandle) {
topHandle = document.createElement('swp-resize-handle');
topHandle.setAttribute('data-position', 'top');
topHandle.style.opacity = '0';
bottomHandle = document.createElement('swp-resize-handle');
bottomHandle.setAttribute('data-position', 'bottom');
bottomHandle.style.opacity = '0';
// Add mousedown listeners for resize functionality
topHandle.addEventListener('mousedown', (e: MouseEvent) => {
e.stopPropagation(); // Forhindre normal drag
e.preventDefault();
this.startResize(eventElement, 'top', e);
});
bottomHandle.addEventListener('mousedown', (e: MouseEvent) => {
e.stopPropagation(); // Forhindre normal drag
e.preventDefault();
this.startResize(eventElement, 'bottom', e);
});
// Insert handles at beginning and end
eventElement.insertBefore(topHandle, eventElement.firstChild);
eventElement.appendChild(bottomHandle);
console.log('Created resize handles for event:', eventElement.dataset.eventId);
}
});
// Mouse move handler for smart visibility
eventElement.addEventListener('mousemove', (e: MouseEvent) => {
if (!topHandle || !bottomHandle) return;
const rect = eventElement.getBoundingClientRect();
const y = e.clientY - rect.top;
const height = rect.height;
// Show top handle if mouse is in top 12px
if (y <= 12) {
topHandle.style.opacity = '1';
bottomHandle.style.opacity = '0';
}
// Show bottom handle if mouse is in bottom 12px
else if (y >= height - 12) {
topHandle.style.opacity = '0';
bottomHandle.style.opacity = '1';
}
// Hide both if mouse is in middle
else {
topHandle.style.opacity = '0';
bottomHandle.style.opacity = '0';
}
});
// Hide handles when mouse leaves event (men kun hvis ikke i resize mode)
eventElement.addEventListener('mouseleave', () => {
console.log('Mouse LEAVE event:', eventElement.dataset.eventId);
if (!this.resizeState && topHandle && bottomHandle) {
topHandle.style.opacity = '0';
bottomHandle.style.opacity = '0';
console.log('Hidden resize handles for event:', eventElement.dataset.eventId);
}
});
}
/**
* Start resize operation
*/
private startResize(eventElement: HTMLElement, handle: 'top' | 'bottom', e: MouseEvent): void {
const gridSettings = calendarConfig.getGridSettings();
const minHeightPx = (this.MIN_EVENT_DURATION_MINUTES / 60) * gridSettings.hourHeight;
this.resizeState = {
element: eventElement,
handle: handle,
startY: e.clientY,
originalTop: parseFloat(eventElement.style.top),
originalHeight: parseFloat(eventElement.style.height),
originalStartTime: new Date(eventElement.dataset.start || ''),
originalEndTime: new Date(eventElement.dataset.end || ''),
minHeightPx: minHeightPx
};
// Global listeners for resize
document.addEventListener('mousemove', this.handleResize);
document.addEventListener('mouseup', this.endResize);
// Add resize cursor to body
document.body.style.cursor = handle === 'top' ? 'n-resize' : 's-resize';
console.log('Starting resize:', handle, 'element:', eventElement.dataset.eventId);
}
/**
* Handle resize drag
*/
private handleResize = (e: MouseEvent): void => {
if (!this.resizeState) return;
const deltaY = e.clientY - this.resizeState.startY;
const snappedDelta = this.snapToGrid(deltaY);
const gridSettings = calendarConfig.getGridSettings();
if (this.resizeState.handle === 'top') {
// Resize fra toppen
const newTop = this.resizeState.originalTop + snappedDelta;
const newHeight = this.resizeState.originalHeight - snappedDelta;
// Check minimum højde
if (newHeight >= this.resizeState.minHeightPx && newTop >= 0) {
this.resizeState.element.style.top = newTop + 'px';
this.resizeState.element.style.height = newHeight + 'px';
// Opdater tidspunkter
const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60;
const newStartTime = this.addMinutes(this.resizeState.originalStartTime, minutesDelta);
this.updateEventDisplay(this.resizeState.element, newStartTime, this.resizeState.originalEndTime);
}
} else {
// Resize fra bunden
const newHeight = this.resizeState.originalHeight + snappedDelta;
// Check minimum højde
if (newHeight >= this.resizeState.minHeightPx) {
this.resizeState.element.style.height = newHeight + 'px';
// Opdater tidspunkter
const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60;
const newEndTime = this.addMinutes(this.resizeState.originalEndTime, minutesDelta);
this.updateEventDisplay(this.resizeState.element, this.resizeState.originalStartTime, newEndTime);
}
}
}
/**
* End resize operation
*/
private endResize = (): void => {
if (!this.resizeState) return;
// Få finale tider fra element
const finalStart = this.resizeState.element.dataset.start;
const finalEnd = this.resizeState.element.dataset.end;
console.log('Ending resize:', this.resizeState.element.dataset.eventId, 'New times:', finalStart, finalEnd);
// Emit event med nye tider
eventBus.emit('event:resized', {
eventId: this.resizeState.element.dataset.eventId,
newStart: finalStart,
newEnd: finalEnd
});
// Cleanup
document.removeEventListener('mousemove', this.handleResize);
document.removeEventListener('mouseup', this.endResize);
document.body.style.cursor = '';
this.resizeState = null;
}
/**
* Snap delta to grid intervals
*/
private snapToGrid(deltaY: number): number {
const gridSettings = calendarConfig.getGridSettings();
const snapInterval = gridSettings.snapInterval;
const hourHeight = gridSettings.hourHeight;
const snapDistancePx = (snapInterval / 60) * hourHeight;
return Math.round(deltaY / snapDistancePx) * snapDistancePx;
}
/**
* Update event display during resize
*/
private updateEventDisplay(element: HTMLElement, startTime: Date, endTime: Date): void {
// Beregn ny duration i minutter
const durationMinutes = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
// Opdater dataset
element.dataset.start = startTime.toISOString();
element.dataset.end = endTime.toISOString();
element.dataset.duration = durationMinutes.toString();
// Opdater visual tid
const timeElement = element.querySelector('swp-event-time');
if (timeElement) {
const startStr = this.formatTime(startTime.toISOString());
const endStr = this.formatTime(endTime.toISOString());
timeElement.textContent = `${startStr} - ${endStr}`;
// Opdater også data-duration attribut på time elementet
timeElement.setAttribute('data-duration', durationMinutes.toString());
}
}
/**
* Add minutes to a date
*/
private addMinutes(date: Date, minutes: number): Date {
return new Date(date.getTime() + minutes * 60000);
}
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 new_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);
// 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';
});
}
}
/**
* Date-based event renderer
*/
export class DateEventRenderer extends BaseEventRenderer {
constructor(dateCalculator?: DateCalculator) {
super(dateCalculator);
this.setupDragEventListeners();
}
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 = DateCalculator.formatISODate(event.start);
const matches = eventDateStr === columnDate;
return matches;
});
return columnEvents;
}
}
/**
* Resource-based event renderer
*/
export class ResourceEventRenderer extends BaseEventRenderer {
protected getColumns(container: HTMLElement): HTMLElement[] {
const columns = container.querySelectorAll('swp-resource-column');
return Array.from(columns) as HTMLElement[];
}
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
const resourceName = column.dataset.resource;
if (!resourceName) return [];
const columnEvents = events.filter(event => {
return event.resource?.name === resourceName;
});
return columnEvents;
}
// ============================================
// NEW OVERLAP DETECTION SYSTEM
// All new functions prefixed with new_
// ============================================
protected overlapDetector = new OverlapDetector();
}