Fixes event overlap detection and stacking logic

Updates the event overlap detection to accurately determine when events overlap in time, fixing incorrect stacking behavior.

Implements column sharing for events starting within 30 minutes of each other.

Applies stacking only when events truly overlap in time but start times differ by more than 30 minutes.

Removes unnecessary data attributes and simplifies styling for stacked events, improving code cleanliness and performance.
This commit is contained in:
Janus Knudsen 2025-09-04 19:22:26 +02:00
parent ff067cfac3
commit 6afea2571b
4 changed files with 361 additions and 90 deletions

85
overlap-fix-plan.md Normal file
View file

@ -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

View file

@ -26,24 +26,39 @@ export class EventOverlapManager {
private nextZIndex = 100; private nextZIndex = 100;
/** /**
* Detect overlap mellem events baseret start tid * Detect overlap mellem events baseret faktisk time overlap og start tid forskel
*/ */
public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType { 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 start1 = new Date(event1.start).getTime();
const start2 = new Date(event2.start).getTime(); const start2 = new Date(event2.start).getTime();
const timeDiffMinutes = Math.abs(start1 - start2) / (1000 * 60); const timeDiffMinutes = Math.abs(start1 - start2) / (1000 * 60);
// Samme start tid = column sharing // Over 30 min start forskel = stacking
if (timeDiffMinutes === 0) {
return OverlapType.COLUMN_SHARING;
}
// Mere end 30 min forskel = stacking
if (timeDiffMinutes > EventOverlapManager.STACKING_TIME_THRESHOLD_MINUTES) { if (timeDiffMinutes > EventOverlapManager.STACKING_TIME_THRESHOLD_MINUTES) {
return OverlapType.STACKING; 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 * Opret flexbox container for column sharing events
*/ */
public createEventGroup(events: CalendarEvent[], position: { top: number; height: number }): HTMLElement { public createEventGroup(events: CalendarEvent[], position: { top: number; height: number }): HTMLElement {
const container = document.createElement('div'); const container = document.createElement('swp-event-group');
container.className = 'event-group';
container.style.position = 'absolute'; container.style.position = 'absolute';
container.style.top = `${position.top}px`; 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.left = '2px';
container.style.right = '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; return container;
} }
@ -114,17 +124,16 @@ export class EventOverlapManager {
* Tilføj event til eksisterende event group * Tilføj event til eksisterende event group
*/ */
public addToEventGroup(container: HTMLElement, eventElement: HTMLElement): void { public addToEventGroup(container: HTMLElement, eventElement: HTMLElement): void {
// Fjern absolute positioning fra event da flexbox håndterer layout // Sørg for at event har korrekt højde baseret på varighed
eventElement.style.position = 'relative'; const duration = eventElement.dataset.duration;
eventElement.style.top = ''; if (duration) {
eventElement.style.left = ''; const durationMinutes = parseInt(duration);
eventElement.style.right = ''; 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); 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.style.position = 'absolute';
eventElement.remove(); eventElement.remove();
// Opdater event count // Tæl resterende events
const currentCount = parseInt(container.dataset.eventCount || '0'); const remainingEvents = container.querySelectorAll('swp-event');
const newCount = Math.max(0, currentCount - 1); const remainingCount = remainingEvents.length;
container.dataset.eventCount = newCount.toString();
// Cleanup hvis tom container // Cleanup hvis tom container
if (newCount === 0) { if (remainingCount === 0) {
container.remove(); container.remove();
return true; // Container blev fjernet return true; // Container blev fjernet
} }
// Hvis kun ét event tilbage, konvertér tilbage til normal event // Hvis kun ét event tilbage, konvertér tilbage til normal event
if (newCount === 1) { if (remainingCount === 1) {
const remainingEvent = container.querySelector('swp-event') as HTMLElement; const remainingEvent = remainingEvents[0] as HTMLElement;
if (remainingEvent) { // Gendan normal event positioning
// Gendan normal event positioning remainingEvent.style.position = 'absolute';
remainingEvent.style.position = 'absolute'; remainingEvent.style.top = container.style.top;
remainingEvent.style.top = container.style.top; remainingEvent.style.left = '2px';
remainingEvent.style.left = '2px'; remainingEvent.style.right = '2px';
remainingEvent.style.right = '2px';
// Indsæt før container og fjern container
// Indsæt før container og fjern container container.parentElement?.insertBefore(remainingEvent, container);
container.parentElement?.insertBefore(remainingEvent, container); container.remove();
container.remove(); return true; // Container blev fjernet
return true; // Container blev fjernet
}
} }
return false; // Container blev ikke 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 { public createStackedEvent(eventElement: HTMLElement, underlyingElement: HTMLElement, stackLevel: number = 1): void {
// Beregn reduceret bredde baseret på swp-events-layer (som har den korrekte fulde bredde) // Brug margin-left i stedet for width manipulation
// Underlying event skal beholde sin fulde bredde const marginLeft = stackLevel * EventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
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.marginLeft = `${marginLeft}px`;
eventElement.style.left = '2px';
eventElement.style.right = '2px'; eventElement.style.right = '2px';
eventElement.style.left = 'auto'; eventElement.style.width = '';
eventElement.style.zIndex = this.getNextZIndex().toString(); eventElement.style.zIndex = this.getNextZIndex().toString();
// Data attributter
eventElement.dataset.overlapType = OverlapType.STACKING;
eventElement.dataset.stackedWidth = stackedWidth.toString();
} }
/** /**
* Fjern stacking styling fra event * Fjern stacking styling fra event
*/ */
public removeStackedStyling(eventElement: HTMLElement): void { public removeStackedStyling(eventElement: HTMLElement): void {
eventElement.style.marginLeft = '';
eventElement.style.width = ''; eventElement.style.width = '';
eventElement.style.right = '';
eventElement.style.left = '2px'; eventElement.style.left = '2px';
eventElement.style.right = '2px';
eventElement.style.zIndex = ''; 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 * Check if element is part of an event group
*/ */
public isInEventGroup(element: HTMLElement): boolean { 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 * Check if element is a stacked event
*/ */
public isStackedEvent(element: HTMLElement): boolean { 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 * Get event group container for an event element
*/ */
public getEventGroup(eventElement: HTMLElement): HTMLElement | null { public getEventGroup(eventElement: HTMLElement): HTMLElement | null {
return eventElement.closest('.event-group') as HTMLElement; return eventElement.closest('swp-event-group') as HTMLElement;
} }
} }

View file

@ -147,10 +147,11 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
clone.style.pointerEvents = 'none'; clone.style.pointerEvents = 'none';
clone.style.opacity = '0.8'; clone.style.opacity = '0.8';
// Keep original dimensions (height stays the same) // Dragged event skal have fuld kolonne bredde
const rect = originalEvent.getBoundingClientRect(); clone.style.left = '2px';
clone.style.width = rect.width + 'px'; clone.style.right = '2px';
clone.style.height = rect.height + 'px'; clone.style.width = '';
clone.style.height = originalEvent.style.height || `${originalEvent.getBoundingClientRect().height}px`;
return clone; return clone;
} }
@ -230,6 +231,11 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
private handleDragStart(originalElement: HTMLElement, eventId: string, mouseOffset: any, column: string): void { private handleDragStart(originalElement: HTMLElement, eventId: string, mouseOffset: any, column: string): void {
this.originalEvent = originalElement; this.originalEvent = originalElement;
// Remove stacking styling from original event before creating clone
if (this.overlapManager.isStackedEvent(originalElement)) {
this.overlapManager.removeStackedStyling(originalElement);
}
// Create clone // Create clone
this.draggedClone = this.createEventClone(originalElement); this.draggedClone = this.createEventClone(originalElement);
@ -293,6 +299,9 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
return; return;
} }
// Remove original event from any existing groups first
this.removeEventFromExistingGroups(this.originalEvent);
// Fade out original // Fade out original
this.fadeOutAndRemove(this.originalEvent); this.fadeOutAndRemove(this.originalEvent);
@ -306,8 +315,10 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
this.draggedClone.style.pointerEvents = ''; this.draggedClone.style.pointerEvents = '';
this.draggedClone.style.opacity = ''; this.draggedClone.style.opacity = '';
this.draggedClone.style.userSelect = ''; 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 // Clean up
this.draggedClone = null; 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 * Handle conversion to all-day event
*/ */
@ -492,7 +693,7 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
}); });
// Debug: Verify events were actually added // Debug: Verify events were actually added
const renderedEvents = eventsLayer.querySelectorAll('swp-event, .event-group'); const renderedEvents = eventsLayer.querySelectorAll('swp-event, swp-event-group');
} else { } 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 { protected renderStackedEvents(group: any, container: Element): void {
// Sort events by duration - longer events render first (background), shorter events on top // 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); container.appendChild(eventElement);
underlyingElement = eventElement; underlyingElement = eventElement;
} else { } else {
// Shorter events are stacked with reduced width and higher z-index // Shorter events are stacked with margin-left offset and higher z-index
// All stacked events use the SAME underlying element (the longest one) // Each subsequent event gets more margin: 15px, 30px, 45px, etc.
if (underlyingElement) { if (underlyingElement) {
this.overlapManager.createStackedEvent(eventElement, underlyingElement); this.overlapManager.createStackedEvent(eventElement, underlyingElement, index);
} }
container.appendChild(eventElement); container.appendChild(eventElement);
// DO NOT update underlyingElement - keep it as the longest event // DO NOT update underlyingElement - keep it as the longest event
@ -814,7 +1015,7 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
} }
clearEvents(container?: HTMLElement): void { clearEvents(container?: HTMLElement): void {
const selector = 'swp-event, .event-group'; const selector = 'swp-event, swp-event-group';
const existingEvents = container const existingEvents = container
? container.querySelectorAll(selector) ? container.querySelectorAll(selector)
: document.querySelectorAll(selector); : document.querySelectorAll(selector);

View file

@ -208,7 +208,7 @@ swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"] {
/* Event overlap styling */ /* Event overlap styling */
/* Event group container for column sharing */ /* Event group container for column sharing */
.event-group { swp-event-group {
position: absolute; position: absolute;
display: flex; display: flex;
gap: 1px; gap: 1px;
@ -217,23 +217,10 @@ swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"] {
z-index: 10; z-index: 10;
} }
.event-group swp-event { swp-event-group swp-event {
flex: 1; flex: 1;
position: relative; position: relative;
left: 0; left: 0;
right: 0; right: 0;
margin: 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);
}