import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { formatISODate, newDate, parseISODateString, newDateFromDate, todayInTz } from 'shared-utils/date-utils' import type { IDateConstraints, TDateRangeMap } from 'shared-types/calendar' import { isDateExcluded, isDayDisabled as checkDayDisabled } from 'shared-utils/calendar/date-validation' import { convertSelectedToDates, updateRangeMap as calculateRangeMap, isRangeAllowed as checkRangeAllowed, addToSelection, removeFromSelection, toggleSelection, handleRangeSelection, } from 'shared-utils/calendar/selection-manager' import { shouldIgnoreKeyboardEvent, findNextSelectableDate as findNextDate, getKeyDirection, } from 'shared-utils/calendar/keyboard-navigation' import { DEFAULT_STRINGS, type IPktCalendar, type ICalendarState } from './types' export function useCalendarState(props: IPktCalendar): ICalendarState { const { selected: selectedProp, multiple = false, maxMultiple = 0, range = false, earliest = null, latest = null, excludedates: excludedatesProp, excludeweekdays = [], weeknumbers = false, withcontrols = false, currentmonth: currentmonthProp, today: todayProp, strings: stringsProp, onDateSelected, onClose, id: idProp, className, } = props const generatedId = useId() const componentId = idProp ?? generatedId const strings = useMemo(() => ({ ...DEFAULT_STRINGS, ...stringsProp }), [stringsProp]) const todayDate = useMemo( () => (todayProp ? parseISODateString(todayProp) : todayInTz()), [todayProp], ) const excludedates = useMemo(() => { if (!excludedatesProp) return [] return excludedatesProp.map((d) => (typeof d === 'string' ? parseISODateString(d) : d)) }, [excludedatesProp]) const isControlled = selectedProp !== undefined const [internalSelected, setInternalSelected] = useState([]) const activeSelected = isControlled ? selectedProp : internalSelected const _selected = useMemo(() => convertSelectedToDates(activeSelected), [activeSelected]) const [year, setYear] = useState(0) const [month, setMonth] = useState(0) const [inRange, setInRange] = useState({}) const [rangeHovered, setRangeHovered] = useState(null) const [focusedDate, setFocusedDate] = useState(null) const calendarRef = useRef(null) const selectableDatesRef = useRef<{ currentDateISO: string; isDisabled: boolean; tabindex: string }[]>([]) const tabIndexSetRef = useRef(0) const currentmonthtouchedRef = useRef(false) const initializedRef = useRef(false) const dateConstraints = useMemo( () => ({ earliest, latest, excludedates, excludeweekdays }), [earliest, latest, excludedates, excludeweekdays], ) useEffect(() => { let effectiveMonth: Date | null = null if (currentmonthProp != null) { if (currentmonthProp instanceof Date) { effectiveMonth = newDateFromDate(currentmonthProp) } else if (typeof currentmonthProp === 'string') { effectiveMonth = parseISODateString(currentmonthProp) } } if (!effectiveMonth || isNaN(effectiveMonth.getTime())) { if (activeSelected.length > 0 && activeSelected[0] !== '') { const d = parseISODateString(activeSelected[activeSelected.length - 1]) effectiveMonth = isNaN(d.getTime()) ? newDateFromDate(todayDate) : d } else { effectiveMonth = newDateFromDate(todayDate) } } if (!effectiveMonth || isNaN(effectiveMonth.getTime())) { effectiveMonth = newDateFromDate(todayDate) } // Clamp to latest/earliest so the calendar doesn't open on an entirely disabled month if (latest) { const latestDate = typeof latest === 'string' ? parseISODateString(latest) : latest if (!isNaN(latestDate.getTime()) && effectiveMonth > latestDate) { effectiveMonth = latestDate } } if (earliest) { const earliestDate = typeof earliest === 'string' ? parseISODateString(earliest) : earliest if (!isNaN(earliestDate.getTime()) && effectiveMonth < earliestDate) { effectiveMonth = earliestDate } } setYear(effectiveMonth.getFullYear()) setMonth(effectiveMonth.getMonth()) initializedRef.current = true }, []) // Sync when selected changes externally (controlled mode) useEffect(() => { if (!initializedRef.current) return if (currentmonthtouchedRef.current) return if (activeSelected.length > 0 && activeSelected[0] !== '') { const d = parseISODateString(activeSelected[activeSelected.length - 1]) if (!isNaN(d.getTime())) { setYear(d.getFullYear()) setMonth(d.getMonth()) } } }, [activeSelected]) // Sync range map when selected changes useEffect(() => { if (range && _selected.length === 2) { setInRange(calculateRangeMap(_selected[0], _selected[1])) } else if (!range || _selected.length < 2) { setInRange({}) } }, [range, _selected]) const updateSelected = useCallback( (newSelected: string[]) => { if (!isControlled) { setInternalSelected(newSelected) } onDateSelected?.(newSelected) }, [isControlled, onDateSelected], ) const close = useCallback(() => { onClose?.() }, [onClose]) const changeMonth = useCallback((newYear: number, newMonth: number) => { setYear(typeof newYear === 'string' ? parseInt(newYear as unknown as string) : newYear) setMonth(typeof newMonth === 'string' ? parseInt(newMonth as unknown as string) : newMonth) tabIndexSetRef.current = 0 setFocusedDate(null) selectableDatesRef.current = [] currentmonthtouchedRef.current = true }, []) const prevMonth = useCallback(() => { const newMonth = month === 0 ? 11 : month - 1 const newYear = month === 0 ? year - 1 : year changeMonth(newYear, newMonth) }, [year, month, changeMonth]) const nextMonth = useCallback(() => { const newMonth = month === 11 ? 0 : month + 1 const newYear = month === 11 ? year + 1 : year changeMonth(newYear, newMonth) }, [year, month, changeMonth]) const isExcluded = useCallback((date: Date) => isDateExcluded(date, dateConstraints), [dateConstraints]) const isDayDisabled = useCallback( (date: Date, isSelected: boolean) => checkDayDisabled(date, isSelected, dateConstraints, { multiple, maxMultiple, selectedCount: activeSelected.length, }), [dateConstraints, multiple, maxMultiple, activeSelected.length], ) const normalizeSelected = useCallback((): string[] => { if (typeof activeSelected === 'string') { return (activeSelected as string).split(',') } return activeSelected }, [activeSelected]) const handleRangeHover = useCallback( (date: Date) => { if ( !range || _selected.length !== 1 || !checkRangeAllowed(date, _selected, excludedates, excludeweekdays) || _selected[0] >= date ) { setRangeHovered(null) return } setRangeHovered(date) setInRange(calculateRangeMap(_selected[0], date)) }, [range, _selected, excludedates, excludeweekdays], ) const handleDateSelect = useCallback( (selectedDate: Date | null) => { if (!selectedDate) return let newSelected: string[] if (range) { newSelected = handleRangeSelection(selectedDate, normalizeSelected(), { excludedates, excludeweekdays, }) if (!isControlled) { setInternalSelected(newSelected) } if (newSelected.length === 2) { onDateSelected?.(newSelected) close() } else if (newSelected.length === 1) { setInRange({}) onDateSelected?.(newSelected) } else { onDateSelected?.(newSelected) } } else if (multiple) { newSelected = toggleSelection(selectedDate, normalizeSelected(), maxMultiple) updateSelected(newSelected) } else { const dateISO = formatISODate(selectedDate) if (activeSelected.includes(dateISO)) { newSelected = [] } else { newSelected = [dateISO] } updateSelected(newSelected) close() } }, [ range, multiple, maxMultiple, normalizeSelected, activeSelected, excludedates, excludeweekdays, isControlled, updateSelected, close, onDateSelected, ], ) const addToSelected = useCallback( (selectedDate: Date) => { const newSelected = addToSelection(selectedDate, normalizeSelected()) if (!isControlled) { setInternalSelected(newSelected) } if (range && newSelected.length === 2) { onDateSelected?.(newSelected) close() } }, [normalizeSelected, isControlled, range, onDateSelected, close], ) const removeFromSelected = useCallback( (selectedDate: Date) => { const newSelected = removeFromSelection(selectedDate, normalizeSelected()) if (!isControlled) { setInternalSelected(newSelected) } }, [normalizeSelected, isControlled], ) const toggleSelectedDate = useCallback( (selectedDate: Date) => { const newSelected = toggleSelection(selectedDate, normalizeSelected(), maxMultiple) if (!isControlled) { setInternalSelected(newSelected) } }, [normalizeSelected, maxMultiple, isControlled], ) const focusOnCurrentDate = useCallback(() => { const currentDateISO = formatISODate(newDateFromDate(todayDate)) const el = calendarRef.current?.querySelector(`button[data-date="${currentDateISO}"]`) if (el instanceof HTMLButtonElement) { setFocusedDate(currentDateISO) el.focus() return } const firstSelectable = selectableDatesRef.current.find((x) => !x.isDisabled) if (firstSelectable) { const firstSelectableEl = calendarRef.current?.querySelector( `button[data-date="${firstSelectable.currentDateISO}"]`, ) if (firstSelectableEl instanceof HTMLButtonElement) { setFocusedDate(firstSelectable.currentDateISO) firstSelectableEl.focus() } } }, []) const handleArrowKey = useCallback( (e: KeyboardEvent, direction: number) => { const target = e.target as HTMLElement if (shouldIgnoreKeyboardEvent(target)) return e.preventDefault() if (!focusedDate) { focusOnCurrentDate() return } const date = newDate(focusedDate) const nextDate = findNextDate(date, direction, calendarRef.current!.querySelector.bind(calendarRef.current!)) if (nextDate) { const el = calendarRef.current!.querySelector(`button[data-date="${formatISODate(nextDate)}"]`) if (el instanceof HTMLButtonElement && !el.dataset.disabled) { setFocusedDate(formatISODate(nextDate)) el.focus() } } }, [focusedDate, focusOnCurrentDate], ) const handleKeydown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() close() return } const direction = getKeyDirection(e.key) if (direction !== null) { handleArrowKey(e.nativeEvent, direction) } }, [close, handleArrowKey], ) const handleFocusOut = useCallback( (e: React.FocusEvent) => { if ( calendarRef.current && !calendarRef.current.contains(e.relatedTarget as Node) && !(e.target as Element).classList.contains('pkt-hide') ) { close() } }, [close], ) return { componentId, strings, todayDate, year, month, activeSelected, _selected, inRange, rangeHovered, focusedDate, range, multiple, weeknumbers, withcontrols, earliest, latest, excludedates, excludeweekdays, className, dateConstraints, calendarRef, selectableDatesRef, tabIndexSetRef, prevMonth, nextMonth, changeMonth, handleDateSelect, addToSelected, removeFromSelected, toggleSelectedDate, handleRangeHover, isExcluded, isDayDisabled, focusOnCurrentDate, handleKeydown, handleFocusOut, close, setFocusedDate, } }