Calendar/STACKING_CONCEPT.md
Janus C. H. Knudsen 2f58ceccd4 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.
2025-10-05 23:54:50 +02:00

22 KiB

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 links create a doubly-linked list stored directly in DOM elements as data attributes.

Interface Definition

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:

<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

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

const STACK_OFFSET_PX = 15;

// For each event in stack:
marginLeft = stackLevel * STACK_OFFSET_PX;
zIndex = 100 + stackLevel;

Example with 3 Stacked Events

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:

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)

Important: With optimized stacking, events at the same stack level are NOT linked via prev/next!

// 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:

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

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

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

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