feat: Implement timezone-independent date picker

This commit is contained in:
gpt-engineer-app[bot]
2025-11-05 16:31:51 +00:00
parent c966b6c5ee
commit 7c35f2932b
5 changed files with 248 additions and 20 deletions

View File

@@ -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 (
<div className="space-y-2">
{label && <Label>{label}</Label>}
<div className="flex items-center gap-2">
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!displayText && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{displayText || `${fromPlaceholder} - ${toPlaceholder}`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="flex flex-col sm:flex-row">
{/* Presets sidebar */}
<div className="border-b sm:border-b-0 sm:border-r border-border p-3 space-y-1">
<div className="text-sm font-semibold mb-2 text-muted-foreground">Presets</div>
{activePresets.map((preset) => (
<Button
key={preset.label}
variant="ghost"
size="sm"
className="w-full justify-start font-normal"
onClick={() => handlePresetSelect(preset)}
>
{preset.label}
</Button>
))}
</div>
{/* Calendar */}
<div className="p-3">
<Calendar
mode="range"
selected={dateRange}
onSelect={handleSelect}
numberOfMonths={2}
defaultMonth={dateRange?.from || new Date()}
fromYear={fromYear}
toYear={toYear}
className="pointer-events-auto"
/>
</div>
</div>
</PopoverContent>
</Popover>
{displayText && (
<Button
variant="ghost"
size="icon"
onClick={handleClear}
className="shrink-0"
title="Clear date range"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{displayText && (
<Badge variant="secondary" className="text-xs">
{fromDate && toDate
? `${fromDate} to ${toDate}`
: fromDate
? `From ${fromDate}`
: toDate
? `Until ${toDate}`
: ''}
</Badge>
)}
</div>
);
}

View File

@@ -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"
/>
<TimeZoneIndependentDateRangePicker
label="Opening Date Range (Full Date)"
fromDate={filters.openingDateFrom || null}
toDate={filters.openingDateTo || null}
onFromChange={(date) => onFiltersChange({ ...filters, openingDateFrom: date })}
onToChange={(date) => onFiltersChange({ ...filters, openingDateTo: date })}
fromPlaceholder="From date"
toPlaceholder="To date"
fromYear={1800}
toYear={new Date().getFullYear()}
/>
</div>
</FilterSection>

View File

@@ -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 */}
<FilterSection title="Dates">
<div className="grid grid-cols-1 gap-4">
<FilterDateRangePicker
label="Opening Date"
<TimeZoneIndependentDateRangePicker
label="Opening Date Range"
fromDate={filters.openingDateFrom}
toDate={filters.openingDateTo}
onFromChange={(date) => 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"
/>
</div>
</FilterSection>

View File

@@ -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;
}
}

View File

@@ -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;
}
}