diff --git a/packages/react-components/react-calendar-preview/library/src/utils/constants.ts b/packages/react-components/react-calendar-preview/library/src/utils/constants.ts new file mode 100644 index 0000000000000..e197ad0cfc7ca --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/constants.ts @@ -0,0 +1,75 @@ +/** + * The days of the week. + */ +export type DayOfWeek = 'sunday' | 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday'; + +/** + * The days of the week, ordered so that each index matches `Date.prototype.getDay()`. + */ +export const daysOfWeek = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +] as const satisfies readonly DayOfWeek[]; + +/** + * The months of the year. + */ +export type MonthOfYear = + | 'january' + | 'february' + | 'march' + | 'april' + | 'may' + | 'june' + | 'july' + | 'august' + | 'september' + | 'october' + | 'november' + | 'december'; + +/** + * The months of the year, ordered so that each index matches `Date.prototype.getMonth()`. + */ +export const monthsOfYear = [ + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december', +] as const satisfies readonly MonthOfYear[]; + +/** + * Determines which week counts as the first week of the year. + * - `firstDay` - the week containing January 1st. + * - `firstFullWeek` - the first week entirely within the new year. + * - `firstFourDayWeek` - the first week with at least four days in the new year. + */ +export type FirstWeekOfYear = 'firstDay' | 'firstFullWeek' | 'firstFourDayWeek'; + +/** + * The supported date range types, describing how many days are selected when the user picks a date. + */ +export type DateRangeType = 'day' | 'week' | 'month' | 'workWeek'; + +/** + * The axis along which the day grid transitions when navigating between months. + */ +export type AnimationDirection = 'horizontal' | 'vertical'; + +/** + * Number of days in a week. + */ +export const DAYS_IN_WEEK = 7; diff --git a/packages/react-components/react-calendar-preview/library/src/utils/dataAttributes.test.ts b/packages/react-components/react-calendar-preview/library/src/utils/dataAttributes.test.ts new file mode 100644 index 0000000000000..e0473242473c9 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/dataAttributes.test.ts @@ -0,0 +1,11 @@ +import { stringifyDataAttribute } from './dataAttributes'; + +describe('stringifyDataAttribute', () => { + it.each([ + [true, ''], + [false, undefined], + [undefined, undefined], + ] as const)('serializes %s as %s', (value, expected) => { + expect(stringifyDataAttribute(value)).toBe(expected); + }); +}); diff --git a/packages/react-components/react-calendar-preview/library/src/utils/dataAttributes.ts b/packages/react-components/react-calendar-preview/library/src/utils/dataAttributes.ts new file mode 100644 index 0000000000000..459e665d4d730 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/dataAttributes.ts @@ -0,0 +1,7 @@ +/** + * Renders a boolean as a presence data attribute: `''` when true, `undefined` when false so the + * attribute is omitted entirely. + */ +export function stringifyDataAttribute(value: boolean | undefined): '' | undefined { + return value ? '' : undefined; +} diff --git a/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/dateAvailability.test.ts b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/dateAvailability.test.ts new file mode 100644 index 0000000000000..1958c39376410 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/dateAvailability.test.ts @@ -0,0 +1,144 @@ +import { findAvailableDate, getBoundedDateRange, isRestrictedDate } from './dateAvailability'; +import * as dateMath from '../dateMath'; +import type { AvailableDateOptions } from './dateGrid.types'; + +jest.mock('../dateMath', () => ({ + __esModule: true, + ...jest.requireActual('../dateMath'), +})); + +describe('isRestrictedDate', () => { + const date = new Date(2020, 8, 18); + + it('allows unrestricted dates and empty restrictions', () => { + expect(isRestrictedDate(date, {})).toBe(false); + expect(isRestrictedDate(date, { restrictedDates: [] })).toBe(false); + }); + + it('uses inclusive min/max bounds ignoring time of day', () => { + const options = { minDate: new Date(2020, 8, 18, 12), maxDate: new Date(2020, 8, 20) }; + expect(isRestrictedDate(date, options)).toBe(false); + expect(isRestrictedDate(new Date(2020, 8, 20, 23), options)).toBe(false); + expect(isRestrictedDate(new Date(2020, 8, 17), options)).toBe(true); + expect(isRestrictedDate(new Date(2020, 8, 21), options)).toBe(true); + }); + + it('matches restricted dates by year, month and day, not time', () => { + expect(isRestrictedDate(date, { restrictedDates: [new Date(2020, 8, 18, 23)] })).toBe(true); + expect(isRestrictedDate(date, { restrictedDates: [new Date(2020, 7, 18), new Date(2019, 8, 18)] })).toBe(false); + }); +}); + +describe('findAvailableDate', () => { + afterEach(() => jest.restoreAllMocks()); + + const targetDate = new Date(2020, 8, 18); + const options: AvailableDateOptions = { targetDate, initialDate: new Date(2020, 8, 15), direction: 1 }; + + it('returns the available target unchanged, including the initial date', () => { + expect(findAvailableDate(options)).toBe(targetDate); + expect(findAvailableDate({ ...options, initialDate: targetDate })).toBe(targetDate); + }); + + it.each([1, -1] as const)('skips consecutive restricted dates in direction %s', direction => { + expect( + findAvailableDate({ + ...options, + direction, + restrictedDates: [targetDate, new Date(2020, 8, 18 + direction)], + }), + ).toEqual(new Date(2020, 8, 18 + 2 * direction)); + expect(targetDate).toEqual(new Date(2020, 8, 18)); + }); + + it.each([1, -1] as const)('stops when reaching the initial date in direction %s', direction => { + expect( + findAvailableDate({ + ...options, + direction, + initialDate: new Date(2020, 8, 18 + direction), + restrictedDates: [targetDate], + }), + ).toBeUndefined(); + }); + + it('does not search beyond the min/max date', () => { + expect(findAvailableDate({ ...options, restrictedDates: [targetDate], maxDate: targetDate })).toBeUndefined(); + expect( + findAvailableDate({ ...options, direction: -1, restrictedDates: [targetDate], minDate: targetDate }), + ).toBeUndefined(); + expect(findAvailableDate({ ...options, minDate: new Date(2020, 8, 19) })).toBeUndefined(); + expect(findAvailableDate({ ...options, maxDate: new Date(2020, 8, 17) })).toBeUndefined(); + }); + + it('returns undefined when the initial date is itself restricted', () => { + expect(findAvailableDate({ ...options, initialDate: targetDate, restrictedDates: [targetDate] })).toBeUndefined(); + }); + + it.each([0, 2, -2, 0.5, NaN, Infinity, -Infinity])('rejects direction %s even for an available target', direction => { + expect(() => + // @ts-expect-error Verify the runtime contract for JavaScript callers. + findAvailableDate({ ...options, direction }), + ).toThrow(new RangeError('direction must be 1 or -1.')); + }); + + it.each(['targetDate', 'initialDate'] as const)('rejects an invalid %s', field => { + expect(() => findAvailableDate({ ...options, [field]: new Date(NaN) })).toThrow( + new RangeError('targetDate and initialDate must be valid.'), + ); + }); + + it('rejects a search that exceeds the Date range', () => { + const lastDate = new Date(8640000000000000); + expect(() => findAvailableDate({ ...options, targetDate: lastDate, restrictedDates: [lastDate] })).toThrow( + new RangeError('Cannot search for an out-of-range date.'), + ); + }); + + it('advances independently when a skipped day normalizes to the previous candidate', () => { + const addDays = dateMath.addDays; + const offset = jest.spyOn(dateMath, 'addDays').mockImplementation((date, days) => { + return days === -1 ? new Date(date) : addDays(date, days); + }); + expect(findAvailableDate({ ...options, direction: -1, restrictedDates: [targetDate] })).toEqual( + new Date(2020, 8, 16), + ); + expect(offset.mock.calls.map(([, days]) => days)).toEqual([-1, -2]); + }); + + it('searches backward across December 30, 2011 using the runtime timezone', () => { + const date = new Date(2011, 11, 31); + const skippedFriday = new Date(2011, 11, 30).getDate() !== 30; + expect( + findAvailableDate({ + targetDate: date, + initialDate: new Date(2012, 0, 1), + direction: -1, + restrictedDates: [date], + }), + ).toEqual(new Date(2011, 11, skippedFriday ? 29 : 30)); + }); +}); + +describe('getBoundedDateRange', () => { + const dates = [new Date(2020, 8, 17), new Date(2020, 8, 18), new Date(2020, 8, 19)]; + + it('returns a copy when there are no bounds', () => { + const result = getBoundedDateRange(dates); + expect(result).toEqual(dates); + expect(result).not.toBe(dates); + }); + + it('filters inclusive min/max bounds independently and together without mutating the input', () => { + expect(getBoundedDateRange(dates, dates[1])).toEqual(dates.slice(1)); + expect(getBoundedDateRange(dates, undefined, dates[1])).toEqual(dates.slice(0, 2)); + expect(getBoundedDateRange(dates, new Date(2020, 8, 18, 12), dates[1])).toEqual([dates[1]]); + expect(dates).toHaveLength(3); + }); + + it('returns no dates for an empty range or disjoint bounds', () => { + expect(getBoundedDateRange([], dates[0], dates[2])).toEqual([]); + expect(getBoundedDateRange(dates, new Date(2020, 8, 20))).toEqual([]); + expect(getBoundedDateRange(dates, dates[2], dates[0])).toEqual([]); + }); +}); diff --git a/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/dateAvailability.ts b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/dateAvailability.ts new file mode 100644 index 0000000000000..9d3edfd639788 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/dateAvailability.ts @@ -0,0 +1,91 @@ +import { addDays, compareDatePart } from '../dateMath'; +import type { AvailableDateOptions, RestrictedDatesOptions } from './dateGrid.types'; + +/** + * Checks if a date is after the maximum allowed date based on the given restrictions. + */ +const isAfterMaxDate = (date: Date, options: RestrictedDatesOptions): boolean => { + const { maxDate } = options; + return maxDate ? compareDatePart(date, maxDate) >= 1 : false; +}; + +/** + * Checks if a date is before the minimum allowed date based on the given restrictions. + */ +const isBeforeMinDate = (date: Date, options: RestrictedDatesOptions): boolean => { + const { minDate } = options; + return minDate ? compareDatePart(minDate, date) >= 1 : false; +}; + +/** + * Checks if `date` falls into the restricted `options` + * @param date - date to check + * @param options - restriction options (min date, max date and list of restricted dates) + */ +export const isRestrictedDate = (date: Date, options: RestrictedDatesOptions): boolean => { + const { restrictedDates, minDate, maxDate } = options; + if (!restrictedDates && !minDate && !maxDate) { + return false; + } + const inRestrictedDates = restrictedDates && restrictedDates.some((rd: Date) => compareDatePart(rd, date) === 0); + return inRestrictedDates || isBeforeMinDate(date, options) || isAfterMaxDate(date, options); +}; + +/** + * Returns closest available date given the restriction `options`, or undefined otherwise + * @param options - list of search options + * @throws RangeError if the direction is not 1 or -1, or search dates cannot be represented. + */ +export const findAvailableDate = (options: AvailableDateOptions): Date | undefined => { + const { targetDate, initialDate, direction, ...restrictionOptions } = options; + if (direction !== 1 && direction !== -1) { + throw new RangeError('direction must be 1 or -1.'); + } + if (!Number.isFinite(targetDate.getTime()) || !Number.isFinite(initialDate.getTime())) { + throw new RangeError('targetDate and initialDate must be valid.'); + } + + let availableDate = targetDate; + let daysOffset = 0; + // if the target date is available, return it immediately + if (!isRestrictedDate(targetDate, restrictionOptions)) { + return targetDate; + } + + while ( + compareDatePart(initialDate, availableDate) !== 0 && + isRestrictedDate(availableDate, restrictionOptions) && + !isAfterMaxDate(availableDate, restrictionOptions) && + !isBeforeMinDate(availableDate, restrictionOptions) + ) { + // A skipped local date may normalize back to the previous candidate. + daysOffset += direction; + availableDate = addDays(targetDate, daysOffset); + if (!Number.isFinite(availableDate.getTime())) { + throw new RangeError('Cannot search for an out-of-range date.'); + } + } + + if (compareDatePart(initialDate, availableDate) !== 0 && !isRestrictedDate(availableDate, restrictionOptions)) { + return availableDate; + } + + return undefined; +}; + +/** + * Generates a list of dates, bounded by min and max dates + * @param dateRange - input date range + * @param minDate - min date to limit the range + * @param maxDate - max date to limit the range + */ +export const getBoundedDateRange = (dateRange: Date[], minDate?: Date, maxDate?: Date): Date[] => { + let boundedDateRange = [...dateRange]; + if (minDate) { + boundedDateRange = boundedDateRange.filter(date => compareDatePart(date, minDate) >= 0); + } + if (maxDate) { + boundedDateRange = boundedDateRange.filter(date => compareDatePart(date, maxDate) <= 0); + } + return boundedDateRange; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/dateGrid.types.ts b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/dateGrid.types.ts new file mode 100644 index 0000000000000..1c76a54db8a81 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/dateGrid.types.ts @@ -0,0 +1,137 @@ +import type { DayOfWeek, DateRangeType, FirstWeekOfYear } from '../constants'; + +export interface Day { + /** + * Local year, zero-based month, and day joined with hyphens (`year-month-day`) + */ + key: string; + /** + * `Date.getDate()` value of current date + */ + date: string; + /** + * `Date` object of current date + */ + originalDate: Date | null; + /** + * Whether this cell represents a civil date that does not exist in the local timezone. + */ + isPlaceholder: boolean; + /** + * Whether the current date is in the same month as the navigated date + */ + isInMonth: boolean; + /** + * Is current date is "today" date + */ + isToday: boolean; + /** + * Whether the current date is selected in a range + */ + isSelected: boolean; + /** + * Whether the current date is selected without a range + */ + isSingleSelected: boolean; + /** + * Is current date within restriction boundaries + */ + isInBounds: boolean; + /** + * Is current date marked + */ + isMarked: boolean; +} + +export interface AvailableDateOptions extends RestrictedDatesOptions { + /** + * Date from which we start the search + */ + initialDate: Date; + /** + * Ideal available date + */ + targetDate: Date; + /** + * Direction of search (`1` - search in future / `-1` search in past) + */ + direction: 1 | -1; +} + +export interface RestrictedDatesOptions { + /** + * If set the Calendar will not allow navigation to or selection of a date earlier than this value. + */ + minDate?: Date; + + /** + * If set the Calendar will not allow navigation to or selection of a date later than this value. + */ + maxDate?: Date; + + /** + * If set the Calendar will not allow selection of dates in this array. + */ + restrictedDates?: Date[]; +} + +export interface DayGridOptions extends RestrictedDatesOptions { + /** + * The first day of the week for your locale. + */ + firstDayOfWeek: DayOfWeek; + + /** + * Defines when the first week of the year should start. + */ + firstWeekOfYear: FirstWeekOfYear; + + /** + * The date range type indicating how many days should be selected as the user + * selects days + */ + dateRangeType: DateRangeType; + + /** + * The number of days to select while `dateRangeType` is `day`. Used in order to have multi-day + * views. + */ + daysToSelectInDayView?: number; + + /** + * Value of today. If unspecified, current time in client machine will be used. + */ + today?: Date; + + /** + * Whether the calendar should show the week number before each week row + */ + showWeekNumbers?: boolean; + + /** + * The days that are selectable when `dateRangeType` is `workWeek`. + * If `dateRangeType` is not `workWeek` this property does nothing. + */ + workWeekDays?: DayOfWeek[]; + + /** + * Which days in the generated grid should be marked. + */ + markedDays?: Date[]; + + /** + * The currently selected date + */ + selectedDate?: Date | null; + + /** + * The currently navigated date + */ + navigatedDate: Date; + + /** + * How many weeks to show by default. If not provided, will show enough weeks to display the current + * month, between 4 and 6 depending + */ + weeksToShow?: number; +} diff --git a/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/getDayGrid.test.ts b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/getDayGrid.test.ts new file mode 100644 index 0000000000000..ef67f7a3b9317 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/getDayGrid.test.ts @@ -0,0 +1,219 @@ +import { getDayGrid } from './getDayGrid'; +import type { DayGridOptions } from './dateGrid.types'; +import * as dateMath from '../dateMath'; +import { daysOfWeek } from '../constants'; + +jest.mock('../dateMath', () => ({ + __esModule: true, + ...jest.requireActual('../dateMath'), +})); + +const defaultOptions: DayGridOptions = { + dateRangeType: 'day', + firstDayOfWeek: 'sunday', + firstWeekOfYear: 'firstFullWeek', + navigatedDate: new Date(2020, 8, 18), + today: new Date(2020, 8, 18), +}; + +describe('getDayGrid', () => { + afterEach(() => jest.restoreAllMocks()); + + it.each([0, -1, 1.5, Infinity, -Infinity, NaN])('rejects an invalid weeksToShow value of %s', weeksToShow => { + expect(() => getDayGrid({ ...defaultOptions, weeksToShow })).toThrow( + new RangeError('weeksToShow must be a positive finite integer.'), + ); + }); + + it('generates the full month with a transition row on each side', () => { + const weeks = getDayGrid(defaultOptions); + expect(weeks).toHaveLength(7); + weeks.forEach((week, weekIndex) => { + expect(week).toHaveLength(7); + week.forEach((day, dayIndex) => { + const expected = new Date(2020, 7, 23 + weekIndex * 7 + dayIndex); + expect(day).toEqual({ + key: `${expected.getFullYear()}-${expected.getMonth()}-${expected.getDate()}`, + date: String(expected.getDate()), + originalDate: expected, + isPlaceholder: false, + isInMonth: expected.getMonth() === 8, + isToday: expected.getTime() === defaultOptions.today?.getTime(), + isSelected: false, + isSingleSelected: false, + isInBounds: true, + isMarked: false, + }); + }); + }); + }); + + it.each(daysOfWeek)('aligns ordinary weeks to %s', firstDayOfWeek => { + const weeks = getDayGrid({ ...defaultOptions, firstDayOfWeek }); + weeks.forEach(week => expect(week[0].originalDate!.getDay()).toBe(daysOfWeek.indexOf(firstDayOfWeek))); + }); + + it.each([1, 2, 3, 4, 5, 6])('includes two transition rows for %s visible weeks', weeksToShow => { + const weeks = getDayGrid({ ...defaultOptions, weeksToShow }); + expect(weeks).toHaveLength(weeksToShow + 2); + expect(weeks[1][0].originalDate).toEqual(new Date(2020, weeksToShow <= 4 ? 8 : 7, weeksToShow <= 4 ? 13 : 30)); + }); + + it.each([ + [2021, 1, 6], + [2020, 7, 8], + ])('covers all days in month %s/%s with %s rows', (year, month, rowCount) => { + const weeks = getDayGrid({ ...defaultOptions, navigatedDate: new Date(year, month, 15), firstDayOfWeek: 'monday' }); + const inMonth = weeks.flat().filter(day => day.isInMonth); + expect(weeks).toHaveLength(rowCount); + expect(inMonth.map(day => day.originalDate!.getDate())).toEqual( + Array.from({ length: new Date(year, month + 1, 0).getDate() }, (_, index) => index + 1), + ); + }); + + it('uses the navigated month rather than today for isInMonth and ignores times for flags', () => { + const days = getDayGrid({ + ...defaultOptions, + today: new Date(2020, 7, 31, 12), + markedDays: [new Date(2020, 8, 18, 23)], + }).flat(); + expect(days.find(day => day.key === '2020-7-31')).toMatchObject({ isToday: true, isInMonth: false }); + expect(days.find(day => day.key === '2020-8-18')).toMatchObject({ + isToday: false, + isInMonth: true, + isMarked: true, + }); + + expect(days.filter(day => day.isMarked)).toHaveLength(1); + }); + + it('does not mark the same month in a different year as in-month', () => { + const gridDays = getDayGrid({ + ...defaultOptions, + navigatedDate: new Date(2020, 11, 1), + weeksToShow: 53, + }).flat(); + + expect(gridDays.find(day => day.key === '2021-11-1')).toMatchObject({ isInMonth: false }); + }); + + it('defaults today to the current date', () => { + jest.useFakeTimers().setSystemTime(new Date(2020, 8, 20, 12)); + try { + expect( + getDayGrid({ ...defaultOptions, today: undefined }) + .flat() + .filter(day => day.isToday) + .map(day => day.key), + ).toEqual(['2020-8-20']); + } finally { + jest.useRealTimers(); + } + }); + + it('falls back to today when a JavaScript caller omits the navigated date', () => { + // @ts-expect-error Verify the runtime fallback for an omitted required date. + expect(getDayGrid({ ...defaultOptions, navigatedDate: undefined })).toEqual(getDayGrid(defaultOptions)); + }); + + it('marks a single selected day', () => { + const days = getDayGrid({ ...defaultOptions, selectedDate: new Date(2020, 8, 18, 12) }).flat(); + expect(days.filter(day => day.isSelected).map(day => day.key)).toEqual(['2020-8-18']); + expect(days.filter(day => day.isSingleSelected).map(day => day.key)).toEqual(['2020-8-18']); + }); + + it.each([ + ['day', [18, 19, 20]], + ['week', [13, 14, 15, 16, 17, 18, 19]], + ['workWeek', [14, 15, 16, 17, 18]], + ['month', Array.from({ length: 30 }, (_, index) => index + 1)], + ] as const)('selects the expected %s range', (dateRangeType, expected) => { + const days = getDayGrid({ + ...defaultOptions, + dateRangeType, + selectedDate: new Date(2020, 8, 18), + daysToSelectInDayView: 3, + }).flat(); + expect(days.filter(day => day.isSelected).map(day => day.originalDate!.getDate())).toEqual(expected); + expect(days.some(day => day.isSingleSelected)).toBe(false); + }); + + it('clips selected ranges to inclusive bounds and separately flags restricted days', () => { + const days = getDayGrid({ + ...defaultOptions, + dateRangeType: 'week', + selectedDate: new Date(2020, 8, 18), + minDate: new Date(2020, 8, 15, 12), + maxDate: new Date(2020, 8, 18), + restrictedDates: [new Date(2020, 8, 16, 12)], + }).flat(); + expect(days.filter(day => day.isSelected).map(day => day.key)).toEqual([ + '2020-8-15', + '2020-8-16', + '2020-8-17', + '2020-8-18', + ]); + expect(days.filter(day => day.isInBounds).map(day => day.key)).toEqual(['2020-8-15', '2020-8-17', '2020-8-18']); + }); + + it('uses a full week for non-contiguous work-week days', () => { + const days = getDayGrid({ + ...defaultOptions, + dateRangeType: 'workWeek', + selectedDate: new Date(2020, 8, 18), + workWeekDays: ['monday', 'wednesday', 'friday'], + }).flat(); + expect(days.filter(day => day.isSelected).map(day => day.originalDate!.getDate())).toEqual([ + 13, 14, 15, 16, 17, 18, 19, + ]); + }); + + it('rejects invalid and unrepresentable grid dates', () => { + expect(() => getDayGrid({ ...defaultOptions, navigatedDate: new Date(NaN) })).toThrow( + new RangeError('navigatedDate must be valid.'), + ); + expect(() => getDayGrid({ ...defaultOptions, navigatedDate: new Date(-8640000000000000) })).toThrow( + new RangeError('Cannot align an invalid or out-of-range date.'), + ); + expect(() => getDayGrid({ ...defaultOptions, navigatedDate: new Date(8640000000000000) })).toThrow( + new RangeError('Cannot generate a grid containing an out-of-range date.'), + ); + }); + + it('bounds alignment even if normalization never produces the requested weekday', () => { + const alignToWeekStart = jest.spyOn(dateMath, 'getStartDateOfWeek').mockImplementation(() => { + throw new RangeError('Could not find a representable week start within two weeks.'); + }); + expect(() => getDayGrid(defaultOptions)).toThrow( + new RangeError('Could not find a representable week start within two weeks.'), + ); + expect(alignToWeekStart).toHaveBeenCalledTimes(1); + }); + + it('aligns January 2012 using the runtime timezone without mocked normalization', () => { + const skippedFriday = new Date(2011, 11, 30).getDate() !== 30; + const options = { ...defaultOptions, navigatedDate: new Date(2012, 0, 1) }; + const mondayWeeks = getDayGrid({ ...options, firstDayOfWeek: 'monday' }); + const fridayWeeks = getDayGrid({ ...options, firstDayOfWeek: 'friday' }); + expect(mondayWeeks[1][0].originalDate).toEqual(new Date(2011, 11, 26)); + expect(fridayWeeks[1][0].originalDate).toEqual(new Date(2011, 11, skippedFriday ? 23 : 30)); + expect( + fridayWeeks + .flat() + .filter(day => day.isInMonth) + .filter(day => day.originalDate) + .map(day => day.originalDate!.getDate()), + ).toEqual(Array.from({ length: 31 }, (_, index) => index + 1)); + if (skippedFriday) { + const placeholder = fridayWeeks.flatMap(week => week).find(day => day.isPlaceholder); + expect(placeholder).toMatchObject({ key: '2011-11-30', originalDate: null, isPlaceholder: true }); + fridayWeeks.forEach(week => + week.forEach((day, index) => { + if (!day.isPlaceholder) { + expect(day.originalDate!.getDay()).toBe((5 + index) % 7); + } + }), + ); + } + }); +}); diff --git a/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/getDayGrid.ts b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/getDayGrid.ts new file mode 100644 index 0000000000000..05f381fb486ce --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/getDayGrid.ts @@ -0,0 +1,146 @@ +import { areDatesEqual, createDate, getDateRange, getStartDateOfWeek, isDateInRange } from '../dateMath'; +import { DAYS_IN_WEEK } from '../constants'; +import type { Day, DayGridOptions } from './dateGrid.types'; +import { getBoundedDateRange, isRestrictedDate } from './dateAvailability'; +import { getDateRangeTypeToUse } from './workWeek'; + +/** + * Generates a grid of days, given the `options`. + * Returns one additional week at the beginning from the previous range + * and one at the end from the future range + * @param options - parameters to specify date related restrictions for the resulting grid + * @throws RangeError if the week count is invalid or grid dates cannot be represented. + */ +export const getDayGrid = (options: DayGridOptions): Day[][] => { + const { + selectedDate, + dateRangeType, + firstDayOfWeek, + today, + minDate, + maxDate, + weeksToShow, + workWeekDays, + daysToSelectInDayView, + restrictedDates, + markedDays, + } = options; + + if ( + weeksToShow !== undefined && + (!Number.isFinite(weeksToShow) || !Number.isInteger(weeksToShow) || weeksToShow <= 0) + ) { + throw new RangeError('weeksToShow must be a positive finite integer.'); + } + + const restrictedDateOptions = { minDate, maxDate, restrictedDates }; + + const todaysDate = today || new Date(); + + const navigatedDate = options.navigatedDate ? options.navigatedDate : todaysDate; + + if (!Number.isFinite(navigatedDate.getTime())) { + throw new RangeError('navigatedDate must be valid.'); + } + + let date; + if (weeksToShow && weeksToShow <= 4) { + // if showing less than a full month, just use date == navigatedDate + date = createDate(navigatedDate.getFullYear(), navigatedDate.getMonth(), navigatedDate.getDate()); + } else { + date = createDate(navigatedDate.getFullYear(), navigatedDate.getMonth(), 1); + } + const weeks: Day[][] = []; + + date = getStartDateOfWeek(date, firstDayOfWeek); + + // add the transition week as last week of previous range + date = createDate(date.getFullYear(), date.getMonth(), date.getDate() - DAYS_IN_WEEK); + let civilDate = { + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), + }; + + // a flag to indicate whether all days of the week are outside the month + let isAllDaysOfWeekOutOfMonth = false; + let hasReachedNavigatedMonth = false; + + // in work week view if the days aren't contiguous we use week view instead + const selectedDateRangeType = getDateRangeTypeToUse(dateRangeType, workWeekDays, firstDayOfWeek); + + let selectedDates: Date[] = []; + + if (selectedDate) { + selectedDates = getDateRange( + selectedDate, + selectedDateRangeType, + firstDayOfWeek, + workWeekDays, + daysToSelectInDayView, + ); + selectedDates = getBoundedDateRange(selectedDates, minDate, maxDate); + } + + let shouldGetWeeks = true; + + for (let weekIndex = 0; shouldGetWeeks; weekIndex++) { + const week: Day[] = []; + + isAllDaysOfWeekOutOfMonth = true; + + for (let dayIndex = 0; dayIndex < DAYS_IN_WEEK; dayIndex++) { + const { year: civilYear, month: civilMonth, day: civilDay } = civilDate; + if (!Number.isFinite(civilYear) || !Number.isFinite(civilMonth) || !Number.isFinite(civilDay)) { + throw new RangeError('Cannot generate a grid containing an out-of-range date.'); + } + const originalDate = createDate(civilYear, civilMonth, civilDay); + const isPlaceholder = + originalDate.getFullYear() !== civilYear || + originalDate.getMonth() !== civilMonth || + originalDate.getDate() !== civilDay; + if (!isPlaceholder && !Number.isFinite(originalDate.getTime())) { + throw new RangeError('Cannot generate a grid containing an out-of-range date.'); + } + const dayInfo: Day = { + key: `${civilYear}-${civilMonth}-${civilDay}`, + date: civilDay.toString(), + originalDate: isPlaceholder ? null : originalDate, + isPlaceholder, + isInMonth: civilYear === navigatedDate.getFullYear() && civilMonth === navigatedDate.getMonth(), + isToday: !isPlaceholder && areDatesEqual(todaysDate, originalDate), + isSelected: !isPlaceholder && isDateInRange(originalDate, selectedDates), + isSingleSelected: + !isPlaceholder && !!selectedDate && selectedDates.length === 1 && areDatesEqual(originalDate, selectedDate), + isInBounds: !isPlaceholder && !isRestrictedDate(originalDate, restrictedDateOptions), + isMarked: + (!isPlaceholder && markedDays?.some((markedDay: Date) => areDatesEqual(originalDate, markedDay))) || false, + }; + + week.push(dayInfo); + + if (dayInfo.isInMonth) { + isAllDaysOfWeekOutOfMonth = false; + hasReachedNavigatedMonth = true; + } + + const nextCivilDate = new Date(Date.UTC(civilYear, civilMonth, civilDay + 1)); + civilDate = { + year: nextCivilDate.getUTCFullYear(), + month: nextCivilDate.getUTCMonth(), + day: nextCivilDate.getUTCDate(), + }; + date = createDate(civilDate.year, civilDate.month, civilDate.day); + } + + // A fixed week count includes both transition rows; a skipped weekday may add leading rows in month view. + shouldGetWeeks = weeksToShow + ? weekIndex < weeksToShow + 1 + : !isAllDaysOfWeekOutOfMonth || !hasReachedNavigatedMonth; + + // we don't check shouldGetWeeks before pushing because we want to add one extra week for transition state + weeks.push(week); + } + + return weeks; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/index.ts b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/index.ts new file mode 100644 index 0000000000000..a0cd509ccf873 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/index.ts @@ -0,0 +1,3 @@ +export type { AvailableDateOptions, Day, DayGridOptions, RestrictedDatesOptions } from './dateGrid.types'; +export { findAvailableDate, getBoundedDateRange, isRestrictedDate } from './dateAvailability'; +export { getDayGrid } from './getDayGrid'; diff --git a/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/workWeek.test.ts b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/workWeek.test.ts new file mode 100644 index 0000000000000..9c9fc44767d2b --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/workWeek.test.ts @@ -0,0 +1,25 @@ +import { daysOfWeek } from '../constants'; +import type { DayOfWeek } from '../constants'; +import { getDateRangeTypeToUse } from './workWeek'; + +describe('getDateRangeTypeToUse', () => { + it.each<{ days: DayOfWeek[] | undefined; firstDay: DayOfWeek; expected: 'week' | 'workWeek' }>([ + { days: undefined, firstDay: 'sunday', expected: 'workWeek' }, + { days: [], firstDay: 'sunday', expected: 'week' }, + { days: ['monday'], firstDay: 'sunday', expected: 'workWeek' }, + { days: ['monday', 'tuesday', 'tuesday'], firstDay: 'sunday', expected: 'workWeek' }, + { days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], firstDay: 'sunday', expected: 'workWeek' }, + { days: ['wednesday', 'monday', 'tuesday'], firstDay: 'sunday', expected: 'workWeek' }, + { days: ['monday', 'wednesday'], firstDay: 'sunday', expected: 'week' }, + { days: ['saturday', 'sunday', 'monday'], firstDay: 'sunday', expected: 'week' }, + { days: ['saturday', 'sunday', 'monday'], firstDay: 'tuesday', expected: 'workWeek' }, + { days: [...daysOfWeek], firstDay: 'monday', expected: 'workWeek' }, + ])('uses $expected for $days with a $firstDay start', ({ days, firstDay, expected }) => { + expect(getDateRangeTypeToUse('workWeek', days, firstDay)).toBe(expected); + }); + + it.each(['day', 'week', 'month'] as const)('preserves %s regardless of work-week days', rangeType => { + expect(getDateRangeTypeToUse(rangeType, [], 'sunday')).toBe(rangeType); + expect(getDateRangeTypeToUse(rangeType, ['monday', 'wednesday'], 'sunday')).toBe(rangeType); + }); +}); diff --git a/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/workWeek.ts b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/workWeek.ts new file mode 100644 index 0000000000000..146f638ba7a0e --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/dateGrid/workWeek.ts @@ -0,0 +1,43 @@ +import { getDayFromIndex, getDayIndex } from '../dateUtils'; +import type { DateRangeType, DayOfWeek } from '../constants'; + +/** + * Checks if the given set of days forms a contiguous sequence within a week. + */ +const isContiguous = (days: DayOfWeek[], isSingleWeek: boolean, firstDayOfWeek: DayOfWeek): boolean => { + const daySet = new Set(days); + let amountOfNoNeighbors = 0; + for (const day of daySet) { + const nextDay = getDayFromIndex(getDayIndex(day) + 1); + if (!(daySet.has(nextDay) && (!isSingleWeek || firstDayOfWeek !== nextDay))) { + amountOfNoNeighbors++; + } + } + + /* + * In case the full week is provided, then each day has a neighbor + * , otherwise the last day does not have a neighbor. + */ + return amountOfNoNeighbors < 2; +}; + +/** + * Return corrected date range type, given `dateRangeType` and list of working days. + * For non-contiguous working days and working week range type, returns general week range type. + * For other cases returns input date range type. + * @param dateRangeType - input type of range + * @param workWeekDays - list of working days in a week + */ +export const getDateRangeTypeToUse = ( + dateRangeType: DateRangeType, + workWeekDays: DayOfWeek[] | undefined, + firstDayOfWeek: DayOfWeek, +): DateRangeType => { + if (workWeekDays && dateRangeType === 'workWeek') { + if (!isContiguous(workWeekDays, true, firstDayOfWeek) || new Set(workWeekDays).size === 0) { + return 'week'; + } + } + + return dateRangeType; +}; diff --git a/packages/react-components/react-calendar-preview/library/src/utils/dateMath.test.ts b/packages/react-components/react-calendar-preview/library/src/utils/dateMath.test.ts new file mode 100644 index 0000000000000..abf682bb5ab84 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/dateMath.test.ts @@ -0,0 +1,631 @@ +import { + addDays, + addWeeks, + addMonths, + addYears, + setMonth, + areDatesEqual, + getDateRange, + getWeekNumbersInMonth, + getWeekNumber, + getMonthStart, + getMonthEnd, + getYearStart, + getYearEnd, + getStartDateOfWeek, + createDate, + compareDatePart, + isDateInRange, +} from './dateMath'; + +enum Months { + Jan = 0, + Feb = 1, + Mar = 2, + Apr = 3, + May = 4, + Jun = 5, + Jul = 6, + Aug = 7, + Sep = 8, + Oct = 9, + Nov = 10, + Dec = 11, +} +describe('DateMath', () => { + it.each([0, 1, 99, 2020])('creates local midnight dates without remapping year %s', year => { + const date = createDate(year, 1, 15); + expect([ + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds(), + ]).toEqual([year, 1, 15, 0, 0, 0, 0]); + }); + + it('normalizes month/day overflow and underflow', () => { + expect(createDate(2020, 12, 1)).toEqual(new Date(2021, 0, 1)); + expect(createDate(2020, 2, 0)).toEqual(new Date(2020, 1, 29)); + }); + + it.each([-2, 0, 2])('adds %s weeks without changing the input or time of day', weeks => { + const date = new Date(2020, 11, 28, 12, 34); + expect(addWeeks(date, weeks)).toEqual(new Date(2020, 11, 28 + weeks * 7, 12, 34)); + expect(date).toEqual(new Date(2020, 11, 28, 12, 34)); + }); + + it('orders dates by year, month and day while ignoring time', () => { + const date = new Date(2020, 8, 18); + expect(compareDatePart(date, new Date(2020, 8, 18, 23))).toBe(0); + for (const later of [new Date(2021, 0, 1), new Date(2020, 9, 1), new Date(2020, 8, 19)]) { + expect(compareDatePart(date, later)).toBeLessThan(0); + expect(compareDatePart(later, date)).toBeGreaterThan(0); + } + }); + + it('finds dates in a range ignoring time, and handles absent dates and empty ranges', () => { + const date = new Date(2020, 8, 18); + expect(isDateInRange(date, [new Date(2020, 8, 17), new Date(2020, 8, 18, 23)])).toBe(true); + expect(isDateInRange(date, [new Date(2019, 8, 18)])).toBe(false); + expect(isDateInRange(date, [])).toBe(false); + }); + + it('can add days', () => { + const startDate = new Date(2016, Months.Apr, 1); + const result = addDays(startDate, 5); + const expected = new Date(2016, Months.Apr, 6); + + expect(result.getTime()).toEqual(expected.getTime()); + }); + + it('can add days across a month boundary', () => { + const startDate = new Date(2016, Months.Mar, 30); + const result = addDays(startDate, 5); + const expected = new Date(2016, Months.Apr, 4); + + expect(result.getTime()).toEqual(expected.getTime()); + }); + + it('can add days across multiple month boundaries', () => { + const startDate = new Date(2016, Months.Mar, 31); + const result = addDays(startDate, 65); + const expected = new Date(2016, Months.Jun, 4); + + expect(result.getTime()).toEqual(expected.getTime()); + }); + + it('can add days across leap day boundaries', () => { + const startDate = new Date(2016, Months.Feb, 28); + const result = addDays(startDate, 2); + const expected = new Date(2016, Months.Mar, 1); + + expect(result.getTime()).toEqual(expected.getTime()); + }); + + it('can add negative days', () => { + const startDate = new Date(2016, Months.Feb, 28); + const result = addDays(startDate, -5); + const expected = new Date(2016, Months.Feb, 23); + + expect(result.getTime()).toEqual(expected.getTime()); + }); + + it('can add months', () => { + const startDate = new Date(2015, Months.Dec, 31); + + let result = addMonths(startDate, 1); + let expected = new Date(2016, Months.Jan, 31); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, 2); + expected = new Date(2016, Months.Feb, 29); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, 3); + expected = new Date(2016, Months.Mar, 31); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, 4); + expected = new Date(2016, Months.Apr, 30); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, 5); + expected = new Date(2016, Months.May, 31); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, 6); + expected = new Date(2016, Months.Jun, 30); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, 7); + expected = new Date(2016, Months.Jul, 31); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, 8); + expected = new Date(2016, Months.Aug, 31); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, 9); + expected = new Date(2016, Months.Sep, 30); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, 10); + expected = new Date(2016, Months.Oct, 31); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, 11); + expected = new Date(2016, Months.Nov, 30); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, 12); + expected = new Date(2016, Months.Dec, 31); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, 14); + expected = new Date(2017, Months.Feb, 28); + expect(result.getTime()).toEqual(expected.getTime()); + }); + + it('can add years', () => { + let startDate = new Date(2016, Months.Feb, 29); + let result = addYears(startDate, 1); + let expected = new Date(2017, Months.Feb, 28); + + expect(result.getTime()).toEqual(expected.getTime()); + + startDate = new Date(2016, Months.Feb, 29); + result = addYears(startDate, 4); + expected = new Date(2020, Months.Feb, 29); + + expect(result.getTime()).toEqual(expected.getTime()); + + startDate = new Date(2016, Months.Jan, 1); + result = addYears(startDate, 1); + expected = new Date(2017, Months.Jan, 1); + + expect(result.getTime()).toEqual(expected.getTime()); + }); + + it('can subtract days', () => { + const startDate = new Date(2016, Months.Apr, 30); + const result = addDays(startDate, -5); + const expected = new Date(2016, Months.Apr, 25); + + expect(result.getTime()).toEqual(expected.getTime()); + }); + + it('can subtract days across a month boundary', () => { + const startDate = new Date(2016, Months.Apr, 1); + const result = addDays(startDate, -5); + const expected = new Date(2016, Months.Mar, 27); + + expect(result.getTime()).toEqual(expected.getTime()); + }); + + it('can subtract days across multiple month boundaries', () => { + const startDate = new Date(2016, Months.Jul, 4); + const result = addDays(startDate, -65); + const expected = new Date(2016, Months.Apr, 30); + + expect(result.getTime()).toEqual(expected.getTime()); + }); + + it('can subtract days across leap day boundaries', () => { + const startDate = new Date(2016, Months.Mar, 1); + const result = addDays(startDate, -2); + const expected = new Date(2016, Months.Feb, 28); + + expect(result.getTime()).toEqual(expected.getTime()); + }); + + it('can subtract months', () => { + const startDate = new Date(2016, Months.Dec, 31); + + let result = addMonths(startDate, -12); + let expected = new Date(2015, Months.Dec, 31); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, -11); + expected = new Date(2016, Months.Jan, 31); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, -10); + expected = new Date(2016, Months.Feb, 29); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, -9); + expected = new Date(2016, Months.Mar, 31); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, -8); + expected = new Date(2016, Months.Apr, 30); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, -7); + expected = new Date(2016, Months.May, 31); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, -6); + expected = new Date(2016, Months.Jun, 30); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, -5); + expected = new Date(2016, Months.Jul, 31); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, -4); + expected = new Date(2016, Months.Aug, 31); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, -3); + expected = new Date(2016, Months.Sep, 30); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, -2); + expected = new Date(2016, Months.Oct, 31); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, -1); + expected = new Date(2016, Months.Nov, 30); + expect(result.getTime()).toEqual(expected.getTime()); + + result = addMonths(startDate, -22); + expected = new Date(2015, Months.Feb, 28); + expect(result.getTime()).toEqual(expected.getTime()); + }); + + it('can subtract years', () => { + let startDate = new Date(2016, Months.Feb, 29); + let result = addYears(startDate, -1); + let expected = new Date(2015, Months.Feb, 28); + + expect(result.getTime()).toEqual(expected.getTime()); + + startDate = new Date(2016, Months.Feb, 29); + result = addYears(startDate, -4); + expected = new Date(2012, Months.Feb, 29); + + expect(result.getTime()).toEqual(expected.getTime()); + + startDate = new Date(2016, Months.Jan, 1); + result = addYears(startDate, -1); + expected = new Date(2015, Months.Jan, 1); + + expect(result.getTime()).toEqual(expected.getTime()); + }); + + it('can set the month', () => { + let startDate = new Date(2016, Months.Jan, 31); + let result = setMonth(startDate, Months.Feb); + let expected = new Date(2016, Months.Feb, 29); + expect(result.getTime()).toEqual(expected.getTime()); + + startDate = new Date(2016, Months.Jun, 1); + result = setMonth(startDate, Months.Feb); + expected = new Date(2016, Months.Feb, 1); + expect(result.getTime()).toEqual(expected.getTime()); + }); + + it('can compare dates', () => { + let date1 = new Date(2016, 4, 1); + let date2 = new Date(2016, 4, 1); + expect(areDatesEqual(date1, date2)).toBe(true); + + date1 = new Date(2016, 4, 1, 12, 30, 0); + date2 = new Date(2016, 4, 1, 10, 0, 0); + expect(areDatesEqual(date1, date2)).toBe(true); + + date1 = new Date(2016, 4, 1); + date2 = new Date(2016, 4, 2); + expect(areDatesEqual(date1, date2)).toBe(false); + + date1 = new Date(2016, 4, 1); + date2 = new Date(2016, 5, 1); + expect(areDatesEqual(date1, date2)).toBe(false); + + date1 = new Date(2016, 4, 1); + date2 = new Date(2017, 4, 1); + expect(areDatesEqual(date1, date2)).toBe(false); + }); + + it('preserves the runtime equality behavior for missing dates', () => { + // @ts-expect-error Verify the existing JavaScript contract for two missing dates. + expect(areDatesEqual(undefined, undefined)).toBe(true); + // @ts-expect-error Verify the existing JavaScript contract for a missing first date. + expect(areDatesEqual(undefined, new Date(2020, 8, 18))).toBe(false); + // @ts-expect-error Verify the existing JavaScript contract for a missing second date. + expect(areDatesEqual(new Date(2020, 8, 18), undefined)).toBe(false); + }); + + describe('Date range array', () => { + const date = new Date(2017, 2, 16); + + function createDaysRange(startDate: Date, numDays: number): Date[] { + return Array.from({ length: numDays }).map( + (_, i) => new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate() + i), + ); + } + + type TestData = { name: string; testItems: Date[]; expected: Date[] }[]; + const testData: TestData = [ + { + name: 'week', + testItems: getDateRange(date, 'week', 'sunday'), + expected: createDaysRange(new Date(2017, 2, 12), 7), + }, + { + name: 'work week', + testItems: getDateRange(date, 'workWeek', 'sunday', ['monday', 'tuesday', 'thursday', 'friday']), + expected: [new Date(2017, 2, 13), new Date(2017, 2, 14), new Date(2017, 2, 16), new Date(2017, 2, 17)], + }, + { + name: 'work week defaults', + testItems: getDateRange(date, 'workWeek', 'sunday'), + expected: createDaysRange(new Date(2017, 2, 13), 5), + }, + { + name: 'month', + testItems: getDateRange(date, 'month', 'sunday'), + expected: createDaysRange(new Date(2017, 2, 1), 31), + }, + { + name: 'first day of week: Tuesday', + testItems: getDateRange(date, 'week', 'tuesday'), + expected: createDaysRange(new Date(2017, 2, 14), 7), + }, + { + name: 'custom date range array', + testItems: getDateRange(date, 'day', 'sunday', undefined, 5), + expected: createDaysRange(new Date(2017, 2, 16), 5), + }, + { + name: 'reverse date range array', + testItems: getDateRange(date, 'day', 'sunday', undefined, -5), + expected: createDaysRange(new Date(2017, 2, 12), 5), + }, + ]; + + it('can get day', () => { + const dateRange = getDateRange(date, 'day', 'sunday'); + expect(dateRange.length).toEqual(1); + expect(areDatesEqual(dateRange[0], date)).toBe(true); + }); + + it('returns an empty range when zero days are requested', () => { + expect(getDateRange(date, 'day', 'sunday', undefined, 0)).toEqual([]); + }); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, 1.5])('rejects an invalid day count of %s', days => { + expect(() => getDateRange(date, 'day', 'sunday', undefined, days)).toThrow( + 'daysToSelectInDayView must be a finite integer', + ); + }); + + it('rejects an invalid date', () => { + expect(() => getDateRange(new Date(Number.NaN), 'day', 'sunday')).toThrow('date must be valid'); + }); + + it('rejects an unsupported range type at runtime', () => { + // @ts-expect-error Verify JavaScript callers receive an explicit error. + expect(() => getDateRange(date, 'invalid', 'sunday')).toThrow('Unexpected object: invalid'); + }); + + it('fails explicitly when the requested range exceeds representable dates', () => { + expect(() => getDateRange(new Date(8640000000000000), 'day', 'sunday')).toThrow( + 'Date range end is outside the representable date range', + ); + }); + + it('returns an empty work-week range when no working days are specified', () => { + expect(getDateRange(date, 'workWeek', 'sunday', [])).toEqual([]); + }); + + it.each(testData)(`can get %s`, ({ testItems, expected }) => { + expect(testItems).toEqual(expected); + }); + }); + + it.each([-1, 1.5, Infinity, -Infinity, NaN])('rejects an invalid weeksInMonth value of %s', weeksInMonth => { + expect(() => getWeekNumbersInMonth(weeksInMonth, 'monday', 'firstFullWeek', new Date(2020, 8, 18))).toThrow( + new RangeError('weeksInMonth must be a non-negative finite integer.'), + ); + }); + + it('returns no week numbers when zero weeks are requested', () => { + expect(getWeekNumbersInMonth(0, 'monday', 'firstFullWeek', new Date(2020, 8, 18))).toEqual([]); + }); + + it('rejects an invalid navigated date when calculating week numbers', () => { + expect(() => getWeekNumbersInMonth(1, 'monday', 'firstFullWeek', new Date(NaN))).toThrow( + new RangeError('navigatedDate must be valid.'), + ); + }); + + it('continues week numbers across a month boundary', () => { + expect(getWeekNumbersInMonth(6, 'sunday', 'firstDay', new Date(2020, 8, 1))).toEqual([36, 37, 38, 39, 40, 41]); + }); + + // Generating week numbers array per month + it('can calculate week numbers from selected date', () => { + // firstDayOfWeek is Monday, firstWeekOfYear is firstFullWeek + let date = new Date(2017, 0, 4); + let result = getWeekNumbersInMonth(6, 'monday', 'firstFullWeek', date); + let expected = 52; + expect(result[0]).toEqual(expected); + + // firstDayOfWeek is Sunday, firstWeekOfYear is firstFullWeek + date = new Date(2000, 11, 31); + result = getWeekNumbersInMonth(6, 'sunday', 'firstFullWeek', date); + expected = 53; + expect(result[5]).toEqual(expected); + + // firstDayOfWeek is Sunday, firstWeekOfYear is firstFullWeek + date = new Date(2010, 0, 1); + result = getWeekNumbersInMonth(6, 'sunday', 'firstFullWeek', date); + expected = 52; + expect(result[0]).toEqual(expected); + + // firstDayOfWeek is Sunday, firstWeekOfYear is firstFourDayWeek + date = new Date(2018, 11, 31); + result = getWeekNumbersInMonth(6, 'sunday', 'firstFourDayWeek', date); + expected = 1; + expect(result[5]).toEqual(expected); + }); + + // First week of year set to 'firstDay' + it('can calculate week numbers - option 0', () => { + // firstDayOfWeek is Sunday + let date1 = new Date(2018, 0, 1); + let result = getWeekNumber(date1, 'sunday', 'firstDay'); + let expected = 1; + expect(result).toEqual(expected); + + // firstDayOfWeek is Sunday + date1 = new Date(2010, 0, 1); + result = getWeekNumber(date1, 'sunday', 'firstDay'); + expected = 1; + expect(result).toEqual(expected); + + // firstDayOfWeek is Sunday + date1 = new Date(2019, 0, 1); + result = getWeekNumber(date1, 'sunday', 'firstDay'); + expected = 1; + expect(result).toEqual(expected); + + // firstDayOfWeek is Monday + date1 = new Date(2010, 11, 31); + result = getWeekNumber(date1, 'monday', 'firstDay'); + expected = 53; + expect(result).toEqual(expected); + }); + + // First week of year set to 'firstFullWeek' + it('can calculate week numbers - option 1', () => { + // firstDayOfWeek is Sunday + let date1 = new Date(2018, 0, 1); + let result = getWeekNumber(date1, 'sunday', 'firstFullWeek'); + let expected = 53; + expect(result).toEqual(expected); + + // firstDayOfWeek is Sunday + date1 = new Date(2017, 11, 31); + result = getWeekNumber(date1, 'sunday', 'firstFullWeek'); + expected = 53; + expect(result).toEqual(expected); + + // firstDayOfWeek is Sunday + date1 = new Date(2010, 11, 31); + result = getWeekNumber(date1, 'sunday', 'firstFullWeek'); + expected = 52; + expect(result).toEqual(expected); + + // firstDayOfWeek is Monday + date1 = new Date(2011, 0, 1); + result = getWeekNumber(date1, 'monday', 'firstFullWeek'); + expected = 52; + expect(result).toEqual(expected); + + // firstDayOfWeek is Sunday + date1 = new Date(2021, 0, 1); + result = getWeekNumber(date1, 'sunday', 'firstFullWeek'); + expected = 52; + expect(result).toEqual(expected); + + // firstDayOfWeek is Monday + date1 = new Date(2021, 0, 1); + result = getWeekNumber(date1, 'monday', 'firstFullWeek'); + expected = 52; + expect(result).toEqual(expected); + }); + + // First week of year set to 'firstFourDayWeek' + it('can calculate week numbers - option 2', () => { + // firstDayOfWeek is Sunday + let date1 = new Date(2019, 0, 5); + let result = getWeekNumber(date1, 'sunday', 'firstFourDayWeek'); + let expected = 1; + expect(result).toEqual(expected); + + // firstDayOfWeek is Sunday + date1 = new Date(2018, 0, 6); + result = getWeekNumber(date1, 'sunday', 'firstFourDayWeek'); + expected = 1; + expect(result).toEqual(expected); + + // firstDayOfWeek is Sunday + date1 = new Date(2014, 11, 31); + result = getWeekNumber(date1, 'sunday', 'firstFourDayWeek'); + expected = 53; + expect(result).toEqual(expected); + + // firstDayOfWeek is Sunday + date1 = new Date(2015, 0, 1); + result = getWeekNumber(date1, 'sunday', 'firstFourDayWeek'); + expected = 53; + expect(result).toEqual(expected); + + // firstDayOfWeek is Sunday + date1 = new Date(2010, 11, 31); + result = getWeekNumber(date1, 'sunday', 'firstFourDayWeek'); + expected = 52; + expect(result).toEqual(expected); + + // firstDayOfWeek is Monday + date1 = new Date(2011, 0, 1); + result = getWeekNumber(date1, 'monday', 'firstFourDayWeek'); + expected = 52; + expect(result).toEqual(expected); + + // firstDayOfWeek is Sunday + date1 = new Date(2021, 0, 1); + result = getWeekNumber(date1, 'sunday', 'firstFourDayWeek'); + expected = 53; + expect(result).toEqual(expected); + + // firstDayOfWeek is Monday + date1 = new Date(2021, 0, 1); + result = getWeekNumber(date1, 'monday', 'firstFourDayWeek'); + expected = 53; + expect(result).toEqual(expected); + + date1 = new Date(2018, 11, 31); + result = getWeekNumber(date1, 'monday', 'firstFourDayWeek'); + expected = 1; + expect(result).toEqual(expected); + }); + + it('can get the month start and end', () => { + const date = new Date('Dec 15 2017'); + + // First day of month + expect(areDatesEqual(new Date('Dec 1 2017'), getMonthStart(date))).toBe(true); + + // Last day of month + expect(areDatesEqual(new Date('Dec 31 2017'), getMonthEnd(date))).toBe(true); + }); + + it('can get the year start and end', () => { + const date = new Date('Dec 15 2017'); + + // First day of year + expect(areDatesEqual(new Date('Jan 1 2017'), getYearStart(date))).toBe(true); + + // Last day of year + expect(areDatesEqual(new Date('Dec 31 2017'), getYearEnd(date))).toBe(true); + }); + + it('can get start date of week', () => { + const date = new Date('Aug 2 2020'); + expect(areDatesEqual(new Date('Jul 28 2020'), getStartDateOfWeek(date, 'tuesday'))).toBe(true); + }); + + it('finds a representable week start when local date normalization skips a day', () => { + const date = new Date(2012, 0, 1); + const start = getStartDateOfWeek(date, 'friday'); + expect(start.getDay()).toBe(5); + expect(start.getDate()).toBe(new Date(2011, 11, 30).getDate() === 30 ? 30 : 23); + }); +}); diff --git a/packages/react-components/react-calendar-preview/library/src/utils/dateMath.ts b/packages/react-components/react-calendar-preview/library/src/utils/dateMath.ts new file mode 100644 index 0000000000000..57596c4060bdd --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/dateMath.ts @@ -0,0 +1,458 @@ +import { DAYS_IN_WEEK } from './constants'; +import { getDayIndex, getMonthIndex } from './dateUtils'; +import type { DateRangeType, DayOfWeek, FirstWeekOfYear } from './constants'; + +/** + * Creates a new Date object with the specified year, month, and day, with the time set to midnight. + */ +export function createDate(year: number, month: number, day: number): Date { + const date = new Date(0); + date.setHours(0, 0, 0, 0); + date.setFullYear(year, month, day); + return date; +} + +/** + * Returns a date offset from the given date by the specified number of days. + * @param date - The origin date + * @param days - The number of days to offset. 'days' can be negative. + * @returns A new Date object offset from the origin date by the given number of days + */ +export function addDays(date: Date, days: number): Date { + const result = new Date(date.getTime()); + result.setDate(result.getDate() + days); + return result; +} + +/** + * Returns a date offset from the given date by the specified number of weeks. + * @param date - The origin date + * @param weeks - The number of weeks to offset. 'weeks' can be negative. + * @returns A new Date object offset from the origin date by the given number of weeks + */ +export function addWeeks(date: Date, weeks: number): Date { + return addDays(date, weeks * DAYS_IN_WEEK); +} + +/** + * Returns a date offset from the given date by the specified number of months. + * The method tries to preserve the day-of-month; however, if the new month does not have enough days + * to contain the original day-of-month, we'll use the last day of the new month. + * @param date - The origin date + * @param months - The number of months to offset. 'months' can be negative. + * @returns A new Date object offset from the origin date by the given number of months + */ +export function addMonths(date: Date, months: number): Date { + let result = new Date(date.getTime()); + const targetMonth = result.getMonth() + months; + result.setMonth(targetMonth); + + const normalizedTargetMonth = ((targetMonth % 12) + 12) % 12; + if (result.getMonth() !== normalizedTargetMonth) { + result = addDays(result, -result.getDate()); + } + + return result; +} + +/** + * Returns a date offset from the given date by the specified number of years. + * The method tries to preserve the day-of-month; however, if the new month does not have enough days + * to contain the original day-of-month, we'll use the last day of the new month. + * @param date - The origin date + * @param years - The number of years to offset. 'years' can be negative. + * @returns A new Date object offset from the origin date by the given number of years + */ +export function addYears(date: Date, years: number): Date { + let result = new Date(date.getTime()); + result.setFullYear(date.getFullYear() + years); + + if (result.getMonth() !== date.getMonth()) { + result = addDays(result, -result.getDate()); + } + + return result; +} + +/** + * Returns a date that is the first day of the month of the provided date. + * @param date - The origin date + * @returns A new Date object with the day set to the first day of the month. + */ +export function getMonthStart(date: Date): Date { + return createDate(date.getFullYear(), date.getMonth(), 1); +} + +/** + * Returns a date that is the last day of the month of the provided date. + * @param date - The origin date + * @returns A new Date object with the day set to the last day of the month. + */ +export function getMonthEnd(date: Date): Date { + return addDays(createDate(date.getFullYear(), date.getMonth() + 1, 1), -1); +} + +/** + * Returns a date that is the first day of the year of the provided date. + * @param date - The origin date + * @returns A new Date object with the day set to the first day of the year. + */ +export function getYearStart(date: Date): Date { + return createDate(date.getFullYear(), 0, 1); +} + +/** + * Returns a date that is the last day of the year of the provided date. + * @param date - The origin date + * @returns A new Date object with the day set to the last day of the year. + */ +export function getYearEnd(date: Date): Date { + return addDays(createDate(date.getFullYear() + 1, 0, 1), -1); +} + +/** + * Returns a date that is a copy of the given date, aside from the month changing to the given month. + * The method tries to preserve the day-of-month; however, if the new month does not have enough days + * to contain the original day-of-month, we'll use the last day of the new month. + * @param date - The origin date + * @param month - The 0-based index of the month to set on the date. + * @returns A new Date object with the given month set. + */ +export function setMonth(date: Date, month: number): Date { + return addMonths(date, month - date.getMonth()); +} + +/** + * Compares two dates, and returns true if the two dates (not accounting for time-of-day) are equal. + * @returns True if the two dates represent the same date (regardless of time-of-day), false otherwise. + */ +export function areDatesEqual(date1: Date, date2: Date): boolean { + if (!date1 && !date2) { + return true; + } else if (!date1 || !date2) { + return false; + } else { + return compareDatePart(date1, date2) === 0; + } +} + +/** + * Compare the date parts of two dates + * @param date1 - The first date to compare + * @param date2 - The second date to compare + * @returns A negative value if date1 is earlier than date2, 0 if the dates are equal, or a positive value + * if date1 is later than date2. + */ +export function compareDatePart(date1: Date, date2: Date): number { + return ( + date1.getFullYear() - date2.getFullYear() || + date1.getMonth() - date2.getMonth() || + date1.getDate() - date2.getDate() + ); +} + +/** + * Gets the date range array including the specified date. The date range array is calculated as the list + * of dates accounting for the specified first day of the week and date range type. + * @param date - The input date + * @param dateRangeType - The desired date range type, i.e., day, week, month, etc. + * @param firstDayOfWeek - The first day of the week. + * @param workWeekDays - The allowed days in work week. Defaults to Monday through Friday. + * @param daysToSelectInDayView - The number of days to include when using dateRangeType === 'day' + * for multiday view. Defaults to 1 + * @returns An array of dates representing the date range containing the specified date. + */ +export function getDateRange( + date: Date, + dateRangeType: DateRangeType, + firstDayOfWeek: DayOfWeek, + workWeekDays?: DayOfWeek[], + daysToSelectInDayView: number = 1, +): Date[] { + if (!Number.isFinite(date.valueOf())) { + throw new RangeError('date must be valid'); + } + + if (!Number.isFinite(daysToSelectInDayView) || !Number.isInteger(daysToSelectInDayView)) { + throw new RangeError('daysToSelectInDayView must be a finite integer'); + } + + if (dateRangeType === 'day' && daysToSelectInDayView === 0) { + return []; + } + + const datesArray: Date[] = []; + let startDate: Date; + let endDate = null; + let maximumRangeLength: number; + + if (!workWeekDays) { + workWeekDays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']; + } + + const workWeekDayIndices = workWeekDays.map(getDayIndex); + + switch (dateRangeType) { + case 'day': + // Create a date range for the specified date + + [startDate, endDate] = [date, addDays(date, daysToSelectInDayView)]; + maximumRangeLength = Math.abs(daysToSelectInDayView); + + // If the start date is after the end date, swap them + if (compareDatePart(startDate, endDate) > 0) { + /* + * For reverse dates we need to add one day to both dates + * to ensure correct start date + */ + [startDate, endDate] = [addDays(endDate, 1), addDays(startDate, 1)]; + } + + break; + + case 'week': + case 'workWeek': + startDate = getStartDateOfWeek(date, firstDayOfWeek); + endDate = addDays(startDate, DAYS_IN_WEEK); + maximumRangeLength = DAYS_IN_WEEK; + break; + + case 'month': + startDate = createDate(date.getFullYear(), date.getMonth(), 1); + endDate = addMonths(startDate, 1); + maximumRangeLength = 31; + break; + + default: + throw new Error('Unexpected object: ' + dateRangeType); + } + + if (!Number.isFinite(endDate.getTime())) { + throw new RangeError('Date range end is outside the representable date range'); + } + + // Populate the dates array with a range-specific bound so a faulty adapter cannot hang rendering. + let nextDate = startDate; + for (let index = 0; index < maximumRangeLength && compareDatePart(nextDate, endDate) !== 0; index++) { + if (dateRangeType !== 'workWeek') { + // push all days not in work week view + datesArray.push(nextDate); + } else if (workWeekDayIndices.indexOf(nextDate.getDay()) !== -1) { + datesArray.push(nextDate); + } + nextDate = addDays(nextDate, 1); + } + + if (compareDatePart(nextDate, endDate) !== 0) { + throw new Error('Date range iteration did not reach the end of the requested range'); + } + + return datesArray; +} + +/** + * Checks whether the specified date is in the given date range. + * @param date - The origin date + * @param dateRange - An array of dates to do the lookup on + * @returns True if the date matches one of the dates in the specified array, false otherwise. + */ +export function isDateInRange(date: Date, dateRange: Date[]): boolean { + for (const dateInRange of dateRange) { + if (compareDatePart(date, dateInRange) === 0) { + return true; + } + } + return false; +} + +/** + * Returns the week number in a year for a date. + * + * @param weeksInMonth - The number of weeks to include; must be a non-negative finite integer. + * @param navigatedDate - A date to find the week number for. + * @param firstDayOfWeek - The named day that starts each week. + * @param firstWeekOfYear - The convention that determines which week is the first week of the year. + * @returns The week number array for the current month. + */ +export function getWeekNumbersInMonth( + weeksInMonth: number, + firstDayOfWeek: DayOfWeek, + firstWeekOfYear: FirstWeekOfYear, + navigatedDate: Date, +): number[] { + if (!Number.isFinite(weeksInMonth) || !Number.isInteger(weeksInMonth) || weeksInMonth < 0) { + throw new RangeError('weeksInMonth must be a non-negative finite integer.'); + } + if (!Number.isFinite(navigatedDate.getTime())) { + throw new RangeError('navigatedDate must be valid.'); + } + + const selectedYear = navigatedDate.getFullYear(); + const selectedMonth = navigatedDate.getMonth(); + const firstDayOfWeekIndex = getDayIndex(firstDayOfWeek); + const dayOfMonth = 1; + const firstDayOfMonth = createDate(selectedYear, selectedMonth, dayOfMonth); + const endOfFirstWeek = + dayOfMonth + + (firstDayOfWeekIndex + DAYS_IN_WEEK - 1) - + adjustWeekDay(firstDayOfWeekIndex, firstDayOfMonth.getDay()); + let endOfWeekRange = createDate(selectedYear, selectedMonth, endOfFirstWeek); + + const weeksArray = []; + for (let i = 0; i < weeksInMonth; i++) { + // Get week number for end of week + weeksArray.push(getWeekNumber(endOfWeekRange, firstDayOfWeek, firstWeekOfYear)); + endOfWeekRange = addDays(endOfWeekRange, DAYS_IN_WEEK); + } + return weeksArray; +} + +/** + * Returns the week number for a date. + * + * @param date - A date to find the week number for. + * @param firstDayOfWeek - The named day that starts each week. + * @param firstWeekOfYear - The convention that determines which week is the first week of the year. + * @returns The week's number in the year. + */ +export function getWeekNumber(date: Date, firstDayOfWeek: DayOfWeek, firstWeekOfYear: FirstWeekOfYear): number { + // First four-day week of the year - minimum days count + const fourDayWeek = 4; + + switch (firstWeekOfYear) { + case 'firstFullWeek': + return getWeekOfYearFullDays(date, firstDayOfWeek, DAYS_IN_WEEK); + + case 'firstFourDayWeek': + return getWeekOfYearFullDays(date, firstDayOfWeek, fourDayWeek); + + default: + return getFirstDayWeekOfYear(date, firstDayOfWeek); + } +} + +/** + * Gets the date for the first day of the week based on the given date assuming + * the specified first day of the week. + * @param date - The date to find the beginning of the week date for. + * @returns A new date object representing the first day of the week containing the input date. + */ +export function getStartDateOfWeek(date: Date, firstDayOfWeek: DayOfWeek): Date { + const year = date.getFullYear(); + const month = date.getMonth(); + const day = date.getDate(); + const firstDayOfWeekIndex = getDayIndex(firstDayOfWeek); + + for (let daysBack = 0; daysBack < 2 * DAYS_IN_WEEK; daysBack++) { + const candidate = createDate(year, month, day - daysBack); + if (!Number.isFinite(candidate.getTime())) { + throw new RangeError('Cannot align an invalid or out-of-range date.'); + } + if (candidate.getDay() === firstDayOfWeekIndex) { + return candidate; + } + } + + throw new RangeError('Could not find a representable week start within two weeks.'); +} + +/** + * Helper function for `getWeekNumber`. + * Returns week number for a date. + * @param date - current selected date. + * @param firstDayOfWeek - The first day of week (0-6, Sunday = 0) + * @param numberOfFullDays - week settings. + * @returns The week's number in the year. + */ +function getWeekOfYearFullDays(date: Date, firstDayOfWeek: DayOfWeek, numberOfFullDays: number): number { + const firstDayOfWeekIndex = getDayIndex(firstDayOfWeek); + const dayOfYear = getDayOfYear(date) - 1; + let num = date.getDay() - (dayOfYear % DAYS_IN_WEEK); + + const lastDayOfPrevYear = createDate(date.getFullYear() - 1, getMonthIndex('december'), 31); + const daysInYear = getDayOfYear(lastDayOfPrevYear) - 1; + + let num2 = (firstDayOfWeekIndex - num + 2 * DAYS_IN_WEEK) % DAYS_IN_WEEK; + if (num2 !== 0 && num2 >= numberOfFullDays) { + num2 -= DAYS_IN_WEEK; + } + + let num3 = dayOfYear - num2; + if (num3 < 0) { + num -= daysInYear % DAYS_IN_WEEK; + num2 = (firstDayOfWeekIndex - num + 2 * DAYS_IN_WEEK) % DAYS_IN_WEEK; + if (num2 !== 0 && num2 + 1 >= numberOfFullDays) { + num2 -= DAYS_IN_WEEK; + } + + num3 = daysInYear - num2; + } + + const nextYearFirstWeekStart = getStartDateOfWeek(createDate(date.getFullYear() + 1, 0, 1), firstDayOfWeek); + const nextYearFirstWeekDays = Array.from({ length: DAYS_IN_WEEK }, (_, index) => + addDays(nextYearFirstWeekStart, index), + ).filter(nextYearDate => nextYearDate.getFullYear() === date.getFullYear() + 1).length; + if (nextYearFirstWeekDays >= numberOfFullDays && compareDatePart(date, nextYearFirstWeekStart) >= 0) { + return 1; + } + + return Math.floor(num3 / DAYS_IN_WEEK + 1); +} + +/** + * Helper function for `getWeekNumber`. + * Returns week number for a date. + * @param date - current selected date. + * @param firstDayOfWeek - The first day of week (0-6, Sunday = 0) + * @returns The week's number in the year. + */ +function getFirstDayWeekOfYear(date: Date, firstDayOfWeek: DayOfWeek): number { + const num = getDayOfYear(date) - 1; + const num2 = date.getDay() - (num % DAYS_IN_WEEK); + const num3 = (num2 - getDayIndex(firstDayOfWeek) + 2 * DAYS_IN_WEEK) % DAYS_IN_WEEK; + + return Math.floor((num + num3) / DAYS_IN_WEEK + 1); +} + +/** + * Helper function for `getWeekNumber`. + * Returns adjusted week day number when `firstDayOfWeek` is other than Sunday. + * For Week Day Number comparison checks + * @param firstDayOfWeekIndex - The first day of week (0-6, Sunday = 0) + * @param dateWeekDay - shifts number forward to 1 week in case passed as true + * @returns The day of week adjusted to `firstDayOfWeek`; e.g. when `firstDayOfWeek` is Monday (1), + * Sunday becomes 7. + */ +function adjustWeekDay(firstDayOfWeekIndex: number, dateWeekDay: number): number { + return firstDayOfWeekIndex !== 0 && dateWeekDay < firstDayOfWeekIndex ? dateWeekDay + DAYS_IN_WEEK : dateWeekDay; +} + +/** + * Returns the day number for a date in a year: + * the number of days since January 1st in the particular year. + * @param date - A date to find the day number for. + * @returns The day's number in the year. + */ +function getDayOfYear(date: Date): number { + const month = date.getMonth(); + const year = date.getFullYear(); + let daysUntilDate = 0; + + for (let i = 0; i < month; i++) { + daysUntilDate += daysInMonth(i + 1, year); + } + + daysUntilDate += date.getDate(); + + return daysUntilDate; +} + +/** + * Returns the number of days in the month + * @param month - The month number to target (months 1-12). + * @param year - The year to target. + * @returns The number of days in the month. + */ +function daysInMonth(month: number, year: number): number { + return createDate(year, month, 0).getDate(); +} diff --git a/packages/react-components/react-calendar-preview/library/src/utils/dateUtils.test.ts b/packages/react-components/react-calendar-preview/library/src/utils/dateUtils.test.ts new file mode 100644 index 0000000000000..1356bb06ae9bc --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/dateUtils.test.ts @@ -0,0 +1,33 @@ +import { DAYS_IN_WEEK, daysOfWeek, monthsOfYear } from './constants'; +import { getDayIndex, getDayFromIndex, getMonthIndex } from './dateUtils'; + +describe('calendar constants', () => { + it('orders weekdays to match local Date indices', () => { + expect(daysOfWeek).toHaveLength(DAYS_IN_WEEK); + daysOfWeek.forEach((day, index) => { + expect(getDayIndex(day)).toBe(new Date(2020, 8, 6 + index).getDay()); + expect(getDayFromIndex(index)).toBe(day); + }); + }); + + it.each([ + [-15, 'saturday'], + [-7, 'sunday'], + [-1, 'saturday'], + [7, 'sunday'], + [15, 'monday'], + ] as const)('wraps weekday index %s to %s', (index, expected) => { + expect(getDayFromIndex(index)).toBe(expected); + }); + + it.each([NaN, Infinity, -Infinity, 1.5])('rejects an invalid weekday index of %s', index => { + expect(() => getDayFromIndex(index)).toThrow(new RangeError('index must be a finite integer.')); + }); + + it('orders months to match local Date indices', () => { + expect(monthsOfYear).toHaveLength(12); + monthsOfYear.forEach((month, index) => { + expect(getMonthIndex(month)).toBe(new Date(2020, index, 1).getMonth()); + }); + }); +}); diff --git a/packages/react-components/react-calendar-preview/library/src/utils/dateUtils.ts b/packages/react-components/react-calendar-preview/library/src/utils/dateUtils.ts new file mode 100644 index 0000000000000..e5255d666d2d7 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/dateUtils.ts @@ -0,0 +1,27 @@ +import { DAYS_IN_WEEK, daysOfWeek, monthsOfYear } from './constants'; +import type { DayOfWeek, MonthOfYear } from './constants'; + +/** + * Converts a day of the week to the index used by `Date.prototype.getDay()`. + */ +export function getDayIndex(day: DayOfWeek): number { + return daysOfWeek.indexOf(day); +} + +/** + * Converts an index used by `Date.prototype.getDay()` to a day of the week, wrapping out-of-range values. + */ +export function getDayFromIndex(index: number): DayOfWeek { + if (!Number.isFinite(index) || !Number.isInteger(index)) { + throw new RangeError('index must be a finite integer.'); + } + + return daysOfWeek[((index % DAYS_IN_WEEK) + DAYS_IN_WEEK) % DAYS_IN_WEEK]; +} + +/** + * Converts a month to the index used by `Date.prototype.getMonth()`. + */ +export function getMonthIndex(month: MonthOfYear): number { + return monthsOfYear.indexOf(month); +} diff --git a/packages/react-components/react-calendar-preview/library/src/utils/focus.test.ts b/packages/react-components/react-calendar-preview/library/src/utils/focus.test.ts new file mode 100644 index 0000000000000..6e8a7138778c3 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/focus.test.ts @@ -0,0 +1,129 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useFluent_unstable } from '@fluentui/react-shared-contexts'; +import { focusAsync } from './focus'; + +describe('focusAsync', () => { + const getWindow = () => { + const { result, unmount } = renderHook(() => useFluent_unstable()); + const win = result.current.targetDocument?.defaultView; + unmount(); + if (!win) { + throw new Error('The focus tests require a DOM window.'); + } + return win; + }; + + beforeEach(() => jest.useFakeTimers()); + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('defers focus until the next animation frame', () => { + const element = { focus: jest.fn() }; + focusAsync(element, getWindow()); + expect(element.focus).not.toHaveBeenCalled(); + jest.advanceTimersToNextFrame(); + expect(element.focus).toHaveBeenCalledTimes(1); + }); + + it('schedules one frame and focuses only the latest target', () => { + const win = getWindow(); + const requestFrame = jest.spyOn(win, 'requestAnimationFrame'); + const first = { focus: jest.fn() }; + const latest = { focus: jest.fn() }; + focusAsync(first, win); + focusAsync(latest, win); + expect(requestFrame).toHaveBeenCalledTimes(1); + expect(latest.focus).not.toHaveBeenCalled(); + jest.advanceTimersToNextFrame(); + expect(first.focus).not.toHaveBeenCalled(); + expect(latest.focus).toHaveBeenCalledTimes(1); + }); + + it('schedules focus independently for each window', () => { + const createWindow = () => { + let callback: FrameRequestCallback | undefined; + return { + requestAnimationFrame: jest.fn(nextCallback => { + callback = nextCallback; + return 0; + }), + runFrame: () => callback?.(0), + }; + }; + const firstWindow = createWindow(); + const secondWindow = createWindow(); + const first = { focus: jest.fn() }; + const second = { focus: jest.fn() }; + + focusAsync(first, firstWindow); + focusAsync(second, secondWindow); + + expect(firstWindow.requestAnimationFrame).toHaveBeenCalledTimes(1); + expect(secondWindow.requestAnimationFrame).toHaveBeenCalledTimes(1); + secondWindow.runFrame(); + expect(second.focus).toHaveBeenCalledTimes(1); + expect(first.focus).not.toHaveBeenCalled(); + firstWindow.runFrame(); + expect(first.focus).toHaveBeenCalledTimes(1); + }); + + it('can schedule another target after the previous frame', () => { + const win = getWindow(); + const first = { focus: jest.fn() }; + const second = { focus: jest.fn() }; + focusAsync(first, win); + jest.advanceTimersToNextFrame(); + focusAsync(second, win); + expect(second.focus).not.toHaveBeenCalled(); + jest.advanceTimersToNextFrame(); + expect(first.focus).toHaveBeenCalledTimes(1); + expect(second.focus).toHaveBeenCalledTimes(1); + }); + + it('allows a focus handler to queue the next target', () => { + const win = getWindow(); + const next = { focus: jest.fn() }; + const first = { + focus: jest.fn(() => { + focusAsync(next, win); + }), + }; + + focusAsync(first, win); + jest.advanceTimersToNextFrame(); + expect(first.focus).toHaveBeenCalledTimes(1); + expect(next.focus).not.toHaveBeenCalled(); + jest.advanceTimersToNextFrame(); + expect(next.focus).toHaveBeenCalledTimes(1); + }); + + it('clears the queue when focus throws', () => { + const win = getWindow(); + const first = { + focus: jest.fn(() => { + throw new Error('focus failed'); + }), + }; + const next = { focus: jest.fn() }; + + focusAsync(first, win); + expect(() => jest.advanceTimersToNextFrame()).toThrow('focus failed'); + focusAsync(next, win); + jest.advanceTimersToNextFrame(); + expect(next.focus).toHaveBeenCalledTimes(1); + }); + + it.each([null, undefined])('ignores a missing target or window (%s)', missing => { + const win = getWindow(); + const requestFrame = jest.spyOn(win, 'requestAnimationFrame'); + const element = { focus: jest.fn() }; + focusAsync(missing, win); + focusAsync(element, missing); + expect(requestFrame).not.toHaveBeenCalled(); + jest.advanceTimersToNextFrame(); + expect(element.focus).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-components/react-calendar-preview/library/src/utils/focus.ts b/packages/react-components/react-calendar-preview/library/src/utils/focus.ts new file mode 100644 index 0000000000000..1aa670ae66bb7 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/focus.ts @@ -0,0 +1,29 @@ +type FocusTarget = HTMLElement | { focus: () => void }; +type FocusWindow = Pick; + +const targetsToFocusOnNextRepaint = new WeakMap(); + +/** + * Sets focus to an element asynchronously. The focus will be set at the next browser repaint, + * meaning it won't cause any extra recalculations. If more than one focusAsync is called during one frame, + * only the latest called focusAsync element will actually be focused + * @param element - The element to focus + */ +export function focusAsync(element: FocusTarget | undefined | null, win: FocusWindow | undefined | null): void { + if (element && win) { + // An element was already queued to be focused, so replace that one with the new element + if (targetsToFocusOnNextRepaint.has(win)) { + targetsToFocusOnNextRepaint.set(win, element); + return; + } + + targetsToFocusOnNextRepaint.set(win, element); + + // element.focus() is a no-op if the element is no longer in the DOM, meaning this is always safe + win.requestAnimationFrame(() => { + const target = targetsToFocusOnNextRepaint.get(win); + targetsToFocusOnNextRepaint.delete(win); + target?.focus(); + }); + } +} diff --git a/packages/react-components/react-calendar-preview/library/src/utils/formatters.test.ts b/packages/react-components/react-calendar-preview/library/src/utils/formatters.test.ts new file mode 100644 index 0000000000000..d2f31948f8466 --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/formatters.test.ts @@ -0,0 +1,89 @@ +import { getMonthIndex } from './dateUtils'; +import { calendarFormatters, createCalendarDateTimeFormatter } from './formatters'; + +const date = new Date(2016, getMonthIndex('april'), 1); + +describe('createCalendarDateTimeFormatter', () => { + it('supports a locale preference list', () => { + expect(createCalendarDateTimeFormatter(['en-GB', 'en-US'])({ date, format: 'monthDayYear' })).toBe('1 April 2016'); + }); + + it('surfaces invalid locale and date errors', () => { + expect(() => createCalendarDateTimeFormatter('invalid_locale')).toThrow(RangeError); + expect(() => createCalendarDateTimeFormatter()({ date: new Date(NaN), format: 'day' })).toThrow(RangeError); + }); + + it.each(['monthDayYear', 'dayMonthYear'] as const)('uses locale ordering for %s', format => { + const formatter = createCalendarDateTimeFormatter('en-GB'); + + expect(formatter({ date, format })).toBe('1 April 2016'); + }); + + it('localizes month and weekday names', () => { + const formatter = createCalendarDateTimeFormatter('de-DE'); + + expect(formatter({ date, format: 'weekday' })).toBe('Freitag'); + expect(formatter({ date, format: 'monthDayYear' })).toBe('1. April 2016'); + }); + + it('always formats Gregorian calendar dates', () => { + const formatter = createCalendarDateTimeFormatter('ar-SA'); + expect(formatter({ date, format: 'monthDayYear' })).toBe( + new Intl.DateTimeFormat('ar-SA', { calendar: 'gregory', day: 'numeric', month: 'long', year: 'numeric' }).format( + date, + ), + ); + }); + + it('supports locale numbering-system extensions', () => { + const formatter = createCalendarDateTimeFormatter('en-US-u-nu-arab'); + + expect(formatter({ date, format: 'year' })).toBe( + new Intl.DateTimeFormat('en-US-u-nu-arab', { year: 'numeric' }).format(date), + ); + }); +}); + +describe('defaultCalendarFormatters', () => { + it.each([ + ['day', '1'], + ['month', 'April'], + ['shortMonth', 'Apr'], + ['year', '2016'], + ['monthDayYear', 'April 1, 2016'], + ['dayMonthYear', 'April 1, 2016'], + ['monthYear', 'April 2016'], + ['weekday', 'Friday'], + ['shortWeekday', 'F'], + ] as const)('formats %s', (format, expected) => { + expect(calendarFormatters.dateTime({ date, format })).toBe(expected); + }); + + const formattedDate = 'April 2016'; + const dateData = { date, formattedDate }; + + it.each([ + ['previousMonthLabel', 'Previous month April 2016'], + ['nextMonthLabel', 'Next month April 2016'], + ['previousYearLabel', 'Previous year April 2016'], + ['nextYearLabel', 'Next year April 2016'], + ['monthPickerHeaderLabel', 'April 2016, change year'], + ['yearPickerHeaderLabel', 'April 2016, change month'], + ['selectedDateLabel', 'Selected date April 2016'], + ['todayDateLabel', "Today's date April 2016"], + ['dayMarkedLabel', 'April 2016, marked'], + ] as const)('formats %s', (formatter, expected) => { + expect(calendarFormatters[formatter](dateData)).toBe(expected); + }); + + it('formats week numbers', () => { + expect(calendarFormatters.weekNumberLabel({ weekNumber: 14 })).toBe('Week number 14'); + }); + + it('formats year ranges', () => { + const range = { fromYear: 2025, toYear: 2036, formattedRange: '2025 - 2036' }; + expect(calendarFormatters.previousYearRangeLabel(range)).toBe('Previous year range 2025 - 2036'); + expect(calendarFormatters.nextYearRangeLabel(range)).toBe('Next year range 2025 - 2036'); + expect(calendarFormatters.yearRangePickerHeaderLabel(range)).toBe('2025 - 2036, change year'); + }); +}); diff --git a/packages/react-components/react-calendar-preview/library/src/utils/formatters.ts b/packages/react-components/react-calendar-preview/library/src/utils/formatters.ts new file mode 100644 index 0000000000000..94d136603dd9d --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/formatters.ts @@ -0,0 +1,162 @@ +/** + * Supported date and time display formats. + */ +export type CalendarDateTimeFormat = + | 'day' + | 'month' + | 'shortMonth' + | 'year' + | 'monthDayYear' + | 'dayMonthYear' + | 'monthYear' + | 'weekday' + | 'shortWeekday'; + +/** + * A date and its formatted display value. + */ +export type CalendarDateLabelData = { + /** + * The actual date object. + */ + date: Date; + /** + * The formatted display value of the date. + */ + formattedDate: string; +}; + +/** + * A year range and its formatted display value. + */ +export type CalendarYearRangeLabelData = { + /** + * The starting year of the range. + */ + fromYear: number; + /** + * The ending year of the range. + */ + toYear: number; + /** + * The formatted display value of the year range. + */ + formattedRange: string; +}; + +/** + * Formatters used for calendar display values and accessibility labels. + */ +export type CalendarFormatters = { + /** + * Formats a date according to the specified format. + */ + dateTime: (data: { date: Date; format: CalendarDateTimeFormat }) => string; + + /** + * Formats the label for the previous month button. + */ + previousMonthLabel: (data: CalendarDateLabelData) => string; + + /** + * Formats the label for the next month button. + */ + nextMonthLabel: (data: CalendarDateLabelData) => string; + + /** + * Formats the label for the previous year button. + */ + previousYearLabel: (data: CalendarDateLabelData) => string; + /** + * Formats the label for the next year button. + */ + nextYearLabel: (data: CalendarDateLabelData) => string; + /** + * Formats the label for the previous year range button. + */ + previousYearRangeLabel: (data: CalendarYearRangeLabelData) => string; + /** + * Formats the label for the next year range button. + */ + nextYearRangeLabel: (data: CalendarYearRangeLabelData) => string; + /** + * Formats the label for the month picker header. + */ + monthPickerHeaderLabel: (data: CalendarDateLabelData) => string; + /** + * Formats the label for the year picker header. + */ + yearPickerHeaderLabel: (data: CalendarDateLabelData) => string; + /** + * Formats the label for the year range picker header. + */ + yearRangePickerHeaderLabel: (data: CalendarYearRangeLabelData) => string; + /** + * Formats the label for the week number column header. + */ + weekNumberLabel: (data: { weekNumber: number }) => string; + + /** + * Formats the label for the selected date. + */ + selectedDateLabel: (data: CalendarDateLabelData) => string; + /** + * Formats the label for today's date. + */ + todayDateLabel: (data: CalendarDateLabelData) => string; + + /** + * Formats the label for a marked day. + */ + dayMarkedLabel: (data: CalendarDateLabelData) => string; +}; + +const dateTimeFormatters = { + day: { day: 'numeric' }, + month: { month: 'long' }, + shortMonth: { month: 'short' }, + year: { year: 'numeric' }, + monthDayYear: { day: 'numeric', month: 'long', year: 'numeric' }, + dayMonthYear: { day: 'numeric', month: 'long', year: 'numeric' }, + monthYear: { month: 'long', year: 'numeric' }, + weekday: { weekday: 'long' }, + shortWeekday: { weekday: 'narrow' }, +} satisfies Record; + +/** + * Creates reusable Intl formatters for every calendar date format. Full dates follow locale-specific + * field ordering, so `monthDayYear` and `dayMonthYear` produce the same locale-appropriate label. + */ +export function createCalendarDateTimeFormatter(locales: string | string[] = 'en-US'): CalendarFormatters['dateTime'] { + const formatters = Object.fromEntries( + Object.entries(dateTimeFormatters).map(([key, fields]) => [ + key, + new Intl.DateTimeFormat(locales, { + ...fields, + ...({ calendar: 'gregory' } as Intl.DateTimeFormatOptions), + }), + ]), + ); + + return data => formatters[data.format].format(data.date); +} + +/** + * Default calendar formatters. + */ +export const calendarFormatters: CalendarFormatters = { + dateTime: createCalendarDateTimeFormatter(), + previousMonthLabel: data => `Previous month ${data.formattedDate}`, + nextMonthLabel: data => `Next month ${data.formattedDate}`, + previousYearLabel: data => `Previous year ${data.formattedDate}`, + nextYearLabel: data => `Next year ${data.formattedDate}`, + previousYearRangeLabel: data => `Previous year range ${data.formattedRange}`, + nextYearRangeLabel: data => `Next year range ${data.formattedRange}`, + monthPickerHeaderLabel: data => `${data.formattedDate}, change year`, + yearPickerHeaderLabel: data => `${data.formattedDate}, change month`, + yearRangePickerHeaderLabel: data => `${data.formattedRange}, change year`, + weekNumberLabel: data => `Week number ${data.weekNumber}`, + selectedDateLabel: data => `Selected date ${data.formattedDate}`, + todayDateLabel: data => `Today's date ${data.formattedDate}`, + dayMarkedLabel: data => `${data.formattedDate}, marked`, +}; diff --git a/packages/react-components/react-calendar-preview/library/src/utils/index.ts b/packages/react-components/react-calendar-preview/library/src/utils/index.ts new file mode 100644 index 0000000000000..ae5b9ea5ae52c --- /dev/null +++ b/packages/react-components/react-calendar-preview/library/src/utils/index.ts @@ -0,0 +1,31 @@ +export { stringifyDataAttribute } from './dataAttributes'; +export { DAYS_IN_WEEK } from './constants'; +export { getDayFromIndex, getDayIndex, getMonthIndex } from './dateUtils'; +export type { AnimationDirection, DateRangeType, DayOfWeek, FirstWeekOfYear, MonthOfYear } from './constants'; +export type { + CalendarDateLabelData, + CalendarDateTimeFormat, + CalendarFormatters, + CalendarYearRangeLabelData, +} from './formatters'; +export { calendarFormatters, createCalendarDateTimeFormatter } from './formatters'; +export type { AvailableDateOptions, Day, DayGridOptions, RestrictedDatesOptions } from './dateGrid'; +export { findAvailableDate, getBoundedDateRange, getDayGrid, isRestrictedDate } from './dateGrid'; +export { + addDays, + addMonths, + addWeeks, + addYears, + compareDatePart, + getDateRange, + getMonthEnd, + getMonthStart, + getStartDateOfWeek, + getWeekNumber, + getWeekNumbersInMonth, + getYearEnd, + getYearStart, + isDateInRange, + setMonth, +} from './dateMath'; +export { focusAsync } from './focus';