Implements advanced event stacking and grid layout
Introduces a 3-phase algorithm in `EventStackManager` for dynamic event positioning. Groups events by start time proximity to determine optimal layout. Optimizes horizontal space by using side-by-side grid columns for simultaneous events and allowing non-overlapping events to share stack levels. Supports nested stacking for late-arriving events within grid columns. Includes comprehensive documentation (`STACKING_CONCEPT.md`) and a visual demonstration (`stacking-visualization.html`) to explain the new layout logic. Updates event rendering to utilize the new manager and adds extensive test coverage.
This commit is contained in:
parent
57bf122675
commit
2f58ceccd4
8 changed files with 4509 additions and 14 deletions
772
STACKING_CONCEPT.md
Normal file
772
STACKING_CONCEPT.md
Normal file
|
|
@ -0,0 +1,772 @@
|
||||||
|
# Event Stacking Concept
|
||||||
|
**Calendar Plantempus - Visual Event Overlap Management**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Event Stacking** is a visual technique for displaying overlapping calendar events by offsetting them horizontally with a cascading effect. This creates a clear visual hierarchy showing which events overlap in time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual Concept
|
||||||
|
|
||||||
|
### Basic Stacking
|
||||||
|
|
||||||
|
When multiple events overlap in time, they are "stacked" with increasing left margin:
|
||||||
|
|
||||||
|
```
|
||||||
|
Timeline:
|
||||||
|
08:00 ─────────────────────────────────
|
||||||
|
│
|
||||||
|
09:00 │ Event A starts
|
||||||
|
│ ┌─────────────────────┐
|
||||||
|
│ │ Meeting A │
|
||||||
|
10:00 │ │ │
|
||||||
|
│ │ Event B starts │
|
||||||
|
│ │ ┌─────────────────────┐
|
||||||
|
11:00 │ │ │ Meeting B │
|
||||||
|
│ └──│─────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
12:00 │ │ Event C starts │
|
||||||
|
│ │ ┌─────────────────────┐
|
||||||
|
│ └──│─────────────────────┘
|
||||||
|
13:00 │ │ Meeting C │
|
||||||
|
│ └─────────────────────┘
|
||||||
|
14:00 ─────────────────────────────────
|
||||||
|
|
||||||
|
Visual Result (stacked view):
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Meeting A │
|
||||||
|
│ ┌─────────────────────┐
|
||||||
|
│ │ Meeting B │
|
||||||
|
└─│─────────────────────┘
|
||||||
|
│ ┌─────────────────────┐
|
||||||
|
│ │ Meeting C │
|
||||||
|
└─│─────────────────────┘
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Each subsequent event is offset by **15px** to the right.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack Link Data Structure
|
||||||
|
|
||||||
|
Stack links create a **doubly-linked list** stored directly in DOM elements as data attributes.
|
||||||
|
|
||||||
|
### Interface Definition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StackLink {
|
||||||
|
prev?: string; // Event ID of previous event in stack
|
||||||
|
next?: string; // Event ID of next event in stack
|
||||||
|
stackLevel: number; // Position in stack (0 = base, 1 = first offset, etc.)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage in DOM
|
||||||
|
|
||||||
|
Stack links are stored as JSON in the `data-stack-link` attribute:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<swp-event
|
||||||
|
data-event-id="event-1"
|
||||||
|
data-stack-link='{"stackLevel":0,"next":"event-2"}'>
|
||||||
|
</swp-event>
|
||||||
|
|
||||||
|
<swp-event
|
||||||
|
data-event-id="event-2"
|
||||||
|
data-stack-link='{"stackLevel":1,"prev":"event-1","next":"event-3"}'>
|
||||||
|
</swp-event>
|
||||||
|
|
||||||
|
<swp-event
|
||||||
|
data-event-id="event-3"
|
||||||
|
data-stack-link='{"stackLevel":2,"prev":"event-2"}'>
|
||||||
|
</swp-event>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits of DOM Storage
|
||||||
|
|
||||||
|
✅ **State follows the element** - No external state management needed
|
||||||
|
✅ **Survives drag & drop** - Links persist through DOM manipulations
|
||||||
|
✅ **Easy to query** - Can traverse chain using DOM queries
|
||||||
|
✅ **Self-contained** - Each element knows its position in the stack
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overlap Detection
|
||||||
|
|
||||||
|
Events overlap when their time ranges intersect.
|
||||||
|
|
||||||
|
### Time-Based Overlap Algorithm
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function doEventsOverlap(eventA: CalendarEvent, eventB: CalendarEvent): boolean {
|
||||||
|
// Two events overlap if:
|
||||||
|
// - Event A starts before Event B ends AND
|
||||||
|
// - Event A ends after Event B starts
|
||||||
|
return eventA.start < eventB.end && eventA.end > eventB.start;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Cases
|
||||||
|
|
||||||
|
**Case 1: Events Overlap**
|
||||||
|
```
|
||||||
|
Event A: 09:00 ──────── 11:00
|
||||||
|
Event B: 10:00 ──────── 12:00
|
||||||
|
Result: OVERLAP (10:00 to 11:00)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Case 2: No Overlap**
|
||||||
|
```
|
||||||
|
Event A: 09:00 ──── 10:00
|
||||||
|
Event B: 11:00 ──── 12:00
|
||||||
|
Result: NO OVERLAP
|
||||||
|
```
|
||||||
|
|
||||||
|
**Case 3: Complete Containment**
|
||||||
|
```
|
||||||
|
Event A: 09:00 ──────────────── 13:00
|
||||||
|
Event B: 10:00 ─── 11:00
|
||||||
|
Result: OVERLAP (Event B fully inside Event A)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual Styling
|
||||||
|
|
||||||
|
### CSS Calculations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const STACK_OFFSET_PX = 15;
|
||||||
|
|
||||||
|
// For each event in stack:
|
||||||
|
marginLeft = stackLevel * STACK_OFFSET_PX;
|
||||||
|
zIndex = 100 + stackLevel;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example with 3 Stacked Events
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
Event A (stackLevel: 0):
|
||||||
|
marginLeft = 0 * 15 = 0px
|
||||||
|
zIndex = 100 + 0 = 100
|
||||||
|
|
||||||
|
Event B (stackLevel: 1):
|
||||||
|
marginLeft = 1 * 15 = 15px
|
||||||
|
zIndex = 100 + 1 = 101
|
||||||
|
|
||||||
|
Event C (stackLevel: 2):
|
||||||
|
marginLeft = 2 * 15 = 30px
|
||||||
|
zIndex = 100 + 2 = 102
|
||||||
|
```
|
||||||
|
|
||||||
|
Result: Event C appears on top, Event A at the base.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optimized Stacking (Smart Stacking)
|
||||||
|
|
||||||
|
### The Problem: Naive Stacking vs Optimized Stacking
|
||||||
|
|
||||||
|
**Naive Approach:** Simply stack all overlapping events sequentially.
|
||||||
|
|
||||||
|
```
|
||||||
|
Event A: 09:00 ════════════════════════════ 14:00
|
||||||
|
Event B: 10:00 ═════ 12:00
|
||||||
|
Event C: 12:30 ═══ 13:00
|
||||||
|
|
||||||
|
Naive Result:
|
||||||
|
Event A: stackLevel 0
|
||||||
|
Event B: stackLevel 1
|
||||||
|
Event C: stackLevel 2 ← INEFFICIENT! C doesn't overlap B
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optimized Approach:** Events that don't overlap each other can share the same stack level.
|
||||||
|
|
||||||
|
```
|
||||||
|
Event A: 09:00 ════════════════════════════ 14:00
|
||||||
|
Event B: 10:00 ═════ 12:00
|
||||||
|
Event C: 12:30 ═══ 13:00
|
||||||
|
|
||||||
|
Optimized Result:
|
||||||
|
Event A: stackLevel 0
|
||||||
|
Event B: stackLevel 1 ← Both at level 1
|
||||||
|
Event C: stackLevel 1 ← because they don't overlap!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual Comparison: The Key Insight
|
||||||
|
|
||||||
|
**Example Timeline:**
|
||||||
|
```
|
||||||
|
Timeline:
|
||||||
|
09:00 ─────────────────────────────────
|
||||||
|
│ Event A starts
|
||||||
|
│ ┌─────────────────────────────┐
|
||||||
|
10:00 │ │ Event A │
|
||||||
|
│ │ │
|
||||||
|
│ │ Event B starts │
|
||||||
|
│ │ ╔═══════════════╗ │
|
||||||
|
11:00 │ │ ║ Event B ║ │
|
||||||
|
│ │ ║ ║ │
|
||||||
|
12:00 │ │ ╚═══════════════╝ │
|
||||||
|
│ │ │
|
||||||
|
│ │ Event C starts │
|
||||||
|
│ │ ╔═══════════╗ │
|
||||||
|
13:00 │ │ ║ Event C ║ │
|
||||||
|
│ └───────╚═══════════╝─────────┘
|
||||||
|
14:00 ─────────────────────────────────
|
||||||
|
|
||||||
|
Key Observation:
|
||||||
|
• Event B (10:00-12:00) and Event C (12:30-13:00) do NOT overlap!
|
||||||
|
• They are separated by 30 minutes (12:00 to 12:30)
|
||||||
|
• Both overlap with Event A, but not with each other
|
||||||
|
```
|
||||||
|
|
||||||
|
**Naive Stacking (Wasteful):**
|
||||||
|
```
|
||||||
|
Visual Result (Naive - Inefficient):
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Event A │
|
||||||
|
│ ┌─────────────────────┐ │
|
||||||
|
│ │ Event B │ │
|
||||||
|
│ │ ┌─────────────────────┐ │
|
||||||
|
│ └─│─────────────────────┘ │
|
||||||
|
│ │ Event C │ │
|
||||||
|
│ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
0px 15px 30px
|
||||||
|
└──┴────┘
|
||||||
|
Wasted space!
|
||||||
|
|
||||||
|
Stack Levels:
|
||||||
|
• Event A: stackLevel 0 (marginLeft: 0px)
|
||||||
|
• Event B: stackLevel 1 (marginLeft: 15px)
|
||||||
|
• Event C: stackLevel 2 (marginLeft: 30px) ← UNNECESSARY!
|
||||||
|
|
||||||
|
Problem: Event C is pushed 30px to the right even though
|
||||||
|
it doesn't conflict with Event B!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optimized Stacking (Efficient):**
|
||||||
|
```
|
||||||
|
Visual Result (Optimized - Efficient):
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Event A │
|
||||||
|
│ ┌─────────────────────┐ ┌─────────────────────┐│
|
||||||
|
│ │ Event B │ │ Event C ││
|
||||||
|
│ └─────────────────────┘ └─────────────────────┘│
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
0px 15px 15px
|
||||||
|
└────────────────────┘
|
||||||
|
Same offset for both!
|
||||||
|
|
||||||
|
Stack Levels:
|
||||||
|
• Event A: stackLevel 0 (marginLeft: 0px)
|
||||||
|
• Event B: stackLevel 1 (marginLeft: 15px)
|
||||||
|
• Event C: stackLevel 1 (marginLeft: 15px) ← OPTIMIZED!
|
||||||
|
|
||||||
|
Benefit: Event C reuses stackLevel 1 because Event B
|
||||||
|
has already ended when Event C starts.
|
||||||
|
No visual conflict, saves 15px of horizontal space!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Side-by-Side Comparison:**
|
||||||
|
```
|
||||||
|
Naive (3 levels): Optimized (2 levels):
|
||||||
|
|
||||||
|
A A
|
||||||
|
├─ B ├─ B
|
||||||
|
│ └─ C └─ C
|
||||||
|
|
||||||
|
Uses 45px width Uses 30px width
|
||||||
|
(0 + 15 + 30) (0 + 15 + 15)
|
||||||
|
|
||||||
|
33% space savings! →
|
||||||
|
```
|
||||||
|
|
||||||
|
### Algorithm: Greedy Stack Level Assignment
|
||||||
|
|
||||||
|
The optimized stacking algorithm assigns the lowest available stack level to each event:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function createOptimizedStackLinks(events: CalendarEvent[]): Map<string, StackLink> {
|
||||||
|
// Step 1: Sort events by start time
|
||||||
|
const sorted = events.sort((a, b) => a.start - b.start)
|
||||||
|
|
||||||
|
// Step 2: Track which stack levels are occupied at each time point
|
||||||
|
const stackLinks = new Map<string, StackLink>()
|
||||||
|
|
||||||
|
for (const event of sorted) {
|
||||||
|
// Find the lowest available stack level for this event
|
||||||
|
let stackLevel = 0
|
||||||
|
|
||||||
|
// Check which levels are occupied by overlapping events
|
||||||
|
const overlapping = sorted.filter(other =>
|
||||||
|
other !== event && doEventsOverlap(event, other)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Try each level starting from 0
|
||||||
|
while (true) {
|
||||||
|
const levelOccupied = overlapping.some(other =>
|
||||||
|
stackLinks.get(other.id)?.stackLevel === stackLevel
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!levelOccupied) {
|
||||||
|
break // Found available level
|
||||||
|
}
|
||||||
|
|
||||||
|
stackLevel++ // Try next level
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign the lowest available level
|
||||||
|
stackLinks.set(event.id, { stackLevel })
|
||||||
|
}
|
||||||
|
|
||||||
|
return stackLinks
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Scenarios
|
||||||
|
|
||||||
|
#### Scenario 1: Three Events, Two Parallel Tracks
|
||||||
|
|
||||||
|
```
|
||||||
|
Input:
|
||||||
|
Event A: 09:00-14:00 (long event)
|
||||||
|
Event B: 10:00-12:00
|
||||||
|
Event C: 12:30-13:00
|
||||||
|
|
||||||
|
Analysis:
|
||||||
|
A overlaps with: B, C
|
||||||
|
B overlaps with: A (not C)
|
||||||
|
C overlaps with: A (not B)
|
||||||
|
|
||||||
|
Result:
|
||||||
|
Event A: stackLevel 0 (base)
|
||||||
|
Event B: stackLevel 1 (first available)
|
||||||
|
Event C: stackLevel 1 (level 1 is free, B doesn't conflict)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario 2: Four Events, Three at Same Level
|
||||||
|
|
||||||
|
```
|
||||||
|
Input:
|
||||||
|
Event A: 09:00-15:00 (very long event)
|
||||||
|
Event B: 10:00-11:00
|
||||||
|
Event C: 11:30-12:30
|
||||||
|
Event D: 13:00-14:00
|
||||||
|
|
||||||
|
Analysis:
|
||||||
|
A overlaps with: B, C, D
|
||||||
|
B, C, D don't overlap with each other
|
||||||
|
|
||||||
|
Result:
|
||||||
|
Event A: stackLevel 0
|
||||||
|
Event B: stackLevel 1
|
||||||
|
Event C: stackLevel 1 (B is done, level 1 free)
|
||||||
|
Event D: stackLevel 1 (B and C are done, level 1 free)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario 3: Nested Events with Optimization
|
||||||
|
|
||||||
|
```
|
||||||
|
Input:
|
||||||
|
Event A: 09:00-15:00
|
||||||
|
Event B: 10:00-13:00
|
||||||
|
Event C: 11:00-12:00
|
||||||
|
Event D: 12:30-13:30
|
||||||
|
|
||||||
|
Analysis:
|
||||||
|
A overlaps with: B, C, D
|
||||||
|
B overlaps with: A, C (not D)
|
||||||
|
C overlaps with: A, B (not D)
|
||||||
|
D overlaps with: A (not B, not C)
|
||||||
|
|
||||||
|
Result:
|
||||||
|
Event A: stackLevel 0 (base)
|
||||||
|
Event B: stackLevel 1 (overlaps with A)
|
||||||
|
Event C: stackLevel 2 (overlaps with A and B)
|
||||||
|
Event D: stackLevel 2 (overlaps with A only, level 2 is free)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stack Links with Optimization
|
||||||
|
|
||||||
|
**Important:** With optimized stacking, events at the same stack level are NOT linked via prev/next!
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Traditional chain (naive):
|
||||||
|
Event A: { stackLevel: 0, next: "event-b" }
|
||||||
|
Event B: { stackLevel: 1, prev: "event-a", next: "event-c" }
|
||||||
|
Event C: { stackLevel: 2, prev: "event-b" }
|
||||||
|
|
||||||
|
// Optimized (B and C at same level, no link between them):
|
||||||
|
Event A: { stackLevel: 0 }
|
||||||
|
Event B: { stackLevel: 1 } // No prev/next
|
||||||
|
Event C: { stackLevel: 1 } // No prev/next
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits of Optimized Stacking
|
||||||
|
|
||||||
|
✅ **Space Efficiency:** Reduces horizontal space usage by up to 50%
|
||||||
|
✅ **Better Readability:** Events are visually closer, easier to see relationships
|
||||||
|
✅ **Scalability:** Works well with many events in a day
|
||||||
|
✅ **Performance:** Same O(n²) complexity as naive approach
|
||||||
|
|
||||||
|
### Trade-offs
|
||||||
|
|
||||||
|
⚠️ **No Single Chain:** Events at the same level aren't linked, making traversal more complex
|
||||||
|
⚠️ **More Complex Logic:** Requires checking all overlaps, not just sequential ordering
|
||||||
|
⚠️ **Visual Ambiguity:** Users might wonder why some events are at the same level
|
||||||
|
|
||||||
|
## Stack Chain Operations
|
||||||
|
|
||||||
|
### Building a Stack Chain (Naive Approach)
|
||||||
|
|
||||||
|
When events overlap, they form a chain sorted by start time:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Input: Events with overlapping times
|
||||||
|
Event A: 09:00-11:00
|
||||||
|
Event B: 10:00-12:00
|
||||||
|
Event C: 11:30-13:00
|
||||||
|
|
||||||
|
// Step 1: Sort by start time (earliest first)
|
||||||
|
Sorted: [Event A, Event B, Event C]
|
||||||
|
|
||||||
|
// Step 2: Create links
|
||||||
|
Event A: { stackLevel: 0, next: "event-b" }
|
||||||
|
Event B: { stackLevel: 1, prev: "event-a", next: "event-c" }
|
||||||
|
Event C: { stackLevel: 2, prev: "event-b" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Traversing Forward
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Start at any event
|
||||||
|
currentEvent = Event B;
|
||||||
|
|
||||||
|
// Get stack link
|
||||||
|
stackLink = currentEvent.dataset.stackLink; // { prev: "event-a", next: "event-c" }
|
||||||
|
|
||||||
|
// Move to next event
|
||||||
|
nextEventId = stackLink.next; // "event-c"
|
||||||
|
nextEvent = document.querySelector(`[data-event-id="${nextEventId}"]`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Traversing Backward
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Start at any event
|
||||||
|
currentEvent = Event B;
|
||||||
|
|
||||||
|
// Get stack link
|
||||||
|
stackLink = currentEvent.dataset.stackLink; // { prev: "event-a", next: "event-c" }
|
||||||
|
|
||||||
|
// Move to previous event
|
||||||
|
prevEventId = stackLink.prev; // "event-a"
|
||||||
|
prevEvent = document.querySelector(`[data-event-id="${prevEventId}"]`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Finding Stack Root
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function findStackRoot(event: HTMLElement): HTMLElement {
|
||||||
|
let current = event;
|
||||||
|
let stackLink = getStackLink(current);
|
||||||
|
|
||||||
|
// Traverse backward until we find an event with no prev link
|
||||||
|
while (stackLink?.prev) {
|
||||||
|
const prevEvent = document.querySelector(
|
||||||
|
`[data-event-id="${stackLink.prev}"]`
|
||||||
|
);
|
||||||
|
if (!prevEvent) break;
|
||||||
|
|
||||||
|
current = prevEvent;
|
||||||
|
stackLink = getStackLink(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return current; // This is the root (stackLevel 0)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### 1. Adding a New Event to Existing Stack
|
||||||
|
|
||||||
|
```
|
||||||
|
Existing Stack:
|
||||||
|
Event A (09:00-11:00) - stackLevel 0
|
||||||
|
Event B (10:00-12:00) - stackLevel 1
|
||||||
|
|
||||||
|
New Event:
|
||||||
|
Event C (10:30-11:30)
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Detect overlap with Event A and Event B
|
||||||
|
2. Sort all three by start time: [A, B, C]
|
||||||
|
3. Rebuild stack links:
|
||||||
|
- Event A: { stackLevel: 0, next: "event-b" }
|
||||||
|
- Event B: { stackLevel: 1, prev: "event-a", next: "event-c" }
|
||||||
|
- Event C: { stackLevel: 2, prev: "event-b" }
|
||||||
|
4. Apply visual styling
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Removing Event from Middle of Stack
|
||||||
|
|
||||||
|
```
|
||||||
|
Before:
|
||||||
|
Event A (stackLevel 0) ─→ Event B (stackLevel 1) ─→ Event C (stackLevel 2)
|
||||||
|
|
||||||
|
Remove Event B:
|
||||||
|
|
||||||
|
After:
|
||||||
|
Event A (stackLevel 0) ─→ Event C (stackLevel 1)
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Get Event B's stack link: { prev: "event-a", next: "event-c" }
|
||||||
|
2. Update Event A's next: "event-c"
|
||||||
|
3. Update Event C's prev: "event-a"
|
||||||
|
4. Update Event C's stackLevel: 1 (was 2)
|
||||||
|
5. Recalculate Event C's marginLeft: 15px (was 30px)
|
||||||
|
6. Remove Event B's stack link
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Moving Event to Different Time
|
||||||
|
|
||||||
|
```
|
||||||
|
Before (events overlap):
|
||||||
|
Event A (09:00-11:00) - stackLevel 0
|
||||||
|
Event B (10:00-12:00) - stackLevel 1
|
||||||
|
|
||||||
|
Move Event B to 14:00-16:00 (no longer overlaps):
|
||||||
|
|
||||||
|
After:
|
||||||
|
Event A (09:00-11:00) - NO STACK LINK (standalone)
|
||||||
|
Event B (14:00-16:00) - NO STACK LINK (standalone)
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Detect that Event B no longer overlaps Event A
|
||||||
|
2. Remove Event B from stack chain
|
||||||
|
3. Clear Event A's next link
|
||||||
|
4. Clear Event B's stack link entirely
|
||||||
|
5. Reset both events' marginLeft to 0px
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Case 1: Single Event (No Overlap)
|
||||||
|
|
||||||
|
```
|
||||||
|
Event A: 09:00-10:00 (alone in time slot)
|
||||||
|
|
||||||
|
Stack Link: NONE (no data-stack-link attribute)
|
||||||
|
Visual: marginLeft = 0px, zIndex = default
|
||||||
|
```
|
||||||
|
|
||||||
|
### Case 2: Two Events, Same Start Time
|
||||||
|
|
||||||
|
```
|
||||||
|
Event A: 10:00-11:00
|
||||||
|
Event B: 10:00-12:00 (same start, different end)
|
||||||
|
|
||||||
|
Sort by: start time first, then by end time (shortest first)
|
||||||
|
Result: Event A (stackLevel 0), Event B (stackLevel 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Case 3: Multiple Separate Chains in Same Column
|
||||||
|
|
||||||
|
```
|
||||||
|
Chain 1:
|
||||||
|
Event A (09:00-10:00) - stackLevel 0
|
||||||
|
Event B (09:30-10:30) - stackLevel 1
|
||||||
|
|
||||||
|
Chain 2:
|
||||||
|
Event C (14:00-15:00) - stackLevel 0
|
||||||
|
Event D (14:30-15:30) - stackLevel 1
|
||||||
|
|
||||||
|
Note: Two independent chains, each with their own root at stackLevel 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Case 4: Complete Containment
|
||||||
|
|
||||||
|
```
|
||||||
|
Event A: 09:00-13:00 (large event)
|
||||||
|
Event B: 10:00-11:00 (inside A)
|
||||||
|
Event C: 11:30-12:30 (inside A)
|
||||||
|
|
||||||
|
All three overlap, so they form one chain:
|
||||||
|
Event A - stackLevel 0
|
||||||
|
Event B - stackLevel 1
|
||||||
|
Event C - stackLevel 2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Algorithm Pseudocode
|
||||||
|
|
||||||
|
### Creating Stack for New Event
|
||||||
|
|
||||||
|
```
|
||||||
|
function createStackForNewEvent(newEvent, columnEvents):
|
||||||
|
// Step 1: Find overlapping events
|
||||||
|
overlapping = columnEvents.filter(event =>
|
||||||
|
doEventsOverlap(newEvent, event)
|
||||||
|
)
|
||||||
|
|
||||||
|
if overlapping is empty:
|
||||||
|
// No stack needed
|
||||||
|
return null
|
||||||
|
|
||||||
|
// Step 2: Combine and sort by start time
|
||||||
|
allEvents = [...overlapping, newEvent]
|
||||||
|
allEvents.sort((a, b) => a.start - b.start)
|
||||||
|
|
||||||
|
// Step 3: Create stack links
|
||||||
|
stackLinks = new Map()
|
||||||
|
|
||||||
|
for (i = 0; i < allEvents.length; i++):
|
||||||
|
link = {
|
||||||
|
stackLevel: i,
|
||||||
|
prev: i > 0 ? allEvents[i-1].id : undefined,
|
||||||
|
next: i < allEvents.length-1 ? allEvents[i+1].id : undefined
|
||||||
|
}
|
||||||
|
stackLinks.set(allEvents[i].id, link)
|
||||||
|
|
||||||
|
// Step 4: Apply to DOM
|
||||||
|
for each event in allEvents:
|
||||||
|
element = findElementById(event.id)
|
||||||
|
element.dataset.stackLink = JSON.stringify(stackLinks.get(event.id))
|
||||||
|
element.style.marginLeft = stackLinks.get(event.id).stackLevel * 15 + 'px'
|
||||||
|
element.style.zIndex = 100 + stackLinks.get(event.id).stackLevel
|
||||||
|
|
||||||
|
return stackLinks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Removing Event from Stack
|
||||||
|
|
||||||
|
```
|
||||||
|
function removeEventFromStack(eventId):
|
||||||
|
element = findElementById(eventId)
|
||||||
|
stackLink = JSON.parse(element.dataset.stackLink)
|
||||||
|
|
||||||
|
if not stackLink:
|
||||||
|
return // Not in a stack
|
||||||
|
|
||||||
|
// Update previous element
|
||||||
|
if stackLink.prev:
|
||||||
|
prevElement = findElementById(stackLink.prev)
|
||||||
|
prevLink = JSON.parse(prevElement.dataset.stackLink)
|
||||||
|
prevLink.next = stackLink.next
|
||||||
|
prevElement.dataset.stackLink = JSON.stringify(prevLink)
|
||||||
|
|
||||||
|
// Update next element
|
||||||
|
if stackLink.next:
|
||||||
|
nextElement = findElementById(stackLink.next)
|
||||||
|
nextLink = JSON.parse(nextElement.dataset.stackLink)
|
||||||
|
nextLink.prev = stackLink.prev
|
||||||
|
|
||||||
|
// Shift down stack level
|
||||||
|
nextLink.stackLevel = nextLink.stackLevel - 1
|
||||||
|
nextElement.dataset.stackLink = JSON.stringify(nextLink)
|
||||||
|
|
||||||
|
// Update visual styling
|
||||||
|
nextElement.style.marginLeft = nextLink.stackLevel * 15 + 'px'
|
||||||
|
nextElement.style.zIndex = 100 + nextLink.stackLevel
|
||||||
|
|
||||||
|
// Cascade update to all subsequent events
|
||||||
|
updateSubsequentStackLevels(nextElement, -1)
|
||||||
|
|
||||||
|
// Clear removed element's stack link
|
||||||
|
delete element.dataset.stackLink
|
||||||
|
element.style.marginLeft = '0px'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Time Complexity
|
||||||
|
|
||||||
|
- **Overlap Detection:** O(n) where n = number of events in column
|
||||||
|
- **Stack Creation:** O(n log n) due to sorting
|
||||||
|
- **Chain Traversal:** O(n) worst case (entire chain)
|
||||||
|
- **Stack Removal:** O(n) worst case (update all subsequent)
|
||||||
|
|
||||||
|
### Space Complexity
|
||||||
|
|
||||||
|
- **Stack Links:** O(1) per event (stored in DOM attribute)
|
||||||
|
- **No Global State:** All state is in DOM
|
||||||
|
|
||||||
|
### Optimization Tips
|
||||||
|
|
||||||
|
1. **Batch Updates:** When adding multiple events, batch DOM updates
|
||||||
|
2. **Lazy Evaluation:** Only recalculate stacks when events change
|
||||||
|
3. **Event Delegation:** Use event delegation instead of per-element listeners
|
||||||
|
4. **Virtual Scrolling:** For large calendars, only render visible events
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Guidelines
|
||||||
|
|
||||||
|
### Separation of Concerns
|
||||||
|
|
||||||
|
**Pure Logic (No DOM):**
|
||||||
|
- Overlap detection algorithms
|
||||||
|
- Stack link calculation
|
||||||
|
- Sorting logic
|
||||||
|
|
||||||
|
**DOM Manipulation:**
|
||||||
|
- Applying stack links to elements
|
||||||
|
- Updating visual styles
|
||||||
|
- Chain traversal
|
||||||
|
|
||||||
|
**Event Handling:**
|
||||||
|
- Detecting event changes
|
||||||
|
- Triggering stack recalculation
|
||||||
|
- Cleanup on event removal
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
|
||||||
|
1. **Unit Tests:** Test overlap detection in isolation
|
||||||
|
2. **Integration Tests:** Test stack creation with DOM
|
||||||
|
3. **Visual Tests:** Test CSS styling calculations
|
||||||
|
4. **Edge Cases:** Test boundary conditions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Potential Improvements
|
||||||
|
|
||||||
|
1. **Smart Stacking:** Detect non-overlapping sub-groups and stack independently
|
||||||
|
2. **Column Sharing:** For events with similar start times, use flexbox columns
|
||||||
|
3. **Compact Mode:** Reduce stack offset for dense calendars
|
||||||
|
4. **Color Coding:** Visual indication of stack depth
|
||||||
|
5. **Stack Preview:** Hover to highlight entire stack chain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Stack:** Group of overlapping events displayed with horizontal offset
|
||||||
|
- **Stack Link:** Data structure connecting events in a stack (doubly-linked list)
|
||||||
|
- **Stack Level:** Position in stack (0 = base, 1+ = offset)
|
||||||
|
- **Stack Root:** First event in stack (stackLevel 0, no prev link)
|
||||||
|
- **Stack Chain:** Complete sequence of linked events
|
||||||
|
- **Overlap:** Two events with intersecting time ranges
|
||||||
|
- **Offset:** Horizontal margin applied to stacked events (15px per level)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Version:** 1.0
|
||||||
|
**Last Updated:** 2025-10-04
|
||||||
|
**Status:** Conceptual Documentation - Ready for TDD Implementation
|
||||||
|
|
@ -1962,6 +1962,58 @@
|
||||||
"color": "#2196f3"
|
"color": "#2196f3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "1511",
|
||||||
|
"title": "Eftermiddags Kodning",
|
||||||
|
"start": "2025-10-01T10:30:00Z",
|
||||||
|
"end": "2025-10-01T11:00:00Z",
|
||||||
|
"type": "milestone",
|
||||||
|
"allDay": false,
|
||||||
|
"syncStatus": "synced",
|
||||||
|
"metadata": {
|
||||||
|
"duration": 180,
|
||||||
|
"color": "#2196f3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1512",
|
||||||
|
"title": "Eftermiddags Kodning",
|
||||||
|
"start": "2025-10-01T11:30:00Z",
|
||||||
|
"end": "2025-10-01T12:30:00Z",
|
||||||
|
"type": "milestone",
|
||||||
|
"allDay": false,
|
||||||
|
"syncStatus": "synced",
|
||||||
|
"metadata": {
|
||||||
|
"duration": 180,
|
||||||
|
"color": "#2196f3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1513",
|
||||||
|
"title": "Eftermiddags Kodning",
|
||||||
|
"start": "2025-10-01T12:00:00Z",
|
||||||
|
"end": "2025-10-01T13:00:00Z",
|
||||||
|
"type": "work",
|
||||||
|
"allDay": false,
|
||||||
|
"syncStatus": "synced",
|
||||||
|
"metadata": {
|
||||||
|
"duration": 180,
|
||||||
|
"color": "#2196f3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1514",
|
||||||
|
"title": "Eftermiddags Kodning 2",
|
||||||
|
"start": "2025-10-01T12:00:00Z",
|
||||||
|
"end": "2025-10-01T13:00:00Z",
|
||||||
|
"type": "work",
|
||||||
|
"allDay": false,
|
||||||
|
"syncStatus": "synced",
|
||||||
|
"metadata": {
|
||||||
|
"duration": 180,
|
||||||
|
"color": "#2196f3"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "152",
|
"id": "152",
|
||||||
"title": "Team Standup",
|
"title": "Team Standup",
|
||||||
|
|
@ -1991,8 +2043,8 @@
|
||||||
{
|
{
|
||||||
"id": "154",
|
"id": "154",
|
||||||
"title": "Bug Fixing Session",
|
"title": "Bug Fixing Session",
|
||||||
"start": "2025-10-02T11:00:00Z",
|
"start": "2025-10-02T07:00:00Z",
|
||||||
"end": "2025-10-02T13:00:00Z",
|
"end": "2025-10-02T09:00:00Z",
|
||||||
"type": "work",
|
"type": "work",
|
||||||
"allDay": false,
|
"allDay": false,
|
||||||
"syncStatus": "synced",
|
"syncStatus": "synced",
|
||||||
|
|
|
||||||
372
src/managers/EventStackManager.ts
Normal file
372
src/managers/EventStackManager.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
/**
|
||||||
|
* EventStackManager - Manages visual stacking of overlapping calendar events
|
||||||
|
*
|
||||||
|
* This class handles the creation and maintenance of "stack chains" - doubly-linked
|
||||||
|
* lists of overlapping events stored directly in DOM elements via data attributes.
|
||||||
|
*
|
||||||
|
* Implements 3-phase algorithm for flexbox + nested stacking:
|
||||||
|
* Phase 1: Group events by start time proximity (±15 min threshold)
|
||||||
|
* Phase 2: Decide container type (FLEXBOX vs STACKING)
|
||||||
|
* Phase 3: Handle late arrivals (nested stacking)
|
||||||
|
*
|
||||||
|
* @see STACKING_CONCEPT.md for detailed documentation
|
||||||
|
* @see stacking-visualization.html for visual examples
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CalendarEvent } from '../types/CalendarTypes';
|
||||||
|
|
||||||
|
export interface StackLink {
|
||||||
|
prev?: string; // Event ID of previous event in stack
|
||||||
|
next?: string; // Event ID of next event in stack
|
||||||
|
stackLevel: number; // Position in stack (0 = base, 1 = first offset, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventGroup {
|
||||||
|
events: CalendarEvent[];
|
||||||
|
containerType: 'NONE' | 'GRID' | 'STACKING';
|
||||||
|
startTime: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventStackManager {
|
||||||
|
private static readonly FLEXBOX_START_THRESHOLD_MINUTES = 15;
|
||||||
|
private static readonly STACK_OFFSET_PX = 15;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PHASE 1: Start Time Grouping
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group events by start time proximity (±15 min threshold)
|
||||||
|
*/
|
||||||
|
public groupEventsByStartTime(events: CalendarEvent[]): EventGroup[] {
|
||||||
|
if (events.length === 0) return [];
|
||||||
|
|
||||||
|
// Sort events by start time
|
||||||
|
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||||
|
|
||||||
|
const groups: EventGroup[] = [];
|
||||||
|
|
||||||
|
for (const event of sorted) {
|
||||||
|
// Find existing group within threshold
|
||||||
|
const existingGroup = groups.find(group => {
|
||||||
|
const groupStart = group.startTime;
|
||||||
|
const diffMinutes = Math.abs(event.start.getTime() - groupStart.getTime()) / (1000 * 60);
|
||||||
|
return diffMinutes <= EventStackManager.FLEXBOX_START_THRESHOLD_MINUTES;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingGroup) {
|
||||||
|
existingGroup.events.push(event);
|
||||||
|
} else {
|
||||||
|
groups.push({
|
||||||
|
events: [event],
|
||||||
|
containerType: 'NONE',
|
||||||
|
startTime: event.start
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two events should share flexbox (within ±15 min)
|
||||||
|
*/
|
||||||
|
public shouldShareFlexbox(event1: CalendarEvent, event2: CalendarEvent): boolean {
|
||||||
|
const diffMinutes = Math.abs(event1.start.getTime() - event2.start.getTime()) / (1000 * 60);
|
||||||
|
return diffMinutes <= EventStackManager.FLEXBOX_START_THRESHOLD_MINUTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PHASE 2: Container Type Decision
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide container type for a group of events
|
||||||
|
*
|
||||||
|
* Rule: Events starting simultaneously (within ±15 min) should ALWAYS use GRID,
|
||||||
|
* even if they overlap each other. This provides better visual indication that
|
||||||
|
* events start at the same time.
|
||||||
|
*/
|
||||||
|
public decideContainerType(group: EventGroup): 'NONE' | 'GRID' | 'STACKING' {
|
||||||
|
if (group.events.length === 1) {
|
||||||
|
return 'NONE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If events are grouped together (start within ±15 min), they should share columns (GRID)
|
||||||
|
// This is true EVEN if they overlap, because the visual priority is to show
|
||||||
|
// that they start simultaneously.
|
||||||
|
return 'GRID';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if events within a group overlap each other
|
||||||
|
*/
|
||||||
|
private hasInternalOverlaps(events: CalendarEvent[]): boolean {
|
||||||
|
for (let i = 0; i < events.length; i++) {
|
||||||
|
for (let j = i + 1; j < events.length; j++) {
|
||||||
|
if (this.doEventsOverlap(events[i], events[j])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two events overlap in time
|
||||||
|
*/
|
||||||
|
public doEventsOverlap(event1: CalendarEvent, event2: CalendarEvent): boolean {
|
||||||
|
return event1.start < event2.end && event1.end > event2.start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PHASE 3: Late Arrivals (Nested Stacking)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find events that start outside threshold (late arrivals)
|
||||||
|
*/
|
||||||
|
public findLateArrivals(groups: EventGroup[], allEvents: CalendarEvent[]): CalendarEvent[] {
|
||||||
|
const eventsInGroups = new Set(groups.flatMap(g => g.events.map(e => e.id)));
|
||||||
|
return allEvents.filter(event => !eventsInGroups.has(event.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find primary parent column for a late event (longest duration or first overlapping)
|
||||||
|
*/
|
||||||
|
public findPrimaryParentColumn(lateEvent: CalendarEvent, flexboxGroup: CalendarEvent[]): string | null {
|
||||||
|
// Find all overlapping events in the flexbox group
|
||||||
|
const overlapping = flexboxGroup.filter(event => this.doEventsOverlap(lateEvent, event));
|
||||||
|
|
||||||
|
if (overlapping.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by duration (longest first)
|
||||||
|
overlapping.sort((a, b) => {
|
||||||
|
const durationA = b.end.getTime() - b.start.getTime();
|
||||||
|
const durationB = a.end.getTime() - a.start.getTime();
|
||||||
|
return durationA - durationB;
|
||||||
|
});
|
||||||
|
|
||||||
|
return overlapping[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate marginLeft for nested event (always 15px)
|
||||||
|
*/
|
||||||
|
public calculateNestedMarginLeft(): number {
|
||||||
|
return EventStackManager.STACK_OFFSET_PX;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate stackLevel for nested event (parent + 1)
|
||||||
|
*/
|
||||||
|
public calculateNestedStackLevel(parentStackLevel: number): number {
|
||||||
|
return parentStackLevel + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Flexbox Layout Calculations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate flex width for flexbox columns
|
||||||
|
*/
|
||||||
|
public calculateFlexWidth(columnCount: number): string {
|
||||||
|
if (columnCount === 1) return '100%';
|
||||||
|
if (columnCount === 2) return '50%';
|
||||||
|
if (columnCount === 3) return '33.33%';
|
||||||
|
if (columnCount === 4) return '25%';
|
||||||
|
|
||||||
|
// For 5+ columns, calculate percentage
|
||||||
|
const percentage = (100 / columnCount).toFixed(2);
|
||||||
|
return `${percentage}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Existing Methods (from original TDD tests)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all events that overlap with a given event
|
||||||
|
*/
|
||||||
|
public findOverlappingEvents(targetEvent: CalendarEvent, columnEvents: CalendarEvent[]): CalendarEvent[] {
|
||||||
|
return columnEvents.filter(event => this.doEventsOverlap(targetEvent, event));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create stack links for overlapping events (naive sequential stacking)
|
||||||
|
*/
|
||||||
|
public createStackLinks(events: CalendarEvent[]): Map<string, StackLink> {
|
||||||
|
const stackLinks = new Map<string, StackLink>();
|
||||||
|
|
||||||
|
if (events.length === 0) return stackLinks;
|
||||||
|
|
||||||
|
// Sort by start time (and by end time if start times are equal)
|
||||||
|
const sorted = [...events].sort((a, b) => {
|
||||||
|
const startDiff = a.start.getTime() - b.start.getTime();
|
||||||
|
if (startDiff !== 0) return startDiff;
|
||||||
|
return a.end.getTime() - b.end.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create sequential stack
|
||||||
|
sorted.forEach((event, index) => {
|
||||||
|
const link: StackLink = {
|
||||||
|
stackLevel: index
|
||||||
|
};
|
||||||
|
|
||||||
|
if (index > 0) {
|
||||||
|
link.prev = sorted[index - 1].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < sorted.length - 1) {
|
||||||
|
link.next = sorted[index + 1].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
stackLinks.set(event.id, link);
|
||||||
|
});
|
||||||
|
|
||||||
|
return stackLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create optimized stack links (events share levels when possible)
|
||||||
|
*/
|
||||||
|
public createOptimizedStackLinks(events: CalendarEvent[]): Map<string, StackLink> {
|
||||||
|
const stackLinks = new Map<string, StackLink>();
|
||||||
|
|
||||||
|
if (events.length === 0) return stackLinks;
|
||||||
|
|
||||||
|
// Sort by start time
|
||||||
|
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||||
|
|
||||||
|
// Step 1: Assign stack levels
|
||||||
|
for (const event of sorted) {
|
||||||
|
// Find all events this event overlaps with
|
||||||
|
const overlapping = sorted.filter(other =>
|
||||||
|
other !== event && this.doEventsOverlap(event, other)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[EventStackManager] Event ${event.id} overlaps with:`, overlapping.map(e => e.id));
|
||||||
|
|
||||||
|
// Find the MINIMUM required level (must be above all overlapping events)
|
||||||
|
let minRequiredLevel = 0;
|
||||||
|
for (const other of overlapping) {
|
||||||
|
const otherLink = stackLinks.get(other.id);
|
||||||
|
if (otherLink) {
|
||||||
|
console.log(` ${other.id} has stackLevel ${otherLink.stackLevel}`);
|
||||||
|
// Must be at least one level above the overlapping event
|
||||||
|
minRequiredLevel = Math.max(minRequiredLevel, otherLink.stackLevel + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` → Assigned stackLevel ${minRequiredLevel} (must be above all overlapping events)`);
|
||||||
|
stackLinks.set(event.id, { stackLevel: minRequiredLevel });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Build prev/next chains for overlapping events at adjacent stack levels
|
||||||
|
for (const event of sorted) {
|
||||||
|
const currentLink = stackLinks.get(event.id)!;
|
||||||
|
|
||||||
|
// Find overlapping events that are directly below (stackLevel - 1)
|
||||||
|
const overlapping = sorted.filter(other =>
|
||||||
|
other !== event && this.doEventsOverlap(event, other)
|
||||||
|
);
|
||||||
|
|
||||||
|
const directlyBelow = overlapping.filter(other => {
|
||||||
|
const otherLink = stackLinks.get(other.id);
|
||||||
|
return otherLink && otherLink.stackLevel === currentLink.stackLevel - 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (directlyBelow.length > 0) {
|
||||||
|
// Use the first one in sorted order as prev
|
||||||
|
currentLink.prev = directlyBelow[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find overlapping events that are directly above (stackLevel + 1)
|
||||||
|
const directlyAbove = overlapping.filter(other => {
|
||||||
|
const otherLink = stackLinks.get(other.id);
|
||||||
|
return otherLink && otherLink.stackLevel === currentLink.stackLevel + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (directlyAbove.length > 0) {
|
||||||
|
// Use the first one in sorted order as next
|
||||||
|
currentLink.next = directlyAbove[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stackLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate marginLeft based on stack level
|
||||||
|
*/
|
||||||
|
public calculateMarginLeft(stackLevel: number): number {
|
||||||
|
return stackLevel * EventStackManager.STACK_OFFSET_PX;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate zIndex based on stack level
|
||||||
|
*/
|
||||||
|
public calculateZIndex(stackLevel: number): number {
|
||||||
|
return 100 + stackLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize stack link to JSON string
|
||||||
|
*/
|
||||||
|
public serializeStackLink(stackLink: StackLink): string {
|
||||||
|
return JSON.stringify(stackLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize JSON string to stack link
|
||||||
|
*/
|
||||||
|
public deserializeStackLink(json: string): StackLink | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(json);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply stack link to DOM element
|
||||||
|
*/
|
||||||
|
public applyStackLinkToElement(element: HTMLElement, stackLink: StackLink): void {
|
||||||
|
element.dataset.stackLink = this.serializeStackLink(stackLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stack link from DOM element
|
||||||
|
*/
|
||||||
|
public getStackLinkFromElement(element: HTMLElement): StackLink | null {
|
||||||
|
const data = element.dataset.stackLink;
|
||||||
|
if (!data) return null;
|
||||||
|
return this.deserializeStackLink(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply visual styling to element based on stack level
|
||||||
|
*/
|
||||||
|
public applyVisualStyling(element: HTMLElement, stackLevel: number): void {
|
||||||
|
element.style.marginLeft = `${this.calculateMarginLeft(stackLevel)}px`;
|
||||||
|
element.style.zIndex = `${this.calculateZIndex(stackLevel)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear stack link from element
|
||||||
|
*/
|
||||||
|
public clearStackLinkFromElement(element: HTMLElement): void {
|
||||||
|
delete element.dataset.stackLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear visual styling from element
|
||||||
|
*/
|
||||||
|
public clearVisualStyling(element: HTMLElement): void {
|
||||||
|
element.style.marginLeft = '';
|
||||||
|
element.style.zIndex = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import { PositionUtils } from '../utils/PositionUtils';
|
||||||
import { ColumnBounds } from '../utils/ColumnDetectionUtils';
|
import { ColumnBounds } from '../utils/ColumnDetectionUtils';
|
||||||
import { DragColumnChangeEventPayload, DragMoveEventPayload, DragStartEventPayload } from '../types/EventTypes';
|
import { DragColumnChangeEventPayload, DragMoveEventPayload, DragStartEventPayload } from '../types/EventTypes';
|
||||||
import { DateService } from '../utils/DateService';
|
import { DateService } from '../utils/DateService';
|
||||||
|
import { EventStackManager, EventGroup, StackLink } from '../managers/EventStackManager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for event rendering strategies
|
* Interface for event rendering strategies
|
||||||
|
|
@ -29,12 +30,14 @@ export interface EventRendererStrategy {
|
||||||
export class DateEventRenderer implements EventRendererStrategy {
|
export class DateEventRenderer implements EventRendererStrategy {
|
||||||
|
|
||||||
private dateService: DateService;
|
private dateService: DateService;
|
||||||
|
private stackManager: EventStackManager;
|
||||||
private draggedClone: HTMLElement | null = null;
|
private draggedClone: HTMLElement | null = null;
|
||||||
private originalEvent: HTMLElement | null = null;
|
private originalEvent: HTMLElement | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const timezone = calendarConfig.getTimezone?.();
|
const timezone = calendarConfig.getTimezone?.();
|
||||||
this.dateService = new DateService(timezone);
|
this.dateService = new DateService(timezone);
|
||||||
|
this.stackManager = new EventStackManager();
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyDragStyling(element: HTMLElement): void {
|
private applyDragStyling(element: HTMLElement): void {
|
||||||
|
|
@ -169,17 +172,176 @@ export class DateEventRenderer implements EventRendererStrategy {
|
||||||
|
|
||||||
columns.forEach(column => {
|
columns.forEach(column => {
|
||||||
const columnEvents = this.getEventsForColumn(column, timedEvents);
|
const columnEvents = this.getEventsForColumn(column, timedEvents);
|
||||||
const eventsLayer = column.querySelector('swp-events-layer');
|
const eventsLayer = column.querySelector('swp-events-layer') as HTMLElement;
|
||||||
|
|
||||||
if (eventsLayer) {
|
if (eventsLayer) {
|
||||||
// Simply render each event - no overlap handling
|
this.renderColumnEvents(columnEvents, eventsLayer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render events in a column using combined stacking + grid algorithm
|
||||||
|
*/
|
||||||
|
private renderColumnEvents(columnEvents: CalendarEvent[], eventsLayer: HTMLElement): void {
|
||||||
|
if (columnEvents.length === 0) return;
|
||||||
|
|
||||||
|
console.log('[EventRenderer] Rendering column with', columnEvents.length, 'events');
|
||||||
|
|
||||||
|
// Step 1: Calculate stack levels for ALL events first (to understand overlaps)
|
||||||
|
const allStackLinks = this.stackManager.createOptimizedStackLinks(columnEvents);
|
||||||
|
|
||||||
|
console.log('[EventRenderer] All stack links:');
|
||||||
columnEvents.forEach(event => {
|
columnEvents.forEach(event => {
|
||||||
|
const link = allStackLinks.get(event.id);
|
||||||
|
console.log(` Event ${event.id} (${event.title}): stackLevel=${link?.stackLevel ?? 'none'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Find grid candidates (start together ±15 min)
|
||||||
|
const groups = this.stackManager.groupEventsByStartTime(columnEvents);
|
||||||
|
const gridGroups = groups.filter(group => {
|
||||||
|
if (group.events.length <= 1) return false;
|
||||||
|
group.containerType = this.stackManager.decideContainerType(group);
|
||||||
|
return group.containerType === 'GRID';
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[EventRenderer] Grid groups:', gridGroups.length);
|
||||||
|
gridGroups.forEach((g, i) => {
|
||||||
|
console.log(` Grid group ${i}:`, g.events.map(e => e.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Render grid groups and track which events have been rendered
|
||||||
|
const renderedIds = new Set<string>();
|
||||||
|
|
||||||
|
gridGroups.forEach((group, index) => {
|
||||||
|
console.log(`[EventRenderer] Rendering grid group ${index} with ${group.events.length} events:`, group.events.map(e => e.id));
|
||||||
|
|
||||||
|
// Calculate grid group stack level by finding what it overlaps OUTSIDE the group
|
||||||
|
const gridStackLevel = this.calculateGridGroupStackLevel(group, columnEvents, allStackLinks);
|
||||||
|
|
||||||
|
console.log(` Grid group stack level: ${gridStackLevel}`);
|
||||||
|
|
||||||
|
this.renderGridGroup(group, eventsLayer, gridStackLevel);
|
||||||
|
group.events.forEach(e => renderedIds.add(e.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: Get remaining events (not in grid)
|
||||||
|
const remainingEvents = columnEvents.filter(e => !renderedIds.has(e.id));
|
||||||
|
|
||||||
|
console.log('[EventRenderer] Remaining events for stacking:');
|
||||||
|
remainingEvents.forEach(event => {
|
||||||
|
const link = allStackLinks.get(event.id);
|
||||||
|
console.log(` Event ${event.id} (${event.title}): stackLevel=${link?.stackLevel ?? 'none'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 5: Render remaining stacked/single events
|
||||||
|
remainingEvents.forEach(event => {
|
||||||
const element = this.renderEvent(event);
|
const element = this.renderEvent(event);
|
||||||
|
const stackLink = allStackLinks.get(event.id);
|
||||||
|
|
||||||
|
console.log(`[EventRenderer] Rendering stacked event ${event.id}, stackLink:`, stackLink);
|
||||||
|
|
||||||
|
if (stackLink) {
|
||||||
|
// Apply stack link to element (for drag-drop)
|
||||||
|
this.stackManager.applyStackLinkToElement(element, stackLink);
|
||||||
|
|
||||||
|
// Apply visual styling
|
||||||
|
this.stackManager.applyVisualStyling(element, stackLink.stackLevel);
|
||||||
|
console.log(` Applied margin-left: ${stackLink.stackLevel * 15}px, stack-link:`, stackLink);
|
||||||
|
}
|
||||||
|
|
||||||
eventsLayer.appendChild(element);
|
eventsLayer.appendChild(element);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate stack level for a grid group based on what it overlaps OUTSIDE the group
|
||||||
|
*/
|
||||||
|
private calculateGridGroupStackLevel(
|
||||||
|
group: EventGroup,
|
||||||
|
allEvents: CalendarEvent[],
|
||||||
|
stackLinks: Map<string, StackLink>
|
||||||
|
): number {
|
||||||
|
const groupEventIds = new Set(group.events.map(e => e.id));
|
||||||
|
|
||||||
|
// Find all events OUTSIDE this group
|
||||||
|
const outsideEvents = allEvents.filter(e => !groupEventIds.has(e.id));
|
||||||
|
|
||||||
|
// Find the highest stackLevel of any event that overlaps with ANY event in the grid group
|
||||||
|
let maxOverlappingLevel = -1;
|
||||||
|
|
||||||
|
for (const gridEvent of group.events) {
|
||||||
|
for (const outsideEvent of outsideEvents) {
|
||||||
|
if (this.stackManager.doEventsOverlap(gridEvent, outsideEvent)) {
|
||||||
|
const outsideLink = stackLinks.get(outsideEvent.id);
|
||||||
|
if (outsideLink) {
|
||||||
|
maxOverlappingLevel = Math.max(maxOverlappingLevel, outsideLink.stackLevel);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid group should be one level above the highest overlapping event
|
||||||
|
return maxOverlappingLevel + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render events in a grid container (side-by-side)
|
||||||
|
*/
|
||||||
|
private renderGridGroup(group: EventGroup, eventsLayer: HTMLElement, stackLevel: number): void {
|
||||||
|
const groupElement = document.createElement('swp-event-group');
|
||||||
|
|
||||||
|
// Add grid column class based on event count
|
||||||
|
const colCount = group.events.length;
|
||||||
|
groupElement.classList.add(`cols-${colCount}`);
|
||||||
|
|
||||||
|
// Add stack level class for margin-left offset
|
||||||
|
groupElement.classList.add(`stack-level-${stackLevel}`);
|
||||||
|
|
||||||
|
// Position based on earliest event
|
||||||
|
const earliestEvent = group.events[0];
|
||||||
|
const position = this.calculateEventPosition(earliestEvent);
|
||||||
|
groupElement.style.top = `${position.top + 1}px`;
|
||||||
|
|
||||||
|
// Add z-index based on stack level
|
||||||
|
groupElement.style.zIndex = `${this.stackManager.calculateZIndex(stackLevel)}`;
|
||||||
|
|
||||||
|
// Add stack-link attribute for drag-drop (group acts as a stacked item)
|
||||||
|
const stackLink: StackLink = {
|
||||||
|
stackLevel: stackLevel
|
||||||
|
// prev/next will be handled by drag-drop manager if needed
|
||||||
|
};
|
||||||
|
this.stackManager.applyStackLinkToElement(groupElement, stackLink);
|
||||||
|
|
||||||
|
// NO height on the group - it should auto-size based on children
|
||||||
|
|
||||||
|
// Render each event within the grid
|
||||||
|
group.events.forEach(event => {
|
||||||
|
const element = this.renderEventInGrid(event, earliestEvent.start);
|
||||||
|
groupElement.appendChild(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventsLayer.appendChild(groupElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render event within a grid container (relative positioning)
|
||||||
|
*/
|
||||||
|
private renderEventInGrid(event: CalendarEvent, containerStart: Date): HTMLElement {
|
||||||
|
const element = SwpEventElement.fromCalendarEvent(event);
|
||||||
|
|
||||||
|
// Calculate event height
|
||||||
|
const position = this.calculateEventPosition(event);
|
||||||
|
|
||||||
|
// Events in grid are positioned relatively - NO top offset needed
|
||||||
|
// The grid container itself is positioned absolutely with the correct top
|
||||||
|
element.style.position = 'relative';
|
||||||
|
element.style.height = `${position.height - 3}px`;
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private renderEvent(event: CalendarEvent): HTMLElement {
|
private renderEvent(event: CalendarEvent): HTMLElement {
|
||||||
const element = SwpEventElement.fromCalendarEvent(event);
|
const element = SwpEventElement.fromCalendarEvent(event);
|
||||||
|
|
|
||||||
1423
stacking-visualization.html
Normal file
1423
stacking-visualization.html
Normal file
File diff suppressed because it is too large
Load diff
1028
test/managers/EventStackManager.flexbox.test.ts
Normal file
1028
test/managers/EventStackManager.flexbox.test.ts
Normal file
File diff suppressed because it is too large
Load diff
653
test/managers/EventStackManager.test.ts
Normal file
653
test/managers/EventStackManager.test.ts
Normal file
|
|
@ -0,0 +1,653 @@
|
||||||
|
/**
|
||||||
|
* TDD Test Suite for EventStackManager
|
||||||
|
*
|
||||||
|
* This test suite follows Test-Driven Development principles:
|
||||||
|
* 1. Write a failing test (RED)
|
||||||
|
* 2. Write minimal code to make it pass (GREEN)
|
||||||
|
* 3. Refactor if needed (REFACTOR)
|
||||||
|
*
|
||||||
|
* @see STACKING_CONCEPT.md for concept documentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { EventStackManager, StackLink } from '../../src/managers/EventStackManager';
|
||||||
|
|
||||||
|
describe('EventStackManager - TDD Suite', () => {
|
||||||
|
let manager: EventStackManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = new EventStackManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Overlap Detection', () => {
|
||||||
|
it('should detect overlap when event A starts before event B ends and event A ends after event B starts', () => {
|
||||||
|
// RED - This test will fail initially
|
||||||
|
const eventA = {
|
||||||
|
id: 'event-a',
|
||||||
|
start: new Date('2025-01-01T09:00:00'),
|
||||||
|
end: new Date('2025-01-01T11:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventB = {
|
||||||
|
id: 'event-b',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T12:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expected: true (events overlap from 10:00 to 11:00)
|
||||||
|
expect(manager.doEventsOverlap(eventA, eventB)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when events do not overlap', () => {
|
||||||
|
const eventA = {
|
||||||
|
id: 'event-a',
|
||||||
|
start: new Date('2025-01-01T09:00:00'),
|
||||||
|
end: new Date('2025-01-01T10:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventB = {
|
||||||
|
id: 'event-b',
|
||||||
|
start: new Date('2025-01-01T11:00:00'),
|
||||||
|
end: new Date('2025-01-01T12:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(manager.doEventsOverlap(eventA, eventB)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect overlap when one event completely contains another', () => {
|
||||||
|
const eventA = {
|
||||||
|
id: 'event-a',
|
||||||
|
start: new Date('2025-01-01T09:00:00'),
|
||||||
|
end: new Date('2025-01-01T13:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventB = {
|
||||||
|
id: 'event-b',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T11:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(manager.doEventsOverlap(eventA, eventB)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when events touch but do not overlap', () => {
|
||||||
|
const eventA = {
|
||||||
|
id: 'event-a',
|
||||||
|
start: new Date('2025-01-01T09:00:00'),
|
||||||
|
end: new Date('2025-01-01T10:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventB = {
|
||||||
|
id: 'event-b',
|
||||||
|
start: new Date('2025-01-01T10:00:00'), // Exactly when A ends
|
||||||
|
end: new Date('2025-01-01T11:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(manager.doEventsOverlap(eventA, eventB)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Find Overlapping Events', () => {
|
||||||
|
it('should find all events that overlap with a given event', () => {
|
||||||
|
const targetEvent = {
|
||||||
|
id: 'target',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T11:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const columnEvents = [
|
||||||
|
{
|
||||||
|
id: 'event-a',
|
||||||
|
start: new Date('2025-01-01T09:00:00'),
|
||||||
|
end: new Date('2025-01-01T10:30:00') // Overlaps
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-b',
|
||||||
|
start: new Date('2025-01-01T12:00:00'),
|
||||||
|
end: new Date('2025-01-01T13:00:00') // Does not overlap
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-c',
|
||||||
|
start: new Date('2025-01-01T10:30:00'),
|
||||||
|
end: new Date('2025-01-01T11:30:00') // Overlaps
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const overlapping = manager.findOverlappingEvents(targetEvent, columnEvents);
|
||||||
|
|
||||||
|
expect(overlapping).toHaveLength(2);
|
||||||
|
expect(overlapping.map(e => e.id)).toContain('event-a');
|
||||||
|
expect(overlapping.map(e => e.id)).toContain('event-c');
|
||||||
|
expect(overlapping.map(e => e.id)).not.toContain('event-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no events overlap', () => {
|
||||||
|
const targetEvent = {
|
||||||
|
id: 'target',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T11:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const columnEvents = [
|
||||||
|
{
|
||||||
|
id: 'event-a',
|
||||||
|
start: new Date('2025-01-01T09:00:00'),
|
||||||
|
end: new Date('2025-01-01T09:30:00')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-b',
|
||||||
|
start: new Date('2025-01-01T12:00:00'),
|
||||||
|
end: new Date('2025-01-01T13:00:00')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const overlapping = manager.findOverlappingEvents(targetEvent, columnEvents);
|
||||||
|
|
||||||
|
expect(overlapping).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Create Stack Links', () => {
|
||||||
|
it('should create stack links for overlapping events sorted by start time', () => {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-b',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T12:00:00')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-a',
|
||||||
|
start: new Date('2025-01-01T09:00:00'),
|
||||||
|
end: new Date('2025-01-01T11:00:00')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-c',
|
||||||
|
start: new Date('2025-01-01T11:00:00'),
|
||||||
|
end: new Date('2025-01-01T13:00:00')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const stackLinks = manager.createStackLinks(events);
|
||||||
|
|
||||||
|
// Should be sorted by start time: event-a, event-b, event-c
|
||||||
|
expect(stackLinks.size).toBe(3);
|
||||||
|
|
||||||
|
const linkA = stackLinks.get('event-a');
|
||||||
|
expect(linkA).toEqual({
|
||||||
|
stackLevel: 0,
|
||||||
|
next: 'event-b'
|
||||||
|
// no prev
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkB = stackLinks.get('event-b');
|
||||||
|
expect(linkB).toEqual({
|
||||||
|
stackLevel: 1,
|
||||||
|
prev: 'event-a',
|
||||||
|
next: 'event-c'
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkC = stackLinks.get('event-c');
|
||||||
|
expect(linkC).toEqual({
|
||||||
|
stackLevel: 2,
|
||||||
|
prev: 'event-b'
|
||||||
|
// no next
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty map for empty event array', () => {
|
||||||
|
const stackLinks = manager.createStackLinks([]);
|
||||||
|
|
||||||
|
expect(stackLinks.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create single stack link for single event', () => {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-a',
|
||||||
|
start: new Date('2025-01-01T09:00:00'),
|
||||||
|
end: new Date('2025-01-01T10:00:00')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const stackLinks = manager.createStackLinks(events);
|
||||||
|
|
||||||
|
expect(stackLinks.size).toBe(1);
|
||||||
|
|
||||||
|
const link = stackLinks.get('event-a');
|
||||||
|
expect(link).toEqual({
|
||||||
|
stackLevel: 0
|
||||||
|
// no prev, no next
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle events with same start time by sorting by end time', () => {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-b',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T12:00:00') // Longer event
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-a',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T11:00:00') // Shorter event (should come first)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const stackLinks = manager.createStackLinks(events);
|
||||||
|
|
||||||
|
// Shorter event should have lower stack level
|
||||||
|
expect(stackLinks.get('event-a')?.stackLevel).toBe(0);
|
||||||
|
expect(stackLinks.get('event-b')?.stackLevel).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Calculate Visual Styling', () => {
|
||||||
|
it('should calculate marginLeft based on stack level', () => {
|
||||||
|
const stackLevel = 0;
|
||||||
|
expect(manager.calculateMarginLeft(stackLevel)).toBe(0);
|
||||||
|
|
||||||
|
const stackLevel1 = 1;
|
||||||
|
expect(manager.calculateMarginLeft(stackLevel1)).toBe(15);
|
||||||
|
|
||||||
|
const stackLevel2 = 2;
|
||||||
|
expect(manager.calculateMarginLeft(stackLevel2)).toBe(30);
|
||||||
|
|
||||||
|
const stackLevel5 = 5;
|
||||||
|
expect(manager.calculateMarginLeft(stackLevel5)).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate zIndex based on stack level', () => {
|
||||||
|
const stackLevel = 0;
|
||||||
|
expect(manager.calculateZIndex(stackLevel)).toBe(100);
|
||||||
|
|
||||||
|
const stackLevel1 = 1;
|
||||||
|
expect(manager.calculateZIndex(stackLevel1)).toBe(101);
|
||||||
|
|
||||||
|
const stackLevel2 = 2;
|
||||||
|
expect(manager.calculateZIndex(stackLevel2)).toBe(102);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Stack Link Serialization', () => {
|
||||||
|
it('should serialize stack link to JSON string', () => {
|
||||||
|
const stackLink: StackLink = {
|
||||||
|
stackLevel: 1,
|
||||||
|
prev: 'event-a',
|
||||||
|
next: 'event-c'
|
||||||
|
};
|
||||||
|
|
||||||
|
const serialized = manager.serializeStackLink(stackLink);
|
||||||
|
|
||||||
|
expect(serialized).toBe('{"stackLevel":1,"prev":"event-a","next":"event-c"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deserialize JSON string to stack link', () => {
|
||||||
|
const json = '{"stackLevel":1,"prev":"event-a","next":"event-c"}';
|
||||||
|
|
||||||
|
const stackLink = manager.deserializeStackLink(json);
|
||||||
|
|
||||||
|
expect(stackLink).toEqual({
|
||||||
|
stackLevel: 1,
|
||||||
|
prev: 'event-a',
|
||||||
|
next: 'event-c'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle stack link without prev/next', () => {
|
||||||
|
const stackLink: StackLink = {
|
||||||
|
stackLevel: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const serialized = manager.serializeStackLink(stackLink);
|
||||||
|
const deserialized = manager.deserializeStackLink(serialized);
|
||||||
|
|
||||||
|
expect(deserialized).toEqual({
|
||||||
|
stackLevel: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when deserializing invalid JSON', () => {
|
||||||
|
const invalid = 'not-valid-json';
|
||||||
|
|
||||||
|
const result = manager.deserializeStackLink(invalid);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DOM Integration', () => {
|
||||||
|
it('should apply stack link to DOM element', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.dataset.eventId = 'event-a';
|
||||||
|
|
||||||
|
const stackLink: StackLink = {
|
||||||
|
stackLevel: 1,
|
||||||
|
prev: 'event-b',
|
||||||
|
next: 'event-c'
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.applyStackLinkToElement(element, stackLink);
|
||||||
|
|
||||||
|
expect(element.dataset.stackLink).toBe('{"stackLevel":1,"prev":"event-b","next":"event-c"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should read stack link from DOM element', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.dataset.stackLink = '{"stackLevel":2,"prev":"event-a"}';
|
||||||
|
|
||||||
|
const stackLink = manager.getStackLinkFromElement(element);
|
||||||
|
|
||||||
|
expect(stackLink).toEqual({
|
||||||
|
stackLevel: 2,
|
||||||
|
prev: 'event-a'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when element has no stack link', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
|
||||||
|
const stackLink = manager.getStackLinkFromElement(element);
|
||||||
|
|
||||||
|
expect(stackLink).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply visual styling to element based on stack level', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
|
||||||
|
manager.applyVisualStyling(element, 2);
|
||||||
|
|
||||||
|
expect(element.style.marginLeft).toBe('30px');
|
||||||
|
expect(element.style.zIndex).toBe('102');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear stack link from element', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.dataset.stackLink = '{"stackLevel":1}';
|
||||||
|
|
||||||
|
manager.clearStackLinkFromElement(element);
|
||||||
|
|
||||||
|
expect(element.dataset.stackLink).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear visual styling from element', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.style.marginLeft = '30px';
|
||||||
|
element.style.zIndex = '102';
|
||||||
|
|
||||||
|
manager.clearVisualStyling(element);
|
||||||
|
|
||||||
|
expect(element.style.marginLeft).toBe('');
|
||||||
|
expect(element.style.zIndex).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should optimize stack levels when events do not overlap each other but both overlap a parent event', () => {
|
||||||
|
// Visual representation:
|
||||||
|
// Event A: 09:00 ════════════════════════════ 14:00
|
||||||
|
// Event B: 10:00 ═════ 12:00
|
||||||
|
// Event C: 12:30 ═══ 13:00
|
||||||
|
//
|
||||||
|
// Expected stacking:
|
||||||
|
// Event A: stackLevel 0 (base)
|
||||||
|
// Event B: stackLevel 1 (conflicts with A)
|
||||||
|
// Event C: stackLevel 1 (conflicts with A, but NOT with B - can share same level!)
|
||||||
|
|
||||||
|
const eventA = {
|
||||||
|
id: 'event-a',
|
||||||
|
start: new Date('2025-01-01T09:00:00'),
|
||||||
|
end: new Date('2025-01-01T14:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventB = {
|
||||||
|
id: 'event-b',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T12:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventC = {
|
||||||
|
id: 'event-c',
|
||||||
|
start: new Date('2025-01-01T12:30:00'),
|
||||||
|
end: new Date('2025-01-01T13:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const stackLinks = manager.createOptimizedStackLinks([eventA, eventB, eventC]);
|
||||||
|
|
||||||
|
expect(stackLinks.size).toBe(3);
|
||||||
|
|
||||||
|
// Event A is the base (contains both B and C)
|
||||||
|
expect(stackLinks.get('event-a')?.stackLevel).toBe(0);
|
||||||
|
|
||||||
|
// Event B and C should both be at stackLevel 1 (they don't overlap each other)
|
||||||
|
expect(stackLinks.get('event-b')?.stackLevel).toBe(1);
|
||||||
|
expect(stackLinks.get('event-c')?.stackLevel).toBe(1);
|
||||||
|
|
||||||
|
// Verify they are NOT linked to each other (no prev/next between B and C)
|
||||||
|
expect(stackLinks.get('event-b')?.next).toBeUndefined();
|
||||||
|
expect(stackLinks.get('event-c')?.prev).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create multiple parallel tracks when events at same level do not overlap', () => {
|
||||||
|
// Complex scenario with multiple parallel tracks:
|
||||||
|
// Event A: 09:00 ════════════════════════════════════ 15:00
|
||||||
|
// Event B: 10:00 ═══ 11:00
|
||||||
|
// Event C: 11:30 ═══ 12:30
|
||||||
|
// Event D: 13:00 ═══ 14:00
|
||||||
|
//
|
||||||
|
// Expected:
|
||||||
|
// - A at level 0 (base)
|
||||||
|
// - B, C, D all at level 1 (they don't overlap each other, only with A)
|
||||||
|
|
||||||
|
const eventA = {
|
||||||
|
id: 'event-a',
|
||||||
|
start: new Date('2025-01-01T09:00:00'),
|
||||||
|
end: new Date('2025-01-01T15:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventB = {
|
||||||
|
id: 'event-b',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T11:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventC = {
|
||||||
|
id: 'event-c',
|
||||||
|
start: new Date('2025-01-01T11:30:00'),
|
||||||
|
end: new Date('2025-01-01T12:30:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventD = {
|
||||||
|
id: 'event-d',
|
||||||
|
start: new Date('2025-01-01T13:00:00'),
|
||||||
|
end: new Date('2025-01-01T14:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const stackLinks = manager.createOptimizedStackLinks([eventA, eventB, eventC, eventD]);
|
||||||
|
|
||||||
|
expect(stackLinks.size).toBe(4);
|
||||||
|
expect(stackLinks.get('event-a')?.stackLevel).toBe(0);
|
||||||
|
expect(stackLinks.get('event-b')?.stackLevel).toBe(1);
|
||||||
|
expect(stackLinks.get('event-c')?.stackLevel).toBe(1);
|
||||||
|
expect(stackLinks.get('event-d')?.stackLevel).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested overlaps with optimal stacking', () => {
|
||||||
|
// Scenario:
|
||||||
|
// Event A: 09:00 ════════════════════════════════════ 15:00
|
||||||
|
// Event B: 10:00 ════════════════════ 13:00
|
||||||
|
// Event C: 11:00 ═══ 12:00
|
||||||
|
// Event D: 12:30 ═══ 13:30
|
||||||
|
//
|
||||||
|
// Expected:
|
||||||
|
// - A at level 0 (base, contains all)
|
||||||
|
// - B at level 1 (overlaps with A)
|
||||||
|
// - C at level 2 (overlaps with A and B)
|
||||||
|
// - D at level 2 (overlaps with A and B, but NOT with C - can share level with C)
|
||||||
|
|
||||||
|
const eventA = {
|
||||||
|
id: 'event-a',
|
||||||
|
start: new Date('2025-01-01T09:00:00'),
|
||||||
|
end: new Date('2025-01-01T15:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventB = {
|
||||||
|
id: 'event-b',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T13:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventC = {
|
||||||
|
id: 'event-c',
|
||||||
|
start: new Date('2025-01-01T11:00:00'),
|
||||||
|
end: new Date('2025-01-01T12:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventD = {
|
||||||
|
id: 'event-d',
|
||||||
|
start: new Date('2025-01-01T12:30:00'),
|
||||||
|
end: new Date('2025-01-01T13:30:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const stackLinks = manager.createOptimizedStackLinks([eventA, eventB, eventC, eventD]);
|
||||||
|
|
||||||
|
expect(stackLinks.size).toBe(4);
|
||||||
|
expect(stackLinks.get('event-a')?.stackLevel).toBe(0);
|
||||||
|
expect(stackLinks.get('event-b')?.stackLevel).toBe(1);
|
||||||
|
expect(stackLinks.get('event-c')?.stackLevel).toBe(2);
|
||||||
|
expect(stackLinks.get('event-d')?.stackLevel).toBe(2); // Can share level with C
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle events with identical start and end times', () => {
|
||||||
|
const eventA = {
|
||||||
|
id: 'event-a',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T11:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventB = {
|
||||||
|
id: 'event-b',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T11:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(manager.doEventsOverlap(eventA, eventB)).toBe(true);
|
||||||
|
|
||||||
|
const stackLinks = manager.createStackLinks([eventA, eventB]);
|
||||||
|
expect(stackLinks.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle events with zero duration', () => {
|
||||||
|
const eventA = {
|
||||||
|
id: 'event-a',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T10:00:00') // Zero duration
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventB = {
|
||||||
|
id: 'event-b',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T11:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zero-duration event should not overlap
|
||||||
|
expect(manager.doEventsOverlap(eventA, eventB)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large number of overlapping events', () => {
|
||||||
|
const events = Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
id: `event-${i}`,
|
||||||
|
start: new Date('2025-01-01T09:00:00'),
|
||||||
|
end: new Date(`2025-01-01T${10 + i}:00:00`)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const stackLinks = manager.createStackLinks(events);
|
||||||
|
|
||||||
|
expect(stackLinks.size).toBe(100);
|
||||||
|
expect(stackLinks.get('event-0')?.stackLevel).toBe(0);
|
||||||
|
expect(stackLinks.get('event-99')?.stackLevel).toBe(99);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration Tests', () => {
|
||||||
|
it('should create complete stack for new event with overlapping events', () => {
|
||||||
|
// Scenario: Adding new event that overlaps with existing events
|
||||||
|
const newEvent = {
|
||||||
|
id: 'new-event',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T11:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingEvents = [
|
||||||
|
{
|
||||||
|
id: 'existing-a',
|
||||||
|
start: new Date('2025-01-01T09:00:00'),
|
||||||
|
end: new Date('2025-01-01T10:30:00')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'existing-b',
|
||||||
|
start: new Date('2025-01-01T10:30:00'),
|
||||||
|
end: new Date('2025-01-01T12:00:00')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Find overlapping
|
||||||
|
const overlapping = manager.findOverlappingEvents(newEvent, existingEvents);
|
||||||
|
|
||||||
|
// Create stack links for all events
|
||||||
|
const allEvents = [...overlapping, newEvent];
|
||||||
|
const stackLinks = manager.createStackLinks(allEvents);
|
||||||
|
|
||||||
|
// Verify complete stack
|
||||||
|
expect(stackLinks.size).toBe(3);
|
||||||
|
expect(stackLinks.get('existing-a')?.stackLevel).toBe(0);
|
||||||
|
expect(stackLinks.get('new-event')?.stackLevel).toBe(1);
|
||||||
|
expect(stackLinks.get('existing-b')?.stackLevel).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complete workflow: detect, create, apply to DOM', () => {
|
||||||
|
const newEvent = {
|
||||||
|
id: 'new-event',
|
||||||
|
start: new Date('2025-01-01T10:00:00'),
|
||||||
|
end: new Date('2025-01-01T11:00:00')
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingEvents = [
|
||||||
|
{
|
||||||
|
id: 'existing-a',
|
||||||
|
start: new Date('2025-01-01T09:00:00'),
|
||||||
|
end: new Date('2025-01-01T10:30:00')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Step 1: Find overlapping
|
||||||
|
const overlapping = manager.findOverlappingEvents(newEvent, existingEvents);
|
||||||
|
expect(overlapping).toHaveLength(1);
|
||||||
|
|
||||||
|
// Step 2: Create stack links
|
||||||
|
const allEvents = [...overlapping, newEvent];
|
||||||
|
const stackLinks = manager.createStackLinks(allEvents);
|
||||||
|
expect(stackLinks.size).toBe(2);
|
||||||
|
|
||||||
|
// Step 3: Apply to DOM
|
||||||
|
const elementA = document.createElement('div');
|
||||||
|
elementA.dataset.eventId = 'existing-a';
|
||||||
|
|
||||||
|
const elementNew = document.createElement('div');
|
||||||
|
elementNew.dataset.eventId = 'new-event';
|
||||||
|
|
||||||
|
manager.applyStackLinkToElement(elementA, stackLinks.get('existing-a')!);
|
||||||
|
manager.applyStackLinkToElement(elementNew, stackLinks.get('new-event')!);
|
||||||
|
|
||||||
|
manager.applyVisualStyling(elementA, stackLinks.get('existing-a')!.stackLevel);
|
||||||
|
manager.applyVisualStyling(elementNew, stackLinks.get('new-event')!.stackLevel);
|
||||||
|
|
||||||
|
// Verify DOM state
|
||||||
|
expect(elementA.dataset.stackLink).toContain('"stackLevel":0');
|
||||||
|
expect(elementA.style.marginLeft).toBe('0px');
|
||||||
|
|
||||||
|
expect(elementNew.dataset.stackLink).toContain('"stackLevel":1');
|
||||||
|
expect(elementNew.style.marginLeft).toBe('15px');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -232,19 +232,52 @@ swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"] {
|
||||||
/* Event group container for column sharing */
|
/* Event group container for column sharing */
|
||||||
swp-event-group {
|
swp-event-group {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 1px;
|
gap: 2px;
|
||||||
width: calc(100% - 4px);
|
|
||||||
left: 2px;
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Grid column configurations */
|
||||||
|
swp-event-group.cols-2 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-event-group.cols-3 {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-event-group.cols-4 {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack levels using margin-left */
|
||||||
|
swp-event-group.stack-level-0 {
|
||||||
|
margin-left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-event-group.stack-level-1 {
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-event-group.stack-level-2 {
|
||||||
|
margin-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-event-group.stack-level-3 {
|
||||||
|
margin-left: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-event-group.stack-level-4 {
|
||||||
|
margin-left: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Child events within grid */
|
||||||
swp-event-group swp-event {
|
swp-event-group swp-event {
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* All-day event transition for smooth repositioning */
|
/* All-day event transition for smooth repositioning */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue