Files
thrilltrack-explorer/src/lib/dateUtils.ts
gpt-engineer-app[bot] d0c613031e Migrate date precision to exact
Batch update all date precision handling to use expanded DatePrecision, replace hardcoded day defaults, and adjust related validation, UI, and helpers. Includes wrapper migration across Phase 1-3 functions, updates to logs, displays, and formatting utilities to align frontend with new precision values ('exact', 'month', 'year', 'decade', 'century', 'approximate').
2025-11-11 22:05:29 +00:00

213 lines
6.6 KiB
TypeScript

/**
* 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: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate' = 'exact'
): 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 'decade':
return `${Math.floor(date.getFullYear() / 10) * 10}s`;
case 'century':
return `${Math.ceil(date.getFullYear() / 100)}th century`;
case 'approximate':
return `circa ${date.getFullYear()}`;
case 'exact':
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: 'exact' | 'month' | 'year' | 'decade' | 'century' | 'approximate'
): 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 'decade':
return `${Math.floor(year / 10) * 10}-01-01`;
case 'century':
return `${Math.floor((year - 1) / 100) * 100 + 1}-01-01`;
case 'approximate':
return `${year}-01-01`;
case 'exact':
default:
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
}