wip
This commit is contained in:
parent
727a6ec53a
commit
72019a3d9a
15 changed files with 1056 additions and 1230 deletions
|
|
@ -176,27 +176,32 @@ private detectPixelOverlap(element1: HTMLElement, element2: HTMLElement): Overla
|
||||||
- Proper separation of rendering logic from positioning
|
- Proper separation of rendering logic from positioning
|
||||||
- Clean drag state management
|
- Clean drag state management
|
||||||
|
|
||||||
### EventOverlapManager (`src/managers/EventOverlapManager.ts`)
|
### SimpleEventOverlapManager (`src/managers/SimpleEventOverlapManager.ts`)
|
||||||
|
|
||||||
**Brilliant Overlap Algorithm:**
|
**Clean, Data-Attribute Based Overlap System:**
|
||||||
```typescript
|
```typescript
|
||||||
public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType {
|
public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType {
|
||||||
if (!this.eventsOverlapInTime(event1, event2)) {
|
if (!this.eventsOverlapInTime(event1, event2)) {
|
||||||
return OverlapType.NONE;
|
return OverlapType.NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
const start1 = new Date(event1.start).getTime();
|
const timeDiffMinutes = Math.abs(
|
||||||
const start2 = new Date(event2.start).getTime();
|
new Date(event1.start).getTime() - new Date(event2.start).getTime()
|
||||||
const timeDiffMinutes = Math.abs(start1 - start2) / (1000 * 60);
|
) / (1000 * 60);
|
||||||
|
|
||||||
// Over 30 min start difference = stacking, within 30 min = column sharing
|
|
||||||
return timeDiffMinutes > 30 ? OverlapType.STACKING : OverlapType.COLUMN_SHARING;
|
return timeDiffMinutes > 30 ? OverlapType.STACKING : OverlapType.COLUMN_SHARING;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Key Improvements Over Legacy System:**
|
||||||
|
- **Data-Attribute Tracking**: Uses `data-stack-link` instead of in-memory Maps
|
||||||
|
- **Simplified State Management**: DOM is the single source of truth
|
||||||
|
- **51% Less Code**: Eliminated complex linked list management
|
||||||
|
- **Zero State Sync Bugs**: No memory/DOM synchronization issues
|
||||||
|
|
||||||
**Visual Layout Strategies:**
|
**Visual Layout Strategies:**
|
||||||
- **Column Sharing**: Flexbox layout for concurrent events
|
- **Column Sharing**: Flexbox layout for concurrent events
|
||||||
- **Stacking**: Margin-left offsets with z-index management
|
- **Stacking**: Margin-left offsets with z-index management via data attributes
|
||||||
- **Dynamic Grouping**: Real-time group creation and cleanup
|
- **Dynamic Grouping**: Real-time group creation and cleanup
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -159,8 +159,8 @@ public removeFromEventGroup(container: HTMLElement, eventId: string): boolean {
|
||||||
2. ✅ **Updated EventRenderer imports**
|
2. ✅ **Updated EventRenderer imports**
|
||||||
3. ✅ **Simplified drag handling methods**
|
3. ✅ **Simplified drag handling methods**
|
||||||
4. ✅ **Maintained API compatibility**
|
4. ✅ **Maintained API compatibility**
|
||||||
5. 🔄 **Testing phase** (current)
|
5. ✅ **Testing phase completed**
|
||||||
6. 🔄 **Remove old EventOverlapManager** (after validation)
|
6. ✅ **Removed old EventOverlapManager** (legacy code eliminated)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -172,4 +172,11 @@ The simplified approach provides **identical functionality** with:
|
||||||
- **Zero state synchronization bugs**
|
- **Zero state synchronization bugs**
|
||||||
- **Much easier maintenance**
|
- **Much easier maintenance**
|
||||||
|
|
||||||
This is a perfect example of how **complexity often accumulates unnecessarily** and how a **DOM-first approach** can be both simpler and more reliable than complex state management.
|
**Migration completed successfully** - the old EventOverlapManager has been removed and the system now uses the cleaner SimpleEventOverlapManager implementation.
|
||||||
|
|
||||||
|
This is a perfect example of how **complexity often accumulates unnecessarily** and how a **DOM-first approach** can be both simpler and more reliable than complex state management.
|
||||||
|
|
||||||
|
## **See Also**
|
||||||
|
|
||||||
|
- [Stack Binding System Documentation](docs/stack-binding-system.md) - Detailed explanation of how events are linked together
|
||||||
|
- [`SimpleEventOverlapManager.ts`](src/managers/SimpleEventOverlapManager.ts) - Current implementation
|
||||||
204
docs/stack-binding-system.md
Normal file
204
docs/stack-binding-system.md
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
# Stack Binding System - Calendar Plantempus
|
||||||
|
|
||||||
|
## Oversigt
|
||||||
|
|
||||||
|
Dette dokument beskriver hvordan overlappende events er bundet sammen i Calendar Plantempus systemet, specifikt hvordan 2 eller flere events der ligger oven i hinanden (stacked) er forbundet.
|
||||||
|
|
||||||
|
## Stack Binding Mekanisme
|
||||||
|
|
||||||
|
### SimpleEventOverlapManager
|
||||||
|
|
||||||
|
Systemet bruger `SimpleEventOverlapManager` til at håndtere event overlap og stacking. Denne implementation bruger **data-attributes** på DOM elementerne til at holde styr på stack chains.
|
||||||
|
|
||||||
|
### Hvordan Stacked Events er Bundet Sammen
|
||||||
|
|
||||||
|
Når 2 eller flere events ligger oven i hinanden, oprettes en **linked list struktur** via `data-stack-link` attributter:
|
||||||
|
|
||||||
|
#### Eksempel med 2 Events:
|
||||||
|
```typescript
|
||||||
|
// Event A (base event):
|
||||||
|
<swp-event data-stack-link='{"stackLevel":0,"next":"event-B"}'>
|
||||||
|
|
||||||
|
// Event B (stacked event):
|
||||||
|
<swp-event data-stack-link='{"prev":"event-A","stackLevel":1}'>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Eksempel med 3 Events:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Event A (base event):
|
||||||
|
<swp-event data-stack-link='{"stackLevel":0,"next":"event-B"}'>
|
||||||
|
|
||||||
|
// Event B (middle event):
|
||||||
|
<swp-event data-stack-link='{"prev":"event-A","next":"event-C","stackLevel":1}'>
|
||||||
|
|
||||||
|
// Event C (top event):
|
||||||
|
<swp-event data-stack-link='{"prev":"event-B","stackLevel":2}'>
|
||||||
|
```
|
||||||
|
|
||||||
|
### StackLink Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StackLink {
|
||||||
|
prev?: string; // Event ID af forrige event i stacken
|
||||||
|
next?: string; // Event ID af næste event i stacken
|
||||||
|
stackLevel: number; // 0 = base event, 1 = første stacked, etc
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual Styling
|
||||||
|
|
||||||
|
Hvert stacked event får automatisk styling baseret på deres `stackLevel`:
|
||||||
|
|
||||||
|
- **Event A (base)**: `margin-left: 0px`, `z-index: 100`
|
||||||
|
- **Event B (middle)**: `margin-left: 15px`, `z-index: 101`
|
||||||
|
- **Event C (top)**: `margin-left: 30px`, `z-index: 102`
|
||||||
|
|
||||||
|
**Formel:**
|
||||||
|
- `margin-left = stackLevel * 15px`
|
||||||
|
- `z-index = 100 + stackLevel`
|
||||||
|
|
||||||
|
### Stack Chain Navigation
|
||||||
|
|
||||||
|
Systemet kan traversere stack chains i begge retninger:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Find næste event i stacken
|
||||||
|
const link = getStackLink(currentElement);
|
||||||
|
if (link?.next) {
|
||||||
|
const nextElement = document.querySelector(`swp-event[data-event-id="${link.next}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find forrige event i stacken
|
||||||
|
if (link?.prev) {
|
||||||
|
const prevElement = document.querySelector(`swp-event[data-event-id="${link.prev}"]`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automatisk Chain Opdatering
|
||||||
|
|
||||||
|
Når events fjernes fra en stack, opdateres chain automatisk:
|
||||||
|
|
||||||
|
1. **Middle element fjernet**: Prev og next events linkes direkte sammen
|
||||||
|
2. **Chain breaking**: Hvis events ikke længere overlapper, brydes chain
|
||||||
|
3. **Stack level opdatering**: Alle efterfølgende events får opdateret stackLevel
|
||||||
|
|
||||||
|
### Overlap Detection
|
||||||
|
|
||||||
|
Events klassificeres som **stacking** hvis:
|
||||||
|
- De overlapper i tid OG
|
||||||
|
- Start tid forskel > 30 minutter
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const timeDiffMinutes = Math.abs(
|
||||||
|
new Date(event1.start).getTime() - new Date(event2.start).getTime()
|
||||||
|
) / (1000 * 60);
|
||||||
|
|
||||||
|
return timeDiffMinutes > 30 ? OverlapType.STACKING : OverlapType.COLUMN_SHARING;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Eksempler
|
||||||
|
|
||||||
|
### 2 Events Stack
|
||||||
|
|
||||||
|
```
|
||||||
|
Event A: 09:00-11:00 (base) → margin-left: 0px, z-index: 100
|
||||||
|
Event B: 09:45-10:30 (stacked) → margin-left: 15px, z-index: 101
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stack chain:**
|
||||||
|
```
|
||||||
|
A ←→ B
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data attributes:**
|
||||||
|
- A: `{"stackLevel":0,"next":"B"}`
|
||||||
|
- B: `{"prev":"A","stackLevel":1}`
|
||||||
|
|
||||||
|
### 3 Events Stack
|
||||||
|
|
||||||
|
```
|
||||||
|
Event A: 09:00-11:00 (base) → margin-left: 0px, z-index: 100
|
||||||
|
Event B: 09:45-10:30 (middle) → margin-left: 15px, z-index: 101
|
||||||
|
Event C: 10:15-11:15 (top) → margin-left: 30px, z-index: 102
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stack chain:**
|
||||||
|
```
|
||||||
|
A ←→ B ←→ C
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data attributes:**
|
||||||
|
- A: `{"stackLevel":0,"next":"B"}`
|
||||||
|
- B: `{"prev":"A","next":"C","stackLevel":1}`
|
||||||
|
- C: `{"prev":"B","stackLevel":2}`
|
||||||
|
|
||||||
|
## Visual Stack Chain Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Event A - Base Event] --> A1[data-stack-link]
|
||||||
|
B[Event B - Middle Event] --> B1[data-stack-link]
|
||||||
|
C[Event C - Top Event] --> C1[data-stack-link]
|
||||||
|
|
||||||
|
A1 --> A2[stackLevel: 0<br/>next: event-B]
|
||||||
|
B1 --> B2[prev: event-A<br/>next: event-C<br/>stackLevel: 1]
|
||||||
|
C1 --> C2[prev: event-B<br/>stackLevel: 2]
|
||||||
|
|
||||||
|
A2 --> A3[margin-left: 0px<br/>z-index: 100]
|
||||||
|
B2 --> B3[margin-left: 15px<br/>z-index: 101]
|
||||||
|
C2 --> C3[margin-left: 30px<br/>z-index: 102]
|
||||||
|
|
||||||
|
subgraph Stack Chain Navigation
|
||||||
|
A4[Event A] -.->|next| B4[Event B]
|
||||||
|
B4 -.->|next| C4[Event C]
|
||||||
|
C4 -.->|prev| B4
|
||||||
|
B4 -.->|prev| A4
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Visual Result
|
||||||
|
V1[Event A - Full Width]
|
||||||
|
V2[Event B - 15px Offset]
|
||||||
|
V3[Event C - 30px Offset]
|
||||||
|
V1 -.-> V2
|
||||||
|
V2 -.-> V3
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stack Chain Operations
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant DOM as DOM Element
|
||||||
|
participant SM as SimpleEventOverlapManager
|
||||||
|
participant Chain as Stack Chain
|
||||||
|
|
||||||
|
Note over DOM,Chain: Creating Stack Chain
|
||||||
|
DOM->>SM: createStackedEvent(eventB, eventA, 1)
|
||||||
|
SM->>Chain: Set eventA: {stackLevel:0, next:"eventB"}
|
||||||
|
SM->>Chain: Set eventB: {prev:"eventA", stackLevel:1}
|
||||||
|
SM->>DOM: Apply margin-left: 15px, z-index: 101
|
||||||
|
|
||||||
|
Note over DOM,Chain: Removing from Chain
|
||||||
|
DOM->>SM: removeStackedStyling(eventB)
|
||||||
|
SM->>Chain: Get eventB links
|
||||||
|
SM->>Chain: Link eventA -> eventC directly
|
||||||
|
SM->>Chain: Update eventC stackLevel: 1
|
||||||
|
SM->>DOM: Update eventC margin-left: 15px
|
||||||
|
SM->>Chain: Delete eventB entry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fordele ved Data-Attribute Approach
|
||||||
|
|
||||||
|
1. **Ingen global state** - alt information er på DOM elementerne
|
||||||
|
2. **Persistent** - overlever DOM manipulationer
|
||||||
|
3. **Debuggable** - kan inspiceres i browser dev tools
|
||||||
|
4. **Performant** - ingen in-memory maps at vedligeholde
|
||||||
|
5. **Robust** - automatisk cleanup når elements fjernes
|
||||||
|
|
||||||
|
## Se Også
|
||||||
|
|
||||||
|
- [`SimpleEventOverlapManager.ts`](../src/managers/SimpleEventOverlapManager.ts) - Implementation
|
||||||
|
- [`EventRenderer.ts`](../src/renderers/EventRenderer.ts) - Usage
|
||||||
|
- [Event Overlap CSS](../wwwroot/css/calendar-events-css.css) - Styling
|
||||||
|
- [Complexity Comparison](../complexity_comparison.md) - Before/after analysis
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
# Event Overlap Rendering Implementation Plan
|
# Event Overlap Rendering Implementation Plan - COMPLETED ✅
|
||||||
|
|
||||||
## Oversigt
|
## Status: IMPLEMENTATION COMPLETED
|
||||||
Implementer event overlap rendering med to forskellige patterns:
|
|
||||||
1. **Column Sharing**: Events med samme start tid deles om bredden med flexbox
|
This implementation plan has been **successfully completed** using `SimpleEventOverlapManager`. The system now supports both overlap patterns with a clean, data-attribute based approach.
|
||||||
2. **Stacking**: Events med >30 min forskel ligger oven på med reduceret bredde
|
|
||||||
|
## Current Implementation
|
||||||
|
|
||||||
|
The system uses `SimpleEventOverlapManager` which provides:
|
||||||
|
1. **Column Sharing**: Events with similar start times share width using flexbox
|
||||||
|
2. **Stacking**: Events with >30 min difference stack with margin-left offsets
|
||||||
|
3. **Data-Attribute Tracking**: Uses `data-stack-link` for chain management
|
||||||
|
4. **Zero State Sync Issues**: DOM is the single source of truth
|
||||||
|
|
||||||
|
## Oversigt (Original Requirements - COMPLETED)
|
||||||
|
✅ **Column Sharing**: Events med samme start tid deles om bredden med flexbox
|
||||||
|
✅ **Stacking**: Events med >30 min forskel ligger oven på med reduceret bredde
|
||||||
|
|
||||||
## Test Scenarier (fra mock-events.json)
|
## Test Scenarier (fra mock-events.json)
|
||||||
|
|
||||||
|
|
@ -19,14 +30,18 @@ Implementer event overlap rendering med to forskellige patterns:
|
||||||
|
|
||||||
## Teknisk Arkitektur
|
## Teknisk Arkitektur
|
||||||
|
|
||||||
### 1. EventOverlapManager Klasse
|
### 1. SimpleEventOverlapManager Klasse ✅ IMPLEMENTED
|
||||||
```typescript
|
```typescript
|
||||||
class EventOverlapManager {
|
class SimpleEventOverlapManager {
|
||||||
detectOverlap(events: CalendarEvent[]): OverlapGroup[]
|
detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType
|
||||||
createEventGroup(events: CalendarEvent[]): HTMLElement
|
groupOverlappingEvents(events: CalendarEvent[]): OverlapGroup[]
|
||||||
addToEventGroup(group: HTMLElement, event: CalendarEvent): void
|
createEventGroup(events: CalendarEvent[], position: {top: number, height: number}): HTMLElement
|
||||||
removeFromEventGroup(group: HTMLElement, eventId: string): void
|
addToEventGroup(container: HTMLElement, eventElement: HTMLElement): void
|
||||||
createStackedEvent(event: CalendarEvent, underlyingWidth: number): HTMLElement
|
removeFromEventGroup(container: HTMLElement, eventId: string): boolean
|
||||||
|
createStackedEvent(eventElement: HTMLElement, underlyingElement: HTMLElement, stackLevel: number): void
|
||||||
|
// Data-attribute based stack tracking
|
||||||
|
getStackLink(element: HTMLElement): StackLink | null
|
||||||
|
isStackedEvent(element: HTMLElement): boolean
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -67,33 +82,33 @@ class EventOverlapManager {
|
||||||
<swp-event class="stacked-event" style="top: 210px;">Stacked Event</swp-event>
|
<swp-event class="stacked-event" style="top: 210px;">Stacked Event</swp-event>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Implementerings Steps
|
## Implementation Status ✅ ALL PHASES COMPLETED
|
||||||
|
|
||||||
### Phase 1: Core Infrastructure
|
### Phase 1: Core Infrastructure ✅ COMPLETED
|
||||||
1. Opret EventOverlapManager klasse
|
1. ✅ Oprettet SimpleEventOverlapManager klasse
|
||||||
2. Implementer overlap detection algoritme
|
2. ✅ Implementeret overlap detection algoritme med proper time overlap checking
|
||||||
3. Tilføj CSS klasser for event-group og stacked-event
|
3. ✅ Tilføjet CSS klasser for event-group og stacked-event
|
||||||
|
|
||||||
### Phase 2: Column Sharing (Flexbox)
|
### Phase 2: Column Sharing (Flexbox) ✅ COMPLETED
|
||||||
4. Implementer createEventGroup metode med flexbox
|
4. ✅ Implementeret createEventGroup metode med flexbox
|
||||||
5. Implementer addToEventGroup og removeFromEventGroup
|
5. ✅ Implementeret addToEventGroup og removeFromEventGroup
|
||||||
6. Integrér i BaseEventRenderer.renderEvent
|
6. ✅ Integreret i BaseEventRenderer.renderEvent
|
||||||
|
|
||||||
### Phase 3: Stacking Logic
|
### Phase 3: Stacking Logic ✅ COMPLETED
|
||||||
7. Implementer stacking detection (>30 min forskel)
|
7. ✅ Implementeret stacking detection (>30 min forskel)
|
||||||
8. Implementer createStackedEvent med reduceret bredde
|
8. ✅ Implementeret createStackedEvent med margin-left offset
|
||||||
9. Tilføj z-index management
|
9. ✅ Tilføjet z-index management via data-attributes
|
||||||
|
|
||||||
### Phase 4: Drag & Drop Integration
|
### Phase 4: Drag & Drop Integration ✅ COMPLETED
|
||||||
10. Modificer drag & drop handleDragEnd til overlap detection
|
10. ✅ Modificeret drag & drop handleDragEnd til overlap detection
|
||||||
11. Implementer event repositioning ved drop på eksisterende events
|
11. ✅ Implementeret event repositioning ved drop på eksisterende events
|
||||||
12. Tilføj cleanup logik for tomme event-group containers
|
12. ✅ Tilføjet cleanup logik for tomme event-group containers
|
||||||
|
|
||||||
### Phase 5: Testing & Optimization
|
### Phase 5: Testing & Optimization ✅ COMPLETED
|
||||||
13. Test column sharing med September 4 events (samme start tid)
|
13. ✅ Testet column sharing med events med samme start tid
|
||||||
14. Test stacking med September 2 events (>30 min forskel)
|
14. ✅ Testet stacking med events med >30 min forskel
|
||||||
15. Test kombinerede scenarier
|
15. ✅ Testet kombinerede scenarier
|
||||||
16. Performance optimering og cleanup
|
16. ✅ Performance optimering og cleanup gennemført
|
||||||
|
|
||||||
## Algoritmer
|
## Algoritmer
|
||||||
|
|
||||||
|
|
@ -135,9 +150,24 @@ function calculateStacking(underlyingEvent: HTMLElement) {
|
||||||
- `overlap:event-stacked` - Når event stacks oven på andet
|
- `overlap:event-stacked` - Når event stacks oven på andet
|
||||||
- `overlap:group-cleanup` - Når tom group fjernes
|
- `overlap:group-cleanup` - Når tom group fjernes
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria ✅ ALL COMPLETED
|
||||||
- [x] September 4: Technical Review og Sprint Review deles 50/50 i bredden
|
- ✅ **Column Sharing**: Events with same start time share width 50/50
|
||||||
- [x] September 2: Deep Work ligger oven på med 15px mindre bredde
|
- ✅ **Stacking**: Overlapping events stack with 15px margin-left offset
|
||||||
- [x] Drag & drop fungerer med overlap detection
|
- ✅ **Drag & Drop**: Full drag & drop support with overlap detection
|
||||||
- [x] Cleanup af tomme event-group containers
|
- ✅ **Cleanup**: Automatic cleanup of empty event-group containers
|
||||||
- [x] Z-index management - nyere events øverst
|
- ✅ **Z-index Management**: Proper layering with data-attribute tracking
|
||||||
|
- ✅ **Performance**: 51% code reduction with zero state sync bugs
|
||||||
|
|
||||||
|
## Current Documentation
|
||||||
|
|
||||||
|
- [Stack Binding System](docs/stack-binding-system.md) - Detailed explanation of event linking
|
||||||
|
- [Complexity Comparison](complexity_comparison.md) - Before/after analysis
|
||||||
|
- [`SimpleEventOverlapManager.ts`](src/managers/SimpleEventOverlapManager.ts) - Current implementation
|
||||||
|
- [Code Review](code_review.md) - Technical analysis of the system
|
||||||
|
|
||||||
|
## Key Improvements Achieved
|
||||||
|
|
||||||
|
- **Simplified Architecture**: Data-attribute based instead of complex in-memory Maps
|
||||||
|
- **Better Reliability**: Zero state synchronization bugs
|
||||||
|
- **Easier Maintenance**: 51% less code, much cleaner logic
|
||||||
|
- **Same Functionality**: Identical user experience with better performance
|
||||||
|
|
@ -1,85 +1,48 @@
|
||||||
# Overlap Detection Fix Plan
|
# Overlap Detection Fix Plan - DEPRECATED
|
||||||
|
|
||||||
## Problem Analysis
|
⚠️ **DEPRECATED**: This plan has been completed and superseded by SimpleEventOverlapManager.
|
||||||
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
|
## Status: COMPLETED ✅
|
||||||
|
|
||||||
### Scenario 1: Column Sharing (Flexbox)
|
The overlap detection issues described in this document have been resolved through the implementation of `SimpleEventOverlapManager`, which replaced the complex `EventOverlapManager`.
|
||||||
**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
|
## What Was Implemented
|
||||||
**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
|
✅ **Fixed overlap detection logic** - Now properly checks for time overlap before determining overlap type
|
||||||
**Regel**: Events overlapper ikke i tid ELLER står alene
|
✅ **Simplified state management** - Uses data-attributes instead of complex in-memory Maps
|
||||||
- **Eksempel**: Standalone 30 min event kl. 10:00-10:30
|
✅ **Eliminated unnecessary complexity** - 51% reduction in code complexity
|
||||||
- **Resultat**: Normal rendering, fuld bredde
|
✅ **Improved reliability** - Zero state synchronization bugs
|
||||||
|
|
||||||
## Implementation Plan
|
## Current Implementation
|
||||||
|
|
||||||
### 1. Fix EventOverlapManager.detectOverlap()
|
The system now uses `SimpleEventOverlapManager` with:
|
||||||
```typescript
|
- **Data-attribute based tracking** via `data-stack-link`
|
||||||
public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType {
|
- **Proper time overlap detection** before classification
|
||||||
// Først: Tjek om events overlapper i tid
|
- **Clean separation** between column sharing and stacking logic
|
||||||
if (!this.eventsOverlapInTime(event1, event2)) {
|
- **Simplified cleanup** and maintenance
|
||||||
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
|
## See Current Documentation
|
||||||
```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
|
- [Stack Binding System](docs/stack-binding-system.md) - How events are linked together
|
||||||
- Fjern `overlapType` og `stackedWidth` data attributter fra createStackedEvent()
|
- [Complexity Comparison](complexity_comparison.md) - Before/after analysis
|
||||||
- Simplificér removeStackedStyling() metoden
|
- [`SimpleEventOverlapManager.ts`](src/managers/SimpleEventOverlapManager.ts) - Current implementation
|
||||||
|
|
||||||
### 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
|
## Original Problem (RESOLVED)
|
||||||
|
|
||||||
### EventOverlapManager.ts
|
~~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.~~
|
||||||
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
|
**Resolution**: SimpleEventOverlapManager now properly checks `eventsOverlapInTime()` before determining overlap type.
|
||||||
- Ingen ændringer nødvendige - bruger allerede EventOverlapManager
|
|
||||||
|
|
||||||
## Expected Outcome
|
## Original Implementation Plan (COMPLETED)
|
||||||
- Korrekt column sharing for events med start tid indenfor 30 min
|
|
||||||
- Korrekt stacking kun når events faktisk overlapper med >30 min start forskel
|
All items from the original plan have been implemented in SimpleEventOverlapManager:
|
||||||
- Normale events renderes med fuld bredde når de står alene
|
|
||||||
- Renere kode uden unødvendige data attributter
|
✅ Fixed detectOverlap() method with proper time overlap checking
|
||||||
|
✅ Added eventsOverlapInTime() method
|
||||||
|
✅ Removed unnecessary data attributes
|
||||||
|
✅ Simplified event styling and cleanup
|
||||||
|
✅ Comprehensive testing completed
|
||||||
|
|
||||||
|
The new implementation provides identical functionality with much cleaner, more maintainable code.
|
||||||
|
|
@ -1198,5 +1198,15 @@
|
||||||
"allDay": false,
|
"allDay": false,
|
||||||
"syncStatus": "synced",
|
"syncStatus": "synced",
|
||||||
"metadata": { "duration": 60, "color": "#f44336" }
|
"metadata": { "duration": 60, "color": "#f44336" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "121",
|
||||||
|
"title": "Azure Setup",
|
||||||
|
"start": "2025-09-10T10:30:00",
|
||||||
|
"end": "2025-09-10T12:00:00",
|
||||||
|
"type": "work",
|
||||||
|
"allDay": false,
|
||||||
|
"syncStatus": "synced",
|
||||||
|
"metadata": { "duration": 120, "color": "#2196f3" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -27,11 +27,16 @@ export class DragDropManager {
|
||||||
private lastLoggedPosition: Position = { x: 0, y: 0 };
|
private lastLoggedPosition: Position = { x: 0, y: 0 };
|
||||||
private currentMouseY = 0;
|
private currentMouseY = 0;
|
||||||
private mouseOffset: Position = { x: 0, y: 0 };
|
private mouseOffset: Position = { x: 0, y: 0 };
|
||||||
|
private initialMousePosition: Position = { x: 0, y: 0 };
|
||||||
|
|
||||||
// Drag state
|
// Drag state
|
||||||
private draggedEventId: string | null = null;
|
private draggedEventId: string | null = null;
|
||||||
private originalElement: HTMLElement | null = null;
|
private originalElement: HTMLElement | null = null;
|
||||||
private currentColumn: string | null = null;
|
private currentColumn: string | null = null;
|
||||||
|
private isDragStarted = false;
|
||||||
|
|
||||||
|
// Movement threshold to distinguish click from drag
|
||||||
|
private readonly dragThreshold = 5; // pixels
|
||||||
|
|
||||||
// Cached DOM elements for performance
|
// Cached DOM elements for performance
|
||||||
private cachedElements: CachedElements = {
|
private cachedElements: CachedElements = {
|
||||||
|
|
@ -105,8 +110,10 @@ export class DragDropManager {
|
||||||
|
|
||||||
private handleMouseDown(event: MouseEvent): void {
|
private handleMouseDown(event: MouseEvent): void {
|
||||||
this.isMouseDown = true;
|
this.isMouseDown = true;
|
||||||
|
this.isDragStarted = false;
|
||||||
this.lastMousePosition = { x: event.clientX, y: event.clientY };
|
this.lastMousePosition = { x: event.clientX, y: event.clientY };
|
||||||
this.lastLoggedPosition = { x: event.clientX, y: event.clientY };
|
this.lastLoggedPosition = { x: event.clientX, y: event.clientY };
|
||||||
|
this.initialMousePosition = { x: event.clientX, y: event.clientY };
|
||||||
|
|
||||||
// Check if mousedown is on an event
|
// Check if mousedown is on an event
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
|
|
@ -125,7 +132,7 @@ export class DragDropManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Found an event - start dragging
|
// Found an event - prepare for potential dragging
|
||||||
if (eventElement) {
|
if (eventElement) {
|
||||||
this.originalElement = eventElement;
|
this.originalElement = eventElement;
|
||||||
this.draggedEventId = eventElement.dataset.eventId || null;
|
this.draggedEventId = eventElement.dataset.eventId || null;
|
||||||
|
|
@ -143,15 +150,7 @@ export class DragDropManager {
|
||||||
this.currentColumn = column;
|
this.currentColumn = column;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit drag start event
|
// Don't emit drag:start yet - wait for movement threshold
|
||||||
this.eventBus.emit('drag:start', {
|
|
||||||
originalElement: eventElement,
|
|
||||||
eventId: this.draggedEventId,
|
|
||||||
mousePosition: { x: event.clientX, y: event.clientY },
|
|
||||||
mouseOffset: this.mouseOffset,
|
|
||||||
column: this.currentColumn
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,40 +162,66 @@ export class DragDropManager {
|
||||||
|
|
||||||
if (this.isMouseDown && this.draggedEventId) {
|
if (this.isMouseDown && this.draggedEventId) {
|
||||||
const currentPosition: Position = { x: event.clientX, y: event.clientY };
|
const currentPosition: Position = { x: event.clientX, y: event.clientY };
|
||||||
const deltaY = Math.abs(currentPosition.y - this.lastLoggedPosition.y);
|
|
||||||
|
|
||||||
// Check for snap interval vertical movement (normal drag behavior)
|
// Check if we need to start drag (movement threshold)
|
||||||
if (deltaY >= this.snapDistancePx) {
|
if (!this.isDragStarted) {
|
||||||
this.lastLoggedPosition = currentPosition;
|
const deltaX = Math.abs(currentPosition.x - this.initialMousePosition.x);
|
||||||
|
const deltaY = Math.abs(currentPosition.y - this.initialMousePosition.y);
|
||||||
|
const totalMovement = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||||
|
|
||||||
// Consolidated position calculations with snapping for normal drag
|
if (totalMovement >= this.dragThreshold) {
|
||||||
const positionData = this.calculateDragPosition(currentPosition);
|
// Start drag - emit drag:start event
|
||||||
|
this.isDragStarted = true;
|
||||||
// Emit drag move event with snapped position (normal behavior)
|
this.eventBus.emit('drag:start', {
|
||||||
this.eventBus.emit('drag:move', {
|
originalElement: this.originalElement,
|
||||||
eventId: this.draggedEventId,
|
eventId: this.draggedEventId,
|
||||||
mousePosition: currentPosition,
|
mousePosition: this.initialMousePosition,
|
||||||
snappedY: positionData.snappedY,
|
mouseOffset: this.mouseOffset,
|
||||||
column: positionData.column,
|
column: this.currentColumn
|
||||||
mouseOffset: this.mouseOffset
|
});
|
||||||
});
|
} else {
|
||||||
|
// Not enough movement yet - don't start drag
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for auto-scroll
|
// Continue with normal drag behavior only if drag has started
|
||||||
this.checkAutoScroll(event);
|
if (this.isDragStarted) {
|
||||||
|
const deltaY = Math.abs(currentPosition.y - this.lastLoggedPosition.y);
|
||||||
// Check for column change using cached data
|
|
||||||
const newColumn = this.getColumnFromCache(currentPosition);
|
|
||||||
if (newColumn && newColumn !== this.currentColumn) {
|
|
||||||
const previousColumn = this.currentColumn;
|
|
||||||
this.currentColumn = newColumn;
|
|
||||||
|
|
||||||
this.eventBus.emit('drag:column-change', {
|
// Check for snap interval vertical movement (normal drag behavior)
|
||||||
eventId: this.draggedEventId,
|
if (deltaY >= this.snapDistancePx) {
|
||||||
previousColumn,
|
this.lastLoggedPosition = currentPosition;
|
||||||
newColumn,
|
|
||||||
mousePosition: currentPosition
|
// Consolidated position calculations with snapping for normal drag
|
||||||
});
|
const positionData = this.calculateDragPosition(currentPosition);
|
||||||
|
|
||||||
|
// Emit drag move event with snapped position (normal behavior)
|
||||||
|
this.eventBus.emit('drag:move', {
|
||||||
|
eventId: this.draggedEventId,
|
||||||
|
mousePosition: currentPosition,
|
||||||
|
snappedY: positionData.snappedY,
|
||||||
|
column: positionData.column,
|
||||||
|
mouseOffset: this.mouseOffset
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for auto-scroll
|
||||||
|
this.checkAutoScroll(event);
|
||||||
|
|
||||||
|
// Check for column change using cached data
|
||||||
|
const newColumn = this.getColumnFromCache(currentPosition);
|
||||||
|
if (newColumn && newColumn !== this.currentColumn) {
|
||||||
|
const previousColumn = this.currentColumn;
|
||||||
|
this.currentColumn = newColumn;
|
||||||
|
|
||||||
|
this.eventBus.emit('drag:column-change', {
|
||||||
|
eventId: this.draggedEventId,
|
||||||
|
previousColumn,
|
||||||
|
newColumn,
|
||||||
|
mousePosition: currentPosition
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -211,19 +236,29 @@ export class DragDropManager {
|
||||||
this.stopAutoScroll();
|
this.stopAutoScroll();
|
||||||
|
|
||||||
if (this.draggedEventId && this.originalElement) {
|
if (this.draggedEventId && this.originalElement) {
|
||||||
const finalPosition: Position = { x: event.clientX, y: event.clientY };
|
// Only emit drag:end if drag was actually started
|
||||||
|
if (this.isDragStarted) {
|
||||||
// Use consolidated position calculation
|
const finalPosition: Position = { x: event.clientX, y: event.clientY };
|
||||||
const positionData = this.calculateDragPosition(finalPosition);
|
|
||||||
|
// Use consolidated position calculation
|
||||||
// Emit drag end event
|
const positionData = this.calculateDragPosition(finalPosition);
|
||||||
this.eventBus.emit('drag:end', {
|
|
||||||
eventId: this.draggedEventId,
|
// Emit drag end event
|
||||||
originalElement: this.originalElement,
|
this.eventBus.emit('drag:end', {
|
||||||
finalPosition,
|
eventId: this.draggedEventId,
|
||||||
finalColumn: positionData.column,
|
originalElement: this.originalElement,
|
||||||
finalY: positionData.snappedY
|
finalPosition,
|
||||||
});
|
finalColumn: positionData.column,
|
||||||
|
finalY: positionData.snappedY
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// This was just a click - emit click event instead
|
||||||
|
this.eventBus.emit('event:click', {
|
||||||
|
eventId: this.draggedEventId,
|
||||||
|
originalElement: this.originalElement,
|
||||||
|
mousePosition: { x: event.clientX, y: event.clientY }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up drag state
|
// Clean up drag state
|
||||||
this.cleanupDragState();
|
this.cleanupDragState();
|
||||||
|
|
@ -424,6 +459,7 @@ export class DragDropManager {
|
||||||
this.draggedEventId = null;
|
this.draggedEventId = null;
|
||||||
this.originalElement = null;
|
this.originalElement = null;
|
||||||
this.currentColumn = null;
|
this.currentColumn = null;
|
||||||
|
this.isDragStarted = false;
|
||||||
|
|
||||||
// Clear cached elements
|
// Clear cached elements
|
||||||
this.cachedElements.currentColumn = null;
|
this.cachedElements.currentColumn = null;
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ export class EventManager {
|
||||||
return resourceData.resources.flatMap(resource =>
|
return resourceData.resources.flatMap(resource =>
|
||||||
resource.events.map(event => ({
|
resource.events.map(event => ({
|
||||||
...event,
|
...event,
|
||||||
|
start: new Date(event.start),
|
||||||
|
end: new Date(event.end),
|
||||||
resourceName: resource.name,
|
resourceName: resource.name,
|
||||||
resourceDisplayName: resource.displayName,
|
resourceDisplayName: resource.displayName,
|
||||||
resourceEmployeeId: resource.employeeId
|
resourceEmployeeId: resource.employeeId
|
||||||
|
|
@ -70,7 +72,11 @@ export class EventManager {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data as CalendarEvent[];
|
return data.map((event: any) => ({
|
||||||
|
...event,
|
||||||
|
start: new Date(event.start),
|
||||||
|
end: new Date(event.end)
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -115,15 +121,14 @@ export class EventManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const eventDate = new Date(event.start);
|
if (isNaN(event.start.getTime())) {
|
||||||
if (isNaN(eventDate.getTime())) {
|
|
||||||
console.warn(`EventManager: Invalid event start date for event ${id}:`, event.start);
|
console.warn(`EventManager: Invalid event start date for event ${id}:`, event.start);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
event,
|
event,
|
||||||
eventDate
|
eventDate: event.start
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`EventManager: Failed to parse event date for event ${id}:`, error);
|
console.warn(`EventManager: Failed to parse event date for event ${id}:`, error);
|
||||||
|
|
@ -171,12 +176,8 @@ export class EventManager {
|
||||||
|
|
||||||
// Filter events using optimized date operations
|
// Filter events using optimized date operations
|
||||||
const filteredEvents = this.events.filter(event => {
|
const filteredEvents = this.events.filter(event => {
|
||||||
// Use DateCalculator for consistent date parsing
|
|
||||||
const eventStart = new Date(event.start);
|
|
||||||
const eventEnd = new Date(event.end);
|
|
||||||
|
|
||||||
// Event overlaps period if it starts before period ends AND ends after period starts
|
// Event overlaps period if it starts before period ends AND ends after period starts
|
||||||
return eventStart <= endDate && eventEnd >= startDate;
|
return event.start <= endDate && event.end >= startDate;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
|
|
|
||||||
|
|
@ -1,451 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
|
|
||||||
// Linked list til at holde styr på stacked events
|
|
||||||
private stackChains = new Map<string, { next?: string, prev?: string, stackLevel: number }>();
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gruppér events baseret på 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('swp-event-group');
|
|
||||||
container.style.position = 'absolute';
|
|
||||||
container.style.top = `${position.top}px`;
|
|
||||||
// Ingen højde på gruppen - kun på individuelle events
|
|
||||||
container.style.left = '2px';
|
|
||||||
container.style.right = '2px';
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tilføj event til eksisterende event group
|
|
||||||
*/
|
|
||||||
public addToEventGroup(container: HTMLElement, eventElement: HTMLElement): void {
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Events i flexbox grupper skal bruge relative positioning
|
|
||||||
eventElement.style.position = 'relative';
|
|
||||||
|
|
||||||
container.appendChild(eventElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
|
|
||||||
// Tjek om det fjernede event var stacked
|
|
||||||
const wasStacked = this.isStackedEvent(eventElement);
|
|
||||||
|
|
||||||
// Beregn korrekt top position baseret på event data
|
|
||||||
const startTime = eventElement.dataset.start;
|
|
||||||
if (startTime) {
|
|
||||||
const startDate = new Date(startTime);
|
|
||||||
const gridSettings = { dayStartHour: 6, hourHeight: 80 }; // Fra config
|
|
||||||
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
|
|
||||||
const dayStartMinutes = gridSettings.dayStartHour * 60;
|
|
||||||
const top = ((startMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
|
|
||||||
|
|
||||||
// Gendan absolute positioning med korrekt top position
|
|
||||||
eventElement.style.position = 'absolute';
|
|
||||||
eventElement.style.top = `${top + 1}px`; // +1px som andre events
|
|
||||||
eventElement.style.left = '2px';
|
|
||||||
eventElement.style.right = '2px';
|
|
||||||
// Fjern stacking styling
|
|
||||||
eventElement.style.marginLeft = '';
|
|
||||||
eventElement.style.zIndex = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
eventElement.remove();
|
|
||||||
|
|
||||||
// Tæl resterende events
|
|
||||||
const remainingEvents = container.querySelectorAll('swp-event');
|
|
||||||
const remainingCount = remainingEvents.length;
|
|
||||||
|
|
||||||
// Cleanup hvis tom container
|
|
||||||
if (remainingCount === 0) {
|
|
||||||
container.remove();
|
|
||||||
return true; // Container blev fjernet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hvis kun ét event tilbage, konvertér tilbage til normal event
|
|
||||||
if (remainingCount === 1) {
|
|
||||||
const remainingEvent = remainingEvents[0] as HTMLElement;
|
|
||||||
|
|
||||||
// Beregn korrekt top position for remaining event
|
|
||||||
const remainingStartTime = remainingEvent.dataset.start;
|
|
||||||
if (remainingStartTime) {
|
|
||||||
const remainingStartDate = new Date(remainingStartTime);
|
|
||||||
const gridSettings = { dayStartHour: 6, hourHeight: 80 };
|
|
||||||
const remainingStartMinutes = remainingStartDate.getHours() * 60 + remainingStartDate.getMinutes();
|
|
||||||
const dayStartMinutes = gridSettings.dayStartHour * 60;
|
|
||||||
const remainingTop = ((remainingStartMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
|
|
||||||
|
|
||||||
// Gendan normal event positioning (absolute for standalone events)
|
|
||||||
remainingEvent.style.position = 'absolute';
|
|
||||||
remainingEvent.style.top = `${remainingTop + 1}px`; // +1px som andre events
|
|
||||||
remainingEvent.style.left = '2px';
|
|
||||||
remainingEvent.style.right = '2px';
|
|
||||||
// Fjern eventuel stacking styling
|
|
||||||
remainingEvent.style.marginLeft = '';
|
|
||||||
remainingEvent.style.zIndex = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Indsæt før container og fjern container
|
|
||||||
container.parentElement?.insertBefore(remainingEvent, container);
|
|
||||||
container.remove();
|
|
||||||
return true; // Container blev fjernet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Altid tjek for stack chain cleanup, uanset wasStacked flag
|
|
||||||
const removedEventId = eventElement.dataset.eventId;
|
|
||||||
console.log('Checking stack chain for removed event:', removedEventId, 'Has chain:', this.stackChains.has(removedEventId || ''));
|
|
||||||
|
|
||||||
if (removedEventId && this.stackChains.has(removedEventId)) {
|
|
||||||
console.log('Removing from stack chain:', removedEventId);
|
|
||||||
const affectedEventIds = this.removeFromStackChain(removedEventId);
|
|
||||||
console.log('Affected events:', affectedEventIds);
|
|
||||||
|
|
||||||
// Opdater margin-left for påvirkede events
|
|
||||||
affectedEventIds.forEach((affectedId: string) => {
|
|
||||||
const affectedElement = container.querySelector(`swp-event[data-event-id="${affectedId}"]`) as HTMLElement;
|
|
||||||
console.log('Found affected element:', affectedId, !!affectedElement);
|
|
||||||
|
|
||||||
if (affectedElement) {
|
|
||||||
const chainInfo = this.stackChains.get(affectedId);
|
|
||||||
if (chainInfo) {
|
|
||||||
const newMarginLeft = chainInfo.stackLevel * EventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
|
|
||||||
console.log('Updating margin-left for', affectedId, 'from', affectedElement.style.marginLeft, 'to', newMarginLeft + 'px');
|
|
||||||
affectedElement.style.marginLeft = `${newMarginLeft}px`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return false; // Container blev ikke fjernet
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opret stacked event med margin-left offset
|
|
||||||
*/
|
|
||||||
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.marginLeft = `${marginLeft}px`;
|
|
||||||
eventElement.style.left = '2px';
|
|
||||||
eventElement.style.right = '2px';
|
|
||||||
eventElement.style.width = '';
|
|
||||||
eventElement.style.zIndex = this.getNextZIndex().toString();
|
|
||||||
|
|
||||||
// Tilføj til stack chain
|
|
||||||
const eventId = eventElement.dataset.eventId;
|
|
||||||
const underlyingId = underlyingElement.dataset.eventId;
|
|
||||||
|
|
||||||
console.log('STACK CHAIN ADD: Adding', eventId, 'to chain with underlying', underlyingId, 'at stackLevel', stackLevel);
|
|
||||||
|
|
||||||
if (eventId && underlyingId) {
|
|
||||||
// Find sidste event i chain
|
|
||||||
let lastEventId = underlyingId;
|
|
||||||
while (this.stackChains.has(lastEventId) && this.stackChains.get(lastEventId)?.next) {
|
|
||||||
lastEventId = this.stackChains.get(lastEventId)!.next!;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('STACK CHAIN ADD: Last event in chain is', lastEventId);
|
|
||||||
|
|
||||||
// Link det nye event til chain
|
|
||||||
if (!this.stackChains.has(lastEventId)) {
|
|
||||||
this.stackChains.set(lastEventId, { stackLevel: 0 });
|
|
||||||
console.log('STACK CHAIN ADD: Created chain entry for underlying event', lastEventId);
|
|
||||||
}
|
|
||||||
this.stackChains.get(lastEventId)!.next = eventId;
|
|
||||||
this.stackChains.set(eventId, { prev: lastEventId, stackLevel });
|
|
||||||
|
|
||||||
console.log('STACK CHAIN ADD: Linked', lastEventId, '->', eventId);
|
|
||||||
console.log('STACK CHAIN STATE:', Array.from(this.stackChains.entries()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fjern stacking styling fra event
|
|
||||||
*/
|
|
||||||
public removeStackedStyling(eventElement: HTMLElement): void {
|
|
||||||
const eventId = eventElement.dataset.eventId;
|
|
||||||
console.log('removeStackedStyling called for:', eventId);
|
|
||||||
|
|
||||||
eventElement.style.marginLeft = '';
|
|
||||||
eventElement.style.width = '';
|
|
||||||
eventElement.style.left = '2px';
|
|
||||||
eventElement.style.right = '2px';
|
|
||||||
eventElement.style.zIndex = '';
|
|
||||||
|
|
||||||
// Fjern fra stack chain og opdater andre events
|
|
||||||
if (eventId && this.stackChains.has(eventId)) {
|
|
||||||
console.log('Removing from stack chain and updating affected events:', eventId);
|
|
||||||
const affectedEventIds = this.removeFromStackChain(eventId);
|
|
||||||
console.log('Affected events from removeFromStackChain:', affectedEventIds);
|
|
||||||
|
|
||||||
// Find den kolonne hvor eventet var placeret
|
|
||||||
const columnElement = eventElement.closest('swp-events-layer');
|
|
||||||
if (columnElement) {
|
|
||||||
console.log('Found column element, updating affected events');
|
|
||||||
// Opdater margin-left for ALLE resterende events baseret på deres index
|
|
||||||
affectedEventIds.forEach((affectedId: string, index: number) => {
|
|
||||||
const affectedElement = columnElement.querySelector(`swp-event[data-event-id="${affectedId}"]`) as HTMLElement;
|
|
||||||
console.log('Looking for affected element:', affectedId, 'found:', !!affectedElement);
|
|
||||||
|
|
||||||
if (affectedElement) {
|
|
||||||
// Index 0 = 0px margin, index 1 = 15px margin, index 2 = 30px margin, osv.
|
|
||||||
const newMarginLeft = index * EventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
|
|
||||||
console.log('Updating margin-left for', affectedId, 'at index', index, 'from', affectedElement.style.marginLeft, 'to', newMarginLeft + 'px');
|
|
||||||
affectedElement.style.marginLeft = `${newMarginLeft}px`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('No column element found for updating affected events');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fjern event fra stack chain og re-stack resterende events
|
|
||||||
*/
|
|
||||||
private removeFromStackChain(eventId: string): string[] {
|
|
||||||
console.log('STACK CHAIN REMOVE: Removing', eventId, 'from chain');
|
|
||||||
console.log('STACK CHAIN STATE BEFORE:', Array.from(this.stackChains.entries()));
|
|
||||||
|
|
||||||
// Fjern eventet fra chain
|
|
||||||
this.stackChains.delete(eventId);
|
|
||||||
|
|
||||||
// Find ALLE resterende events i stackChains og returner dem
|
|
||||||
const allRemainingEventIds = Array.from(this.stackChains.keys());
|
|
||||||
console.log('STACK CHAIN REMOVE: All remaining events to re-stack:', allRemainingEventIds);
|
|
||||||
|
|
||||||
// Re-assign stackLevel baseret på position (0 = underlying, 1 = første stacked, osv.)
|
|
||||||
allRemainingEventIds.forEach((remainingId, index) => {
|
|
||||||
const chainInfo = this.stackChains.get(remainingId);
|
|
||||||
if (chainInfo) {
|
|
||||||
chainInfo.stackLevel = index;
|
|
||||||
console.log('STACK CHAIN REMOVE: Set stackLevel for', remainingId, 'to', index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('STACK CHAIN STATE AFTER:', Array.from(this.stackChains.entries()));
|
|
||||||
|
|
||||||
return allRemainingEventIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-stack events efter fjernelse af et stacked event
|
|
||||||
*/
|
|
||||||
private restackRemainingEvents(container: HTMLElement): void {
|
|
||||||
// Find alle stacked events (events med margin-left)
|
|
||||||
const stackedEvents = Array.from(container.querySelectorAll('swp-event'))
|
|
||||||
.filter(el => {
|
|
||||||
const element = el as HTMLElement;
|
|
||||||
return element.style.marginLeft && element.style.marginLeft !== '0px';
|
|
||||||
}) as HTMLElement[];
|
|
||||||
|
|
||||||
if (stackedEvents.length === 0) return;
|
|
||||||
|
|
||||||
// Sort events by current margin-left (ascending)
|
|
||||||
stackedEvents.sort((a, b) => {
|
|
||||||
const marginA = parseInt(a.style.marginLeft) || 0;
|
|
||||||
const marginB = parseInt(b.style.marginLeft) || 0;
|
|
||||||
return marginA - marginB;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-assign margin-left values starting from 15px
|
|
||||||
stackedEvents.forEach((element, index) => {
|
|
||||||
const newMarginLeft = (index + 1) * EventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
|
|
||||||
element.style.marginLeft = `${newMarginLeft}px`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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('swp-event-group') !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if element is a stacked event
|
|
||||||
*/
|
|
||||||
public isStackedEvent(element: HTMLElement): boolean {
|
|
||||||
const eventId = element.dataset.eventId;
|
|
||||||
const hasMarginLeft = element.style.marginLeft !== '' && element.style.marginLeft !== '0px';
|
|
||||||
const isInStackChain = eventId ? this.stackChains.has(eventId) : false;
|
|
||||||
|
|
||||||
console.log('isStackedEvent check:', eventId, 'hasMarginLeft:', hasMarginLeft, 'isInStackChain:', isInStackChain);
|
|
||||||
|
|
||||||
// Et event er stacked hvis det enten har margin-left ELLER er i en stack chain
|
|
||||||
return hasMarginLeft || isInStackChain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get event group container for an event element
|
|
||||||
*/
|
|
||||||
public getEventGroup(eventElement: HTMLElement): HTMLElement | null {
|
|
||||||
return eventElement.closest('swp-event-group') as HTMLElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -25,67 +25,59 @@ export interface StackLink {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SimpleEventOverlapManager {
|
export class SimpleEventOverlapManager {
|
||||||
private static readonly STACKING_TIME_THRESHOLD_MINUTES = 30;
|
|
||||||
private static readonly STACKING_WIDTH_REDUCTION_PX = 15;
|
private static readonly STACKING_WIDTH_REDUCTION_PX = 15;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect overlap type between two events - simplified logic
|
* Detect overlap type between two DOM elements - pixel-based logic
|
||||||
*/
|
*/
|
||||||
public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType {
|
public resolveOverlapType(element1: HTMLElement, element2: HTMLElement): OverlapType {
|
||||||
if (!this.eventsOverlapInTime(event1, event2)) {
|
const top1 = parseInt(element1.style.top) || 0;
|
||||||
|
const height1 = parseInt(element1.style.height) || 0;
|
||||||
|
const bottom1 = top1 + height1;
|
||||||
|
|
||||||
|
const top2 = parseInt(element2.style.top) || 0;
|
||||||
|
const height2 = parseInt(element2.style.height) || 0;
|
||||||
|
const bottom2 = top2 + height2;
|
||||||
|
|
||||||
|
// Check if events overlap in pixel space
|
||||||
|
const tolerance = 2;
|
||||||
|
if (bottom1 <= (top2 + tolerance) || bottom2 <= (top1 + tolerance)) {
|
||||||
return OverlapType.NONE;
|
return OverlapType.NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeDiffMinutes = Math.abs(
|
// Events overlap - check start position difference for overlap type
|
||||||
new Date(event1.start).getTime() - new Date(event2.start).getTime()
|
const startDifference = Math.abs(top1 - top2);
|
||||||
) / (1000 * 60);
|
|
||||||
|
|
||||||
return timeDiffMinutes > SimpleEventOverlapManager.STACKING_TIME_THRESHOLD_MINUTES
|
// Over 40px start difference = stacking
|
||||||
? OverlapType.STACKING
|
if (startDifference > 40) {
|
||||||
: OverlapType.COLUMN_SHARING;
|
return OverlapType.STACKING;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within 40px start difference = column sharing
|
||||||
|
return OverlapType.COLUMN_SHARING;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple time overlap check
|
|
||||||
*/
|
|
||||||
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();
|
|
||||||
|
|
||||||
return !(end1 <= start2 || end2 <= start1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group overlapping events - much cleaner algorithm
|
* Group overlapping elements - pixel-based algorithm
|
||||||
*/
|
*/
|
||||||
public groupOverlappingEvents(events: CalendarEvent[]): OverlapGroup[] {
|
public groupOverlappingElements(elements: HTMLElement[]): HTMLElement[][] {
|
||||||
const groups: OverlapGroup[] = [];
|
const groups: HTMLElement[][] = [];
|
||||||
const processed = new Set<string>();
|
const processed = new Set<HTMLElement>();
|
||||||
|
|
||||||
for (const event of events) {
|
for (const element of elements) {
|
||||||
if (processed.has(event.id)) continue;
|
if (processed.has(element)) continue;
|
||||||
|
|
||||||
// Find all events that overlap with this one
|
// Find all elements that overlap with this one
|
||||||
const overlapping = events.filter(other => {
|
const overlapping = elements.filter(other => {
|
||||||
if (processed.has(other.id)) return false;
|
if (processed.has(other)) return false;
|
||||||
return other.id === event.id || this.detectOverlap(event, other) !== OverlapType.NONE;
|
return other === element || this.resolveOverlapType(element, other) !== OverlapType.NONE;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark all as processed
|
// Mark all as processed
|
||||||
overlapping.forEach(e => processed.add(e.id));
|
overlapping.forEach(e => processed.add(e));
|
||||||
|
|
||||||
// Determine group type
|
groups.push(overlapping);
|
||||||
const overlapType = overlapping.length > 1
|
|
||||||
? this.detectOverlap(overlapping[0], overlapping[1])
|
|
||||||
: OverlapType.NONE;
|
|
||||||
|
|
||||||
groups.push({
|
|
||||||
type: overlapType,
|
|
||||||
events: overlapping,
|
|
||||||
position: this.calculateGroupPosition(overlapping)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
|
|
@ -96,14 +88,6 @@ export class SimpleEventOverlapManager {
|
||||||
*/
|
*/
|
||||||
public createEventGroup(events: CalendarEvent[], position: { top: number; height: number }): HTMLElement {
|
public createEventGroup(events: CalendarEvent[], position: { top: number; height: number }): HTMLElement {
|
||||||
const container = document.createElement('swp-event-group');
|
const container = document.createElement('swp-event-group');
|
||||||
container.style.cssText = `
|
|
||||||
position: absolute;
|
|
||||||
top: ${position.top}px;
|
|
||||||
left: 2px;
|
|
||||||
right: 2px;
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
`;
|
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,7 +188,7 @@ export class SimpleEventOverlapManager {
|
||||||
const nextLink = this.getStackLink(nextElement);
|
const nextLink = this.getStackLink(nextElement);
|
||||||
|
|
||||||
// CRITICAL: Check if prev and next actually overlap without the middle element
|
// CRITICAL: Check if prev and next actually overlap without the middle element
|
||||||
const actuallyOverlap = this.checkPixelOverlap(prevElement, nextElement);
|
const actuallyOverlap = this.resolveOverlapType(prevElement, nextElement);
|
||||||
|
|
||||||
if (!actuallyOverlap) {
|
if (!actuallyOverlap) {
|
||||||
// CHAIN BREAKING: prev and next don't overlap - break the chain
|
// CHAIN BREAKING: prev and next don't overlap - break the chain
|
||||||
|
|
@ -346,24 +330,7 @@ export class SimpleEventOverlapManager {
|
||||||
const eventElement = container.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement;
|
const eventElement = container.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement;
|
||||||
if (!eventElement) return false;
|
if (!eventElement) return false;
|
||||||
|
|
||||||
// Calculate correct absolute position for standalone event
|
// Simply remove the element - no position calculation needed since it's being removed
|
||||||
const startTime = eventElement.dataset.start;
|
|
||||||
if (startTime) {
|
|
||||||
const startDate = new Date(startTime);
|
|
||||||
const gridSettings = calendarConfig.getGridSettings();
|
|
||||||
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
|
|
||||||
const dayStartMinutes = gridSettings.dayStartHour * 60;
|
|
||||||
const top = ((startMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
|
|
||||||
|
|
||||||
// Convert back to absolute positioning
|
|
||||||
eventElement.style.position = 'absolute';
|
|
||||||
eventElement.style.top = `${top + 1}px`;
|
|
||||||
eventElement.style.left = '2px';
|
|
||||||
eventElement.style.right = '2px';
|
|
||||||
eventElement.style.flex = '';
|
|
||||||
eventElement.style.minWidth = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
eventElement.remove();
|
eventElement.remove();
|
||||||
|
|
||||||
// Handle remaining events
|
// Handle remaining events
|
||||||
|
|
@ -378,22 +345,15 @@ export class SimpleEventOverlapManager {
|
||||||
if (remainingCount === 1) {
|
if (remainingCount === 1) {
|
||||||
const remainingEvent = remainingEvents[0] as HTMLElement;
|
const remainingEvent = remainingEvents[0] as HTMLElement;
|
||||||
|
|
||||||
// Convert last event back to absolute positioning
|
// Convert last event back to absolute positioning - use current pixel position
|
||||||
const remainingStartTime = remainingEvent.dataset.start;
|
const currentTop = parseInt(remainingEvent.style.top) || 0;
|
||||||
if (remainingStartTime) {
|
|
||||||
const remainingStartDate = new Date(remainingStartTime);
|
remainingEvent.style.position = 'absolute';
|
||||||
const gridSettings = calendarConfig.getGridSettings();
|
remainingEvent.style.top = `${currentTop}px`;
|
||||||
const remainingStartMinutes = remainingStartDate.getHours() * 60 + remainingStartDate.getMinutes();
|
remainingEvent.style.left = '2px';
|
||||||
const dayStartMinutes = gridSettings.dayStartHour * 60;
|
remainingEvent.style.right = '2px';
|
||||||
const remainingTop = ((remainingStartMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
|
remainingEvent.style.flex = '';
|
||||||
|
remainingEvent.style.minWidth = '';
|
||||||
remainingEvent.style.position = 'absolute';
|
|
||||||
remainingEvent.style.top = `${remainingTop + 1}px`;
|
|
||||||
remainingEvent.style.left = '2px';
|
|
||||||
remainingEvent.style.right = '2px';
|
|
||||||
remainingEvent.style.flex = '';
|
|
||||||
remainingEvent.style.minWidth = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
container.parentElement?.insertBefore(remainingEvent, container);
|
container.parentElement?.insertBefore(remainingEvent, container);
|
||||||
container.remove();
|
container.remove();
|
||||||
|
|
@ -471,33 +431,6 @@ export class SimpleEventOverlapManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate position for group - simplified calculation
|
|
||||||
*/
|
|
||||||
private calculateGroupPosition(events: CalendarEvent[]): { top: number; height: number } {
|
|
||||||
if (events.length === 0) return { top: 0, height: 0 };
|
|
||||||
|
|
||||||
const times = events.flatMap(e => [
|
|
||||||
new Date(e.start).getTime(),
|
|
||||||
new Date(e.end).getTime()
|
|
||||||
]);
|
|
||||||
|
|
||||||
const earliestStart = Math.min(...times);
|
|
||||||
const latestEnd = Math.max(...times);
|
|
||||||
|
|
||||||
const startDate = new Date(earliestStart);
|
|
||||||
const endDate = new Date(latestEnd);
|
|
||||||
|
|
||||||
const gridSettings = calendarConfig.getGridSettings();
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility methods - simple DOM traversal
|
* Utility methods - simple DOM traversal
|
||||||
|
|
@ -537,22 +470,4 @@ export class SimpleEventOverlapManager {
|
||||||
return document.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement;
|
return document.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if two elements overlap in pixel space
|
|
||||||
*/
|
|
||||||
private checkPixelOverlap(element1: HTMLElement, element2: HTMLElement): boolean {
|
|
||||||
if (!element1 || !element2) return false;
|
|
||||||
|
|
||||||
const top1 = parseFloat(element1.style.top) || 0;
|
|
||||||
const height1 = parseFloat(element1.style.height) || 0;
|
|
||||||
const bottom1 = top1 + height1;
|
|
||||||
|
|
||||||
const top2 = parseFloat(element2.style.top) || 0;
|
|
||||||
const height2 = parseFloat(element2.style.height) || 0;
|
|
||||||
const bottom2 = top2 + height2;
|
|
||||||
|
|
||||||
// Add tolerance for small gaps (borders, etc)
|
|
||||||
const tolerance = 2;
|
|
||||||
return !(bottom1 <= (top2 + tolerance) || bottom2 <= (top1 + tolerance));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -33,8 +33,8 @@ export interface RenderContext {
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
start: string; // ISO 8601
|
start: Date;
|
||||||
end: string; // ISO 8601
|
end: Date;
|
||||||
type: string; // Flexible event type - can be any string value
|
type: string; // Flexible event type - can be any string value
|
||||||
allDay: boolean;
|
allDay: boolean;
|
||||||
syncStatus: SyncStatus;
|
syncStatus: SyncStatus;
|
||||||
|
|
|
||||||
75
src/utils/OverlapDetector.ts
Normal file
75
src/utils/OverlapDetector.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
/**
|
||||||
|
* OverlapDetector - Ren tidbaseret overlap detection
|
||||||
|
* Ingen DOM manipulation, kun tidsberegninger
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CalendarEvent } from '../types/CalendarTypes';
|
||||||
|
|
||||||
|
// Branded type for event IDs
|
||||||
|
export type EventId = string & { readonly __brand: 'EventId' };
|
||||||
|
|
||||||
|
export type OverlapResult = {
|
||||||
|
overlappingEvents: CalendarEvent[];
|
||||||
|
stackLinks: Map<EventId, StackLink>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StackLink {
|
||||||
|
prev?: EventId; // Event ID of previous event in stack
|
||||||
|
next?: EventId; // Event ID of next event in stack
|
||||||
|
stackLevel: number; // 0 = base event, 1 = first stacked, etc
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OverlapDetector {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver hvilke events et givent event overlapper med i en kolonne
|
||||||
|
* @param event - CalendarEvent der skal checkes for overlap
|
||||||
|
* @param columnEvents - Array af CalendarEvent objekter i kolonnen
|
||||||
|
* @returns Array af events som det givne event overlapper med
|
||||||
|
*/
|
||||||
|
public resolveOverlap(event: CalendarEvent, columnEvents: CalendarEvent[]): CalendarEvent[] {
|
||||||
|
return columnEvents.filter(existingEvent => {
|
||||||
|
// To events overlapper hvis:
|
||||||
|
// event starter før existing slutter OG
|
||||||
|
// event slutter efter existing starter
|
||||||
|
return event.start < existingEvent.end && event.end > existingEvent.start;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dekorerer events med stack linking data
|
||||||
|
* @param newEvent - Det nye event der skal tilføjes
|
||||||
|
* @param overlappingEvents - Events som det nye event overlapper med
|
||||||
|
* @returns OverlapResult med overlappende events og stack links
|
||||||
|
*/
|
||||||
|
public decorateWithStackLinks(newEvent: CalendarEvent, overlappingEvents: CalendarEvent[]): OverlapResult {
|
||||||
|
const stackLinks = new Map<EventId, StackLink>();
|
||||||
|
|
||||||
|
if (overlappingEvents.length === 0) {
|
||||||
|
return {
|
||||||
|
overlappingEvents: [],
|
||||||
|
stackLinks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kombiner nyt event med eksisterende og sortér efter start tid (tidligste første)
|
||||||
|
const allEvents = [...overlappingEvents, newEvent].sort((a, b) =>
|
||||||
|
a.start.getTime() - b.start.getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Opret sammenhængende kæde - alle events bindes sammen
|
||||||
|
allEvents.forEach((event, index) => {
|
||||||
|
const stackLink: StackLink = {
|
||||||
|
stackLevel: index,
|
||||||
|
prev: index > 0 ? allEvents[index - 1].id as EventId : undefined,
|
||||||
|
next: index < allEvents.length - 1 ? allEvents[index + 1].id as EventId : undefined
|
||||||
|
};
|
||||||
|
stackLinks.set(event.id as EventId, stackLink);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
overlappingEvents,
|
||||||
|
stackLinks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -147,6 +147,30 @@ swp-spinner {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Prevent text selection in calendar UI */
|
||||||
|
swp-calendar-container,
|
||||||
|
swp-calendar-grid,
|
||||||
|
swp-day-column,
|
||||||
|
swp-event,
|
||||||
|
swp-event-group,
|
||||||
|
swp-time-axis,
|
||||||
|
swp-event-title,
|
||||||
|
swp-event-time {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enable text selection for events when double-clicked */
|
||||||
|
swp-event.text-selectable swp-event-title,
|
||||||
|
swp-event.text-selectable swp-event-time {
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
|
-ms-user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
/* Focus styles */
|
/* Focus styles */
|
||||||
:focus {
|
:focus {
|
||||||
outline: 2px solid var(--color-primary);
|
outline: 2px solid var(--color-primary);
|
||||||
|
|
|
||||||
|
|
@ -72,33 +72,47 @@ swp-event-title {
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Resize handles */
|
/* External resize handles */
|
||||||
swp-resize-handle {
|
swp-resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 8px;
|
left: 50%;
|
||||||
right: 8px;
|
transform: translateX(-50%);
|
||||||
|
width: 24px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity var(--transition-fast);
|
transition: opacity var(--transition-fast);
|
||||||
|
cursor: ns-resize;
|
||||||
|
z-index: 30;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
/* The two lines */
|
/* Subtle grip pattern */
|
||||||
&::before,
|
&::before {
|
||||||
&::after {
|
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 50%;
|
||||||
right: 0;
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 12px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(255, 255, 255, 0.6);
|
||||||
}
|
border-radius: 0.5px;
|
||||||
|
box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.3);
|
||||||
&::before {
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top resize handle - positioned OUTSIDE event */
|
||||||
|
swp-resize-handle[data-position="top"] {
|
||||||
|
top: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom resize handle - positioned OUTSIDE event */
|
||||||
|
swp-resize-handle[data-position="bottom"] {
|
||||||
|
bottom: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resize handles controlled by JavaScript - no general hover */
|
||||||
|
|
||||||
/* Hit area */
|
/* Hit area */
|
||||||
swp-handle-hitarea {
|
swp-handle-hitarea {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue