/** * Date Utility Functions for Timezone-Agnostic Date Handling * * This module provides utilities for handling calendar dates (not moments in time) * without timezone shifts. All dates are stored as YYYY-MM-DD strings in the database * using the DATE type (not TIMESTAMP). * * Key Principle: Calendar dates like "January 1, 2024" should remain "2024-01-01" * regardless of user timezone. We never use UTC conversion for DATE fields. * * @see docs/DATE_HANDLING.md for full documentation */ /** * Converts a Date object to YYYY-MM-DD string in LOCAL timezone * * This prevents timezone shifts where selecting "Jan 1, 2024" could * save as "2023-12-31" or "2024-01-02" due to UTC conversion. * * @param date - Date object to convert * @returns YYYY-MM-DD formatted string in local timezone * * @example * // User in UTC-8 selects Jan 1, 2024 11:00 PM * const date = new Date('2024-01-01T23:00:00-08:00'); * toDateOnly(date); // Returns "2024-01-01" ✅ (NOT "2024-01-02") */ export function toDateOnly(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } /** * Parses a YYYY-MM-DD string to a Date object at midnight local time * * @param dateString - YYYY-MM-DD formatted string * @returns Date object set to midnight local time * * @example * parseDateOnly('2024-01-01'); // Returns Date object for Jan 1, 2024 00:00:00 local */ export function parseDateOnly(dateString: string): Date { const [year, month, day] = dateString.split('-').map(Number); return new Date(year, month - 1, day); } /** * Gets current date as YYYY-MM-DD string in local timezone * * @returns Current date in YYYY-MM-DD format * * @example * getCurrentDateLocal(); // "2024-01-15" */ export function getCurrentDateLocal(): string { return toDateOnly(new Date()); } /** * Formats a date string for display based on precision * * @param dateString - YYYY-MM-DD formatted string * @param precision - Display precision: 'day', 'month', or 'year' * @returns Formatted display string * * @example * formatDateDisplay('2024-01-01', 'year'); // "2024" * formatDateDisplay('2024-01-01', 'month'); // "January 2024" * formatDateDisplay('2024-01-01', 'day'); // "January 1, 2024" */ export function formatDateDisplay( dateString: string | null | undefined, precision: 'day' | 'month' | 'year' = 'day' ): string { if (!dateString) return ''; const date = parseDateOnly(dateString); switch (precision) { case 'year': return date.getFullYear().toString(); case 'month': return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' }); case 'day': default: return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); } } /** * Validates YYYY-MM-DD date format * * @param dateString - String to validate * @returns true if valid YYYY-MM-DD format * * @example * isValidDateString('2024-01-01'); // true * isValidDateString('01/01/2024'); // false * isValidDateString('2024-1-1'); // false */ export function isValidDateString(dateString: string): boolean { return /^\d{4}-\d{2}-\d{2}$/.test(dateString); } /** * Checks if a date is within a valid range * * @param dateString - YYYY-MM-DD formatted string * @param minYear - Minimum allowed year (default: 1800) * @param maxYear - Maximum allowed year (default: current year + 10) * @returns true if date is within range */ export function isDateInRange( dateString: string, minYear: number = 1800, maxYear: number = new Date().getFullYear() + 10 ): boolean { if (!isValidDateString(dateString)) return false; const year = parseInt(dateString.split('-')[0]); return year >= minYear && year <= maxYear; } /** * Compares two date strings * * @param date1 - First date in YYYY-MM-DD format * @param date2 - Second date in YYYY-MM-DD format * @returns -1 if date1 < date2, 0 if equal, 1 if date1 > date2 */ export function compareDateStrings(date1: string, date2: string): number { if (date1 === date2) return 0; return date1 < date2 ? -1 : 1; } /** * Safely parses a date value (string or Date) for display formatting * Ensures YYYY-MM-DD strings are interpreted as local dates, not UTC * * This prevents timezone bugs where "1972-10-01" would display as * "September 30, 1972" for users in negative UTC offset timezones. * * @param date - Date string (YYYY-MM-DD) or Date object * @returns Date object in local timezone * * @example * // User in UTC-8 viewing "1972-10-01" * parseDateForDisplay("1972-10-01"); // Returns Oct 1, 1972 00:00 PST ✅ * // NOT Sep 30, 1972 16:00 PST (what new Date() would create) */ export function parseDateForDisplay(date: string | Date): Date { if (date instanceof Date) { return date; } // If it's a YYYY-MM-DD string, use parseDateOnly for local interpretation if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) { return parseDateOnly(date); } // Fallback for other date strings (timestamps, ISO strings, etc.) return new Date(date); } /** * Creates a date string for a specific precision * Sets the date to the first day of the period for month/year precision * * @param date - Date object * @param precision - 'day', 'month', or 'year' * @returns YYYY-MM-DD formatted string * * @example * const date = new Date('2024-06-15'); * toDateWithPrecision(date, 'year'); // "2024-01-01" * toDateWithPrecision(date, 'month'); // "2024-06-01" * toDateWithPrecision(date, 'day'); // "2024-06-15" */ export function toDateWithPrecision( date: Date, precision: 'day' | 'month' | 'year' ): string { const year = date.getFullYear(); const month = date.getMonth() + 1; const day = date.getDate(); switch (precision) { case 'year': return `${year}-01-01`; case 'month': return `${year}-${String(month).padStart(2, '0')}-01`; case 'day': default: return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; } }