diff --git a/package-lock.json b/package-lock.json index f6e9311..d6b6f5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "dependencies": { "@rollup/rollup-win32-x64-msvc": "^4.52.2", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "fuse.js": "^7.1.0" }, "devDependencies": { @@ -1202,6 +1204,25 @@ "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": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 5d534d1..6beb926 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ }, "dependencies": { "@rollup/rollup-win32-x64-msvc": "^4.52.2", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "fuse.js": "^7.1.0" } } diff --git a/src/renderers/EventRenderer.ts b/src/renderers/EventRenderer.ts index efcc779..2d16267 100644 --- a/src/renderers/EventRenderer.ts +++ b/src/renderers/EventRenderer.ts @@ -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); diff --git a/src/utils/DateCalculator.ts b/src/utils/DateCalculator.ts index 2b51f1d..10b1549 100644 --- a/src/utils/DateCalculator.ts +++ b/src/utils/DateCalculator.ts @@ -1,12 +1,15 @@ /** * DateCalculator - Centralized date calculation logic for calendar + * Now uses DateService internally for all date operations * Handles all date computations with proper week start handling */ import { CalendarConfig } from '../core/CalendarConfig'; +import { DateService } from './DateService'; export class DateCalculator { private static config: CalendarConfig; + private static dateService: DateService = new DateService('Europe/Copenhagen'); /** * Initialize DateCalculator with configuration @@ -14,6 +17,9 @@ export class DateCalculator { */ static initialize(config: CalendarConfig): void { 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 */ 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}`); } } @@ -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 * @returns The Monday of the ISO week */ static getISOWeekStart(date: Date): Date { DateCalculator.validateDate(date, 'getISOWeekStart'); - const monday = new Date(date); - const currentDay = monday.getDay(); - const daysToSubtract = currentDay === 0 ? 6 : currentDay - 1; - monday.setDate(monday.getDate() - daysToSubtract); - monday.setHours(0, 0, 0, 0); - return monday; + const weekBounds = DateCalculator.dateService.getWeekBounds(date); + return DateCalculator.dateService.startOfDay(weekBounds.start); } - /** - * 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 * @returns The end date of the ISO week (Sunday) */ static getWeekEnd(date: Date): Date { DateCalculator.validateDate(date, 'getWeekEnd'); - const weekStart = DateCalculator.getISOWeekStart(date); - const weekEnd = new Date(weekStart); - weekEnd.setDate(weekStart.getDate() + 6); - weekEnd.setHours(23, 59, 59, 999); - return weekEnd; + const weekBounds = DateCalculator.dateService.getWeekBounds(date); + return DateCalculator.dateService.endOfDay(weekBounds.end); } /** @@ -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 * @returns ISO 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 * @returns True if the date is today */ static isToday(date: Date): boolean { - const today = new Date(); - return date.toDateString() === today.toDateString(); + return DateCalculator.dateService.isSameDay(date, new Date()); } /** - * Add days to a date + * Add days to a date using DateService * @param date - Base date * @param days - Number of days to add (can be negative) * @returns New date */ static addDays(date: Date, days: number): Date { - const result = new Date(date); - result.setDate(result.getDate() + days); - return result; + return DateCalculator.dateService.addDays(date, days); } /** - * Add weeks to a date + * Add weeks to a date using DateService * @param date - Base date * @param weeks - Number of weeks to add (can be negative) * @returns New 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 * @returns Time 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 * @returns Time string */ static minutesToTime(minutes: number): string { - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - const period = hours >= 12 ? 'PM' : 'AM'; - const displayHours = hours % 12 || 12; - - return `${displayHours}:${String(mins).padStart(2, '0')} ${period}`; + return DateCalculator.dateService.minutesToTime(minutes); } /** - * Convert time string to minutes since midnight + * Convert time string to minutes since midnight using DateService * @param timeStr - Time string * @returns Minutes since midnight */ static timeToMinutes(timeStr: string): number { - const [time] = timeStr.split('T').pop()!.split('.'); - const [hours, minutes] = time.split(':').map(Number); - return hours * 60 + minutes; + return DateCalculator.dateService.timeToMinutes(timeStr); } /** - * Get minutes since start of day + * Get minutes since start of day using DateService * @param date - Date or ISO string * @returns Minutes since midnight */ static getMinutesSinceMidnight(date: Date | string): number { - const d = typeof date === 'string' ? new Date(date) : date; - return d.getHours() * 60 + d.getMinutes(); + const d = typeof date === 'string' ? DateCalculator.dateService.parseISO(date) : date; + 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 end - End date or ISO string * @returns Duration in minutes */ static getDurationMinutes(start: Date | string, end: Date | string): number { - const startDate = typeof start === 'string' ? new Date(start) : start; - const endDate = typeof end === 'string' ? new Date(end) : end; - return Math.floor((endDate.getTime() - startDate.getTime()) / 60000); + return DateCalculator.dateService.getDurationMinutes(start, end); } /** - * 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 date2 - Second date * @returns True if same day */ 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 */ static isMultiDay(start: Date | string, end: Date | string): boolean { - const startDate = typeof start === 'string' ? new Date(start) : start; - const endDate = typeof end === 'string' ? new Date(end) : end; + const startDate = typeof start === 'string' ? DateCalculator.dateService.parseISO(start) : start; + const endDate = typeof end === 'string' ? DateCalculator.dateService.parseISO(end) : end; return !DateCalculator.isSameDay(startDate, endDate); } diff --git a/src/utils/DateService.ts b/src/utils/DateService.ts new file mode 100644 index 0000000..607022a --- /dev/null +++ b/src/utils/DateService.ts @@ -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); + } +} \ No newline at end of file diff --git a/src/utils/PositionUtils.ts b/src/utils/PositionUtils.ts index 6b8a134..5f6626d 100644 --- a/src/utils/PositionUtils.ts +++ b/src/utils/PositionUtils.ts @@ -6,6 +6,8 @@ import { TimeFormatter } from './TimeFormatter'; /** * PositionUtils - Static positioning utilities using singleton calendarConfig * Focuses on pixel/position calculations while delegating date operations + * + * Note: Uses DateCalculator and TimeFormatter which internally use DateService with date-fns */ export class PositionUtils { /** diff --git a/src/utils/TimeFormatter.ts b/src/utils/TimeFormatter.ts index 09480fd..735b5dc 100644 --- a/src/utils/TimeFormatter.ts +++ b/src/utils/TimeFormatter.ts @@ -1,5 +1,6 @@ /** * 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) * 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 */ +import { DateService } from './DateService'; + export interface TimeFormatSettings { timezone: string; use24HourFormat: boolean; @@ -24,11 +27,15 @@ export class TimeFormatter { showSeconds: false // Don't show seconds by default }; + private static dateService: DateService = new DateService('Europe/Copenhagen'); + /** * Configure time formatting settings */ static configure(settings: Partial): void { 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 - * @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 */ - static convertToLocalTime(utcDate: Date): Date { - // Create a new date to avoid mutating the original - const localDate = new Date(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()); + static convertToLocalTime(utcDate: Date | string): Date { + if (typeof utcDate === 'string') { + return TimeFormatter.dateService.fromUTC(utcDate); } - 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 * @returns Formatted time string (e.g., "09:00") */ static format24Hour(date: Date): string { const localDate = TimeFormatter.convertToLocalTime(date); - - // 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}`; + return TimeFormatter.dateService.formatTime(localDate, TimeFormatter.settings.showSeconds); } /** @@ -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) * @returns Formatted time string */ static formatTimeFromMinutes(totalMinutes: number): string { - const hours = Math.floor(totalMinutes / 60) % 24; - 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); + return TimeFormatter.dateService.formatTimeFromMinutes(totalMinutes); } /** @@ -146,15 +138,15 @@ export class TimeFormatter { } /** - * Format time range (start - end) + * Format time range (start - end) using DateService * @param startDate - Start date * @param endDate - End date * @returns Formatted time range string (e.g., "09:00 - 10:30") */ static formatTimeRange(startDate: Date, endDate: Date): string { - const startTime = TimeFormatter.formatTime(startDate); - const endTime = TimeFormatter.formatTime(endDate); - return `${startTime} - ${endTime}`; + const localStart = TimeFormatter.convertToLocalTime(startDate); + const localEnd = TimeFormatter.convertToLocalTime(endDate); + 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 { - let year = date.getFullYear(); - let month = String(date.getMonth() + 1).padStart(2, '0'); - let day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; + const localDate = TimeFormatter.convertToLocalTime(date); + return TimeFormatter.dateService.formatDate(localDate); } /** - * 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 { - let hours = String(date.getHours()).padStart(2, '0'); - let minutes = String(date.getMinutes()).padStart(2, '0'); - - if (includeSeconds) { - let seconds = String(date.getSeconds()).padStart(2, '0'); - return `${hours}:${minutes}:${seconds}`; - } - return `${hours}:${minutes}`; + const localDate = TimeFormatter.convertToLocalTime(date); + return TimeFormatter.dateService.formatTime(localDate, includeSeconds); } /** - * 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 { - let localDate = TimeFormatter.convertToLocalTime(date); - let dateStr = TimeFormatter.formatDateTechnical(localDate); - let timeStr = TimeFormatter.formatTimeTechnical(localDate, TimeFormatter.settings.showSeconds); - return `${dateStr} ${timeStr}`; + const localDate = TimeFormatter.convertToLocalTime(date); + return TimeFormatter.dateService.formatTechnicalDateTime(localDate); } + /** + * 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); + } } \ No newline at end of file diff --git a/test/utils/DateService.test.ts b/test/utils/DateService.test.ts new file mode 100644 index 0000000..4944c81 --- /dev/null +++ b/test/utils/DateService.test.ts @@ -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 + }); + }); +}); \ No newline at end of file