Implements event overlap rendering

Adds logic to handle event overlaps in the calendar view. It introduces two patterns: column sharing for events with the same start time (rendered using flexbox) and stacking for events with a >30 min difference (rendered with reduced width and z-index).

It also introduces deep linking to specific events via URL parameters.
This commit is contained in:
Janus Knudsen 2025-09-04 00:16:35 +02:00
parent 7a1c776bc1
commit ff067cfac3
11 changed files with 837 additions and 16 deletions

View file

@ -6,6 +6,7 @@ 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
@ -20,6 +21,7 @@ export interface EventRendererStrategy {
*/
export abstract class BaseEventRenderer implements EventRendererStrategy {
protected dateCalculator: DateCalculator;
protected overlapManager: EventOverlapManager;
// Drag and drop state
private draggedClone: HTMLElement | null = null;
@ -30,6 +32,7 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
DateCalculator.initialize(calendarConfig);
}
this.dateCalculator = dateCalculator || new DateCalculator();
this.overlapManager = new EventOverlapManager();
}
/**
@ -230,10 +233,16 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
// Create clone
this.draggedClone = this.createEventClone(originalElement);
// Add to current column
// Add to current column's events layer (not directly to column)
const columnElement = document.querySelector(`swp-day-column[data-date="${column}"]`);
if (columnElement) {
columnElement.appendChild(this.draggedClone);
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
@ -262,10 +271,16 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
private handleColumnChange(eventId: string, newColumn: string): void {
if (!this.draggedClone) return;
// Move clone to new column
// Move clone to new column's events layer
const newColumnElement = document.querySelector(`swp-day-column[data-date="${newColumn}"]`);
if (newColumnElement && this.draggedClone.parentElement !== newColumnElement) {
newColumnElement.appendChild(this.draggedClone);
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);
}
}
}
@ -458,12 +473,26 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
const eventsLayer = column.querySelector('swp-events-layer');
if (eventsLayer) {
columnEvents.forEach(event => {
this.renderEvent(event, 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');
const renderedEvents = eventsLayer.querySelectorAll('swp-event, .event-group');
} else {
}
});
@ -679,9 +708,114 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
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 reduced width
*/
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 reduced width and higher z-index
// All stacked events use the SAME underlying element (the longest one)
if (underlyingElement) {
this.overlapManager.createStackedEvent(eventElement, underlyingElement);
}
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';
const existingEvents = container
const selector = 'swp-event, .event-group';
const existingEvents = container
? container.querySelectorAll(selector)
: document.querySelectorAll(selector);