Introduces SwpEvent class to centralize event element data and calculations Improves drag and resize event handling by extracting common logic Simplifies calculation of event start/end times from element positions Enhances type safety and reduces code complexity in event managers Removes direct pixel/minute calculations from multiple managers
408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../../types/CalendarTypes';
|
|
import { EventService } from '../../storage/events/EventService';
|
|
import { DateService } from '../../core/DateService';
|
|
import { IGridConfig } from '../../core/IGridConfig';
|
|
import { calculateEventPosition, snapToGrid, pixelsToMinutes } from '../../utils/PositionUtils';
|
|
import { CoreEvents } from '../../constants/CoreEvents';
|
|
import { IDragColumnChangePayload, IDragMovePayload, IDragEndPayload } from '../../types/DragTypes';
|
|
import { calculateColumnLayout } from './EventLayoutEngine';
|
|
import { IGridGroupLayout } from './EventLayoutTypes';
|
|
|
|
/**
|
|
* EventRenderer - Renders calendar events to the DOM
|
|
*
|
|
* CLEAN approach:
|
|
* - Only data-id attribute on event element
|
|
* - innerHTML contains only visible content
|
|
* - Event data retrieved via EventService when needed
|
|
*/
|
|
export class EventRenderer {
|
|
private container: HTMLElement | null = null;
|
|
|
|
constructor(
|
|
private eventService: EventService,
|
|
private dateService: DateService,
|
|
private gridConfig: IGridConfig,
|
|
private eventBus: IEventBus
|
|
) {
|
|
this.setupListeners();
|
|
}
|
|
|
|
/**
|
|
* Setup listeners for drag-drop and update events
|
|
*/
|
|
private setupListeners(): void {
|
|
this.eventBus.on(CoreEvents.EVENT_DRAG_COLUMN_CHANGE, (e) => {
|
|
const payload = (e as CustomEvent<IDragColumnChangePayload>).detail;
|
|
this.handleColumnChange(payload);
|
|
});
|
|
|
|
this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE, (e) => {
|
|
const payload = (e as CustomEvent<IDragMovePayload>).detail;
|
|
this.updateDragTimestamp(payload);
|
|
});
|
|
|
|
this.eventBus.on(CoreEvents.EVENT_UPDATED, (e) => {
|
|
const payload = (e as CustomEvent<IEventUpdatedPayload>).detail;
|
|
this.handleEventUpdated(payload);
|
|
});
|
|
|
|
this.eventBus.on(CoreEvents.EVENT_DRAG_END, (e) => {
|
|
const payload = (e as CustomEvent<IDragEndPayload>).detail;
|
|
this.handleDragEnd(payload);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle EVENT_DRAG_END - remove element if dropped in header
|
|
*/
|
|
private handleDragEnd(payload: IDragEndPayload): void {
|
|
if (payload.target === 'header') {
|
|
// Event was dropped in header drawer - remove from grid
|
|
const element = this.container?.querySelector(`swp-content-viewport swp-event[data-event-id="${payload.swpEvent.eventId}"]`);
|
|
element?.remove();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle EVENT_UPDATED - re-render affected columns
|
|
*/
|
|
private async handleEventUpdated(payload: IEventUpdatedPayload): Promise<void> {
|
|
// Re-render source column (if different from target)
|
|
if (payload.sourceDateKey !== payload.targetDateKey ||
|
|
payload.sourceResourceId !== payload.targetResourceId) {
|
|
await this.rerenderColumn(payload.sourceDateKey, payload.sourceResourceId);
|
|
}
|
|
|
|
// Re-render target column
|
|
await this.rerenderColumn(payload.targetDateKey, payload.targetResourceId);
|
|
}
|
|
|
|
/**
|
|
* Re-render a single column with fresh data from IndexedDB
|
|
*/
|
|
private async rerenderColumn(dateKey: string, resourceId?: string): Promise<void> {
|
|
const column = this.findColumn(dateKey, resourceId);
|
|
if (!column) return;
|
|
|
|
// Get date range for this day
|
|
const startDate = new Date(dateKey);
|
|
const endDate = new Date(dateKey);
|
|
endDate.setHours(23, 59, 59, 999);
|
|
|
|
// Fetch events from IndexedDB
|
|
const events = resourceId
|
|
? await this.eventService.getByResourceAndDateRange(resourceId, startDate, endDate)
|
|
: await this.eventService.getByDateRange(startDate, endDate);
|
|
|
|
// Filter to timed events and match dateKey exactly
|
|
const timedEvents = events.filter(event =>
|
|
!event.allDay && this.dateService.getDateKey(event.start) === dateKey
|
|
);
|
|
|
|
// Get or create events layer
|
|
let eventsLayer = column.querySelector('swp-events-layer');
|
|
if (!eventsLayer) {
|
|
eventsLayer = document.createElement('swp-events-layer');
|
|
column.appendChild(eventsLayer);
|
|
}
|
|
|
|
// Clear existing events
|
|
eventsLayer.innerHTML = '';
|
|
|
|
// Calculate layout with stacking/grouping
|
|
const layout = calculateColumnLayout(timedEvents, this.gridConfig);
|
|
|
|
// Render GRID groups
|
|
layout.grids.forEach(grid => {
|
|
const groupEl = this.renderGridGroup(grid);
|
|
eventsLayer!.appendChild(groupEl);
|
|
});
|
|
|
|
// Render STACKED events
|
|
layout.stacked.forEach(item => {
|
|
const eventEl = this.renderStackedEvent(item.event, item.stackLevel);
|
|
eventsLayer!.appendChild(eventEl);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find a column element by dateKey and optional resourceId
|
|
*/
|
|
private findColumn(dateKey: string, resourceId?: string): HTMLElement | null {
|
|
if (!this.container) return null;
|
|
|
|
const columns = this.container.querySelectorAll('swp-day-column');
|
|
for (const col of columns) {
|
|
const colEl = col as HTMLElement;
|
|
if (colEl.dataset.date !== dateKey) continue;
|
|
|
|
// If resourceId specified, must match
|
|
if (resourceId && colEl.dataset.resourceId !== resourceId) continue;
|
|
|
|
// If no resourceId specified but column has one, skip (simple view case)
|
|
if (!resourceId && colEl.dataset.resourceId) continue;
|
|
|
|
return colEl;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Handle event moving to a new column during drag
|
|
*/
|
|
private handleColumnChange(payload: IDragColumnChangePayload): void {
|
|
const eventsLayer = payload.newColumn.querySelector('swp-events-layer');
|
|
if (!eventsLayer) return;
|
|
|
|
// Move element to new column
|
|
eventsLayer.appendChild(payload.element);
|
|
|
|
// Preserve Y position
|
|
payload.element.style.top = `${payload.currentY}px`;
|
|
}
|
|
|
|
/**
|
|
* Update timestamp display during drag (snapped to grid)
|
|
*/
|
|
private updateDragTimestamp(payload: IDragMovePayload): void {
|
|
const timeEl = payload.element.querySelector('swp-event-time');
|
|
if (!timeEl) return;
|
|
|
|
// Snap position to grid interval
|
|
const snappedY = snapToGrid(payload.currentY, this.gridConfig);
|
|
|
|
// Calculate new start time
|
|
const minutesFromGridStart = pixelsToMinutes(snappedY, this.gridConfig);
|
|
const startMinutes = (this.gridConfig.dayStartHour * 60) + minutesFromGridStart;
|
|
|
|
// Keep original duration (from element height)
|
|
const height = parseFloat(payload.element.style.height) || this.gridConfig.hourHeight;
|
|
const durationMinutes = pixelsToMinutes(height, this.gridConfig);
|
|
|
|
// Create Date objects for consistent formatting via DateService
|
|
const start = this.minutesToDate(startMinutes);
|
|
const end = this.minutesToDate(startMinutes + durationMinutes);
|
|
|
|
timeEl.textContent = this.dateService.formatTimeRange(start, end);
|
|
}
|
|
|
|
/**
|
|
* Convert minutes since midnight to a Date object (today)
|
|
*/
|
|
private minutesToDate(minutes: number): Date {
|
|
const date = new Date();
|
|
date.setHours(Math.floor(minutes / 60) % 24, minutes % 60, 0, 0);
|
|
return date;
|
|
}
|
|
|
|
/**
|
|
* Render events for visible dates into day columns
|
|
* @param container - Calendar container element
|
|
* @param filter - Filter with 'date' and optionally 'resource' arrays
|
|
*/
|
|
async render(container: HTMLElement, filter: Record<string, string[]>): Promise<void> {
|
|
// Store container reference for later re-renders
|
|
this.container = container;
|
|
|
|
const visibleDates = filter['date'] || [];
|
|
|
|
if (visibleDates.length === 0) return;
|
|
|
|
// Get date range for query
|
|
const startDate = new Date(visibleDates[0]);
|
|
const endDate = new Date(visibleDates[visibleDates.length - 1]);
|
|
endDate.setHours(23, 59, 59, 999);
|
|
|
|
// Fetch events from IndexedDB
|
|
const events = await this.eventService.getByDateRange(startDate, endDate);
|
|
|
|
// Find day columns
|
|
const dayColumns = container.querySelector('swp-day-columns');
|
|
if (!dayColumns) return;
|
|
|
|
const columns = dayColumns.querySelectorAll('swp-day-column');
|
|
|
|
// Render events into each column based on data attributes
|
|
columns.forEach(column => {
|
|
const dateKey = (column as HTMLElement).dataset.date;
|
|
const columnResourceId = (column as HTMLElement).dataset.resourceId;
|
|
|
|
if (!dateKey) return;
|
|
|
|
// Filter events for this column
|
|
const columnEvents = events.filter(event => {
|
|
// Must match date
|
|
if (this.dateService.getDateKey(event.start) !== dateKey) return false;
|
|
|
|
// If column has resourceId, event must match
|
|
if (columnResourceId && event.resourceId !== columnResourceId) return false;
|
|
|
|
// If no resourceId on column but resources in filter, show all
|
|
// (this handles 'simple' view without resources)
|
|
|
|
return true;
|
|
});
|
|
|
|
// Get or create events layer
|
|
let eventsLayer = column.querySelector('swp-events-layer');
|
|
if (!eventsLayer) {
|
|
eventsLayer = document.createElement('swp-events-layer');
|
|
column.appendChild(eventsLayer);
|
|
}
|
|
|
|
// Clear existing events
|
|
eventsLayer.innerHTML = '';
|
|
|
|
// Filter to timed events only
|
|
const timedEvents = columnEvents.filter(event => !event.allDay);
|
|
|
|
// Calculate layout with stacking/grouping
|
|
const layout = calculateColumnLayout(timedEvents, this.gridConfig);
|
|
|
|
// Render GRID groups (simultaneous events side-by-side)
|
|
layout.grids.forEach(grid => {
|
|
const groupEl = this.renderGridGroup(grid);
|
|
eventsLayer!.appendChild(groupEl);
|
|
});
|
|
|
|
// Render STACKED events (overlapping with margin offset)
|
|
layout.stacked.forEach(item => {
|
|
const eventEl = this.renderStackedEvent(item.event, item.stackLevel);
|
|
eventsLayer!.appendChild(eventEl);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a single event element
|
|
*
|
|
* CLEAN approach:
|
|
* - Only data-id for lookup
|
|
* - Visible content in innerHTML only
|
|
*/
|
|
private createEventElement(event: ICalendarEvent): HTMLElement {
|
|
const element = document.createElement('swp-event');
|
|
|
|
// Data attributes for SwpEvent compatibility
|
|
element.dataset.eventId = event.id;
|
|
if (event.resourceId) {
|
|
element.dataset.resourceId = event.resourceId;
|
|
}
|
|
|
|
// Calculate position
|
|
const position = calculateEventPosition(event.start, event.end, this.gridConfig);
|
|
element.style.top = `${position.top}px`;
|
|
element.style.height = `${position.height}px`;
|
|
|
|
// Color class based on event type
|
|
const colorClass = this.getColorClass(event);
|
|
if (colorClass) {
|
|
element.classList.add(colorClass);
|
|
}
|
|
|
|
// Visible content only
|
|
element.innerHTML = `
|
|
<swp-event-time>${this.dateService.formatTimeRange(event.start, event.end)}</swp-event-time>
|
|
<swp-event-title>${this.escapeHtml(event.title)}</swp-event-title>
|
|
${event.description ? `<swp-event-description>${this.escapeHtml(event.description)}</swp-event-description>` : ''}
|
|
`;
|
|
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* Get color class based on metadata.color or event type
|
|
*/
|
|
private getColorClass(event: ICalendarEvent): string {
|
|
// Check metadata.color first
|
|
if (event.metadata?.color) {
|
|
return `is-${event.metadata.color}`;
|
|
}
|
|
|
|
// Fallback to type-based color
|
|
const typeColors: Record<string, string> = {
|
|
'customer': 'is-blue',
|
|
'vacation': 'is-green',
|
|
'break': 'is-amber',
|
|
'meeting': 'is-purple',
|
|
'blocked': 'is-red'
|
|
};
|
|
return typeColors[event.type] || 'is-blue';
|
|
}
|
|
|
|
/**
|
|
* Escape HTML to prevent XSS
|
|
*/
|
|
private escapeHtml(text: string): string {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* Render a GRID group with side-by-side columns
|
|
* Used when multiple events start at the same time
|
|
*/
|
|
private renderGridGroup(layout: IGridGroupLayout): HTMLElement {
|
|
const group = document.createElement('swp-event-group');
|
|
group.classList.add(`cols-${layout.columns.length}`);
|
|
group.style.top = `${layout.position.top}px`;
|
|
|
|
// Stack level styling for entire group (if nested in another event)
|
|
if (layout.stackLevel > 0) {
|
|
group.style.marginLeft = `${layout.stackLevel * 15}px`;
|
|
group.style.zIndex = `${100 + layout.stackLevel}`;
|
|
}
|
|
|
|
// Calculate the height needed for the group (tallest event)
|
|
let maxBottom = 0;
|
|
for (const event of layout.events) {
|
|
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
|
|
const eventBottom = pos.top + pos.height;
|
|
if (eventBottom > maxBottom) maxBottom = eventBottom;
|
|
}
|
|
const groupHeight = maxBottom - layout.position.top;
|
|
group.style.height = `${groupHeight}px`;
|
|
|
|
// Create wrapper div for each column
|
|
layout.columns.forEach(columnEvents => {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.style.position = 'relative';
|
|
|
|
columnEvents.forEach(event => {
|
|
const eventEl = this.createEventElement(event);
|
|
// Position relative to group top
|
|
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
|
|
eventEl.style.top = `${pos.top - layout.position.top}px`;
|
|
eventEl.style.position = 'absolute';
|
|
eventEl.style.left = '0';
|
|
eventEl.style.right = '0';
|
|
wrapper.appendChild(eventEl);
|
|
});
|
|
|
|
group.appendChild(wrapper);
|
|
});
|
|
|
|
return group;
|
|
}
|
|
|
|
/**
|
|
* Render a STACKED event with margin-left offset
|
|
* Used for overlapping events that don't start at the same time
|
|
*/
|
|
private renderStackedEvent(event: ICalendarEvent, stackLevel: number): HTMLElement {
|
|
const element = this.createEventElement(event);
|
|
|
|
// Add stack metadata for drag-drop and other features
|
|
element.dataset.stackLink = JSON.stringify({ stackLevel });
|
|
|
|
// Visual styling based on stack level
|
|
if (stackLevel > 0) {
|
|
element.style.marginLeft = `${stackLevel * 15}px`;
|
|
element.style.zIndex = `${100 + stackLevel}`;
|
|
}
|
|
|
|
return element;
|
|
}
|
|
}
|