From 0f742f36b620d68bcc40bc6a374c5433f2ec4d3e Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 19:24:54 +0000 Subject: [PATCH] Fix timezone-independent date display --- docs/DATE_HANDLING.md | 104 ++++++++++++++++-- src/components/profile/RideCreditCard.tsx | 8 +- src/components/rides/FormerNames.tsx | 3 +- src/components/timeline/TimelineEventCard.tsx | 5 +- src/components/ui/flexible-date-display.tsx | 5 +- .../versioning/HistoricalEntityCard.tsx | 6 +- src/lib/dateUtils.ts | 27 +++++ 7 files changed, 139 insertions(+), 19 deletions(-) diff --git a/docs/DATE_HANDLING.md b/docs/DATE_HANDLING.md index b41add32..e4904f0c 100644 --- a/docs/DATE_HANDLING.md +++ b/docs/DATE_HANDLING.md @@ -6,8 +6,14 @@ When a user selects "January 1, 2024," that date should be stored as `2024-01-01` regardless of the user's timezone. We never use UTC conversion for calendar date fields. +**CRITICAL**: When displaying dates from YYYY-MM-DD strings, always use `parseDateOnly()` or `parseDateForDisplay()` to prevent timezone shifts. + ## Why This Matters +### The Input Problem: Storing Dates + +When storing dates (user input → database), using `.toISOString()` causes timezone shifts. + ### The Problem with `.toISOString()` ```typescript @@ -24,7 +30,7 @@ When a user in UTC-8 (Pacific Time) selects "January 1, 2024" at 11:00 PM local **Result**: User selected Jan 1, but we stored Jan 2! -### The Solution: `toDateOnly()` +### The Solution for Input: `toDateOnly()` ```typescript // ✅ CORRECT - Preserves local date @@ -34,6 +40,33 @@ const date = new Date('2024-01-01T23:00:00-08:00'); toDateOnly(date); // Returns "2024-01-01" ✅ (local timezone preserved) ``` +### The Display Problem: Showing Dates + +When displaying dates (database → user display), using `new Date("YYYY-MM-DD")` also causes timezone shifts! + +```typescript +// ❌ WRONG - Creates timezone shifts +// User in UTC-8 (Pacific Time) +const dateStr = "1972-10-01"; // October 1, 1972 in database +const dateObj = new Date(dateStr); // Interprets as Oct 1 00:00 UTC (Sep 30 16:00 PST) +format(dateObj, "PPP"); // Shows "September 30, 1972" ❌ +``` + +**Why this happens**: `new Date("YYYY-MM-DD")` interprets date-only strings as **UTC midnight**, not local midnight. When formatting in local timezone, users in negative UTC offsets (UTC-8, UTC-5, etc.) see the previous day. + +### The Solution for Display: `parseDateForDisplay()` + +```typescript +// ✅ CORRECT - Preserves local date for display +import { parseDateForDisplay } from '@/lib/dateUtils'; + +const dateStr = "1972-10-01"; // October 1, 1972 in database +const dateObj = parseDateForDisplay(dateStr); // Creates Oct 1 00:00 LOCAL time +format(dateObj, "PPP"); // Shows "October 1, 1972" ✅ (correct in all timezones) +``` + +**Key Rule**: If you're displaying a YYYY-MM-DD string from the database, NEVER use `new Date(dateString)` directly. Always use `parseDateForDisplay()` or `parseDateOnly()`. + ## Database Schema All calendar date columns use the `DATE` type (not `TIMESTAMP`): @@ -108,6 +141,23 @@ Gets current date as YYYY-MM-DD string in local timezone. getCurrentDateLocal(); // "2024-01-15" ``` +#### `parseDateForDisplay(date: string | Date): Date` +Safely parses a date value for display formatting. Handles both YYYY-MM-DD strings and Date objects. + +```typescript +// With YYYY-MM-DD string (uses parseDateOnly internally) +parseDateForDisplay("1972-10-01"); // Date object for Oct 1, 1972 00:00 LOCAL ✅ + +// With Date object (returns as-is) +parseDateForDisplay(new Date()); // Pass-through + +// Then format for display +const dateObj = parseDateForDisplay("1972-10-01"); +format(dateObj, "PPP"); // "October 1, 1972" ✅ (correct in all timezones) +``` + +**When to use**: In any display component that receives dates that might be YYYY-MM-DD strings from the database. This is safer than `parseDateOnly()` because it handles both strings and Date objects. + ### Validation Functions #### `isValidDateString(dateString: string): boolean` @@ -179,17 +229,38 @@ Used when exact date is known (e.g., "Opened July 4, 1976") ## Common Mistakes and Fixes -### Mistake 1: Using `.toISOString()` for Dates +### Mistake 1: Using `.toISOString()` for Date Input ```typescript -// ❌ WRONG +// ❌ WRONG - Storing dates date.toISOString().split('T')[0] -// ✅ CORRECT +// ✅ CORRECT - Storing dates toDateOnly(date) ``` -### Mistake 2: Storing Time Components for Calendar Dates +### Mistake 2: Using `new Date()` for Date Display + +```typescript +// ❌ WRONG - Displaying dates from database +const dateObj = new Date(dateString); // Interprets as UTC! +format(dateObj, "PPP"); + +// ✅ CORRECT - Displaying dates from database +const dateObj = parseDateForDisplay(dateString); // Interprets as local! +format(dateObj, "PPP"); +``` + +**Critical Display Components** that were fixed: +- `FlexibleDateDisplay.tsx` - General date display component +- `TimelineEventCard.tsx` - Timeline event dates +- `HistoricalEntityCard.tsx` - Historical entity operating dates +- `FormerNames.tsx` - Ride name change dates +- `RideCreditCard.tsx` - User ride credit dates + +All these components now use `parseDateForDisplay()` instead of `new Date()`. + +### Mistake 3: Storing Time Components for Calendar Dates ```typescript // ❌ WRONG - Don't store time for calendar dates @@ -201,13 +272,13 @@ created_at TIMESTAMP // OK for creation timestamp opening_date DATE // ✅ Correct for calendar date ``` -### Mistake 3: Not Handling Precision +### Mistake 4: Not Handling Precision ```typescript -// ❌ WRONG - Always shows full date +// ❌ WRONG - Always shows full date, uses new Date() {format(new Date(park.opening_date), 'PPP')} -// ✅ CORRECT - Respects precision +// ✅ CORRECT - Respects precision, uses parseDateForDisplay() - First: {format(new Date(credit.first_ride_date), 'MMM d, yyyy')} + {/* ⚠️ Use parseDateForDisplay to prevent timezone shifts */} + First: {format(parseDateForDisplay(credit.first_ride_date), 'MMM d, yyyy')} )} {credit.last_ride_date && (
- Last: {format(new Date(credit.last_ride_date), 'MMM d, yyyy')} + Last: {format(parseDateForDisplay(credit.last_ride_date), 'MMM d, yyyy')}
)} @@ -410,7 +412,7 @@ export function RideCreditCard({ credit, position, maxPosition, viewMode, isEdit {credit.first_ride_date && (
- {format(new Date(credit.first_ride_date), 'MMM d, yyyy')} + {format(parseDateForDisplay(credit.first_ride_date), 'MMM d, yyyy')}
)} diff --git a/src/components/rides/FormerNames.tsx b/src/components/rides/FormerNames.tsx index 60f6a1bf..3d112bed 100644 --- a/src/components/rides/FormerNames.tsx +++ b/src/components/rides/FormerNames.tsx @@ -3,6 +3,7 @@ import { Badge } from '@/components/ui/badge'; import { History } from 'lucide-react'; import { RideNameHistory } from '@/types/database'; import { format } from 'date-fns'; +import { parseDateForDisplay } from '@/lib/dateUtils'; interface FormerName { name: string; @@ -83,7 +84,7 @@ export function FormerNames({ formerNames, nameHistory, currentName }: FormerNam )} {former.date_changed && ( -
Changed: {format(new Date(former.date_changed), 'MMM d, yyyy')}
+
Changed: {format(parseDateForDisplay(former.date_changed), 'MMM d, yyyy')}
)} {former.reason && (
{former.reason}
diff --git a/src/components/timeline/TimelineEventCard.tsx b/src/components/timeline/TimelineEventCard.tsx index e33ca45e..495f2c50 100644 --- a/src/components/timeline/TimelineEventCard.tsx +++ b/src/components/timeline/TimelineEventCard.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { format } from 'date-fns'; +import { parseDateForDisplay } from '@/lib/dateUtils'; import type { TimelineEvent } from '@/types/timeline'; interface TimelineEventCardProps { @@ -14,8 +15,10 @@ interface TimelineEventCardProps { isPending?: boolean; } +// ⚠️ IMPORTANT: Use parseDateForDisplay to prevent timezone shifts +// YYYY-MM-DD strings must be interpreted as local dates, not UTC const formatEventDate = (date: string, precision: string = 'day') => { - const dateObj = new Date(date); + const dateObj = parseDateForDisplay(date); switch (precision) { case 'year': diff --git a/src/components/ui/flexible-date-display.tsx b/src/components/ui/flexible-date-display.tsx index 821d1bc6..cebb174c 100644 --- a/src/components/ui/flexible-date-display.tsx +++ b/src/components/ui/flexible-date-display.tsx @@ -1,4 +1,5 @@ import { format } from 'date-fns'; +import { parseDateForDisplay } from '@/lib/dateUtils'; import type { DatePrecision } from './flexible-date-input'; interface FlexibleDateDisplayProps { @@ -18,7 +19,9 @@ export function FlexibleDateDisplay({ return {fallback}; } - const dateObj = typeof date === 'string' ? new Date(date) : date; + // ⚠️ IMPORTANT: Use parseDateForDisplay to prevent timezone shifts + // YYYY-MM-DD strings must be interpreted as local dates, not UTC + const dateObj = parseDateForDisplay(date); // Check for invalid date if (isNaN(dateObj.getTime())) { diff --git a/src/components/versioning/HistoricalEntityCard.tsx b/src/components/versioning/HistoricalEntityCard.tsx index bdb1a84e..1eb7595b 100644 --- a/src/components/versioning/HistoricalEntityCard.tsx +++ b/src/components/versioning/HistoricalEntityCard.tsx @@ -3,6 +3,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Calendar, MapPin, ArrowRight, Building2 } from 'lucide-react'; import { format } from 'date-fns'; +import { parseDateForDisplay } from '@/lib/dateUtils'; interface HistoricalEntityCardProps { type: 'park' | 'ride'; @@ -65,9 +66,10 @@ export function HistoricalEntityCard({ type, entity, onViewDetails }: Historical Operated:
- {entity.operated_from && format(new Date(entity.operated_from), 'MMM d, yyyy')} + {/* ⚠️ Use parseDateForDisplay to prevent timezone shifts */} + {entity.operated_from && format(parseDateForDisplay(entity.operated_from), 'MMM d, yyyy')} {' - '} - {entity.operated_until && format(new Date(entity.operated_until), 'MMM d, yyyy')} + {entity.operated_until && format(parseDateForDisplay(entity.operated_until), 'MMM d, yyyy')}
diff --git a/src/lib/dateUtils.ts b/src/lib/dateUtils.ts index aaddfe35..2624811b 100644 --- a/src/lib/dateUtils.ts +++ b/src/lib/dateUtils.ts @@ -139,6 +139,33 @@ export function compareDateStrings(date1: string, date2: string): number { 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