Extends event model to include optional description field Enhances event rendering with: - New description getter/setter in base event element - Updated CSS grid layout for description display - Dynamic description handling in event rendering - Updated mock event data with descriptions Improves visual information density and event context in calendar view
380 lines
No EOL
11 KiB
TypeScript
380 lines
No EOL
11 KiB
TypeScript
import { ICalendarEvent } from '../types/CalendarTypes';
|
|
import { Configuration } from '../configurations/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: Configuration;
|
|
|
|
constructor() {
|
|
super();
|
|
// Get singleton instance for web components (can't use DI)
|
|
this.config = Configuration.getInstance();
|
|
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 description(): string {
|
|
return this.dataset.description || '';
|
|
}
|
|
set description(value: string) {
|
|
this.dataset.description = 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-description', '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.gridSettings;
|
|
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>
|
|
${this.description ? `<swp-event-description>${this.description}</swp-event-description>` : ''}
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Update time display when attributes change
|
|
*/
|
|
private updateDisplay(): void {
|
|
const timeEl = this.querySelector('swp-event-time');
|
|
const titleEl = this.querySelector('swp-event-title');
|
|
const descEl = this.querySelector('swp-event-description');
|
|
|
|
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;
|
|
}
|
|
|
|
if (this.dataset.description) {
|
|
if (descEl) {
|
|
descEl.textContent = this.dataset.description;
|
|
} else if (this.description) {
|
|
// Add description element if it doesn't exist
|
|
const newDescEl = document.createElement('swp-event-description');
|
|
newDescEl.textContent = this.description;
|
|
this.appendChild(newDescEl);
|
|
}
|
|
} else if (descEl) {
|
|
// Remove description element if description is empty
|
|
descEl.remove();
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Calculate start/end minutes from Y position
|
|
*/
|
|
private calculateTimesFromPosition(snappedY: number): { startMinutes: number; endMinutes: number } {
|
|
const gridSettings = this.config.gridSettings;
|
|
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 ICalendarEvent
|
|
*/
|
|
public static fromCalendarEvent(event: ICalendarEvent): SwpEventElement {
|
|
const element = document.createElement('swp-event') as SwpEventElement;
|
|
const config = Configuration.getInstance();
|
|
const dateService = new DateService(config);
|
|
|
|
element.dataset.eventId = event.id;
|
|
element.dataset.title = event.title;
|
|
element.dataset.description = event.description || '';
|
|
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 ICalendarEvent from DOM element
|
|
*/
|
|
public static extractCalendarEventFromElement(element: HTMLElement): ICalendarEvent {
|
|
return {
|
|
id: element.dataset.eventId || '',
|
|
title: element.dataset.title || '',
|
|
description: element.dataset.description || undefined,
|
|
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 ICalendarEvent
|
|
*/
|
|
public static fromCalendarEvent(event: ICalendarEvent): SwpAllDayEventElement {
|
|
const element = document.createElement('swp-allday-event') as SwpAllDayEventElement;
|
|
const config = Configuration.getInstance();
|
|
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); |