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.
391 lines
No EOL
12 KiB
TypeScript
391 lines
No EOL
12 KiB
TypeScript
import { CalendarEvent } from '../types/CalendarTypes';
|
|
import { calendarConfig } from '../core/CalendarConfig';
|
|
import { TimeFormatter } from '../utils/TimeFormatter';
|
|
import { PositionUtils } from '../utils/PositionUtils';
|
|
|
|
/**
|
|
* Abstract base class for event DOM elements
|
|
*/
|
|
export abstract class BaseEventElement {
|
|
protected element: HTMLElement;
|
|
protected event: CalendarEvent;
|
|
|
|
protected constructor(event: CalendarEvent) {
|
|
this.event = event;
|
|
this.element = this.createElement();
|
|
this.setDataAttributes();
|
|
}
|
|
|
|
/**
|
|
* Create the underlying DOM element
|
|
*/
|
|
protected abstract createElement(): HTMLElement;
|
|
|
|
/**
|
|
* Set standard data attributes on the element
|
|
*/
|
|
protected setDataAttributes(): void {
|
|
this.element.dataset.eventId = this.event.id;
|
|
this.element.dataset.title = this.event.title;
|
|
this.element.dataset.start = this.event.start.toISOString();
|
|
this.element.dataset.end = this.event.end.toISOString();
|
|
this.element.dataset.type = this.event.type;
|
|
this.element.dataset.duration = this.event.metadata?.duration?.toString() || '60';
|
|
}
|
|
|
|
/**
|
|
* Get the DOM element
|
|
*/
|
|
public getElement(): HTMLElement {
|
|
return this.element;
|
|
}
|
|
|
|
/**
|
|
* Format time for display using TimeFormatter
|
|
*/
|
|
protected formatTime(date: Date): string {
|
|
return TimeFormatter.formatTime(date);
|
|
}
|
|
|
|
/**
|
|
* Calculate event position for timed events using PositionUtils
|
|
*/
|
|
protected calculateEventPosition(): { top: number; height: number } {
|
|
return PositionUtils.calculateEventPosition(this.event.start, this.event.end);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Timed event element (swp-event)
|
|
*/
|
|
export class SwpEventElement extends BaseEventElement {
|
|
private constructor(event: CalendarEvent) {
|
|
super(event);
|
|
this.createInnerStructure();
|
|
this.applyPositioning();
|
|
}
|
|
|
|
protected createElement(): HTMLElement {
|
|
return document.createElement('swp-event');
|
|
}
|
|
|
|
/**
|
|
* Create inner HTML structure
|
|
*/
|
|
private createInnerStructure(): void {
|
|
const timeRange = TimeFormatter.formatTimeRange(this.event.start, this.event.end);
|
|
const durationMinutes = (this.event.end.getTime() - this.event.start.getTime()) / (1000 * 60);
|
|
|
|
this.element.innerHTML = `
|
|
<swp-event-time data-duration="${durationMinutes}">${timeRange}</swp-event-time>
|
|
<swp-event-title>${this.event.title}</swp-event-title>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Apply positioning styles
|
|
*/
|
|
private applyPositioning(): void {
|
|
const position = this.calculateEventPosition();
|
|
this.element.style.position = 'absolute';
|
|
this.element.style.top = `${position.top + 1}px`;
|
|
this.element.style.height = `${position.height - 3}px`;
|
|
this.element.style.left = '2px';
|
|
this.element.style.right = '2px';
|
|
}
|
|
|
|
/**
|
|
* Factory method to create a SwpEventElement from a CalendarEvent
|
|
*/
|
|
public static fromCalendarEvent(event: CalendarEvent): SwpEventElement {
|
|
return new SwpEventElement(event);
|
|
}
|
|
|
|
/**
|
|
* Create a clone of this SwpEventElement with "clone-" prefix
|
|
*/
|
|
public createClone(): SwpEventElement {
|
|
// Clone the underlying DOM element
|
|
const clonedElement = this.element.cloneNode(true) as HTMLElement;
|
|
|
|
// Create new SwpEventElement instance from the cloned DOM
|
|
const clonedSwpEvent = SwpEventElement.fromExistingElement(clonedElement);
|
|
|
|
// Apply "clone-" prefix to ID
|
|
clonedSwpEvent.updateEventId(`clone-${this.event.id}`);
|
|
|
|
// Cache original duration for drag operations
|
|
const originalDuration = this.getOriginalEventDuration();
|
|
clonedSwpEvent.element.dataset.originalDuration = originalDuration.toString();
|
|
|
|
// Set height from original element
|
|
clonedSwpEvent.element.style.height = this.element.style.height || `${this.element.getBoundingClientRect().height}px`;
|
|
|
|
return clonedSwpEvent;
|
|
}
|
|
|
|
/**
|
|
* Factory method to create SwpEventElement from existing DOM element
|
|
*/
|
|
public static fromExistingElement(element: HTMLElement): SwpEventElement {
|
|
// Extract CalendarEvent data from DOM element
|
|
const event = this.extractCalendarEventFromElement(element);
|
|
|
|
// Create new instance but replace the created element with the existing one
|
|
const swpEvent = new SwpEventElement(event);
|
|
swpEvent.element = element;
|
|
|
|
return swpEvent;
|
|
}
|
|
|
|
/**
|
|
* Update the event ID in both the CalendarEvent and DOM element
|
|
*/
|
|
private updateEventId(newId: string): void {
|
|
this.event.id = newId;
|
|
this.element.dataset.eventId = newId;
|
|
}
|
|
|
|
/**
|
|
* Extract original event duration from DOM element
|
|
*/
|
|
private getOriginalEventDuration(): number {
|
|
const timeElement = this.element.querySelector('swp-event-time');
|
|
if (timeElement) {
|
|
const duration = timeElement.getAttribute('data-duration');
|
|
if (duration) {
|
|
return parseInt(duration);
|
|
}
|
|
}
|
|
return 60; // Fallback
|
|
}
|
|
|
|
/**
|
|
* Extract CalendarEvent from DOM element
|
|
*/
|
|
private static extractCalendarEventFromElement(element: HTMLElement): CalendarEvent {
|
|
return {
|
|
id: element.dataset.eventId || '',
|
|
title: element.dataset.title || '',
|
|
start: new Date(element.dataset.start || ''),
|
|
end: new Date(element.dataset.end || ''),
|
|
type: element.dataset.type || 'work',
|
|
allDay: false,
|
|
syncStatus: 'synced',
|
|
metadata: {
|
|
duration: element.dataset.duration
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Factory method to convert an all-day HTML element to a timed SwpEventElement
|
|
*/
|
|
public static fromAllDayElement(allDayElement: HTMLElement): SwpEventElement {
|
|
// Extract data from all-day element's dataset
|
|
const eventId = allDayElement.dataset.eventId || '';
|
|
const title = allDayElement.dataset.title || allDayElement.textContent || 'Untitled';
|
|
const type = allDayElement.dataset.type || 'work';
|
|
const startStr = allDayElement.dataset.start;
|
|
const endStr = allDayElement.dataset.end;
|
|
const durationStr = allDayElement.dataset.duration;
|
|
|
|
if (!startStr || !endStr) {
|
|
throw new Error('All-day element missing start/end dates');
|
|
}
|
|
|
|
// Parse dates and set reasonable 1-hour duration for timed event
|
|
const originalStart = new Date(startStr);
|
|
const duration = durationStr ? parseInt(durationStr) : 60; // Default 1 hour
|
|
|
|
// For conversion, use current time or a reasonable default (9 AM)
|
|
const now = new Date();
|
|
const startDate = new Date(originalStart);
|
|
startDate.setHours(now.getHours() || 9, now.getMinutes() || 0, 0, 0);
|
|
|
|
const endDate = new Date(startDate);
|
|
endDate.setMinutes(endDate.getMinutes() + duration);
|
|
|
|
// Create CalendarEvent object
|
|
const calendarEvent: CalendarEvent = {
|
|
id: eventId,
|
|
title: title,
|
|
start: startDate,
|
|
end: endDate,
|
|
type: type,
|
|
allDay: false,
|
|
syncStatus: 'synced',
|
|
metadata: {
|
|
duration: duration.toString()
|
|
}
|
|
};
|
|
|
|
return new SwpEventElement(calendarEvent);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* All-day event element (now using unified swp-event tag)
|
|
*/
|
|
export class SwpAllDayEventElement extends BaseEventElement {
|
|
private columnIndex: number;
|
|
|
|
private constructor(event: CalendarEvent, columnIndex: number) {
|
|
super(event);
|
|
this.columnIndex = columnIndex;
|
|
this.setAllDayAttributes();
|
|
this.createInnerStructure();
|
|
this.applyGridPositioning();
|
|
}
|
|
|
|
protected createElement(): HTMLElement {
|
|
return document.createElement('swp-event');
|
|
}
|
|
|
|
/**
|
|
* Set all-day specific attributes
|
|
*/
|
|
private setAllDayAttributes(): void {
|
|
this.element.dataset.allDay = "true";
|
|
// For all-day events, preserve original start/end dates but set to full day times
|
|
const startDateStr = this.event.start.toISOString().split('T')[0];
|
|
const endDateStr = this.event.end.toISOString().split('T')[0];
|
|
this.element.dataset.start = `${startDateStr}T00:00:00`;
|
|
this.element.dataset.end = `${endDateStr}T23:59:59`;
|
|
}
|
|
|
|
/**
|
|
* Create inner structure (just text content for all-day events)
|
|
*/
|
|
private createInnerStructure(): void {
|
|
this.element.textContent = this.event.title;
|
|
}
|
|
|
|
/**
|
|
* Apply CSS grid positioning
|
|
*/
|
|
private applyGridPositioning(): void {
|
|
this.element.style.gridColumn = this.columnIndex.toString();
|
|
}
|
|
|
|
/**
|
|
* Set grid row for this all-day event
|
|
*/
|
|
public setGridRow(row: number): void {
|
|
this.element.style.gridRow = row.toString();
|
|
}
|
|
|
|
/**
|
|
* Set grid column span for this all-day event
|
|
*/
|
|
public setColumnSpan(startColumn: number, endColumn: number): void {
|
|
this.element.style.gridColumn = `${startColumn} / ${endColumn + 1}`;
|
|
}
|
|
|
|
/**
|
|
* Factory method to create from CalendarEvent and layout (provided by AllDayManager)
|
|
*/
|
|
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);
|
|
|
|
// 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: Created all-day event with AllDayLayoutEngine', {
|
|
eventId: event.id,
|
|
title: event.title,
|
|
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
|
|
*/
|
|
private static calculateColumnSpan(event: CalendarEvent): { startColumn: number; endColumn: number; columnSpan: number } {
|
|
const dayHeaders = document.querySelectorAll('swp-day-header');
|
|
|
|
// Extract dates from headers
|
|
const headerDates: string[] = [];
|
|
dayHeaders.forEach(header => {
|
|
const date = (header as HTMLElement).dataset.date;
|
|
if (date) {
|
|
headerDates.push(date);
|
|
}
|
|
});
|
|
|
|
// Format event dates for comparison (YYYY-MM-DD format)
|
|
const eventStartDate = event.start.toISOString().split('T')[0];
|
|
const eventEndDate = event.end.toISOString().split('T')[0];
|
|
|
|
// Find start and end column indices
|
|
let startColumn = 1;
|
|
let endColumn = headerDates.length;
|
|
|
|
headerDates.forEach((dateStr, index) => {
|
|
if (dateStr === eventStartDate) {
|
|
startColumn = index + 1;
|
|
}
|
|
if (dateStr === eventEndDate) {
|
|
endColumn = index + 1;
|
|
}
|
|
});
|
|
|
|
// Ensure end column is at least start column
|
|
if (endColumn < startColumn) {
|
|
endColumn = startColumn;
|
|
}
|
|
|
|
const columnSpan = endColumn - startColumn + 1;
|
|
|
|
return { startColumn, endColumn, columnSpan };
|
|
}
|
|
|
|
/**
|
|
* Get column index for a specific date
|
|
*/
|
|
private static 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;
|
|
}
|
|
|
|
/**
|
|
* Check if two column ranges overlap
|
|
*/
|
|
private static columnsOverlap(startA: number, endA: number, startB: number, endB: number): boolean {
|
|
return !(endA < startB || endB < startA);
|
|
}
|
|
} |