import { useCallback, useMemo, useState } from "react"; import type { CalendarProps } from "@/components/Calendar"; import { fromDateId, toDateId, startOfMonth, addMonths, subMonths, differenceInMonths, getWeeksInMonth, } from "@/helpers/dates"; import type { UseCalendarParams } from "@/hooks/useCalendar"; import { pipe } from "@/helpers/functions"; export interface CalendarMonth { id: string; date: Date; numberOfWeeks: number; } const buildMonthList = ( startingMonth: Date, endingMonth: Date, firstDayOfWeek: CalendarProps["calendarFirstDayOfWeek"] = "sunday" ): CalendarMonth[] => { const startingMonthId = toDateId(startingMonth); const endingMonthId = toDateId(endingMonth); if (endingMonthId < startingMonthId) { return []; } const months = [ { id: toDateId(startingMonth), date: startingMonth, numberOfWeeks: getWeeksInMonth(startingMonth, firstDayOfWeek), }, ]; if (startingMonthId === endingMonthId) { return months; } const numberOfMonths = differenceInMonths(endingMonth, startingMonth); for (let i = 1; i <= numberOfMonths; i++) { const month = addMonths(startingMonth, i); const numberOfWeeks = getWeeksInMonth(month, firstDayOfWeek); months.push({ id: toDateId(month), date: month, numberOfWeeks, }); } return months; }; export interface UseCalendarListParams extends Pick { /** * The initial month to open the calendar to, as a `YYYY-MM-DD` string. * @defaultValue today */ calendarInitialMonthId?: string; /** * How many months to show before the current month. Only applicable if * `calendarMinDateId` is not set. */ calendarPastScrollRangeInMonths: number; /** * How many months to show after the current month. Applicable if * `calendarMaxDateId` is not set. */ calendarFutureScrollRangeInMonths: number; calendarFirstDayOfWeek: "monday" | "sunday"; } const getEndingMonth = ( calendarFutureScrollRange: number, calendarMaxDateId: string | undefined, baseDate: Date ) => { const endingMonthFromRange = addMonths(baseDate, calendarFutureScrollRange); const newEndingMonthId = toDateId(endingMonthFromRange); const safeMaxDateId = calendarMaxDateId ?? newEndingMonthId; // We've exceeded the max date return startOfMonth( newEndingMonthId > safeMaxDateId ? fromDateId(safeMaxDateId) : endingMonthFromRange ); }; const getStartingMonth = ( calendarPastScrollRange: number, calendarMinDateId: string | undefined, baseDate: Date ) => { const startingMonthFromRange = subMonths(baseDate, calendarPastScrollRange); const newStartingMonthId = toDateId(startingMonthFromRange); const safeMinDateId = calendarMinDateId ?? newStartingMonthId; // We've exceeded the min date. return safeMinDateId > newStartingMonthId ? // Normalize to start of month since each month ID is represented by the first day of month pipe(fromDateId(safeMinDateId), startOfMonth) : startingMonthFromRange; }; /** * Returns a list of months to display in the calendar, and methods to append * and prepend months to the list. */ export const useCalendarList = ({ calendarInitialMonthId, calendarPastScrollRangeInMonths, calendarFutureScrollRangeInMonths, calendarFirstDayOfWeek, calendarMaxDateId, calendarMinDateId, }: UseCalendarListParams) => { // Initialize key values const { initialMonth, initialMonthId } = useMemo(() => { const baseDate = calendarInitialMonthId ? fromDateId(calendarInitialMonthId) : fromDateId(toDateId(new Date())); // Normalize to start of month since each month ID is represented by the first day of month const baseStartOfMonth = startOfMonth(baseDate); return { initialMonth: baseStartOfMonth, initialMonthId: toDateId(baseStartOfMonth), }; }, [calendarInitialMonthId]); const [monthList, setMonthList] = useState(() => { const currentMonth = startOfMonth(initialMonth); const startingMonth = getStartingMonth( calendarPastScrollRangeInMonths, calendarMinDateId, currentMonth ); const endingMonth = getEndingMonth( calendarFutureScrollRangeInMonths, calendarMaxDateId, currentMonth ); return buildMonthList(startingMonth, endingMonth, calendarFirstDayOfWeek); }); /** * Append new months to the list. */ const appendMonths = useCallback( (numberOfMonths: number) => { // Last month + 1 const startingMonth = addMonths(monthList[monthList.length - 1].date, 1); const endingMonth = getEndingMonth( Math.max(numberOfMonths - 1, 0), calendarMaxDateId, startingMonth ); const hasReachedEndingMonth = monthList.find( (m) => m.id === toDateId(endingMonth) ); if (hasReachedEndingMonth) { return monthList; } const newMonths = buildMonthList( startingMonth, endingMonth, calendarFirstDayOfWeek ); const newMonthList = [...monthList, ...newMonths]; setMonthList(newMonthList); return newMonthList; }, [calendarFirstDayOfWeek, calendarMaxDateId, monthList] ); const prependMonths = useCallback( (numberOfMonths: number) => { const endingMonth = subMonths(monthList[0].date, 1); const startingMonth = getStartingMonth( Math.max(numberOfMonths - 1, 0), calendarMinDateId, endingMonth ); const newMonths = buildMonthList( startingMonth, endingMonth, calendarFirstDayOfWeek ); const newMonthList = [...newMonths, ...monthList]; setMonthList(newMonthList); return newMonthList; }, [calendarFirstDayOfWeek, calendarMinDateId, monthList] ); const addMissingMonths = useCallback( (targetMonthId: string) => { const firstMonth = monthList[0]; const lastMonth = monthList[monthList.length - 1]; if (targetMonthId > lastMonth.id) { return appendMonths( differenceInMonths(fromDateId(targetMonthId), lastMonth.date) ); } else { return prependMonths( differenceInMonths(firstMonth.date, fromDateId(targetMonthId)) ); } }, [appendMonths, monthList, prependMonths] ); const initialMonthIndex = useMemo(() => { const index = monthList.findIndex((i) => i.id === initialMonthId); return index; }, [initialMonthId, monthList]); return { /** * The list of months to display in the calendar. */ monthList, /** * The index of the initial month in the list. */ initialMonthIndex, /** * Appends new months to the list. */ appendMonths, /** * Prepends new months to the list. */ prependMonths, /** * Adds missing months to the list, so that the target month is included. */ addMissingMonths, }; }; /** * Returns the absolute height for a month, accounting for the spacings and * headers. */ export const getHeightForMonth = ({ calendarRowVerticalSpacing: vSpacing, calendarDayHeight: day, calendarWeekHeaderHeight: weekName, calendarMonthHeaderHeight: header, calendarAdditionalHeight: extraHeight, calendarMonth, calendarSpacing, }: { calendarAdditionalHeight: number; calendarDayHeight: number; calendarMonthHeaderHeight: number; calendarRowVerticalSpacing: number; calendarWeekHeaderHeight: number; calendarMonth: CalendarMonth; calendarSpacing: number; }) => { const headerHeight = header + vSpacing + weekName + vSpacing; const daysHeight = day * calendarMonth.numberOfWeeks + // The last week doesn't have a bottom spacing (not referring to `calendarSpacing`) (calendarMonth.numberOfWeeks - 1) * vSpacing; return headerHeight + daysHeight + extraHeight + calendarSpacing; };