diff --git a/overlap-fix-plan.md b/overlap-fix-plan.md new file mode 100644 index 0000000..4217c9b --- /dev/null +++ b/overlap-fix-plan.md @@ -0,0 +1,85 @@ +# Overlap Detection Fix Plan + +## Problem Analysis +Den nuværende overlap detection logik i EventOverlapManager tjekker kun på tidsforskel mellem start tidspunkter, men ikke om events faktisk overlapper i tid. Dette resulterer i forkert stacking behavior. + +## Updated Overlap Logic Requirements + +### Scenario 1: Column Sharing (Flexbox) +**Regel**: Events med samme start tid ELLER start tid indenfor 30 minutter +- **Eksempel**: Event A (09:00-10:00) + Event B (09:15-10:30) +- **Resultat**: Deler pladsen med flexbox - ingen stacking + +### Scenario 2: Stacking +**Regel**: Events overlapper i tid MEN har >30 min forskel i start tid +- **Eksempel**: Product Planning (14:00-16:00) + Deep Work (15:00-15:30) +- **Resultat**: Stacking med reduceret bredde for kortere event + +### Scenario 3: Ingen Overlap +**Regel**: Events overlapper ikke i tid ELLER står alene +- **Eksempel**: Standalone 30 min event kl. 10:00-10:30 +- **Resultat**: Normal rendering, fuld bredde + +## Implementation Plan + +### 1. Fix EventOverlapManager.detectOverlap() +```typescript +public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType { + // Først: Tjek om events overlapper i tid + if (!this.eventsOverlapInTime(event1, event2)) { + return OverlapType.NONE; + } + + // Events overlapper i tid - nu tjek start tid forskel + const start1 = new Date(event1.start).getTime(); + const start2 = new Date(event2.start).getTime(); + const timeDiffMinutes = Math.abs(start1 - start2) / (1000 * 60); + + // Indenfor 30 min start forskel = column sharing + if (timeDiffMinutes <= 30) { + return OverlapType.COLUMN_SHARING; + } + + // Mere end 30 min start forskel = stacking + return OverlapType.STACKING; +} +``` + +### 2. Add eventsOverlapInTime() method +```typescript +private eventsOverlapInTime(event1: CalendarEvent, event2: CalendarEvent): boolean { + const start1 = new Date(event1.start).getTime(); + const end1 = new Date(event1.end).getTime(); + const start2 = new Date(event2.start).getTime(); + const end2 = new Date(event2.end).getTime(); + + // Events overlapper hvis de deler mindst ét tidspunkt + return !(end1 <= start2 || end2 <= start1); +} +``` + +### 3. Remove Unnecessary Data Attributes +- Fjern `overlapType` og `stackedWidth` data attributter fra createStackedEvent() +- Simplificér removeStackedStyling() metoden + +### 4. Test Scenarios +- Test med Product Planning (14:00-16:00) + Deep Work (15:00-15:30) = stacking +- Test med events indenfor 30 min start forskel = column sharing +- Test med standalone events = normal rendering + +## Changes Required + +### EventOverlapManager.ts +1. Tilføj eventsOverlapInTime() private metode +2. Modificer detectOverlap() metode med ny logik +3. Fjern data attributter i createStackedEvent() +4. Simplificér removeStackedStyling() + +### EventRenderer.ts +- Ingen ændringer nødvendige - bruger allerede EventOverlapManager + +## Expected Outcome +- Korrekt column sharing for events med start tid indenfor 30 min +- Korrekt stacking kun når events faktisk overlapper med >30 min start forskel +- Normale events renderes med fuld bredde når de står alene +- Renere kode uden unødvendige data attributter \ No newline at end of file diff --git a/src/managers/EventOverlapManager.ts b/src/managers/EventOverlapManager.ts index e3bddca..c03bfd2 100644 --- a/src/managers/EventOverlapManager.ts +++ b/src/managers/EventOverlapManager.ts @@ -26,24 +26,39 @@ export class EventOverlapManager { private nextZIndex = 100; /** - * Detect overlap mellem events baseret på start tid + * Detect overlap mellem events baseret på faktisk time overlap og start tid forskel */ public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType { + // Først: Tjek om events overlapper i tid + if (!this.eventsOverlapInTime(event1, event2)) { + return OverlapType.NONE; + } + + // Events overlapper i tid - nu tjek start tid forskel 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 + + // Over 30 min start forskel = stacking if (timeDiffMinutes > EventOverlapManager.STACKING_TIME_THRESHOLD_MINUTES) { return OverlapType.STACKING; } + + // Indenfor 30 min start forskel = column sharing + return OverlapType.COLUMN_SHARING; + } - return OverlapType.NONE; + /** + * Tjek om to events faktisk overlapper i tid + */ + private eventsOverlapInTime(event1: CalendarEvent, event2: CalendarEvent): boolean { + const start1 = new Date(event1.start).getTime(); + const end1 = new Date(event1.end).getTime(); + const start2 = new Date(event2.start).getTime(); + const end2 = new Date(event2.end).getTime(); + + // Events overlapper hvis de deler mindst ét tidspunkt + return !(end1 <= start2 || end2 <= start1); } /** @@ -95,17 +110,12 @@ export class EventOverlapManager { * 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'; + const container = document.createElement('swp-event-group'); container.style.position = 'absolute'; container.style.top = `${position.top}px`; - container.style.height = `${position.height}px`; + // Ingen højde på gruppen - kun på individuelle events 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; } @@ -114,17 +124,16 @@ export class EventOverlapManager { * 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 = ''; + // Sørg for at event har korrekt højde baseret på varighed + const duration = eventElement.dataset.duration; + if (duration) { + const durationMinutes = parseInt(duration); + const gridSettings = { hourHeight: 80 }; // Fra config + const height = (durationMinutes / 60) * gridSettings.hourHeight; + eventElement.style.height = `${height - 3}px`; // -3px som andre events + } container.appendChild(eventElement); - - // Opdater event count - const currentCount = parseInt(container.dataset.eventCount || '0'); - container.dataset.eventCount = (currentCount + 1).toString(); } /** @@ -138,68 +147,57 @@ export class EventOverlapManager { 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(); + // Tæl resterende events + const remainingEvents = container.querySelectorAll('swp-event'); + const remainingCount = remainingEvents.length; // Cleanup hvis tom container - if (newCount === 0) { + if (remainingCount === 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 - } + if (remainingCount === 1) { + const remainingEvent = remainingEvents[0] as HTMLElement; + // 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 + * Opret stacked event med margin-left offset */ - 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); + public createStackedEvent(eventElement: HTMLElement, underlyingElement: HTMLElement, stackLevel: number = 1): void { + // Brug margin-left i stedet for width manipulation + const marginLeft = stackLevel * EventOverlapManager.STACKING_WIDTH_REDUCTION_PX; - eventElement.style.width = `${stackedWidth}px`; + eventElement.style.marginLeft = `${marginLeft}px`; + eventElement.style.left = '2px'; eventElement.style.right = '2px'; - eventElement.style.left = 'auto'; + eventElement.style.width = ''; 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.marginLeft = ''; eventElement.style.width = ''; - eventElement.style.right = ''; eventElement.style.left = '2px'; + eventElement.style.right = '2px'; eventElement.style.zIndex = ''; - - delete eventElement.dataset.overlapType; - delete eventElement.dataset.stackedWidth; } /** @@ -249,20 +247,20 @@ export class EventOverlapManager { * Check if element is part of an event group */ public isInEventGroup(element: HTMLElement): boolean { - return element.closest('.event-group') !== null; + return element.closest('swp-event-group') !== null; } /** * Check if element is a stacked event */ public isStackedEvent(element: HTMLElement): boolean { - return element.dataset.overlapType === OverlapType.STACKING; + return element.style.marginLeft !== '' && element.style.marginLeft !== '0px'; } /** * Get event group container for an event element */ public getEventGroup(eventElement: HTMLElement): HTMLElement | null { - return eventElement.closest('.event-group') as HTMLElement; + return eventElement.closest('swp-event-group') as HTMLElement; } } \ No newline at end of file diff --git a/src/renderers/EventRenderer.ts b/src/renderers/EventRenderer.ts index ce56976..ecff38c 100644 --- a/src/renderers/EventRenderer.ts +++ b/src/renderers/EventRenderer.ts @@ -147,10 +147,11 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { clone.style.pointerEvents = 'none'; clone.style.opacity = '0.8'; - // Keep original dimensions (height stays the same) - const rect = originalEvent.getBoundingClientRect(); - clone.style.width = rect.width + 'px'; - clone.style.height = rect.height + 'px'; + // 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; } @@ -230,6 +231,11 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { 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); @@ -293,6 +299,9 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { return; } + // Remove original event from any existing groups first + this.removeEventFromExistingGroups(this.originalEvent); + // Fade out original this.fadeOutAndRemove(this.originalEvent); @@ -306,8 +315,10 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { this.draggedClone.style.pointerEvents = ''; this.draggedClone.style.opacity = ''; this.draggedClone.style.userSelect = ''; - this.draggedClone.style.zIndex = ''; + // 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; @@ -315,6 +326,196 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { } + /** + * 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; + + // Get all existing events in the column (excluding the dropped element) + const existingEvents = Array.from(eventsLayer.querySelectorAll('swp-event')) + .filter(el => el !== droppedElement) as HTMLElement[]; + + // Convert dropped element to CalendarEvent using its NEW position + const droppedEvent = this.elementToCalendarEventWithNewPosition(droppedElement, targetColumn); + if (!droppedEvent) return; + + // Check if dropped event overlaps with any existing events + let hasOverlaps = false; + const overlappingEvents: CalendarEvent[] = [droppedEvent]; + + for (const existingElement of existingEvents) { + const existingEvent = this.elementToCalendarEvent(existingElement); + if (!existingEvent) continue; + + const overlapType = this.overlapManager.detectOverlap(droppedEvent, existingEvent); + if (overlapType !== OverlapType.NONE) { + hasOverlaps = true; + overlappingEvents.push(existingEvent); + } + } + + // 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 */ @@ -492,7 +693,7 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { }); // Debug: Verify events were actually added - const renderedEvents = eventsLayer.querySelectorAll('swp-event, .event-group'); + const renderedEvents = eventsLayer.querySelectorAll('swp-event, swp-event-group'); } else { } }); @@ -731,7 +932,7 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { } /** - * Render stacked events with reduced width + * 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 @@ -753,10 +954,10 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { 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) + // 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); + this.overlapManager.createStackedEvent(eventElement, underlyingElement, index); } container.appendChild(eventElement); // DO NOT update underlyingElement - keep it as the longest event @@ -814,7 +1015,7 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { } clearEvents(container?: HTMLElement): void { - const selector = 'swp-event, .event-group'; + const selector = 'swp-event, swp-event-group'; const existingEvents = container ? container.querySelectorAll(selector) : document.querySelectorAll(selector); diff --git a/wwwroot/css/calendar-events-css.css b/wwwroot/css/calendar-events-css.css index a771e4f..2e404be 100644 --- a/wwwroot/css/calendar-events-css.css +++ b/wwwroot/css/calendar-events-css.css @@ -208,7 +208,7 @@ swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"] { /* Event overlap styling */ /* Event group container for column sharing */ -.event-group { +swp-event-group { position: absolute; display: flex; gap: 1px; @@ -217,23 +217,10 @@ swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"] { z-index: 10; } -.event-group swp-event { +swp-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