Improves all-day event layout calculation

Refactors all-day event rendering to use a layout engine
for overlap detection and positioning, ensuring events
are placed in available rows and columns.

Removes deprecated method and adds unit tests.
This commit is contained in:
Janus C. H. Knudsen 2025-09-25 23:38:17 +02:00
parent 274753936e
commit a624394ffb
11 changed files with 2898 additions and 145 deletions

View file

@ -0,0 +1,166 @@
/**
* AllDayLayoutEngine - Pure data-driven layout calculation for all-day events
*/
import { CalendarEvent } from '../types/CalendarTypes';
export interface EventLayout {
id: string;
gridArea: string; // "row-start / col-start / row-end / col-end"
startColumn: number;
endColumn: number;
row: number;
columnSpan: number;
}
export class AllDayLayoutEngine {
private weekDates: string[];
constructor(weekDates: string[]) {
this.weekDates = weekDates;
}
/**
* Calculate layout for all events with proper overlap detection
*/
public calculateLayout(events: CalendarEvent[]): Map<string, EventLayout> {
const layouts = new Map<string, EventLayout>();
// Sort by event duration (longest first), then by start date
const sortedEvents = [...events].sort((a, b) => {
const durationA = this.calculateEventDuration(a);
const durationB = this.calculateEventDuration(b);
// Primary sort: longest duration first
if (durationA !== durationB) {
return durationB - durationA;
}
// Secondary sort: earliest start date first
const startA = a.start.toISOString().split('T')[0];
const startB = b.start.toISOString().split('T')[0];
return startA.localeCompare(startB);
});
sortedEvents.forEach(event => {
const layout = this.calculateEventLayout(event, layouts);
layouts.set(event.id, layout);
});
return layouts;
}
/**
* Calculate event duration in days
*/
private calculateEventDuration(event: CalendarEvent): number {
const startDate = event.start;
const endDate = event.end;
const diffTime = endDate.getTime() - startDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; // +1 because same day = 1 day
return diffDays;
}
/**
* Calculate layout for single event considering existing events
*/
private calculateEventLayout(event: CalendarEvent, existingLayouts: Map<string, EventLayout>): EventLayout {
// Calculate column span
const { startColumn, endColumn, columnSpan } = this.calculateColumnSpan(event);
// Find available row using overlap detection
const availableRow = this.findAvailableRow(startColumn, endColumn, existingLayouts);
// Generate grid-area string: "row-start / col-start / row-end / col-end"
const gridArea = `${availableRow} / ${startColumn} / ${availableRow + 1} / ${endColumn + 1}`;
return {
id: event.id,
gridArea,
startColumn,
endColumn,
row: availableRow,
columnSpan
};
}
/**
* Calculate column span based on event start and end dates
*/
private calculateColumnSpan(event: CalendarEvent): { startColumn: number; endColumn: number; columnSpan: number } {
// Convert CalendarEvent dates to YYYY-MM-DD format
const startDate = event.start.toISOString().split('T')[0];
const endDate = event.end.toISOString().split('T')[0];
// Find start and end column indices (1-based)
let startColumn = -1;
let endColumn = -1;
this.weekDates.forEach((dateStr, index) => {
if (dateStr === startDate) {
startColumn = index + 1;
}
if (dateStr === endDate) {
endColumn = index + 1;
}
});
// Handle events that start before or end after the week
if (startColumn === -1) {
startColumn = 1; // Event starts before this week
}
if (endColumn === -1) {
endColumn = this.weekDates.length; // Event ends after this week
}
// Ensure end column is at least start column
if (endColumn < startColumn) {
endColumn = startColumn;
}
const columnSpan = endColumn - startColumn + 1;
return { startColumn, endColumn, columnSpan };
}
/**
* Find available row using overlap detection
*/
private findAvailableRow(
newStartColumn: number,
newEndColumn: number,
existingLayouts: Map<string, EventLayout>
): number {
const occupiedRows = new Set<number>();
// Check all existing events for overlaps
existingLayouts.forEach(layout => {
const overlaps = this.columnsOverlap(
newStartColumn, newEndColumn,
layout.startColumn, layout.endColumn
);
if (overlaps) {
occupiedRows.add(layout.row);
}
});
// Find first available row
let targetRow = 1;
while (occupiedRows.has(targetRow)) {
targetRow++;
}
return targetRow;
}
/**
* Check if two column ranges overlap
*/
private columnsOverlap(
startA: number, endA: number,
startB: number, endB: number
): boolean {
// Two ranges overlap if one doesn't end before the other starts
return !(endA < startB || endB < startA);
}
}