Calendar/src/renderers/EventRenderer.ts
Janus Knudsen be4a8af7c4 Updates all-day event grid row layout
Ensures the all-day event container's grid layout is correctly updated to reflect the number of rows needed, even when the overall height doesn't change.
This prevents layout issues when events are rearranged without triggering a height recalculation.
Also updates the grid template when the height is updated in the BaseEventRenderer.
2025-08-27 20:54:06 +02:00

384 lines
No EOL
14 KiB
TypeScript

// Event rendering strategy interface and implementations
import { CalendarEvent } from '../types/CalendarTypes';
import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
import { CalendarConfig } from '../core/CalendarConfig';
import { DateCalculator } from '../utils/DateCalculator';
/**
* Interface for event rendering strategies
*/
export interface EventRendererStrategy {
renderEvents(events: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void;
clearEvents(container?: HTMLElement): void;
}
/**
* Base class for event renderers with common functionality
*/
export abstract class BaseEventRenderer implements EventRendererStrategy {
protected dateCalculator: DateCalculator;
constructor(config: CalendarConfig) {
this.dateCalculator = new DateCalculator(config);
}
renderEvents(events: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void {
console.log('BaseEventRenderer: renderEvents called with', events.length, 'events');
// NOTE: Removed clearEvents() to support sliding animation
// With sliding animation, multiple grid containers exist simultaneously
// 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);
console.log(`BaseEventRenderer: Rendering ${allDayEvents.length} all-day events and ${regularEvents.length} regular events`);
// Always call renderAllDayEvents to ensure height is set correctly (even to 0)
this.renderAllDayEvents(allDayEvents, container, config);
// Find columns in the specific container for regular events
const columns = this.getColumns(container);
console.log(`BaseEventRenderer: Found ${columns.length} columns in container`);
columns.forEach(column => {
const columnEvents = this.getEventsForColumn(column, regularEvents);
console.log(`BaseEventRenderer: Rendering ${columnEvents.length} regular events in column`);
const eventsLayer = column.querySelector('swp-events-layer');
if (eventsLayer) {
columnEvents.forEach(event => {
console.log(`BaseEventRenderer: Rendering event "${event.title}" in events layer`);
this.renderEvent(event, eventsLayer, config);
});
// Debug: Verify events were actually added
const renderedEvents = eventsLayer.querySelectorAll('swp-event');
console.log(`BaseEventRenderer: Events layer now has ${renderedEvents.length} events`);
} else {
console.warn('BaseEventRenderer: No events layer found in column');
}
});
}
// Abstract methods that subclasses must implement
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, config: CalendarConfig): void {
console.log(`BaseEventRenderer: Rendering ${allDayEvents.length} all-day events using nested grid`);
// Find the calendar header
const calendarHeader = container.querySelector('swp-calendar-header');
if (!calendarHeader) {
console.warn('BaseEventRenderer: No calendar header found for all-day events');
return;
}
// Find the all-day container
const allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement;
if (!allDayContainer) {
console.warn('BaseEventRenderer: No swp-allday-container found - HeaderRenderer should create this');
return;
}
// Clear existing events
allDayContainer.innerHTML = '';
if (allDayEvents.length === 0) {
// No events - just return
this.updateAllDayHeight(1);
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.eventsOverlap(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 the all-day event element
const allDayEvent = document.createElement('swp-allday-event');
allDayEvent.textContent = event.title;
allDayEvent.setAttribute('data-event-id', event.id);
allDayEvent.setAttribute('data-type', event.type || 'work');
// Set grid position (column and row)
(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);
console.log(`BaseEventRenderer: Placed "${event.title}" in row ${row}, columns ${span.startColumn} to ${span.startColumn + span.columnSpan - 1}`);
});
// Update height based on max row
this.updateAllDayHeight(maxRow);
console.log(`BaseEventRenderer: Created ${maxRow} rows with auto-expanding grid`);
}
protected renderEvent(event: CalendarEvent, container: Element, config: CalendarConfig): void {
const eventElement = document.createElement('swp-event');
eventElement.dataset.eventId = event.id;
eventElement.dataset.type = event.type;
// Calculate position based on time
const position = this.calculateEventPosition(event, config);
eventElement.style.position = 'absolute';
eventElement.style.top = `${position.top + 1}px`;
eventElement.style.height = `${position.height - 3}px`; //adjusted so bottom does not cover horizontal time lines.
// Color is now handled by CSS classes based on data-type attribute
// Format time for display
const startTime = this.dateCalculator.formatTime(new Date(event.start));
const endTime = this.dateCalculator.formatTime(new Date(event.end));
// Create event content
eventElement.innerHTML = `
<swp-event-time>${startTime} - ${endTime}</swp-event-time>
<swp-event-title>${event.title}</swp-event-title>
`;
container.appendChild(eventElement);
console.log(`BaseEventRenderer: Created event element for "${event.title}":`, {
top: eventElement.style.top,
height: eventElement.style.height,
dataType: eventElement.dataset.type,
position: eventElement.style.position,
innerHTML: eventElement.innerHTML
});
}
protected calculateEventPosition(event: CalendarEvent, config: CalendarConfig): { top: number; height: number } {
const startDate = new Date(event.start);
const endDate = new Date(event.end);
const gridSettings = config.getGridSettings();
const dayStartHour = gridSettings.dayStartHour;
const hourHeight = gridSettings.hourHeight;
// Calculate minutes from visible day start
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
const endMinutes = endDate.getHours() * 60 + endDate.getMinutes();
const dayStartMinutes = dayStartHour * 60;
// Calculate top position (subtract day start to align with time axis)
const top = ((startMinutes - dayStartMinutes) / 60) * hourHeight;
// Calculate height
const durationMinutes = endMinutes - startMinutes;
const height = (durationMinutes / 60) * hourHeight;
console.log('Event positioning calculation:', {
eventTime: `${startDate.getHours()}:${startDate.getMinutes()}`,
startMinutes,
endMinutes,
dayStartMinutes,
dayStartHour,
hourHeight,
top,
height
});
return { top, height };
}
protected formatTime(isoString: string): string {
const date = new Date(isoString);
const hours = date.getHours();
const minutes = date.getMinutes();
const period = hours >= 12 ? 'PM' : 'AM';
const displayHour = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
return `${displayHour}:${minutes.toString().padStart(2, '0')} ${period}`;
}
/**
* Update all-day row height and grid template based on number of rows
*/
private updateAllDayHeight(maxRows: number): void {
const root = document.documentElement;
const eventHeight = parseInt(getComputedStyle(root).getPropertyValue('--allday-event-height') || '26');
const calculatedHeight = maxRows * eventHeight;
root.style.setProperty('--all-day-row-height', `${calculatedHeight}px`);
// Update grid-template-rows for all swp-allday-containers
const allDayContainers = document.querySelectorAll('swp-allday-container');
allDayContainers.forEach(container => {
const gridRows = `repeat(${maxRows}, var(--allday-event-height, 26px))`;
(container as HTMLElement).style.gridTemplateRows = gridRows;
});
console.log(`BaseEventRenderer: Set all-day height to ${calculatedHeight}px and grid-template-rows to ${maxRows} rows`);
}
/**
* Calculate grid column span for event
*/
private calculateEventGridSpan(event: CalendarEvent, dateToColumnMap: Map<string, number>): { startColumn: number, columnSpan: number } {
const startDate = new Date(event.start);
const endDate = new Date(event.end);
const startDateKey = this.dateCalculator.formatISODate(startDate);
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(startDate);
while (currentDate <= endDate) {
currentDate.setDate(currentDate.getDate() + 1);
const dateKey = this.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 events overlap in columns
*/
private eventsOverlap(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';
const existingEvents = container
? container.querySelectorAll(selector)
: document.querySelectorAll(selector);
if (existingEvents.length > 0) {
console.log(`BaseEventRenderer: Clearing ${existingEvents.length} events`,
container ? 'from container' : 'globally');
}
existingEvents.forEach(event => event.remove());
}
}
/**
* Date-based event renderer
*/
export class DateEventRenderer extends BaseEventRenderer {
protected getColumns(container: HTMLElement): HTMLElement[] {
const columns = container.querySelectorAll('swp-day-column');
console.log('DateEventRenderer: Found', columns.length, 'day columns in container');
return Array.from(columns) as HTMLElement[];
}
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
const columnDate = column.dataset.date;
if (!columnDate) {
console.log(`DateEventRenderer: Column has no dataset.date`);
return [];
}
const columnEvents = events.filter(event => {
const eventDate = new Date(event.start);
const eventDateStr = this.dateCalculator.formatISODate(eventDate);
const matches = eventDateStr === columnDate;
if (!matches) {
if(event.title == 'Architecture Planning')
console.log(`DateEventRenderer: Event ${event.title} (${eventDateStr}) does not match column (${columnDate})`);
}
return matches;
});
console.log(`DateEventRenderer: Column ${columnDate} has ${columnEvents.length} events from ${events.length} total`);
return columnEvents;
}
}
/**
* Resource-based event renderer
*/
export class ResourceEventRenderer extends BaseEventRenderer {
protected getColumns(container: HTMLElement): HTMLElement[] {
const columns = container.querySelectorAll('swp-resource-column');
console.log('ResourceEventRenderer: Found', columns.length, 'resource columns in container');
return Array.from(columns) as HTMLElement[];
}
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
const resourceName = column.dataset.resource;
if (!resourceName) return [];
const columnEvents = events.filter(event => {
return event.resource?.name === resourceName;
});
console.log(`ResourceEventRenderer: Resource ${resourceName} has ${columnEvents.length} events`);
return columnEvents;
}
}