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

@ -103,6 +103,60 @@ export class EventManager {
return this.events.find(event => event.id === id);
}
/**
* Get event by ID and return event info for navigation
* @param id Event ID to find
* @returns Event with navigation info or null if not found
*/
public getEventForNavigation(id: string): { event: CalendarEvent; eventDate: Date } | null {
const event = this.getEventById(id);
if (!event) {
return null;
}
try {
const eventDate = new Date(event.start);
if (isNaN(eventDate.getTime())) {
console.warn(`EventManager: Invalid event start date for event ${id}:`, event.start);
return null;
}
return {
event,
eventDate
};
} catch (error) {
console.warn(`EventManager: Failed to parse event date for event ${id}:`, error);
return null;
}
}
/**
* Navigate to specific event by ID
* Emits navigation events for other managers to handle
* @param eventId Event ID to navigate to
* @returns true if event found and navigation initiated, false otherwise
*/
public navigateToEvent(eventId: string): boolean {
const eventInfo = this.getEventForNavigation(eventId);
if (!eventInfo) {
console.warn(`EventManager: Event with ID ${eventId} not found`);
return false;
}
const { event, eventDate } = eventInfo;
// Emit navigation request event
this.eventBus.emit(CoreEvents.NAVIGATE_TO_EVENT, {
eventId,
event,
eventDate,
eventStartTime: event.start
});
return true;
}
/**
* Optimized events for period with caching and DateCalculator
*/

View file

@ -0,0 +1,268 @@
/**
* EventOverlapManager - Håndterer overlap detection og DOM manipulation for overlapping events
* Implementerer både column sharing (flexbox) og stacking patterns
*/
import { CalendarEvent } from '../types/CalendarTypes';
import { DateCalculator } from '../utils/DateCalculator';
import { calendarConfig } from '../core/CalendarConfig';
export enum OverlapType {
NONE = 'none',
COLUMN_SHARING = 'column_sharing',
STACKING = 'stacking'
}
export interface OverlapGroup {
type: OverlapType;
events: CalendarEvent[];
position: { top: number; height: number };
container?: HTMLElement;
}
export class EventOverlapManager {
private static readonly STACKING_TIME_THRESHOLD_MINUTES = 30;
private static readonly STACKING_WIDTH_REDUCTION_PX = 15;
private nextZIndex = 100;
/**
* Detect overlap mellem events baseret start tid
*/
public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType {
const start1 = new Date(event1.start).getTime();
const start2 = new Date(event2.start).getTime();
const timeDiffMinutes = Math.abs(start1 - start2) / (1000 * 60);
// Samme start tid = column sharing
if (timeDiffMinutes === 0) {
return OverlapType.COLUMN_SHARING;
}
// Mere end 30 min forskel = stacking
if (timeDiffMinutes > EventOverlapManager.STACKING_TIME_THRESHOLD_MINUTES) {
return OverlapType.STACKING;
}
return OverlapType.NONE;
}
/**
* Gruppér events baseret overlap type
*/
public groupOverlappingEvents(events: CalendarEvent[]): OverlapGroup[] {
const groups: OverlapGroup[] = [];
const processedEvents = new Set<string>();
for (const event of events) {
if (processedEvents.has(event.id)) continue;
const overlappingEvents = [event];
processedEvents.add(event.id);
// Find alle events der overlapper med dette event
for (const otherEvent of events) {
if (otherEvent.id === event.id || processedEvents.has(otherEvent.id)) continue;
const overlapType = this.detectOverlap(event, otherEvent);
if (overlapType !== OverlapType.NONE) {
overlappingEvents.push(otherEvent);
processedEvents.add(otherEvent.id);
}
}
// Opret gruppe hvis der er overlap
if (overlappingEvents.length > 1) {
const overlapType = this.detectOverlap(overlappingEvents[0], overlappingEvents[1]);
groups.push({
type: overlapType,
events: overlappingEvents,
position: this.calculateGroupPosition(overlappingEvents)
});
} else {
// Single event - ingen overlap
groups.push({
type: OverlapType.NONE,
events: [event],
position: this.calculateGroupPosition([event])
});
}
}
return groups;
}
/**
* Opret flexbox container for column sharing events
*/
public createEventGroup(events: CalendarEvent[], position: { top: number; height: number }): HTMLElement {
const container = document.createElement('div');
container.className = 'event-group';
container.style.position = 'absolute';
container.style.top = `${position.top}px`;
container.style.height = `${position.height}px`;
container.style.left = '2px';
container.style.right = '2px';
// Data attributter for debugging og styling
container.dataset.eventCount = events.length.toString();
container.dataset.overlapType = OverlapType.COLUMN_SHARING;
return container;
}
/**
* Tilføj event til eksisterende event group
*/
public addToEventGroup(container: HTMLElement, eventElement: HTMLElement): void {
// Fjern absolute positioning fra event da flexbox håndterer layout
eventElement.style.position = 'relative';
eventElement.style.top = '';
eventElement.style.left = '';
eventElement.style.right = '';
container.appendChild(eventElement);
// Opdater event count
const currentCount = parseInt(container.dataset.eventCount || '0');
container.dataset.eventCount = (currentCount + 1).toString();
}
/**
* Fjern event fra event group og cleanup hvis tom
*/
public removeFromEventGroup(container: HTMLElement, eventId: string): boolean {
const eventElement = container.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement;
if (!eventElement) return false;
// Gendan absolute positioning
eventElement.style.position = 'absolute';
eventElement.remove();
// Opdater event count
const currentCount = parseInt(container.dataset.eventCount || '0');
const newCount = Math.max(0, currentCount - 1);
container.dataset.eventCount = newCount.toString();
// Cleanup hvis tom container
if (newCount === 0) {
container.remove();
return true; // Container blev fjernet
}
// Hvis kun ét event tilbage, konvertér tilbage til normal event
if (newCount === 1) {
const remainingEvent = container.querySelector('swp-event') as HTMLElement;
if (remainingEvent) {
// Gendan normal event positioning
remainingEvent.style.position = 'absolute';
remainingEvent.style.top = container.style.top;
remainingEvent.style.left = '2px';
remainingEvent.style.right = '2px';
// Indsæt før container og fjern container
container.parentElement?.insertBefore(remainingEvent, container);
container.remove();
return true; // Container blev fjernet
}
}
return false; // Container blev ikke fjernet
}
/**
* Opret stacked event med reduceret bredde
*/
public createStackedEvent(eventElement: HTMLElement, underlyingElement: HTMLElement): void {
// Beregn reduceret bredde baseret på swp-events-layer (som har den korrekte fulde bredde)
// Underlying event skal beholde sin fulde bredde
const eventsLayer = underlyingElement.parentElement;
const columnWidth = eventsLayer ? eventsLayer.offsetWidth : 200; // fallback
const stackedWidth = Math.max(50, columnWidth - EventOverlapManager.STACKING_WIDTH_REDUCTION_PX);
eventElement.style.width = `${stackedWidth}px`;
eventElement.style.right = '2px';
eventElement.style.left = 'auto';
eventElement.style.zIndex = this.getNextZIndex().toString();
// Data attributter
eventElement.dataset.overlapType = OverlapType.STACKING;
eventElement.dataset.stackedWidth = stackedWidth.toString();
}
/**
* Fjern stacking styling fra event
*/
public removeStackedStyling(eventElement: HTMLElement): void {
eventElement.style.width = '';
eventElement.style.right = '';
eventElement.style.left = '2px';
eventElement.style.zIndex = '';
delete eventElement.dataset.overlapType;
delete eventElement.dataset.stackedWidth;
}
/**
* Beregn position for event gruppe
*/
private calculateGroupPosition(events: CalendarEvent[]): { top: number; height: number } {
if (events.length === 0) return { top: 0, height: 0 };
// Find tidligste start og seneste slut
const startTimes = events.map(e => new Date(e.start).getTime());
const endTimes = events.map(e => new Date(e.end).getTime());
const earliestStart = Math.min(...startTimes);
const latestEnd = Math.max(...endTimes);
// Konvertér til pixel positions (dette skal matches med EventRenderer logik)
const startDate = new Date(earliestStart);
const endDate = new Date(latestEnd);
// Brug samme logik som EventRenderer.calculateEventPosition
const gridSettings = { dayStartHour: 6, hourHeight: 80 }; // Fra config
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
const endMinutes = endDate.getHours() * 60 + endDate.getMinutes();
const dayStartMinutes = gridSettings.dayStartHour * 60;
const top = ((startMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
const height = ((endMinutes - startMinutes) / 60) * gridSettings.hourHeight;
return { top, height };
}
/**
* Get next available z-index for stacked events
*/
private getNextZIndex(): number {
return ++this.nextZIndex;
}
/**
* Reset z-index counter
*/
public resetZIndex(): void {
this.nextZIndex = 100;
}
/**
* Check if element is part of an event group
*/
public isInEventGroup(element: HTMLElement): boolean {
return element.closest('.event-group') !== null;
}
/**
* Check if element is a stacked event
*/
public isStackedEvent(element: HTMLElement): boolean {
return element.dataset.overlapType === OverlapType.STACKING;
}
/**
* Get event group container for an event element
*/
public getEventGroup(eventElement: HTMLElement): HTMLElement | null {
return eventElement.closest('.event-group') as HTMLElement;
}
}

View file

@ -118,6 +118,53 @@ export class NavigationManager {
this.navigateToDate(targetDate);
});
// Listen for event navigation requests
this.eventBus.on(CoreEvents.NAVIGATE_TO_EVENT, (event: Event) => {
const customEvent = event as CustomEvent;
const { eventDate, eventStartTime } = customEvent.detail;
if (!eventDate || !eventStartTime) {
console.warn('NavigationManager: Invalid event navigation data');
return;
}
this.navigateToEventDate(eventDate, eventStartTime);
});
}
/**
* Navigate to specific event date and emit scroll event after navigation
*/
private navigateToEventDate(eventDate: Date, eventStartTime: string): void {
const weekStart = DateCalculator.getISOWeekStart(eventDate);
this.targetWeek = new Date(weekStart);
const currentTime = this.currentWeek.getTime();
const targetTime = weekStart.getTime();
// Store event start time for scrolling after navigation
const scrollAfterNavigation = () => {
// Emit scroll request after navigation is complete
this.eventBus.emit('scroll:to-event-time', {
eventStartTime
});
};
if (currentTime < targetTime) {
this.animationQueue++;
this.animateTransition('next', weekStart);
// Listen for navigation completion to trigger scroll
this.eventBus.once(CoreEvents.NAVIGATION_COMPLETED, scrollAfterNavigation);
} else if (currentTime > targetTime) {
this.animationQueue++;
this.animateTransition('prev', weekStart);
// Listen for navigation completion to trigger scroll
this.eventBus.once(CoreEvents.NAVIGATION_COMPLETED, scrollAfterNavigation);
} else {
// Already on correct week, just scroll
scrollAfterNavigation();
}
}
private navigateToPreviousWeek(): void {

View file

@ -45,6 +45,16 @@ export class ScrollManager {
window.addEventListener('resize', () => {
this.updateScrollableHeight();
});
// Listen for scroll to event time requests
eventBus.on('scroll:to-event-time', (event: Event) => {
const customEvent = event as CustomEvent;
const { eventStartTime } = customEvent.detail;
if (eventStartTime) {
this.scrollToEventTime(eventStartTime);
}
});
}
/**
@ -97,6 +107,25 @@ export class ScrollManager {
this.scrollTo(scrollTop);
}
/**
* Scroll to specific event time
* @param eventStartTime ISO string of event start time
*/
scrollToEventTime(eventStartTime: string): void {
try {
const eventDate = new Date(eventStartTime);
const eventHour = eventDate.getHours();
const eventMinutes = eventDate.getMinutes();
// Convert to decimal hour (e.g., 14:30 becomes 14.5)
const decimalHour = eventHour + (eventMinutes / 60);
this.scrollToHour(decimalHour);
} catch (error) {
console.warn('ScrollManager: Failed to scroll to event time:', error);
}
}
/**
* Setup ResizeObserver to monitor container size changes
*/