'use client'; import { useState, useRef, useMemo, useCallback, } from 'react'; import { format, parse, startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth, isSameDay, addMonths, subMonths, isValid, } from 'date-fns'; import { clsx } from 'clsx/lite'; import useClickInsideOutside from '@/utility/useClickInsideOutside'; import { useFormStatus } from 'react-dom'; import { TbCalendar, TbChevronLeft, TbChevronRight } from 'react-icons/tb'; import { DATE_FORMAT_POSTGRES, DAY_NAMES, getLocalTimeZoneLabel, } from '@/utility/date'; type DateTimePickerType = 'utc' | 'naive'; type DisplayMode = 'utc' | 'local'; const LOCAL_TZ_LABEL = getLocalTimeZoneLabel() ?? 'LOCAL'; function parseValue( value: string, type: DateTimePickerType, displayMode: DisplayMode, ): Date | null { if (!value) return null; try { if (type === 'utc') { const utcDate = new Date(value); if (!isValid(utcDate)) return null; if (displayMode === 'local') { // Return as-is; JS Date local getters will show local time return utcDate; } // Shift UTC components into a local Date so calendar renders UTC time return new Date( utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate(), utcDate.getUTCHours(), utcDate.getUTCMinutes(), utcDate.getUTCSeconds(), ); } else { const d = parse(value, DATE_FORMAT_POSTGRES, new Date()); return isValid(d) ? d : null; } } catch { return null; } } function formatValue( displayDate: Date, type: DateTimePickerType, displayMode: DisplayMode, ): string { if (type === 'utc') { if (displayMode === 'local') { // Local Date components → JS converts to UTC in toISOString() return new Date( displayDate.getFullYear(), displayDate.getMonth(), displayDate.getDate(), displayDate.getHours(), displayDate.getMinutes(), displayDate.getSeconds(), ).toISOString(); } // UTC mode: treat display components as UTC return new Date(Date.UTC( displayDate.getFullYear(), displayDate.getMonth(), displayDate.getDate(), displayDate.getHours(), displayDate.getMinutes(), displayDate.getSeconds(), )).toISOString(); } return format(displayDate, DATE_FORMAT_POSTGRES); } export default function DateTimePicker({ value, onChange, type, readOnly, }: { value: string onChange?: (value: string) => void type: DateTimePickerType readOnly?: boolean }) { const containerRef = useRef(null); const { pending } = useFormStatus(); const isDisabled = readOnly || pending; const [isOpen, setIsOpen] = useState(false); const [viewMonth, setViewMonth] = useState(new Date()); const [displayMode, setDisplayMode] = useState('utc'); const parsedDate = useMemo( () => parseValue(value, type, displayMode), [value, type, displayMode], ); useClickInsideOutside({ htmlElements: [containerRef], onClickOutside: () => setIsOpen(false), shouldListenToClicks: isOpen, }); const calendarDays = useMemo(() => { const start = startOfWeek(startOfMonth(viewMonth)); const end = endOfWeek(endOfMonth(viewMonth)); return eachDayOfInterval({ start, end }); }, [viewMonth]); const updateDate = useCallback((newDisplayDate: Date) => { onChange?.(formatValue(newDisplayDate, type, displayMode)); }, [onChange, type, displayMode]); const handleDayClick = (day: Date) => { const base = parsedDate ?? new Date(); updateDate(new Date( day.getFullYear(), day.getMonth(), day.getDate(), base.getHours(), base.getMinutes(), base.getSeconds(), )); }; const h = parsedDate?.getHours() ?? 0; const m = parsedDate?.getMinutes() ?? 0; const s = parsedDate?.getSeconds() ?? 0; return (
{isOpen && (
{/* Month navigation */}
{format(viewMonth, 'MMMM yyyy')}
{/* Day headers */}
{DAY_NAMES.map(day => (
{day}
))}
{/* Calendar days */}
{calendarDays.map(day => { const isCurrentMonth = isSameMonth(day, viewMonth); const isSelected = parsedDate !== null && isSameDay(day, parsedDate); return ( ); })}
{/* Time */}
{ const base = parsedDate ?? new Date(); updateDate(new Date( base.getFullYear(), base.getMonth(), base.getDate(), newH, m, s, )); }} /> : { const base = parsedDate ?? new Date(); updateDate(new Date( base.getFullYear(), base.getMonth(), base.getDate(), h, newM, s, )); }} /> : { const base = parsedDate ?? new Date(); updateDate(new Date( base.getFullYear(), base.getMonth(), base.getDate(), h, m, newS, )); }} />
{type === 'utc' && ( )}
)}
); } function TimeField({ value, max, onChange, }: { value: number max: number onChange: (value: number) => void }) { const formatted = useMemo(() => String(value).padStart(2, '0'), [value]); const [draft, setDraft] = useState(null); return ( { const val = e.target.value; if (!/^\d{0,2}$/.test(val)) return; setDraft(val); const n = parseInt(val, 10); if (!isNaN(n) && n >= 0 && n <= max) { onChange(n); } }} onFocus={() => setDraft(formatted)} onBlur={() => setDraft(null)} className="w-9! min-h-0! text-center px-1! py-1! text-xs" /> ); }