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

@ -13,10 +13,11 @@ export const CoreEvents = {
VIEW_RENDERED: 'view:rendered',
WORKWEEK_CHANGED: 'workweek:changed',
// Navigation events (3)
// Navigation events (4)
DATE_CHANGED: 'nav:date-changed',
NAVIGATION_COMPLETED: 'nav:navigation-completed',
PERIOD_INFO_UPDATE: 'nav:period-info-update',
NAVIGATE_TO_EVENT: 'nav:navigate-to-event',
// Data events (4)
DATA_LOADING: 'data:loading',

View file

@ -952,12 +952,12 @@
{
"id": "96",
"title": "Deep Work",
"start": "2025-09-03T10:00:00",
"end": "2025-09-03T12:00:00",
"start": "2025-09-02T15:00:00",
"end": "2025-09-02T15:30:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#3f51b5" }
"metadata": { "duration": 30, "color": "#3f51b5" }
},
{
"id": "97",
@ -992,8 +992,8 @@
{
"id": "100",
"title": "Sprint Review",
"start": "2025-09-05T14:00:00",
"end": "2025-09-05T15:00:00",
"start": "2025-09-04T15:00:00",
"end": "2025-09-04T16:00:00",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",

View file

@ -4,6 +4,31 @@ import { calendarConfig } from './core/CalendarConfig.js';
import { CalendarTypeFactory } from './factories/CalendarTypeFactory.js';
import { ManagerFactory } from './factories/ManagerFactory.js';
import { DateCalculator } from './utils/DateCalculator.js';
import { URLManager } from './utils/URLManager.js';
/**
* Handle deep linking functionality after managers are initialized
*/
async function handleDeepLinking(managers: any): Promise<void> {
try {
const urlManager = new URLManager(eventBus);
const eventId = urlManager.parseEventIdFromURL();
if (eventId) {
console.log(`Deep linking to event ID: ${eventId}`);
// Wait a bit for managers to be fully ready
setTimeout(() => {
const success = managers.eventManager.navigateToEvent(eventId);
if (!success) {
console.warn(`Deep linking failed: Event with ID ${eventId} not found`);
}
}, 500);
}
} catch (error) {
console.warn('Deep linking failed:', error);
}
}
/**
* Initialize the calendar application with simple direct calls
@ -30,6 +55,8 @@ async function initializeCalendar(): Promise<void> {
// Initialize all managers
await managerFactory.initializeManagers(managers);
// Handle deep linking after managers are initialized
await handleDeepLinking(managers);
// Expose to window for debugging
(window as any).calendarDebug = {

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
*/

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);

86
src/utils/URLManager.ts Normal file
View file

@ -0,0 +1,86 @@
import { EventBus } from '../core/EventBus';
import { IEventBus } from '../types/CalendarTypes';
/**
* URLManager handles URL query parameter parsing and deep linking functionality
* Follows event-driven architecture with no global state
*/
export class URLManager {
private eventBus: IEventBus;
constructor(eventBus: IEventBus) {
this.eventBus = eventBus;
}
/**
* Parse eventId from URL query parameters
* @returns eventId string or null if not found
*/
public parseEventIdFromURL(): string | null {
try {
const urlParams = new URLSearchParams(window.location.search);
const eventId = urlParams.get('eventId');
if (eventId && eventId.trim() !== '') {
return eventId.trim();
}
return null;
} catch (error) {
console.warn('URLManager: Failed to parse URL parameters:', error);
return null;
}
}
/**
* Get all query parameters as an object
* @returns object with all query parameters
*/
public getAllQueryParams(): Record<string, string> {
try {
const urlParams = new URLSearchParams(window.location.search);
const params: Record<string, string> = {};
for (const [key, value] of urlParams.entries()) {
params[key] = value;
}
return params;
} catch (error) {
console.warn('URLManager: Failed to parse URL parameters:', error);
return {};
}
}
/**
* Update URL without page reload (for future use)
* @param params object with parameters to update
*/
public updateURL(params: Record<string, string | null>): void {
try {
const url = new URL(window.location.href);
// Update or remove parameters
Object.entries(params).forEach(([key, value]) => {
if (value === null) {
url.searchParams.delete(key);
} else {
url.searchParams.set(key, value);
}
});
// Update URL without page reload
window.history.replaceState({}, '', url.toString());
} catch (error) {
console.warn('URLManager: Failed to update URL:', error);
}
}
/**
* Check if current URL has any query parameters
* @returns true if URL has query parameters
*/
public hasQueryParams(): boolean {
return window.location.search.length > 0;
}
}