From 7c35f2932bca7e30b61ea350aca9ddbe64e507fc Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:31:51 +0000 Subject: [PATCH] feat: Implement timezone-independent date picker --- .../TimeZoneIndependentDateRangePicker.tsx | 195 ++++++++++++++++++ src/components/parks/ParkFilters.tsx | 15 ++ src/components/rides/RideFilters.tsx | 18 +- src/pages/Parks.tsx | 31 ++- src/pages/Rides.tsx | 9 +- 5 files changed, 248 insertions(+), 20 deletions(-) create mode 100644 src/components/filters/TimeZoneIndependentDateRangePicker.tsx diff --git a/src/components/filters/TimeZoneIndependentDateRangePicker.tsx b/src/components/filters/TimeZoneIndependentDateRangePicker.tsx new file mode 100644 index 00000000..370cafe2 --- /dev/null +++ b/src/components/filters/TimeZoneIndependentDateRangePicker.tsx @@ -0,0 +1,195 @@ +import { useState, useMemo } from 'react'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Calendar } from '@/components/ui/calendar'; +import { CalendarIcon, X } from 'lucide-react'; +import { toDateOnly, parseDateForDisplay, getCurrentDateLocal, formatDateDisplay } from '@/lib/dateUtils'; +import { cn } from '@/lib/utils'; +import type { DateRange } from 'react-day-picker'; + +interface TimeZoneIndependentDateRangePickerProps { + label?: string; + fromDate?: string | null; + toDate?: string | null; + onFromChange: (date: string | null) => void; + onToChange: (date: string | null) => void; + fromPlaceholder?: string; + toPlaceholder?: string; + fromYear?: number; + toYear?: number; + presets?: Array<{ + label: string; + from?: string; + to?: string; + }>; +} + +export function TimeZoneIndependentDateRangePicker({ + label = 'Date Range', + fromDate, + toDate, + onFromChange, + onToChange, + fromPlaceholder = 'From date', + toPlaceholder = 'To date', + fromYear = 1800, + toYear = new Date().getFullYear(), + presets, +}: TimeZoneIndependentDateRangePickerProps) { + const [isOpen, setIsOpen] = useState(false); + + // Default presets for ride/park filtering + const defaultPresets = useMemo(() => { + const currentYear = new Date().getFullYear(); + return [ + { label: 'Last Year', from: `${currentYear - 1}-01-01`, to: `${currentYear - 1}-12-31` }, + { label: 'Last 5 Years', from: `${currentYear - 5}-01-01`, to: getCurrentDateLocal() }, + { label: 'Last 10 Years', from: `${currentYear - 10}-01-01`, to: getCurrentDateLocal() }, + { label: '1990s', from: '1990-01-01', to: '1999-12-31' }, + { label: '2000s', from: '2000-01-01', to: '2009-12-31' }, + { label: '2010s', from: '2010-01-01', to: '2019-12-31' }, + { label: '2020s', from: '2020-01-01', to: '2029-12-31' }, + ]; + }, []); + + const activePresets = presets || defaultPresets; + + // Convert YYYY-MM-DD strings to Date objects for calendar display + const dateRange: DateRange | undefined = useMemo(() => { + if (!fromDate && !toDate) return undefined; + + return { + from: fromDate ? parseDateForDisplay(fromDate) : undefined, + to: toDate ? parseDateForDisplay(toDate) : undefined, + }; + }, [fromDate, toDate]); + + // Handle calendar selection + const handleSelect = (range: DateRange | undefined) => { + if (range?.from) { + const fromString = toDateOnly(range.from); + onFromChange(fromString); + } else { + onFromChange(null); + } + + if (range?.to) { + const toString = toDateOnly(range.to); + onToChange(toString); + } else if (!range?.from) { + // If from is cleared, clear to as well + onToChange(null); + } + }; + + // Handle preset selection + const handlePresetSelect = (preset: { from?: string; to?: string }) => { + onFromChange(preset.from || null); + onToChange(preset.to || null); + setIsOpen(false); + }; + + // Handle clear + const handleClear = () => { + onFromChange(null); + onToChange(null); + }; + + // Format range for display + const formatRange = () => { + if (!fromDate && !toDate) return null; + + if (fromDate && toDate) { + return `${formatDateDisplay(fromDate, 'day')} - ${formatDateDisplay(toDate, 'day')}`; + } else if (fromDate) { + return `From ${formatDateDisplay(fromDate, 'day')}`; + } else if (toDate) { + return `Until ${formatDateDisplay(toDate, 'day')}`; + } + + return null; + }; + + const displayText = formatRange(); + + return ( +
+ {label && } +
+ + + + + +
+ {/* Presets sidebar */} +
+
Presets
+ {activePresets.map((preset) => ( + + ))} +
+ + {/* Calendar */} +
+ +
+
+
+
+ + {displayText && ( + + )} +
+ + {displayText && ( + + {fromDate && toDate + ? `${fromDate} to ${toDate}` + : fromDate + ? `From ${fromDate}` + : toDate + ? `Until ${toDate}` + : ''} + + )} +
+ ); +} diff --git a/src/components/parks/ParkFilters.tsx b/src/components/parks/ParkFilters.tsx index 63779d4a..aa5616b2 100644 --- a/src/components/parks/ParkFilters.tsx +++ b/src/components/parks/ParkFilters.tsx @@ -10,6 +10,7 @@ import { Park } from '@/types/database'; import { FilterState } from '@/pages/Parks'; import { FilterRangeSlider } from '@/components/filters/FilterRangeSlider'; import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker'; +import { TimeZoneIndependentDateRangePicker } from '@/components/filters/TimeZoneIndependentDateRangePicker'; import { FilterSection } from '@/components/filters/FilterSection'; import { FilterMultiSelectCombobox } from '@/components/filters/FilterMultiSelectCombobox'; import { MultiSelectOption } from '@/components/ui/multi-select-combobox'; @@ -128,6 +129,8 @@ export function ParkFilters({ filters, onFiltersChange, parks }: ParkFiltersProp maxReviews: maxReviews, openingYearStart: null, openingYearEnd: null, + openingDateFrom: null, + openingDateTo: null, }); }; @@ -225,6 +228,18 @@ export function ParkFilters({ filters, onFiltersChange, parks }: ParkFiltersProp fromPlaceholder="From year" toPlaceholder="To year" /> + + onFiltersChange({ ...filters, openingDateFrom: date })} + onToChange={(date) => onFiltersChange({ ...filters, openingDateTo: date })} + fromPlaceholder="From date" + toPlaceholder="To date" + fromYear={1800} + toYear={new Date().getFullYear()} + /> diff --git a/src/components/rides/RideFilters.tsx b/src/components/rides/RideFilters.tsx index d0520453..087fab46 100644 --- a/src/components/rides/RideFilters.tsx +++ b/src/components/rides/RideFilters.tsx @@ -8,7 +8,7 @@ import { Separator } from '@/components/ui/separator'; import { RotateCcw } from 'lucide-react'; import { supabase } from '@/lib/supabaseClient'; import { FilterRangeSlider } from '@/components/filters/FilterRangeSlider'; -import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker'; +import { TimeZoneIndependentDateRangePicker } from '@/components/filters/TimeZoneIndependentDateRangePicker'; import { FilterSection } from '@/components/filters/FilterSection'; import { FilterMultiSelectCombobox } from '@/components/filters/FilterMultiSelectCombobox'; import { MultiSelectOption } from '@/components/ui/multi-select-combobox'; @@ -43,8 +43,8 @@ export interface RideFilterState { maxLength: number; minInversions: number; maxInversions: number; - openingDateFrom: Date | null; - openingDateTo: Date | null; + openingDateFrom: string | null; + openingDateTo: string | null; hasInversions: boolean; operatingOnly: boolean; } @@ -468,14 +468,14 @@ export function RideFilters({ filters, onFiltersChange, rides }: RideFiltersProp {/* Date Filters */}
- onFiltersChange({ ...filters, openingDateFrom: date || null })} - onToChange={(date) => onFiltersChange({ ...filters, openingDateTo: date || null })} - fromPlaceholder="From year" - toPlaceholder="To year" + onFromChange={(date) => onFiltersChange({ ...filters, openingDateFrom: date })} + onToChange={(date) => onFiltersChange({ ...filters, openingDateTo: date })} + fromPlaceholder="From date" + toPlaceholder="To date" />
diff --git a/src/pages/Parks.tsx b/src/pages/Parks.tsx index bdfc3bc4..4deb601c 100644 --- a/src/pages/Parks.tsx +++ b/src/pages/Parks.tsx @@ -59,6 +59,8 @@ export interface FilterState { maxReviews?: number; openingYearStart: number | null; openingYearEnd: number | null; + openingDateFrom?: string | null; + openingDateTo?: string | null; } export interface SortState { @@ -85,6 +87,8 @@ const initialFilters: FilterState = { maxReviews: 1000, openingYearStart: null, openingYearEnd: null, + openingDateFrom: null, + openingDateTo: null, }; const initialSort: SortState = { @@ -213,13 +217,26 @@ export default function Parks() { } } - // Opening year filter - if (filters.openingYearStart || filters.openingYearEnd) { - const openingYear = park.opening_date ? parseInt(park.opening_date.split("-")[0]) : null; - if (openingYear) { - if (filters.openingYearStart && openingYear < filters.openingYearStart) return false; - if (filters.openingYearEnd && openingYear > filters.openingYearEnd) return false; - } else if (filters.openingYearStart || filters.openingYearEnd) { + // Opening date filter (timezone-independent) + if (filters.openingDateFrom || filters.openingDateTo || filters.openingYearStart || filters.openingYearEnd) { + if (!park.opening_date) { + return false; + } + + // Full date filtering (if date range is set) + if (filters.openingDateFrom && park.opening_date < filters.openingDateFrom) { + return false; + } + if (filters.openingDateTo && park.opening_date > filters.openingDateTo) { + return false; + } + + // Year-only filtering (for backward compatibility) + const openingYear = parseInt(park.opening_date.split("-")[0]); + if (filters.openingYearStart && openingYear < filters.openingYearStart) { + return false; + } + if (filters.openingYearEnd && openingYear > filters.openingYearEnd) { return false; } } diff --git a/src/pages/Rides.tsx b/src/pages/Rides.tsx index 6e5adc79..0266f410 100644 --- a/src/pages/Rides.tsx +++ b/src/pages/Rides.tsx @@ -233,16 +233,17 @@ export default function Rides() { return false; } - // Opening date filter + // Opening date filter (timezone-independent string comparison) if (filters.openingDateFrom || filters.openingDateTo) { if (!ride.opening_date) { return false; } - const openingDate = new Date(ride.opening_date); - if (filters.openingDateFrom && openingDate < filters.openingDateFrom) { + + // Direct YYYY-MM-DD string comparison (lexicographically correct) + if (filters.openingDateFrom && ride.opening_date < filters.openingDateFrom) { return false; } - if (filters.openingDateTo && openingDate > filters.openingDateTo) { + if (filters.openingDateTo && ride.opening_date > filters.openingDateTo) { return false; } }