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

@ -283,80 +283,49 @@ export class SwpAllDayEventElement extends BaseEventElement {
}
/**
* Factory method to create from CalendarEvent and target date
* Factory method to create from CalendarEvent and layout (provided by AllDayManager)
*/
public static fromCalendarEvent(event: CalendarEvent, targetDate?: string): SwpAllDayEventElement {
// Calculate column span based on event start and end dates
const { startColumn, endColumn, columnSpan } = this.calculateColumnSpan(event);
public static fromCalendarEventWithLayout(
event: CalendarEvent,
layout: { startColumn: number; endColumn: number; row: number; columnSpan: number }
): SwpAllDayEventElement {
// Create element with provided layout
const element = new SwpAllDayEventElement(event, layout.startColumn);
// For backwards compatibility, use targetDate if provided, otherwise use calculated start column
const finalStartColumn = targetDate ? this.getColumnIndexForDate(targetDate) : startColumn;
const finalEndColumn = targetDate ? finalStartColumn : endColumn;
const finalColumnSpan = targetDate ? 1 : columnSpan;
// Find occupied rows in the spanned columns using computedStyle
const existingEvents = document.querySelectorAll('swp-allday-container swp-event');
const occupiedRows = new Set<number>();
// Set complete grid-area instead of individual properties
const gridArea = `${layout.row} / ${layout.startColumn} / ${layout.row + 1} / ${layout.endColumn + 1}`;
element.element.style.gridArea = gridArea;
console.log('🔍 SwpAllDayEventElement: Checking grid row for new event', {
targetDate,
finalStartColumn,
finalEndColumn,
existingEventsCount: existingEvents.length
});
existingEvents.forEach(existingEvent => {
const style = getComputedStyle(existingEvent);
const eventStartCol = parseInt(style.gridColumnStart);
const eventEndCol = parseInt(style.gridColumnEnd);
const eventRow = parseInt(style.gridRowStart) || 1;
const eventId = (existingEvent as HTMLElement).dataset.eventId;
console.log('📊 SwpAllDayEventElement: Checking existing event', {
eventId,
eventStartCol,
eventEndCol,
eventRow,
newEventColumn: finalStartColumn
});
// FIXED: Only check events in the same column (not overlap detection)
if (eventStartCol === finalStartColumn) {
console.log('✅ SwpAllDayEventElement: Same column - adding occupied row', eventRow);
occupiedRows.add(eventRow);
} else {
console.log('⏭️ SwpAllDayEventElement: Different column - skipping');
}
});
// Find first available row
let targetRow = 1;
while (occupiedRows.has(targetRow)) {
targetRow++;
}
console.log('🎯 SwpAllDayEventElement: Final row assignment', {
targetDate,
finalStartColumn,
occupiedRows: Array.from(occupiedRows).sort(),
assignedRow: targetRow
});
// Create element with calculated column span
const element = new SwpAllDayEventElement(event, finalStartColumn);
element.setGridRow(targetRow);
element.setColumnSpan(finalStartColumn, finalEndColumn);
console.log('✅ SwpAllDayEventElement: Created all-day event', {
console.log('✅ SwpAllDayEventElement: Created all-day event with AllDayLayoutEngine', {
eventId: event.id,
title: event.title,
column: finalStartColumn,
row: targetRow
gridArea: gridArea,
layout: layout
});
return element;
}
/**
* Factory method to create from CalendarEvent and target date (DEPRECATED - use AllDayManager.calculateAllDayEventLayout)
* @deprecated Use AllDayManager.calculateAllDayEventLayout() and fromCalendarEventWithLayout() instead
*/
public static fromCalendarEvent(event: CalendarEvent, targetDate?: string): SwpAllDayEventElement {
console.warn('⚠️ SwpAllDayEventElement.fromCalendarEvent is deprecated. Use AllDayManager.calculateAllDayEventLayout() instead.');
// Fallback to simple column calculation without overlap detection
const { startColumn, endColumn } = this.calculateColumnSpan(event);
const finalStartColumn = targetDate ? this.getColumnIndexForDate(targetDate) : startColumn;
const finalEndColumn = targetDate ? finalStartColumn : endColumn;
// Create element with row 1 (no overlap detection)
const element = new SwpAllDayEventElement(event, finalStartColumn);
element.setGridRow(1);
element.setColumnSpan(finalStartColumn, finalEndColumn);
return element;
}
/**
* Calculate column span based on event start and end dates
*/

View file

@ -3,6 +3,7 @@
import { eventBus } from '../core/EventBus';
import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer';
import { AllDayLayoutEngine } from '../utils/AllDayLayoutEngine';
import { CalendarEvent } from '../types/CalendarTypes';
import {
DragMouseEnterHeaderEventPayload,
@ -14,10 +15,11 @@ import { DragOffset, MousePosition } from '../types/DragDropTypes';
/**
* AllDayManager - Handles all-day row height animations and management
* Separated from HeaderManager for clean responsibility separation
* Uses AllDayLayoutEngine for all overlap detection and layout calculation
*/
export class AllDayManager {
private allDayEventRenderer: AllDayEventRenderer;
private layoutEngine: AllDayLayoutEngine | null = null;
constructor() {
this.allDayEventRenderer = new AllDayEventRenderer();
@ -28,8 +30,6 @@ export class AllDayManager {
* Setup event listeners for drag conversions
*/
private setupEventListeners(): void {
eventBus.on('drag:mouseenter-header', (event) => {
const { targetDate, mousePosition, originalElement, cloneElement } = (event as CustomEvent<DragMouseEnterHeaderEventPayload>).detail;
@ -58,7 +58,6 @@ export class AllDayManager {
}
this.checkAndAnimateAllDayHeight();
});
// Listen for drag operations on all-day events
@ -101,7 +100,6 @@ export class AllDayManager {
});
const dragClone = document.querySelector(`swp-allday-container swp-event[data-event-id="clone-${eventId}"]`);
console.log('🎯 AllDayManager: Ending drag for all-day event', { eventId });
this.handleDragEnd(draggedElement, dragClone as HTMLElement, { column: finalPosition.column || '', y: 0 });
});
@ -126,7 +124,6 @@ export class AllDayManager {
});
}
private getAllDayContainer(): HTMLElement | null {
return document.querySelector('swp-calendar-header swp-allday-container');
}
@ -149,7 +146,9 @@ export class AllDayManager {
} {
const root = document.documentElement;
const targetHeight = targetRows * ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT;
const currentHeight = parseInt(getComputedStyle(root).getPropertyValue('--all-day-row-height') || '0');
// Read CSS variable directly from style property or default to 0
const currentHeightStr = root.style.getPropertyValue('--all-day-row-height') || '0px';
const currentHeight = parseInt(currentHeightStr) || 0;
const heightDifference = targetHeight - currentHeight;
return { targetHeight, currentHeight, heightDifference };
@ -182,7 +181,7 @@ export class AllDayManager {
let highestRow = 0;
(Array.from(allDayEvents) as HTMLElement[]).forEach((event: HTMLElement) => {
const gridRow = parseInt(getComputedStyle(event).gridRowStart) || 1;
const gridRow = parseInt(event.style.gridRow) || 1;
highestRow = Math.max(highestRow, gridRow);
});
@ -235,8 +234,10 @@ export class AllDayManager {
// Add spacer animation if spacer exists, but don't use fill: 'forwards'
if (headerSpacer) {
const root = document.documentElement;
const currentSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + currentHeight;
const targetSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + targetHeight;
const headerHeightStr = root.style.getPropertyValue('--header-height');
const headerHeight = parseInt(headerHeightStr);
const currentSpacerHeight = headerHeight + currentHeight;
const targetSpacerHeight = headerHeight + targetHeight;
animations.push(
headerSpacer.animate([
@ -258,11 +259,64 @@ export class AllDayManager {
});
}
/**
* Calculate layout for ALL all-day events using AllDayLayoutEngine
* This is the correct method that processes all events together for proper overlap detection
*/
public calculateAllDayEventsLayout(events: CalendarEvent[], weekDates: string[]): Map<string, {
startColumn: number;
endColumn: number;
row: number;
columnSpan: number;
gridArea: string;
}> {
console.log('🔍 AllDayManager: calculateAllDayEventsLayout - Processing all events together', {
eventCount: events.length,
events: events.map(e => ({ id: e.id, title: e.title, start: e.start.toISOString().split('T')[0], end: e.end.toISOString().split('T')[0] })),
weekDates
});
// Initialize layout engine with provided week dates
this.layoutEngine = new AllDayLayoutEngine(weekDates);
// Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly
const layouts = this.layoutEngine.calculateLayout(events);
// Convert to expected return format
const result = new Map<string, {
startColumn: number;
endColumn: number;
row: number;
columnSpan: number;
gridArea: string;
}>();
layouts.forEach((layout, eventId) => {
result.set(eventId, {
startColumn: layout.startColumn,
endColumn: layout.endColumn,
row: layout.row,
columnSpan: layout.columnSpan,
gridArea: layout.gridArea
});
console.log('✅ AllDayManager: Calculated layout for event', {
eventId,
title: events.find(e => e.id === eventId)?.title,
gridArea: layout.gridArea,
layout: layout
});
});
return result;
}
/**
* Handle conversion of timed event to all-day event using CSS styling
*/
private handleConvertToAllDay(targetDate: string, cloneElement: HTMLElement): void {
console.log('🔄 AllDayManager: Converting to all-day using CSS approach', {
console.log('🔄 AllDayManager: Converting to all-day using AllDayLayoutEngine', {
eventId: cloneElement.dataset.eventId,
targetDate
});
@ -282,72 +336,52 @@ export class AllDayManager {
}
}
// Calculate position BEFORE adding to container (to avoid counting clone as existing event)
const columnIndex = this.getColumnIndexForDate(targetDate);
const availableRow = this.findAvailableRow(targetDate);
// Create mock event for layout calculation
const mockEvent: CalendarEvent = {
id: cloneElement.dataset.eventId || '',
title: cloneElement.dataset.title || '',
start: new Date(targetDate),
end: new Date(targetDate),
type: 'work',
allDay: true,
syncStatus: 'synced'
};
// Get existing all-day events from EventManager
const existingEvents = this.getExistingAllDayEvents();
// Add the new drag event to the array
const allEvents = [...existingEvents, mockEvent];
// Get actual visible dates from DOM headers (same as EventRendererManager does)
const weekDates = this.getVisibleDatesFromDOM();
// Calculate layout for all events including the new one
const layouts = this.calculateAllDayEventsLayout(allEvents, weekDates);
const layout = layouts.get(mockEvent.id);
if (!layout) {
console.error('AllDayManager: No layout found for drag event', mockEvent.id);
return;
}
// Set all properties BEFORE adding to DOM
cloneElement.classList.add('all-day-style');
cloneElement.style.gridColumn = columnIndex.toString();
cloneElement.style.gridRow = availableRow.toString();
cloneElement.style.gridColumn = layout.startColumn.toString();
cloneElement.style.gridRow = layout.row.toString();
cloneElement.dataset.allDayDate = targetDate;
cloneElement.style.display = '';
// NOW add to container (after all positioning is calculated)
allDayContainer.appendChild(cloneElement);
console.log('✅ AllDayManager: Converted to all-day style', {
console.log('✅ AllDayManager: Converted to all-day style using AllDayLayoutEngine', {
eventId: cloneElement.dataset.eventId,
gridColumn: columnIndex,
gridRow: availableRow
gridColumn: layout.startColumn,
gridRow: layout.row
});
}
/**
* Get column index for a specific date
*/
private getColumnIndexForDate(targetDate: string): number {
const dayHeaders = document.querySelectorAll('swp-day-header');
let columnIndex = 1;
dayHeaders.forEach((header, index) => {
if ((header as HTMLElement).dataset.date === targetDate) {
columnIndex = index + 1;
}
});
return columnIndex;
}
/**
* Find available row for all-day event in specific date column
*/
private findAvailableRow(targetDate: string): number {
const container = this.getAllDayContainer();
if (!container) return 1;
const columnIndex = this.getColumnIndexForDate(targetDate);
const existingEvents = container.querySelectorAll('swp-event');
const occupiedRows = new Set<number>();
existingEvents.forEach(event => {
const style = getComputedStyle(event);
const eventStartCol = parseInt(style.gridColumnStart);
const eventRow = parseInt(style.gridRowStart) || 1;
// Only check events in the same column
if (eventStartCol === columnIndex) {
occupiedRows.add(eventRow);
}
});
// Find first available row
let targetRow = 1;
while (occupiedRows.has(targetRow)) {
targetRow++;
}
return targetRow;
}
/**
* Handle conversion from all-day back to timed event
*/
@ -366,9 +400,6 @@ export class AllDayManager {
// Remove all-day date attribute
delete cloneElement.dataset.allDayDate;
// Move back to appropriate day column (will be handled by drag logic)
// The drag system will position it correctly
console.log('✅ AllDayManager: Converted from all-day back to timed');
}
@ -436,7 +467,6 @@ export class AllDayManager {
* Handle drag end for all-day events
*/
private handleDragEnd(originalElement: HTMLElement, dragClone: HTMLElement, finalPosition: { column: string; y: number }): void {
// Normalize clone
const cloneId = dragClone.dataset.eventId;
if (cloneId?.startsWith('clone-')) {
@ -449,11 +479,59 @@ export class AllDayManager {
dragClone.style.cursor = '';
dragClone.style.opacity = '';
console.log('✅ AllDayManager: Completed drag operation for all-day event', {
eventId: dragClone.dataset.eventId,
finalColumn: dragClone.style.gridColumn
});
}
/**
* Get existing all-day events from DOM
* Since we don't have direct access to EventManager, we'll get events from the current DOM
*/
private getExistingAllDayEvents(): CalendarEvent[] {
const allDayContainer = this.getAllDayContainer();
if (!allDayContainer) {
return [];
}
const existingElements = allDayContainer.querySelectorAll('swp-event');
const events: CalendarEvent[] = [];
existingElements.forEach(element => {
const htmlElement = element as HTMLElement;
const eventId = htmlElement.dataset.eventId;
const title = htmlElement.dataset.title || htmlElement.textContent || '';
const allDayDate = htmlElement.dataset.allDayDate;
if (eventId && allDayDate) {
events.push({
id: eventId,
title: title,
start: new Date(allDayDate),
end: new Date(allDayDate),
type: 'work',
allDay: true,
syncStatus: 'synced'
});
}
});
return events;
}
private getVisibleDatesFromDOM(): string[] {
const dayHeaders = document.querySelectorAll('swp-calendar-header swp-day-header');
const weekDates: string[] = [];
dayHeaders.forEach(header => {
const dateAttr = header.getAttribute('data-date');
if (dateAttr) {
weekDates.push(dateAttr);
}
});
return weekDates;
}
}

View file

@ -4,6 +4,7 @@ import { SwpAllDayEventElement } from '../elements/SwpEventElement';
/**
* AllDayEventRenderer - Simple rendering of all-day events
* Handles adding and removing all-day events from the header container
* NOTE: Layout calculation is now handled by AllDayManager
*/
export class AllDayEventRenderer {
private container: HTMLElement | null = null;
@ -34,19 +35,23 @@ export class AllDayEventRenderer {
// REMOVED: createGhostColumns() method - no longer needed!
/**
* Render an all-day event using factory pattern
* Render an all-day event with pre-calculated layout
*/
public renderAllDayEvent(event: CalendarEvent, targetDate?: string): HTMLElement | null {
public renderAllDayEventWithLayout(
event: CalendarEvent,
layout: { startColumn: number; endColumn: number; row: number; columnSpan: number }
): HTMLElement | null {
const container = this.getContainer();
if (!container) return null;
const allDayElement = SwpAllDayEventElement.fromCalendarEvent(event, targetDate);
const allDayElement = SwpAllDayEventElement.fromCalendarEventWithLayout(event, layout);
const element = allDayElement.getElement();
container.appendChild(element);
return element;
}
/**
* Remove an all-day event by ID
*/

View file

@ -4,6 +4,7 @@ import { CoreEvents } from '../constants/CoreEvents';
import { calendarConfig } from '../core/CalendarConfig';
import { CalendarTypeFactory } from '../factories/CalendarTypeFactory';
import { EventManager } from '../managers/EventManager';
import { AllDayManager } from '../managers/AllDayManager';
import { EventRendererStrategy } from './EventRenderer';
import { SwpEventElement } from '../elements/SwpEventElement';
import { AllDayEventRenderer } from './AllDayEventRenderer';
@ -17,7 +18,7 @@ export class EventRenderingService {
private eventManager: EventManager;
private strategy: EventRendererStrategy;
private allDayEventRenderer: AllDayEventRenderer;
private allDayManager: AllDayManager;
private dragMouseLeaveHeaderListener: ((event: Event) => void) | null = null;
@ -29,8 +30,9 @@ export class EventRenderingService {
const calendarType = calendarConfig.getCalendarMode();
this.strategy = CalendarTypeFactory.getEventRenderer(calendarType);
// Initialize all-day event renderer
// Initialize all-day event renderer and manager
this.allDayEventRenderer = new AllDayEventRenderer();
this.allDayManager = new AllDayManager();
this.setupEventListeners();
}
@ -349,13 +351,35 @@ export class EventRenderingService {
// Clear existing all-day events first
this.clearAllDayEvents();
// Render each all-day event
// Get actual visible dates from DOM headers instead of generating them
const weekDates = this.getVisibleDatesFromDOM();
console.log('🔍 EventRenderingService: Using visible dates from DOM', {
weekDates,
count: weekDates.length
});
// Calculate layout for ALL all-day events together using AllDayLayoutEngine
const layouts = this.allDayManager.calculateAllDayEventsLayout(allDayEvents, weekDates);
// Render each all-day event with pre-calculated layout
allDayEvents.forEach(event => {
const renderedElement = this.allDayEventRenderer.renderAllDayEvent(event);
const layout = layouts.get(event.id);
if (!layout) {
console.warn('❌ EventRenderingService: No layout found for all-day event', {
id: event.id,
title: event.title
});
return;
}
// Render with pre-calculated layout
const renderedElement = this.allDayEventRenderer.renderAllDayEventWithLayout(event, layout);
if (renderedElement) {
console.log('✅ EventRenderingService: Rendered all-day event', {
console.log('✅ EventRenderingService: Rendered all-day event with AllDayLayoutEngine', {
id: event.id,
title: event.title,
gridArea: layout.gridArea,
element: renderedElement.tagName
});
} else {
@ -392,6 +416,26 @@ export class EventRenderingService {
this.clearEvents(container);
}
/**
* Get visible dates from DOM headers - only the dates that are actually displayed
*/
private getVisibleDatesFromDOM(): string[] {
const dayHeaders = document.querySelectorAll('swp-calendar-header swp-day-header');
const weekDates: string[] = [];
dayHeaders.forEach(header => {
const dateAttr = header.getAttribute('data-date');
if (dateAttr) {
weekDates.push(dateAttr);
}
});
return weekDates;
}
public destroy(): void {
this.clearEvents();
}

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);
}
}