mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:11:13 -05:00
Refactor: Implement datetime standardization
This commit is contained in:
286
docs/DATE_HANDLING.md
Normal file
286
docs/DATE_HANDLING.md
Normal file
@@ -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';
|
||||||
|
|
||||||
|
<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 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
|
||||||
|
<span>{format(new Date(park.opening_date), 'PPP')}</span>
|
||||||
|
|
||||||
|
// ✅ CORRECT - Respects precision
|
||||||
|
<FlexibleDateDisplay
|
||||||
|
date={park.opening_date}
|
||||||
|
precision={park.opening_date_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)
|
||||||
@@ -20,6 +20,7 @@ import { submitManufacturerCreation, submitManufacturerUpdate } from '@/lib/enti
|
|||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { toDateOnly } from '@/lib/dateUtils';
|
||||||
|
|
||||||
// Raw form input state (before Zod transformation)
|
// Raw form input state (before Zod transformation)
|
||||||
interface ManufacturerFormInput {
|
interface ManufacturerFormInput {
|
||||||
@@ -187,7 +188,7 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
|
|||||||
value={watch('founded_date') ? new Date(watch('founded_date')) : undefined}
|
value={watch('founded_date') ? new Date(watch('founded_date')) : undefined}
|
||||||
precision={(watch('founded_date_precision') as DatePrecision) || 'year'}
|
precision={(watch('founded_date_precision') as DatePrecision) || 'year'}
|
||||||
onChange={(date, precision) => {
|
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);
|
setValue('founded_date_precision', precision);
|
||||||
}}
|
}}
|
||||||
label="Founded Date"
|
label="Founded Date"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-
|
|||||||
import { SlugField } from '@/components/ui/slug-field';
|
import { SlugField } from '@/components/ui/slug-field';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { MapPin, Save, X, Plus } from 'lucide-react';
|
import { MapPin, Save, X, Plus } from 'lucide-react';
|
||||||
|
import { toDateOnly } from '@/lib/dateUtils';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
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}
|
value={watch('opening_date') ? new Date(watch('opening_date')) : undefined}
|
||||||
precision={(watch('opening_date_precision') as DatePrecision) || 'day'}
|
precision={(watch('opening_date_precision') as DatePrecision) || 'day'}
|
||||||
onChange={(date, precision) => {
|
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);
|
setValue('opening_date_precision', precision);
|
||||||
}}
|
}}
|
||||||
label="Opening Date"
|
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}
|
value={watch('closing_date') ? new Date(watch('closing_date')) : undefined}
|
||||||
precision={(watch('closing_date_precision') as DatePrecision) || 'day'}
|
precision={(watch('closing_date_precision') as DatePrecision) || 'day'}
|
||||||
onChange={(date, precision) => {
|
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);
|
setValue('closing_date_precision', precision);
|
||||||
}}
|
}}
|
||||||
label="Closing Date (if applicable)"
|
label="Closing Date (if applicable)"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Combobox } from '@/components/ui/combobox';
|
|||||||
import { SlugField } from '@/components/ui/slug-field';
|
import { SlugField } from '@/components/ui/slug-field';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Plus, Zap, Save, X } from 'lucide-react';
|
import { Plus, Zap, Save, X } from 'lucide-react';
|
||||||
|
import { toDateOnly } from '@/lib/dateUtils';
|
||||||
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
|
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
|
||||||
import { useManufacturers, useRideModels } from '@/hooks/useAutocompleteData';
|
import { useManufacturers, useRideModels } from '@/hooks/useAutocompleteData';
|
||||||
import { useUserRole } from '@/hooks/useUserRole';
|
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}
|
value={watch('opening_date') ? new Date(watch('opening_date')) : undefined}
|
||||||
precision={(watch('opening_date_precision') as DatePrecision) || 'day'}
|
precision={(watch('opening_date_precision') as DatePrecision) || 'day'}
|
||||||
onChange={(date, precision) => {
|
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);
|
setValue('opening_date_precision', precision);
|
||||||
}}
|
}}
|
||||||
label="Opening Date"
|
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}
|
value={watch('closing_date') ? new Date(watch('closing_date')) : undefined}
|
||||||
precision={(watch('closing_date_precision') as DatePrecision) || 'day'}
|
precision={(watch('closing_date_precision') as DatePrecision) || 'day'}
|
||||||
onChange={(date, precision) => {
|
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);
|
setValue('closing_date_precision', precision);
|
||||||
}}
|
}}
|
||||||
label="Closing Date (if applicable)"
|
label="Closing Date (if applicable)"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { supabase } from '@/integrations/supabase/client';
|
|||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { PhotoUpload } from '@/components/upload/PhotoUpload';
|
import { PhotoUpload } from '@/components/upload/PhotoUpload';
|
||||||
import { StarRating } from './StarRating';
|
import { StarRating } from './StarRating';
|
||||||
|
import { toDateOnly } from '@/lib/dateUtils';
|
||||||
const reviewSchema = z.object({
|
const reviewSchema = z.object({
|
||||||
rating: z.number().min(0.5).max(5).multipleOf(0.5),
|
rating: z.number().min(0.5).max(5).multipleOf(0.5),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
@@ -175,7 +176,7 @@ export function ReviewForm({
|
|||||||
<Label>Visit Date</Label>
|
<Label>Visit Date</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
date={watch('visit_date') ? new Date(watch('visit_date')) : undefined}
|
date={watch('visit_date') ? new Date(watch('visit_date')) : undefined}
|
||||||
onSelect={(date) => setValue('visit_date', date ? date.toISOString().split('T')[0] : undefined)}
|
onSelect={(date) => setValue('visit_date', date ? toDateOnly(date) : undefined)}
|
||||||
placeholder="When did you visit?"
|
placeholder="When did you visit?"
|
||||||
disableFuture={true}
|
disableFuture={true}
|
||||||
fromYear={1950}
|
fromYear={1950}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { toDateOnly, toDateWithPrecision } from "@/lib/dateUtils";
|
||||||
|
|
||||||
export type DatePrecision = 'day' | 'month' | 'year';
|
export type DatePrecision = 'day' | 'month' | 'year';
|
||||||
|
|
||||||
@@ -70,11 +71,11 @@ export function FlexibleDateInput({
|
|||||||
let newDate: Date;
|
let newDate: Date;
|
||||||
switch (newPrecision) {
|
switch (newPrecision) {
|
||||||
case 'year':
|
case 'year':
|
||||||
newDate = new Date(year, 0, 1); // January 1st
|
newDate = new Date(year, 0, 1); // January 1st (local timezone)
|
||||||
setYearValue(year.toString());
|
setYearValue(year.toString());
|
||||||
break;
|
break;
|
||||||
case 'month':
|
case 'month':
|
||||||
newDate = new Date(year, month, 1); // 1st of month
|
newDate = new Date(year, month, 1); // 1st of month (local timezone)
|
||||||
break;
|
break;
|
||||||
case 'day':
|
case 'day':
|
||||||
default:
|
default:
|
||||||
|
|||||||
173
src/lib/dateUtils.ts
Normal file
173
src/lib/dateUtils.ts
Normal file
@@ -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')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user