Introduces DateService for time zone handling

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

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

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

View file

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

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

View file

@ -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 {
/**

View file

@ -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<TimeFormatSettings>): 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);
}
}