2025-09-10 22:36:11 +02:00
|
|
|
import { CalendarEvent } from '../types/CalendarTypes';
|
2025-10-30 23:47:30 +01:00
|
|
|
import { CalendarConfig } from '../core/CalendarConfig';
|
2025-09-12 22:21:56 +02:00
|
|
|
import { TimeFormatter } from '../utils/TimeFormatter';
|
2025-09-13 00:39:56 +02:00
|
|
|
import { PositionUtils } from '../utils/PositionUtils';
|
2025-10-04 00:32:26 +02:00
|
|
|
import { DateService } from '../utils/DateService';
|
2025-09-10 22:36:11 +02:00
|
|
|
|
|
|
|
|
/**
|
2025-10-04 15:35:09 +02:00
|
|
|
* Base class for event elements
|
2025-09-10 22:36:11 +02:00
|
|
|
*/
|
2025-10-04 23:10:09 +02:00
|
|
|
export abstract class BaseSwpEventElement extends HTMLElement {
|
2025-10-04 00:32:26 +02:00
|
|
|
protected dateService: DateService;
|
2025-10-30 23:47:30 +01:00
|
|
|
protected config: CalendarConfig;
|
2025-09-10 22:36:11 +02:00
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
constructor() {
|
|
|
|
|
super();
|
2025-10-30 23:47:30 +01:00
|
|
|
// TODO: Find better solution for web component DI
|
|
|
|
|
this.config = new CalendarConfig();
|
|
|
|
|
this.dateService = new DateService(this.config);
|
2025-09-10 22:36:11 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-04 23:10:09 +02:00
|
|
|
// ============================================
|
|
|
|
|
// Abstract Methods
|
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a clone for drag operations
|
|
|
|
|
* Must be implemented by subclasses
|
|
|
|
|
*/
|
|
|
|
|
public abstract createClone(): HTMLElement;
|
|
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
// ============================================
|
|
|
|
|
// Common Getters/Setters
|
|
|
|
|
// ============================================
|
2025-09-10 22:36:11 +02:00
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
get eventId(): string {
|
|
|
|
|
return this.dataset.eventId || '';
|
|
|
|
|
}
|
|
|
|
|
set eventId(value: string) {
|
|
|
|
|
this.dataset.eventId = value;
|
2025-09-10 22:36:11 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
get start(): Date {
|
|
|
|
|
return new Date(this.dataset.start || '');
|
|
|
|
|
}
|
|
|
|
|
set start(value: Date) {
|
|
|
|
|
this.dataset.start = this.dateService.toUTC(value);
|
2025-09-10 22:36:11 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
get end(): Date {
|
|
|
|
|
return new Date(this.dataset.end || '');
|
|
|
|
|
}
|
|
|
|
|
set end(value: Date) {
|
|
|
|
|
this.dataset.end = this.dateService.toUTC(value);
|
2025-09-10 22:36:11 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
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;
|
2025-09-10 22:36:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-04 15:35:09 +02:00
|
|
|
* Web Component for timed calendar events (Light DOM)
|
2025-09-10 22:36:11 +02:00
|
|
|
*/
|
2025-10-04 15:35:09 +02:00
|
|
|
export class SwpEventElement extends BaseSwpEventElement {
|
2025-09-10 22:36:11 +02:00
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
/**
|
|
|
|
|
* Observed attributes - changes trigger attributeChangedCallback
|
|
|
|
|
*/
|
|
|
|
|
static get observedAttributes() {
|
|
|
|
|
return ['data-start', 'data-end', 'data-title', 'data-type'];
|
2025-09-10 22:36:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-04 15:35:09 +02:00
|
|
|
* Called when element is added to DOM
|
2025-09-10 22:36:11 +02:00
|
|
|
*/
|
2025-10-04 15:35:09 +02:00
|
|
|
connectedCallback() {
|
|
|
|
|
if (!this.hasChildNodes()) {
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
2025-09-10 22:36:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-04 15:35:09 +02:00
|
|
|
* Called when observed attribute changes
|
2025-09-10 22:36:11 +02:00
|
|
|
*/
|
2025-10-04 15:35:09 +02:00
|
|
|
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
|
|
|
|
|
if (oldValue !== newValue && this.isConnected) {
|
|
|
|
|
this.updateDisplay();
|
|
|
|
|
}
|
2025-09-10 22:36:11 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
// ============================================
|
|
|
|
|
// Public Methods
|
|
|
|
|
// ============================================
|
|
|
|
|
|
2025-09-10 22:36:11 +02:00
|
|
|
/**
|
2025-10-04 15:35:09 +02:00
|
|
|
* Update event position during drag
|
|
|
|
|
* @param columnDate - The date of the column
|
|
|
|
|
* @param snappedY - The Y position in pixels
|
2025-09-10 22:36:11 +02:00
|
|
|
*/
|
2025-10-04 15:35:09 +02:00
|
|
|
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;
|
2025-09-10 22:36:11 +02:00
|
|
|
}
|
2025-09-10 23:57:48 +02:00
|
|
|
|
2025-10-08 00:58:38 +02:00
|
|
|
/**
|
|
|
|
|
* 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
|
2025-10-30 23:47:30 +01:00
|
|
|
const gridSettings = this.config.getGridSettings();
|
2025-10-08 19:05:09 +02:00
|
|
|
const { hourHeight, snapInterval } = gridSettings;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
|
|
|
|
// Get current start time
|
|
|
|
|
const start = this.start;
|
|
|
|
|
|
|
|
|
|
// Calculate duration from height
|
2025-10-08 19:05:09 +02:00
|
|
|
const rawDurationMinutes = (newHeight / hourHeight) * 60;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-08 19:05:09 +02:00
|
|
|
// 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);
|
2025-10-08 00:58:38 +02:00
|
|
|
|
|
|
|
|
// 3. Update end attribute (triggers attributeChangedCallback → updateDisplay)
|
|
|
|
|
this.end = endDate;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-20 09:40:56 +02:00
|
|
|
/**
|
2025-10-04 15:35:09 +02:00
|
|
|
* Create a clone for drag operations
|
2025-09-20 09:40:56 +02:00
|
|
|
*/
|
|
|
|
|
public createClone(): SwpEventElement {
|
2025-10-04 15:35:09 +02:00
|
|
|
const clone = this.cloneNode(true) as SwpEventElement;
|
2025-09-27 15:01:22 +02:00
|
|
|
|
2025-09-20 09:40:56 +02:00
|
|
|
// Apply "clone-" prefix to ID
|
2025-10-04 15:35:09 +02:00
|
|
|
clone.dataset.eventId = `clone-${this.eventId}`;
|
2025-09-27 15:01:22 +02:00
|
|
|
|
2025-10-08 00:58:38 +02:00
|
|
|
// Disable pointer events on clone so it doesn't interfere with hover detection
|
|
|
|
|
clone.style.pointerEvents = 'none';
|
|
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
// Cache original duration
|
|
|
|
|
const timeEl = this.querySelector('swp-event-time');
|
|
|
|
|
if (timeEl) {
|
|
|
|
|
const duration = timeEl.getAttribute('data-duration');
|
|
|
|
|
if (duration) {
|
|
|
|
|
clone.dataset.originalDuration = duration;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-27 15:01:22 +02:00
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
// Set height from original
|
|
|
|
|
clone.style.height = this.style.height || `${this.getBoundingClientRect().height}px`;
|
2025-09-27 15:01:22 +02:00
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
return clone;
|
2025-09-20 09:40:56 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
// ============================================
|
|
|
|
|
// Private Methods
|
|
|
|
|
// ============================================
|
|
|
|
|
|
2025-09-20 09:40:56 +02:00
|
|
|
/**
|
2025-10-04 15:35:09 +02:00
|
|
|
* Render inner HTML structure
|
2025-09-20 09:40:56 +02:00
|
|
|
*/
|
2025-10-04 15:35:09 +02:00
|
|
|
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);
|
2025-09-27 15:01:22 +02:00
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
this.innerHTML = `
|
|
|
|
|
<swp-event-time data-duration="${durationMinutes}">${timeRange}</swp-event-time>
|
|
|
|
|
<swp-event-title>${this.title}</swp-event-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());
|
|
|
|
|
}
|
2025-09-27 15:01:22 +02:00
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
if (titleEl && this.dataset.title) {
|
|
|
|
|
titleEl.textContent = this.dataset.title;
|
|
|
|
|
}
|
2025-09-20 09:40:56 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-04 15:35:09 +02:00
|
|
|
* Calculate start/end minutes from Y position
|
2025-09-20 09:40:56 +02:00
|
|
|
*/
|
2025-10-04 15:35:09 +02:00
|
|
|
private calculateTimesFromPosition(snappedY: number): { startMinutes: number; endMinutes: number } {
|
2025-10-30 23:47:30 +01:00
|
|
|
const gridSettings = this.config.getGridSettings();
|
2025-10-04 15:35:09 +02:00
|
|
|
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;
|
2025-10-30 23:47:30 +01:00
|
|
|
const config = new CalendarConfig();
|
|
|
|
|
const dateService = new DateService(config);
|
2025-10-04 15:35:09 +02:00
|
|
|
|
|
|
|
|
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;
|
2025-09-20 09:40:56 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Extract CalendarEvent from DOM element
|
|
|
|
|
*/
|
2025-09-29 18:39:40 +02:00
|
|
|
public static extractCalendarEventFromElement(element: HTMLElement): CalendarEvent {
|
2025-09-20 09:40:56 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-10 22:36:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-04 15:35:09 +02:00
|
|
|
* Web Component for all-day calendar events
|
2025-09-10 22:36:11 +02:00
|
|
|
*/
|
2025-10-04 15:35:09 +02:00
|
|
|
export class SwpAllDayEventElement extends BaseSwpEventElement {
|
2025-09-10 22:36:11 +02:00
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
connectedCallback() {
|
|
|
|
|
if (!this.textContent) {
|
|
|
|
|
this.textContent = this.dataset.title || 'Untitled';
|
|
|
|
|
}
|
2025-09-10 22:36:11 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-04 23:10:09 +02:00
|
|
|
/**
|
|
|
|
|
* Create a clone for drag operations
|
|
|
|
|
*/
|
|
|
|
|
public createClone(): SwpAllDayEventElement {
|
|
|
|
|
const clone = this.cloneNode(true) as SwpAllDayEventElement;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
2025-10-04 23:10:09 +02:00
|
|
|
// Apply "clone-" prefix to ID
|
|
|
|
|
clone.dataset.eventId = `clone-${this.eventId}`;
|
2025-10-08 00:58:38 +02:00
|
|
|
|
|
|
|
|
// Disable pointer events on clone so it doesn't interfere with hover detection
|
|
|
|
|
clone.style.pointerEvents = 'none';
|
|
|
|
|
|
2025-10-09 22:31:49 +02:00
|
|
|
// Preserve full opacity during drag
|
|
|
|
|
clone.style.opacity = '1';
|
|
|
|
|
|
2025-10-04 23:10:09 +02:00
|
|
|
return clone;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-10 22:36:11 +02:00
|
|
|
/**
|
2025-10-04 15:35:09 +02:00
|
|
|
* Apply CSS grid positioning
|
2025-09-10 22:36:11 +02:00
|
|
|
*/
|
2025-10-04 15:35:09 +02:00
|
|
|
public applyGridPositioning(row: number, startColumn: number, endColumn: number): void {
|
|
|
|
|
const gridArea = `${row} / ${startColumn} / ${row + 1} / ${endColumn + 1}`;
|
|
|
|
|
this.style.gridArea = gridArea;
|
2025-09-10 22:36:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-04 15:35:09 +02:00
|
|
|
* Create from CalendarEvent
|
2025-09-10 22:36:11 +02:00
|
|
|
*/
|
2025-10-04 15:35:09 +02:00
|
|
|
public static fromCalendarEvent(event: CalendarEvent): SwpAllDayEventElement {
|
|
|
|
|
const element = document.createElement('swp-allday-event') as SwpAllDayEventElement;
|
2025-10-30 23:47:30 +01:00
|
|
|
const config = new CalendarConfig();
|
|
|
|
|
const dateService = new DateService(config);
|
2025-09-10 22:36:11 +02:00
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
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;
|
2025-10-04 16:20:09 +02:00
|
|
|
element.dataset.allday = 'true';
|
2025-10-04 15:35:09 +02:00
|
|
|
element.textContent = event.title;
|
|
|
|
|
|
|
|
|
|
return element;
|
2025-09-25 23:38:17 +02:00
|
|
|
}
|
2025-10-04 15:35:09 +02:00
|
|
|
}
|
2025-09-25 23:38:17 +02:00
|
|
|
|
2025-10-04 15:35:09 +02:00
|
|
|
// Register custom elements
|
|
|
|
|
customElements.define('swp-event', SwpEventElement);
|
|
|
|
|
customElements.define('swp-allday-event', SwpAllDayEventElement);
|