feat: simplify date picker shortcuts

This commit is contained in:
2026-04-10 15:40:47 -04:00
parent 27492ee01f
commit f3350e0124
2 changed files with 164 additions and 77 deletions

View File

@@ -18,13 +18,6 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
interface DateTimePickerProps {
@@ -35,6 +28,14 @@ interface DateTimePickerProps {
className?: string;
}
type QuickShortcut = "Today" | "Next week" | "Next month";
interface QuickShortcutResult {
keepOpen: true;
nextMonth: Date;
nextValue: string;
}
/** Parse the incoming ISO / date string into a Date object, or return undefined. */
function parseValue(value: string): Date | undefined {
if (!value) return undefined;
@@ -65,8 +66,55 @@ function buildValue(
return `${format(date, "yyyy-MM-dd")}T${hh}:${mm}:00`;
}
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MINUTES = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55];
function resolveQuickShortcutDate(shortcut: QuickShortcut, now: Date): Date {
const today = startOfDay(now);
if (shortcut === "Today") return today;
if (shortcut === "Next week") return startOfDay(addWeeks(today, 1));
return startOfDay(addMonths(today, 1));
}
function getTimeParts(value: string, allDay: boolean) {
const parsed = parseValue(value);
if (!parsed || allDay) {
return { hour: 0, minute: 0 };
}
return {
hour: parsed.getHours(),
minute: parsed.getMinutes(),
};
}
export function getCalendarMonthForValue(
value: string,
fallbackDate: Date,
): Date {
return parseValue(value) ?? fallbackDate;
}
export function applyQuickDateShortcut({
shortcut,
value,
allDay,
now,
}: {
shortcut: QuickShortcut;
value: string;
allDay: boolean;
now: Date;
}): QuickShortcutResult {
const nextMonth = resolveQuickShortcutDate(shortcut, now);
const { hour, minute } = getTimeParts(value, allDay);
return {
keepOpen: true,
nextMonth,
nextValue: buildValue(nextMonth, hour, minute, allDay),
};
}
export function DateTimePicker({
value,
@@ -76,46 +124,41 @@ export function DateTimePicker({
className,
}: DateTimePickerProps) {
const parsed = parseValue(value);
// Derive hour/minute from the current value (fallback to 0:00)
const currentHour = parsed && !allDay ? parsed.getHours() : 0;
const currentMinute = parsed && !allDay ? parsed.getMinutes() : 0;
// Snap current minute to nearest MINUTES bucket for the select
const snappedMinute = MINUTES.reduce((prev, curr) =>
Math.abs(curr - currentMinute) < Math.abs(prev - currentMinute)
? curr
: prev,
const { hour: currentHour, minute: currentMinute } = getTimeParts(
value,
allDay,
);
const [open, setOpen] = React.useState(false);
const [visibleMonth, setVisibleMonth] = React.useState(() =>
getCalendarMonthForValue(value, new Date()),
);
React.useEffect(() => {
setVisibleMonth(getCalendarMonthForValue(value, new Date()));
}, [value]);
const handleDaySelect = (day: Date | undefined) => {
if (!day) return;
onChange(buildValue(day, currentHour, snappedMinute, allDay));
onChange(buildValue(day, currentHour, currentMinute, allDay));
setVisibleMonth(day);
setOpen(false);
};
const handleHourChange = (h: string) => {
const base = parsed ?? new Date();
onChange(buildValue(base, Number(h), snappedMinute, allDay));
const handleQuickSelect = (shortcut: QuickShortcut) => {
const result = applyQuickDateShortcut({
shortcut,
value,
allDay,
now: new Date(),
});
onChange(result.nextValue);
setVisibleMonth(result.nextMonth);
setOpen(result.keepOpen);
};
const handleMinuteChange = (m: string) => {
const base = parsed ?? new Date();
onChange(buildValue(base, currentHour, Number(m), allDay));
};
const handleQuickSelect = (day: Date) => {
onChange(buildValue(day, currentHour, snappedMinute, allDay));
setOpen(false);
};
const quickOptions: { label: string; date: Date }[] = [
{ label: "Today", date: startOfDay(new Date()) },
{ label: "Next week", date: startOfDay(addWeeks(new Date(), 1)) },
{ label: "Next month", date: startOfDay(addMonths(new Date(), 1)) },
];
const quickOptions: QuickShortcut[] = ["Today", "Next week", "Next month"];
const displayLabel = parsed
? allDay
@@ -124,7 +167,7 @@ export function DateTimePicker({
: null;
return (
<div className={cn("flex items-center gap-2", className)}>
<div className={cn("w-full", className)}>
{/* Date popover trigger */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -142,15 +185,19 @@ export function DateTimePicker({
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start" sideOffset={8}>
<div className="flex gap-1 border-b border-border px-3 py-2">
{quickOptions.map(({ label, date }) => (
<PopoverContent
className="w-[min(22rem,calc(100vw-2rem))] p-0"
align="start"
sideOffset={8}
>
<div className="flex flex-wrap gap-1 border-b border-border px-3 py-2">
{quickOptions.map((label) => (
<Button
key={label}
type="button"
variant="ghost"
size="sm"
onClick={() => handleQuickSelect(date)}
onClick={() => handleQuickSelect(label)}
className="h-auto px-2 py-1 text-xs text-muted-foreground"
>
{label}
@@ -159,46 +206,14 @@ export function DateTimePicker({
</div>
<Calendar
mode="single"
month={visibleMonth}
onMonthChange={setVisibleMonth}
selected={parsed}
onSelect={handleDaySelect}
initialFocus
/>
</PopoverContent>
</Popover>
{/* Time selects — only when !allDay */}
{!allDay && (
<div className="flex items-center gap-1">
<Select value={String(currentHour)} onValueChange={handleHourChange}>
<SelectTrigger className="w-[62px] px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-56">
{HOURS.map((h) => (
<SelectItem key={h} value={String(h)} className="text-xs">
{String(h).padStart(2, "0")}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs select-none">:</span>
<Select
value={String(snappedMinute)}
onValueChange={handleMinuteChange}
>
<SelectTrigger className="w-[62px] px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m} value={String(m)} className="text-xs">
{String(m).padStart(2, "0")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
);
}