From cc5ce95e1b6d5ccfc17b4f53ff1d51f406df4ef8 Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Wed, 8 Apr 2026 01:02:47 -0400 Subject: [PATCH] feat: replace raw date/time inputs with shadcn Calendar + Select picker - Add popover shadcn component (radix-ui based) - Create DateTimePicker component: Calendar popover for date + hour/minute selects for time, allDay-aware, emits ISO / YYYY-MM-DD strings - Replace datetime-local / date fields in EventDialog with DateTimePicker - Remove unused CalendarIcon and Clock imports from event-dialog --- src/components/date-time-picker.tsx | 171 ++++++++++++++++++++++++++++ src/components/event-dialog.tsx | 53 +++------ src/components/ui/popover.tsx | 89 +++++++++++++++ 3 files changed, 274 insertions(+), 39 deletions(-) create mode 100644 src/components/date-time-picker.tsx create mode 100644 src/components/ui/popover.tsx 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 ( +
+ {/* Date popover trigger */} + + + + + + + + + + {/* Time selects — only when !allDay */} + {!allDay && ( +
+ + : + +
+ )} +
+ ); +} diff --git a/src/components/event-dialog.tsx b/src/components/event-dialog.tsx index 5f3e3ce..8d00959 100644 --- a/src/components/event-dialog.tsx +++ b/src/components/event-dialog.tsx @@ -1,6 +1,7 @@ "use client"; -import { CalendarIcon, Clock, LucideMapPin } from "lucide-react"; +import { LucideMapPin } from "lucide-react"; +import { DateTimePicker } from "@/components/date-time-picker"; import { RecurrencePicker } from "@/components/recurrence-picker"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -139,44 +140,18 @@ export const EventDialog = ({
-
- - {!allDay ? ( - setStart(e.target.value)} - className="pl-8" - placeholder="Start" - /> - ) : ( - setStart(e.target.value)} - className="pl-8" - /> - )} -
-
- - {!allDay ? ( - setEnd(e.target.value)} - className="pl-8" - placeholder="End" - /> - ) : ( - setEnd(e.target.value)} - className="pl-8" - /> - )} -
+ +
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..93d0bc9 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Popover as PopoverPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Popover({ + ...props +}: React.ComponentProps) { + return ; +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return ; +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { + return ( +
+ ); +} + +function PopoverDescription({ + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ ); +} + +export { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +};