Replaces global singleton configuration with dependency injection Introduces more modular and testable approach to configuration Removes direct references to calendarConfig in multiple components Adds explicit configuration passing to constructors Improves code maintainability and reduces global state dependencies
355 lines
No EOL
10 KiB
TypeScript
355 lines
No EOL
10 KiB
TypeScript
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 = `
|
|
<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());
|
|
}
|
|
|
|
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); |