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; constructor() { super(); const timezone = calendarConfig.getTimezone?.(); this.dateService = new DateService(timezone); } // ============================================ // 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 = calendarConfig.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 = calendarConfig.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 timezone = calendarConfig.getTimezone?.(); const dateService = new DateService(timezone); 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 } }; } /** * Factory method to convert an all-day HTML element to a timed SwpEventElement */ public static fromAllDayElement(allDayElement: HTMLElement): SwpEventElement { const eventId = allDayElement.dataset.eventId || ''; const title = allDayElement.dataset.title || allDayElement.textContent || 'Untitled'; const type = allDayElement.dataset.type || 'work'; const startStr = allDayElement.dataset.start; const endStr = allDayElement.dataset.end; const durationStr = allDayElement.dataset.duration; if (!startStr || !endStr) { throw new Error('All-day element missing start/end dates'); } const originalStart = new Date(startStr); const duration = durationStr ? parseInt(durationStr) : 60; const now = new Date(); const startDate = new Date(originalStart); startDate.setHours(now.getHours() || 9, now.getMinutes() || 0, 0, 0); const endDate = new Date(startDate); endDate.setMinutes(endDate.getMinutes() + duration); const calendarEvent: CalendarEvent = { id: eventId, title: title, start: startDate, end: endDate, type: type, allDay: false, syncStatus: 'synced', metadata: { duration: duration.toString() } }; return SwpEventElement.fromCalendarEvent(calendarEvent); } } /** * 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'; 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 timezone = calendarConfig.getTimezone?.(); const dateService = new DateService(timezone); 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);