date-time-picker-range.tsx•10.8 kB
import { format, subDays, addDays, startOfDay, endOfDay } from 'date-fns';
import { t } from 'i18next';
import { Calendar as CalendarIcon, Clock } from 'lucide-react';
import * as React from 'react';
import { DateRange } from 'react-day-picker';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { Separator } from './separator';
import { TimePicker } from './time-picker';
export type PresetKey =
| '7days'
| '14days'
| '30days'
| '90days'
| '7'
| '14'
| '30'
| '90';
type DateTimePickerWithRangeProps = {
onChange: (date: DateRange | undefined) => void;
className?: string;
from?: string;
to?: string;
maxDate?: Date;
minDate?: Date;
presetType: 'past' | 'future';
defaultSelectedRange?: PresetKey;
};
const applyTimeToDate = (timeDate: Date, targetDate: Date): Date => {
const d = new Date(targetDate);
d.setHours(
timeDate.getHours(),
timeDate.getMinutes(),
timeDate.getSeconds(),
timeDate.getMilliseconds(),
);
return d;
};
const getDayBoundaries = () => {
const now = new Date();
return {
from: startOfDay(now),
to: endOfDay(now),
};
};
const PRESETS: Record<PresetKey, () => { from: Date; to: Date }> = {
'7days': () => ({ from: subDays(new Date(), 7), to: new Date() }),
'14days': () => ({ from: subDays(new Date(), 14), to: new Date() }),
'30days': () => ({ from: subDays(new Date(), 30), to: new Date() }),
'90days': () => ({ from: subDays(new Date(), 90), to: new Date() }),
'7': () => ({ from: new Date(), to: addDays(new Date(), 7) }),
'14': () => ({ from: new Date(), to: addDays(new Date(), 14) }),
'30': () => ({ from: new Date(), to: addDays(new Date(), 30) }),
'90': () => ({ from: new Date(), to: addDays(new Date(), 90) }),
};
const getPresetLabel = (value: string) => {
const labels: Record<string, string> = {
'7days': t('Last 7 Days'),
'14days': t('Last 14 Days'),
'30days': t('Last 30 Days'),
'90days': t('Last 90 Days'),
'7': t('Next 7 days'),
'14': t('Next 14 days'),
'30': t('Next 30 days'),
'90': t('Next 90 days'),
};
return labels[value] || '';
};
const detectPreset = (
from?: Date,
to?: Date,
presetType?: 'past' | 'future',
): string | null => {
if (!from || !to) return null;
const candidates =
presetType === 'past'
? (['7days', '14days', '30days', '90days'] as PresetKey[])
: (['7', '14', '30', '90'] as PresetKey[]);
for (const key of candidates) {
const { from: pf, to: pt } = PRESETS[key]();
if (
startOfDay(pf).getTime() === startOfDay(from).getTime() &&
endOfDay(pt).getTime() === endOfDay(to).getTime()
) {
return key;
}
}
return null;
};
const getDefaultRange = (presetKey: PresetKey) => {
const preset = PRESETS[presetKey]();
preset.from!.setHours(0, 0, 0, 0);
preset.to!.setHours(23, 59, 59, 999);
return preset;
};
const getInitialDateAndPreset = (
fromProp?: string,
toProp?: string,
presetType: 'past' | 'future' = 'past',
defaultPresetKey?: PresetKey,
): { initialDate: DateRange | undefined; initialPreset: string | null } => {
let initialDate: DateRange | undefined;
let initialPreset: string | null = null;
if (fromProp && toProp) {
initialDate = {
from: new Date(fromProp),
to: new Date(toProp),
};
initialPreset = detectPreset(initialDate.from, initialDate.to, presetType);
} else if (defaultPresetKey) {
initialDate = getDefaultRange(defaultPresetKey);
initialPreset = defaultPresetKey;
}
return { initialDate, initialPreset };
};
export function DateTimePickerWithRange({
className,
onChange,
from,
to,
maxDate = new Date(),
minDate,
presetType = 'past',
defaultSelectedRange,
}: DateTimePickerWithRangeProps) {
const { initialDate, initialPreset } = React.useMemo(() => {
return getInitialDateAndPreset(from, to, presetType, defaultSelectedRange);
}, [from, to, presetType, defaultSelectedRange]);
const [date, setDate] = React.useState<DateRange | undefined>(initialDate);
const [timeDate, setTimeDate] = React.useState<DateRange>({
from: initialDate?.from,
to: initialDate?.to,
});
const [selectedPreset, setSelectedPreset] = React.useState<string | null>(
initialPreset,
);
const isDefaultApplied = React.useRef(!!initialPreset && !from && !to);
React.useEffect(() => {
if (isDefaultApplied.current && date) {
onChange(date);
isDefaultApplied.current = false;
}
}, [date, onChange]);
React.useEffect(() => {
if (from && to) {
const newDate: DateRange = { from: new Date(from), to: new Date(to) };
setDate(newDate);
setTimeDate({ from: newDate.from, to: newDate.to });
const preset = detectPreset(newDate.from, newDate.to, presetType);
setSelectedPreset(preset);
} else if (!from && !to) {
setDate(initialDate);
setTimeDate({ from: initialDate?.from, to: initialDate?.to });
setSelectedPreset(initialPreset);
}
}, [from, to, presetType, initialDate, initialPreset]);
const handleSelect = (selectedDate: DateRange | undefined) => {
setSelectedPreset(null);
if (!selectedDate) {
setDate(undefined);
onChange(undefined);
return;
}
const newDate = {
from: selectedDate.from
? applyTimeToDate(
timeDate.from || getDayBoundaries().from,
selectedDate.from,
)
: undefined,
to: selectedDate.to
? applyTimeToDate(timeDate.to || getDayBoundaries().to, selectedDate.to)
: undefined,
};
setDate(newDate);
onChange(newDate);
};
const handlePresetChange = (value: string) => {
const newRange = PRESETS[value as PresetKey]();
newRange.from!.setHours(0, 0, 0, 0);
newRange.to!.setHours(23, 59, 59, 999);
setDate(newRange);
setTimeDate({ from: newRange.from, to: newRange.to });
setSelectedPreset(value);
onChange(newRange);
};
return (
<div className={cn('grid gap-2', className)}>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'min-w-[90px] border-dashed justify-start text-left font-normal',
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedPreset ? (
<span>{getPresetLabel(selectedPreset)}</span>
) : date?.from ? (
date.to ? (
<div className="flex gap-2 items-center">
<div>{format(date.from, 'LLL dd, y, hh:mm a')}</div>
<div>{t('to')}</div>
<div>{format(date.to, 'LLL dd, y, hh:mm a')}</div>
</div>
) : (
format(date.from, 'LLL dd, y, hh:mm a')
)
) : (
<span>{t('Pick a date range')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex space-x-2 mb-2">
<Select
onValueChange={handlePresetChange}
value={selectedPreset || undefined}
>
<SelectTrigger>
<SelectValue placeholder={t('Select preset')} />
</SelectTrigger>
<SelectContent>
{presetType === 'past' ? (
<>
<SelectItem value="7days">{t('Last 7 Days')}</SelectItem>
<SelectItem value="14days">{t('Last 14 Days')}</SelectItem>
<SelectItem value="30days">{t('Last 30 Days')}</SelectItem>
<SelectItem value="90days">{t('Last 90 Days')}</SelectItem>
</>
) : (
<>
<SelectItem value="7">{t('Next 7 days')}</SelectItem>
<SelectItem value="14">{t('Next 14 days')}</SelectItem>
<SelectItem value="30">{t('Next 30 days')}</SelectItem>
<SelectItem value="90">{t('Next 90 days')}</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
<Calendar
initialFocus
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={handleSelect}
numberOfMonths={2}
weekStartsOn={1}
toDate={maxDate}
fromDate={minDate}
/>
<Separator className="mb-4" />
<div className="flex gap-1.5 px-2 items-center text-sm mb-3">
<Clock className="w-4 h-4 text-muted-foreground" />
{t('Select Time Range')}
</div>
<div className="flex gap-3 items-center px-2 mb-2">
<TimePicker
date={timeDate.from}
name="from"
setDate={(fromTime) => {
const fromWithTime = applyTimeToDate(
fromTime,
date?.from ?? new Date(),
);
const updated = { from: fromWithTime, to: date?.to };
setDate(updated);
setTimeDate({ ...timeDate, from: fromTime });
setSelectedPreset(null);
onChange(updated);
}}
/>
{t('to')}
<TimePicker
date={timeDate.to}
name="to"
setDate={(toTime) => {
const toWithTime = applyTimeToDate(
toTime,
date?.to ?? date?.from ?? new Date(),
);
const updated = { from: date?.from, to: toWithTime };
setDate(updated);
setTimeDate({ ...timeDate, to: toTime });
setSelectedPreset(null);
onChange(updated);
}}
/>
</div>
<div className="flex justify-center mt-3">
<Button
variant="ghost"
size="sm"
className="text-primary hover:!text-primary w-full"
onClick={() => {
setDate(undefined);
setTimeDate({ from: undefined, to: undefined });
setSelectedPreset(null);
onChange(undefined);
}}
>
{t('Clear')}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
}