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:
Janus C. H. Knudsen 2025-10-05 23:54:50 +02:00
parent 57bf122675
commit 2f58ceccd4
8 changed files with 4509 additions and 14 deletions

View file

@ -1962,6 +1962,58 @@
"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",
"title": "Team Standup",
@ -1991,8 +2043,8 @@
{
"id": "154",
"title": "Bug Fixing Session",
"start": "2025-10-02T11:00:00Z",
"end": "2025-10-02T13:00:00Z",
"start": "2025-10-02T07:00:00Z",
"end": "2025-10-02T09:00:00Z",
"type": "work",
"allDay": false,
"syncStatus": "synced",

View 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 = '';
}
}

View file

@ -7,6 +7,7 @@ import { PositionUtils } from '../utils/PositionUtils';
import { ColumnBounds } from '../utils/ColumnDetectionUtils';
import { DragColumnChangeEventPayload, DragMoveEventPayload, DragStartEventPayload } from '../types/EventTypes';
import { DateService } from '../utils/DateService';
import { EventStackManager, EventGroup, StackLink } from '../managers/EventStackManager';
/**
* Interface for event rendering strategies
@ -29,12 +30,14 @@ export interface EventRendererStrategy {
export class DateEventRenderer implements EventRendererStrategy {
private dateService: DateService;
private stackManager: EventStackManager;
private draggedClone: HTMLElement | null = null;
private originalEvent: HTMLElement | null = null;
constructor() {
const timezone = calendarConfig.getTimezone?.();
this.dateService = new DateService(timezone);
this.stackManager = new EventStackManager();
}
private applyDragStyling(element: HTMLElement): void {
@ -169,18 +172,177 @@ export class DateEventRenderer implements EventRendererStrategy {
columns.forEach(column => {
const columnEvents = this.getEventsForColumn(column, timedEvents);
const eventsLayer = column.querySelector('swp-events-layer');
const eventsLayer = column.querySelector('swp-events-layer') as HTMLElement;
if (eventsLayer) {
// Simply render each event - no overlap handling
columnEvents.forEach(event => {
const element = this.renderEvent(event);
eventsLayer.appendChild(element);
});
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 => {
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 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);
});
}
/**
* 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 {
const element = SwpEventElement.fromCalendarEvent(event);