From 4859f42450ec3dead8cbb606599f1ad5ba3c5d9b Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Fri, 3 Oct 2025 19:48:04 +0200 Subject: [PATCH] Enhances date handling and formatting Improves date validation and adds flexible date/time formatting capabilities. The date validation is updated to return a boolean and is incorporated directly into calling functions to throw errors, improving code readability and maintainability. DateService is extended with functions for formatting time in 12-hour format, getting day names, and formatting date ranges with customizable options. --- src/utils/DateCalculator.ts | 26 ++- src/utils/DateService.ts | 154 +++++++++++++-- test/utils/DateCalculator.test.ts | 310 ++++++++++++++++++++++++++++++ 3 files changed, 465 insertions(+), 25 deletions(-) create mode 100644 test/utils/DateCalculator.test.ts diff --git a/src/utils/DateCalculator.ts b/src/utils/DateCalculator.ts index 10b1549..d9d5d37 100644 --- a/src/utils/DateCalculator.ts +++ b/src/utils/DateCalculator.ts @@ -25,13 +25,10 @@ export class DateCalculator { /** * Validate that a date is valid * @param date - Date to validate - * @param methodName - Name of calling method for error messages - * @throws Error if date is invalid + * @returns True if date is valid, false otherwise */ - private static validateDate(date: Date, methodName: string): void { - if (!date || !(date instanceof Date) || !DateCalculator.dateService.isValid(date)) { - throw new Error(`${methodName}: Invalid date provided - ${date}`); - } + private static validateDate(date: Date): boolean { + return date && date instanceof Date && DateCalculator.dateService.isValid(date); } /** @@ -40,7 +37,9 @@ export class DateCalculator { * @returns Array of dates for the configured work days */ static getWorkWeekDates(weekStart: Date): Date[] { - DateCalculator.validateDate(weekStart, 'getWorkWeekDates'); + if (!DateCalculator.validateDate(weekStart)) { + throw new Error('getWorkWeekDates: Invalid date provided'); + } const dates: Date[] = []; const workWeekSettings = DateCalculator.config.getWorkWeekSettings(); @@ -66,7 +65,9 @@ export class DateCalculator { * @returns The Monday of the ISO week */ static getISOWeekStart(date: Date): Date { - DateCalculator.validateDate(date, 'getISOWeekStart'); + if (!DateCalculator.validateDate(date)) { + throw new Error('getISOWeekStart: Invalid date provided'); + } const weekBounds = DateCalculator.dateService.getWeekBounds(date); return DateCalculator.dateService.startOfDay(weekBounds.start); @@ -78,7 +79,9 @@ export class DateCalculator { * @returns The end date of the ISO week (Sunday) */ static getWeekEnd(date: Date): Date { - DateCalculator.validateDate(date, 'getWeekEnd'); + if (!DateCalculator.validateDate(date)) { + throw new Error('getWeekEnd: Invalid date provided'); + } const weekBounds = DateCalculator.dateService.getWeekBounds(date); return DateCalculator.dateService.endOfDay(weekBounds.end); @@ -137,9 +140,12 @@ export class DateCalculator { /** * Format a date to ISO date string (YYYY-MM-DD) using DateService * @param date - Date to format - * @returns ISO date string + * @returns ISO date string or empty string if invalid */ static formatISODate(date: Date): string { + if (!DateCalculator.validateDate(date)) { + return ''; + } return DateCalculator.dateService.formatDate(date); } diff --git a/src/utils/DateService.ts b/src/utils/DateService.ts index 607022a..1ccfea8 100644 --- a/src/utils/DateService.ts +++ b/src/utils/DateService.ts @@ -3,24 +3,25 @@ * Handles all date operations, timezone conversions, and formatting */ -import { - format, - parse, - addMinutes, - differenceInMinutes, - startOfDay, +import { + format, + parse, + addMinutes, + differenceInMinutes, + startOfDay, endOfDay, - setHours, - setMinutes as setMins, - getHours, - getMinutes, + setHours, + setMinutes as setMins, + getHours, + getMinutes, parseISO, - isValid, - addDays, - startOfWeek, - endOfWeek, + isValid, + addDays, + startOfWeek, + endOfWeek, addWeeks, - isSameDay + isSameDay, + getISOWeek } from 'date-fns'; import { toZonedTime, @@ -109,6 +110,70 @@ export class DateService { 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 // ============================================ @@ -193,6 +258,53 @@ export class DateService { return addWeeks(date, weeks); } + /** + * 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 // ============================================ @@ -290,4 +402,16 @@ export class DateService { 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); + } } \ No newline at end of file diff --git a/test/utils/DateCalculator.test.ts b/test/utils/DateCalculator.test.ts new file mode 100644 index 0000000..72f6035 --- /dev/null +++ b/test/utils/DateCalculator.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { DateCalculator } from '../../src/utils/DateCalculator'; +import { CalendarConfig } from '../../src/core/CalendarConfig'; + +describe('DateCalculator', () => { + let testConfig: CalendarConfig; + + beforeEach(() => { + testConfig = new CalendarConfig(); + DateCalculator.initialize(testConfig); + }); + + describe('Week Operations', () => { + it('should get ISO week start (Monday)', () => { + // Wednesday, January 17, 2024 + const date = new Date(2024, 0, 17); + const weekStart = DateCalculator.getISOWeekStart(date); + + // Should be Monday, January 15 + expect(weekStart.getDate()).toBe(15); + expect(weekStart.getDay()).toBe(1); // Monday + expect(weekStart.getHours()).toBe(0); + expect(weekStart.getMinutes()).toBe(0); + }); + + it('should get ISO week start for Sunday', () => { + // Sunday, January 21, 2024 + const date = new Date(2024, 0, 21); + const weekStart = DateCalculator.getISOWeekStart(date); + + // Should be Monday, January 15 + expect(weekStart.getDate()).toBe(15); + expect(weekStart.getDay()).toBe(1); + }); + + it('should get week end (Sunday)', () => { + // Wednesday, January 17, 2024 + const date = new Date(2024, 0, 17); + const weekEnd = DateCalculator.getWeekEnd(date); + + // Should be Sunday, January 21 + expect(weekEnd.getDate()).toBe(21); + expect(weekEnd.getDay()).toBe(0); // Sunday + expect(weekEnd.getHours()).toBe(23); + expect(weekEnd.getMinutes()).toBe(59); + }); + + it('should get work week dates (Mon-Fri)', () => { + const date = new Date(2024, 0, 17); // Wednesday + const workDays = DateCalculator.getWorkWeekDates(date); + + expect(workDays).toHaveLength(5); + expect(workDays[0].getDay()).toBe(1); // Monday + expect(workDays[4].getDay()).toBe(5); // Friday + }); + + it('should get full week dates (7 days)', () => { + const weekStart = new Date(2024, 0, 15); // Monday + const fullWeek = DateCalculator.getFullWeekDates(weekStart); + + expect(fullWeek).toHaveLength(7); + expect(fullWeek[0].getDay()).toBe(1); // Monday + expect(fullWeek[6].getDay()).toBe(0); // Sunday + }); + + it('should calculate ISO week number', () => { + const date1 = new Date(2024, 0, 1); // January 1, 2024 + const weekNum1 = DateCalculator.getWeekNumber(date1); + expect(weekNum1).toBe(1); + + const date2 = new Date(2024, 0, 15); // January 15, 2024 + const weekNum2 = DateCalculator.getWeekNumber(date2); + expect(weekNum2).toBe(3); + }); + + it('should handle year boundary for week numbers', () => { + const date = new Date(2023, 11, 31); // December 31, 2023 + const weekNum = DateCalculator.getWeekNumber(date); + // Week 52 or 53 depending on year + expect(weekNum).toBeGreaterThanOrEqual(52); + }); + }); + + describe('Date Manipulation', () => { + it('should add days', () => { + const date = new Date(2024, 0, 15); + const newDate = DateCalculator.addDays(date, 5); + + expect(newDate.getDate()).toBe(20); + expect(newDate.getMonth()).toBe(0); + }); + + it('should subtract days', () => { + const date = new Date(2024, 0, 15); + const newDate = DateCalculator.addDays(date, -5); + + expect(newDate.getDate()).toBe(10); + }); + + it('should add weeks', () => { + const date = new Date(2024, 0, 15); + const newDate = DateCalculator.addWeeks(date, 2); + + expect(newDate.getDate()).toBe(29); + }); + + it('should subtract weeks', () => { + const date = new Date(2024, 0, 15); + const newDate = DateCalculator.addWeeks(date, -1); + + expect(newDate.getDate()).toBe(8); + }); + + it('should handle month boundaries when adding days', () => { + const date = new Date(2024, 0, 30); // January 30 + const newDate = DateCalculator.addDays(date, 5); + + expect(newDate.getDate()).toBe(4); // February 4 + expect(newDate.getMonth()).toBe(1); + }); + }); + + describe('Time Formatting', () => { + it('should format time (24-hour)', () => { + const date = new Date(2024, 0, 15, 14, 30, 45); + const formatted = DateCalculator.formatTime(date); + + expect(formatted).toBe('14:30'); + }); + + it('should format time (12-hour)', () => { + const date1 = new Date(2024, 0, 15, 14, 30, 0); + const formatted1 = DateCalculator.formatTime12(date1); + expect(formatted1).toBe('2:30 PM'); + + const date2 = new Date(2024, 0, 15, 9, 15, 0); + const formatted2 = DateCalculator.formatTime12(date2); + expect(formatted2).toBe('9:15 AM'); + + const date3 = new Date(2024, 0, 15, 0, 0, 0); + const formatted3 = DateCalculator.formatTime12(date3); + expect(formatted3).toBe('12:00 AM'); + }); + + it('should format ISO date', () => { + const date = new Date(2024, 0, 15, 14, 30, 0); + const formatted = DateCalculator.formatISODate(date); + + expect(formatted).toBe('2024-01-15'); + }); + + it('should format date range', () => { + const start = new Date(2024, 0, 15); + const end = new Date(2024, 0, 21); + const formatted = DateCalculator.formatDateRange(start, end); + + expect(formatted).toContain('Jan'); + expect(formatted).toContain('15'); + expect(formatted).toContain('21'); + }); + + it('should get day name (short)', () => { + const monday = new Date(2024, 0, 15); // Monday + const dayName = DateCalculator.getDayName(monday, 'short'); + + expect(dayName).toBe('Mon'); + }); + + it('should get day name (long)', () => { + const monday = new Date(2024, 0, 15); // Monday + const dayName = DateCalculator.getDayName(monday, 'long'); + + expect(dayName).toBe('Monday'); + }); + }); + + describe('Time Calculations', () => { + it('should convert time string to minutes', () => { + expect(DateCalculator.timeToMinutes('09:00')).toBe(540); + expect(DateCalculator.timeToMinutes('14:30')).toBe(870); + expect(DateCalculator.timeToMinutes('00:00')).toBe(0); + expect(DateCalculator.timeToMinutes('23:59')).toBe(1439); + }); + + it('should convert minutes to time string', () => { + expect(DateCalculator.minutesToTime(540)).toBe('09:00'); + expect(DateCalculator.minutesToTime(870)).toBe('14:30'); + expect(DateCalculator.minutesToTime(0)).toBe('00:00'); + expect(DateCalculator.minutesToTime(1439)).toBe('23:59'); + }); + + it('should get minutes since midnight from Date', () => { + const date = new Date(2024, 0, 15, 14, 30, 0); + const minutes = DateCalculator.getMinutesSinceMidnight(date); + + expect(minutes).toBe(870); // 14*60 + 30 + }); + + it('should get minutes since midnight from ISO string', () => { + const isoString = '2024-01-15T14:30:00.000Z'; + const minutes = DateCalculator.getMinutesSinceMidnight(isoString); + + // Note: This will be in local time after parsing + expect(minutes).toBeGreaterThanOrEqual(0); + expect(minutes).toBeLessThan(1440); + }); + + 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 = DateCalculator.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 = DateCalculator.getDurationMinutes(start, end); + + expect(duration).toBe(90); + }); + + 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 = DateCalculator.getDurationMinutes(start, end); + + expect(duration).toBe(120); // 2 hours + }); + }); + + describe('Date Comparisons', () => { + it('should check if date is today', () => { + const today = new Date(); + const yesterday = DateCalculator.addDays(new Date(), -1); + + expect(DateCalculator.isToday(today)).toBe(true); + expect(DateCalculator.isToday(yesterday)).toBe(false); + }); + + 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(DateCalculator.isSameDay(date1, date2)).toBe(true); + expect(DateCalculator.isSameDay(date1, date3)).toBe(false); + }); + + it('should check if multi-day event (Date objects)', () => { + const start = new Date(2024, 0, 15, 10, 0, 0); + const end1 = new Date(2024, 0, 15, 14, 0, 0); + const end2 = new Date(2024, 0, 16, 10, 0, 0); + + expect(DateCalculator.isMultiDay(start, end1)).toBe(false); + expect(DateCalculator.isMultiDay(start, end2)).toBe(true); + }); + + it('should check if multi-day event (ISO strings)', () => { + const start = '2024-01-15T10:00:00.000Z'; + const end1 = '2024-01-15T14:00:00.000Z'; + const end2 = '2024-01-16T10:00:00.000Z'; + + expect(DateCalculator.isMultiDay(start, end1)).toBe(false); + expect(DateCalculator.isMultiDay(start, end2)).toBe(true); + }); + }); + + describe('Edge Cases', () => { + it('should handle midnight', () => { + const date = new Date(2024, 0, 15, 0, 0, 0); + const minutes = DateCalculator.getMinutesSinceMidnight(date); + + expect(minutes).toBe(0); + }); + + it('should handle end of day', () => { + const date = new Date(2024, 0, 15, 23, 59, 0); + const minutes = DateCalculator.getMinutesSinceMidnight(date); + + expect(minutes).toBe(1439); + }); + + it('should handle leap year', () => { + const date = new Date(2024, 1, 29); // February 29, 2024 (leap year) + const nextDay = DateCalculator.addDays(date, 1); + + expect(nextDay.getDate()).toBe(1); // March 1 + expect(nextDay.getMonth()).toBe(2); + }); + + it('should handle DST transitions', () => { + // This test depends on timezone, but we test the basic functionality + const beforeDST = new Date(2024, 2, 30); // March 30, 2024 + const afterDST = DateCalculator.addDays(beforeDST, 1); + + expect(afterDST.getDate()).toBe(31); + }); + }); + + describe('Error Handling', () => { + it('should handle invalid dates gracefully', () => { + const invalidDate = new Date('invalid'); + + const result = DateCalculator.formatISODate(invalidDate); + expect(result).toBe(''); + }); + }); +}); \ No newline at end of file