Improves all-day event layout calculation

Updates the all-day event layout engine for better event
rendering, especially when dealing with partial week views.

The layout engine now correctly clips events that start
before or end after the visible date range, ensuring that
only relevant portions of events are displayed.

It also fixes event ordering.

Includes new unit tests to validate date range filtering and
clipping logic.
This commit is contained in:
Janus C. H. Knudsen 2025-09-26 17:47:02 +02:00
parent a551bc59ff
commit 41d078e2e8
4 changed files with 277 additions and 265 deletions

View file

@ -223,7 +223,7 @@
"id": "23",
"title": "Summer Team Event",
"start": "2025-07-18T00:00:00",
"end": "2025-07-18T23:59:59",
"end": "2025-07-19T00:00:00",
"type": "meeting",
"allDay": true,
"syncStatus": "synced",
@ -463,7 +463,7 @@
"id": "47",
"title": "Company Holiday",
"start": "2025-08-04T00:00:00",
"end": "2025-08-05T23:59:59",
"end": "2025-08-06T00:00:00",
"type": "milestone",
"allDay": true,
"syncStatus": "synced",
@ -523,7 +523,7 @@
"id": "53",
"title": "Team Building Event",
"start": "2025-08-06T00:00:00",
"end": "2025-08-06T23:59:59",
"end": "2025-08-07T00:00:00",
"type": "meeting",
"allDay": true,
"syncStatus": "synced",
@ -693,7 +693,7 @@
"id": "70",
"title": "Summer Festival",
"start": "2025-08-14T00:00:00",
"end": "2025-08-16T23:59:59",
"end": "2025-08-17T00:00:00",
"type": "milestone",
"allDay": true,
"syncStatus": "synced",
@ -1113,7 +1113,7 @@
"id": "112",
"title": "Autumn Equinox",
"start": "2025-09-23T00:00:00",
"end": "2025-09-23T23:59:59",
"end": "2025-09-24T00:00:00",
"type": "milestone",
"allDay": true,
"syncStatus": "synced",
@ -1213,7 +1213,7 @@
"id": "122",
"title": "Multi-Day Conference",
"start": "2025-09-22T00:00:00",
"end": "2025-09-24T23:59:59",
"end": "2025-09-25T00:00:00",
"type": "meeting",
"allDay": true,
"syncStatus": "synced",
@ -1223,7 +1223,7 @@
"id": "123",
"title": "Project Sprint",
"start": "2025-09-23T00:00:00",
"end": "2025-09-25T23:59:59",
"end": "2025-09-26T00:00:00",
"type": "work",
"allDay": true,
"syncStatus": "synced",
@ -1233,7 +1233,7 @@
"id": "124",
"title": "Training Week",
"start": "2025-09-29T00:00:00",
"end": "2025-10-03T23:59:59",
"end": "2025-10-04T00:00:00",
"type": "meeting",
"allDay": true,
"syncStatus": "synced",
@ -1243,7 +1243,7 @@
"id": "125",
"title": "Holiday Weekend",
"start": "2025-10-04T00:00:00",
"end": "2025-10-06T23:59:59",
"end": "2025-10-07T00:00:00",
"type": "milestone",
"allDay": true,
"syncStatus": "synced",
@ -1253,7 +1253,7 @@
"id": "126",
"title": "Client Visit",
"start": "2025-10-07T00:00:00",
"end": "2025-10-09T23:59:59",
"end": "2025-10-10T00:00:00",
"type": "meeting",
"allDay": true,
"syncStatus": "synced",
@ -1263,7 +1263,7 @@
"id": "127",
"title": "Development Marathon",
"start": "2025-10-13T00:00:00",
"end": "2025-10-15T23:59:59",
"end": "2025-10-16T00:00:00",
"type": "work",
"allDay": true,
"syncStatus": "synced",
@ -1423,7 +1423,7 @@
"id": "143",
"title": "Weekend Hackathon",
"start": "2025-09-27T00:00:00",
"end": "2025-09-28T23:59:59",
"end": "2025-09-29T00:00:00",
"type": "work",
"allDay": true,
"syncStatus": "synced",
@ -1603,7 +1603,7 @@
"id": "161",
"title": "Teknisk Workshop",
"start": "2025-09-24T00:00:00",
"end": "2025-09-26T23:59:59",
"end": "2025-09-27T00:00:00",
"type": "meeting",
"allDay": true,
"syncStatus": "synced",
@ -1613,7 +1613,7 @@
"id": "162",
"title": "Produktudvikling Sprint",
"start": "2025-10-01T00:00:00",
"end": "2025-10-03T23:59:59",
"end": "2025-10-04T00:00:00",
"type": "work",
"allDay": true,
"syncStatus": "synced",

View file

@ -1,7 +1,3 @@
/**
* AllDayLayoutEngine - Pure data-driven layout calculation for all-day events
*/
import { CalendarEvent } from '../types/CalendarTypes';
export interface EventLayout {
@ -15,152 +11,136 @@ export interface EventLayout {
export class AllDayLayoutEngine {
private weekDates: string[];
private tracks: boolean[][];
constructor(weekDates: string[]) {
this.weekDates = weekDates;
this.tracks = [];
}
/**
* Calculate layout for all events with proper overlap detection
* Calculate layout for all events using clean day-based logic
*/
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);
if (this.weekDates.length === 0) {
return layouts;
}
// Reset tracks for new calculation
this.tracks = [new Array(this.weekDates.length).fill(false)];
// Filter to only visible events
const visibleEvents = events.filter(event => this.isEventVisible(event));
// Process events in input order (no sorting)
for (const event of visibleEvents) {
const startDay = this.getEventStartDay(event);
const endDay = this.getEventEndDay(event);
// Primary sort: longest duration first
if (durationA !== durationB) {
return durationB - durationA;
if (startDay > 0 && endDay > 0) {
const track = this.findAvailableTrack(startDay - 1, endDay - 1); // Convert to 0-based for tracks
// Mark days as occupied
for (let day = startDay - 1; day <= endDay - 1; day++) {
this.tracks[track][day] = true;
}
const layout: EventLayout = {
id: event.id,
gridArea: `${track + 1} / ${startDay} / ${track + 2} / ${endDay + 1}`,
startColumn: startDay,
endColumn: endDay,
row: track + 1,
columnSpan: endDay - startDay + 1
};
layouts.set(event.id, layout);
}
// 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
* Find available track for event spanning from startDay to endDay (0-based indices)
*/
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;
private findAvailableTrack(startDay: number, endDay: number): number {
for (let trackIndex = 0; trackIndex < this.tracks.length; trackIndex++) {
if (this.isTrackAvailable(trackIndex, startDay, endDay)) {
return trackIndex;
}
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 };
// Create new track if none available
this.tracks.push(new Array(this.weekDates.length).fill(false));
return this.tracks.length - 1;
}
/**
* Find available row using overlap detection
* Check if track is available for the given day range (0-based indices)
*/
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);
private isTrackAvailable(trackIndex: number, startDay: number, endDay: number): boolean {
for (let day = startDay; day <= endDay; day++) {
if (this.tracks[trackIndex][day]) {
return false;
}
});
// Find first available row
let targetRow = 1;
while (occupiedRows.has(targetRow)) {
targetRow++;
}
return targetRow;
return true;
}
/**
* Check if two column ranges overlap
* Get start day index for event (1-based, 0 if not visible)
*/
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);
private getEventStartDay(event: CalendarEvent): number {
const eventStartDate = this.formatDate(event.start);
const firstVisibleDate = this.weekDates[0];
// If event starts before visible range, clip to first visible day
const clippedStartDate = eventStartDate < firstVisibleDate ? firstVisibleDate : eventStartDate;
const dayIndex = this.weekDates.indexOf(clippedStartDate);
return dayIndex >= 0 ? dayIndex + 1 : 0;
}
/**
* Get end day index for event (1-based, 0 if not visible)
*/
private getEventEndDay(event: CalendarEvent): number {
const eventEndDate = this.formatDate(event.end);
const lastVisibleDate = this.weekDates[this.weekDates.length - 1];
// If event ends after visible range, clip to last visible day
const clippedEndDate = eventEndDate > lastVisibleDate ? lastVisibleDate : eventEndDate;
const dayIndex = this.weekDates.indexOf(clippedEndDate);
return dayIndex >= 0 ? dayIndex + 1 : 0;
}
/**
* Check if event is visible in the current date range
*/
private isEventVisible(event: CalendarEvent): boolean {
if (this.weekDates.length === 0) return false;
const eventStartDate = this.formatDate(event.start);
const eventEndDate = this.formatDate(event.end);
const firstVisibleDate = this.weekDates[0];
const lastVisibleDate = this.weekDates[this.weekDates.length - 1];
// Event overlaps if it doesn't end before visible range starts
// AND doesn't start after visible range ends
return !(eventEndDate < firstVisibleDate || eventStartDate > lastVisibleDate);
}
/**
* Format date to YYYY-MM-DD string using local date
*/
private formatDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
}