Files
thrilltrack-explorer/docs/DATE_HANDLING.md
2025-11-02 19:24:54 +00:00

11 KiB

Date Handling Philosophy

Core Principle

All calendar dates in ThrillWiki are timezone-agnostic and stored as DATE (not TIMESTAMP) in the database.

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()

// ❌ WRONG - Creates timezone shifts
const date = new Date('2024-01-01T23:00:00-08:00'); // User in UTC-8 selects Jan 1, 2024 11:00 PM
date.toISOString().split('T')[0]; // Returns "2024-01-02" ❌ (shifted to UTC)

When a user in UTC-8 (Pacific Time) selects "January 1, 2024" at 11:00 PM local time:

  • Their local time: 2024-01-01 23:00 PST
  • UTC time: 2024-01-02 07:00 UTC
  • .toISOString() returns: "2024-01-02T07:00:00.000Z"
  • .split('T')[0] extracts: "2024-01-02"

Result: User selected Jan 1, but we stored Jan 2!

The Solution for Input: toDateOnly()

// ✅ CORRECT - Preserves local date
import { toDateOnly } from '@/lib/dateUtils';

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!

// ❌ 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()

// ✅ 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):

-- Parks
opening_date DATE
closing_date DATE

-- Rides
opening_date DATE
closing_date DATE

-- Companies
founded_date DATE

-- Photos
date_taken DATE

-- Reviews
visit_date DATE

When to Use What

Use DATE and toDateOnly() for:

  • Opening/closing dates (parks, rides)
  • Founded dates (companies)
  • Birth dates (user profiles)
  • Visit dates (reviews)
  • Photo capture dates
  • Any calendar date where the specific time doesn't matter

Use TIMESTAMP and .toISOString() for:

  • created_at / updated_at timestamps
  • Submission timestamps
  • Upload timestamps
  • Any moment in time where the exact second matters

API Reference

Core Functions

toDateOnly(date: Date): string

Converts a Date object to YYYY-MM-DD string in LOCAL timezone.

const date = new Date('2024-01-01T23:00:00-08:00');
toDateOnly(date); // "2024-01-01"

parseDateOnly(dateString: string): Date

Parses a YYYY-MM-DD string to a Date object at midnight local time.

parseDateOnly('2024-01-01'); // Date object for Jan 1, 2024 00:00:00 local

formatDateDisplay(dateString: string, precision: DatePrecision): string

Formats a date string for display based on precision.

formatDateDisplay('2024-01-01', 'year');  // "2024"
formatDateDisplay('2024-01-01', 'month'); // "January 2024"
formatDateDisplay('2024-01-01', 'day');   // "January 1, 2024"

getCurrentDateLocal(): string

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.

// 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

Validates YYYY-MM-DD format.

isValidDateString('2024-01-01'); // true
isValidDateString('01/01/2024'); // false

isDateInRange(dateString: string, minYear?: number, maxYear?: number): boolean

Checks if a date is within a valid range.

isDateInRange('2024-01-01', 1800, 2030); // true
isDateInRange('1799-01-01', 1800, 2030); // false

Form Implementation Pattern

Correct Implementation

import { toDateOnly } from '@/lib/dateUtils';

<FlexibleDateInput
  value={watch('opening_date') ? new Date(watch('opening_date')) : undefined}
  precision={(watch('opening_date_precision') as DatePrecision) || 'day'}
  onChange={(date, precision) => {
    setValue('opening_date', date ? toDateOnly(date) : undefined);
    setValue('opening_date_precision', precision);
  }}
  label="Opening Date"
/>

Incorrect Implementation

// ❌ DON'T DO THIS
<FlexibleDateInput
  onChange={(date, precision) => {
    setValue('opening_date', date ? date.toISOString().split('T')[0] : undefined); // ❌
  }}
/>

Date Precision Handling

ThrillWiki supports three precision levels for historical accuracy:

Year Precision

Used when only the year is known (e.g., "Founded in 1972")

  • Stored as: 1972-01-01
  • Precision field: year
  • Displayed as: "1972"

Month Precision

Used when month and year are known (e.g., "Opened June 1985")

  • Stored as: 1985-06-01
  • Precision field: month
  • Displayed as: "June 1985"

Day Precision

Used when exact date is known (e.g., "Opened July 4, 1976")

  • Stored as: 1976-07-04
  • Precision field: day
  • Displayed as: "July 4, 1976"

Common Mistakes and Fixes

Mistake 1: Using .toISOString() for Date Input

// ❌ WRONG - Storing dates
date.toISOString().split('T')[0]

// ✅ CORRECT - Storing dates
toDateOnly(date)

Mistake 2: Using new Date() for Date Display

// ❌ 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

// ❌ WRONG - Don't store time for calendar dates
created_at TIMESTAMP // OK for creation timestamp
opening_date TIMESTAMP // ❌ Wrong! Use DATE

// ✅ CORRECT
created_at TIMESTAMP // OK for creation timestamp
opening_date DATE // ✅ Correct for calendar date

Mistake 4: Not Handling Precision

// ❌ WRONG - Always shows full date, uses new Date()
<span>{format(new Date(park.opening_date), 'PPP')}</span>

// ✅ CORRECT - Respects precision, uses parseDateForDisplay()
<FlexibleDateDisplay 
  date={park.opening_date} 
  precision={park.opening_date_precision}
/>

Testing Checklist

When implementing date handling, test these scenarios:

Timezone Edge Cases (Input)

  • User in UTC-12 selects December 31, 2024 11:00 PM → Stores as 2024-12-31
  • User in UTC+14 selects January 1, 2024 1:00 AM → Stores as 2024-01-01
  • User in UTC-8 selects date → No timezone shift occurs

Timezone Edge Cases (Display)

  • User in UTC-8 views "1972-10-01" → Displays "October 1, 1972" (not September 30)
  • User in UTC-5 views "2024-12-31" → Displays "December 31, 2024" (not December 30)
  • User in UTC+10 views "1985-01-01" → Displays "January 1, 1985" (not January 2)
  • All historical dates (parks, rides, events) show correctly in all timezones

Precision Handling

  • Year precision stores as YYYY-01-01
  • Month precision stores as YYYY-MM-01
  • Day precision stores as YYYY-MM-DD
  • Display respects precision setting

Date Comparisons

  • Closing date validation: closing > opening
  • Future date restrictions work correctly
  • Historical date limits (nothing before 1800) enforced

Form Persistence

  • Edit park with opening_date → Date doesn't shift when re-opening form
  • Moderation queue shows correct dates in previews
  • Date survives form validation and submission

Migration Notes

Updating Existing Code

  1. Import the utility:

    import { toDateOnly } from '@/lib/dateUtils';
    
  2. Replace .toISOString().split('T')[0]:

    // Before
    setValue('date', date ? date.toISOString().split('T')[0] : undefined);
    
    // After
    setValue('date', date ? toDateOnly(date) : undefined);
    
  3. Update form components (See files updated in Phase 2 of implementation)

Database Schema

No database migrations needed - all date columns are already using the DATE type.

  • Core Utilities: src/lib/dateUtils.ts
  • Form Components:
    • src/components/admin/ParkForm.tsx
    • src/components/admin/RideForm.tsx
    • src/components/admin/ManufacturerForm.tsx
    • src/components/reviews/ReviewForm.tsx
  • UI Components:
    • src/components/ui/flexible-date-input.tsx (Input handling)
    • src/components/ui/flexible-date-display.tsx (Display - uses parseDateForDisplay())
  • Display Components (All use parseDateForDisplay()):
    • src/components/timeline/TimelineEventCard.tsx
    • src/components/versioning/HistoricalEntityCard.tsx
    • src/components/rides/FormerNames.tsx
    • src/components/profile/RideCreditCard.tsx
  • Validation: src/lib/entityValidationSchemas.ts
  • Server Validation: supabase/functions/process-selective-approval/validation.ts

Support

If you encounter date-related bugs:

  1. Check the browser console for the actual date values being submitted
  2. Verify the database column type is DATE (not TIMESTAMP)
  3. Ensure toDateOnly() is being used instead of .toISOString().split('T')[0]
  4. Test in different timezones (use browser DevTools to simulate)