Calendar/src/utils/DateService.ts
Janus C. H. Knudsen 9bc082eed4 Improves date handling and event stacking
Enhances date validation and timezone handling using DateService, ensuring data integrity and consistency.

Refactors event rendering and dragging to correctly handle date transformations.

Adds a test plan for event stacking and z-index management.

Fixes edge cases in navigation and date calculations for week/year boundaries and DST transitions.
2025-10-04 00:32:26 +02:00

508 lines
No EOL
14 KiB
TypeScript

/**
* 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,
addMonths,
isSameDay,
getISOWeek
} 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 "Month Year" (e.g., "January 2025")
* @param date - Date to format
* @param locale - Locale for month name (default: 'en-US')
* @returns Formatted month and year
*/
public formatMonthYear(date: Date, locale: string = 'en-US'): string {
return date.toLocaleDateString(locale, { month: 'long', year: 'numeric' });
}
/**
* 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);
}
/**
* Format time in 12-hour format with AM/PM
* @param date - Date to format
* @returns Time string in 12-hour format (e.g., "2:30 PM")
*/
public formatTime12(date: Date): string {
const hours = getHours(date);
const minutes = getMinutes(date);
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
return `${displayHours}:${String(minutes).padStart(2, '0')} ${period}`;
}
/**
* Get day name for a date
* @param date - Date to get day name for
* @param format - 'short' (e.g., 'Mon') or 'long' (e.g., 'Monday')
* @returns Day name
*/
public getDayName(date: Date, format: 'short' | 'long' = 'short'): string {
const formatter = new Intl.DateTimeFormat('en-US', {
weekday: format
});
return formatter.format(date);
}
/**
* Format a date range with customizable options
* @param start - Start date
* @param end - End date
* @param options - Formatting options
* @returns Formatted date range string
*/
public formatDateRange(
start: Date,
end: Date,
options: {
locale?: string;
month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow';
day?: 'numeric' | '2-digit';
year?: 'numeric' | '2-digit';
} = {}
): string {
const { locale = 'en-US', month = 'short', day = 'numeric' } = options;
const startYear = start.getFullYear();
const endYear = end.getFullYear();
const formatter = new Intl.DateTimeFormat(locale, {
month,
day,
year: startYear !== endYear ? 'numeric' : undefined
});
// @ts-ignore - formatRange is available in modern browsers
if (typeof formatter.formatRange === 'function') {
// @ts-ignore
return formatter.formatRange(start, end);
}
return `${formatter.format(start)} - ${formatter.format(end)}`;
}
// ============================================
// 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);
}
/**
* Add months to a date
* @param date - Base date
* @param months - Number of months to add (can be negative)
* @returns New date
*/
public addMonths(date: Date, months: number): Date {
return addMonths(date, months);
}
/**
* Get ISO week number (1-53)
* @param date - Date to get week number for
* @returns ISO week number
*/
public getWeekNumber(date: Date): number {
return getISOWeek(date);
}
/**
* Get all dates in a full week (7 days starting from given date)
* @param weekStart - Start date of the week
* @returns Array of 7 dates
*/
public getFullWeekDates(weekStart: Date): Date[] {
const dates: Date[] = [];
for (let i = 0; i < 7; i++) {
dates.push(this.addDays(weekStart, i));
}
return dates;
}
/**
* Get dates for work week using ISO 8601 day numbering (Monday=1, Sunday=7)
* @param weekStart - Any date in the week
* @param workDays - Array of ISO day numbers (1=Monday, 7=Sunday)
* @returns Array of dates for the specified work days
*/
public getWorkWeekDates(weekStart: Date, workDays: number[]): Date[] {
const dates: Date[] = [];
// Get Monday of the week
const weekBounds = this.getWeekBounds(weekStart);
const mondayOfWeek = this.startOfDay(weekBounds.start);
// Calculate dates for each work day using ISO numbering
workDays.forEach(isoDay => {
const date = new Date(mondayOfWeek);
// ISO day 1=Monday is +0 days, ISO day 7=Sunday is +6 days
const daysFromMonday = isoDay === 7 ? 6 : isoDay - 1;
date.setDate(mondayOfWeek.getDate() + daysFromMonday);
dates.push(date);
});
return dates;
}
// ============================================
// 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);
}
/**
* Validate date range (start must be before or equal to end)
* @param start - Start date
* @param end - End date
* @returns True if valid range
*/
public isValidRange(start: Date, end: Date): boolean {
if (!this.isValid(start) || !this.isValid(end)) {
return false;
}
return start.getTime() <= end.getTime();
}
/**
* Check if date is within reasonable bounds (1900-2100)
* @param date - Date to check
* @returns True if within bounds
*/
public isWithinBounds(date: Date): boolean {
if (!this.isValid(date)) {
return false;
}
const year = date.getFullYear();
return year >= 1900 && year <= 2100;
}
/**
* Validate date with comprehensive checks
* @param date - Date to validate
* @param options - Validation options
* @returns Validation result with error message
*/
public validateDate(
date: Date,
options: {
requireFuture?: boolean;
requirePast?: boolean;
minDate?: Date;
maxDate?: Date;
} = {}
): { valid: boolean; error?: string } {
if (!this.isValid(date)) {
return { valid: false, error: 'Invalid date' };
}
if (!this.isWithinBounds(date)) {
return { valid: false, error: 'Date out of bounds (1900-2100)' };
}
const now = new Date();
if (options.requireFuture && date <= now) {
return { valid: false, error: 'Date must be in the future' };
}
if (options.requirePast && date >= now) {
return { valid: false, error: 'Date must be in the past' };
}
if (options.minDate && date < options.minDate) {
return { valid: false, error: `Date must be after ${this.formatDate(options.minDate)}` };
}
if (options.maxDate && date > options.maxDate) {
return { valid: false, error: `Date must be before ${this.formatDate(options.maxDate)}` };
}
return { valid: true };
}
/**
* Check if event spans multiple days
* @param start - Start date or ISO string
* @param end - End date or ISO string
* @returns True if spans multiple days
*/
public isMultiDay(start: Date | string, end: Date | string): boolean {
const startDate = typeof start === 'string' ? this.parseISO(start) : start;
const endDate = typeof end === 'string' ? this.parseISO(end) : end;
return !this.isSameDay(startDate, endDate);
}
}