2025-08-07 00:15:44 +02:00
|
|
|
// Event rendering strategy interface and implementations
|
|
|
|
|
|
|
|
|
|
import { CalendarEvent } from '../types/CalendarTypes';
|
2025-08-24 23:31:11 +02:00
|
|
|
import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
|
2025-08-07 00:15:44 +02:00
|
|
|
import { CalendarConfig } from '../core/CalendarConfig';
|
2025-08-20 00:39:31 +02:00
|
|
|
import { DateCalculator } from '../utils/DateCalculator';
|
2025-08-27 22:50:13 +02:00
|
|
|
import { eventBus } from '../core/EventBus';
|
2025-08-07 00:15:44 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Interface for event rendering strategies
|
|
|
|
|
*/
|
|
|
|
|
export interface EventRendererStrategy {
|
2025-08-16 00:51:12 +02:00
|
|
|
renderEvents(events: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void;
|
|
|
|
|
clearEvents(container?: HTMLElement): void;
|
2025-08-07 00:15:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Base class for event renderers with common functionality
|
|
|
|
|
*/
|
|
|
|
|
export abstract class BaseEventRenderer implements EventRendererStrategy {
|
2025-08-20 00:39:31 +02:00
|
|
|
protected dateCalculator: DateCalculator;
|
2025-08-27 22:50:13 +02:00
|
|
|
protected config: CalendarConfig;
|
|
|
|
|
|
|
|
|
|
// Drag and drop state
|
|
|
|
|
private draggedClone: HTMLElement | null = null;
|
|
|
|
|
private originalEvent: HTMLElement | null = null;
|
2025-08-20 00:39:31 +02:00
|
|
|
|
|
|
|
|
constructor(config: CalendarConfig) {
|
2025-08-27 22:50:13 +02:00
|
|
|
this.config = config;
|
2025-08-20 00:39:31 +02:00
|
|
|
this.dateCalculator = new DateCalculator(config);
|
2025-08-27 22:50:13 +02:00
|
|
|
this.setupDragEventListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Setup listeners for drag events from DragDropManager
|
|
|
|
|
*/
|
|
|
|
|
private setupDragEventListeners(): void {
|
|
|
|
|
// Handle drag start
|
|
|
|
|
eventBus.on('drag:start', (event) => {
|
|
|
|
|
const { originalElement, eventId, mouseOffset, column } = (event as CustomEvent).detail;
|
|
|
|
|
this.handleDragStart(originalElement, eventId, mouseOffset, column);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle drag move
|
|
|
|
|
eventBus.on('drag:move', (event) => {
|
|
|
|
|
const { eventId, snappedY, column, mouseOffset } = (event as CustomEvent).detail;
|
|
|
|
|
this.handleDragMove(eventId, snappedY, column, mouseOffset);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle drag end
|
|
|
|
|
eventBus.on('drag:end', (event) => {
|
|
|
|
|
const { eventId, originalElement, finalColumn, finalY } = (event as CustomEvent).detail;
|
|
|
|
|
this.handleDragEnd(eventId, originalElement, finalColumn, finalY);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle column change
|
|
|
|
|
eventBus.on('drag:column-change', (event) => {
|
|
|
|
|
const { eventId, newColumn } = (event as CustomEvent).detail;
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get original event duration from data-duration attribute
|
|
|
|
|
*/
|
|
|
|
|
private getOriginalEventDuration(originalEvent: HTMLElement): number {
|
|
|
|
|
// Find the swp-event-time element with data-duration attribute
|
|
|
|
|
const timeElement = originalEvent.querySelector('swp-event-time');
|
|
|
|
|
if (timeElement) {
|
|
|
|
|
const duration = timeElement.getAttribute('data-duration');
|
|
|
|
|
if (duration) {
|
|
|
|
|
const durationMinutes = parseInt(duration);
|
|
|
|
|
console.log(`EventRenderer: Read duration ${durationMinutes} minutes from data-duration attribute`);
|
|
|
|
|
return durationMinutes;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback to 60 minutes if attribute not found
|
|
|
|
|
console.warn('EventRenderer: No data-duration found, using fallback 60 minutes');
|
|
|
|
|
return 60;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a clone of an event for dragging
|
|
|
|
|
*/
|
|
|
|
|
private createEventClone(originalEvent: HTMLElement): HTMLElement {
|
|
|
|
|
const clone = originalEvent.cloneNode(true) as HTMLElement;
|
|
|
|
|
|
|
|
|
|
// Prefix ID with "clone-"
|
|
|
|
|
const originalId = originalEvent.dataset.eventId;
|
|
|
|
|
if (originalId) {
|
|
|
|
|
clone.dataset.eventId = `clone-${originalId}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get and cache original duration from data-duration attribute
|
|
|
|
|
const originalDurationMinutes = this.getOriginalEventDuration(originalEvent);
|
|
|
|
|
clone.dataset.originalDuration = originalDurationMinutes.toString();
|
|
|
|
|
|
|
|
|
|
console.log(`EventRenderer: Clone created with ${originalDurationMinutes} minutes duration from data-duration`);
|
|
|
|
|
|
|
|
|
|
// Style for dragging
|
|
|
|
|
clone.style.position = 'absolute';
|
|
|
|
|
clone.style.zIndex = '999999';
|
|
|
|
|
clone.style.pointerEvents = 'none';
|
|
|
|
|
clone.style.opacity = '0.8';
|
|
|
|
|
|
|
|
|
|
// Keep original dimensions (height stays the same)
|
|
|
|
|
const rect = originalEvent.getBoundingClientRect();
|
|
|
|
|
clone.style.width = rect.width + 'px';
|
|
|
|
|
clone.style.height = rect.height + 'px';
|
|
|
|
|
|
|
|
|
|
return clone;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update clone timestamp based on new position
|
|
|
|
|
*/
|
|
|
|
|
private updateCloneTimestamp(clone: HTMLElement, snappedY: number): void {
|
|
|
|
|
const gridSettings = this.config.getGridSettings();
|
|
|
|
|
const hourHeight = gridSettings.hourHeight;
|
|
|
|
|
const dayStartHour = gridSettings.dayStartHour;
|
|
|
|
|
const snapInterval = 15; // TODO: Get from config
|
|
|
|
|
|
|
|
|
|
// Calculate total minutes from top
|
|
|
|
|
const totalMinutesFromTop = (snappedY / hourHeight) * 60;
|
|
|
|
|
const startTotalMinutes = Math.max(
|
|
|
|
|
dayStartHour * 60,
|
|
|
|
|
Math.round((dayStartHour * 60 + totalMinutesFromTop) / snapInterval) * snapInterval
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Use cached original duration (no recalculation)
|
|
|
|
|
const cachedDuration = parseInt(clone.dataset.originalDuration || '60');
|
|
|
|
|
const endTotalMinutes = startTotalMinutes + cachedDuration;
|
|
|
|
|
|
|
|
|
|
// Update display
|
|
|
|
|
const timeElement = clone.querySelector('swp-event-time');
|
|
|
|
|
if (timeElement) {
|
|
|
|
|
const newTimeText = `${this.formatTime(startTotalMinutes)} - ${this.formatTime(endTotalMinutes)}`;
|
|
|
|
|
timeElement.textContent = newTimeText;
|
|
|
|
|
|
|
|
|
|
console.log(`EventRenderer: Updated timestamp to ${newTimeText} (${cachedDuration} min duration)`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Calculate event duration in minutes from element height
|
|
|
|
|
*/
|
|
|
|
|
private getEventDuration(element: HTMLElement): number {
|
|
|
|
|
const gridSettings = this.config.getGridSettings();
|
|
|
|
|
const hourHeight = gridSettings.hourHeight;
|
|
|
|
|
|
|
|
|
|
// Get height from style or computed
|
|
|
|
|
let heightPx = parseFloat(element.style.height) || 0;
|
|
|
|
|
if (!heightPx) {
|
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
|
heightPx = rect.height;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Math.round((heightPx / hourHeight) * 60);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format time from total minutes
|
|
|
|
|
*/
|
|
|
|
|
private formatTime(totalMinutes: number): string {
|
|
|
|
|
const hours = Math.floor(totalMinutes / 60) % 24;
|
|
|
|
|
const minutes = totalMinutes % 60;
|
|
|
|
|
const period = hours >= 12 ? 'PM' : 'AM';
|
|
|
|
|
const displayHours = hours % 12 || 12;
|
|
|
|
|
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle drag start event
|
|
|
|
|
*/
|
|
|
|
|
private handleDragStart(originalElement: HTMLElement, eventId: string, mouseOffset: any, column: string): void {
|
|
|
|
|
this.originalEvent = originalElement;
|
|
|
|
|
|
|
|
|
|
// Create clone
|
|
|
|
|
this.draggedClone = this.createEventClone(originalElement);
|
|
|
|
|
|
|
|
|
|
// Add to current column
|
|
|
|
|
const columnElement = document.querySelector(`swp-day-column[data-date="${column}"]`);
|
|
|
|
|
if (columnElement) {
|
|
|
|
|
columnElement.appendChild(this.draggedClone);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Make original semi-transparent
|
|
|
|
|
originalElement.style.opacity = '0.3';
|
|
|
|
|
originalElement.style.userSelect = 'none';
|
|
|
|
|
|
|
|
|
|
console.log('EventRenderer: Drag started, clone created');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle drag move event
|
|
|
|
|
*/
|
|
|
|
|
private handleDragMove(eventId: string, snappedY: number, column: string, mouseOffset: any): void {
|
|
|
|
|
if (!this.draggedClone) return;
|
|
|
|
|
|
|
|
|
|
// Update position
|
|
|
|
|
this.draggedClone.style.top = snappedY + 'px';
|
|
|
|
|
|
|
|
|
|
// Update timestamp display
|
|
|
|
|
this.updateCloneTimestamp(this.draggedClone, snappedY);
|
|
|
|
|
|
|
|
|
|
console.log('EventRenderer: Clone position and timestamp updated');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle column change during drag
|
|
|
|
|
*/
|
|
|
|
|
private handleColumnChange(eventId: string, newColumn: string): void {
|
|
|
|
|
if (!this.draggedClone) return;
|
|
|
|
|
|
|
|
|
|
// Move clone to new column
|
|
|
|
|
const newColumnElement = document.querySelector(`swp-day-column[data-date="${newColumn}"]`);
|
|
|
|
|
if (newColumnElement && this.draggedClone.parentElement !== newColumnElement) {
|
|
|
|
|
newColumnElement.appendChild(this.draggedClone);
|
|
|
|
|
console.log(`EventRenderer: Clone moved to column ${newColumn}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle drag end event
|
|
|
|
|
*/
|
|
|
|
|
private handleDragEnd(eventId: string, originalElement: HTMLElement, finalColumn: string, finalY: number): void {
|
|
|
|
|
if (!this.draggedClone || !this.originalEvent) return;
|
|
|
|
|
|
|
|
|
|
// Fade out original
|
|
|
|
|
this.fadeOutAndRemove(this.originalEvent);
|
|
|
|
|
|
|
|
|
|
// Remove clone prefix and enable pointer events
|
|
|
|
|
const cloneId = this.draggedClone.dataset.eventId;
|
|
|
|
|
if (cloneId && cloneId.startsWith('clone-')) {
|
|
|
|
|
this.draggedClone.dataset.eventId = cloneId.replace('clone-', '');
|
|
|
|
|
}
|
|
|
|
|
this.draggedClone.style.pointerEvents = '';
|
|
|
|
|
this.draggedClone.style.opacity = '';
|
|
|
|
|
|
|
|
|
|
// Clean up
|
|
|
|
|
this.draggedClone = null;
|
|
|
|
|
this.originalEvent = null;
|
|
|
|
|
|
|
|
|
|
console.log('EventRenderer: Drag completed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.tagName === 'SWP-ALLDAY-EVENT') return;
|
|
|
|
|
|
|
|
|
|
// Transform clone to all-day format
|
|
|
|
|
this.transformCloneToAllDay(this.draggedClone, targetDate);
|
|
|
|
|
|
|
|
|
|
// Expand header if needed
|
|
|
|
|
headerRenderer.addToAllDay(this.draggedClone.parentElement);
|
|
|
|
|
|
|
|
|
|
console.log(`EventRenderer: Converted to all-day event for date ${targetDate}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Transform clone from timed to all-day event
|
|
|
|
|
*/
|
|
|
|
|
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 title
|
|
|
|
|
const titleElement = clone.querySelector('swp-event-title');
|
|
|
|
|
const eventTitle = titleElement ? titleElement.textContent || 'Untitled' : 'Untitled';
|
|
|
|
|
|
|
|
|
|
// Calculate column index
|
|
|
|
|
const dayHeaders = document.querySelectorAll('swp-day-header');
|
|
|
|
|
let columnIndex = 1;
|
|
|
|
|
dayHeaders.forEach((header, index) => {
|
|
|
|
|
if ((header as HTMLElement).dataset.date === targetDate) {
|
|
|
|
|
columnIndex = index + 1;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Create all-day event
|
|
|
|
|
const allDayEvent = document.createElement('swp-allday-event');
|
|
|
|
|
allDayEvent.dataset.eventId = clone.dataset.eventId || '';
|
|
|
|
|
allDayEvent.dataset.type = clone.dataset.type || 'work';
|
|
|
|
|
allDayEvent.textContent = eventTitle;
|
|
|
|
|
|
|
|
|
|
// Position in grid
|
|
|
|
|
(allDayEvent as HTMLElement).style.gridColumn = columnIndex.toString();
|
|
|
|
|
(allDayEvent as HTMLElement).style.gridRow = '1';
|
|
|
|
|
|
|
|
|
|
// Remove original clone
|
|
|
|
|
if (clone.parentElement) {
|
|
|
|
|
clone.parentElement.removeChild(clone);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add to all-day container
|
|
|
|
|
allDayContainer.appendChild(allDayEvent);
|
|
|
|
|
|
|
|
|
|
// Update reference
|
|
|
|
|
this.draggedClone = allDayEvent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Fade out and remove element
|
|
|
|
|
*/
|
|
|
|
|
private fadeOutAndRemove(element: HTMLElement): void {
|
|
|
|
|
element.style.transition = 'opacity 0.3s ease-out';
|
|
|
|
|
element.style.opacity = '0';
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
element.remove();
|
|
|
|
|
}, 300);
|
2025-08-20 00:39:31 +02:00
|
|
|
}
|
2025-08-16 00:51:12 +02:00
|
|
|
renderEvents(events: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void {
|
2025-08-07 00:15:44 +02:00
|
|
|
console.log('BaseEventRenderer: renderEvents called with', events.length, 'events');
|
|
|
|
|
|
2025-08-12 00:31:02 +02:00
|
|
|
// 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
|
2025-08-07 00:15:44 +02:00
|
|
|
|
2025-08-24 00:13:07 +02:00
|
|
|
// 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);
|
2025-08-07 00:15:44 +02:00
|
|
|
|
2025-08-24 00:13:07 +02:00
|
|
|
// Find columns in the specific container for regular events
|
2025-08-16 00:51:12 +02:00
|
|
|
const columns = this.getColumns(container);
|
|
|
|
|
console.log(`BaseEventRenderer: Found ${columns.length} columns in container`);
|
2025-08-13 23:05:58 +02:00
|
|
|
|
|
|
|
|
columns.forEach(column => {
|
2025-08-24 00:13:07 +02:00
|
|
|
const columnEvents = this.getEventsForColumn(column, regularEvents);
|
|
|
|
|
console.log(`BaseEventRenderer: Rendering ${columnEvents.length} regular events in column`);
|
2025-08-07 00:15:44 +02:00
|
|
|
|
2025-08-13 23:05:58 +02:00
|
|
|
const eventsLayer = column.querySelector('swp-events-layer');
|
|
|
|
|
if (eventsLayer) {
|
|
|
|
|
columnEvents.forEach(event => {
|
2025-08-09 01:16:04 +02:00
|
|
|
console.log(`BaseEventRenderer: Rendering event "${event.title}" in events layer`);
|
2025-08-07 00:15:44 +02:00
|
|
|
this.renderEvent(event, eventsLayer, config);
|
2025-08-13 23:05:58 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Debug: Verify events were actually added
|
|
|
|
|
const renderedEvents = eventsLayer.querySelectorAll('swp-event');
|
|
|
|
|
console.log(`BaseEventRenderer: Events layer now has ${renderedEvents.length} events`);
|
2025-08-07 00:15:44 +02:00
|
|
|
} else {
|
2025-08-13 23:05:58 +02:00
|
|
|
console.warn('BaseEventRenderer: No events layer found in column');
|
2025-08-07 00:15:44 +02:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-16 00:51:12 +02:00
|
|
|
// Abstract methods that subclasses must implement
|
|
|
|
|
protected abstract getColumns(container: HTMLElement): HTMLElement[];
|
2025-08-13 23:05:58 +02:00
|
|
|
protected abstract getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[];
|
|
|
|
|
|
2025-08-24 00:13:07 +02:00
|
|
|
/**
|
|
|
|
|
* Render all-day events in the header row 2
|
|
|
|
|
*/
|
|
|
|
|
protected renderAllDayEvents(allDayEvents: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void {
|
2025-08-26 00:05:42 +02:00
|
|
|
console.log(`BaseEventRenderer: Rendering ${allDayEvents.length} all-day events using nested grid`);
|
2025-08-24 00:13:07 +02:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-26 00:05:42 +02:00
|
|
|
// 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
|
2025-08-24 00:13:07 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-26 00:05:42 +02:00
|
|
|
// 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 }> = [];
|
2025-08-24 00:13:07 +02:00
|
|
|
|
2025-08-26 00:05:42 +02:00
|
|
|
eventSpans.forEach(eventItem => {
|
|
|
|
|
let assignedRow = 1;
|
2025-08-24 00:13:07 +02:00
|
|
|
|
2025-08-26 00:05:42 +02:00
|
|
|
// 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++;
|
2025-08-24 00:13:07 +02:00
|
|
|
}
|
|
|
|
|
|
2025-08-26 00:05:42 +02:00
|
|
|
eventPlacements.push({
|
|
|
|
|
event: eventItem.event,
|
|
|
|
|
span: eventItem.span,
|
|
|
|
|
row: assignedRow
|
|
|
|
|
});
|
2025-08-24 00:13:07 +02:00
|
|
|
});
|
|
|
|
|
|
2025-08-26 00:05:42 +02:00
|
|
|
// Get max row needed
|
|
|
|
|
const maxRow = Math.max(...eventPlacements.map(item => item.row), 1);
|
2025-08-25 22:05:57 +02:00
|
|
|
|
2025-08-26 00:05:42 +02:00
|
|
|
// 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}`);
|
2025-08-24 00:13:07 +02:00
|
|
|
});
|
2025-08-26 00:05:42 +02:00
|
|
|
|
|
|
|
|
// Update height based on max row
|
|
|
|
|
this.updateAllDayHeight(maxRow);
|
2025-08-24 00:13:07 +02:00
|
|
|
|
2025-08-26 00:05:42 +02:00
|
|
|
console.log(`BaseEventRenderer: Created ${maxRow} rows with auto-expanding grid`);
|
2025-08-24 00:13:07 +02:00
|
|
|
}
|
|
|
|
|
|
2025-08-07 00:15:44 +02:00
|
|
|
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`;
|
2025-08-22 22:57:35 +02:00
|
|
|
eventElement.style.height = `${position.height - 3}px`; //adjusted so bottom does not cover horizontal time lines.
|
2025-08-07 00:15:44 +02:00
|
|
|
|
2025-08-21 22:09:15 +02:00
|
|
|
// Color is now handled by CSS classes based on data-type attribute
|
2025-08-07 00:15:44 +02:00
|
|
|
|
|
|
|
|
// Format time for display
|
2025-08-27 22:50:13 +02:00
|
|
|
const startTime = this.formatTimeFromISOString(event.start);
|
|
|
|
|
const endTime = this.formatTimeFromISOString(event.end);
|
|
|
|
|
|
|
|
|
|
// Calculate duration in minutes
|
|
|
|
|
const startDate = new Date(event.start);
|
|
|
|
|
const endDate = new Date(event.end);
|
|
|
|
|
const durationMinutes = (endDate.getTime() - startDate.getTime()) / (1000 * 60);
|
2025-08-07 00:15:44 +02:00
|
|
|
|
|
|
|
|
// Create event content
|
|
|
|
|
eventElement.innerHTML = `
|
2025-08-27 22:50:13 +02:00
|
|
|
<swp-event-time data-duration="${durationMinutes}">${startTime} - ${endTime}</swp-event-time>
|
2025-08-07 00:15:44 +02:00
|
|
|
<swp-event-title>${event.title}</swp-event-title>
|
|
|
|
|
`;
|
2025-08-27 22:50:13 +02:00
|
|
|
|
|
|
|
|
console.log(`BaseEventRenderer: Rendered "${event.title}" with ${durationMinutes} minutes duration`);
|
2025-08-07 00:15:44 +02:00
|
|
|
|
|
|
|
|
container.appendChild(eventElement);
|
2025-08-09 01:16:04 +02:00
|
|
|
|
|
|
|
|
console.log(`BaseEventRenderer: Created event element for "${event.title}":`, {
|
|
|
|
|
top: eventElement.style.top,
|
|
|
|
|
height: eventElement.style.height,
|
2025-08-21 22:09:15 +02:00
|
|
|
dataType: eventElement.dataset.type,
|
2025-08-09 01:16:04 +02:00
|
|
|
position: eventElement.style.position,
|
|
|
|
|
innerHTML: eventElement.innerHTML
|
|
|
|
|
});
|
2025-08-07 00:15:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected calculateEventPosition(event: CalendarEvent, config: CalendarConfig): { top: number; height: number } {
|
|
|
|
|
const startDate = new Date(event.start);
|
|
|
|
|
const endDate = new Date(event.end);
|
|
|
|
|
|
2025-08-09 01:16:04 +02:00
|
|
|
const gridSettings = config.getGridSettings();
|
|
|
|
|
const dayStartHour = gridSettings.dayStartHour;
|
|
|
|
|
const hourHeight = gridSettings.hourHeight;
|
2025-08-07 00:15:44 +02:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
2025-08-09 01:16:04 +02:00
|
|
|
console.log('Event positioning calculation:', {
|
|
|
|
|
eventTime: `${startDate.getHours()}:${startDate.getMinutes()}`,
|
|
|
|
|
startMinutes,
|
|
|
|
|
endMinutes,
|
|
|
|
|
dayStartMinutes,
|
|
|
|
|
dayStartHour,
|
|
|
|
|
hourHeight,
|
|
|
|
|
top,
|
|
|
|
|
height
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-07 00:15:44 +02:00
|
|
|
return { top, height };
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-27 22:50:13 +02:00
|
|
|
protected formatTimeFromISOString(isoString: string): string {
|
2025-08-07 00:15:44 +02:00
|
|
|
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}`;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-26 00:05:42 +02:00
|
|
|
/**
|
2025-08-27 20:54:06 +02:00
|
|
|
* Update all-day row height and grid template based on number of rows
|
2025-08-26 00:05:42 +02:00
|
|
|
*/
|
|
|
|
|
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`);
|
|
|
|
|
|
2025-08-27 20:54:06 +02:00
|
|
|
// 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`);
|
2025-08-26 00:05:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-16 00:51:12 +02:00
|
|
|
clearEvents(container?: HTMLElement): void {
|
|
|
|
|
const selector = 'swp-event';
|
|
|
|
|
const existingEvents = container
|
|
|
|
|
? container.querySelectorAll(selector)
|
|
|
|
|
: document.querySelectorAll(selector);
|
|
|
|
|
|
2025-08-09 01:16:04 +02:00
|
|
|
if (existingEvents.length > 0) {
|
2025-08-16 00:51:12 +02:00
|
|
|
console.log(`BaseEventRenderer: Clearing ${existingEvents.length} events`,
|
|
|
|
|
container ? 'from container' : 'globally');
|
2025-08-09 01:16:04 +02:00
|
|
|
}
|
2025-08-07 00:15:44 +02:00
|
|
|
existingEvents.forEach(event => event.remove());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Date-based event renderer
|
|
|
|
|
*/
|
|
|
|
|
export class DateEventRenderer extends BaseEventRenderer {
|
2025-08-16 00:51:12 +02:00
|
|
|
protected getColumns(container: HTMLElement): HTMLElement[] {
|
|
|
|
|
const columns = container.querySelectorAll('swp-day-column');
|
|
|
|
|
console.log('DateEventRenderer: Found', columns.length, 'day columns in container');
|
2025-08-13 23:05:58 +02:00
|
|
|
return Array.from(columns) as HTMLElement[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
|
|
|
|
|
const columnDate = column.dataset.date;
|
2025-08-20 00:39:31 +02:00
|
|
|
if (!columnDate) {
|
|
|
|
|
console.log(`DateEventRenderer: Column has no dataset.date`);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
2025-08-13 23:05:58 +02:00
|
|
|
|
|
|
|
|
const columnEvents = events.filter(event => {
|
|
|
|
|
const eventDate = new Date(event.start);
|
2025-08-20 00:39:31 +02:00
|
|
|
const eventDateStr = this.dateCalculator.formatISODate(eventDate);
|
|
|
|
|
const matches = eventDateStr === columnDate;
|
|
|
|
|
|
|
|
|
|
if (!matches) {
|
2025-08-20 21:38:54 +02:00
|
|
|
if(event.title == 'Architecture Planning')
|
2025-08-20 00:39:31 +02:00
|
|
|
console.log(`DateEventRenderer: Event ${event.title} (${eventDateStr}) does not match column (${columnDate})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return matches;
|
2025-08-13 23:05:58 +02:00
|
|
|
});
|
|
|
|
|
|
2025-08-20 00:39:31 +02:00
|
|
|
console.log(`DateEventRenderer: Column ${columnDate} has ${columnEvents.length} events from ${events.length} total`);
|
2025-08-13 23:05:58 +02:00
|
|
|
return columnEvents;
|
|
|
|
|
}
|
2025-08-07 00:15:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resource-based event renderer
|
|
|
|
|
*/
|
|
|
|
|
export class ResourceEventRenderer extends BaseEventRenderer {
|
2025-08-16 00:51:12 +02:00
|
|
|
protected getColumns(container: HTMLElement): HTMLElement[] {
|
|
|
|
|
const columns = container.querySelectorAll('swp-resource-column');
|
|
|
|
|
console.log('ResourceEventRenderer: Found', columns.length, 'resource columns in container');
|
2025-08-13 23:05:58 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2025-08-07 00:15:44 +02:00
|
|
|
}
|