Calendar/src/elements/SwpEventElement.ts
Janus C. H. Knudsen 6583ed1063 Adds event description support in calendar UI
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
2025-11-12 21:17:20 +01:00

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);