Introduces DateService for time zone handling

Adds DateService using date-fns-tz for robust time zone
conversions and date manipulations.

Refactors DateCalculator and TimeFormatter to utilize the
DateService, centralizing date logic and ensuring consistent
time zone handling throughout the application.

Improves event dragging by updating time displays and data
attributes, handling cross-midnight events correctly.
This commit is contained in:
Janus C. H. Knudsen 2025-10-03 16:47:42 +02:00
parent 1821d805d1
commit 53cf097a47
8 changed files with 764 additions and 136 deletions

View file

@ -11,6 +11,8 @@ import { PositionUtils } from '../utils/PositionUtils';
import { DragOffset, StackLinkData } from '../types/DragDropTypes';
import { ColumnBounds } from '../utils/ColumnDetectionUtils';
import { DragColumnChangeEventPayload, DragMoveEventPayload, DragStartEventPayload } from '../types/EventTypes';
import { DateService } from '../utils/DateService';
import { format, setHours, setMinutes, setSeconds, addDays } from 'date-fns';
/**
* Interface for event rendering strategies
@ -113,37 +115,95 @@ export class DateEventRenderer implements EventRendererStrategy {
* Update clone timestamp based on new position
*/
private updateCloneTimestamp(payload: DragMoveEventPayload): void {
//important as events can pile up, so they will still fire after event has been converted to another rendered type
if (payload.draggedClone.dataset.allDay == "true") return;
if (payload.draggedClone.dataset.allDay === "true" || !payload.columnBounds) return;
const gridSettings = calendarConfig.getGridSettings();
const hourHeight = gridSettings.hourHeight;
const dayStartHour = gridSettings.dayStartHour;
const snapInterval = gridSettings.snapInterval;
// Calculate minutes from grid start (not from midnight)
const minutesFromGridStart = (payload.snappedY / hourHeight) * 60;
// Add dayStartHour offset to get actual time
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
// Snap to interval
const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval;
if (!payload.draggedClone.dataset.originalDuration)
throw new DOMException("missing clone.dataset.originalDuration")
const endTotalMinutes = snappedStartMinutes + parseInt(payload.draggedClone.dataset.originalDuration);
// Update visual time display only
const timeElement = payload.draggedClone.querySelector('swp-event-time');
if (timeElement) {
let startTime = TimeFormatter.formatTimeFromMinutes(snappedStartMinutes);
let endTime = TimeFormatter.formatTimeFromMinutes(endTotalMinutes);
timeElement.textContent = `${startTime} - ${endTime}`;
const { hourHeight, dayStartHour, snapInterval } = gridSettings;
if (!payload.draggedClone.dataset.originalDuration) {
throw new DOMException("missing clone.dataset.originalDuration");
}
// Calculate snapped start minutes
const minutesFromGridStart = (payload.snappedY / hourHeight) * 60;
const snappedStartMinutes = this.calculateSnappedMinutes(
minutesFromGridStart, dayStartHour, snapInterval
);
// Calculate end minutes
const originalDuration = parseInt(payload.draggedClone.dataset.originalDuration);
const endTotalMinutes = snappedStartMinutes + originalDuration;
// Update UI
this.updateTimeDisplay(payload.draggedClone, snappedStartMinutes, endTotalMinutes);
// Update data attributes
this.updateDateTimeAttributes(
payload.draggedClone,
new Date(payload.columnBounds.date),
snappedStartMinutes,
endTotalMinutes
);
}
/**
* Calculate snapped minutes from grid start
*/
private calculateSnappedMinutes(minutesFromGridStart: number, dayStartHour: number, snapInterval: number): number {
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
return Math.round(actualStartMinutes / snapInterval) * snapInterval;
}
/**
* Update time display in the UI
*/
private updateTimeDisplay(element: HTMLElement, startMinutes: number, endMinutes: number): void {
const timeElement = element.querySelector('swp-event-time');
if (!timeElement) return;
const startTime = this.formatTimeFromMinutes(startMinutes);
const endTime = this.formatTimeFromMinutes(endMinutes);
timeElement.textContent = `${startTime} - ${endTime}`;
}
/**
* Update data-start and data-end attributes with ISO timestamps
*/
private updateDateTimeAttributes(element: HTMLElement, columnDate: Date, startMinutes: number, endMinutes: number): void {
const startDate = this.createDateWithMinutes(columnDate, startMinutes);
let endDate = this.createDateWithMinutes(columnDate, endMinutes);
// Handle cross-midnight events
if (endMinutes >= 1440) {
const extraDays = Math.floor(endMinutes / 1440);
endDate = addDays(endDate, extraDays);
}
element.dataset.start = startDate.toISOString();
element.dataset.end = endDate.toISOString();
}
/**
* Create a date with specific minutes since midnight
*/
private createDateWithMinutes(baseDate: Date, totalMinutes: number): Date {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return setSeconds(setMinutes(setHours(baseDate, hours), minutes), 0);
}
/**
* Format minutes since midnight to time string
*/
private formatTimeFromMinutes(totalMinutes: number): string {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const date = new Date();
date.setHours(hours, minutes, 0, 0);
return format(date, 'HH:mm');
}
/**
@ -209,7 +269,19 @@ export class DateEventRenderer implements EventRendererStrategy {
const eventsLayer = dragColumnChangeEvent.newColumn.element.querySelector('swp-events-layer');
if (eventsLayer && this.draggedClone.parentElement !== eventsLayer) {
eventsLayer.appendChild(this.draggedClone);
// Recalculate timestamps with new column date
const currentTop = parseFloat(this.draggedClone.style.top) || 0;
const mockPayload: DragMoveEventPayload = {
draggedElement: dragColumnChangeEvent.originalElement,
draggedClone: this.draggedClone,
mousePosition: dragColumnChangeEvent.mousePosition,
mouseOffset: { x: 0, y: 0 },
columnBounds: dragColumnChangeEvent.newColumn,
snappedY: currentTop
};
this.updateCloneTimestamp(mockPayload);
}
}
@ -312,14 +384,8 @@ export class DateEventRenderer implements EventRendererStrategy {
draggedClone.classList.remove('dragging');
// Behold z-index hvis det er et stacked event
// Update dataset with new times after successful drop (only for timed events)
if (draggedClone.dataset.displayType !== 'allday') {
const newEvent = SwpEventElement.extractCalendarEventFromElement(draggedClone);
if (newEvent) {
draggedClone.dataset.start = newEvent.start.toISOString();
draggedClone.dataset.end = newEvent.end.toISOString();
}
}
// Data attributes are already updated during drag:move, so no need to update again
// The updateCloneTimestamp method keeps them synchronized throughout the drag operation
// Detect overlaps with other events in the target column and reposition if needed
this.handleDragDropOverlaps(draggedClone, finalColumn);