Introduces DateService for time zone handling

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

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

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

293
src/utils/DateService.ts Normal file
View 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);
}
}