mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:31:13 -05:00
369 lines
11 KiB
Markdown
369 lines
11 KiB
Markdown
# 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()`
|
|
|
|
```typescript
|
|
// ❌ 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()`
|
|
|
|
```typescript
|
|
// ✅ 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!
|
|
|
|
```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`):
|
|
|
|
```sql
|
|
-- 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.
|
|
|
|
```typescript
|
|
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.
|
|
|
|
```typescript
|
|
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.
|
|
|
|
```typescript
|
|
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.
|
|
|
|
```typescript
|
|
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`
|
|
Validates YYYY-MM-DD format.
|
|
|
|
```typescript
|
|
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.
|
|
|
|
```typescript
|
|
isDateInRange('2024-01-01', 1800, 2030); // true
|
|
isDateInRange('1799-01-01', 1800, 2030); // false
|
|
```
|
|
|
|
## Form Implementation Pattern
|
|
|
|
### Correct Implementation
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// ❌ 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
|
|
|
|
```typescript
|
|
// ❌ WRONG - Storing dates
|
|
date.toISOString().split('T')[0]
|
|
|
|
// ✅ CORRECT - Storing dates
|
|
toDateOnly(date)
|
|
```
|
|
|
|
### 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
|
|
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
|
|
|
|
```typescript
|
|
// ❌ 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**:
|
|
```typescript
|
|
import { toDateOnly } from '@/lib/dateUtils';
|
|
```
|
|
|
|
2. **Replace `.toISOString().split('T')[0]`**:
|
|
```typescript
|
|
// 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.
|
|
|
|
## Related Files
|
|
|
|
- **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)
|