From 5feee9f4bc663a1add231a4abfd7f99b856bea93 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:32:58 +0000 Subject: [PATCH] Refactor: Implement datetime standardization --- docs/DATE_HANDLING.md | 286 ++++++++++++++++++++++ src/components/admin/ManufacturerForm.tsx | 3 +- src/components/admin/ParkForm.tsx | 5 +- src/components/admin/RideForm.tsx | 5 +- src/components/reviews/ReviewForm.tsx | 3 +- src/components/ui/flexible-date-input.tsx | 5 +- src/lib/dateUtils.ts | 173 +++++++++++++ 7 files changed, 472 insertions(+), 8 deletions(-) create mode 100644 docs/DATE_HANDLING.md create mode 100644 src/lib/dateUtils.ts diff --git a/docs/DATE_HANDLING.md b/docs/DATE_HANDLING.md new file mode 100644 index 00000000..b41add32 --- /dev/null +++ b/docs/DATE_HANDLING.md @@ -0,0 +1,286 @@ +# 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. + +## Why This Matters + +### 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: `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) +``` + +## 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" +``` + +### 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'; + + { + setValue('opening_date', date ? toDateOnly(date) : undefined); + setValue('opening_date_precision', precision); + }} + label="Opening Date" +/> +``` + +### Incorrect Implementation + +```typescript +// ❌ DON'T DO THIS + { + 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 Dates + +```typescript +// ❌ WRONG +date.toISOString().split('T')[0] + +// ✅ CORRECT +toDateOnly(date) +``` + +### Mistake 2: 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 3: Not Handling Precision + +```typescript +// ❌ WRONG - Always shows full date +{format(new Date(park.opening_date), 'PPP')} + +// ✅ CORRECT - Respects precision + +``` + +## Testing Checklist + +When implementing date handling, test these scenarios: + +### Timezone Edge Cases +- [ ] 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 + +### 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` + - `src/components/ui/flexible-date-display.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) diff --git a/src/components/admin/ManufacturerForm.tsx b/src/components/admin/ManufacturerForm.tsx index 9ce7727b..185aef09 100644 --- a/src/components/admin/ManufacturerForm.tsx +++ b/src/components/admin/ManufacturerForm.tsx @@ -20,6 +20,7 @@ import { submitManufacturerCreation, submitManufacturerUpdate } from '@/lib/enti import { useAuth } from '@/hooks/useAuth'; import { toast } from 'sonner'; import { useNavigate } from 'react-router-dom'; +import { toDateOnly } from '@/lib/dateUtils'; // Raw form input state (before Zod transformation) interface ManufacturerFormInput { @@ -187,7 +188,7 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur value={watch('founded_date') ? new Date(watch('founded_date')) : undefined} precision={(watch('founded_date_precision') as DatePrecision) || 'year'} onChange={(date, precision) => { - setValue('founded_date', date ? date.toISOString().split('T')[0] : undefined); + setValue('founded_date', date ? toDateOnly(date) : undefined); setValue('founded_date_precision', precision); }} label="Founded Date" diff --git a/src/components/admin/ParkForm.tsx b/src/components/admin/ParkForm.tsx index b56b0d61..71061ab2 100644 --- a/src/components/admin/ParkForm.tsx +++ b/src/components/admin/ParkForm.tsx @@ -16,6 +16,7 @@ import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible- import { SlugField } from '@/components/ui/slug-field'; import { toast } from '@/hooks/use-toast'; import { MapPin, Save, X, Plus } from 'lucide-react'; +import { toDateOnly } from '@/lib/dateUtils'; import { Badge } from '@/components/ui/badge'; import { Combobox } from '@/components/ui/combobox'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; @@ -286,7 +287,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: 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 ? date.toISOString().split('T')[0] : undefined); + setValue('opening_date', date ? toDateOnly(date) : undefined); setValue('opening_date_precision', precision); }} label="Opening Date" @@ -299,7 +300,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: value={watch('closing_date') ? new Date(watch('closing_date')) : undefined} precision={(watch('closing_date_precision') as DatePrecision) || 'day'} onChange={(date, precision) => { - setValue('closing_date', date ? date.toISOString().split('T')[0] : undefined); + setValue('closing_date', date ? toDateOnly(date) : undefined); setValue('closing_date_precision', precision); }} label="Closing Date (if applicable)" diff --git a/src/components/admin/RideForm.tsx b/src/components/admin/RideForm.tsx index 4ea019a4..ae4c1591 100644 --- a/src/components/admin/RideForm.tsx +++ b/src/components/admin/RideForm.tsx @@ -19,6 +19,7 @@ import { Combobox } from '@/components/ui/combobox'; import { SlugField } from '@/components/ui/slug-field'; import { toast } from '@/hooks/use-toast'; import { Plus, Zap, Save, X } from 'lucide-react'; +import { toDateOnly } from '@/lib/dateUtils'; import { useUnitPreferences } from '@/hooks/useUnitPreferences'; import { useManufacturers, useRideModels } from '@/hooks/useAutocompleteData'; import { useUserRole } from '@/hooks/useUserRole'; @@ -493,7 +494,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: 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 ? date.toISOString().split('T')[0] : undefined); + setValue('opening_date', date ? toDateOnly(date) : undefined); setValue('opening_date_precision', precision); }} label="Opening Date" @@ -506,7 +507,7 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: value={watch('closing_date') ? new Date(watch('closing_date')) : undefined} precision={(watch('closing_date_precision') as DatePrecision) || 'day'} onChange={(date, precision) => { - setValue('closing_date', date ? date.toISOString().split('T')[0] : undefined); + setValue('closing_date', date ? toDateOnly(date) : undefined); setValue('closing_date_precision', precision); }} label="Closing Date (if applicable)" diff --git a/src/components/reviews/ReviewForm.tsx b/src/components/reviews/ReviewForm.tsx index a44a40fc..6f3b75b7 100644 --- a/src/components/reviews/ReviewForm.tsx +++ b/src/components/reviews/ReviewForm.tsx @@ -14,6 +14,7 @@ import { supabase } from '@/integrations/supabase/client'; import { toast } from '@/hooks/use-toast'; import { PhotoUpload } from '@/components/upload/PhotoUpload'; import { StarRating } from './StarRating'; +import { toDateOnly } from '@/lib/dateUtils'; const reviewSchema = z.object({ rating: z.number().min(0.5).max(5).multipleOf(0.5), title: z.string().optional(), @@ -175,7 +176,7 @@ export function ReviewForm({ setValue('visit_date', date ? date.toISOString().split('T')[0] : undefined)} + onSelect={(date) => setValue('visit_date', date ? toDateOnly(date) : undefined)} placeholder="When did you visit?" disableFuture={true} fromYear={1950} diff --git a/src/components/ui/flexible-date-input.tsx b/src/components/ui/flexible-date-input.tsx index fd1c8e4d..d3e021fb 100644 --- a/src/components/ui/flexible-date-input.tsx +++ b/src/components/ui/flexible-date-input.tsx @@ -14,6 +14,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { toDateOnly, toDateWithPrecision } from "@/lib/dateUtils"; export type DatePrecision = 'day' | 'month' | 'year'; @@ -70,11 +71,11 @@ export function FlexibleDateInput({ let newDate: Date; switch (newPrecision) { case 'year': - newDate = new Date(year, 0, 1); // January 1st + newDate = new Date(year, 0, 1); // January 1st (local timezone) setYearValue(year.toString()); break; case 'month': - newDate = new Date(year, month, 1); // 1st of month + newDate = new Date(year, month, 1); // 1st of month (local timezone) break; case 'day': default: diff --git a/src/lib/dateUtils.ts b/src/lib/dateUtils.ts new file mode 100644 index 00000000..aaddfe35 --- /dev/null +++ b/src/lib/dateUtils.ts @@ -0,0 +1,173 @@ +/** + * 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; +} + +/** + * 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')}`; + } +}