From f3350e0124faf6f59048b41eed1932441fa9bdca Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Fri, 10 Apr 2026 15:40:47 -0400 Subject: [PATCH] feat: simplify date picker shortcuts --- src/components/date-time-picker.tsx | 169 +++++++++++++++------------- tests/date-time-picker.test.ts | 72 ++++++++++++ 2 files changed, 164 insertions(+), 77 deletions(-) create mode 100644 tests/date-time-picker.test.ts diff --git a/src/components/date-time-picker.tsx b/src/components/date-time-picker.tsx index 323d64a..f2631e4 100644 --- a/src/components/date-time-picker.tsx +++ b/src/components/date-time-picker.tsx @@ -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 ( -
+
{/* Date popover trigger */} @@ -142,15 +185,19 @@ export function DateTimePicker({ - -
- {quickOptions.map(({ label, date }) => ( + +
+ {quickOptions.map((label) => (
- - {/* Time selects — only when !allDay */} - {!allDay && ( -
- - : - -
- )}
); } diff --git a/tests/date-time-picker.test.ts b/tests/date-time-picker.test.ts new file mode 100644 index 0000000..b0ffe70 --- /dev/null +++ b/tests/date-time-picker.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { + applyQuickDateShortcut, + getCalendarMonthForValue, +} from "@/components/date-time-picker"; + +describe("DateTimePicker quick shortcuts", () => { + test("Today selects today, navigates the visible month, and keeps the picker open", () => { + const now = new Date("2026-04-09T10:30:00Z"); + + const result = applyQuickDateShortcut({ + shortcut: "Today", + value: "2026-06-15T14:30:00", + allDay: false, + now, + }); + + expect(result.nextValue).toBe("2026-04-09T14:30:00"); + expect(result.nextMonth.toISOString()).toBe("2026-04-09T00:00:00.000Z"); + expect(result.keepOpen).toBe(true); + }); + + test("Next week moves selection forward a week without dropping the existing time", () => { + const now = new Date("2026-04-09T10:30:00Z"); + + const result = applyQuickDateShortcut({ + shortcut: "Next week", + value: "2026-04-01T09:45:00", + allDay: false, + now, + }); + + expect(result.nextValue).toBe("2026-04-16T09:45:00"); + expect(result.nextMonth.toISOString()).toBe("2026-04-16T00:00:00.000Z"); + }); + + test("Next month updates the selected all-day date and visible month in place", () => { + const now = new Date("2026-04-09T10:30:00Z"); + + const result = applyQuickDateShortcut({ + shortcut: "Next month", + value: "2026-04-01", + allDay: true, + now, + }); + + expect(result.nextValue).toBe("2026-05-09"); + expect(result.nextMonth.toISOString()).toBe("2026-05-09T00:00:00.000Z"); + expect(result.keepOpen).toBe(true); + }); +}); + +describe("DateTimePicker visible month", () => { + test("uses the selected value month when one is present", () => { + const fallbackDate = new Date("2026-04-09T10:30:00Z"); + + const visibleMonth = getCalendarMonthForValue( + "2026-07-18T08:15:00", + fallbackDate, + ); + + expect(visibleMonth.toISOString()).toBe("2026-07-18T08:15:00.000Z"); + }); + + test("falls back to the current month when no value is selected yet", () => { + const fallbackDate = new Date("2026-04-09T10:30:00Z"); + + const visibleMonth = getCalendarMonthForValue("", fallbackDate); + + expect(visibleMonth.toISOString()).toBe("2026-04-09T10:30:00.000Z"); + }); +});