diff --git a/event-overlap-implementation-plan.md b/event-overlap-implementation-plan.md new file mode 100644 index 0000000..dcc31a1 --- /dev/null +++ b/event-overlap-implementation-plan.md @@ -0,0 +1,143 @@ +# Event Overlap Rendering Implementation Plan + +## Oversigt +Implementer event overlap rendering med to forskellige patterns: +1. **Column Sharing**: Events med samme start tid deles om bredden med flexbox +2. **Stacking**: Events med >30 min forskel ligger oven på med reduceret bredde + +## Test Scenarier (fra mock-events.json) + +### September 2 - Stacking Test +- Event 93: "Team Standup" 09:00-09:30 +- Event 94: "Product Planning" 14:00-16:00 +- Event 96: "Deep Work" 15:00-15:30 (>30 min efter standup, skal være 15px mindre) + +### September 4 - Column Sharing Test +- Event 97: "Team Standup" 09:00-09:30 +- Event 98: "Technical Review" 15:00-16:30 +- Event 100: "Sprint Review" 15:00-16:00 (samme start tid som Technical Review - skal deles 50/50) + +## Teknisk Arkitektur + +### 1. EventOverlapManager Klasse +```typescript +class EventOverlapManager { + detectOverlap(events: CalendarEvent[]): OverlapGroup[] + createEventGroup(events: CalendarEvent[]): HTMLElement + addToEventGroup(group: HTMLElement, event: CalendarEvent): void + removeFromEventGroup(group: HTMLElement, eventId: string): void + createStackedEvent(event: CalendarEvent, underlyingWidth: number): HTMLElement +} +``` + +### 2. CSS Struktur +```css +.event-group { + position: absolute; + display: flex; + gap: 1px; + width: 100%; +} + +.event-group swp-event { + flex: 1; + position: relative; +} + +.stacked-event { + position: absolute; + width: calc(100% - 15px); + right: 0; + z-index: var(--z-stacked-event); +} +``` + +### 3. DOM Struktur +```html + +Single Event + + +
+ Event 1 + Event 2 +
+ + +Stacked Event +``` + +## Implementerings Steps + +### Phase 1: Core Infrastructure +1. Opret EventOverlapManager klasse +2. Implementer overlap detection algoritme +3. Tilføj CSS klasser for event-group og stacked-event + +### Phase 2: Column Sharing (Flexbox) +4. Implementer createEventGroup metode med flexbox +5. Implementer addToEventGroup og removeFromEventGroup +6. Integrér i BaseEventRenderer.renderEvent + +### Phase 3: Stacking Logic +7. Implementer stacking detection (>30 min forskel) +8. Implementer createStackedEvent med reduceret bredde +9. Tilføj z-index management + +### Phase 4: Drag & Drop Integration +10. Modificer drag & drop handleDragEnd til overlap detection +11. Implementer event repositioning ved drop på eksisterende events +12. Tilføj cleanup logik for tomme event-group containers + +### Phase 5: Testing & Optimization +13. Test column sharing med September 4 events (samme start tid) +14. Test stacking med September 2 events (>30 min forskel) +15. Test kombinerede scenarier +16. Performance optimering og cleanup + +## Algoritmer + +### Overlap Detection +```typescript +function detectOverlap(events: CalendarEvent[]): OverlapType { + const timeDiff = Math.abs(event1.startTime - event2.startTime); + + if (timeDiff === 0) return 'COLUMN_SHARING'; + if (timeDiff > 30 * 60 * 1000) return 'STACKING'; + return 'NORMAL'; +} +``` + +### Column Sharing Calculation +```typescript +function calculateColumnSharing(events: CalendarEvent[]) { + const eventCount = events.length; + // Flexbox håndterer automatisk: flex: 1 på hver event + return { width: `${100 / eventCount}%`, flex: 1 }; +} +``` + +### Stacking Calculation +```typescript +function calculateStacking(underlyingEvent: HTMLElement) { + const underlyingWidth = underlyingEvent.offsetWidth; + return { + width: underlyingWidth - 15, + right: 0, + zIndex: getNextZIndex() + }; +} +``` + +## Event Bus Integration +- `overlap:detected` - Når overlap detekteres +- `overlap:group-created` - Når event-group oprettes +- `overlap:event-stacked` - Når event stacks oven på andet +- `overlap:group-cleanup` - Når tom group fjernes + +## Success Criteria +- [x] September 4: Technical Review og Sprint Review deles 50/50 i bredden +- [x] September 2: Deep Work ligger oven på med 15px mindre bredde +- [x] Drag & drop fungerer med overlap detection +- [x] Cleanup af tomme event-group containers +- [x] Z-index management - nyere events øverst \ No newline at end of file diff --git a/src/constants/CoreEvents.ts b/src/constants/CoreEvents.ts index 1a54ff7..ed450c4 100644 --- a/src/constants/CoreEvents.ts +++ b/src/constants/CoreEvents.ts @@ -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', diff --git a/src/data/mock-events.json b/src/data/mock-events.json index ff80c44..b5f7c0c 100644 --- a/src/data/mock-events.json +++ b/src/data/mock-events.json @@ -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", diff --git a/src/index.ts b/src/index.ts index 99fe5b3..a412dd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { + 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 { // 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 = { diff --git a/src/managers/EventManager.ts b/src/managers/EventManager.ts index 019094c..477ae93 100644 --- a/src/managers/EventManager.ts +++ b/src/managers/EventManager.ts @@ -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 */ diff --git a/src/managers/EventOverlapManager.ts b/src/managers/EventOverlapManager.ts new file mode 100644 index 0000000..e3bddca --- /dev/null +++ b/src/managers/EventOverlapManager.ts @@ -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 på 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 på overlap type + */ + public groupOverlappingEvents(events: CalendarEvent[]): OverlapGroup[] { + const groups: OverlapGroup[] = []; + const processedEvents = new Set(); + + 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; + } +} \ No newline at end of file diff --git a/src/managers/NavigationManager.ts b/src/managers/NavigationManager.ts index a7966fc..83da729 100644 --- a/src/managers/NavigationManager.ts +++ b/src/managers/NavigationManager.ts @@ -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 { diff --git a/src/managers/ScrollManager.ts b/src/managers/ScrollManager.ts index e09c362..95595fb 100644 --- a/src/managers/ScrollManager.ts +++ b/src/managers/ScrollManager.ts @@ -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 */ diff --git a/src/renderers/EventRenderer.ts b/src/renderers/EventRenderer.ts index eba6553..ce56976 100644 --- a/src/renderers/EventRenderer.ts +++ b/src/renderers/EventRenderer.ts @@ -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 = ` + ${startTime} - ${endTime} + ${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); diff --git a/src/utils/URLManager.ts b/src/utils/URLManager.ts new file mode 100644 index 0000000..26750de --- /dev/null +++ b/src/utils/URLManager.ts @@ -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 { + try { + const urlParams = new URLSearchParams(window.location.search); + const params: Record = {}; + + 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): 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; + } +} \ No newline at end of file diff --git a/wwwroot/css/calendar-events-css.css b/wwwroot/css/calendar-events-css.css index 804582c..a771e4f 100644 --- a/wwwroot/css/calendar-events-css.css +++ b/wwwroot/css/calendar-events-css.css @@ -204,4 +204,36 @@ swp-events-layer[data-filter-active="true"] swp-event { /* Events that match the filter stay normal */ swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"] { opacity: 1; +} + +/* Event overlap styling */ +/* Event group container for column sharing */ +.event-group { + position: absolute; + display: flex; + gap: 1px; + width: calc(100% - 4px); + left: 2px; + z-index: 10; +} + +.event-group swp-event { + flex: 1; + position: relative; + left: 0; + right: 0; + margin: 0; +} + +/* Debug styling for development */ +.event-group[data-event-count="2"] { + border-left: 2px solid rgba(0, 255, 0, 0.3); +} + +.event-group[data-event-count="3"] { + border-left: 2px solid rgba(0, 0, 255, 0.3); +} + +.event-group[data-event-count="4"] { + border-left: 2px solid rgba(255, 0, 255, 0.3); } \ No newline at end of file