diff --git a/frontend/src/components/ui/date-picker.tsx b/frontend/src/components/ui/date-picker.tsx index 1326d66..1ad42b3 100644 --- a/frontend/src/components/ui/date-picker.tsx +++ b/frontend/src/components/ui/date-picker.tsx @@ -21,12 +21,63 @@ function formatDate(y: number, m: number, d: number): string { return `${y}-${pad(m + 1)}-${pad(d)}`; } +function to12Hour(h24: number): { hour: number; ampm: 'AM' | 'PM' } { + if (h24 === 0) return { hour: 12, ampm: 'AM' }; + if (h24 < 12) return { hour: h24, ampm: 'AM' }; + if (h24 === 12) return { hour: 12, ampm: 'PM' }; + return { hour: h24 - 12, ampm: 'PM' }; +} + +function to24Hour(h12: number, ampm: string): number { + const isPM = ampm.toUpperCase() === 'PM'; + if (h12 === 12) return isPM ? 12 : 0; + return isPM ? h12 + 12 : h12; +} + +/** ISO string → user-friendly display: DD/MM/YYYY or DD/MM/YYYY h:mm AM/PM */ +function isoToDisplay(iso: string, mode: 'date' | 'datetime'): string { + if (!iso) return ''; + const parts = iso.split('T'); + const dp = parts[0]?.split('-'); + if (!dp || dp.length !== 3) return ''; + const dateStr = `${dp[2]}/${dp[1]}/${dp[0]}`; + if (mode === 'date') return dateStr; + const tp = parts[1]?.split(':'); + if (!tp || tp.length < 2) return dateStr; + const h = parseInt(tp[0], 10); + if (isNaN(h)) return dateStr; + const { hour: h12, ampm } = to12Hour(h); + return `${dateStr} ${h12}:${tp[1].slice(0, 2)} ${ampm}`; +} + +/** User-friendly display → ISO string, or null if incomplete/invalid */ +function displayToIso(display: string, mode: 'date' | 'datetime'): string | null { + if (!display) return null; + if (mode === 'date') { + const m = display.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (!m) return null; + const d = parseInt(m[1], 10), mo = parseInt(m[2], 10), y = parseInt(m[3], 10); + if (mo < 1 || mo > 12 || d < 1 || d > getDaysInMonth(y, mo - 1)) return null; + return `${m[3]}-${m[2]}-${m[1]}`; + } + const m = display.match(/^(\d{2})\/(\d{2})\/(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM)$/i); + if (!m) return null; + const d = parseInt(m[1], 10), mo = parseInt(m[2], 10), y = parseInt(m[3], 10); + if (mo < 1 || mo > 12 || d < 1 || d > getDaysInMonth(y, mo - 1)) return null; + let h = parseInt(m[4], 10); + const min = parseInt(m[5], 10); + if (h < 1 || h > 12 || min < 0 || min > 59) return null; + h = to24Hour(h, m[6]); + return `${m[3]}-${m[2]}-${m[1]}T${pad(h)}:${pad(min)}`; +} + const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; +const HOUR_OPTIONS = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; // ── Props ── @@ -76,7 +127,7 @@ const DatePicker = React.forwardRef( const currentYear = new Date().getFullYear(); const [startYear, endYear] = yearRange ?? [1900, currentYear + 20]; - // Parse current value + // Parse ISO value into parts const parseDateValue = () => { if (!value) return null; const parts = value.split('T'); @@ -101,20 +152,24 @@ const DatePicker = React.forwardRef( const [hour, setHour] = React.useState(parsed?.hour ?? 0); const [minute, setMinute] = React.useState(parsed?.minute ?? 0); + // Input variant: user-friendly display string (DD/MM/YYYY or DD/MM/YYYY h:mm AM/PM) + const [displayValue, setDisplayValue] = React.useState(() => + variant === 'input' ? isoToDisplay(value, mode) : '' + ); + const isInternalChange = React.useRef(false); + // Refs - const triggerRef = React.useRef(null); // button variant - const wrapperRef = React.useRef(null); // input variant - const inputElRef = React.useRef(null); // input variant + const triggerRef = React.useRef(null); + const wrapperRef = React.useRef(null); + const inputElRef = React.useRef(null); const popupRef = React.useRef(null); const blurTimeoutRef = React.useRef>(); const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 }); - // Merge forwarded ref with internal ref (button variant only) React.useImperativeHandle(ref, () => triggerRef.current!); - // Sync internal state when value changes (only when popup is closed to avoid - // jumping the calendar view while the user is navigating months or typing) + // Sync popup view state when value changes (only when popup is closed) React.useEffect(() => { if (open) return; const p = parseDateValue(); @@ -127,7 +182,19 @@ const DatePicker = React.forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, open]); - // Position popup relative to trigger (button) or wrapper (input) + // Sync display string from ISO value (input variant). + // Skips when the change originated from the text input to avoid + // overwriting the user's in-progress typing. + React.useEffect(() => { + if (variant !== 'input') return; + if (isInternalChange.current) { + isInternalChange.current = false; + return; + } + setDisplayValue(value ? isoToDisplay(value, mode) : ''); + }, [value, mode, variant]); + + // Position popup const updatePosition = React.useCallback(() => { const el = variant === 'input' ? wrapperRef.current : triggerRef.current; if (!el) return; @@ -153,9 +220,6 @@ const DatePicker = React.forwardRef( }; }, [open, updatePosition]); - // Close popup. refocusTrigger=true returns focus to the trigger/input - // (used for day selection, Escape, Done). false lets focus go wherever the - // user clicked (used for outside-click dismiss). const closePopup = React.useCallback( (refocusTrigger = true) => { setOpen(false); @@ -194,9 +258,7 @@ const DatePicker = React.forwardRef( return () => document.removeEventListener('keydown', handler); }, [open, closePopup]); - // Input variant: smart blur — only fires onBlur when focus truly leaves the - // component group (input + icon + popup). Uses a short timeout to let focus - // settle on the new target before checking. + // Input variant: smart blur — only fires when focus truly leaves the component const handleInputBlur = React.useCallback(() => { if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); blurTimeoutRef.current = setTimeout(() => { @@ -209,7 +271,6 @@ const DatePicker = React.forwardRef( }, 10); }, [onBlur]); - // Cleanup blur timeout on unmount React.useEffect(() => { return () => { if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); @@ -218,7 +279,6 @@ const DatePicker = React.forwardRef( const openPopup = () => { if (disabled) return; - // Re-sync view to current value when opening const p = parseDateValue(); if (p) { setViewYear(p.year); @@ -234,7 +294,7 @@ const DatePicker = React.forwardRef( else openPopup(); }; - // Min/Max date boundaries + // Min/Max boundaries const minDate = min ? new Date(min + 'T00:00:00') : null; const maxDate = max ? new Date(max + 'T00:00:00') : null; @@ -245,7 +305,6 @@ const DatePicker = React.forwardRef( return false; }; - // Select a day const selectDay = (day: number) => { if (isDayDisabled(viewYear, viewMonth, day)) return; const dateStr = formatDate(viewYear, viewMonth, day); @@ -266,45 +325,40 @@ const DatePicker = React.forwardRef( } }; - // Navigation const prevMonth = () => { - if (viewMonth === 0) { - setViewMonth(11); - setViewYear((y) => y - 1); - } else { - setViewMonth((m) => m - 1); - } + if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1); } + else setViewMonth((m) => m - 1); }; const nextMonth = () => { - if (viewMonth === 11) { - setViewMonth(0); - setViewYear((y) => y + 1); - } else { - setViewMonth((m) => m + 1); - } + if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); } + else setViewMonth((m) => m + 1); }; - // Build calendar grid + // Calendar grid const daysInMonth = getDaysInMonth(viewYear, viewMonth); const firstDay = getFirstDayOfWeek(viewYear, viewMonth); const cells: (number | null)[] = []; for (let i = 0; i < firstDay; i++) cells.push(null); for (let d = 1; d <= daysInMonth; d++) cells.push(d); - const isToday = (d: number) => + const isTodayDay = (d: number) => d === today.getDate() && viewMonth === today.getMonth() && viewYear === today.getFullYear(); const isSelected = (d: number) => parsed !== null && d === parsed.day && viewMonth === parsed.month && viewYear === parsed.year; - // Display text (button variant only) + // 12-hour display values for time selectors + const { hour: h12, ampm: currentAmpm } = to12Hour(hour); + + // Button variant display text const displayText = (() => { if (!parsed) return ''; const monthName = MONTH_NAMES[parsed.month]; const base = `${monthName} ${parsed.day}, ${parsed.year}`; if (mode === 'datetime') { - return `${base} ${pad(parsed.hour)}:${pad(parsed.minute)}`; + const { hour: dh, ampm: da } = to12Hour(parsed.hour); + return `${base} ${dh}:${pad(parsed.minute)} ${da}`; } return base; })(); @@ -322,16 +376,11 @@ const DatePicker = React.forwardRef( style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 60 }} className="w-[280px] rounded-lg border border-input bg-card shadow-lg animate-fade-in" > - {/* Month/Year Nav */} + {/* Month/Year nav */}
- -
- -
@@ -369,12 +409,7 @@ const DatePicker = React.forwardRef( {/* Day headers */}
{DAY_HEADERS.map((d) => ( -
- {d} -
+
{d}
))}
@@ -393,7 +428,7 @@ const DatePicker = React.forwardRef( 'h-8 w-full rounded-md text-sm transition-colors', 'hover:bg-accent/10 focus:outline-none focus-visible:ring-1 focus-visible:ring-ring', isSelected(day) && 'bg-accent text-accent-foreground font-medium', - isToday(day) && !isSelected(day) && 'border border-accent/50 text-accent', + isTodayDay(day) && !isSelected(day) && 'border border-accent/50 text-accent', isDayDisabled(viewYear, viewMonth, day) && 'opacity-30 cursor-not-allowed hover:bg-transparent' )} @@ -404,33 +439,37 @@ const DatePicker = React.forwardRef( )} - {/* Time selectors (datetime mode only) */} + {/* Time selectors — 12-hour with AM/PM */} {mode === 'datetime' && ( -
+
: +