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