diff --git a/src/components/date-time-picker.tsx b/src/components/date-time-picker.tsx new file mode 100644 index 0000000..d0fc311 --- /dev/null +++ b/src/components/date-time-picker.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { format, isValid, parse, parseISO } from "date-fns"; +import { CalendarIcon } from "lucide-react"; +import * as React from "react"; +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"; + +interface DateTimePickerProps { + value: string; + onChange: (value: string) => void; + allDay: boolean; + placeholder?: string; + className?: string; +} + +/** Parse the incoming ISO / date string into a Date object, or return undefined. */ +function parseValue(value: string): Date | undefined { + if (!value) return undefined; + // Try ISO 8601 with offset (e.g. "2024-01-15T14:30:00+00:00") + const iso = parseISO(value); + if (isValid(iso)) return iso; + // Try plain date "YYYY-MM-DD" + const plain = parse(value, "yyyy-MM-dd", new Date()); + if (isValid(plain)) return plain; + // Try datetime-local "YYYY-MM-DDTHH:mm" + const local = parse(value, "yyyy-MM-dd'T'HH:mm", new Date()); + if (isValid(local)) return local; + return undefined; +} + +/** Emit ISO date string from Date + time parts depending on allDay mode. */ +function buildValue( + date: Date, + hour: number, + minute: number, + allDay: boolean, +): string { + if (allDay) { + return format(date, "yyyy-MM-dd"); + } + const hh = String(hour).padStart(2, "0"); + const mm = String(minute).padStart(2, "0"); + 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]; + +export function DateTimePicker({ + value, + onChange, + allDay, + placeholder = "Pick a date", + 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 [open, setOpen] = React.useState(false); + + const handleDaySelect = (day: Date | undefined) => { + if (!day) return; + onChange(buildValue(day, currentHour, snappedMinute, allDay)); + setOpen(false); + }; + + const handleHourChange = (h: string) => { + const base = parsed ?? new Date(); + onChange(buildValue(base, Number(h), snappedMinute, allDay)); + }; + + const handleMinuteChange = (m: string) => { + const base = parsed ?? new Date(); + onChange(buildValue(base, currentHour, Number(m), allDay)); + }; + + const displayLabel = parsed + ? allDay + ? format(parsed, "MMM d, yyyy") + : format(parsed, "MMM d, yyyy · HH:mm") + : null; + + return ( +