De 6 vigtigste fund (med fixes) Gruppering kan “brygge” mellem to grupper uden at merge dem groupEventsByStartTime finder første eksisterende gruppe med konflikt og lægger eventet deri. Hvis et nyt event konflikter med flere grupper, bliver grupperne ikke merged → inkonsistente “grid”-klumper. Løs: merge alle matchende grupper eller brug union-find/sweep-line konfliktsæt. EventStackManager ContainerType er “GRID” for alle grupper >1 — også ved dybe overlaps decideContainerType returnerer altid 'GRID' når events.length > 1. Det kan være tilsigtet, men så skal du være tryg ved, at lange overlappende events, der kun næsten starter samtidigt, stadig pakkes i kolonner fremfor “stacking”. Overvej: GRID kun når samtidighed er vigtigere end varighed, ellers fald tilbage til STACKING. EventStackManager Stack level-algoritmen kan eskalere niveauer unødigt createOptimizedStackLinks sætter stackLevel = max(overlappende tidligere) + 1. Det er mere “stak-tårn” end “før-ledig-kolonne” og giver højere niveauer end nødvendigt (ikke minimal farvelægning). Løs: interval partitioning med min-heap (giver laveste ledige level). EventStackManager Grid-top beregnes fra ét event, men børn positioneres relativt til containerStart I koordinatoren bruges earliestEvent til top, og renderer bruger earliestEvent.start som containerStart. Det er ok — men sørg for, at earliestEvent garanteret er det tidligste i gruppen og sortér eksplicit inden brug (robusthed mod fremtidige ændringer). EventLayoutCoordinator EventRenderer Drag bruger rå new Date(...) i stedet for DateService Kan give TZ/DST-glitches. Brug samme parse/logik som resten. EventRenderer Ingen reflow af kolonne efter drop handleDragEnd normaliserer DOM men recalculerer ikke layout → forkert stacking/margin efter flyt. Kald din kolonne-pipeline igen for den berørte kolonne. EventRenderer Bonus: getEventsForColumn matcher kun start-dato === kolonnedato; events der krydser midnat forsvinder. Overvej interval-overlap mod døgnets [00:00–23:59:59.999]. EventRenderer Målrettede patches (små og sikre) A) Merge grupper når et event rammer flere (EventStackManager) Erstat den nuværende “find første gruppe”-logik med merge af alle matchende: // inde i groupEventsByStartTime const matches: number[] = []; for (let gi = 0; gi < groups.length; gi++) { const group = groups[gi]; const conflict = group.events.some(ge => { const s2s = Math.abs(event.start.getTime() - ge.start.getTime()) / 60000; if (s2s <= thresholdMinutes) return true; const e2s = (ge.end.getTime() - event.start.getTime()) / 60000; if (e2s > 0 && e2s <= thresholdMinutes) return true; const rev = (event.end.getTime() - ge.start.getTime()) / 60000; if (rev > 0 && rev <= thresholdMinutes) return true; return false; }); if (conflict) matches.push(gi); } if (matches.length === 0) { groups.push({ events: [event], containerType: 'NONE', startTime: event.start }); } else { // merge alle matchende grupper + dette event const base = matches[0]; groups[base].events.push(event); for (let i = matches.length - 1; i >= 1; i--) { const idx = matches[i]; groups[base].events.push(...groups[idx].events); groups.splice(idx, 1); } // opdatér startTime til min start groups[base].startTime = new Date( Math.min(...groups[base].events.map(e => e.start.getTime())) ); } Nu undgår du “brobygning” der splitter reelt sammenhængende grupper. EventStackManager B) Minimal stack level med min-heap (EventStackManager) Udskift level-tildeling med klassisk interval partitioning: public createOptimizedStackLinks(events: CalendarEvent[]): Map { const res = new Map(); if (!events.length) return res; const sorted = [...events].sort((a,b)=> a.start.getTime() - b.start.getTime()); type Col = { level: number; end: number }; const cols: Col[] = []; // min-heap på end const push = (c: Col) => { cols.push(c); cols.sort((x,y)=> x.end - y.end); }; for (const ev of sorted) { const t = ev.start.getTime(); // find første kolonne der er fri let placed = false; for (let i = 0; i < cols.length; i++) { if (cols[i].end <= t) { cols[i].end = ev.end.getTime(); res.set(ev.id, { stackLevel: cols[i].level }); placed = true; break; } } if (!placed) { const level = cols.length; push({ level, end: ev.end.getTime() }); res.set(ev.id, { stackLevel: level }); } } // evt. byg prev/next separat hvis nødvendigt return res; } Dette giver laveste ledige niveau og undgår “trappetårne”. EventStackManager C) Konsolidér margin/zIndex + brug DateService i drag (EventRenderer) Lad StackManager styre marginLeft konsekvent (og undgå magic numbers): // renderGridGroup groupElement.style.top = `${gridGroup.position.top}px`; this.stackManager.applyVisualStyling(groupElement, gridGroup.stackLevel); // i stedet for *15 this.stackManager.applyStackLinkToElement(groupElement, { stackLevel: gridGroup.stackLevel }); EventRenderer Brug DateService i drag: public handleDragMove(payload: DragMoveEventPayload): void { if (!this.draggedClone || !payload.columnBounds) return; const swp = this.draggedClone as SwpEventElement; const colDate = this.dateService.parseISODate?.(payload.columnBounds.date) ?? new Date(payload.columnBounds.date); swp.updatePosition(colDate, payload.snappedY); } public handleColumnChange(e: DragColumnChangeEventPayload): void { if (!this.draggedClone) return; const layer = e.newColumn.element.querySelector('swp-events-layer'); if (layer && this.draggedClone.parentElement !== layer) { layer.appendChild(this.draggedClone); const currentTop = parseFloat(this.draggedClone.style.top) || 0; const swp = this.draggedClone as SwpEventElement; const colDate = this.dateService.parseISODate?.(e.newColumn.date) ?? new Date(e.newColumn.date); swp.updatePosition(colDate, currentTop); } } EventRenderer D) Reflow efter drop (EventRenderer) Genberegn layout for den berørte kolonne: public handleDragEnd(id: string, original: HTMLElement, clone: HTMLElement, finalColumn: ColumnBounds): void { if (!clone || !original) { console.warn('Missing clone/original'); return; } this.fadeOutAndRemove(original); const cid = clone.dataset.eventId; if (cid && cid.startsWith('clone-')) clone.dataset.eventId = cid.replace('clone-',''); clone.classList.remove('dragging'); const layer = finalColumn.element.querySelector('swp-events-layer') as HTMLElement | null; if (layer) { // 1) Hent kolonnens events fra din model/state (inkl. opdateret event) const columnEvents: CalendarEvent[] = /* ... */; // 2) Ryd layer.querySelectorAll('swp-event, swp-event-group').forEach(el => el.remove()); // 3) Render igen via layout this.renderColumnEvents(columnEvents, layer); } this.draggedClone = null; this.originalEvent = null; } EventRenderer E) Døgn-overlap i kolonnefilter (EventRenderer) Hvis ønsket (ellers behold din nuværende): protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] { const d = column.dataset.date; if (!d) return []; const start = this.dateService.parseISODate(`${d}T00:00:00`); const end = this.dateService.parseISODate(`${d}T23:59:59.999`); return events.filter(ev => ev.start < end && ev.end > start); } EventRenderer F) Eksplicit “earliest” i GRID (Coordinator) Gør det robust i tilfælde af usorteret input: const earliestEvent = [...gridCandidates].sort((a,b)=> a.start.getTime()-b.start.getTime())[0]; const pos = PositionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end); EventLayoutCoordinator Mini-noter allocateColumns er O(n²); det er fint for typiske dagvisninger. Hvis I ser >100 events/kolonne, kan I optimere med sweep-line + min-heap. EventLayoutCoordinator Overvej at lade koordinatoren returnere rene layout-maps (id → {level, z, margin}) og holde DOM-påføring 100% i renderer — det gør DnD-”reflow” enklere at teste. EventLayoutCoordinator EventRenderer