import { CalendarEvent } from '../types/CalendarTypes'; import { CalendarConfig } from '../core/CalendarConfig'; import { TimeFormatter } from '../utils/TimeFormatter'; import { PositionUtils } from '../utils/PositionUtils'; import { DateService } from '../utils/DateService'; /** * Base class for event elements */ export abstract class BaseSwpEventElement extends HTMLElement { protected dateService: DateService; protected config: CalendarConfig; constructor() { super(); // TODO: Find better solution for web component DI this.config = new CalendarConfig(); this.dateService = new DateService(this.config); } // ============================================ // Abstract Methods // ============================================ /** * Create a clone for drag operations * Must be implemented by subclasses */ public abstract createClone(): HTMLElement; // ============================================ // Common Getters/Setters // ============================================ get eventId(): string { return this.dataset.eventId || ''; } set eventId(value: string) { this.dataset.eventId = value; } get start(): Date { return new Date(this.dataset.start || ''); } set start(value: Date) { this.dataset.start = this.dateService.toUTC(value); } get end(): Date { return new Date(this.dataset.end || ''); } set end(value: Date) { this.dataset.end = this.dateService.toUTC(value); } get title(): string { return this.dataset.title || ''; } set title(value: string) { this.dataset.title = value; } get type(): string { return this.dataset.type || 'work'; } set type(value: string) { this.dataset.type = value; } } /** * Web Component for timed calendar events (Light DOM) */ export class SwpEventElement extends BaseSwpEventElement { /** * Observed attributes - changes trigger attributeChangedCallback */ static get observedAttributes() { return ['data-start', 'data-end', 'data-title', 'data-type']; } /** * Called when element is added to DOM */ connectedCallback() { if (!this.hasChildNodes()) { this.render(); } } /** * Called when observed attribute changes */ attributeChangedCallback(name: string, oldValue: string, newValue: string) { if (oldValue !== newValue && this.isConnected) { this.updateDisplay(); } } // ============================================ // Public Methods // ============================================ /** * Update event position during drag * @param columnDate - The date of the column * @param snappedY - The Y position in pixels */ public updatePosition(columnDate: Date, snappedY: number): void { // 1. Update visual position this.style.top = `${snappedY + 1}px`; // 2. Calculate new timestamps const { startMinutes, endMinutes } = this.calculateTimesFromPosition(snappedY); // 3. Update data attributes (triggers attributeChangedCallback) const startDate = this.dateService.createDateAtTime(columnDate, startMinutes); let endDate = this.dateService.createDateAtTime(columnDate, endMinutes); // Handle cross-midnight events if (endMinutes >= 1440) { const extraDays = Math.floor(endMinutes / 1440); endDate = this.dateService.addDays(endDate, extraDays); } this.start = startDate; this.end = endDate; } /** * Update event height during resize * @param newHeight - The new height in pixels */ public updateHeight(newHeight: number): void { // 1. Update visual height this.style.height = `${newHeight}px`; // 2. Calculate new end time based on height const gridSettings = this.config.getGridSettings(); const { hourHeight, snapInterval } = gridSettings; // Get current start time const start = this.start; // Calculate duration from height const rawDurationMinutes = (newHeight / hourHeight) * 60; // Snap duration to grid interval (like drag & drop) const snappedDurationMinutes = Math.round(rawDurationMinutes / snapInterval) * snapInterval; // Calculate new end time by adding snapped duration to start (using DateService for timezone safety) const endDate = this.dateService.addMinutes(start, snappedDurationMinutes); // 3. Update end attribute (triggers attributeChangedCallback → updateDisplay) this.end = endDate; } /** * Create a clone for drag operations */ public createClone(): SwpEventElement { const clone = this.cloneNode(true) as SwpEventElement; // Apply "clone-" prefix to ID clone.dataset.eventId = `clone-${this.eventId}`; // Disable pointer events on clone so it doesn't interfere with hover detection clone.style.pointerEvents = 'none'; // Cache original duration const timeEl = this.querySelector('swp-event-time'); if (timeEl) { const duration = timeEl.getAttribute('data-duration'); if (duration) { clone.dataset.originalDuration = duration; } } // Set height from original clone.style.height = this.style.height || `${this.getBoundingClientRect().height}px`; return clone; } // ============================================ // Private Methods // ============================================ /** * Render inner HTML structure */ private render(): void { const start = this.start; const end = this.end; const timeRange = TimeFormatter.formatTimeRange(start, end); const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60); this.innerHTML = ` ${timeRange} ${this.title} `; } /** * Update time display when attributes change */ private updateDisplay(): void { const timeEl = this.querySelector('swp-event-time'); const titleEl = this.querySelector('swp-event-title'); if (timeEl && this.dataset.start && this.dataset.end) { const start = new Date(this.dataset.start); const end = new Date(this.dataset.end); const timeRange = TimeFormatter.formatTimeRange(start, end); timeEl.textContent = timeRange; // Update duration attribute const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60); timeEl.setAttribute('data-duration', durationMinutes.toString()); } if (titleEl && this.dataset.title) { titleEl.textContent = this.dataset.title; } } /** * Calculate start/end minutes from Y position */ private calculateTimesFromPosition(snappedY: number): { startMinutes: number; endMinutes: number } { const gridSettings = this.config.getGridSettings(); const { hourHeight, dayStartHour, snapInterval } = gridSettings; // Get original duration const originalDuration = parseInt( this.dataset.originalDuration || this.dataset.duration || '60' ); // Calculate snapped start minutes const minutesFromGridStart = (snappedY / hourHeight) * 60; const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart; const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval; // Calculate end minutes const endMinutes = snappedStartMinutes + originalDuration; return { startMinutes: snappedStartMinutes, endMinutes }; } // ============================================ // Static Factory Methods // ============================================ /** * Create SwpEventElement from CalendarEvent */ public static fromCalendarEvent(event: CalendarEvent): SwpEventElement { const element = document.createElement('swp-event') as SwpEventElement; const config = new CalendarConfig(); const dateService = new DateService(config); element.dataset.eventId = event.id; element.dataset.title = event.title; element.dataset.start = dateService.toUTC(event.start); element.dataset.end = dateService.toUTC(event.end); element.dataset.type = event.type; element.dataset.duration = event.metadata?.duration?.toString() || '60'; return element; } /** * Extract CalendarEvent from DOM element */ public 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 } }; } } /** * Web Component for all-day calendar events */ export class SwpAllDayEventElement extends BaseSwpEventElement { connectedCallback() { if (!this.textContent) { this.textContent = this.dataset.title || 'Untitled'; } } /** * Create a clone for drag operations */ public createClone(): SwpAllDayEventElement { const clone = this.cloneNode(true) as SwpAllDayEventElement; // Apply "clone-" prefix to ID clone.dataset.eventId = `clone-${this.eventId}`; // Disable pointer events on clone so it doesn't interfere with hover detection clone.style.pointerEvents = 'none'; // Preserve full opacity during drag clone.style.opacity = '1'; return clone; } /** * Apply CSS grid positioning */ public applyGridPositioning(row: number, startColumn: number, endColumn: number): void { const gridArea = `${row} / ${startColumn} / ${row + 1} / ${endColumn + 1}`; this.style.gridArea = gridArea; } /** * Create from CalendarEvent */ public static fromCalendarEvent(event: CalendarEvent): SwpAllDayEventElement { const element = document.createElement('swp-allday-event') as SwpAllDayEventElement; const config = new CalendarConfig(); const dateService = new DateService(config); element.dataset.eventId = event.id; element.dataset.title = event.title; element.dataset.start = dateService.toUTC(event.start); element.dataset.end = dateService.toUTC(event.end); element.dataset.type = event.type; element.dataset.allday = 'true'; element.textContent = event.title; return element; } } // Register custom elements customElements.define('swp-event', SwpEventElement); customElements.define('swp-allday-event', SwpAllDayEventElement);