"use client"; import React from "react"; import { buildDatePresets } from "@/lib/frontend/search/datePresets"; import { getSearchDateRangeValidation } from "@/lib/frontend/search/searchDateValidation"; import { formatIsoDateDe, formatIsoDateRangeLabelDe, toDateFromIsoDateYmd, toIsoDateYmdFromDate, } from "@/lib/frontend/search/dateRange"; import { normalizeDayClickArgs, buildCalendarState, } from "@/lib/frontend/search/dateRangePickerUtils"; const ACTIVE_FIELD = Object.freeze({ FROM: "from", TO: "to", }); function hasOwn(obj, key) { return Object.prototype.hasOwnProperty.call(obj || {}, key); } function focusRef(ref) { requestAnimationFrame(() => ref?.current?.focus?.()); } export function useSearchDateRangePicker({ from, to, onDateRangeChange, isSubmitting, }) { const disabled = Boolean(isSubmitting); const [open, setOpen] = React.useState(false); const [activeField, setActiveField] = React.useState(ACTIVE_FIELD.FROM); const fromRef = React.useRef(null); const toRef = React.useRef(null); const fromDate = React.useMemo(() => toDateFromIsoDateYmd(from), [from]); const toDate = React.useMemo(() => toDateFromIsoDateYmd(to), [to]); const validation = React.useMemo(() => { return getSearchDateRangeValidation(from ?? null, to ?? null); }, [from, to]); const isRangeInvalid = validation?.code === "VALIDATION_SEARCH_RANGE"; // Presets should reflect the current day even if the app is open across midnight. // We refresh presets on popover open, and also compute a fresh preset on click. const [presetsNow, setPresetsNow] = React.useState(() => new Date()); const presets = React.useMemo(() => { return buildDatePresets({ now: presetsNow }); }, [presetsNow]); const presetsRow1 = React.useMemo(() => presets.slice(0, 4), [presets]); const presetsRow2 = React.useMemo(() => presets.slice(4), [presets]); const [month, setMonth] = React.useState(() => { return fromDate || toDate || new Date(); }); React.useEffect(() => { if (!open) return; // Refresh preset reference "now" whenever the popover opens. setPresetsNow(new Date()); // Only run on open transition. // We intentionally do not depend on from/to to avoid focus jumps while clicking. if (from && !to) { setActiveField(ACTIVE_FIELD.TO); focusRef(toRef); } else { setActiveField(ACTIVE_FIELD.FROM); focusRef(fromRef); } setMonth(fromDate || toDate || new Date()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); const safeOnDateRangeChange = React.useCallback( (patch) => { if (typeof onDateRangeChange !== "function") return; const hasFrom = hasOwn(patch, "from"); const hasTo = hasOwn(patch, "to"); onDateRangeChange({ from: hasFrom ? patch.from : (from ?? null), to: hasTo ? patch.to : (to ?? null), }); }, [onDateRangeChange, from, to], ); const { calendarSelected, calendarModifiers, calendarModifiersClassNames } = React.useMemo(() => { return buildCalendarState({ fromDate, toDate, isRangeInvalid }); }, [fromDate, toDate, isRangeInvalid]); const calendarKey = React.useMemo(() => { return `${from || ""}|${to || ""}|${isRangeInvalid ? "inv" : "ok"}`; }, [from, to, isRangeInvalid]); const summary = React.useMemo(() => { return formatIsoDateRangeLabelDe({ from, to }) || "Zeitraum auswählen"; }, [from, to]); const fromDisplay = React.useMemo(() => formatIsoDateDe(from) || "", [from]); const toDisplay = React.useMemo(() => formatIsoDateDe(to) || "", [to]); const handlePickDay = React.useCallback( (...args) => { if (disabled) return; const { day, modifiers } = normalizeDayClickArgs(args); if (!(day instanceof Date) || Number.isNaN(day.getTime())) return; if (modifiers?.disabled) return; const iso = toIsoDateYmdFromDate(day); if (!iso) return; if (activeField === ACTIVE_FIELD.FROM) { safeOnDateRangeChange({ from: iso }); setActiveField(ACTIVE_FIELD.TO); focusRef(toRef); return; } safeOnDateRangeChange({ to: iso }); setActiveField(ACTIVE_FIELD.TO); focusRef(toRef); }, [disabled, activeField, safeOnDateRangeChange], ); const handleClearFrom = React.useCallback( (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); if (disabled) return; safeOnDateRangeChange({ from: null }); setActiveField(ACTIVE_FIELD.FROM); focusRef(fromRef); }, [disabled, safeOnDateRangeChange], ); const handleClearTo = React.useCallback( (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); if (disabled) return; safeOnDateRangeChange({ to: null }); setActiveField(ACTIVE_FIELD.TO); focusRef(toRef); }, [disabled, safeOnDateRangeChange], ); const handleReset = React.useCallback(() => { if (disabled) return; safeOnDateRangeChange({ from: null, to: null }); setActiveField(ACTIVE_FIELD.FROM); setMonth(new Date()); focusRef(fromRef); }, [disabled, safeOnDateRangeChange]); const applyPreset = React.useCallback( (preset) => { if (disabled) return; if (!preset?.key) return; // Defensive: compute a fresh preset at click-time (midnight edge-case). const freshList = buildDatePresets({ now: new Date() }); const fresh = freshList.find((p) => p.key === preset.key) || preset; if (!fresh?.from || !fresh?.to) return; safeOnDateRangeChange({ from: fresh.from, to: fresh.to }); const nextMonth = toDateFromIsoDateYmd(fresh.from) || toDateFromIsoDateYmd(fresh.to) || new Date(); setMonth(nextMonth); setActiveField(ACTIVE_FIELD.TO); focusRef(toRef); }, [disabled, safeOnDateRangeChange], ); return { disabled, open, setOpen, activeField, setActiveField, fromRef, toRef, month, setMonth, summary, fromDisplay, toDisplay, presetsRow1, presetsRow2, calendarKey, calendarSelected, calendarModifiers, calendarModifiersClassNames, isRangeInvalid, handlePickDay, handleClearFrom, handleClearTo, handleReset, applyPreset, }; }