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.
This commit is contained in:
Janus C. H. Knudsen 2025-10-03 19:48:04 +02:00
parent 4fea01c76b
commit 4859f42450
3 changed files with 465 additions and 25 deletions

View file

@ -25,13 +25,10 @@ export class DateCalculator {
/** /**
* Validate that a date is valid * Validate that a date is valid
* @param date - Date to validate * @param date - Date to validate
* @param methodName - Name of calling method for error messages * @returns True if date is valid, false otherwise
* @throws Error if date is invalid
*/ */
private static validateDate(date: Date, methodName: string): void { private static validateDate(date: Date): boolean {
if (!date || !(date instanceof Date) || !DateCalculator.dateService.isValid(date)) { return date && date instanceof Date && DateCalculator.dateService.isValid(date);
throw new Error(`${methodName}: Invalid date provided - ${date}`);
}
} }
/** /**
@ -40,7 +37,9 @@ export class DateCalculator {
* @returns Array of dates for the configured work days * @returns Array of dates for the configured work days
*/ */
static getWorkWeekDates(weekStart: Date): Date[] { static getWorkWeekDates(weekStart: Date): Date[] {
DateCalculator.validateDate(weekStart, 'getWorkWeekDates'); if (!DateCalculator.validateDate(weekStart)) {
throw new Error('getWorkWeekDates: Invalid date provided');
}
const dates: Date[] = []; const dates: Date[] = [];
const workWeekSettings = DateCalculator.config.getWorkWeekSettings(); const workWeekSettings = DateCalculator.config.getWorkWeekSettings();
@ -66,7 +65,9 @@ export class DateCalculator {
* @returns The Monday of the ISO week * @returns The Monday of the ISO week
*/ */
static getISOWeekStart(date: Date): Date { 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); const weekBounds = DateCalculator.dateService.getWeekBounds(date);
return DateCalculator.dateService.startOfDay(weekBounds.start); return DateCalculator.dateService.startOfDay(weekBounds.start);
@ -78,7 +79,9 @@ export class DateCalculator {
* @returns The end date of the ISO week (Sunday) * @returns The end date of the ISO week (Sunday)
*/ */
static getWeekEnd(date: Date): Date { 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); const weekBounds = DateCalculator.dateService.getWeekBounds(date);
return DateCalculator.dateService.endOfDay(weekBounds.end); 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 * Format a date to ISO date string (YYYY-MM-DD) using DateService
* @param date - Date to format * @param date - Date to format
* @returns ISO date string * @returns ISO date string or empty string if invalid
*/ */
static formatISODate(date: Date): string { static formatISODate(date: Date): string {
if (!DateCalculator.validateDate(date)) {
return '';
}
return DateCalculator.dateService.formatDate(date); return DateCalculator.dateService.formatDate(date);
} }

View file

@ -20,7 +20,8 @@ import {
startOfWeek, startOfWeek,
endOfWeek, endOfWeek,
addWeeks, addWeeks,
isSameDay isSameDay,
getISOWeek
} from 'date-fns'; } from 'date-fns';
import { import {
toZonedTime, toZonedTime,
@ -109,6 +110,70 @@ export class DateService {
return this.formatDate(date); 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 // TIME CALCULATIONS
// ============================================ // ============================================
@ -193,6 +258,53 @@ export class DateService {
return addWeeks(date, weeks); 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 // GRID HELPERS
// ============================================ // ============================================
@ -290,4 +402,16 @@ export class DateService {
public isValid(date: Date): boolean { public isValid(date: Date): boolean {
return isValid(date); 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);
}
} }

View file

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