Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<typeof import('../dateMath')>('../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([]);
});
});
Original file line number Diff line number Diff line change
@@ -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 => {
Comment thread
dmytrokirpa marked this conversation as resolved.
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;
};
Loading
Loading