From e1a8544c2ef4c0980c9ec3aa19302e7f96f72ef1 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:18:09 -0500 Subject: [PATCH] refactor: extract shared calendar picker utilities from date picker components --- .../ui/calendar-picker-utils.test.ts | 242 ++++++++++++ .../components/ui/calendar-picker-utils.tsx | 314 +++++++++++++++ .../app/src/components/ui/date-picker.tsx | 358 ++++-------------- .../src/components/ui/date-range-picker.tsx | 334 ++++------------ .../src/components/ui/multi-date-picker.tsx | 276 +++----------- 5 files changed, 754 insertions(+), 770 deletions(-) create mode 100644 packages/app/src/components/ui/calendar-picker-utils.test.ts create mode 100644 packages/app/src/components/ui/calendar-picker-utils.tsx diff --git a/packages/app/src/components/ui/calendar-picker-utils.test.ts b/packages/app/src/components/ui/calendar-picker-utils.test.ts new file mode 100644 index 0000000..d43ac34 --- /dev/null +++ b/packages/app/src/components/ui/calendar-picker-utils.test.ts @@ -0,0 +1,242 @@ +// @vitest-environment jsdom + +import React, { type Dispatch, type SetStateAction } from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + CalendarMonthPanel, + formatCalendarDate, + getCalendarMonthNavState, + getInitialCalendarMonth, + getLatestSelectableDate, + isCalendarDateOutOfRange, + parseCalendarDate, + resolveCalendarDateBounds, + useCalendarMonth, +} from '@/components/ui/calendar-picker-utils'; + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean; +} + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('calendar-picker-utils', () => { + it('parses ISO and legacy calendar date formats', () => { + expect(formatCalendarDate(parseCalendarDate('2026-04-02'))).toBe('2026-04-02'); + expect(formatCalendarDate(parseCalendarDate('04/02/2026, 13:45:59'))).toBe('2026-04-02'); + }); + + it('returns the latest available date or falls back to maxDate/today', () => { + expect(getLatestSelectableDate(['2026-01-10', '2026-03-20'], '2026-02-01')).toBe('2026-03-20'); + expect(getLatestSelectableDate([], '2026-02-01')).toBe('2026-02-01'); + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-02T12:00:00')); + expect(getLatestSelectableDate()).toBe('2026-04-02'); + vi.useRealTimers(); + }); + + it('resolves min/max bounds from explicit limits or sorted available dates', () => { + const bounds = resolveCalendarDateBounds( + '2026-01-01', + '2026-05-10', + ['2026-02-01', '2026-04-20'], + '2025-10-05', + ); + + expect(formatCalendarDate(bounds.minAllowedDate)).toBe('2026-01-01'); + expect(formatCalendarDate(bounds.maxAllowedDate)).toBe('2026-05-10'); + expect(formatCalendarDate(bounds.earliestMonth)).toBe('2026-02-01'); + expect(formatCalendarDate(bounds.latestMonth)).toBe('2026-04-20'); + }); + + it('supports inclusive and exclusive min/max boundary checks', () => { + const min = parseCalendarDate('2026-01-10'); + const max = parseCalendarDate('2026-01-20'); + + expect(isCalendarDateOutOfRange(parseCalendarDate('2026-01-10'), min, max)).toBe(false); + expect(isCalendarDateOutOfRange(parseCalendarDate('2026-01-20'), min, max)).toBe(false); + expect(isCalendarDateOutOfRange(parseCalendarDate('2026-01-10'), min, max, true)).toBe(true); + expect(isCalendarDateOutOfRange(parseCalendarDate('2026-01-20'), min, max, true)).toBe(true); + expect(isCalendarDateOutOfRange(parseCalendarDate('2026-01-09'), min, max)).toBe(true); + expect(isCalendarDateOutOfRange(parseCalendarDate('2026-01-21'), min, max)).toBe(true); + }); + + it('prefers the selected month, then latest available month, then maxDate fallback', () => { + expect( + formatCalendarDate( + getInitialCalendarMonth('2026-03-01', ['2026-02-01', '2026-04-01'], new Date(2026, 6, 1)), + ), + ).toBe('2026-04-01'); + expect( + formatCalendarDate( + getInitialCalendarMonth('2026-03-01', undefined, parseCalendarDate('2026-05-01')), + ), + ).toBe('2026-03-01'); + expect( + formatCalendarDate( + getInitialCalendarMonth(undefined, ['2026-02-01', '2026-04-01'], new Date(2026, 6, 1)), + ), + ).toBe('2026-04-01'); + expect( + formatCalendarDate( + getInitialCalendarMonth(undefined, undefined, parseCalendarDate('2026-02-15')), + ), + ).toBe('2026-02-15'); + }); + + it('clamps month navigation using the second visible month for range pickers', () => { + expect( + getCalendarMonthNavState( + parseCalendarDate('2026-03-01'), + parseCalendarDate('2026-01-01'), + parseCalendarDate('2026-05-01'), + ), + ).toEqual({ canGoPrevious: true, canGoNext: true }); + + expect( + getCalendarMonthNavState( + parseCalendarDate('2026-01-01'), + parseCalendarDate('2026-01-01'), + parseCalendarDate('2026-02-01'), + parseCalendarDate('2026-02-01'), + ), + ).toEqual({ canGoPrevious: false, canGoNext: false }); + }); +}); + +describe('useCalendarMonth', () => { + let container: HTMLDivElement; + let root: Root; + let currentMonth: Date; + let setCurrentMonth: Dispatch>; + + interface TestComponentProps { + selectedDate?: string; + availableDates?: string[]; + maxAllowedDate: Date; + deps: ReadonlyArray; + } + + function TestComponent({ + selectedDate, + availableDates, + maxAllowedDate, + deps, + }: TestComponentProps) { + [currentMonth, setCurrentMonth] = useCalendarMonth( + selectedDate, + availableDates, + maxAllowedDate, + deps, + ); + return null; + } + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('resets when selection and availability change', () => { + act(() => { + root.render( + React.createElement(TestComponent, { + selectedDate: '2026-03-15', + availableDates: ['2026-02-10', '2026-03-15'], + maxAllowedDate: parseCalendarDate('2026-04-30'), + deps: ['2026-03-15'], + }), + ); + }); + expect(formatCalendarDate(currentMonth)).toBe('2026-03-15'); + + act(() => setCurrentMonth(parseCalendarDate('2026-02-01'))); + expect(formatCalendarDate(currentMonth)).toBe('2026-02-01'); + + act(() => { + root.render( + React.createElement(TestComponent, { + selectedDate: '2026-03-15', + availableDates: ['2026-04-05', '2026-04-20'], + maxAllowedDate: parseCalendarDate('2026-04-30'), + deps: ['2026-03-15'], + }), + ); + }); + expect(formatCalendarDate(currentMonth)).toBe('2026-04-20'); + + act(() => { + root.render( + React.createElement(TestComponent, { + selectedDate: '2026-04-05', + availableDates: ['2026-04-05', '2026-04-20'], + maxAllowedDate: parseCalendarDate('2026-04-30'), + deps: ['2026-04-05'], + }), + ); + }); + expect(formatCalendarDate(currentMonth)).toBe('2026-04-05'); + }); +}); + +describe('CalendarMonthPanel', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders one month and forwards nav/date callbacks', () => { + const onPreviousMonth = vi.fn(); + const onNextMonth = vi.fn(); + const onDateClick = vi.fn(); + + act(() => { + root.render( + React.createElement(CalendarMonthPanel, { + month: parseCalendarDate('2026-04-01'), + onPreviousMonth, + onNextMonth, + canGoPrevious: true, + canGoNext: true, + getDayState: (date) => ({ + selected: formatCalendarDate(date) === '2026-04-02', + disabled: formatCalendarDate(date) === '2026-04-03', + }), + onDateClick, + }), + ); + }); + + expect(container.textContent).toContain('April 2026'); + + const buttons = Array.from(container.querySelectorAll('button')); + act(() => buttons[0].click()); + act(() => buttons[1].click()); + act(() => buttons.find((button) => button.textContent === '2')?.click()); + act(() => buttons.find((button) => button.textContent === '3')?.click()); + + expect(onPreviousMonth).toHaveBeenCalledTimes(1); + expect(onNextMonth).toHaveBeenCalledTimes(1); + expect(onDateClick).toHaveBeenCalledTimes(1); + expect(formatCalendarDate(onDateClick.mock.calls[0][0])).toBe('2026-04-02'); + }); +}); diff --git a/packages/app/src/components/ui/calendar-picker-utils.tsx b/packages/app/src/components/ui/calendar-picker-utils.tsx new file mode 100644 index 0000000..f76e709 --- /dev/null +++ b/packages/app/src/components/ui/calendar-picker-utils.tsx @@ -0,0 +1,314 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +const DISPLAY_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', +}); + +export interface CalendarDateBounds { + minAllowedDate: Date; + maxAllowedDate: Date; + earliestMonth: Date; + latestMonth: Date; +} + +export interface CalendarDayState { + selected?: boolean; + disabled?: boolean; + hovered?: boolean; + inRange?: boolean; + outOfRange?: boolean; +} + +export interface CalendarMonthPanelProps { + month: Date; + onPreviousMonth?: () => void; + onNextMonth?: () => void; + canGoPrevious?: boolean; + canGoNext?: boolean; + isDisabled?: boolean; + getDayState: (date: Date) => CalendarDayState; + onDateClick: (date: Date) => void; + onDateHover?: (date: Date | null) => void; +} + +type CalendarMonthResetDep = string | number | boolean | null | undefined; + +export function parseCalendarDate(dateStr: string): Date { + if (dateStr.includes('-') && !dateStr.includes(',')) { + const [year, month, day] = dateStr.split('-'); + return new Date(Number(year), Number(month) - 1, Number(day)); + } + + const [datePart] = dateStr.split(', '); + const [month, day, year] = datePart.split('/'); + return new Date(Number(year), Number(month) - 1, Number(day)); +} + +export function formatCalendarDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export function formatDisplayDate(dateStr: string): string { + return DISPLAY_DATE_FORMATTER.format(parseCalendarDate(dateStr)); +} + +export function getLatestSelectableDate(availableDates?: string[], maxDate?: string): string { + if (availableDates && availableDates.length > 0) { + // Callers provide ascending dates; first/last are the min/max selectable entries. + return availableDates[availableDates.length - 1]; + } + + return maxDate || formatCalendarDate(new Date()); +} + +export function resolveCalendarDateBounds( + minDate: string | undefined, + maxDate: string | undefined, + availableDates: string[] | undefined, + fallbackMinDate: string, +): CalendarDateBounds { + const minAllowedDate = parseCalendarDate(minDate || fallbackMinDate); + const maxAllowedDate = maxDate ? parseCalendarDate(maxDate) : new Date(); + maxAllowedDate.setHours(23, 59, 59, 999); + + // Callers provide ascending dates; first/last bound the month navigation range. + const earliestMonth = + availableDates && availableDates.length > 0 + ? parseCalendarDate(availableDates[0]) + : minAllowedDate; + const latestMonth = + availableDates && availableDates.length > 0 + ? parseCalendarDate(availableDates[availableDates.length - 1]) + : maxAllowedDate; + + return { + minAllowedDate, + maxAllowedDate, + earliestMonth, + latestMonth, + }; +} + +export function isCalendarDateOutOfRange( + date: Date, + minAllowedDate: Date, + maxAllowedDate: Date, + // MultiDatePicker keeps legacy exclusive min/max boundaries; the other pickers use inclusive edges. + excludeBoundaryDates = false, +): boolean { + const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const minDateOnly = new Date( + minAllowedDate.getFullYear(), + minAllowedDate.getMonth(), + minAllowedDate.getDate(), + ); + const maxDateOnly = new Date( + maxAllowedDate.getFullYear(), + maxAllowedDate.getMonth(), + maxAllowedDate.getDate(), + ); + if (excludeBoundaryDates) { + return dateOnly <= minDateOnly || dateOnly >= maxDateOnly; + } + + return dateOnly < minDateOnly || dateOnly > maxDateOnly; +} + +export function getInitialCalendarMonth( + selectedDate: string | undefined, + availableDates: string[] | undefined, + maxAllowedDate: Date, +): Date { + const selectedCalendarDate = selectedDate + ? formatCalendarDate(parseCalendarDate(selectedDate)) + : undefined; + + if ( + selectedDate && + (availableDates === undefined || + (selectedCalendarDate && availableDates.includes(selectedCalendarDate))) + ) { + return parseCalendarDate(selectedDate); + } + + if (availableDates && availableDates.length > 0) { + return parseCalendarDate(availableDates[availableDates.length - 1]); + } + + const today = new Date(); + return maxAllowedDate >= today ? today : maxAllowedDate; +} + +/** + * `deps` are stringified into a reset key, so callers should pass stable primitive values only. + */ +export function useCalendarMonth( + selectedDate: string | undefined, + availableDates: string[] | undefined, + maxAllowedDate: Date, + deps: ReadonlyArray, +) { + const resetMonthKey = formatCalendarDate( + getInitialCalendarMonth(selectedDate, availableDates, maxAllowedDate), + ); + const availableDatesKey = availableDates?.join(',') ?? ''; + const maxAllowedDateKey = formatCalendarDate(maxAllowedDate); + const selectionResetKey = deps.map((dep) => String(dep ?? '')).join('\u001f'); + const [currentMonth, setCurrentMonth] = useState(() => parseCalendarDate(resetMonthKey)); + + useEffect(() => { + setCurrentMonth(parseCalendarDate(resetMonthKey)); + }, [availableDatesKey, maxAllowedDateKey, resetMonthKey, selectionResetKey]); + + return [currentMonth, setCurrentMonth] as const; +} + +export function getCalendarMonthNavState( + currentMonth: Date, + earliestMonth: Date, + latestMonth: Date, + // For two-panel range pickers, pass the right-hand visible month so next-nav clamps correctly. + nextButtonMonth = currentMonth, +) { + const canGoPrevious = + currentMonth.getFullYear() > earliestMonth.getFullYear() || + (currentMonth.getFullYear() === earliestMonth.getFullYear() && + currentMonth.getMonth() > earliestMonth.getMonth()); + const canGoNext = + nextButtonMonth.getFullYear() < latestMonth.getFullYear() || + (nextButtonMonth.getFullYear() === latestMonth.getFullYear() && + nextButtonMonth.getMonth() < latestMonth.getMonth()); + + return { canGoPrevious, canGoNext }; +} + +function getCalendarMonthDays(month: Date): Array { + const year = month.getFullYear(); + const monthIndex = month.getMonth(); + const firstDay = new Date(year, monthIndex, 1); + const lastDay = new Date(year, monthIndex + 1, 0); + const daysInMonth = lastDay.getDate(); + const startingDayOfWeek = firstDay.getDay(); + + const days: Array = []; + + for (let i = 0; i < startingDayOfWeek; i++) { + days.push(null); + } + + for (let day = 1; day <= daysInMonth; day++) { + days.push(new Date(year, monthIndex, day)); + } + + while (days.length < 42) { + days.push(null); + } + + return days; +} + +export function CalendarMonthPanel({ + month, + onPreviousMonth, + onNextMonth, + canGoPrevious = true, + canGoNext = true, + isDisabled = false, + getDayState, + onDateClick, + onDateHover, +}: CalendarMonthPanelProps) { + const monthName = month.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); + const days = getCalendarMonthDays(month); + + return ( +
+
+ {onPreviousMonth ? ( + + ) : ( +
+ )} +

{monthName}

+ {onNextMonth ? ( + + ) : ( +
+ )} +
+ +
+ {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((weekday) => ( +
+ {weekday} +
+ ))} + + {days.map((day, index) => { + if (!day) { + return
; + } + + const { selected, disabled, hovered, inRange, outOfRange } = getDayState(day); + const isToday = day.toDateString() === new Date().toDateString(); + + return ( + + ); + })} +
+
+ ); +} diff --git a/packages/app/src/components/ui/date-picker.tsx b/packages/app/src/components/ui/date-picker.tsx index 88761e5..1542a92 100644 --- a/packages/app/src/components/ui/date-picker.tsx +++ b/packages/app/src/components/ui/date-picker.tsx @@ -1,11 +1,22 @@ 'use client'; import { Calendar, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { track } from '@/lib/analytics'; import { Button } from '@/components/ui/button'; +import { + CalendarMonthPanel, + formatCalendarDate, + formatDisplayDate, + getCalendarMonthNavState, + getLatestSelectableDate, + isCalendarDateOutOfRange, + parseCalendarDate, + resolveCalendarDateBounds, + useCalendarMonth, +} from '@/components/ui/calendar-picker-utils'; import { Dialog, DialogClose, @@ -15,7 +26,6 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; -import { cn } from '@/lib/utils'; export interface DatePickerProps { date?: string; @@ -24,7 +34,7 @@ export interface DatePickerProps { maxDate?: string; className?: string; placeholder?: string; - availableDates?: string[]; // Add this + availableDates?: string[]; isCheckingAvailableDates?: boolean; } @@ -37,88 +47,26 @@ export function DatePicker({ minDate, maxDate, placeholder = 'Select date', - availableDates, // Add this + availableDates, isCheckingAvailableDates, }: DatePickerProps) { const [open, setOpen] = useState(false); - // Convert date prop to internal format for calendar (MM/DD/YYYY, HH:mm:ss) - const convertToInternalFormat = (dateStr: string | undefined): string | undefined => { - if (!dateStr) { - return undefined; - } - if (dateStr.includes('-') && !dateStr.includes(',')) { - const [year, month, day] = dateStr.split('-'); - const now = new Date(); - const hours = String(now.getHours()).padStart(2, '0'); - const minutes = String(now.getMinutes()).padStart(2, '0'); - const seconds = String(now.getSeconds()).padStart(2, '0'); - return `${month}/${day}/${year}, ${hours}:${minutes}:${seconds}`; - } - return dateStr; - }; - const [tempDate, setTempDate] = useState(() => convertToInternalFormat(date)); + const [tempDate, setTempDate] = useState(date); const [isApplying, _setIsApplying] = useState(false); const [error, setError] = useState(''); - // Helper to convert string (MM/dd/yyyy, HH:mm:ss) to Date - const parseDate = (dateStr: string): Date => { - // Parse "12/01/2025, 24:22:39" format - const [datePart] = dateStr.split(', '); - const [month, day, year] = datePart.split('/'); - return new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); - }; - - // Helper to convert Date to string (MM/dd/yyyy, HH:mm:ss) - const dateToString = (dateObj: Date): string => { - const year = dateObj.getFullYear(); - const month = String(dateObj.getMonth() + 1).padStart(2, '0'); - const day = String(dateObj.getDate()).padStart(2, '0'); - const hours = String(dateObj.getHours()).padStart(2, '0'); - const minutes = String(dateObj.getMinutes()).padStart(2, '0'); - const seconds = String(dateObj.getSeconds()).padStart(2, '0'); - return `${month}/${day}/${year}, ${hours}:${minutes}:${seconds}`; - }; - - // Helper to convert Date to internal string (YYYY-MM-DD) for comparison - const dateToInternalString = (dateObj: Date): string => { - const year = dateObj.getFullYear(); - const month = String(dateObj.getMonth() + 1).padStart(2, '0'); - const day = String(dateObj.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; - }; - - // Format date for display (handles both YYYY-MM-DD and MM/DD/YYYY, HH:mm:ss formats) - const formatDate = (dateStr: string) => { - let dateObj: Date; - if (dateStr.includes('-') && !dateStr.includes(',')) { - // YYYY-MM-DD format - const [year, month, day] = dateStr.split('-'); - dateObj = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); - } else { - // MM/DD/YYYY, HH:mm:ss format - dateObj = parseDate(dateStr); - } - return new Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }).format(dateObj); - }; - // Get display text for the input const getDisplayText = () => { if (!date) { return placeholder; } - return formatDate(date); + return formatDisplayDate(date); }; // Handle date selection in calendar const handleDateClick = (dateObj: Date) => { - const now = new Date(); - dateObj.setHours(now.getHours(), now.getMinutes(), now.getSeconds()); - const dateStr = dateToString(dateObj); - const isSelected = tempDate && parseDate(tempDate).toDateString() === dateObj.toDateString(); + const dateStr = formatCalendarDate(dateObj); + const isSelected = tempDate === dateStr; if (isSelected) { setTempDate(undefined); @@ -126,7 +74,7 @@ export function DatePicker({ setTempDate(dateStr); } track('date_picker_date_clicked', { - date: dateToInternalString(dateObj), + date: dateStr, selected: !isSelected, }); }; @@ -138,17 +86,13 @@ export function DatePicker({ return; } - // Convert to YYYY-MM-DD format for existence check - const dateObj = parseDate(tempDate); - const internalDateStr = dateToInternalString(dateObj); - - if (availableDates && !availableDates.includes(internalDateStr)) { - setError(`This date does not exist: ${formatDate(tempDate)}`); + if (availableDates && !availableDates.includes(tempDate)) { + setError(`This date does not exist: ${formatDisplayDate(tempDate)}`); return; } - track('date_picker_applied', { date: internalDateStr }); - onChange(internalDateStr); + track('date_picker_applied', { date: tempDate }); + onChange(tempDate); setOpen(false); }; @@ -158,15 +102,7 @@ export function DatePicker({ setOpen(false); }; - // Get the latest date from availableDates or maxDate - const getLatestDate = () => { - if (availableDates && availableDates.length > 0) { - // availableDates is already sorted, so take the last one - return availableDates[availableDates.length - 1]; - } - // Fallback to maxDate if provided, otherwise use current date - return maxDate || dateToInternalString(new Date()); - }; + const getLatestDate = () => getLatestSelectableDate(availableDates, maxDate); // Check if the selected date is already the latest const isLatestDateSelected = () => { @@ -174,10 +110,7 @@ export function DatePicker({ return false; } - const latestDate = getLatestDate(); - const selectedDateObj = parseDate(tempDate); - const selectedInternalDate = dateToInternalString(selectedDateObj); - return selectedInternalDate === latestDate; + return tempDate === getLatestDate(); }; // Check if the current date prop is already the latest (for external button) @@ -192,16 +125,7 @@ export function DatePicker({ // Go to latest date (for calendar dialog) const handleGoToLatest = () => { - const latestDate = getLatestDate(); - - // Parse the latest date (YYYY-MM-DD format) and convert to display format - const [year, month, day] = latestDate.split('-'); - const dateObj = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); - const now = new Date(); - dateObj.setHours(now.getHours(), now.getMinutes(), now.getSeconds()); - const dateStr = dateToString(dateObj); - - setTempDate(dateStr); + setTempDate(getLatestDate()); }; // Go to latest date directly (for external button) @@ -253,7 +177,7 @@ export function DatePicker({ const handleOpenChange = (isOpen: boolean) => { track(isOpen ? 'date_picker_opened' : 'date_picker_closed'); if (isOpen) { - setTempDate(convertToInternalFormat(date)); + setTempDate(date); } setOpen(isOpen); }; @@ -310,7 +234,7 @@ export function DatePicker({ onDateClick={handleDateClick} minDate={minDate} maxDate={maxDate} - availableDates={availableDates} // Add this + availableDates={availableDates} isCheckingAvailableDates={isCheckingAvailableDates} /> {isCheckingAvailableDates && ( @@ -363,7 +287,7 @@ interface CalendarGridProps { onDateClick: (date: Date) => void; minDate?: string; maxDate?: string; - availableDates?: string[]; // Add this + availableDates?: string[]; isCheckingAvailableDates?: boolean; } @@ -375,144 +299,46 @@ function CalendarGrid({ availableDates, isCheckingAvailableDates, }: CalendarGridProps) { - // Helper to parse date string (MM/dd/yyyy, HH:mm:ss) to Date - const parseDateStr = (dateStr: string): Date => { - const [datePart] = dateStr.split(', '); - const [month, day, year] = datePart.split('/'); - return new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); - }; - - // Helper to convert Date to string (YYYY-MM-DD) for comparison with availableDates - const dateToInternalString = (dateObj: Date): string => { - const year = dateObj.getFullYear(); - const month = String(dateObj.getMonth() + 1).padStart(2, '0'); - const day = String(dateObj.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; - }; - - // Parse minDate and maxDate props (YYYY-MM-DD format), with defaults - const minAllowedDate = minDate - ? new Date(minDate + ' 12:00:00') - : new Date('2025-10-05 12:00:00'); - - const maxAllowedDate = maxDate ? new Date(maxDate + ' 12:00:00') : new Date(); - - maxAllowedDate.setHours(23, 59, 59, 999); // End of day - - // Determine initial month to display - const getInitialMonth = () => { - // If there is a selected date, show the month of the selected date - if (selectedDate) { - return parseDateStr(selectedDate); - } - - // Default to the latest month with available data - if (availableDates && availableDates.length > 0) { - return parseDateStr(availableDates[availableDates.length - 1]); - } - const today = new Date(); - if (maxAllowedDate >= today) { - return today; - } - return maxAllowedDate; - }; - - const [currentMonth, setCurrentMonth] = useState(getInitialMonth()); - - // Reset to initial month when selectedDate changes (dialog reopens) - React.useEffect(() => { - setCurrentMonth(getInitialMonth()); - }, [selectedDate]); + const { minAllowedDate, maxAllowedDate, earliestMonth, latestMonth } = resolveCalendarDateBounds( + minDate, + maxDate, + availableDates, + '2025-10-05', + ); + const [currentMonth, setCurrentMonth] = useCalendarMonth( + selectedDate, + availableDates, + maxAllowedDate, + [selectedDate], + ); const isDateSelected = (date: Date) => { if (!selectedDate) { return false; } - const selectedDateObj = parseDateStr(selectedDate); + const selectedDateObj = parseCalendarDate(selectedDate); return selectedDateObj.toDateString() === date.toDateString(); }; - const isDateOutOfRange = (date: Date) => { - const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate()); - const minDateOnly = new Date( - minAllowedDate.getFullYear(), - minAllowedDate.getMonth(), - minAllowedDate.getDate(), - ); - const maxDateOnly = new Date( - maxAllowedDate.getFullYear(), - maxAllowedDate.getMonth(), - maxAllowedDate.getDate(), - ); - return dateOnly < minDateOnly || dateOnly > maxDateOnly; - }; - - const isDateDisabled = (date: Date) => { - if (isDateOutOfRange(date)) { - return true; - } - if (availableDates) { - const dateStr = dateToInternalString(date); - if (!availableDates.includes(dateStr)) { - return true; - } - } - return false; - }; - - // Generate calendar days - always returns 42 cells (6 rows) for consistent height - const getDaysInMonth = () => { - const year = currentMonth.getFullYear(); - const month = currentMonth.getMonth(); - const firstDay = new Date(year, month, 1); - const lastDay = new Date(year, month + 1, 0); - const daysInMonth = lastDay.getDate(); - const startingDayOfWeek = firstDay.getDay(); - - const days: (Date | null)[] = []; - - // Add empty cells for days before month starts - for (let i = 0; i < startingDayOfWeek; i++) { - days.push(null); - } - - // Add days of the month - for (let day = 1; day <= daysInMonth; day++) { - days.push(new Date(year, month, day)); - } - - // Pad to 42 cells (6 rows × 7 days) for consistent height - while (days.length < 42) { - days.push(null); - } + const getDayState = (date: Date) => { + const outOfRange = isCalendarDateOutOfRange(date, minAllowedDate, maxAllowedDate); + const dateStr = formatCalendarDate(date); - return days; + return { + selected: isDateSelected(date), + disabled: outOfRange || (availableDates !== undefined && !availableDates.includes(dateStr)), + outOfRange, + }; }; - const days = getDaysInMonth(); - const monthName = currentMonth.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); - - // Clamp navigation to months that contain available data - const earliestMonth = - availableDates && availableDates.length > 0 - ? new Date(availableDates[0] + 'T12:00:00') - : minAllowedDate; - const latestMonth = - availableDates && availableDates.length > 0 - ? new Date(availableDates[availableDates.length - 1] + 'T12:00:00') - : maxAllowedDate; - - const canGoPrev = - currentMonth.getFullYear() > earliestMonth.getFullYear() || - (currentMonth.getFullYear() === earliestMonth.getFullYear() && - currentMonth.getMonth() > earliestMonth.getMonth()); - const canGoNext = - currentMonth.getFullYear() < latestMonth.getFullYear() || - (currentMonth.getFullYear() === latestMonth.getFullYear() && - currentMonth.getMonth() < latestMonth.getMonth()); + const { canGoPrevious, canGoNext } = getCalendarMonthNavState( + currentMonth, + earliestMonth, + latestMonth, + ); const goToPreviousMonth = () => { - if (canGoPrev) { + if (canGoPrevious) { const newMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1); track('date_picker_month_navigated', { direction: 'previous', @@ -534,67 +360,15 @@ function CalendarGrid({ }; return ( -
-
- -

{monthName}

- -
-
- {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((day) => ( -
- {day} -
- ))} - {days.map((day, index) => { - if (!day) { - return
; - } - - const selected = isDateSelected(day); - const disabled = isDateDisabled(day); - const isToday = day.toDateString() === new Date().toDateString(); - const outOfRange = isDateOutOfRange(day); - - return ( - - ); - })} -
-
+ ); } diff --git a/packages/app/src/components/ui/date-range-picker.tsx b/packages/app/src/components/ui/date-range-picker.tsx index eef59b6..b70ee57 100644 --- a/packages/app/src/components/ui/date-range-picker.tsx +++ b/packages/app/src/components/ui/date-range-picker.tsx @@ -1,11 +1,20 @@ 'use client'; import { Calendar, Loader2 } from 'lucide-react'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { track } from '@/lib/analytics'; import { Button } from '@/components/ui/button'; +import { + CalendarMonthPanel, + formatCalendarDate, + formatDisplayDate, + getCalendarMonthNavState, + isCalendarDateOutOfRange, + resolveCalendarDateBounds, + useCalendarMonth, +} from '@/components/ui/calendar-picker-utils'; import { Dialog, DialogClose, @@ -30,7 +39,7 @@ export interface DateRangePickerProps { placeholder?: string; minDate?: string; maxDate?: string; - availableDates?: string[]; // Add this + availableDates?: string[]; isCheckingAvailableDates?: boolean; } @@ -45,7 +54,7 @@ export function DateRangePicker({ placeholder = 'Select date range', minDate, maxDate, - availableDates, // Add this + availableDates, isCheckingAvailableDates, }: DateRangePickerProps) { const [open, setOpen] = useState(false); @@ -54,28 +63,6 @@ export function DateRangePicker({ const [error, setError] = useState(''); const [isApplying, _setIsApplying] = useState(false); const [hoveredDate, setHoveredDate] = useState(null); - // Helper to convert string to Date - const parseDate = (dateStr: string): Date => { - return new Date(dateStr + 'T12:00:00'); - }; - - // Helper to convert Date to string (YYYY-MM-DD) - const dateToString = (date: Date): string => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; - }; - - // Format date for display - const formatDate = (dateStr: string) => { - const date = parseDate(dateStr); - return new Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }).format(date); - }; // Get display text for the input const getDisplayText = () => { @@ -83,17 +70,17 @@ export function DateRangePicker({ return placeholder; } if (dateRange.startDate && dateRange.endDate) { - return `${formatDate(dateRange.startDate)} - ${formatDate(dateRange.endDate)}`; + return `${formatDisplayDate(dateRange.startDate)} - ${formatDisplayDate(dateRange.endDate)}`; } if (dateRange.startDate) { - return `${formatDate(dateRange.startDate)} - ...`; + return `${formatDisplayDate(dateRange.startDate)} - ...`; } return placeholder; }; // Handle date selection in calendar const handleDateClick = (date: Date) => { - const dateStr = dateToString(date); + const dateStr = formatCalendarDate(date); track('date_range_picker_date_clicked', { date: dateStr }); if (tempRange.startDate && tempRange.endDate) { @@ -173,14 +160,15 @@ export function DateRangePicker({ Selected:{' '} - {formatDate(tempRange.startDate)} - {formatDate(tempRange.endDate)} + {formatDisplayDate(tempRange.startDate)} -{' '} + {formatDisplayDate(tempRange.endDate)} ) : tempRange.startDate ? ( Start date:{' '} - {formatDate(tempRange.startDate)} + {formatDisplayDate(tempRange.startDate)} {' '} - Choose an end date @@ -198,7 +186,7 @@ export function DateRangePicker({ maxDate={maxDate} hoveredDate={hoveredDate} onDateHover={setHoveredDate} - availableDates={availableDates} // Add this + availableDates={availableDates} isCheckingAvailableDates={isCheckingAvailableDates} /> {isCheckingAvailableDates && ( @@ -322,7 +310,7 @@ interface CalendarGridProps { maxDate?: string; hoveredDate: string | null; onDateHover: (date: string | null) => void; - availableDates?: string[]; // Add this + availableDates?: string[]; isCheckingAvailableDates?: boolean; } @@ -334,48 +322,21 @@ function CalendarGrid({ maxDate, hoveredDate, onDateHover, - availableDates, // Add this + availableDates, isCheckingAvailableDates, }: CalendarGridProps) { - // Parse minDate and maxDate props, with defaults - const minAllowedDate = minDate - ? new Date(minDate + ' 12:00:00') - : new Date('2025-10-05 12:00:00'); - - const maxAllowedDate = maxDate ? new Date(maxDate + ' 12:00:00') : new Date(); - - maxAllowedDate.setHours(23, 59, 59, 999); // End of day - - // Determine initial month to display - const getInitialMonth = () => { - if (dateRange.startDate) { - return new Date(dateRange.startDate + ' 12:00:00'); - } - // Default to the latest month with available data - if (availableDates && availableDates.length > 0) { - return new Date(availableDates[availableDates.length - 1] + 'T12:00:00'); - } - const today = new Date(); - if (maxAllowedDate >= today) { - return today; - } - return maxAllowedDate; - }; - - const [currentMonth, setCurrentMonth] = useState(getInitialMonth()); - - // Reset to initial month when dateRange changes (dialog reopens) - useEffect(() => { - setCurrentMonth(getInitialMonth()); - }, [dateRange.startDate, dateRange.endDate]); - - // Helper to convert Date to string (YYYY-MM-DD) - const dateToString = (date: Date): string => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; - }; + const { minAllowedDate, maxAllowedDate, earliestMonth, latestMonth } = resolveCalendarDateBounds( + minDate, + maxDate, + availableDates, + '2025-10-05', + ); + const [currentMonth, setCurrentMonth] = useCalendarMonth( + dateRange.startDate, + availableDates, + maxAllowedDate, + [dateRange.startDate, dateRange.endDate], + ); // Get the effective range for highlighting (includes hover) const getEffectiveRange = () => { @@ -403,7 +364,7 @@ function CalendarGrid({ if (!effectiveRange.start) { return false; } - const dateStr = dateToString(date); + const dateStr = formatCalendarDate(date); // Don't highlight if it's the start or end date if (dateStr === effectiveRange.start || dateStr === effectiveRange.end) { @@ -416,111 +377,30 @@ function CalendarGrid({ return false; }; - const isDateSelected = (date: Date) => { - const dateStr = dateToString(date); - return dateStr === dateRange.startDate || dateStr === dateRange.endDate; - }; - - const isDateHovered = (date: Date) => { - if (!hoveredDate) { - return false; - } - const dateStr = dateToString(date); - return dateStr === hoveredDate; - }; - - const isDateOutOfRange = (date: Date) => { - const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate()); - const minDateOnly = new Date( - minAllowedDate.getFullYear(), - minAllowedDate.getMonth(), - minAllowedDate.getDate(), - ); - const maxDateOnly = new Date( - maxAllowedDate.getFullYear(), - maxAllowedDate.getMonth(), - maxAllowedDate.getDate(), - ); - return dateOnly < minDateOnly || dateOnly > maxDateOnly; - }; - - const isDateDisabled = (date: Date) => { - if (isDateOutOfRange(date)) { - return true; - } - if (availableDates) { - const dateStr = dateToString(date); - if (!availableDates.includes(dateStr)) { - return true; - } - } - return false; - }; - - // Generate calendar days for a given month - always returns 42 cells (6 rows) for consistent height - const getDaysInMonth = (month: Date) => { - const year = month.getFullYear(); - const monthIndex = month.getMonth(); - const firstDay = new Date(year, monthIndex, 1); - const lastDay = new Date(year, monthIndex + 1, 0); - const daysInMonth = lastDay.getDate(); - const startingDayOfWeek = firstDay.getDay(); - - const days: (Date | null)[] = []; - - // Add empty cells for days before month starts - for (let i = 0; i < startingDayOfWeek; i++) { - days.push(null); - } - - // Add days of the month - for (let day = 1; day <= daysInMonth; day++) { - days.push(new Date(year, monthIndex, day)); - } - - // Pad to 42 cells (6 rows × 7 days) for consistent height - while (days.length < 42) { - days.push(null); - } - - return days; + const getDayState = (date: Date) => { + const dateStr = formatCalendarDate(date); + const outOfRange = isCalendarDateOutOfRange(date, minAllowedDate, maxAllowedDate); + + return { + selected: dateStr === dateRange.startDate || dateStr === dateRange.endDate, + disabled: outOfRange || (availableDates !== undefined && !availableDates.includes(dateStr)), + hovered: dateStr === hoveredDate, + inRange: isDateInRange(date), + outOfRange, + }; }; // Get second month (next month) const secondMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1); - - const firstMonthDays = getDaysInMonth(currentMonth); - const secondMonthDays = getDaysInMonth(secondMonth); - const firstMonthName = currentMonth.toLocaleDateString('en-US', { - month: 'long', - year: 'numeric', - }); - const secondMonthName = secondMonth.toLocaleDateString('en-US', { - month: 'long', - year: 'numeric', - }); - - // Clamp navigation to months that contain available data - const earliestMonth = - availableDates && availableDates.length > 0 - ? new Date(availableDates[0] + 'T12:00:00') - : minAllowedDate; - const latestMonth = - availableDates && availableDates.length > 0 - ? new Date(availableDates[availableDates.length - 1] + 'T12:00:00') - : maxAllowedDate; - - const canGoPrev = - currentMonth.getFullYear() > earliestMonth.getFullYear() || - (currentMonth.getFullYear() === earliestMonth.getFullYear() && - currentMonth.getMonth() > earliestMonth.getMonth()); - const canGoNext = - secondMonth.getFullYear() < latestMonth.getFullYear() || - (secondMonth.getFullYear() === latestMonth.getFullYear() && - secondMonth.getMonth() < latestMonth.getMonth()); + const { canGoPrevious, canGoNext } = getCalendarMonthNavState( + currentMonth, + earliestMonth, + latestMonth, + secondMonth, + ); const goToPreviousMonth = () => { - if (canGoPrev) { + if (canGoPrevious) { const newMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1); track('date_range_picker_month_navigated', { direction: 'previous', @@ -549,105 +429,33 @@ function CalendarGrid({ // Only show hover effect when start date is selected and we're selecting end date if (!selectingStart && dateRange.startDate) { - onDateHover(dateToString(date)); + onDateHover(formatCalendarDate(date)); } else { onDateHover(null); } }; - const renderCalendarMonth = ( - days: (Date | null)[], - monthName: string, - showPrevButton: boolean, - showNextButton: boolean, - ) => ( -
-
- {showPrevButton ? ( - - ) : ( -
- )} -

{monthName}

- {showNextButton ? ( - - ) : ( -
- )} -
-
- {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((day) => ( -
- {day} -
- ))} - {days.map((day, index) => { - if (!day) { - return
; - } - - const selected = isDateSelected(day); - const inRange = isDateInRange(day); - const hovered = isDateHovered(day); - const disabled = isDateDisabled(day); - const isToday = day.toDateString() === new Date().toDateString(); - const outOfRange = isDateOutOfRange(day); - - return ( - - ); - })} -
-
- ); - return (
!isCheckingAvailableDates && onDateHover(null)}>
- {renderCalendarMonth(firstMonthDays, firstMonthName, true, false)} - {renderCalendarMonth(secondMonthDays, secondMonthName, false, true)} + +
); diff --git a/packages/app/src/components/ui/multi-date-picker.tsx b/packages/app/src/components/ui/multi-date-picker.tsx index ee6137f..3819bb7 100644 --- a/packages/app/src/components/ui/multi-date-picker.tsx +++ b/packages/app/src/components/ui/multi-date-picker.tsx @@ -1,11 +1,20 @@ 'use client'; import { Calendar, X } from 'lucide-react'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { track } from '@/lib/analytics'; import { Button } from '@/components/ui/button'; +import { + CalendarMonthPanel, + formatCalendarDate, + formatDisplayDate, + getCalendarMonthNavState, + isCalendarDateOutOfRange, + resolveCalendarDateBounds, + useCalendarMonth, +} from '@/components/ui/calendar-picker-utils'; import { Dialog, DialogClose, @@ -25,7 +34,7 @@ export interface MultiDatePickerProps { maxDate?: string; className?: string; placeholder?: string; - availableDates?: string[]; // Add this + availableDates?: string[]; } /** @@ -40,34 +49,12 @@ export function MultiDatePicker({ maxDate, className, placeholder = 'Select dates', - availableDates, // Add this + availableDates, }: MultiDatePickerProps) { const [open, setOpen] = useState(false); const [tempDates, setTempDates] = useState(dates); const [isApplying, _setIsApplying] = useState(false); const [error, setError] = useState(''); - // Helper to convert string to Date - const parseDate = (dateStr: string): Date => { - return new Date(dateStr + 'T12:00:00'); - }; - - // Helper to convert Date to string (YYYY-MM-DD) - const dateToString = (date: Date): string => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; - }; - - // Format date for display - const formatDate = (dateStr: string) => { - const date = parseDate(dateStr); - return new Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }).format(date); - }; // Get display text for the input const getDisplayText = () => { @@ -75,17 +62,17 @@ export function MultiDatePicker({ return placeholder; } if (dates.length === 1) { - return formatDate(dates[0]); + return formatDisplayDate(dates[0]); } if (dates.length === 2) { - return `${formatDate(dates[0])} vs ${formatDate(dates[1])}`; + return `${formatDisplayDate(dates[0])} vs ${formatDisplayDate(dates[1])}`; } return `${dates.length} dates selected`; }; // Handle date selection in calendar const handleDateClick = (date: Date) => { - const dateStr = dateToString(date); + const dateStr = formatCalendarDate(date); const isSelected = tempDates.includes(dateStr); if (isSelected) { @@ -168,7 +155,7 @@ export function MultiDatePicker({ maxDates={maxDates} minDate={minDate} maxDate={maxDate} - availableDates={availableDates} // Add this + availableDates={availableDates} /> {tempDates.length > 0 && maxDates > 1 && (
@@ -192,14 +179,14 @@ export function MultiDatePicker({ key={index} className="px-2 py-1 bg-primary text-primary-foreground rounded-md text-xs flex items-center gap-1 group" > - {formatDate(dateStr)} + {formatDisplayDate(dateStr)} @@ -232,7 +219,7 @@ interface CalendarGridProps { maxDates: number; minDate?: string; maxDate?: string; - availableDates?: string[]; // Add this + availableDates?: string[]; } function CalendarGrid({ @@ -241,136 +228,48 @@ function CalendarGrid({ maxDates, minDate, maxDate, - availableDates, // Add this + availableDates, }: CalendarGridProps) { - // Parse minDate and maxDate props, with defaults - const minAllowedDate = minDate - ? new Date(minDate + ' 12:00:00') - : new Date('2025-10-10 12:00:00'); - - const maxAllowedDate = maxDate ? new Date(maxDate + ' 12:00:00') : new Date(); - - maxAllowedDate.setHours(23, 59, 59, 999); // End of day - - // Determine initial month to display - const getInitialMonth = () => { - // If there are selected dates, show the month of the first selected date - if (selectedDates.length > 0) { - return new Date(selectedDates[0] + ' 12:00:00'); - } - - // Default to the latest month with available data - if (availableDates && availableDates.length > 0) { - return new Date(availableDates[availableDates.length - 1] + 'T12:00:00'); - } - const today = new Date(); - if (maxAllowedDate >= today) { - return today; - } - return maxAllowedDate; - }; - - const [currentMonth, setCurrentMonth] = useState(getInitialMonth()); - - // Reset to initial month when selectedDates change (dialog reopens) - React.useEffect(() => { - setCurrentMonth(getInitialMonth()); - }, [selectedDates.join(',')]); // Use join to create a stable dependency - - // Helper to convert Date to string (YYYY-MM-DD) - const dateToString = (date: Date): string => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; - }; + const { minAllowedDate, maxAllowedDate, earliestMonth, latestMonth } = resolveCalendarDateBounds( + minDate, + maxDate, + availableDates, + '2025-10-10', + ); + const [currentMonth, setCurrentMonth] = useCalendarMonth( + selectedDates[0], + availableDates, + maxAllowedDate, + [selectedDates.join(',')], + ); const isDateSelected = (date: Date) => { - return selectedDates.includes(dateToString(date)); + return selectedDates.includes(formatCalendarDate(date)); }; - const isDateOutOfRange = (date: Date) => { - const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate()); - const minDateOnly = new Date( - minAllowedDate.getFullYear(), - minAllowedDate.getMonth(), - minAllowedDate.getDate(), - ); - const maxDateOnly = new Date( - maxAllowedDate.getFullYear(), - maxAllowedDate.getMonth(), - maxAllowedDate.getDate(), - ); - return dateOnly <= minDateOnly || dateOnly >= maxDateOnly; + const getDayState = (date: Date) => { + const outOfRange = isCalendarDateOutOfRange(date, minAllowedDate, maxAllowedDate, true); + const selected = isDateSelected(date); + const dateStr = formatCalendarDate(date); + + return { + selected, + disabled: + outOfRange || + (availableDates !== undefined && !availableDates.includes(dateStr)) || + (selectedDates.length >= maxDates && !selected), + outOfRange, + }; }; - const isDateDisabled = (date: Date) => { - const outOfRange = isDateOutOfRange(date); - if (outOfRange) { - return true; - } - if (availableDates) { - const dateStr = dateToString(date); - if (!availableDates.includes(dateStr)) { - return true; - } - } - return selectedDates.length >= maxDates && !isDateSelected(date); - }; - - // Generate calendar days - always returns 42 cells (6 rows) for consistent height - const getDaysInMonth = () => { - const year = currentMonth.getFullYear(); - const month = currentMonth.getMonth(); - const firstDay = new Date(year, month, 1); - const lastDay = new Date(year, month + 1, 0); - const daysInMonth = lastDay.getDate(); - const startingDayOfWeek = firstDay.getDay(); - - const days: (Date | null)[] = []; - - // Add empty cells for days before month starts - for (let i = 0; i < startingDayOfWeek; i++) { - days.push(null); - } - - // Add days of the month - for (let day = 1; day <= daysInMonth; day++) { - days.push(new Date(year, month, day)); - } - - // Pad to 42 cells (6 rows × 7 days) for consistent height - while (days.length < 42) { - days.push(null); - } - - return days; - }; - - const days = getDaysInMonth(); - const monthName = currentMonth.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); - - // Clamp navigation to months that contain available data - const earliestMonth = - availableDates && availableDates.length > 0 - ? new Date(availableDates[0] + 'T12:00:00') - : minAllowedDate; - const latestMonth = - availableDates && availableDates.length > 0 - ? new Date(availableDates[availableDates.length - 1] + 'T12:00:00') - : maxAllowedDate; - - const canGoPrev = - currentMonth.getFullYear() > earliestMonth.getFullYear() || - (currentMonth.getFullYear() === earliestMonth.getFullYear() && - currentMonth.getMonth() > earliestMonth.getMonth()); - const canGoNext = - currentMonth.getFullYear() < latestMonth.getFullYear() || - (currentMonth.getFullYear() === latestMonth.getFullYear() && - currentMonth.getMonth() < latestMonth.getMonth()); + const { canGoPrevious, canGoNext } = getCalendarMonthNavState( + currentMonth, + earliestMonth, + latestMonth, + ); const goToPreviousMonth = () => { - if (canGoPrev) { + if (canGoPrevious) { const newMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1); track('multi_date_picker_month_navigated', { direction: 'previous', @@ -392,67 +291,14 @@ function CalendarGrid({ }; return ( -
-
- -

{monthName}

- -
-
- {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((day) => ( -
- {day} -
- ))} - {days.map((day, index) => { - if (!day) { - return
; - } - - const selected = isDateSelected(day); - const disabled = isDateDisabled(day); - const isToday = day.toDateString() === new Date().toDateString(); - const outOfRange = isDateOutOfRange(day); - - return ( - - ); - })} -
-
+ ); }