Calendar/src/renderers/EventRenderer.ts
Janus Knudsen 1aef54bffb Improves event overlap detection logic
Refactors event dropping logic to first check for existing
event groups. If a group exists and the dropped event overlaps
with it, the dropped event is added to the group. Otherwise,
it proceeds with full overlap detection against individual events.

This change prevents unnecessary group creation and optimizes
event arrangement within columns. It also ensures the dragged
event is placed on the rightmost side by adding it to the
overlapping events array last.
2025-09-04 20:06:09 +02:00

1111 lines
No EOL
39 KiB
TypeScript

// Event rendering strategy interface and implementations
import { CalendarEvent } from '../types/CalendarTypes';
import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
import { calendarConfig } from '../core/CalendarConfig';
import { DateCalculator } from '../utils/DateCalculator';
import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { EventOverlapManager, OverlapType } from '../managers/EventOverlapManager';
/**
* 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;
protected overlapManager: EventOverlapManager;
// Drag and drop state
private draggedClone: HTMLElement | null = null;
private originalEvent: HTMLElement | null = null;
constructor(dateCalculator?: DateCalculator) {
if (!dateCalculator) {
DateCalculator.initialize(calendarConfig);
}
this.dateCalculator = dateCalculator || new DateCalculator();
this.overlapManager = new EventOverlapManager();
}
/**
* 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 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.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 = parseFloat(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 {
this.originalEvent = originalElement;
// Remove stacking styling from original event before creating clone
if (this.overlapManager.isStackedEvent(originalElement)) {
this.overlapManager.removeStackedStyling(originalElement);
}
// 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 {
if (!this.draggedClone || !this.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
// Detect overlaps with other events in the target column and reposition if needed
this.detectAndHandleOverlaps(this.draggedClone, finalColumn);
// Clean up
this.draggedClone = null;
this.originalEvent = null;
}
/**
* Remove event from any existing groups and cleanup empty containers
*/
private removeEventFromExistingGroups(eventElement: HTMLElement): void {
const eventGroup = this.overlapManager.getEventGroup(eventElement);
if (eventGroup) {
const eventId = eventElement.dataset.eventId;
if (eventId) {
this.overlapManager.removeFromEventGroup(eventGroup, eventId);
// Gendan normal kolonne bredde efter fjernelse fra group
this.restoreNormalEventStyling(eventElement);
}
} else if (this.overlapManager.isStackedEvent(eventElement)) {
// Remove stacking styling if it's a stacked event
this.overlapManager.removeStackedStyling(eventElement);
}
}
/**
* 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
}
/**
* Detect overlaps with other events in target column and handle repositioning
*/
private detectAndHandleOverlaps(droppedElement: HTMLElement, targetColumn: string): void {
// Find target column element
const columnElement = document.querySelector(`swp-day-column[data-date="${targetColumn}"]`);
if (!columnElement) return;
const eventsLayer = columnElement.querySelector('swp-events-layer');
if (!eventsLayer) return;
// Convert dropped element to CalendarEvent using its NEW position
const droppedEvent = this.elementToCalendarEventWithNewPosition(droppedElement, targetColumn);
if (!droppedEvent) return;
// Check if there's already an existing swp-event-group in the column
const existingGroup = eventsLayer.querySelector('swp-event-group') as HTMLElement;
if (existingGroup) {
// Check if dropped event overlaps with the group's events
const groupEvents = Array.from(existingGroup.querySelectorAll('swp-event')) as HTMLElement[];
let overlapsWithGroup = false;
for (const groupEvent of groupEvents) {
const existingEvent = this.elementToCalendarEvent(groupEvent);
if (!existingEvent) continue;
const overlapType = this.overlapManager.detectOverlap(droppedEvent, existingEvent);
if (overlapType === OverlapType.COLUMN_SHARING) {
overlapsWithGroup = true;
break;
}
}
if (overlapsWithGroup) {
// Simply add the dropped event to the existing group
this.updateElementDataset(droppedElement, droppedEvent);
this.overlapManager.addToEventGroup(existingGroup, droppedElement);
return;
}
}
// No existing group or no overlap with existing group - run full overlap detection
const existingEvents = Array.from(eventsLayer.querySelectorAll('swp-event'))
.filter(el => el !== droppedElement) as HTMLElement[];
// Check if dropped event overlaps with any existing events
let hasOverlaps = false;
const overlappingEvents: CalendarEvent[] = [];
for (const existingElement of existingEvents) {
const existingEvent = this.elementToCalendarEvent(existingElement);
if (!existingEvent) continue;
// Skip if it's the same event (comparing IDs)
if (existingEvent.id === droppedEvent.id) continue;
const overlapType = this.overlapManager.detectOverlap(droppedEvent, existingEvent);
if (overlapType !== OverlapType.NONE) {
hasOverlaps = true;
overlappingEvents.push(existingEvent);
}
}
// Add dropped event LAST so it appears rightmost in flexbox
overlappingEvents.push(droppedEvent);
// Only re-render if there are actual overlaps
if (!hasOverlaps) {
// No overlaps - just update the dropped element's dataset with new times
this.updateElementDataset(droppedElement, droppedEvent);
return;
}
// There are overlaps - group and re-render overlapping events
const overlapGroups = this.overlapManager.groupOverlappingEvents(overlappingEvents);
// Remove overlapping events from DOM
const overlappingEventIds = new Set(overlappingEvents.map(e => e.id));
existingEvents
.filter(el => overlappingEventIds.has(el.dataset.eventId || ''))
.forEach(el => el.remove());
droppedElement.remove();
// Re-render overlapping events with proper grouping
overlapGroups.forEach(group => {
if (group.type === OverlapType.COLUMN_SHARING && group.events.length > 1) {
this.renderColumnSharingGroup(group, eventsLayer);
} else if (group.type === OverlapType.STACKING && group.events.length > 1) {
this.renderStackedEvents(group, eventsLayer);
} else {
group.events.forEach(event => {
const eventElement = this.createEventElement(event);
this.positionEvent(eventElement, event);
eventsLayer.appendChild(eventElement);
});
}
});
}
/**
* Update element's dataset with new times after successful drop
*/
private updateElementDataset(element: HTMLElement, event: CalendarEvent): void {
element.dataset.start = event.start;
element.dataset.end = event.end;
// 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 = parseFloat(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.toISOString(),
end: endDate.toISOString(),
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: start,
end: 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) {
// Group events by overlap type
const overlapGroups = this.overlapManager.groupOverlappingEvents(columnEvents);
overlapGroups.forEach(group => {
if (group.type === OverlapType.COLUMN_SHARING && group.events.length > 1) {
// Create flexbox container for column sharing
this.renderColumnSharingGroup(group, eventsLayer);
} else if (group.type === OverlapType.STACKING && group.events.length > 1) {
// Render stacked events
this.renderStackedEvents(group, eventsLayer);
} else {
// Render normal single events
group.events.forEach(event => {
this.renderEvent(event, eventsLayer);
});
}
});
// Debug: Verify events were actually added
const renderedEvents = eventsLayer.querySelectorAll('swp-event, swp-event-group');
} else {
}
});
}
// 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.eventsOverlap(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;
allDayEvent.dataset.end = event.end;
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, container: Element): void {
const eventElement = document.createElement('swp-event');
eventElement.dataset.eventId = event.id;
eventElement.dataset.title = event.title;
eventElement.dataset.start = event.start;
eventElement.dataset.end = event.end;
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 startDate = new Date(event.start);
const endDate = new Date(event.end);
const durationMinutes = (endDate.getTime() - startDate.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>
`;
container.appendChild(eventElement);
}
protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } {
const startDate = new Date(event.start);
const endDate = new Date(event.end);
const gridSettings = calendarConfig.getGridSettings();
const dayStartHour = gridSettings.dayStartHour;
const hourHeight = gridSettings.hourHeight;
// Calculate minutes from midnight
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
const endMinutes = endDate.getHours() * 60 + endDate.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 startDate = new Date(event.start);
const endDate = new Date(event.end);
const startDateKey = DateCalculator.formatISODate(startDate);
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(startDate);
while (currentDate <= endDate) {
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 events overlap in columns
*/
private eventsOverlap(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);
}
/**
* Render column sharing group with flexbox container
*/
protected renderColumnSharingGroup(group: any, container: Element): void {
const groupContainer = this.overlapManager.createEventGroup(group.events, group.position);
// Render each event in the group
group.events.forEach((event: CalendarEvent) => {
const eventElement = this.createEventElement(event);
this.overlapManager.addToEventGroup(groupContainer, eventElement);
});
container.appendChild(groupContainer);
// Emit event for debugging/logging
eventBus.emit('overlap:group-created', {
type: 'column_sharing',
eventCount: group.events.length,
events: group.events.map((e: CalendarEvent) => e.id)
});
}
/**
* Render stacked events with margin-left offset
*/
protected renderStackedEvents(group: any, container: Element): void {
// Sort events by duration - longer events render first (background), shorter events on top
// This way shorter events are more visible and get higher z-index
const sortedEvents = [...group.events].sort((a, b) => {
const durationA = new Date(a.end).getTime() - new Date(a.start).getTime();
const durationB = new Date(b.end).getTime() - new Date(b.start).getTime();
return durationB - durationA; // Longer duration first (background)
});
let underlyingElement: HTMLElement | null = null;
sortedEvents.forEach((event: CalendarEvent, index: number) => {
const eventElement = this.createEventElement(event);
this.positionEvent(eventElement, event);
if (index === 0) {
// First (longest duration) event renders normally at full width - UNCHANGED
container.appendChild(eventElement);
underlyingElement = eventElement;
} else {
// Shorter events are stacked with margin-left offset and higher z-index
// Each subsequent event gets more margin: 15px, 30px, 45px, etc.
if (underlyingElement) {
this.overlapManager.createStackedEvent(eventElement, underlyingElement, index);
}
container.appendChild(eventElement);
// DO NOT update underlyingElement - keep it as the longest event
}
});
// Emit event for debugging/logging
eventBus.emit('overlap:events-stacked', {
type: 'stacking',
eventCount: group.events.length,
events: group.events.map((e: CalendarEvent) => e.id)
});
}
/**
* Create event element without positioning
*/
protected createEventElement(event: CalendarEvent): HTMLElement {
const eventElement = document.createElement('swp-event');
eventElement.dataset.eventId = event.id;
eventElement.dataset.title = event.title;
eventElement.dataset.start = event.start;
eventElement.dataset.end = event.end;
eventElement.dataset.type = event.type;
eventElement.dataset.duration = event.metadata?.duration?.toString() || '60';
// Format time for display using unified method
const startTime = this.formatTime(event.start);
const endTime = this.formatTime(event.end);
// Calculate duration in minutes
const startDate = new Date(event.start);
const endDate = new Date(event.end);
const durationMinutes = (endDate.getTime() - startDate.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>
`;
return eventElement;
}
/**
* Position event element
*/
protected positionEvent(eventElement: HTMLElement, event: CalendarEvent): void {
const position = this.calculateEventPosition(event);
eventElement.style.position = 'absolute';
eventElement.style.top = `${position.top + 1}px`;
eventElement.style.height = `${position.height - 3}px`;
eventElement.style.left = '2px';
eventElement.style.right = '2px';
}
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());
}
}
/**
* 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 eventDate = new Date(event.start);
const eventDateStr = DateCalculator.formatISODate(eventDate);
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;
}
}