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:
parent
1821d805d1
commit
53cf097a47
8 changed files with 764 additions and 136 deletions
21
package-lock.json
generated
21
package-lock.json
generated
|
|
@ -9,6 +9,8 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rollup/rollup-win32-x64-msvc": "^4.52.2",
|
"@rollup/rollup-win32-x64-msvc": "^4.52.2",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"date-fns-tz": "^3.2.0",
|
||||||
"fuse.js": "^7.1.0"
|
"fuse.js": "^7.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1202,6 +1204,25 @@
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/date-fns-tz": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"date-fns": "^3.0.0 || ^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rollup/rollup-win32-x64-msvc": "^4.52.2",
|
"@rollup/rollup-win32-x64-msvc": "^4.52.2",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"date-fns-tz": "^3.2.0",
|
||||||
"fuse.js": "^7.1.0"
|
"fuse.js": "^7.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import { PositionUtils } from '../utils/PositionUtils';
|
||||||
import { DragOffset, StackLinkData } from '../types/DragDropTypes';
|
import { DragOffset, StackLinkData } from '../types/DragDropTypes';
|
||||||
import { ColumnBounds } from '../utils/ColumnDetectionUtils';
|
import { ColumnBounds } from '../utils/ColumnDetectionUtils';
|
||||||
import { DragColumnChangeEventPayload, DragMoveEventPayload, DragStartEventPayload } from '../types/EventTypes';
|
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
|
* Interface for event rendering strategies
|
||||||
|
|
@ -113,37 +115,95 @@ export class DateEventRenderer implements EventRendererStrategy {
|
||||||
* Update clone timestamp based on new position
|
* Update clone timestamp based on new position
|
||||||
*/
|
*/
|
||||||
private updateCloneTimestamp(payload: DragMoveEventPayload): void {
|
private updateCloneTimestamp(payload: DragMoveEventPayload): void {
|
||||||
|
if (payload.draggedClone.dataset.allDay === "true" || !payload.columnBounds) return;
|
||||||
//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;
|
|
||||||
|
|
||||||
const gridSettings = calendarConfig.getGridSettings();
|
const gridSettings = calendarConfig.getGridSettings();
|
||||||
const hourHeight = gridSettings.hourHeight;
|
const { hourHeight, dayStartHour, snapInterval } = gridSettings;
|
||||||
const dayStartHour = gridSettings.dayStartHour;
|
|
||||||
const snapInterval = gridSettings.snapInterval;
|
|
||||||
|
|
||||||
// Calculate minutes from grid start (not from midnight)
|
if (!payload.draggedClone.dataset.originalDuration) {
|
||||||
|
throw new DOMException("missing clone.dataset.originalDuration");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate snapped start minutes
|
||||||
const minutesFromGridStart = (payload.snappedY / hourHeight) * 60;
|
const minutesFromGridStart = (payload.snappedY / hourHeight) * 60;
|
||||||
|
const snappedStartMinutes = this.calculateSnappedMinutes(
|
||||||
|
minutesFromGridStart, dayStartHour, snapInterval
|
||||||
|
);
|
||||||
|
|
||||||
// Add dayStartHour offset to get actual time
|
// 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;
|
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
|
||||||
|
return Math.round(actualStartMinutes / snapInterval) * snapInterval;
|
||||||
|
}
|
||||||
|
|
||||||
// Snap to interval
|
/**
|
||||||
const snappedStartMinutes = 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);
|
||||||
if (!payload.draggedClone.dataset.originalDuration)
|
const endTime = this.formatTimeFromMinutes(endMinutes);
|
||||||
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}`;
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -210,6 +270,18 @@ export class DateEventRenderer implements EventRendererStrategy {
|
||||||
if (eventsLayer && this.draggedClone.parentElement !== eventsLayer) {
|
if (eventsLayer && this.draggedClone.parentElement !== eventsLayer) {
|
||||||
eventsLayer.appendChild(this.draggedClone);
|
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');
|
draggedClone.classList.remove('dragging');
|
||||||
// Behold z-index hvis det er et stacked event
|
// Behold z-index hvis det er et stacked event
|
||||||
|
|
||||||
// Update dataset with new times after successful drop (only for timed events)
|
// Data attributes are already updated during drag:move, so no need to update again
|
||||||
if (draggedClone.dataset.displayType !== 'allday') {
|
// The updateCloneTimestamp method keeps them synchronized throughout the drag operation
|
||||||
const newEvent = SwpEventElement.extractCalendarEventFromElement(draggedClone);
|
|
||||||
if (newEvent) {
|
|
||||||
draggedClone.dataset.start = newEvent.start.toISOString();
|
|
||||||
draggedClone.dataset.end = newEvent.end.toISOString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect overlaps with other events in the target column and reposition if needed
|
// Detect overlaps with other events in the target column and reposition if needed
|
||||||
this.handleDragDropOverlaps(draggedClone, finalColumn);
|
this.handleDragDropOverlaps(draggedClone, finalColumn);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* DateCalculator - Centralized date calculation logic for calendar
|
* DateCalculator - Centralized date calculation logic for calendar
|
||||||
|
* Now uses DateService internally for all date operations
|
||||||
* Handles all date computations with proper week start handling
|
* Handles all date computations with proper week start handling
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CalendarConfig } from '../core/CalendarConfig';
|
import { CalendarConfig } from '../core/CalendarConfig';
|
||||||
|
import { DateService } from './DateService';
|
||||||
|
|
||||||
export class DateCalculator {
|
export class DateCalculator {
|
||||||
private static config: CalendarConfig;
|
private static config: CalendarConfig;
|
||||||
|
private static dateService: DateService = new DateService('Europe/Copenhagen');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize DateCalculator with configuration
|
* Initialize DateCalculator with configuration
|
||||||
|
|
@ -14,6 +17,9 @@ export class DateCalculator {
|
||||||
*/
|
*/
|
||||||
static initialize(config: CalendarConfig): void {
|
static initialize(config: CalendarConfig): void {
|
||||||
DateCalculator.config = config;
|
DateCalculator.config = config;
|
||||||
|
// Update DateService with timezone from config if available
|
||||||
|
const timezone = config.getTimezone?.() || 'Europe/Copenhagen';
|
||||||
|
DateCalculator.dateService = new DateService(timezone);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -23,7 +29,7 @@ export class DateCalculator {
|
||||||
* @throws Error if date is invalid
|
* @throws Error if date is invalid
|
||||||
*/
|
*/
|
||||||
private static validateDate(date: Date, methodName: string): void {
|
private static validateDate(date: Date, methodName: string): void {
|
||||||
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
|
if (!date || !(date instanceof Date) || !DateCalculator.dateService.isValid(date)) {
|
||||||
throw new Error(`${methodName}: Invalid date provided - ${date}`);
|
throw new Error(`${methodName}: Invalid date provided - ${date}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -55,35 +61,27 @@ export class DateCalculator {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the start of the ISO week (Monday) for a given date
|
* Get the start of the ISO week (Monday) for a given date using DateService
|
||||||
* @param date - Any date in the week
|
* @param date - Any date in the week
|
||||||
* @returns The Monday of the ISO week
|
* @returns The Monday of the ISO week
|
||||||
*/
|
*/
|
||||||
static getISOWeekStart(date: Date): Date {
|
static getISOWeekStart(date: Date): Date {
|
||||||
DateCalculator.validateDate(date, 'getISOWeekStart');
|
DateCalculator.validateDate(date, 'getISOWeekStart');
|
||||||
|
|
||||||
const monday = new Date(date);
|
const weekBounds = DateCalculator.dateService.getWeekBounds(date);
|
||||||
const currentDay = monday.getDay();
|
return DateCalculator.dateService.startOfDay(weekBounds.start);
|
||||||
const daysToSubtract = currentDay === 0 ? 6 : currentDay - 1;
|
|
||||||
monday.setDate(monday.getDate() - daysToSubtract);
|
|
||||||
monday.setHours(0, 0, 0, 0);
|
|
||||||
return monday;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the end of the ISO week for a given date
|
* Get the end of the ISO week for a given date using DateService
|
||||||
* @param date - Any date in the week
|
* @param date - Any date in the week
|
||||||
* @returns The end date of the ISO week (Sunday)
|
* @returns The end date of the ISO week (Sunday)
|
||||||
*/
|
*/
|
||||||
static getWeekEnd(date: Date): Date {
|
static getWeekEnd(date: Date): Date {
|
||||||
DateCalculator.validateDate(date, 'getWeekEnd');
|
DateCalculator.validateDate(date, 'getWeekEnd');
|
||||||
|
|
||||||
const weekStart = DateCalculator.getISOWeekStart(date);
|
const weekBounds = DateCalculator.dateService.getWeekBounds(date);
|
||||||
const weekEnd = new Date(weekStart);
|
return DateCalculator.dateService.endOfDay(weekBounds.end);
|
||||||
weekEnd.setDate(weekStart.getDate() + 6);
|
|
||||||
weekEnd.setHours(23, 59, 59, 999);
|
|
||||||
return weekEnd;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -137,44 +135,41 @@ export class DateCalculator {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a date to ISO date string (YYYY-MM-DD)
|
* Format a date to ISO date string (YYYY-MM-DD) using DateService
|
||||||
* @param date - Date to format
|
* @param date - Date to format
|
||||||
* @returns ISO date string
|
* @returns ISO date string
|
||||||
*/
|
*/
|
||||||
static formatISODate(date: Date): string {
|
static formatISODate(date: Date): string {
|
||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
return DateCalculator.dateService.formatDate(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a date is today
|
* Check if a date is today using DateService
|
||||||
* @param date - Date to check
|
* @param date - Date to check
|
||||||
* @returns True if the date is today
|
* @returns True if the date is today
|
||||||
*/
|
*/
|
||||||
static isToday(date: Date): boolean {
|
static isToday(date: Date): boolean {
|
||||||
const today = new Date();
|
return DateCalculator.dateService.isSameDay(date, new Date());
|
||||||
return date.toDateString() === today.toDateString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add days to a date
|
* Add days to a date using DateService
|
||||||
* @param date - Base date
|
* @param date - Base date
|
||||||
* @param days - Number of days to add (can be negative)
|
* @param days - Number of days to add (can be negative)
|
||||||
* @returns New date
|
* @returns New date
|
||||||
*/
|
*/
|
||||||
static addDays(date: Date, days: number): Date {
|
static addDays(date: Date, days: number): Date {
|
||||||
const result = new Date(date);
|
return DateCalculator.dateService.addDays(date, days);
|
||||||
result.setDate(result.getDate() + days);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add weeks to a date
|
* Add weeks to a date using DateService
|
||||||
* @param date - Base date
|
* @param date - Base date
|
||||||
* @param weeks - Number of weeks to add (can be negative)
|
* @param weeks - Number of weeks to add (can be negative)
|
||||||
* @returns New date
|
* @returns New date
|
||||||
*/
|
*/
|
||||||
static addWeeks(date: Date, weeks: number): Date {
|
static addWeeks(date: Date, weeks: number): Date {
|
||||||
return DateCalculator.addDays(date, weeks * 7);
|
return DateCalculator.dateService.addWeeks(date, weeks);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -204,12 +199,12 @@ export class DateCalculator {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format time to HH:MM
|
* Format time to HH:MM using DateService
|
||||||
* @param date - Date to format
|
* @param date - Date to format
|
||||||
* @returns Time string
|
* @returns Time string
|
||||||
*/
|
*/
|
||||||
static formatTime(date: Date): string {
|
static formatTime(date: Date): string {
|
||||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
return DateCalculator.dateService.formatTime(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -227,60 +222,51 @@ export class DateCalculator {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert minutes since midnight to time string
|
* Convert minutes since midnight to time string using DateService
|
||||||
* @param minutes - Minutes since midnight
|
* @param minutes - Minutes since midnight
|
||||||
* @returns Time string
|
* @returns Time string
|
||||||
*/
|
*/
|
||||||
static minutesToTime(minutes: number): string {
|
static minutesToTime(minutes: number): string {
|
||||||
const hours = Math.floor(minutes / 60);
|
return DateCalculator.dateService.minutesToTime(minutes);
|
||||||
const mins = minutes % 60;
|
|
||||||
const period = hours >= 12 ? 'PM' : 'AM';
|
|
||||||
const displayHours = hours % 12 || 12;
|
|
||||||
|
|
||||||
return `${displayHours}:${String(mins).padStart(2, '0')} ${period}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert time string to minutes since midnight
|
* Convert time string to minutes since midnight using DateService
|
||||||
* @param timeStr - Time string
|
* @param timeStr - Time string
|
||||||
* @returns Minutes since midnight
|
* @returns Minutes since midnight
|
||||||
*/
|
*/
|
||||||
static timeToMinutes(timeStr: string): number {
|
static timeToMinutes(timeStr: string): number {
|
||||||
const [time] = timeStr.split('T').pop()!.split('.');
|
return DateCalculator.dateService.timeToMinutes(timeStr);
|
||||||
const [hours, minutes] = time.split(':').map(Number);
|
|
||||||
return hours * 60 + minutes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get minutes since start of day
|
* Get minutes since start of day using DateService
|
||||||
* @param date - Date or ISO string
|
* @param date - Date or ISO string
|
||||||
* @returns Minutes since midnight
|
* @returns Minutes since midnight
|
||||||
*/
|
*/
|
||||||
static getMinutesSinceMidnight(date: Date | string): number {
|
static getMinutesSinceMidnight(date: Date | string): number {
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
const d = typeof date === 'string' ? DateCalculator.dateService.parseISO(date) : date;
|
||||||
return d.getHours() * 60 + d.getMinutes();
|
return DateCalculator.dateService.getMinutesSinceMidnight(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate duration in minutes between two dates
|
* Calculate duration in minutes between two dates using DateService
|
||||||
* @param start - Start date or ISO string
|
* @param start - Start date or ISO string
|
||||||
* @param end - End date or ISO string
|
* @param end - End date or ISO string
|
||||||
* @returns Duration in minutes
|
* @returns Duration in minutes
|
||||||
*/
|
*/
|
||||||
static getDurationMinutes(start: Date | string, end: Date | string): number {
|
static getDurationMinutes(start: Date | string, end: Date | string): number {
|
||||||
const startDate = typeof start === 'string' ? new Date(start) : start;
|
return DateCalculator.dateService.getDurationMinutes(start, end);
|
||||||
const endDate = typeof end === 'string' ? new Date(end) : end;
|
|
||||||
return Math.floor((endDate.getTime() - startDate.getTime()) / 60000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if two dates are on the same day
|
* Check if two dates are on the same day using DateService
|
||||||
* @param date1 - First date
|
* @param date1 - First date
|
||||||
* @param date2 - Second date
|
* @param date2 - Second date
|
||||||
* @returns True if same day
|
* @returns True if same day
|
||||||
*/
|
*/
|
||||||
static isSameDay(date1: Date, date2: Date): boolean {
|
static isSameDay(date1: Date, date2: Date): boolean {
|
||||||
return date1.toDateString() === date2.toDateString();
|
return DateCalculator.dateService.isSameDay(date1, date2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -290,8 +276,8 @@ export class DateCalculator {
|
||||||
* @returns True if spans multiple days
|
* @returns True if spans multiple days
|
||||||
*/
|
*/
|
||||||
static isMultiDay(start: Date | string, end: Date | string): boolean {
|
static isMultiDay(start: Date | string, end: Date | string): boolean {
|
||||||
const startDate = typeof start === 'string' ? new Date(start) : start;
|
const startDate = typeof start === 'string' ? DateCalculator.dateService.parseISO(start) : start;
|
||||||
const endDate = typeof end === 'string' ? new Date(end) : end;
|
const endDate = typeof end === 'string' ? DateCalculator.dateService.parseISO(end) : end;
|
||||||
return !DateCalculator.isSameDay(startDate, endDate);
|
return !DateCalculator.isSameDay(startDate, endDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
293
src/utils/DateService.ts
Normal file
293
src/utils/DateService.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
/**
|
||||||
|
* DateService - Unified date/time service using date-fns
|
||||||
|
* Handles all date operations, timezone conversions, and formatting
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
parse,
|
||||||
|
addMinutes,
|
||||||
|
differenceInMinutes,
|
||||||
|
startOfDay,
|
||||||
|
endOfDay,
|
||||||
|
setHours,
|
||||||
|
setMinutes as setMins,
|
||||||
|
getHours,
|
||||||
|
getMinutes,
|
||||||
|
parseISO,
|
||||||
|
isValid,
|
||||||
|
addDays,
|
||||||
|
startOfWeek,
|
||||||
|
endOfWeek,
|
||||||
|
addWeeks,
|
||||||
|
isSameDay
|
||||||
|
} from 'date-fns';
|
||||||
|
import {
|
||||||
|
toZonedTime,
|
||||||
|
fromZonedTime,
|
||||||
|
formatInTimeZone
|
||||||
|
} from 'date-fns-tz';
|
||||||
|
|
||||||
|
export class DateService {
|
||||||
|
private timezone: string;
|
||||||
|
|
||||||
|
constructor(timezone: string = 'Europe/Copenhagen') {
|
||||||
|
this.timezone = timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CORE CONVERSIONS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert local date to UTC ISO string
|
||||||
|
* @param localDate - Date in local timezone
|
||||||
|
* @returns ISO string in UTC (with 'Z' suffix)
|
||||||
|
*/
|
||||||
|
public toUTC(localDate: Date): string {
|
||||||
|
return fromZonedTime(localDate, this.timezone).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert UTC ISO string to local date
|
||||||
|
* @param utcString - ISO string in UTC
|
||||||
|
* @returns Date in local timezone
|
||||||
|
*/
|
||||||
|
public fromUTC(utcString: string): Date {
|
||||||
|
return toZonedTime(parseISO(utcString), this.timezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// FORMATTING
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time as HH:mm or HH:mm:ss
|
||||||
|
* @param date - Date to format
|
||||||
|
* @param showSeconds - Include seconds in output
|
||||||
|
* @returns Formatted time string
|
||||||
|
*/
|
||||||
|
public formatTime(date: Date, showSeconds = false): string {
|
||||||
|
const pattern = showSeconds ? 'HH:mm:ss' : 'HH:mm';
|
||||||
|
return format(date, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time range as "HH:mm - HH:mm"
|
||||||
|
* @param start - Start date
|
||||||
|
* @param end - End date
|
||||||
|
* @returns Formatted time range
|
||||||
|
*/
|
||||||
|
public formatTimeRange(start: Date, end: Date): string {
|
||||||
|
return `${this.formatTime(start)} - ${this.formatTime(end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date and time in technical format: yyyy-MM-dd HH:mm:ss
|
||||||
|
* @param date - Date to format
|
||||||
|
* @returns Technical datetime string
|
||||||
|
*/
|
||||||
|
public formatTechnicalDateTime(date: Date): string {
|
||||||
|
return format(date, 'yyyy-MM-dd HH:mm:ss');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date as yyyy-MM-dd
|
||||||
|
* @param date - Date to format
|
||||||
|
* @returns ISO date string
|
||||||
|
*/
|
||||||
|
public formatDate(date: Date): string {
|
||||||
|
return format(date, 'yyyy-MM-dd');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date as ISO string (same as formatDate for compatibility)
|
||||||
|
* @param date - Date to format
|
||||||
|
* @returns ISO date string
|
||||||
|
*/
|
||||||
|
public formatISODate(date: Date): string {
|
||||||
|
return this.formatDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TIME CALCULATIONS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert time string (HH:mm or HH:mm:ss) to total minutes since midnight
|
||||||
|
* @param timeString - Time in format HH:mm or HH:mm:ss
|
||||||
|
* @returns Total minutes since midnight
|
||||||
|
*/
|
||||||
|
public timeToMinutes(timeString: string): number {
|
||||||
|
const parts = timeString.split(':').map(Number);
|
||||||
|
const hours = parts[0] || 0;
|
||||||
|
const minutes = parts[1] || 0;
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert total minutes since midnight to time string HH:mm
|
||||||
|
* @param totalMinutes - Minutes since midnight
|
||||||
|
* @returns Time string in format HH:mm
|
||||||
|
*/
|
||||||
|
public minutesToTime(totalMinutes: number): string {
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
const date = setMins(setHours(new Date(), hours), minutes);
|
||||||
|
return format(date, 'HH:mm');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time from total minutes (alias for minutesToTime)
|
||||||
|
* @param totalMinutes - Minutes since midnight
|
||||||
|
* @returns Time string in format HH:mm
|
||||||
|
*/
|
||||||
|
public formatTimeFromMinutes(totalMinutes: number): string {
|
||||||
|
return this.minutesToTime(totalMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get minutes since midnight for a given date
|
||||||
|
* @param date - Date to calculate from
|
||||||
|
* @returns Minutes since midnight
|
||||||
|
*/
|
||||||
|
public getMinutesSinceMidnight(date: Date): number {
|
||||||
|
return getHours(date) * 60 + getMinutes(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate duration in minutes between two dates
|
||||||
|
* @param start - Start date or ISO string
|
||||||
|
* @param end - End date or ISO string
|
||||||
|
* @returns Duration in minutes
|
||||||
|
*/
|
||||||
|
public getDurationMinutes(start: Date | string, end: Date | string): number {
|
||||||
|
const startDate = typeof start === 'string' ? parseISO(start) : start;
|
||||||
|
const endDate = typeof end === 'string' ? parseISO(end) : end;
|
||||||
|
return differenceInMinutes(endDate, startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// WEEK OPERATIONS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get start and end of week (Monday to Sunday)
|
||||||
|
* @param date - Reference date
|
||||||
|
* @returns Object with start and end dates
|
||||||
|
*/
|
||||||
|
public getWeekBounds(date: Date): { start: Date; end: Date } {
|
||||||
|
return {
|
||||||
|
start: startOfWeek(date, { weekStartsOn: 1 }), // Monday
|
||||||
|
end: endOfWeek(date, { weekStartsOn: 1 }) // Sunday
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add weeks to a date
|
||||||
|
* @param date - Base date
|
||||||
|
* @param weeks - Number of weeks to add (can be negative)
|
||||||
|
* @returns New date
|
||||||
|
*/
|
||||||
|
public addWeeks(date: Date, weeks: number): Date {
|
||||||
|
return addWeeks(date, weeks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GRID HELPERS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a date at a specific time (minutes since midnight)
|
||||||
|
* @param baseDate - Base date (date component)
|
||||||
|
* @param totalMinutes - Minutes since midnight
|
||||||
|
* @returns New date with specified time
|
||||||
|
*/
|
||||||
|
public createDateAtTime(baseDate: Date, totalMinutes: number): Date {
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
return setMins(setHours(startOfDay(baseDate), hours), minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snap date to nearest interval
|
||||||
|
* @param date - Date to snap
|
||||||
|
* @param intervalMinutes - Snap interval in minutes
|
||||||
|
* @returns Snapped date
|
||||||
|
*/
|
||||||
|
public snapToInterval(date: Date, intervalMinutes: number): Date {
|
||||||
|
const minutes = this.getMinutesSinceMidnight(date);
|
||||||
|
const snappedMinutes = Math.round(minutes / intervalMinutes) * intervalMinutes;
|
||||||
|
return this.createDateAtTime(date, snappedMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// UTILITY METHODS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two dates are the same day
|
||||||
|
* @param date1 - First date
|
||||||
|
* @param date2 - Second date
|
||||||
|
* @returns True if same day
|
||||||
|
*/
|
||||||
|
public isSameDay(date1: Date, date2: Date): boolean {
|
||||||
|
return isSameDay(date1, date2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get start of day
|
||||||
|
* @param date - Date
|
||||||
|
* @returns Start of day (00:00:00)
|
||||||
|
*/
|
||||||
|
public startOfDay(date: Date): Date {
|
||||||
|
return startOfDay(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get end of day
|
||||||
|
* @param date - Date
|
||||||
|
* @returns End of day (23:59:59.999)
|
||||||
|
*/
|
||||||
|
public endOfDay(date: Date): Date {
|
||||||
|
return endOfDay(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add days to a date
|
||||||
|
* @param date - Base date
|
||||||
|
* @param days - Number of days to add (can be negative)
|
||||||
|
* @returns New date
|
||||||
|
*/
|
||||||
|
public addDays(date: Date, days: number): Date {
|
||||||
|
return addDays(date, days);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add minutes to a date
|
||||||
|
* @param date - Base date
|
||||||
|
* @param minutes - Number of minutes to add (can be negative)
|
||||||
|
* @returns New date
|
||||||
|
*/
|
||||||
|
public addMinutes(date: Date, minutes: number): Date {
|
||||||
|
return addMinutes(date, minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse ISO string to date
|
||||||
|
* @param isoString - ISO date string
|
||||||
|
* @returns Parsed date
|
||||||
|
*/
|
||||||
|
public parseISO(isoString: string): Date {
|
||||||
|
return parseISO(isoString);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if date is valid
|
||||||
|
* @param date - Date to check
|
||||||
|
* @returns True if valid
|
||||||
|
*/
|
||||||
|
public isValid(date: Date): boolean {
|
||||||
|
return isValid(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ import { TimeFormatter } from './TimeFormatter';
|
||||||
/**
|
/**
|
||||||
* PositionUtils - Static positioning utilities using singleton calendarConfig
|
* PositionUtils - Static positioning utilities using singleton calendarConfig
|
||||||
* Focuses on pixel/position calculations while delegating date operations
|
* Focuses on pixel/position calculations while delegating date operations
|
||||||
|
*
|
||||||
|
* Note: Uses DateCalculator and TimeFormatter which internally use DateService with date-fns
|
||||||
*/
|
*/
|
||||||
export class PositionUtils {
|
export class PositionUtils {
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* TimeFormatter - Centralized time formatting with timezone support
|
* TimeFormatter - Centralized time formatting with timezone support
|
||||||
|
* Now uses DateService internally for all date/time operations
|
||||||
*
|
*
|
||||||
* Handles conversion from UTC/Zulu time to configured timezone (default: Europe/Copenhagen)
|
* Handles conversion from UTC/Zulu time to configured timezone (default: Europe/Copenhagen)
|
||||||
* Supports both 12-hour and 24-hour format configuration
|
* Supports both 12-hour and 24-hour format configuration
|
||||||
|
|
@ -7,6 +8,8 @@
|
||||||
* All events in the system are stored in UTC and must be converted to local timezone
|
* All events in the system are stored in UTC and must be converted to local timezone
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DateService } from './DateService';
|
||||||
|
|
||||||
export interface TimeFormatSettings {
|
export interface TimeFormatSettings {
|
||||||
timezone: string;
|
timezone: string;
|
||||||
use24HourFormat: boolean;
|
use24HourFormat: boolean;
|
||||||
|
|
@ -24,11 +27,15 @@ export class TimeFormatter {
|
||||||
showSeconds: false // Don't show seconds by default
|
showSeconds: false // Don't show seconds by default
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static dateService: DateService = new DateService('Europe/Copenhagen');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure time formatting settings
|
* Configure time formatting settings
|
||||||
*/
|
*/
|
||||||
static configure(settings: Partial<TimeFormatSettings>): void {
|
static configure(settings: Partial<TimeFormatSettings>): void {
|
||||||
TimeFormatter.settings = { ...TimeFormatter.settings, ...settings };
|
TimeFormatter.settings = { ...TimeFormatter.settings, ...settings };
|
||||||
|
// Update DateService with new timezone
|
||||||
|
TimeFormatter.dateService = new DateService(TimeFormatter.settings.timezone);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -40,21 +47,17 @@ export class TimeFormatter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert UTC date to configured timezone
|
* Convert UTC date to configured timezone
|
||||||
* @param utcDate - Date in UTC (or assumed to be UTC)
|
* @param utcDate - Date in UTC (or ISO string)
|
||||||
* @returns Date object adjusted to configured timezone
|
* @returns Date object adjusted to configured timezone
|
||||||
*/
|
*/
|
||||||
static convertToLocalTime(utcDate: Date): Date {
|
static convertToLocalTime(utcDate: Date | string): Date {
|
||||||
// Create a new date to avoid mutating the original
|
if (typeof utcDate === 'string') {
|
||||||
const localDate = new Date(utcDate);
|
return TimeFormatter.dateService.fromUTC(utcDate);
|
||||||
|
|
||||||
// If the date doesn't have timezone info, treat it as UTC
|
|
||||||
// This handles cases where mock data doesn't have 'Z' suffix
|
|
||||||
if (!utcDate.toISOString().endsWith('Z') && utcDate.getTimezoneOffset() === new Date().getTimezoneOffset()) {
|
|
||||||
// Adjust for the fact that we're treating local time as UTC
|
|
||||||
localDate.setMinutes(localDate.getMinutes() + localDate.getTimezoneOffset());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return localDate;
|
// If it's already a Date object, convert to UTC string first, then back to local
|
||||||
|
const utcString = utcDate.toISOString();
|
||||||
|
return TimeFormatter.dateService.fromUTC(utcString);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -85,17 +88,13 @@ export class TimeFormatter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format time in 24-hour format
|
* Format time in 24-hour format using DateService
|
||||||
* @param date - Date to format
|
* @param date - Date to format
|
||||||
* @returns Formatted time string (e.g., "09:00")
|
* @returns Formatted time string (e.g., "09:00")
|
||||||
*/
|
*/
|
||||||
static format24Hour(date: Date): string {
|
static format24Hour(date: Date): string {
|
||||||
const localDate = TimeFormatter.convertToLocalTime(date);
|
const localDate = TimeFormatter.convertToLocalTime(date);
|
||||||
|
return TimeFormatter.dateService.formatTime(localDate, TimeFormatter.settings.showSeconds);
|
||||||
// Always use colon separator, not locale-specific formatting
|
|
||||||
let hours = String(localDate.getHours()).padStart(2, '0');
|
|
||||||
let minutes = String(localDate.getMinutes()).padStart(2, '0');
|
|
||||||
return `${hours}:${minutes}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -110,19 +109,12 @@ export class TimeFormatter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format time from total minutes since midnight
|
* Format time from total minutes since midnight using DateService
|
||||||
* @param totalMinutes - Minutes since midnight (e.g., 540 for 9:00 AM)
|
* @param totalMinutes - Minutes since midnight (e.g., 540 for 9:00 AM)
|
||||||
* @returns Formatted time string
|
* @returns Formatted time string
|
||||||
*/
|
*/
|
||||||
static formatTimeFromMinutes(totalMinutes: number): string {
|
static formatTimeFromMinutes(totalMinutes: number): string {
|
||||||
const hours = Math.floor(totalMinutes / 60) % 24;
|
return TimeFormatter.dateService.formatTimeFromMinutes(totalMinutes);
|
||||||
const minutes = totalMinutes % 60;
|
|
||||||
|
|
||||||
// Create a date object for today with the specified time
|
|
||||||
const date = new Date();
|
|
||||||
date.setHours(hours, minutes, 0, 0);
|
|
||||||
|
|
||||||
return TimeFormatter.formatTime(date);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -146,15 +138,15 @@ export class TimeFormatter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format time range (start - end)
|
* Format time range (start - end) using DateService
|
||||||
* @param startDate - Start date
|
* @param startDate - Start date
|
||||||
* @param endDate - End date
|
* @param endDate - End date
|
||||||
* @returns Formatted time range string (e.g., "09:00 - 10:30")
|
* @returns Formatted time range string (e.g., "09:00 - 10:30")
|
||||||
*/
|
*/
|
||||||
static formatTimeRange(startDate: Date, endDate: Date): string {
|
static formatTimeRange(startDate: Date, endDate: Date): string {
|
||||||
const startTime = TimeFormatter.formatTime(startDate);
|
const localStart = TimeFormatter.convertToLocalTime(startDate);
|
||||||
const endTime = TimeFormatter.formatTime(endDate);
|
const localEnd = TimeFormatter.convertToLocalTime(endDate);
|
||||||
return `${startTime} - ${endTime}`;
|
return TimeFormatter.dateService.formatTimeRange(localStart, localEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -187,37 +179,44 @@ export class TimeFormatter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format date in technical format: yyyy-mm-dd
|
* Format date in technical format: yyyy-mm-dd using DateService
|
||||||
*/
|
*/
|
||||||
static formatDateTechnical(date: Date): string {
|
static formatDateTechnical(date: Date): string {
|
||||||
let year = date.getFullYear();
|
const localDate = TimeFormatter.convertToLocalTime(date);
|
||||||
let month = String(date.getMonth() + 1).padStart(2, '0');
|
return TimeFormatter.dateService.formatDate(localDate);
|
||||||
let day = String(date.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format time in technical format: hh:mm or hh:mm:ss
|
* Format time in technical format: hh:mm or hh:mm:ss using DateService
|
||||||
*/
|
*/
|
||||||
static formatTimeTechnical(date: Date, includeSeconds: boolean = false): string {
|
static formatTimeTechnical(date: Date, includeSeconds: boolean = false): string {
|
||||||
let hours = String(date.getHours()).padStart(2, '0');
|
const localDate = TimeFormatter.convertToLocalTime(date);
|
||||||
let minutes = String(date.getMinutes()).padStart(2, '0');
|
return TimeFormatter.dateService.formatTime(localDate, includeSeconds);
|
||||||
|
|
||||||
if (includeSeconds) {
|
|
||||||
let seconds = String(date.getSeconds()).padStart(2, '0');
|
|
||||||
return `${hours}:${minutes}:${seconds}`;
|
|
||||||
}
|
|
||||||
return `${hours}:${minutes}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format date and time in technical format: yyyy-mm-dd hh:mm:ss
|
* Format date and time in technical format: yyyy-mm-dd hh:mm:ss using DateService
|
||||||
*/
|
*/
|
||||||
static formatDateTimeTechnical(date: Date): string {
|
static formatDateTimeTechnical(date: Date): string {
|
||||||
let localDate = TimeFormatter.convertToLocalTime(date);
|
const localDate = TimeFormatter.convertToLocalTime(date);
|
||||||
let dateStr = TimeFormatter.formatDateTechnical(localDate);
|
return TimeFormatter.dateService.formatTechnicalDateTime(localDate);
|
||||||
let timeStr = TimeFormatter.formatTimeTechnical(localDate, TimeFormatter.settings.showSeconds);
|
|
||||||
return `${dateStr} ${timeStr}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert local date to UTC ISO string using DateService
|
||||||
|
* @param localDate - Date in local timezone
|
||||||
|
* @returns ISO string in UTC (with 'Z' suffix)
|
||||||
|
*/
|
||||||
|
static toUTC(localDate: Date): string {
|
||||||
|
return TimeFormatter.dateService.toUTC(localDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert UTC ISO string to local date using DateService
|
||||||
|
* @param utcString - ISO string in UTC
|
||||||
|
* @returns Date in local timezone
|
||||||
|
*/
|
||||||
|
static fromUTC(utcString: string): Date {
|
||||||
|
return TimeFormatter.dateService.fromUTC(utcString);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
259
test/utils/DateService.test.ts
Normal file
259
test/utils/DateService.test.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { DateService } from '../../src/utils/DateService';
|
||||||
|
|
||||||
|
describe('DateService', () => {
|
||||||
|
let dateService: DateService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dateService = new DateService('Europe/Copenhagen');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Core Conversions', () => {
|
||||||
|
it('should convert local date to UTC', () => {
|
||||||
|
// 2024-01-15 10:00:00 Copenhagen (UTC+1 in winter)
|
||||||
|
const localDate = new Date(2024, 0, 15, 10, 0, 0);
|
||||||
|
const utcString = dateService.toUTC(localDate);
|
||||||
|
|
||||||
|
// Should be 09:00:00 UTC
|
||||||
|
expect(utcString).toContain('2024-01-15T09:00:00');
|
||||||
|
expect(utcString).toContain('Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert UTC to local date', () => {
|
||||||
|
const utcString = '2024-01-15T09:00:00.000Z';
|
||||||
|
const localDate = dateService.fromUTC(utcString);
|
||||||
|
|
||||||
|
// Should be 10:00 in Copenhagen (UTC+1)
|
||||||
|
expect(localDate.getHours()).toBe(10);
|
||||||
|
expect(localDate.getMinutes()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle summer time (DST)', () => {
|
||||||
|
// 2024-07-15 10:00:00 Copenhagen (UTC+2 in summer)
|
||||||
|
const localDate = new Date(2024, 6, 15, 10, 0, 0);
|
||||||
|
const utcString = dateService.toUTC(localDate);
|
||||||
|
|
||||||
|
// Should be 08:00:00 UTC
|
||||||
|
expect(utcString).toContain('2024-07-15T08:00:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Time Formatting', () => {
|
||||||
|
it('should format time without seconds', () => {
|
||||||
|
const date = new Date(2024, 0, 15, 14, 30, 45);
|
||||||
|
const formatted = dateService.formatTime(date);
|
||||||
|
|
||||||
|
expect(formatted).toBe('14:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format time with seconds', () => {
|
||||||
|
const date = new Date(2024, 0, 15, 14, 30, 45);
|
||||||
|
const formatted = dateService.formatTime(date, true);
|
||||||
|
|
||||||
|
expect(formatted).toBe('14:30:45');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format time range', () => {
|
||||||
|
const start = new Date(2024, 0, 15, 9, 0, 0);
|
||||||
|
const end = new Date(2024, 0, 15, 10, 30, 0);
|
||||||
|
const formatted = dateService.formatTimeRange(start, end);
|
||||||
|
|
||||||
|
expect(formatted).toBe('09:00 - 10:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format technical datetime', () => {
|
||||||
|
const date = new Date(2024, 0, 15, 14, 30, 45);
|
||||||
|
const formatted = dateService.formatTechnicalDateTime(date);
|
||||||
|
|
||||||
|
expect(formatted).toBe('2024-01-15 14:30:45');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format date as ISO', () => {
|
||||||
|
const date = new Date(2024, 0, 15, 14, 30, 0);
|
||||||
|
const formatted = dateService.formatDate(date);
|
||||||
|
|
||||||
|
expect(formatted).toBe('2024-01-15');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Time Calculations', () => {
|
||||||
|
it('should convert time string to minutes', () => {
|
||||||
|
expect(dateService.timeToMinutes('09:00')).toBe(540);
|
||||||
|
expect(dateService.timeToMinutes('14:30')).toBe(870);
|
||||||
|
expect(dateService.timeToMinutes('00:00')).toBe(0);
|
||||||
|
expect(dateService.timeToMinutes('23:59')).toBe(1439);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert minutes to time string', () => {
|
||||||
|
expect(dateService.minutesToTime(540)).toBe('09:00');
|
||||||
|
expect(dateService.minutesToTime(870)).toBe('14:30');
|
||||||
|
expect(dateService.minutesToTime(0)).toBe('00:00');
|
||||||
|
expect(dateService.minutesToTime(1439)).toBe('23:59');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get minutes since midnight', () => {
|
||||||
|
const date = new Date(2024, 0, 15, 14, 30, 0);
|
||||||
|
const minutes = dateService.getMinutesSinceMidnight(date);
|
||||||
|
|
||||||
|
expect(minutes).toBe(870); // 14*60 + 30
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate duration in minutes', () => {
|
||||||
|
const start = new Date(2024, 0, 15, 9, 0, 0);
|
||||||
|
const end = new Date(2024, 0, 15, 10, 30, 0);
|
||||||
|
const duration = dateService.getDurationMinutes(start, end);
|
||||||
|
|
||||||
|
expect(duration).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate duration from ISO strings', () => {
|
||||||
|
const start = '2024-01-15T09:00:00.000Z';
|
||||||
|
const end = '2024-01-15T10:30:00.000Z';
|
||||||
|
const duration = dateService.getDurationMinutes(start, end);
|
||||||
|
|
||||||
|
expect(duration).toBe(90);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Week Operations', () => {
|
||||||
|
it('should get week bounds (Monday to Sunday)', () => {
|
||||||
|
// Wednesday, January 17, 2024
|
||||||
|
const date = new Date(2024, 0, 17);
|
||||||
|
const bounds = dateService.getWeekBounds(date);
|
||||||
|
|
||||||
|
// Should start on Monday, January 15
|
||||||
|
expect(bounds.start.getDate()).toBe(15);
|
||||||
|
expect(bounds.start.getDay()).toBe(1); // Monday
|
||||||
|
|
||||||
|
// Should end on Sunday, January 21
|
||||||
|
expect(bounds.end.getDate()).toBe(21);
|
||||||
|
expect(bounds.end.getDay()).toBe(0); // Sunday
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add weeks', () => {
|
||||||
|
const date = new Date(2024, 0, 15);
|
||||||
|
const newDate = dateService.addWeeks(date, 2);
|
||||||
|
|
||||||
|
expect(newDate.getDate()).toBe(29);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should subtract weeks', () => {
|
||||||
|
const date = new Date(2024, 0, 15);
|
||||||
|
const newDate = dateService.addWeeks(date, -1);
|
||||||
|
|
||||||
|
expect(newDate.getDate()).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Grid Helpers', () => {
|
||||||
|
it('should create date at specific time', () => {
|
||||||
|
const baseDate = new Date(2024, 0, 15);
|
||||||
|
const date = dateService.createDateAtTime(baseDate, 870); // 14:30
|
||||||
|
|
||||||
|
expect(date.getHours()).toBe(14);
|
||||||
|
expect(date.getMinutes()).toBe(30);
|
||||||
|
expect(date.getDate()).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should snap to 15-minute interval', () => {
|
||||||
|
const date = new Date(2024, 0, 15, 14, 37, 0); // 14:37
|
||||||
|
const snapped = dateService.snapToInterval(date, 15);
|
||||||
|
|
||||||
|
// 14:37 is closer to 14:30 than 14:45, so should snap to 14:30
|
||||||
|
expect(snapped.getHours()).toBe(14);
|
||||||
|
expect(snapped.getMinutes()).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should snap to 30-minute interval', () => {
|
||||||
|
const date = new Date(2024, 0, 15, 14, 20, 0); // 14:20
|
||||||
|
const snapped = dateService.snapToInterval(date, 30);
|
||||||
|
|
||||||
|
// Should snap to 14:30
|
||||||
|
expect(snapped.getHours()).toBe(14);
|
||||||
|
expect(snapped.getMinutes()).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Utility Methods', () => {
|
||||||
|
it('should check if same day', () => {
|
||||||
|
const date1 = new Date(2024, 0, 15, 10, 0, 0);
|
||||||
|
const date2 = new Date(2024, 0, 15, 14, 30, 0);
|
||||||
|
const date3 = new Date(2024, 0, 16, 10, 0, 0);
|
||||||
|
|
||||||
|
expect(dateService.isSameDay(date1, date2)).toBe(true);
|
||||||
|
expect(dateService.isSameDay(date1, date3)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get start of day', () => {
|
||||||
|
const date = new Date(2024, 0, 15, 14, 30, 45);
|
||||||
|
const start = dateService.startOfDay(date);
|
||||||
|
|
||||||
|
expect(start.getHours()).toBe(0);
|
||||||
|
expect(start.getMinutes()).toBe(0);
|
||||||
|
expect(start.getSeconds()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get end of day', () => {
|
||||||
|
const date = new Date(2024, 0, 15, 14, 30, 45);
|
||||||
|
const end = dateService.endOfDay(date);
|
||||||
|
|
||||||
|
expect(end.getHours()).toBe(23);
|
||||||
|
expect(end.getMinutes()).toBe(59);
|
||||||
|
expect(end.getSeconds()).toBe(59);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add days', () => {
|
||||||
|
const date = new Date(2024, 0, 15);
|
||||||
|
const newDate = dateService.addDays(date, 5);
|
||||||
|
|
||||||
|
expect(newDate.getDate()).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add minutes', () => {
|
||||||
|
const date = new Date(2024, 0, 15, 10, 0, 0);
|
||||||
|
const newDate = dateService.addMinutes(date, 90);
|
||||||
|
|
||||||
|
expect(newDate.getHours()).toBe(11);
|
||||||
|
expect(newDate.getMinutes()).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse ISO string', () => {
|
||||||
|
const isoString = '2024-01-15T10:30:00.000Z';
|
||||||
|
const date = dateService.parseISO(isoString);
|
||||||
|
|
||||||
|
expect(date.toISOString()).toBe(isoString);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate dates', () => {
|
||||||
|
const validDate = new Date(2024, 0, 15);
|
||||||
|
const invalidDate = new Date('invalid');
|
||||||
|
|
||||||
|
expect(dateService.isValid(validDate)).toBe(true);
|
||||||
|
expect(dateService.isValid(invalidDate)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle midnight', () => {
|
||||||
|
const date = new Date(2024, 0, 15, 0, 0, 0);
|
||||||
|
const minutes = dateService.getMinutesSinceMidnight(date);
|
||||||
|
|
||||||
|
expect(minutes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle end of day', () => {
|
||||||
|
const date = new Date(2024, 0, 15, 23, 59, 0);
|
||||||
|
const minutes = dateService.getMinutesSinceMidnight(date);
|
||||||
|
|
||||||
|
expect(minutes).toBe(1439);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cross-midnight duration', () => {
|
||||||
|
const start = new Date(2024, 0, 15, 23, 0, 0);
|
||||||
|
const end = new Date(2024, 0, 16, 1, 0, 0);
|
||||||
|
const duration = dateService.getDurationMinutes(start, end);
|
||||||
|
|
||||||
|
expect(duration).toBe(120); // 2 hours
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue