Refactors calendar event rendering and management

Improves code organization and maintainability by separating concerns related to all-day event rendering, header management, and event resizing.

Moves all-day event rendering logic into a dedicated `AllDayEventRenderer` class, utilizing the factory pattern for event element creation.

Refactors `AllDayManager` to handle all-day row height animations, separated from `HeaderManager`.

Removes the `ResizeManager` and related functionality.

These changes aim to reduce code duplication, improve testability, and enhance the overall architecture of the calendar component.
This commit is contained in:
Janus Knudsen 2025-09-12 00:36:02 +02:00
parent e0b83ebd70
commit c07d83d86f
13 changed files with 599 additions and 1306 deletions

View file

@ -6,7 +6,6 @@ import { DateCalculator } from '../utils/DateCalculator';
import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector';
import { ResizeManager } from '../managers/ResizeManager';
import { SwpEventElement, SwpAllDayEventElement } from '../elements/SwpEventElement';
/**
@ -28,14 +27,12 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
private originalEvent: HTMLElement | null = null;
// Resize manager
private resizeManager: ResizeManager;
constructor(dateCalculator?: DateCalculator) {
if (!dateCalculator) {
DateCalculator.initialize(calendarConfig);
}
this.dateCalculator = dateCalculator || new DateCalculator();
this.resizeManager = new ResizeManager(eventBus);
}
// ============================================
@ -135,40 +132,13 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
this.handleColumnChange(eventId, newColumn);
});
// Handle convert to all-day
eventBus.on('drag:convert-to-allday', (event) => {
const { eventId, targetDate, headerRenderer } = (event as CustomEvent).detail;
this.handleConvertToAllDay(eventId, targetDate, headerRenderer);
});
// Handle convert to timed event
eventBus.on('drag:convert-to-timed', (event) => {
const { eventId, targetColumn, targetY } = (event as CustomEvent).detail;
this.handleConvertToTimed(eventId, targetColumn, targetY);
});
// Handle all-day to timed conversion (when leaving header)
eventBus.on('drag:convert-allday-to-timed', (event) => {
const { eventId, originalElement } = (event as CustomEvent).detail;
this.handleConvertAllDayToTimed(eventId, originalElement);
});
// Handle navigation period change (when slide animation completes)
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => {
// Animate all-day height after navigation completes
this.triggerAllDayHeightAnimation();
});
}
/**
* Trigger all-day height animation without creating new renderer instance
*/
private triggerAllDayHeightAnimation(): void {
import('./HeaderRenderer').then(({ DateHeaderRenderer }) => {
const headerRenderer = new DateHeaderRenderer();
headerRenderer.checkAndAnimateAllDayHeight();
});
}
/**
* Cleanup method for proper resource management
@ -688,250 +658,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
/**
* Handle conversion to all-day event
*/
private handleConvertToAllDay(eventId: string, targetDate: string, headerRenderer: any): void {
if (!this.draggedClone) return;
// Only convert once
if (this.draggedClone.dataset.displayType === 'allday') return;
// Transform clone to all-day format
this.transformCloneToAllDay(this.draggedClone, targetDate);
// Expand header if needed
headerRenderer.addToAllDay(this.draggedClone.parentElement);
}
/**
* Transform clone from timed to all-day event by modifying existing element
*/
private transformCloneToAllDay(clone: HTMLElement, targetDate: string): void {
const calendarHeader = document.querySelector('swp-calendar-header');
if (!calendarHeader) return;
// Find all-day container
const allDayContainer = calendarHeader.querySelector('swp-allday-container');
if (!allDayContainer) return;
// Extract event data for transformation
const titleElement = clone.querySelector('swp-event-title');
const eventTitle = titleElement ? titleElement.textContent || 'Untitled' : 'Untitled';
const timeElement = clone.querySelector('swp-event-time');
const eventDuration = timeElement ? timeElement.getAttribute('data-duration') || '' : '';
// Calculate column index for CSS Grid positioning
const dayHeaders = document.querySelectorAll('swp-day-header');
let columnIndex = 1;
dayHeaders.forEach((header, index) => {
if ((header as HTMLElement).dataset.date === targetDate) {
columnIndex = index + 1;
}
});
// Transform the existing element in-place instead of creating new one
// Update dataset for all-day format
clone.dataset.displayType = "allday";
clone.dataset.allDay = "true";
clone.dataset.start = `${targetDate}T00:00:00`;
clone.dataset.end = `${targetDate}T23:59:59`;
if (eventDuration) {
clone.dataset.duration = eventDuration;
}
// Change content to all-day format (just title)
clone.innerHTML = eventTitle;
// Clear timed event positioning
clone.style.position = '';
clone.style.top = '';
clone.style.height = '';
clone.style.left = '';
clone.style.right = '';
// Apply CSS grid positioning for all-day
clone.style.gridColumn = columnIndex.toString();
// Move element to all-day container
const parent = clone.parentElement;
if (parent) {
parent.removeChild(clone);
}
allDayContainer.appendChild(clone);
// draggedClone reference stays the same since it's the same element
// Check if height animation is needed
this.triggerAllDayHeightAnimation();
}
/**
* Handle conversion from all-day to timed event
*/
private handleConvertToTimed(eventId: string, targetColumn: string, targetY: number): void {
if (!this.draggedClone) return;
// Only convert if it's an all-day event
if (this.draggedClone.dataset.displayType !== 'allday') return;
// Transform clone to timed format
this.transformAllDayToTimed(this.draggedClone, targetColumn, targetY);
}
/**
* Handle all-day to timed conversion by transforming existing element
*/
private handleConvertAllDayToTimed(eventId: string, originalElement: HTMLElement): void {
if (!this.draggedClone) return;
// Only convert if it's an all-day event
if (this.draggedClone.dataset.displayType !== 'allday') return;
// Transform the existing element instead of creating a new one
this.transformAllDayToTimedInPlace(this.draggedClone);
}
/**
* Transform all-day element to timed by modifying existing element in place
*/
private transformAllDayToTimedInPlace(allDayElement: HTMLElement): void {
// Extract event data
const eventId = allDayElement.dataset.eventId || '';
const eventTitle = allDayElement.dataset.title || allDayElement.textContent || 'Untitled';
const eventType = allDayElement.dataset.type || 'work';
const duration = parseInt(allDayElement.dataset.duration || '60');
// Calculate position for timed event (use current time or 9 AM default)
const now = new Date();
const startHour = now.getHours() || 9;
const startMinutes = now.getMinutes() || 0;
// Transform the existing element in-place instead of creating new one
// Update dataset for timed format
allDayElement.dataset.displayType = "timed";
delete allDayElement.dataset.allDay;
// Set timed event structure
const startTime = this.formatTime(new Date(2000, 0, 1, startHour, startMinutes));
const endTime = this.formatTime(new Date(2000, 0, 1, startHour, startMinutes + duration));
allDayElement.innerHTML = `
<swp-event-time data-duration="${duration}">${startTime} - ${endTime}</swp-event-time>
<swp-event-title>${eventTitle}</swp-event-title>
`;
// Clear all-day positioning
allDayElement.style.gridColumn = '';
// Apply timed event positioning
allDayElement.style.position = 'absolute';
allDayElement.style.left = '2px';
allDayElement.style.right = '2px';
allDayElement.style.top = '100px'; // Default position, will be adjusted by drag system
allDayElement.style.height = '57px'; // Default height for 1 hour
// Find a day column to place the element (try to use today's column)
const columns = document.querySelectorAll('swp-day-column');
let targetColumn = columns[0]; // fallback
const today = new Date().toISOString().split('T')[0];
columns.forEach(col => {
if ((col as HTMLElement).dataset.date === today) {
targetColumn = col;
}
});
const eventsLayer = targetColumn?.querySelector('swp-events-layer');
// Move element from all-day container to events layer
const parent = allDayElement.parentElement;
if (parent) {
parent.removeChild(allDayElement);
}
// Add to events layer
if (eventsLayer) {
eventsLayer.appendChild(allDayElement);
}
// draggedClone reference stays the same since it's the same element
}
/**
* Transform clone from all-day to timed event
*/
private transformAllDayToTimed(allDayClone: HTMLElement, targetColumn: string, targetY: number): void {
// Find target column element
const columnElement = document.querySelector(`swp-day-column[data-date="${targetColumn}"]`);
if (!columnElement) return;
const eventsLayer = columnElement.querySelector('swp-events-layer');
if (!eventsLayer) return;
// Extract event data from all-day element
const eventId = allDayClone.dataset.eventId || '';
const eventTitle = allDayClone.dataset.title || allDayClone.textContent || 'Untitled';
const eventType = allDayClone.dataset.type || 'work';
// Calculate time from Y position
const gridSettings = calendarConfig.getGridSettings();
const hourHeight = gridSettings.hourHeight;
const dayStartHour = gridSettings.dayStartHour;
const snapInterval = gridSettings.snapInterval;
// Calculate start time from position
const minutesFromGridStart = (targetY / hourHeight) * 60;
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval;
// Use default duration or extract from dataset
const duration = parseInt(allDayClone.dataset.duration || '60');
const endMinutes = snappedStartMinutes + duration;
// Create dates with target column date
const columnDate = new Date(targetColumn + 'T00:00:00');
const startDate = new Date(columnDate);
startDate.setMinutes(snappedStartMinutes);
const endDate = new Date(columnDate);
endDate.setMinutes(endMinutes);
// Create CalendarEvent object for helper methods
const tempEvent: CalendarEvent = {
id: eventId,
title: eventTitle,
start: startDate,
end: endDate,
type: eventType,
allDay: false,
syncStatus: 'synced',
metadata: {
duration: duration
}
};
// Create timed event using factory
const swpTimedEvent = SwpEventElement.fromCalendarEvent(tempEvent);
const timedEvent = swpTimedEvent.getElement();
// Set additional drag-specific attributes
timedEvent.dataset.originalDuration = duration.toString();
// Apply drag styling and positioning
this.applyDragStyling(timedEvent);
const eventHeight = (duration / 60) * hourHeight - 3;
timedEvent.style.height = `${eventHeight}px`;
timedEvent.style.top = `${targetY}px`;
// Remove all-day element
allDayClone.remove();
// Add timed event to events layer
eventsLayer.appendChild(timedEvent);
// Update reference
this.draggedClone = timedEvent;
}
/**
* Fade out and remove element
@ -953,19 +679,15 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
// clearEvents() would remove events from all containers, breaking the animation
// Events are now rendered directly into the new container without clearing
// Separate all-day events from regular events
const allDayEvents = events.filter(event => event.allDay);
const regularEvents = events.filter(event => !event.allDay);
// Only handle regular (non-all-day) events
// Always call renderAllDayEvents to ensure height is set correctly (even to 0)
this.renderAllDayEvents(allDayEvents, container);
// Find columns in the specific container for regular events
const columns = this.getColumns(container);
columns.forEach(column => {
const columnEvents = this.getEventsForColumn(column, regularEvents);
const columnEvents = this.getEventsForColumn(column, events);
const eventsLayer = column.querySelector('swp-events-layer');
if (eventsLayer) {
@ -979,101 +701,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
protected abstract getColumns(container: HTMLElement): HTMLElement[];
protected abstract getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[];
/**
* Render all-day events in the header row 2
*/
protected renderAllDayEvents(allDayEvents: CalendarEvent[], container: HTMLElement): void {
// Find the calendar header
const calendarHeader = container.querySelector('swp-calendar-header');
if (!calendarHeader) {
return;
}
// Find the all-day container (should always exist now)
const allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement;
if (!allDayContainer) {
console.warn('All-day container not found - this should not happen');
return;
}
// Clear existing events
allDayContainer.innerHTML = '';
if (allDayEvents.length === 0) {
// No events - container exists but is empty and hidden
return;
}
// Build date to column mapping
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
const dateToColumnMap = new Map<string, number>();
dayHeaders.forEach((header, index) => {
const dateStr = (header as any).dataset.date;
if (dateStr) {
dateToColumnMap.set(dateStr, index + 1); // 1-based column index
}
});
// Calculate grid spans for all events
const eventSpans = allDayEvents.map(event => ({
event,
span: this.calculateEventGridSpan(event, dateToColumnMap)
})).filter(item => item.span.columnSpan > 0); // Remove events outside visible range
// Simple row assignment using overlap detection
const eventPlacements: Array<{ event: CalendarEvent, span: { startColumn: number, columnSpan: number }, row: number }> = [];
eventSpans.forEach(eventItem => {
let assignedRow = 1;
// Find first row where this event doesn't overlap with any existing event
while (true) {
const rowEvents = eventPlacements.filter(item => item.row === assignedRow);
const hasOverlap = rowEvents.some(rowEvent =>
this.spansOverlap(eventItem.span, rowEvent.span)
);
if (!hasOverlap) {
break; // Found available row
}
assignedRow++;
}
eventPlacements.push({
event: eventItem.event,
span: eventItem.span,
row: assignedRow
});
});
// Get max row needed
const maxRow = Math.max(...eventPlacements.map(item => item.row), 1);
// Place events directly in the single container
eventPlacements.forEach(({ event, span, row }) => {
// Create all-day event using factory
const eventDateStr = DateCalculator.formatISODate(event.start);
const swpAllDayEvent = SwpAllDayEventElement.fromCalendarEvent(event, eventDateStr);
const allDayEvent = swpAllDayEvent.getElement();
// Override grid position for spanning events
(allDayEvent as HTMLElement).style.gridColumn = span.columnSpan > 1
? `${span.startColumn} / span ${span.columnSpan}`
: `${span.startColumn}`;
(allDayEvent as HTMLElement).style.gridRow = row.toString();
// Use event metadata for color if available
if (event.metadata?.color) {
(allDayEvent as HTMLElement).style.backgroundColor = event.metadata.color;
}
allDayContainer.appendChild(allDayEvent);
});
}
protected renderEvent(event: CalendarEvent): HTMLElement {
const swpEvent = SwpEventElement.fromCalendarEvent(event);
@ -1082,7 +709,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
// Setup resize handles on first mouseover only
eventElement.addEventListener('mouseover', () => {
if (eventElement.dataset.hasResizeHandlers !== 'true') {
this.resizeManager.setupResizeHandles(eventElement);
eventElement.dataset.hasResizeHandlers = 'true';
}
}, { once: true });
@ -1113,51 +739,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
return { top, height };
}
/**
* Calculate grid column span for event
*/
private calculateEventGridSpan(event: CalendarEvent, dateToColumnMap: Map<string, number>): { startColumn: number, columnSpan: number } {
const startDateKey = DateCalculator.formatISODate(event.start);
const startColumn = dateToColumnMap.get(startDateKey);
if (!startColumn) {
return { startColumn: 0, columnSpan: 0 }; // Event outside visible range
}
// Calculate span by checking each day
let endColumn = startColumn;
const currentDate = new Date(event.start);
while (currentDate <= event.end) {
currentDate.setDate(currentDate.getDate() + 1);
const dateKey = DateCalculator.formatISODate(currentDate);
const col = dateToColumnMap.get(dateKey);
if (col) {
endColumn = col;
} else {
break; // Event extends beyond visible range
}
}
const columnSpan = endColumn - startColumn + 1;
return { startColumn, columnSpan };
}
/**
* Check if two column spans overlap (for all-day events)
*/
private spansOverlap(event1Span: { startColumn: number, columnSpan: number }, event2Span: { startColumn: number, columnSpan: number }): boolean {
const event1End = event1Span.startColumn + event1Span.columnSpan - 1;
const event2End = event2Span.startColumn + event2Span.columnSpan - 1;
return !(event1End < event2Span.startColumn || event2End < event1Span.startColumn);
}
clearEvents(container?: HTMLElement): void {
const selector = 'swp-event, swp-event-group';
const existingEvents = container