diff --git a/packages/date-utils/src/index.ts b/packages/date-utils/src/index.ts index 57c6a0eaeb..9b398f6b5a 100644 --- a/packages/date-utils/src/index.ts +++ b/packages/date-utils/src/index.ts @@ -22,6 +22,7 @@ export { isOnOrBefore } from './isOnOrBefore'; export { isSameTZDay } from './isSameTZDay'; export { isSameTZMonth } from './isSameTZMonth'; export { isSameUTCDay } from './isSameUTCDay'; +export { isSameUTCDayAndTime } from './isSameUTCDayAndTime'; export { isSameUTCMonth } from './isSameUTCMonth'; export { isSameUTCRange } from './isSameUTCRange'; export { isTodayTZ } from './isTodayTZ'; @@ -31,6 +32,7 @@ export { maxDate } from './maxDate'; export { minDate } from './minDate'; export { newTZDate } from './newTZDate'; export { newUTC } from './newUTC'; +export { newUTCFromTimeZone } from './newUTCFromTimeZone'; export { setToUTCMidnight } from './setToUTCMidnight'; export { setUTCDate } from './setUTCDate'; export { setUTCMonth } from './setUTCMonth'; diff --git a/packages/date-utils/src/isSameUTCDayAndTime/index.ts b/packages/date-utils/src/isSameUTCDayAndTime/index.ts new file mode 100644 index 0000000000..4e30a2babb --- /dev/null +++ b/packages/date-utils/src/isSameUTCDayAndTime/index.ts @@ -0,0 +1 @@ +export { isSameUTCDayAndTime } from './isSameUTCDayAndTime'; diff --git a/packages/date-utils/src/isSameUTCDayAndTime/isSameUTCDayAndTime.spec.ts b/packages/date-utils/src/isSameUTCDayAndTime/isSameUTCDayAndTime.spec.ts new file mode 100644 index 0000000000..c1889f66c8 --- /dev/null +++ b/packages/date-utils/src/isSameUTCDayAndTime/isSameUTCDayAndTime.spec.ts @@ -0,0 +1,27 @@ +import { isSameUTCDayAndTime } from './isSameUTCDayAndTime'; + +describe('packages/time-input/utils/isSameUTCDayAndTime', () => { + test('returns true if the two dates are the same day and time in UTC', () => { + const date1 = new Date('2025-01-01T12:00:00Z'); + const date2 = new Date('2025-01-01T12:00:00Z'); + expect(isSameUTCDayAndTime(date1, date2)).toBe(true); + }); + + test('returns false if the two dates are not the same day in UTC', () => { + const date1 = new Date('2025-01-01T12:00:00Z'); + const date2 = new Date('2025-01-02T12:00:00Z'); + expect(isSameUTCDayAndTime(date1, date2)).toBe(false); + }); + + test('returns false if the two dates are not the same time in UTC', () => { + const date1 = new Date('2025-01-01T12:00:00Z'); + const date2 = new Date('2025-01-01T12:00:01Z'); + expect(isSameUTCDayAndTime(date1, date2)).toBe(false); + }); + + test('returns false if the two dates are not the same date and time in UTC', () => { + const date1 = new Date('2025-02-01T12:00:00Z'); + const date2 = new Date('2025-01-01T12:00:01Z'); + expect(isSameUTCDayAndTime(date1, date2)).toBe(false); + }); +}); diff --git a/packages/date-utils/src/isSameUTCDayAndTime/isSameUTCDayAndTime.ts b/packages/date-utils/src/isSameUTCDayAndTime/isSameUTCDayAndTime.ts new file mode 100644 index 0000000000..5314416b18 --- /dev/null +++ b/packages/date-utils/src/isSameUTCDayAndTime/isSameUTCDayAndTime.ts @@ -0,0 +1,25 @@ +import { isValidDate } from '../isValidDate'; +import { DateType } from '../types'; + +/** + * Checks if two dates are the same day and time in UTC. + * + * @param day1 - The first date to check + * @param day2 - The second date to check + * @returns Whether the two dates are the same day and time in UTC + */ +export const isSameUTCDayAndTime = ( + day1?: DateType, + day2?: DateType, +): boolean => { + if (!isValidDate(day1) || !isValidDate(day2)) return false; + + return ( + day1.getUTCDate() === day2.getUTCDate() && + day1.getUTCMonth() === day2.getUTCMonth() && + day1.getUTCFullYear() === day2.getUTCFullYear() && + day1.getUTCHours() === day2.getUTCHours() && + day1.getUTCMinutes() === day2.getUTCMinutes() && + day1.getUTCSeconds() === day2.getUTCSeconds() + ); +}; diff --git a/packages/date-utils/src/newUTCFromTimeZone/index.ts b/packages/date-utils/src/newUTCFromTimeZone/index.ts new file mode 100644 index 0000000000..6e1f0a30fd --- /dev/null +++ b/packages/date-utils/src/newUTCFromTimeZone/index.ts @@ -0,0 +1 @@ +export { newUTCFromTimeZone } from './newUTCFromTimeZone'; diff --git a/packages/date-utils/src/newUTCFromTimeZone/newUTCFromTimeZone.spec.ts b/packages/date-utils/src/newUTCFromTimeZone/newUTCFromTimeZone.spec.ts new file mode 100644 index 0000000000..7a1e9a0f24 --- /dev/null +++ b/packages/date-utils/src/newUTCFromTimeZone/newUTCFromTimeZone.spec.ts @@ -0,0 +1,54 @@ +import { newUTCFromTimeZone } from './newUTCFromTimeZone'; + +describe('packages/date-utils/newUTCFromTimeZone', () => { + describe('UTC', () => { + test('creates a new UTC date from a given time zone', () => { + const date = newUTCFromTimeZone({ + year: '2026', + month: '02', + day: '20', + hour: '23', + minute: '00', + second: '00', + timeZone: 'UTC', + }); + + // February 20, 2026 11:00:00 PM/23:00:00 in UTC is February 20, 2026 23:00:00 UTC + expect(date).toEqual(new Date('2026-02-20T23:00:00Z')); + }); + }); + + describe('America/New_York', () => { + test('creates a new UTC date from a given time zone', () => { + const date = newUTCFromTimeZone({ + year: '2026', + month: '02', + day: '20', + hour: '23', + minute: '00', + second: '00', + timeZone: 'America/New_York', + }); + + // February 20, 2026 11:00:00 PM/23:00:00 in America/New_York is February 21, 2026 04:00:00 UTC (UTC-5 hours) + expect(date).toEqual(new Date('2026-02-21T04:00:00Z')); + }); + }); + + describe('Pacific/Kiritimati', () => { + test('creates a new UTC date from a given time zone', () => { + const date = newUTCFromTimeZone({ + year: '2026', + month: '02', + day: '20', + hour: '23', + minute: '00', + second: '00', + timeZone: 'Pacific/Kiritimati', + }); + + // February 20, 2026 11:00:00 PM/23:00:00 in Pacific/Kiritimati is February 20, 2026 09:00:00 UTC (UTC+14 hours) + expect(date).toEqual(new Date('2026-02-20T09:00:00Z')); + }); + }); +}); diff --git a/packages/date-utils/src/newUTCFromTimeZone/newUTCFromTimeZone.ts b/packages/date-utils/src/newUTCFromTimeZone/newUTCFromTimeZone.ts new file mode 100644 index 0000000000..f1f722b8aa --- /dev/null +++ b/packages/date-utils/src/newUTCFromTimeZone/newUTCFromTimeZone.ts @@ -0,0 +1,50 @@ +import { zonedTimeToUtc } from 'date-fns-tz'; + +/** + * Creates a new UTC date from a given time zone. + * This takes the local date created above and converts it to UTC using the `zonedTimeToUtc` helper function. + * + * @param year - The year + * @param month - The month (1-12) + * @param day - The day + * @param hour - The hour in 24 hour format + * @param minute - The minute + * @param second - The second + * @param timeZone - The time zone + * @returns The new UTC date + * + * @example + * ```js + * // February 20, 2026 11:00:00 PM/23:00:00 in America/New_York is February 21, 2026 04:00:00 UTC + * newUTCFromTimeZone({ year: '2026', month: '02', day: '20', hour: '11', minute: '00', second: '00', timeZone: 'America/New_York' }); + * // returns new Date('2026-02-21T04:00:00Z') + * ``` + */ +export const newUTCFromTimeZone = ({ + year, + month, + day, + hour, + minute, + second, + timeZone, +}: { + year: string; + month: string; + day: string; + hour: string; + minute: string; + second: string; + timeZone: string; +}) => { + const newDate = new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second), + ); + + return zonedTimeToUtc(newDate, timeZone); +}; diff --git a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx index c00eadebef..9f3fa0de82 100644 --- a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx +++ b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx @@ -3,7 +3,7 @@ import React, { forwardRef } from 'react'; import { unitOptions } from '../constants'; import { useTimeInputContext, useTimeInputDisplayContext } from '../Context'; import { useSelectUnit } from '../hooks'; -import { TimeSegmentsState } from '../shared.types'; +import { DayPeriod, TimeSegmentsState } from '../shared.types'; import { TimeFormField, TimeFormFieldInputContainer } from '../TimeFormField'; import { TimeInputBox } from '../TimeInputBox/TimeInputBox'; import { TimeInputSelect } from '../TimeInputSelect/TimeInputSelect'; @@ -51,7 +51,7 @@ export const TimeInputInputs = forwardRef( * // TODO: This is temp and will be replaced in the next PR */ const { selectUnit, setSelectUnit } = useSelectUnit({ - dayPeriod: timeParts.dayPeriod, + dayPeriod: timeParts.dayPeriod as DayPeriod, value, unitOptions, }); diff --git a/packages/time-input/src/hooks/useSelectUnit/useSelectUnit.ts b/packages/time-input/src/hooks/useSelectUnit/useSelectUnit.ts index fa4ab18565..f02c4e7430 100644 --- a/packages/time-input/src/hooks/useSelectUnit/useSelectUnit.ts +++ b/packages/time-input/src/hooks/useSelectUnit/useSelectUnit.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { DateType, isValidDate } from '@leafygreen-ui/date-utils'; +import { DayPeriod } from '../../shared.types'; import { UnitOption } from '../../TimeInputSelect/TimeInputSelect.types'; interface UseSelectUnitReturn { @@ -17,7 +18,7 @@ interface UseSelectUnitReturn { * @returns The select unit option. */ const findSelectUnit = ( - dayPeriod: string, + dayPeriod: DayPeriod, unitOptions: Array, ): UnitOption => { const selectUnitOption = unitOptions.find( @@ -39,7 +40,7 @@ export const useSelectUnit = ({ value, unitOptions, }: { - dayPeriod: string; + dayPeriod: DayPeriod; value: DateType | undefined; unitOptions: Array; }): UseSelectUnitReturn => { diff --git a/packages/time-input/src/shared.types.ts b/packages/time-input/src/shared.types.ts index f88e4cfc3d..efa23370c5 100644 --- a/packages/time-input/src/shared.types.ts +++ b/packages/time-input/src/shared.types.ts @@ -29,6 +29,16 @@ export type TimeSegment = (typeof TimeSegment)[keyof typeof TimeSegment]; export type TimeSegmentsState = Record; +/* + * An enumerable object that maps the day period names to their values + */ +export const DayPeriod = { + AM: 'AM', + PM: 'PM', +} as const; + +export type DayPeriod = (typeof DayPeriod)[keyof typeof DayPeriod]; + /** * The type for the time input segment change event */ diff --git a/packages/time-input/src/utils/convert12hTo24h/convert12hTo24h.spec.ts b/packages/time-input/src/utils/convert12hTo24h/convert12hTo24h.spec.ts new file mode 100644 index 0000000000..8105454dd3 --- /dev/null +++ b/packages/time-input/src/utils/convert12hTo24h/convert12hTo24h.spec.ts @@ -0,0 +1,57 @@ +import range from 'lodash/range'; + +import { consoleOnce } from '@leafygreen-ui/lib'; + +import { convert12hTo24h } from './convert12hTo24h'; + +describe('convert12hTo24h', () => { + describe('AM', () => { + test('12 AM converts to 0', () => { + expect(convert12hTo24h(12, 'AM')).toEqual(0); + }); + + test.each(range(1, 12).map(i => [i, i]))( + '%i AM converts to %i', + (input, expected) => { + expect(convert12hTo24h(input, 'AM')).toEqual(expected); + }, + ); + }); + + describe('PM', () => { + test('12 PM converts to 12', () => { + expect(convert12hTo24h(12, 'PM')).toEqual(12); + }); + + test.each(range(1, 12).map(i => [i, i + 12]))( + '%i PM converts to %i', + (input, expected) => { + expect(convert12hTo24h(input, 'PM')).toEqual(expected); + }, + ); + }); + + describe('Invalid hour', () => { + test('less than 1 returns the hour', () => { + const consoleWarnSpy = jest + .spyOn(consoleOnce, 'warn') + .mockImplementation(() => {}); + expect(convert12hTo24h(0, 'AM')).toEqual(0); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'convert12hTo24h > Invalid hour: 0', + ); + consoleWarnSpy.mockRestore(); + }); + + test('greater than 12 returns the hour', () => { + const consoleWarnSpy = jest + .spyOn(consoleOnce, 'warn') + .mockImplementation(() => {}); + expect(convert12hTo24h(13, 'AM')).toEqual(13); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'convert12hTo24h > Invalid hour: 13', + ); + consoleWarnSpy.mockRestore(); + }); + }); +}); diff --git a/packages/time-input/src/utils/convert12hTo24h/convert12hTo24h.ts b/packages/time-input/src/utils/convert12hTo24h/convert12hTo24h.ts new file mode 100644 index 0000000000..1b1d38f9a4 --- /dev/null +++ b/packages/time-input/src/utils/convert12hTo24h/convert12hTo24h.ts @@ -0,0 +1,40 @@ +import { consoleOnce } from '@leafygreen-ui/lib'; + +import { DayPeriod } from '../../shared.types'; + +/** + * Converts a 12 hour format hour to a 24 hour format hour + * + * @param hour - The hour to convert + * @param dayPeriod - The day period to use for the conversion (AM or PM) + * @returns The converted hour or the original hour if it is invalid + * + * @example + * ```js + * convert12hTo24h(12, 'AM'); // 0 + * convert12hTo24h(12, 'PM'); // 12 + * convert12hTo24h(1, 'AM'); // 1 + * convert12hTo24h(1, 'PM'); // 13 + * convert12hTo24h(0, 'AM'); // 0 + * convert12hTo24h(13, 'AM'); // 13 + * ``` + */ +export const convert12hTo24h = (hour: number, dayPeriod: DayPeriod): number => { + if (hour < 1 || hour > 12) { + consoleOnce.warn(`convert12hTo24h > Invalid hour: ${hour}`); + return hour; + } + + if (hour === 12) { + // 12AM -> 0:00 + // 12PM -> 12:00 + return dayPeriod === DayPeriod.AM ? 0 : 12; + } + + // if dayPeriod is PM, return hour + 12 + if (dayPeriod === DayPeriod.PM) { + return hour + 12; + } + + return hour; +}; diff --git a/packages/time-input/src/utils/doesSomeSegmentExist/doesSomeSegmentExist.spec.ts b/packages/time-input/src/utils/doesSomeSegmentExist/doesSomeSegmentExist.spec.ts new file mode 100644 index 0000000000..a23eba4d27 --- /dev/null +++ b/packages/time-input/src/utils/doesSomeSegmentExist/doesSomeSegmentExist.spec.ts @@ -0,0 +1,21 @@ +import { doesSomeSegmentExist } from './doesSomeSegmentExist'; + +describe('doesSomeSegmentExist', () => { + test('returns true if at least one segment is filled', () => { + expect(doesSomeSegmentExist({ hour: '', minute: '', second: '00' })).toBe( + true, + ); + }); + + test('returns true if all segments are filled', () => { + expect( + doesSomeSegmentExist({ hour: '12', minute: '00', second: '00' }), + ).toBe(true); + }); + + test('returns false if no segments are filled', () => { + expect(doesSomeSegmentExist({ hour: '', minute: '', second: '' })).toBe( + false, + ); + }); +}); diff --git a/packages/time-input/src/utils/doesSomeSegmentExist/doesSomeSegmentExist.ts b/packages/time-input/src/utils/doesSomeSegmentExist/doesSomeSegmentExist.ts new file mode 100644 index 0000000000..1537c4039a --- /dev/null +++ b/packages/time-input/src/utils/doesSomeSegmentExist/doesSomeSegmentExist.ts @@ -0,0 +1,12 @@ +import { TimeSegmentsState } from '../../shared.types'; + +/** + * Checks if some segment exists + * + * @param segments - The segments to check + * @returns Whether some segment exists + */ +export const doesSomeSegmentExist = (segments: TimeSegmentsState) => { + // check if all segments are not empty + return Object.values(segments).some(segment => segment !== ''); +}; diff --git a/packages/time-input/src/utils/findUnitOptionByDayPeriod/findUnitOptionByDayPeriod.spec.ts b/packages/time-input/src/utils/findUnitOptionByDayPeriod/findUnitOptionByDayPeriod.spec.ts new file mode 100644 index 0000000000..f62604d1d6 --- /dev/null +++ b/packages/time-input/src/utils/findUnitOptionByDayPeriod/findUnitOptionByDayPeriod.spec.ts @@ -0,0 +1,27 @@ +import { findUnitOptionByDayPeriod } from './findUnitOptionByDayPeriod'; + +describe('packages/time-input/utils/findUnitOptionByDayPeriod', () => { + test('returns the unit option by day period', () => { + expect( + findUnitOptionByDayPeriod('AM', [ + { displayName: 'AM', value: 'AM' }, + { displayName: 'PM', value: 'PM' }, + ]), + ).toEqual({ displayName: 'AM', value: 'AM' }); + expect( + findUnitOptionByDayPeriod('PM', [ + { displayName: 'AM', value: 'AM' }, + { displayName: 'PM', value: 'PM' }, + ]), + ).toEqual({ displayName: 'PM', value: 'PM' }); + }); + test('returns the first unit option if the day period is not found', () => { + expect( + // @ts-expect-error - invalid day period + findUnitOptionByDayPeriod('', [ + { displayName: 'AM', value: 'AM' }, + { displayName: 'PM', value: 'PM' }, + ]), + ).toEqual({ displayName: 'AM', value: 'AM' }); + }); +}); diff --git a/packages/time-input/src/utils/findUnitOptionByDayPeriod/findUnitOptionByDayPeriod.ts b/packages/time-input/src/utils/findUnitOptionByDayPeriod/findUnitOptionByDayPeriod.ts new file mode 100644 index 0000000000..474dd14503 --- /dev/null +++ b/packages/time-input/src/utils/findUnitOptionByDayPeriod/findUnitOptionByDayPeriod.ts @@ -0,0 +1,20 @@ +import { DayPeriod } from '../../shared.types'; +import { UnitOption } from '../../TimeInputSelect/TimeInputSelect.types'; + +/** + * Finds the select unit option based on the day period. + * + * @param dayPeriod - The day period to use for the select unit. + * @param unitOptions - The valid unit options to use for the select unit. + * @returns The select unit option or the first unit option if the day period is not found + */ +export const findUnitOptionByDayPeriod = ( + dayPeriod: DayPeriod, + unitOptions: Array, +): UnitOption => { + const selectUnitOption = unitOptions.find( + option => option.displayName === dayPeriod, + ); + + return selectUnitOption ?? unitOptions[0]; +}; diff --git a/packages/time-input/src/utils/getNewUTCDateFromSegments/getNewUTCDateFromSegments.spec.ts b/packages/time-input/src/utils/getNewUTCDateFromSegments/getNewUTCDateFromSegments.spec.ts new file mode 100644 index 0000000000..8e28aef51f --- /dev/null +++ b/packages/time-input/src/utils/getNewUTCDateFromSegments/getNewUTCDateFromSegments.spec.ts @@ -0,0 +1,595 @@ +import { Month } from '@leafygreen-ui/date-utils'; + +import { getNewUTCDateFromSegments } from './getNewUTCDateFromSegments'; + +describe('getNewUTCDateFromSegments', () => { + describe('When all segments are filled and valid, a valid date is returned', () => { + describe('UTC (UTC+0)', () => { + describe('12h format', () => { + describe('PM', () => { + test('returns the UTC date', () => { + const segments = { + hour: '11', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'UTC', + dayPeriod: 'PM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); // February 20, 2026 11:00:00 PM + + // February 20, 2026 11:00:00 PM in UTC is February 20, 2026 23:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.February, 20, 23, 0, 0)), + ); + }); + }); + describe('AM', () => { + test('returns the UTC date', () => { + const segments = { + hour: '11', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'UTC', + dayPeriod: 'AM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); // February 20, 2026 11:00:00 AM + + // February 20, 2026 11:00:00 AM in UTC is February 20, 2026 11:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.February, 20, 11, 0, 0)), + ); + }); + }); + }); + describe('24h format', () => {}); + }); + + describe('America/New_York', () => { + describe('EST (UTC-5)', () => { + describe('12h format', () => { + describe('PM', () => { + test('returns the UTC date', () => { + const segments = { + hour: '11', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'America/New_York', + dayPeriod: 'PM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); // February 20, 2026 11:00:00 PM America/New_York + + // February 20, 2026 11:00:00 PM in America/New_York is February 21, 2026 04:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.February, 21, 4, 0, 0)), + ); + }); + }); + describe('AM', () => { + test('returns the UTC date', () => { + const segments = { + hour: '11', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'America/New_York', + dayPeriod: 'AM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); // February 20, 2026 11:00:00 AM America/New_York + + // February 20, 2026 11:00:00 AM in America/New_York is February 20, 2026 16:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.February, 20, 16, 0, 0)), + ); + }); + }); + }); + describe('24h format', () => { + test('returns the UTC date', () => { + const segments = { + hour: '14', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: false, + timeZone: 'America/New_York', + dayPeriod: 'AM', // This is not used for 24h format + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); // February 20, 2026 14:00:00 America/New_York + + // February 20, 2026 14:00:00 in America/New_York is February 20, 2026 19:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.February, 20, 19, 0, 0)), + ); + }); + }); + }); + describe('EDT (UTC-4)', () => { + describe('12h format', () => { + describe('PM', () => { + test('returns the UTC date', () => { + const segments = { + hour: '11', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'America/New_York', + dayPeriod: 'PM', + dateValues: { + day: '20', + month: '03', + year: '2026', + }, + }); // March 20, 2026 11:00:00 PM America/New_York + + // March 20, 2026 11:00:00 PM in America/New_York is March 21, 2026 03:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.March, 21, 3, 0, 0)), + ); + }); + }); + describe('AM', () => { + test('returns the UTC date', () => { + const segments = { + hour: '11', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'America/New_York', + dayPeriod: 'AM', + dateValues: { + day: '20', + month: '03', + year: '2026', + }, + }); // March 20, 2026 11:00:00 AM America/New_York + + // March 20, 2026 11:00:00 AM in America/New_York is March 20, 2026 15:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.March, 20, 15, 0, 0)), + ); + }); + }); + }); + describe('24h format', () => { + test('returns the UTC date', () => { + const segments = { + hour: '14', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: false, + timeZone: 'America/New_York', + dayPeriod: 'AM', // This is not used for 24h format + dateValues: { + day: '20', + month: '03', + year: '2026', + }, + }); // March 20, 2026 14:00:00 America/New_York + + // March 20, 2026 14:00:00 in America/New_York is March 20, 2026 18:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.March, 20, 18, 0, 0)), + ); + }); + }); + }); + }); + + describe('Pacific/Auckland', () => { + describe('NZST (UTC+12)', () => { + describe('12h format', () => { + describe('PM', () => { + test('returns the UTC date', () => { + const segments = { + hour: '11', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'Pacific/Auckland', + dayPeriod: 'PM', + dateValues: { + day: '01', + month: '05', + year: '2026', + }, + }); // May 1, 2026 11:00:00 PM Pacific/Auckland + + // May 1, 2026 11:00:00 PM in Pacific/Auckland is May 1, 2026 11:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.May, 1, 11, 0, 0)), + ); + }); + }); + describe('AM', () => { + test('returns the UTC date', () => { + const segments = { + hour: '11', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'Pacific/Auckland', + dayPeriod: 'AM', + dateValues: { + day: '01', + month: '05', + year: '2026', + }, + }); // May 1, 2026 11:00:00 AM Pacific/Auckland + + // May 1, 2026 11:00:00 AM in Pacific/Auckland is April 30, 2026 23:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.April, 30, 23, 0, 0)), + ); + }); + }); + }); + describe('24h format', () => { + test('returns the UTC date', () => { + const segments = { + hour: '13', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: false, + timeZone: 'Pacific/Auckland', + dayPeriod: 'AM', // This is not used for 24h format + dateValues: { + day: '01', + month: '05', + year: '2026', + }, + }); // May 1, 2026 13:00:00 Pacific/Auckland + + // May 1, 2026 13:00:00 Pacific/Auckland is May 1, 2026 01:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.May, 1, 1, 0, 0)), + ); + }); + }); + }); + describe('NZDT (UTC+13)', () => { + describe('PM', () => { + test('returns the UTC date', () => { + const segments = { + hour: '11', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'Pacific/Auckland', + dayPeriod: 'PM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); // February 20, 2026 11:00:00 PM Pacific/Auckland + + // February 20, 2026 11:00:00 PM in Pacific/Auckland is February 20, 2026 10:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.February, 20, 10, 0, 0)), + ); + }); + + describe('AM', () => { + test('returns the UTC date', () => { + const segments = { + hour: '11', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'Pacific/Auckland', + dayPeriod: 'AM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); // February 20, 2026 11:00:00 AM Pacific/Auckland + + // February 20, 2026 11:00:00 AM in Pacific/Auckland is February 19, 2026 22:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.February, 19, 22, 0, 0)), + ); + }); + }); + }); + describe('24h format', () => { + test('returns the UTC date', () => { + const segments = { + hour: '13', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: false, + timeZone: 'Pacific/Auckland', + dayPeriod: 'AM', // This is not used for 24h format + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); // February 20, 2026 13:00:00 Pacific/Auckland + + // February 20, 2026 13:00:00 Pacific/Auckland is February 20, 2026 00:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.February, 20, 0, 0, 0)), + ); + }); + }); + }); + }); + }); + + describe('When all segments are filled but not all segments are valid, an invalid date object is returned', () => { + describe('12h format', () => { + describe('hour', () => { + test('is invalid', () => { + const segments = { + hour: '13', // Invalid hour + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'UTC', + dayPeriod: 'PM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); + + expect(newDate?.getTime()).toBeNaN(); + expect(newDate).not.toBeNull(); + }); + + test('is empty', () => { + const segments = { + hour: '', // Empty hour + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'UTC', + dayPeriod: 'PM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); + + expect(newDate?.getTime()).toBeNaN(); + expect(newDate).not.toBeNull(); + }); + }); + }); + + describe('24h format', () => { + describe('hour', () => { + test('is invalid', () => { + const segments = { + hour: '24', // Invalid hour + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: false, + timeZone: 'UTC', + dayPeriod: 'PM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); + + expect(newDate?.getTime()).toBeNaN(); + expect(newDate).not.toBeNull(); + }); + + test('is empty', () => { + const segments = { + hour: '', // Empty hour + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: false, + timeZone: 'UTC', + dayPeriod: 'PM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); + + expect(newDate?.getTime()).toBeNaN(); + expect(newDate).not.toBeNull(); + }); + }); + }); + + describe('minute', () => { + test('is invalid', () => { + const segments = { + hour: '11', + minute: '60', // Invalid minute + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'UTC', + dayPeriod: 'PM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); + + expect(newDate?.getTime()).toBeNaN(); + expect(newDate).not.toBeNull(); + }); + + test('is empty', () => { + const segments = { + hour: '11', + minute: '', // Empty minute + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'UTC', + dayPeriod: 'PM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); + + expect(newDate?.getTime()).toBeNaN(); + expect(newDate).not.toBeNull(); + }); + }); + + describe('second', () => { + test('is invalid', () => { + const segments = { + hour: '11', + minute: '00', + second: '60', // Invalid second + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'UTC', + dayPeriod: 'PM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); + + expect(newDate?.getTime()).toBeNaN(); + expect(newDate).not.toBeNull(); + }); + + test('is empty', () => { + const segments = { + hour: '11', + minute: '00', + second: '', // Empty second + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'UTC', + dayPeriod: 'PM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); + + expect(newDate?.getTime()).toBeNaN(); + expect(newDate).not.toBeNull(); + }); + }); + }); + + test('When all segments are empty, null is returned', () => { + const segments = { + hour: '', + minute: '', + second: '', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: true, + timeZone: 'UTC', + dayPeriod: 'PM', + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); + + expect(newDate).toBeNull(); + }); +}); diff --git a/packages/time-input/src/utils/getNewUTCDateFromSegments/getNewUTCDateFromSegments.ts b/packages/time-input/src/utils/getNewUTCDateFromSegments/getNewUTCDateFromSegments.ts new file mode 100644 index 0000000000..073ab2c226 --- /dev/null +++ b/packages/time-input/src/utils/getNewUTCDateFromSegments/getNewUTCDateFromSegments.ts @@ -0,0 +1,72 @@ +import { newUTCFromTimeZone } from '@leafygreen-ui/date-utils'; + +import { TimeSegmentsState } from '../../shared.types'; +import { DayPeriod } from '../../shared.types'; +import { convert12hTo24h } from '../convert12hTo24h/convert12hTo24h'; +import { doesSomeSegmentExist } from '../doesSomeSegmentExist/doesSomeSegmentExist'; +import { isEverySegmentFilled } from '../isEverySegmentFilled/isEverySegmentFilled'; +import { isEverySegmentValid } from '../isEverySegmentValid/isEverySegmentValid'; + +/** + * Takes local time segments, creates a local date object and converts it to UTC. + * + * @param segments - The segments to create the date from + * @param is12HourFormat - Whether the time is in 12 hour format + * @param dateValues - The date values to create the date from + * @param timeZone - The time zone to create the date in + * @returns The either a new date object in UTC, null, or an invalid date object + */ +export const getNewUTCDateFromSegments = ({ + segments, + is12HourFormat, + dateValues, + timeZone, + dayPeriod, +}: { + segments: TimeSegmentsState; + is12HourFormat: boolean; + timeZone: string; + dateValues: { + day: string; + month: string; + year: string; + }; + dayPeriod: DayPeriod; +}) => { + const { day, month, year } = dateValues; + const { hour, minute, second } = segments; + + const converted12hTo24hHour = is12HourFormat + ? convert12hTo24h(Number(hour), dayPeriod).toString() + : hour; + + /** + * Check if all segments are filled and valid (not necessarily explicit). If they are, return the UTC date. + */ + if ( + isEverySegmentFilled(segments) && + isEverySegmentValid({ segments, is12HourFormat }) + ) { + return newUTCFromTimeZone({ + year, + month, + day, + hour: converted12hTo24hHour, + minute, + second, + timeZone, + }); + } + + /** + * Check if any segments are filled. If not, return null. This means all segments are empty. + */ + if (!doesSomeSegmentExist(segments)) { + return null; + } + + /** + * Return an invalid date object if some segments are empty or invalid. + */ + return new Date('invalid'); +}; diff --git a/packages/time-input/src/utils/getPaddedTimeSegmentsFromDate/getPaddedTimeSegments/getPaddedTimeSegments.spec.ts b/packages/time-input/src/utils/getPaddedTimeSegmentsFromDate/getPaddedTimeSegments/getPaddedTimeSegments.spec.ts new file mode 100644 index 0000000000..37f065a1aa --- /dev/null +++ b/packages/time-input/src/utils/getPaddedTimeSegmentsFromDate/getPaddedTimeSegments/getPaddedTimeSegments.spec.ts @@ -0,0 +1,42 @@ +import { getPaddedTimeSegments } from './getPaddedTimeSegments'; + +describe('packages/time-input/utils/getPaddedTimeSegments', () => { + test('returns the padded time segments if all segments are 0', () => { + const paddedTimeSegments = getPaddedTimeSegments({ + hour: '0', + minute: '0', + second: '0', + }); + expect(paddedTimeSegments).toEqual({ + hour: '00', + minute: '00', + second: '00', + }); + }); + + test('returns the padded time segments', () => { + const paddedTimeSegments = getPaddedTimeSegments({ + hour: '2', + minute: '3', + second: '1', + }); + expect(paddedTimeSegments).toEqual({ + hour: '02', + minute: '03', + second: '01', + }); + }); + + test('does not pad segments that are already padded', () => { + const paddedTimeSegments = getPaddedTimeSegments({ + hour: '02', + minute: '03', + second: '01', + }); + expect(paddedTimeSegments).toEqual({ + hour: '02', + minute: '03', + second: '01', + }); + }); +}); diff --git a/packages/time-input/src/utils/getPaddedTimeSegmentsFromDate/getPaddedTimeSegments/getPaddedTimeSegments.ts b/packages/time-input/src/utils/getPaddedTimeSegmentsFromDate/getPaddedTimeSegments/getPaddedTimeSegments.ts new file mode 100644 index 0000000000..ed92bea8db --- /dev/null +++ b/packages/time-input/src/utils/getPaddedTimeSegmentsFromDate/getPaddedTimeSegments/getPaddedTimeSegments.ts @@ -0,0 +1,28 @@ +import { getValueFormatter } from '@leafygreen-ui/input-box'; + +import { TimeSegmentsState } from '../../../shared.types'; + +/** + * Formats the time segments to a string with 2 digits for each segment. + * + * @param segments - The time segments to format + * @returns The formatted time segments + * + * @example + * ```js + * getPaddedTimeSegments({ hour: '2', minute: '30', second: '0' }); + * // returns: { hour: '02', minute: '30', second: '00' } + * ``` + */ +export const getPaddedTimeSegments = (segments: TimeSegmentsState) => { + const hour = getValueFormatter({ charsCount: 2, allowZero: true })( + segments.hour, + ); + const minute = getValueFormatter({ charsCount: 2, allowZero: true })( + segments.minute, + ); + const second = getValueFormatter({ charsCount: 2, allowZero: true })( + segments.second, + ); + return { hour, minute, second }; +}; diff --git a/packages/time-input/src/utils/getPaddedTimeSegmentsFromDate/getPaddedTimeSegmentsFromDate.spec.ts b/packages/time-input/src/utils/getPaddedTimeSegmentsFromDate/getPaddedTimeSegmentsFromDate.spec.ts new file mode 100644 index 0000000000..290f5d8a9d --- /dev/null +++ b/packages/time-input/src/utils/getPaddedTimeSegmentsFromDate/getPaddedTimeSegmentsFromDate.spec.ts @@ -0,0 +1,16 @@ +import { getPaddedTimeSegmentsFromDate } from './getPaddedTimeSegmentsFromDate'; + +describe('packages/time-input/utils/getPaddedTimeSegmentsFromDate', () => { + test('returns the formatted time segments from a date', () => { + const formattedTimeSegments = getPaddedTimeSegmentsFromDate( + new Date('2025-01-01T01:00:00Z'), + 'en-US', + 'America/New_York', + ); + expect(formattedTimeSegments).toEqual({ + hour: '08', + minute: '00', + second: '00', + }); + }); +}); diff --git a/packages/time-input/src/utils/getPaddedTimeSegmentsFromDate/getPaddedTimeSegmentsFromDate.ts b/packages/time-input/src/utils/getPaddedTimeSegmentsFromDate/getPaddedTimeSegmentsFromDate.ts new file mode 100644 index 0000000000..55aaf4b55c --- /dev/null +++ b/packages/time-input/src/utils/getPaddedTimeSegmentsFromDate/getPaddedTimeSegmentsFromDate.ts @@ -0,0 +1,34 @@ +import { DateType, LocaleString } from '@leafygreen-ui/date-utils'; + +import { TimeSegmentsState } from '../../shared.types'; +import { getFormatPartsValues } from '../getFormatPartsValues/getFormatPartsValues'; + +import { getPaddedTimeSegments } from './getPaddedTimeSegments/getPaddedTimeSegments'; + +/** + * Gets the formatted time segments from a date + * + * @param date - The date to get the formatted time segments from + * @param locale - The locale to use + * @param timeZone - The time zone to use + * @returns The formatted time segments + * + * @example + * ```js + * getPaddedTimeSegmentsFromDate(new Date('2025-01-01T12:00:00Z'), 'en-US', 'America/New_York'); + * // returns: { hour: '12', minute: '00', second: '00' } + * ``` + */ +export const getPaddedTimeSegmentsFromDate = ( + date: DateType, + locale: LocaleString, + timeZone: string, +): TimeSegmentsState => { + const { hour, minute, second } = getFormatPartsValues({ + locale, + timeZone, + value: date, + }); + + return getPaddedTimeSegments({ hour, minute, second }); +}; diff --git a/packages/time-input/src/utils/index.ts b/packages/time-input/src/utils/index.ts index 84443d6e17..0f24564f6d 100644 --- a/packages/time-input/src/utils/index.ts +++ b/packages/time-input/src/utils/index.ts @@ -1,5 +1,14 @@ +export { convert12hTo24h } from './convert12hTo24h/convert12hTo24h'; +export { doesSomeSegmentExist } from './doesSomeSegmentExist/doesSomeSegmentExist'; +export { findUnitOptionByDayPeriod } from './findUnitOptionByDayPeriod/findUnitOptionByDayPeriod'; export { getFormatParts } from './getFormatParts/getFormatParts'; export { getFormatPartsValues } from './getFormatPartsValues'; export { getFormatter } from './getFormatter/getFormatter'; export { getLgIds } from './getLgIds'; +export { getNewUTCDateFromSegments } from './getNewUTCDateFromSegments/getNewUTCDateFromSegments'; +export { getPaddedTimeSegmentsFromDate } from './getPaddedTimeSegmentsFromDate/getPaddedTimeSegmentsFromDate'; export { hasDayPeriod } from './hasDayPeriod/hasDayPeriod'; +export { isEverySegmentFilled } from './isEverySegmentFilled/isEverySegmentFilled'; +export { isEverySegmentValid } from './isEverySegmentValid/isEverySegmentValid'; +export { isEverySegmentValueExplicit } from './isEverySegmentValueExplicit/isEverySegmentValueExplicit'; +export { shouldSetValue } from './shouldSetValue/shouldSetValue'; diff --git a/packages/time-input/src/utils/isEverySegmentFilled/isEverySegmentFilled.spec.ts b/packages/time-input/src/utils/isEverySegmentFilled/isEverySegmentFilled.spec.ts new file mode 100644 index 0000000000..6f0b4da2d5 --- /dev/null +++ b/packages/time-input/src/utils/isEverySegmentFilled/isEverySegmentFilled.spec.ts @@ -0,0 +1,21 @@ +import { isEverySegmentFilled } from './isEverySegmentFilled'; + +describe('isEverySegmentFilled', () => { + test('returns true if all segments are filled', () => { + expect( + isEverySegmentFilled({ hour: '12', minute: '00', second: '00' }), + ).toBe(true); + }); + + test('returns false if any segment is empty', () => { + expect(isEverySegmentFilled({ hour: '', minute: '00', second: '00' })).toBe( + false, + ); + }); + + test('returns false if all segments are empty', () => { + expect(isEverySegmentFilled({ hour: '', minute: '', second: '' })).toBe( + false, + ); + }); +}); diff --git a/packages/time-input/src/utils/isEverySegmentFilled/isEverySegmentFilled.ts b/packages/time-input/src/utils/isEverySegmentFilled/isEverySegmentFilled.ts new file mode 100644 index 0000000000..318178bd54 --- /dev/null +++ b/packages/time-input/src/utils/isEverySegmentFilled/isEverySegmentFilled.ts @@ -0,0 +1,19 @@ +import { isDefined } from '@leafygreen-ui/lib'; + +import { TimeSegmentsState } from '../../shared.types'; + +/** + * Checks if every segment is filled + * + * @param segments - The segments to check + * @returns Whether every segment is filled + */ +export const isEverySegmentFilled = (segments: TimeSegmentsState) => { + const isEverySegmentFilled = Object.values(segments).every(segment => { + const isSegmentDefined = isDefined(segment); + const isSegmentEmpty = segment === ''; + return !isSegmentEmpty && isSegmentDefined; + }); + // check if all segments are not empty + return isEverySegmentFilled; +}; diff --git a/packages/time-input/src/utils/isEverySegmentValid/isEverySegmentValid.spec.ts b/packages/time-input/src/utils/isEverySegmentValid/isEverySegmentValid.spec.ts new file mode 100644 index 0000000000..ef0d038fd0 --- /dev/null +++ b/packages/time-input/src/utils/isEverySegmentValid/isEverySegmentValid.spec.ts @@ -0,0 +1,173 @@ +import { getDefaultMax, getDefaultMin } from '../../constants'; + +import { isEverySegmentValid } from './isEverySegmentValid'; + +describe('isEverySegmentValueValid', () => { + describe('12 hour format', () => { + test('returns false if all segments are 00', () => { + // when 12 hour format, 00 is not a valid value for the hour segment + expect( + isEverySegmentValid({ + segments: { hour: '00', minute: '00', second: '00' }, + is12HourFormat: true, + }), + ).toBe(false); + }); + + test('returns true if all segments are at the default min', () => { + expect( + isEverySegmentValid({ + segments: { + hour: getDefaultMin({ is12HourFormat: true })['hour'].toString(), + minute: getDefaultMin({ is12HourFormat: true })[ + 'minute' + ].toString(), + second: getDefaultMin({ is12HourFormat: true })[ + 'second' + ].toString(), + }, + is12HourFormat: true, + }), + ).toBe(true); + }); + + test('returns true if all segments are at the default max', () => { + expect( + isEverySegmentValid({ + segments: { + hour: getDefaultMax({ is12HourFormat: true })['hour'].toString(), + minute: getDefaultMax({ is12HourFormat: true })[ + 'minute' + ].toString(), + second: getDefaultMax({ is12HourFormat: true })[ + 'second' + ].toString(), + }, + is12HourFormat: true, + }), + ).toBe(true); + }); + + describe('hour', () => { + test('returns false if hour is greater than the default max', () => { + expect( + isEverySegmentValid({ + segments: { + hour: ( + getDefaultMax({ is12HourFormat: true })['hour'] + 1 + ).toString(), + minute: '00', + second: '00', + }, + is12HourFormat: true, + }), + ).toBe(false); + }); + }); + + describe('minute', () => { + test('returns false if minute is greater than the default max', () => { + expect( + isEverySegmentValid({ + segments: { + hour: '00', + minute: ( + getDefaultMax({ is12HourFormat: true })['minute'] + 1 + ).toString(), + second: '00', + }, + is12HourFormat: true, + }), + ).toBe(false); + }); + }); + + describe('second', () => { + test('returns false if second is greater than the default max', () => { + expect( + isEverySegmentValid({ + segments: { + hour: '00', + minute: '00', + second: ( + getDefaultMax({ is12HourFormat: true })['second'] + 1 + ).toString(), + }, + is12HourFormat: true, + }), + ).toBe(false); + }); + }); + }); + + describe('24 hour format', () => { + test('returns true if all segments are 00', () => { + expect( + isEverySegmentValid({ + segments: { hour: '00', minute: '00', second: '00' }, + is12HourFormat: false, + }), + ).toBe(true); + }); + + test('returns true if all segments are valid', () => { + expect( + isEverySegmentValid({ + segments: { hour: '12', minute: '00', second: '00' }, + is12HourFormat: false, + }), + ).toBe(true); + }); + + describe('hour', () => { + test('returns false if hour is greater than the default max', () => { + expect( + isEverySegmentValid({ + segments: { + hour: ( + getDefaultMax({ is12HourFormat: false })['hour'] + 1 + ).toString(), + minute: '00', + second: '00', + }, + is12HourFormat: false, + }), + ).toBe(false); + }); + }); + + describe('minute', () => { + test('returns false if minute is greater than the default max', () => { + expect( + isEverySegmentValid({ + segments: { + hour: '00', + minute: ( + getDefaultMax({ is12HourFormat: false })['minute'] + 1 + ).toString(), + second: '00', + }, + is12HourFormat: false, + }), + ).toBe(false); + }); + }); + + describe('second', () => { + test('returns false if second is greater than the default max', () => { + expect( + isEverySegmentValid({ + segments: { + hour: '00', + minute: '00', + second: ( + getDefaultMax({ is12HourFormat: false })['second'] + 1 + ).toString(), + }, + is12HourFormat: false, + }), + ).toBe(false); + }); + }); + }); +}); diff --git a/packages/time-input/src/utils/isEverySegmentValid/isEverySegmentValid.ts b/packages/time-input/src/utils/isEverySegmentValid/isEverySegmentValid.ts new file mode 100644 index 0000000000..0f3443605e --- /dev/null +++ b/packages/time-input/src/utils/isEverySegmentValid/isEverySegmentValid.ts @@ -0,0 +1,36 @@ +import { isValidValueForSegment } from '@leafygreen-ui/input-box'; + +import { getDefaultMax, getDefaultMin } from '../../constants'; +import { TimeSegment } from '../../shared.types'; +import { TimeSegmentsState } from '../../shared.types'; + +/** + * Checks if every segment is valid + * + * @param segments - The segments to check + * @param is12HourFormat - Whether the time is in 12 hour format + * @returns Whether every segment is valid + */ +export const isEverySegmentValid = ({ + segments, + is12HourFormat, +}: { + segments: TimeSegmentsState; + is12HourFormat: boolean; +}) => { + const isEverySegmentValid = Object.entries(segments).every( + ([segment, value]) => { + const isSegmentValid = isValidValueForSegment({ + segment: segment as TimeSegment, + value: value as string, + defaultMin: getDefaultMin({ is12HourFormat })[segment as TimeSegment], + defaultMax: getDefaultMax({ is12HourFormat })[segment as TimeSegment], + segmentEnum: TimeSegment, + }); + + return isSegmentValid; + }, + ); + + return isEverySegmentValid; +}; diff --git a/packages/time-input/src/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.spec.ts b/packages/time-input/src/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.spec.ts new file mode 100644 index 0000000000..dd2ffc111f --- /dev/null +++ b/packages/time-input/src/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.spec.ts @@ -0,0 +1,160 @@ +import range from 'lodash/range'; + +import { isEverySegmentValueExplicit } from './isEverySegmentValueExplicit'; + +describe('isEverySegmentValueExplicit', () => { + describe('12 hour format', () => { + test('returns false if all values are not explicit', () => { + expect( + isEverySegmentValueExplicit({ + segments: { hour: '1', minute: '1', second: '1' }, + is12HourFormat: true, + }), + ).toBe(false); + }); + + describe('hour', () => { + describe('returns false', () => { + test('if hour is 0', () => { + // in 12 hour format, 00 is not a valid hour + expect( + isEverySegmentValueExplicit({ + segments: { hour: '00', minute: '00', second: '00' }, + is12HourFormat: true, + }), + ).toBe(false); + }); + + test('if hour is a single digit below the min explicit value (2)', () => { + expect( + isEverySegmentValueExplicit({ + segments: { hour: '1', minute: '00', second: '00' }, + is12HourFormat: true, + }), + ).toBe(false); + }); + }); + + describe('returns true', () => { + describe('when single digit and value is greater than or equal to the min explicit value (2)', () => { + test.each(range(2, 10))('%i', i => { + expect( + isEverySegmentValueExplicit({ + segments: { hour: i.toString(), minute: '00', second: '00' }, + is12HourFormat: true, + }), + ).toBe(true); + }); + }); + describe('when double digit and value is greater than the min explicit value (2)', () => { + test.each(range(11, 13))('%i', i => { + expect( + isEverySegmentValueExplicit({ + segments: { hour: i.toString(), minute: '00', second: '00' }, + is12HourFormat: true, + }), + ).toBe(true); + }); + }); + }); + }); + }); + + describe('24 hour format', () => { + test('returns false if all values are not explicit', () => { + expect( + isEverySegmentValueExplicit({ + segments: { hour: '1', minute: '1', second: '1' }, + is12HourFormat: false, + }), + ).toBe(false); + }); + + describe('hour', () => { + describe('returns false', () => { + test.each(range(0, 3))('if hour is %i', i => { + expect( + isEverySegmentValueExplicit({ + segments: { hour: i.toString(), minute: '00', second: '00' }, + is12HourFormat: false, + }), + ).toBe(false); + }); + }); + + describe('returns true', () => { + describe('if is single digit and greater than or equal to the min explicit value (3)', () => { + test.each(range(3, 10))('%i', i => { + expect( + isEverySegmentValueExplicit({ + segments: { hour: i.toString(), minute: '00', second: '00' }, + is12HourFormat: false, + }), + ).toBe(true); + }); + }); + + describe('if is double digit and greater than the min explicit value (3)', () => { + test.each(range(10, 24))('%i', i => { + expect( + isEverySegmentValueExplicit({ + segments: { hour: i.toString(), minute: '00', second: '00' }, + is12HourFormat: false, + }), + ).toBe(true); + }); + }); + }); + }); + + describe.each(['minute', 'second'])('%s', segment => { + describe('returns false', () => { + describe('if is single digit and less than the min explicit value (6)', () => { + test.each(range(0, 6))('%i', i => { + expect( + isEverySegmentValueExplicit({ + segments: { + hour: '12', + minute: segment === 'minute' ? i.toString() : '00', + second: segment === 'second' ? i.toString() : '00', + }, + is12HourFormat: true, + }), + ).toBe(false); + }); + }); + describe('if is double digit and greater than the min explicit value (6)', () => { + test.each(range(10, 60))('%i', i => { + expect( + isEverySegmentValueExplicit({ + segments: { + hour: '12', + minute: segment === 'minute' ? i.toString() : '00', + second: segment === 'second' ? i.toString() : '00', + }, + is12HourFormat: true, + }), + ).toBe(true); + }); + }); + }); + + describe('returns true', () => { + describe('if is single digit and greater than the min explicit value (6)', () => { + test.each(range(6, 10))('%i', i => { + expect( + isEverySegmentValueExplicit({ + segments: { + hour: '12', + minute: segment === 'minute' ? i.toString() : '00', + second: segment === 'second' ? i.toString() : '00', + }, + is12HourFormat: true, + }), + ).toBe(true); + }); + }); + }); + }); + }); +}); diff --git a/packages/time-input/src/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts b/packages/time-input/src/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts new file mode 100644 index 0000000000..1fa6cfc413 --- /dev/null +++ b/packages/time-input/src/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts @@ -0,0 +1,32 @@ +import { createExplicitSegmentValidator } from '@leafygreen-ui/input-box'; + +import { getTimeSegmentRules } from '../../constants'; +import { TimeSegment, TimeSegmentsState } from '../../shared.types'; + +export const isExplicitSegmentValue = (is12HourFormat: boolean) => + createExplicitSegmentValidator({ + segmentEnum: TimeSegment, + rules: getTimeSegmentRules({ is12HourFormat }), + }); + +/** + * Returns whether every segment's value is explicit and unambiguous. + * (see {@link isExplicitSegmentValue}) + */ +export const isEverySegmentValueExplicit = ({ + segments, + is12HourFormat, +}: { + segments: TimeSegmentsState; + is12HourFormat: boolean; +}): boolean => { + return Object.entries(segments).every(([segment, value]) => { + const isExplicit = isExplicitSegmentValue(is12HourFormat)({ + segment: segment as TimeSegment, + value, + allowZero: segment === TimeSegment.Hour ? !is12HourFormat : true, + }); + + return isExplicit; + }); +}; diff --git a/packages/time-input/src/utils/shouldSetValue/shouldSetValue.spec.ts b/packages/time-input/src/utils/shouldSetValue/shouldSetValue.spec.ts new file mode 100644 index 0000000000..b3ced7b08d --- /dev/null +++ b/packages/time-input/src/utils/shouldSetValue/shouldSetValue.spec.ts @@ -0,0 +1,159 @@ +import { Month, newUTC } from '@leafygreen-ui/date-utils'; + +import { TimeSegmentsState } from '../../shared.types'; + +import { shouldSetValue } from './shouldSetValue'; + +describe('packages/time-input/utils/shouldSetValue', () => { + describe('when the date is valid', () => { + describe('should return true', () => { + describe('when all the segments are explicit', () => { + test('12 hour format', () => { + const newDate = new Date(newUTC(2021, Month.January, 1)); + const segments: TimeSegmentsState = { + hour: '01', + minute: '01', + second: '01', + }; + + const shouldSetNewValue = shouldSetValue({ + newDate, + isDirty: true, + segments, + is12HourFormat: true, + }); + + expect(shouldSetNewValue).toBe(true); + }); + test('24 hour format', () => { + const newDate = new Date(newUTC(2021, Month.January, 1)); + const segments: TimeSegmentsState = { + hour: '13', + minute: '01', + second: '01', + }; + + const shouldSetNewValue = shouldSetValue({ + newDate, + isDirty: true, + segments, + is12HourFormat: false, + }); + + expect(shouldSetNewValue).toBe(true); + }); + }); + }); + describe('should return false', () => { + describe('when not all the segments are explicit', () => { + test('12 hour format', () => { + const newDate = new Date(newUTC(2021, Month.January, 1)); + const segments: TimeSegmentsState = { + hour: '1', + minute: '01', + second: '01', + }; + + const shouldSetNewValue = shouldSetValue({ + newDate, + isDirty: true, + segments, + is12HourFormat: true, + }); + + expect(shouldSetNewValue).toBe(false); + }); + test('24 hour format', () => { + const newDate = new Date(newUTC(2021, Month.January, 1)); + const segments: TimeSegmentsState = { + hour: '2', + minute: '01', + second: '01', + }; + const shouldSetNewValue = shouldSetValue({ + newDate, + isDirty: true, + segments, + is12HourFormat: false, + }); + + expect(shouldSetNewValue).toBe(false); + }); + }); + }); + }); + + describe('when the date is invalid', () => { + describe('should return true', () => { + test('when the component is dirty', () => { + const newDate = new Date('invalid'); + const segments: TimeSegmentsState = { + hour: '01', + minute: '01', + second: '01', + }; + const shouldSetNewValue = shouldSetValue({ + newDate, + isDirty: true, + segments, + is12HourFormat: true, + }); + + expect(shouldSetNewValue).toBe(true); + }); + test('when the component is not dirty and every segment is filled', () => { + const newDate = new Date('invalid'); + const segments: TimeSegmentsState = { + hour: '01', + minute: '01', + second: '01', + }; + const shouldSetNewValue = shouldSetValue({ + newDate, + isDirty: false, + segments, + is12HourFormat: true, + }); + + expect(shouldSetNewValue).toBe(true); + }); + }); + describe('should return false', () => { + test('when the component is not dirty and not every segment is filled', () => { + const newDate = new Date('invalid'); + const segments: TimeSegmentsState = { + hour: '', + minute: '01', + second: '01', + }; + const shouldSetNewValue = shouldSetValue({ + newDate, + isDirty: false, + segments, + is12HourFormat: true, + }); + + expect(shouldSetNewValue).toBe(false); + }); + }); + }); + + describe('when the date is null', () => { + test('should return true', () => { + const newDate = null; + const segments: TimeSegmentsState = { + hour: '01', + minute: '01', + second: '01', + }; + const shouldSetNewValue = shouldSetValue({ + newDate, + isDirty: true, + segments, + is12HourFormat: true, + }); + + expect(shouldSetNewValue).toBe(true); + }); + }); +}); diff --git a/packages/time-input/src/utils/shouldSetValue/shouldSetValue.ts b/packages/time-input/src/utils/shouldSetValue/shouldSetValue.ts new file mode 100644 index 0000000000..93a835b805 --- /dev/null +++ b/packages/time-input/src/utils/shouldSetValue/shouldSetValue.ts @@ -0,0 +1,55 @@ +import isNull from 'lodash/isNull'; + +import { DateType, isValidDate } from '@leafygreen-ui/date-utils'; +import { isInvalidDateObject } from '@leafygreen-ui/date-utils'; + +import { TimeSegmentsState } from '../../shared.types'; +import { isEverySegmentFilled } from '../isEverySegmentFilled/isEverySegmentFilled'; +import { isEverySegmentValueExplicit } from '../isEverySegmentValueExplicit/isEverySegmentValueExplicit'; + +/** + * Checks if the new date should be set. + * + * The date should be set if one of the following conditions is met: + * - The date is null + * - The date is valid and all segments are explicit + * - The date is invalid and the component is dirty + * - The date is invalid, the component is not dirty, and every segment is filled + * + * @param newDate - The new date to check + * @param isDirty - Whether the component is dirty + * @param segments - The segments to check + * @param is12HourFormat - Whether the time is in 12 hour format + * @returns Whether the new date should be set + */ +export const shouldSetValue = ({ + newDate, + isDirty, + segments, + is12HourFormat, +}: { + newDate: DateType; + isDirty: boolean; + segments: TimeSegmentsState; + is12HourFormat: boolean; +}): boolean => { + // If the date is valid and all segments are explicit, then the value should be set. + const isValidDateAndSegmentsAreExplicit = + isValidDate(newDate) && + isEverySegmentValueExplicit({ + segments, + is12HourFormat, + }); + + // If the date is invalid and the component is dirty, it means the user has interacted with the component and the value should be set. + // If the date is invalid and the component is not dirty and every segment is filled, then the value should be set. (This prevents the value from being set on the very first interaction when not all the segments are filled) + const isInvalidDateObjectAndDirty = + isInvalidDateObject(newDate) && (isDirty || isEverySegmentFilled(segments)); + + const shouldSetValue = + isNull(newDate) || + isValidDateAndSegmentsAreExplicit || + isInvalidDateObjectAndDirty; + + return shouldSetValue; +};