Enhances event layout engine with advanced rendering logic

Introduces sophisticated event layout algorithm for handling complex scheduling scenarios

Adds support for:
- Grid and stacked event rendering
- Automatic column allocation
- Nested event stacking
- Threshold-based event grouping

Improves visual representation of overlapping and concurrent events
This commit is contained in:
Janus C. H. Knudsen 2025-12-11 18:11:11 +01:00
parent 4e22fbc948
commit 70172e8f10
26 changed files with 2108 additions and 44 deletions

View file

@ -0,0 +1,258 @@
import { describe, it, expect } from 'vitest';
import { calculateColumnLayout, eventsOverlap } from '../../src/v2/features/event/EventLayoutEngine';
import { ICalendarEvent } from '../../src/v2/types/CalendarTypes';
import { IGridConfig } from '../../src/v2/core/IGridConfig';
// Helper to create test events
function createEvent(id: string, startHour: number, startMin: number, endHour: number, endMin: number): ICalendarEvent {
const baseDate = new Date('2025-10-06');
const start = new Date(baseDate);
start.setHours(startHour, startMin, 0, 0);
const end = new Date(baseDate);
end.setHours(endHour, endMin, 0, 0);
return {
id,
title: `Event ${id}`,
start,
end,
type: 'work',
allDay: false
};
}
const gridConfig: IGridConfig = {
hourHeight: 60,
dayStartHour: 8,
dayEndHour: 20,
snapInterval: 15,
gridStartThresholdMinutes: 30 // Match calendar-config.json
};
describe('EventLayoutEngine', () => {
describe('eventsOverlap', () => {
it('should return true for overlapping events', () => {
const a = createEvent('a', 10, 0, 11, 0);
const b = createEvent('b', 10, 30, 11, 30);
expect(eventsOverlap(a, b)).toBe(true);
});
it('should return false for edge-adjacent events (end === start)', () => {
const a = createEvent('a', 10, 0, 11, 0);
const b = createEvent('b', 11, 0, 12, 0);
expect(eventsOverlap(a, b)).toBe(false);
});
it('should return false for non-overlapping events', () => {
const a = createEvent('a', 10, 0, 11, 0);
const b = createEvent('b', 12, 0, 13, 0);
expect(eventsOverlap(a, b)).toBe(false);
});
});
describe('Scenario 1: No Overlap', () => {
it('should assign stackLevel=0 to all sequential non-overlapping events', () => {
const events = [
createEvent('S1A', 10, 0, 11, 0),
createEvent('S1B', 11, 0, 12, 0),
createEvent('S1C', 12, 0, 13, 0)
];
const layout = calculateColumnLayout(events, gridConfig);
expect(layout.grids).toHaveLength(0);
expect(layout.stacked).toHaveLength(3);
const levels = layout.stacked.map(s => ({ id: s.event.id, level: s.stackLevel }));
expect(levels).toContainEqual({ id: 'S1A', level: 0 });
expect(levels).toContainEqual({ id: 'S1B', level: 0 });
expect(levels).toContainEqual({ id: 'S1C', level: 0 });
});
});
describe('Scenario 2: Column Sharing (Grid)', () => {
it('should create grid with 2 columns for simultaneous events', () => {
const events = [
createEvent('S2A', 10, 0, 11, 0),
createEvent('S2B', 10, 0, 11, 0)
];
const layout = calculateColumnLayout(events, gridConfig);
expect(layout.stacked).toHaveLength(0);
expect(layout.grids).toHaveLength(1);
const grid = layout.grids[0];
expect(grid.columns).toHaveLength(2);
expect(grid.stackLevel).toBe(0);
});
});
describe('Scenario 3: Nested Stacking', () => {
it('should calculate progressive stack levels for nested events', () => {
// A: 09:00-15:00, B: 10:00-13:00, C: 11:00-12:00, D: 12:30-13:30
const events = [
createEvent('S3A', 9, 0, 15, 0),
createEvent('S3B', 10, 0, 13, 0),
createEvent('S3C', 11, 0, 12, 0),
createEvent('S3D', 12, 30, 13, 30)
];
const layout = calculateColumnLayout(events, gridConfig);
expect(layout.grids).toHaveLength(0);
expect(layout.stacked).toHaveLength(4);
const getLevel = (id: string) => layout.stacked.find(s => s.event.id === id)?.stackLevel;
expect(getLevel('S3A')).toBe(0);
expect(getLevel('S3B')).toBe(1);
expect(getLevel('S3C')).toBe(2);
expect(getLevel('S3D')).toBe(2);
});
});
describe('Scenario 4: Complex Stacking', () => {
it('should handle long event with multiple nested events at different times', () => {
// A: 14:00-20:00, B: 15:00-17:00, C: 15:30-16:30, D: 18:00-19:00
const events = [
createEvent('S4A', 14, 0, 20, 0),
createEvent('S4B', 15, 0, 17, 0),
createEvent('S4C', 15, 30, 16, 30),
createEvent('S4D', 18, 0, 19, 0)
];
const layout = calculateColumnLayout(events, gridConfig);
expect(layout.grids).toHaveLength(0);
expect(layout.stacked).toHaveLength(4);
const getLevel = (id: string) => layout.stacked.find(s => s.event.id === id)?.stackLevel;
expect(getLevel('S4A')).toBe(0);
expect(getLevel('S4B')).toBe(1);
expect(getLevel('S4C')).toBe(2);
expect(getLevel('S4D')).toBe(1);
});
});
describe('Scenario 5: Three Column Share', () => {
it('should create grid with 3 columns for 3 simultaneous events', () => {
const events = [
createEvent('S5A', 10, 0, 11, 0),
createEvent('S5B', 10, 0, 11, 0),
createEvent('S5C', 10, 0, 11, 0)
];
const layout = calculateColumnLayout(events, gridConfig);
expect(layout.stacked).toHaveLength(0);
expect(layout.grids).toHaveLength(1);
const grid = layout.grids[0];
expect(grid.columns).toHaveLength(3);
expect(grid.stackLevel).toBe(0);
});
});
describe('Scenario 6: Overlapping Pairs', () => {
it('should handle two independent pairs of overlapping events', () => {
// Pair 1: A (10:00-12:00), B (11:00-12:00)
// Pair 2: C (13:00-15:00), D (14:00-15:00)
const events = [
createEvent('S6A', 10, 0, 12, 0),
createEvent('S6B', 11, 0, 12, 0),
createEvent('S6C', 13, 0, 15, 0),
createEvent('S6D', 14, 0, 15, 0)
];
const layout = calculateColumnLayout(events, gridConfig);
expect(layout.grids).toHaveLength(0);
expect(layout.stacked).toHaveLength(4);
const getLevel = (id: string) => layout.stacked.find(s => s.event.id === id)?.stackLevel;
expect(getLevel('S6A')).toBe(0);
expect(getLevel('S6B')).toBe(1);
expect(getLevel('S6C')).toBe(0);
expect(getLevel('S6D')).toBe(1);
});
});
describe('Scenario 7: Long Event Container', () => {
it('should assign same level to non-overlapping events inside container', () => {
// A: 09:00-15:00 (container), B: 10:00-11:00, C: 12:00-13:00
const events = [
createEvent('S7A', 9, 0, 15, 0),
createEvent('S7B', 10, 0, 11, 0),
createEvent('S7C', 12, 0, 13, 0)
];
const layout = calculateColumnLayout(events, gridConfig);
expect(layout.grids).toHaveLength(0);
expect(layout.stacked).toHaveLength(3);
const getLevel = (id: string) => layout.stacked.find(s => s.event.id === id)?.stackLevel;
expect(getLevel('S7A')).toBe(0);
expect(getLevel('S7B')).toBe(1);
expect(getLevel('S7C')).toBe(1);
});
});
describe('Scenario 8: Edge-Adjacent Events', () => {
it('should not stack events that touch at boundary (end === start)', () => {
const events = [
createEvent('S8A', 10, 0, 11, 0),
createEvent('S8B', 11, 0, 12, 0)
];
const layout = calculateColumnLayout(events, gridConfig);
expect(layout.grids).toHaveLength(0);
expect(layout.stacked).toHaveLength(2);
const getLevel = (id: string) => layout.stacked.find(s => s.event.id === id)?.stackLevel;
expect(getLevel('S8A')).toBe(0);
expect(getLevel('S8B')).toBe(0);
});
});
describe('Scenario 9: End-to-Start Chain', () => {
it('should create grid for events connected through conflict chain', () => {
// A: 12:00-13:00, B: 12:30-13:30, C: 13:15-15:00
// A overlaps B, B overlaps C, but they're within threshold -> GRID
const events = [
createEvent('S9A', 12, 0, 13, 0),
createEvent('S9B', 12, 30, 13, 30),
createEvent('S9C', 13, 15, 15, 0)
];
const layout = calculateColumnLayout(events, gridConfig);
// This should create a grid because events are within threshold
expect(layout.grids).toHaveLength(1);
expect(layout.grids[0].columns).toHaveLength(2);
expect(layout.grids[0].events).toHaveLength(3);
});
});
describe('Scenario 10: Four Column Grid', () => {
it('should create grid with 4 columns for 4 simultaneous events', () => {
const events = [
createEvent('S10A', 14, 0, 15, 0),
createEvent('S10B', 14, 0, 15, 0),
createEvent('S10C', 14, 0, 15, 0),
createEvent('S10D', 14, 0, 15, 0)
];
const layout = calculateColumnLayout(events, gridConfig);
expect(layout.stacked).toHaveLength(0);
expect(layout.grids).toHaveLength(1);
const grid = layout.grids[0];
expect(grid.columns).toHaveLength(4);
expect(grid.stackLevel).toBe(0);
});
});
});