diff --git a/packages/date-utils/src/index.ts b/packages/date-utils/src/index.ts index 57c6a0eaeb..a1a3139ba4 100644 --- a/packages/date-utils/src/index.ts +++ b/packages/date-utils/src/index.ts @@ -21,6 +21,7 @@ export { isOnOrAfter } from './isOnOrAfter'; export { isOnOrBefore } from './isOnOrBefore'; export { isSameTZDay } from './isSameTZDay'; export { isSameTZMonth } from './isSameTZMonth'; +export { isSameUTCDateTime } from './isSameUTCDateTime'; export { isSameUTCDay } from './isSameUTCDay'; export { isSameUTCMonth } from './isSameUTCMonth'; export { isSameUTCRange } from './isSameUTCRange'; diff --git a/packages/date-utils/src/isSameUTCDateTime/index.ts b/packages/date-utils/src/isSameUTCDateTime/index.ts new file mode 100644 index 0000000000..b3312f0a20 --- /dev/null +++ b/packages/date-utils/src/isSameUTCDateTime/index.ts @@ -0,0 +1 @@ +export { isSameUTCDateTime } from './isSameUTCDateTime'; diff --git a/packages/date-utils/src/isSameUTCDateTime/isSameUTCDateTime.spec.ts b/packages/date-utils/src/isSameUTCDateTime/isSameUTCDateTime.spec.ts new file mode 100644 index 0000000000..08f3d4c80c --- /dev/null +++ b/packages/date-utils/src/isSameUTCDateTime/isSameUTCDateTime.spec.ts @@ -0,0 +1,41 @@ +import { isSameUTCDateTime } from './isSameUTCDateTime'; + +describe('packages/time-input/utils/isSameUTCDateTime', () => { + 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(isSameUTCDateTime(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(isSameUTCDateTime(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(isSameUTCDateTime(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(isSameUTCDateTime(date1, date2)).toBe(false); + }); + + test('returns false when one or both dates is null', () => { + expect(isSameUTCDateTime(new Date(), null)).toBe(false); + expect(isSameUTCDateTime(null, new Date())).toBe(false); + expect(isSameUTCDateTime(null, null)).toBe(false); + }); + + test('returns false when one or both dates is invalid', () => { + expect(isSameUTCDateTime(new Date(), new Date('invalid'))).toBe(false); + expect(isSameUTCDateTime(new Date('invalid'), new Date())).toBe(false); + expect(isSameUTCDateTime(new Date('invalid'), new Date('invalid'))).toBe( + false, + ); + }); +}); diff --git a/packages/date-utils/src/isSameUTCDateTime/isSameUTCDateTime.ts b/packages/date-utils/src/isSameUTCDateTime/isSameUTCDateTime.ts new file mode 100644 index 0000000000..fdc708c0cf --- /dev/null +++ b/packages/date-utils/src/isSameUTCDateTime/isSameUTCDateTime.ts @@ -0,0 +1,25 @@ +import { isValidDate } from '../isValidDate'; +import { DateType } from '../types'; + +/** + * Checks if two dates are the same date 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 date and time in UTC + */ +export const isSameUTCDateTime = ( + 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/isSameUTCDay/isSameUTCDay.spec.ts b/packages/date-utils/src/isSameUTCDay/isSameUTCDay.spec.ts index d6385c4c52..8e9b13fcd2 100644 --- a/packages/date-utils/src/isSameUTCDay/isSameUTCDay.spec.ts +++ b/packages/date-utils/src/isSameUTCDay/isSameUTCDay.spec.ts @@ -4,24 +4,24 @@ import { mockTimeZone } from '../testing/mockTimeZone'; import { isSameUTCDay } from '.'; -const TZOffset = -5; +const americaNewYorkTimeZone = 'America/New_York'; describe('packages/date-utils/isSameUTCDay', () => { beforeEach(() => { - mockTimeZone('America/New_York', -5); + mockTimeZone(americaNewYorkTimeZone, -4); // EDT is UTC-4 (4 hours behind UTC) in September }); afterEach(() => { jest.resetAllMocks(); }); describe('when both dates are defined in UTC', () => { - test('returns true', () => { + test('returns true when both dates are equal', () => { const utc1 = newUTC(2023, 8, 1, 0, 0, 0); const utc2 = newUTC(2023, 8, 1, 21, 0, 0); expect(isSameUTCDay(utc1, utc2)).toBe(true); }); - test('returns false', () => { + test('returns false when both dates are not equal', () => { const utc1 = newUTC(2023, 8, 1, 0, 0, 0); const utc2 = newUTC(2023, 8, 2, 0, 0, 0); expect(isSameUTCDay(utc1, utc2)).toBe(false); @@ -29,32 +29,84 @@ describe('packages/date-utils/isSameUTCDay', () => { }); describe('when one date is defined locally', () => { - test('returns true ', () => { - const utc = newUTC(2023, 8, 10, 0, 0, 0); - // '2023-09-09T21:00:00' NY time - const local = newTZDate(TZOffset, 2023, 8, 9, 21, 0); //2023-09-10 02:00:00 UTC + test('returns true when both dates are the same day in UTC', () => { + const utc = newUTC(2023, 8, 10, 0, 0, 0); // September 10, 2023 00:00 UTC + + // September 9, 2023 21:00 EDT => September 10, 2023 01:00 UTC + const local = newTZDate({ + timeZone: americaNewYorkTimeZone, + year: 2023, + month: 8, + date: 9, + hours: 21, + minutes: 0, + }); expect(isSameUTCDay(utc, local)).toBe(true); }); - test('returns false', () => { - const utc = newUTC(2023, 8, 10); - // '2023-09-09T12:00' NY time - const local = newTZDate(TZOffset, 2023, 8, 9, 12, 0); //2023-09-09 17:00:00 UTC + test('returns false when both dates are not the same day in UTC', () => { + const utc = newUTC(2023, 8, 10); // September 10, 2023 00:00 UTC + + // September 9, 2023 12:00 EDT => September 9, 2023 16:00 UTC + const local = newTZDate({ + timeZone: americaNewYorkTimeZone, + year: 2023, + month: 8, + date: 9, + hours: 12, + minutes: 0, + }); + expect(isSameUTCDay(utc, local)).toBe(false); }); }); describe('when both dates are defined locally', () => { - test('returns true', () => { - const local1 = newTZDate(TZOffset, 2023, 8, 8, 20, 0); // 02:00 UTC + 1day - const local2 = newTZDate(TZOffset, 2023, 8, 9, 18, 0); // 23:00 UTC + test('returns true when both dates are the same day in UTC', () => { + // September 8, 2023 20:00 EDT => September 9, 2023 00:00 UTC + const local1 = newTZDate({ + timeZone: americaNewYorkTimeZone, + year: 2023, + month: 8, + date: 8, + hours: 20, + minutes: 0, + }); + + // September 9, 2023 18:00 EDT => September 9, 2023 22:00 UTC + const local2 = newTZDate({ + timeZone: americaNewYorkTimeZone, + year: 2023, + month: 8, + date: 9, + hours: 18, + minutes: 0, + }); expect(isSameUTCDay(local1, local2)).toBe(true); }); - test('returns false', () => { - const local1 = newTZDate(TZOffset, 2023, 8, 9, 0, 0); // 05:00 UTC - const local2 = newTZDate(TZOffset, 2023, 8, 9, 20, 0); // 02:00 UTC +1 day + test('returns false when both dates are not the same day in UTC', () => { + // September 9, 2023 00:00 EDT => September 9, 2023 04:00 UTC + const local1 = newTZDate({ + timeZone: americaNewYorkTimeZone, + year: 2023, + month: 8, + date: 9, + hours: 0, + minutes: 0, + }); + + // September 9, 2023 20:00 EDT => September 10, 2023 00:00 UTC + const local2 = newTZDate({ + timeZone: americaNewYorkTimeZone, + year: 2023, + month: 8, + date: 9, + hours: 20, + minutes: 0, + }); + expect(isSameUTCDay(local1, local2)).toBe(false); }); }); diff --git a/packages/date-utils/src/isSameUTCMonth/isSameUTCMonth.spec.ts b/packages/date-utils/src/isSameUTCMonth/isSameUTCMonth.spec.ts index b10e224ca9..a76c7bde48 100644 --- a/packages/date-utils/src/isSameUTCMonth/isSameUTCMonth.spec.ts +++ b/packages/date-utils/src/isSameUTCMonth/isSameUTCMonth.spec.ts @@ -5,24 +5,25 @@ import { mockTimeZone } from '../testing/mockTimeZone'; import { isSameUTCMonth } from '.'; -const TZOffset = -5; +const TZOffset = -4; +const americaNewYorkTimeZone = 'America/New_York'; describe('packages/date-utils/isSameUTCMonth', () => { beforeEach(() => { - mockTimeZone('America/New_York', TZOffset); + mockTimeZone(americaNewYorkTimeZone, TZOffset); }); afterEach(() => { jest.resetAllMocks(); }); describe('when both dates are defined in UTC', () => { - test('true', () => { + test('returns true when both dates are the same month', () => { const utc1 = newUTC(2023, Month.September, 1); const utc2 = newUTC(2023, Month.September, 10); expect(isSameUTCMonth(utc1, utc2)).toBe(true); }); - test('false', () => { + test('returns false when both dates are not the same month', () => { const utc1 = newUTC(2023, Month.September, 1); const utc2 = newUTC(2023, Month.August, 31); expect(isSameUTCMonth(utc1, utc2)).toBe(false); @@ -30,40 +31,85 @@ describe('packages/date-utils/isSameUTCMonth', () => { }); describe('when one date is defined locally', () => { - test('true', () => { + test('returns true when both dates are the same month in UTC', () => { const utc = newUTC(2023, Month.September, 10); - const local = newTZDate(TZOffset, 2023, Month.August, 31, 21, 0, 0); + + // August 31, 2023 21:00 EDT (UTC-4) => September 01, 2023 01:00 UTC + const local = newTZDate({ + timeZone: americaNewYorkTimeZone, + year: 2023, + month: Month.August, + date: 31, + hours: 21, + minutes: 0, + }); expect(isSameUTCMonth(utc, local)).toBe(true); }); - test('false', () => { + test('returns false when both dates are not the same month in UTC', () => { const utc = newUTC(2023, Month.September, 10); - const local = newTZDate(TZOffset, 2023, Month.August, 31, 12, 0); + // August 31, 2023 12:00 EDT (UTC-4) => August 31, 2023 16:00 UTC + const local = newTZDate({ + timeZone: americaNewYorkTimeZone, + year: 2023, + month: Month.August, + date: 31, + hours: 12, + minutes: 0, + }); expect(isSameUTCMonth(utc, local)).toBe(false); }); }); describe(' when both dates are defined locally', () => { - test('true', () => { - const local1 = newTZDate(TZOffset, 2023, Month.September, 1, 0, 0); - const local2 = newTZDate(TZOffset, 2023, Month.September, 30, 18, 0); + test('returns true when both dates are the same month in UTC', () => { + const local1 = newTZDate({ + timeZone: americaNewYorkTimeZone, + year: 2023, + month: Month.September, + date: 1, + hours: 0, + minutes: 0, + }); + const local2 = newTZDate({ + timeZone: americaNewYorkTimeZone, + year: 2023, + month: Month.September, + date: 30, + hours: 18, + minutes: 0, + }); expect(isSameUTCMonth(local1, local2)).toBe(true); }); - test('false', () => { - const local1 = newTZDate(TZOffset, 2023, Month.September, 1, 0, 0); - const local2 = newTZDate(TZOffset, 2023, Month.September, 30, 20, 0); + test('returns false when both dates are not the same month in UTC', () => { + const local1 = newTZDate({ + timeZone: americaNewYorkTimeZone, + year: 2023, + month: Month.September, + date: 1, + hours: 0, + minutes: 0, + }); + const local2 = newTZDate({ + timeZone: americaNewYorkTimeZone, + year: 2023, + month: Month.September, + date: 30, + hours: 20, + minutes: 0, + }); expect(isSameUTCMonth(local1, local2)).toBe(false); }); }); - test('false: when one or both dates is null', () => { + test('returns false when one or both dates is null', () => { expect(isSameUTCMonth(new Date(), null)).toBe(false); expect(isSameUTCMonth(null, new Date())).toBe(false); expect(isSameUTCMonth(null, null)).toBe(false); }); - test('false for different years', () => { + test('returns false for different years', () => { const utc1 = newUTC(2023, Month.September, 1); const utc2 = newUTC(2024, Month.September, 10); expect(isSameUTCMonth(utc1, utc2)).toBe(false); diff --git a/packages/date-utils/src/newTZDate/newTZDate.spec.ts b/packages/date-utils/src/newTZDate/newTZDate.spec.ts index 7879bea539..53a96a392a 100644 --- a/packages/date-utils/src/newTZDate/newTZDate.spec.ts +++ b/packages/date-utils/src/newTZDate/newTZDate.spec.ts @@ -4,41 +4,118 @@ import { newTZDate } from './newTZDate'; describe('packages/date-utils/newTZDate', () => { test('creates a UTC date when 0 offset is provided', () => { - const date = newTZDate(0, 2020, Month.January, 5); + const date = newTZDate({ + timeZone: 'UTC', // UTC is 0 hours from UTC + year: 2020, + month: Month.January, + date: 5, + }); expect(date.getUTCFullYear()).toBe(2020); expect(date.getUTCMonth()).toBe(0); expect(date.getUTCDate()).toBe(5); expect(date.getUTCHours()).toBe(0); }); - test('positive UTC offset (2020-01-05 UTC => 2020-01-04T19:00 UTC+5)', () => { - const date = newTZDate(5, 2020, Month.January, 5); - expect(date.getUTCFullYear()).toBe(2020); - expect(date.getUTCMonth()).toBe(0); - expect(date.getUTCDate()).toBe(4); - expect(date.getUTCHours()).toBe(19); + test('positive UTC offset (2023-08-15T00:00:00 CEST (UTC+2) => 2023-08-14T22:00:00 UTC)', () => { + const date = newTZDate({ + timeZone: 'Europe/Paris', // Europe/Paris is +2 hours from UTC in August (CEST) + year: 2023, + month: Month.August, + date: 15, + }); + expect(date.getUTCFullYear()).toBe(2023); + expect(date.getUTCMonth()).toBe(7); + expect(date.getUTCDate()).toBe(14); }); - test('positive UTC offset with hours (2020-01-05T23:00 UTC => 2020-01-05T18:00 UTC+5)', () => { - const date = newTZDate(5, 2020, Month.January, 5, 23); + test('positive UTC offset with hours, minutes, and seconds with DST (2023-08-15T23:00 CEST (UTC+2) => 2023-08-15T21:00 UTC)', () => { + const date = newTZDate({ + timeZone: 'Europe/Paris', // Europe/Paris is +2 hours from UTC in August (CEST) + year: 2023, + month: Month.August, + date: 15, + hours: 23, + minutes: 30, + seconds: 45, + }); + expect(date.getUTCFullYear()).toBe(2023); + expect(date.getUTCMonth()).toBe(7); + expect(date.getUTCDate()).toBe(15); + expect(date.getUTCHours()).toBe(21); + expect(date.getUTCMinutes()).toBe(30); + expect(date.getUTCSeconds()).toBe(45); + expect(date.getUTCMilliseconds()).toBe(0); + }); + + test('positive UTC offset with hours, minutes, and seconds without DST (2020-01-15T23:00 CET (UTC+1) => 2020-01-15T22:00 UTC)', () => { + const date = newTZDate({ + timeZone: 'Europe/Paris', // Europe/Paris is +1 hour from UTC in January (CET) + year: 2020, + month: Month.January, + date: 15, + hours: 23, + minutes: 30, + seconds: 45, + }); expect(date.getUTCFullYear()).toBe(2020); expect(date.getUTCMonth()).toBe(0); - expect(date.getUTCDate()).toBe(5); - expect(date.getUTCHours()).toBe(18); + expect(date.getUTCDate()).toBe(15); + expect(date.getUTCHours()).toBe(22); + expect(date.getUTCMinutes()).toBe(30); + expect(date.getUTCSeconds()).toBe(45); + expect(date.getUTCMilliseconds()).toBe(0); }); - test('negative UTC offset (2020-01-05 UTC => 2020-01-05T05:00 UTC-5)', () => { - const date = newTZDate(-5, 2020, Month.January, 5); + test('negative UTC offset (2020-01-05T00:00 EST (UTC-5) => 2020-01-05T05:00 UTC)', () => { + const date = newTZDate({ + timeZone: 'America/New_York', // America/New_York is -5 hours from UTC in January + year: 2020, + month: Month.January, + date: 5, + }); expect(date.getUTCFullYear()).toBe(2020); expect(date.getUTCMonth()).toBe(0); expect(date.getUTCDate()).toBe(5); expect(date.getUTCHours()).toBe(5); }); - test('negative UTC offset with hours (2020-01-05T23:00 UTC => 2020-01-06T04:00 UTC-5)', () => { - const date = newTZDate(-5, 2020, Month.January, 5, 23); + + test('negative UTC offset with hours, minutes, and seconds with DST (2020-01-05T23:30:45 EST (UTC-5) => 2020-01-06T04:30:45 UTC)', () => { + // January 5, 2020 23:00 EST is January 6, 2020 04:00 UTC-5 + const date = newTZDate({ + timeZone: 'America/New_York', // America/New_York is -5 hours from UTC in January + year: 2020, + month: Month.January, + date: 5, + hours: 23, + minutes: 30, + seconds: 45, + }); expect(date.getUTCFullYear()).toBe(2020); expect(date.getUTCMonth()).toBe(0); expect(date.getUTCDate()).toBe(6); expect(date.getUTCHours()).toBe(4); + expect(date.getUTCMinutes()).toBe(30); + expect(date.getUTCSeconds()).toBe(45); + expect(date.getUTCMilliseconds()).toBe(0); + }); + + test('negative UTC offset with hours, minutes, and seconds without DST (2020-05-05T23:30:45 EDT (UTC-4) => 2020-05-06T03:30:45 UTC)', () => { + // May 5, 2020 23:00 EDT is May 6, 2020 03:00 UTC-4 + const date = newTZDate({ + timeZone: 'America/New_York', // America/New_York is -4 hours from UTC in May + year: 2020, + month: Month.May, + date: 5, + hours: 23, + minutes: 30, + seconds: 45, + }); + expect(date.getUTCFullYear()).toBe(2020); + expect(date.getUTCMonth()).toBe(4); + expect(date.getUTCDate()).toBe(6); + expect(date.getUTCHours()).toBe(3); + expect(date.getUTCMinutes()).toBe(30); + expect(date.getUTCSeconds()).toBe(45); + expect(date.getUTCMilliseconds()).toBe(0); }); }); diff --git a/packages/date-utils/src/newTZDate/newTZDate.ts b/packages/date-utils/src/newTZDate/newTZDate.ts index 97c67ce2d7..569e75837a 100644 --- a/packages/date-utils/src/newTZDate/newTZDate.ts +++ b/packages/date-utils/src/newTZDate/newTZDate.ts @@ -1,15 +1,61 @@ +import { getTimezoneOffset } from 'date-fns-tz'; + /** - * Creates a Date object for a given UTC offset. - * @internal + * 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 index (0-11) + * @param date - The day + * @param hours - The hour in 24 hour format + * @param minutes - The minute + * @param seconds - The second + * @param ms - The millisecond + * @param timeZone - The time zone + * @returns The new UTC date + * + * @example + * ```js + * // February 20, 2026 23:00:00 in America/New_York is February 21, 2026 04:00:00 UTC + * newTZDate({ + * timeZone: 'America/New_York', + * year: 2026, + * month: 1, + * date: 20, + * hours: 23, + * minutes: 0, + * seconds: 0, + * }); + * // returns new Date('2026-02-21T04:00:00Z') + * ``` */ -// This API is less than perfect, -// but we shouldn't be constructing many (if any) -// TZ dependent dates other than for testing -export const newTZDate = ( - UTCOffset = 0, - ...args: Parameters -): Date => { - const [y, m, d, h, ...rest] = args; - const hour = (h ?? 0) - UTCOffset; - return new Date(Date.UTC(y, m, d, hour, ...rest)); +export const newTZDate = ({ + timeZone, + year, + month, + date = 1, + hours = 0, + minutes = 0, + seconds = 0, + ms = 0, +}: { + timeZone: string; + year: number; + month: number; + date?: number; + hours?: number; + minutes?: number; + seconds?: number; + ms?: number; +}): Date => { + // Create a new date object in the local time zone + const localDate = new Date(year, month, date, hours, minutes, seconds, ms); + // Get the offset in milliseconds between the local time zone and UTC. We pass the local date to account for DST. + const offsetInMs = getTimezoneOffset(timeZone, localDate); + // Convert the offset in milliseconds to hours + const offsetInHours = offsetInMs / (60 * 60 * 1000); + // Convert the hours to UTC by subtracting the offset in hours + const newHours = (hours ?? 0) - offsetInHours; + + return new Date(Date.UTC(year, month, date, newHours, minutes, seconds, ms)); }; diff --git a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx index c00eadebef..3a6bed92cf 100644 --- a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx +++ b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx @@ -4,10 +4,10 @@ import { unitOptions } from '../constants'; import { useTimeInputContext, useTimeInputDisplayContext } from '../Context'; import { useSelectUnit } from '../hooks'; import { TimeSegmentsState } from '../shared.types'; +import { UnitOption } from '../shared.types'; import { TimeFormField, TimeFormFieldInputContainer } from '../TimeFormField'; import { TimeInputBox } from '../TimeInputBox/TimeInputBox'; import { TimeInputSelect } from '../TimeInputSelect/TimeInputSelect'; -import { UnitOption } from '../TimeInputSelect/TimeInputSelect.types'; import { getFormatPartsValues } from '../utils'; import { wrapperBaseStyles } from './TimeInputInputs.styles'; diff --git a/packages/time-input/src/TimeInputSelect/TimeInputSelect.tsx b/packages/time-input/src/TimeInputSelect/TimeInputSelect.tsx index 5305a70c88..c70f2a752b 100644 --- a/packages/time-input/src/TimeInputSelect/TimeInputSelect.tsx +++ b/packages/time-input/src/TimeInputSelect/TimeInputSelect.tsx @@ -10,9 +10,10 @@ import { import { unitOptions } from '../constants'; import { useTimeInputDisplayContext } from '../Context'; +import { UnitOption } from '../shared.types'; import { selectStyles, wrapperBaseStyles } from './TimeInputSelect.styles'; -import { TimeInputSelectProps, UnitOption } from './TimeInputSelect.types'; +import { TimeInputSelectProps } from './TimeInputSelect.types'; /** * @internal diff --git a/packages/time-input/src/TimeInputSelect/TimeInputSelect.types.ts b/packages/time-input/src/TimeInputSelect/TimeInputSelect.types.ts index f59cd87c68..285b61208f 100644 --- a/packages/time-input/src/TimeInputSelect/TimeInputSelect.types.ts +++ b/packages/time-input/src/TimeInputSelect/TimeInputSelect.types.ts @@ -1,7 +1,4 @@ -export interface UnitOption { - displayName: string; - value: string; -} +import { UnitOption } from '../shared.types'; export interface TimeInputSelectProps { /** diff --git a/packages/time-input/src/constants.ts b/packages/time-input/src/constants.ts index a0881fb628..916412d85f 100644 --- a/packages/time-input/src/constants.ts +++ b/packages/time-input/src/constants.ts @@ -6,7 +6,7 @@ import { DateTimeParts } from './shared.types'; export const unitOptions = [ { displayName: 'AM', value: 'AM' }, { displayName: 'PM', value: 'PM' }, -]; +] as const; /** * The default placeholders for each segment diff --git a/packages/time-input/src/hooks/useSelectUnit/useSelectUnit.ts b/packages/time-input/src/hooks/useSelectUnit/useSelectUnit.ts index fa4ab18565..786ae97c8e 100644 --- a/packages/time-input/src/hooks/useSelectUnit/useSelectUnit.ts +++ b/packages/time-input/src/hooks/useSelectUnit/useSelectUnit.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { DateType, isValidDate } from '@leafygreen-ui/date-utils'; -import { UnitOption } from '../../TimeInputSelect/TimeInputSelect.types'; +import { DayPeriod, UnitOption, UnitOptions } from '../../shared.types'; interface UseSelectUnitReturn { selectUnit: UnitOption; @@ -17,8 +17,8 @@ interface UseSelectUnitReturn { * @returns The select unit option. */ const findSelectUnit = ( - dayPeriod: string, - unitOptions: Array, + dayPeriod: DayPeriod, + unitOptions: UnitOptions, ): UnitOption => { const selectUnitOption = unitOptions.find( option => option.displayName === dayPeriod, @@ -39,9 +39,9 @@ export const useSelectUnit = ({ value, unitOptions, }: { - dayPeriod: string; + dayPeriod: DayPeriod; value: DateType | undefined; - unitOptions: Array; + unitOptions: UnitOptions; }): UseSelectUnitReturn => { const selectUnitOption = findSelectUnit(dayPeriod, unitOptions); const [selectUnit, setSelectUnit] = useState(selectUnitOption); diff --git a/packages/time-input/src/shared.types.ts b/packages/time-input/src/shared.types.ts index 53f5bf28cd..d1c7ae49a9 100644 --- a/packages/time-input/src/shared.types.ts +++ b/packages/time-input/src/shared.types.ts @@ -1,20 +1,6 @@ import { InputSegmentChangeEventHandler } from '@leafygreen-ui/input-box'; -import { keyMap } from '@leafygreen-ui/lib'; -export const DateTimePartKeys = { - hour: 'hour', - minute: 'minute', - second: 'second', - month: 'month', - day: 'day', - year: 'year', - dayPeriod: 'dayPeriod', -} as const; - -export type DateTimePartKeys = - (typeof DateTimePartKeys)[keyof typeof DateTimePartKeys]; - -export type DateTimeParts = Record; +import { unitOptions } from './constants'; /** * An enumerable object that maps the time segment names to their values @@ -29,6 +15,41 @@ export type TimeSegment = (typeof TimeSegment)[keyof typeof TimeSegment]; export type TimeSegmentsState = Record; +/** + * An enumerable object that maps the date and time segment names to their values + */ +export const DateTimePartKeys = { + ...TimeSegment, + Month: 'month', + Day: 'day', + Year: 'year', + DayPeriod: 'dayPeriod', +} as const; + +export type DateTimePartKeys = + (typeof DateTimePartKeys)[keyof typeof DateTimePartKeys]; + +export type DateTimePartKeysWithoutDayPeriod = Exclude< + DateTimePartKeys, + typeof DateTimePartKeys.DayPeriod +>; + +export type DateTimeParts = Record & { + [DateTimePartKeys.DayPeriod]: DayPeriod; +}; + +export type DateParts = Pick; + +/* + * 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 handler */ @@ -36,3 +57,13 @@ export type TimeInputSegmentChangeEventHandler = InputSegmentChangeEventHandler< TimeSegment, string >; + +/** + * The type for the unit options + */ +export type UnitOptions = typeof unitOptions; + +/** + * The type for the unit option + */ +export type UnitOption = (typeof unitOptions)[number]; 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..8d654d8fe7 --- /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('packages/time-input/utils/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/convert12hTo24h/index.ts b/packages/time-input/src/utils/convert12hTo24h/index.ts new file mode 100644 index 0000000000..89c294dbd9 --- /dev/null +++ b/packages/time-input/src/utils/convert12hTo24h/index.ts @@ -0,0 +1 @@ +export { convert12hTo24h } from './convert12hTo24h'; 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..c0a91983db --- /dev/null +++ b/packages/time-input/src/utils/doesSomeSegmentExist/doesSomeSegmentExist.spec.ts @@ -0,0 +1,21 @@ +import { doesSomeSegmentExist } from './doesSomeSegmentExist'; + +describe('packages/time-input/utils/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..54e6555022 --- /dev/null +++ b/packages/time-input/src/utils/doesSomeSegmentExist/doesSomeSegmentExist.ts @@ -0,0 +1,11 @@ +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) => { + return Object.values(segments).some(segment => segment !== ''); +}; diff --git a/packages/time-input/src/utils/doesSomeSegmentExist/index.ts b/packages/time-input/src/utils/doesSomeSegmentExist/index.ts new file mode 100644 index 0000000000..27cd1a6ec2 --- /dev/null +++ b/packages/time-input/src/utils/doesSomeSegmentExist/index.ts @@ -0,0 +1 @@ +export { doesSomeSegmentExist } from './doesSomeSegmentExist'; 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..feab2d245f --- /dev/null +++ b/packages/time-input/src/utils/findUnitOptionByDayPeriod/findUnitOptionByDayPeriod.spec.ts @@ -0,0 +1,14 @@ +import { unitOptions } from '../../constants'; + +import { findUnitOptionByDayPeriod } from './findUnitOptionByDayPeriod'; + +describe('packages/time-input/utils/findUnitOptionByDayPeriod', () => { + test('returns the unit option by day period', () => { + expect(findUnitOptionByDayPeriod('AM', unitOptions)).toEqual( + unitOptions[0], + ); + expect(findUnitOptionByDayPeriod('PM', unitOptions)).toEqual( + unitOptions[1], + ); + }); +}); 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..74458fe754 --- /dev/null +++ b/packages/time-input/src/utils/findUnitOptionByDayPeriod/findUnitOptionByDayPeriod.ts @@ -0,0 +1,15 @@ +import { DayPeriod, UnitOption, UnitOptions } from '../../shared.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: UnitOptions, +): UnitOption => { + return unitOptions.find(option => option.displayName === dayPeriod)!; +}; diff --git a/packages/time-input/src/utils/findUnitOptionByDayPeriod/index.ts b/packages/time-input/src/utils/findUnitOptionByDayPeriod/index.ts new file mode 100644 index 0000000000..f0b32b3c0c --- /dev/null +++ b/packages/time-input/src/utils/findUnitOptionByDayPeriod/index.ts @@ -0,0 +1 @@ +export { findUnitOptionByDayPeriod } from './findUnitOptionByDayPeriod'; diff --git a/packages/time-input/src/utils/getDefaultMin/getDefaultMin.spec.ts b/packages/time-input/src/utils/getDefaultMin/getDefaultMin.spec.ts index 70ad13940e..99d07badf8 100644 --- a/packages/time-input/src/utils/getDefaultMin/getDefaultMin.spec.ts +++ b/packages/time-input/src/utils/getDefaultMin/getDefaultMin.spec.ts @@ -1,7 +1,7 @@ import { getDefaultMin } from './getDefaultMin'; describe('packages/time-input/utils/getDefaultMin', () => { - test('returns the default min', () => { + test('returns the default min for 12 hour format', () => { const defaultMin = getDefaultMin({ is12HourFormat: true }); expect(defaultMin).toEqual({ hour: 1, diff --git a/packages/time-input/src/utils/getFormatPartsValues/getFormatPartsValues.spec.ts b/packages/time-input/src/utils/getFormatPartsValues/getFormatPartsValues.spec.ts index 8091def990..f177622430 100644 --- a/packages/time-input/src/utils/getFormatPartsValues/getFormatPartsValues.spec.ts +++ b/packages/time-input/src/utils/getFormatPartsValues/getFormatPartsValues.spec.ts @@ -3,190 +3,186 @@ import { Month, SupportedLocales } from '@leafygreen-ui/date-utils'; import { getFormatPartsValues } from './getFormatPartsValues'; describe('packages/time-input/utils/getFormatPartsValues', () => { - describe('returns the correct values', () => { - beforeEach(() => { - // Mock the current date/time in UTC - jest.useFakeTimers().setSystemTime( - new Date(Date.UTC(2025, Month.January, 1, 0, 0, 0)), // January 1, 2025 00:00:00 UTC - ); - }); + beforeEach(() => { + // Mock the current date/time in UTC + jest.useFakeTimers().setSystemTime( + new Date(Date.UTC(2025, Month.January, 1, 0, 0, 0)), // January 1, 2025 00:00:00 UTC + ); + }); - afterEach(() => { - jest.useRealTimers(); - }); + afterEach(() => { + jest.useRealTimers(); + }); - describe.each([undefined, new Date('invalid')])( - 'when the value is %p', - value => { - describe('and the time zone is', () => { - test('UTC', () => { - const formatPartsValues = getFormatPartsValues({ - locale: SupportedLocales.ISO_8601, - timeZone: 'UTC', - value, - }); - // January 1, 2025 00:00:00 UTC in UTC is January 1, 2025 00:00:00 (UTC) - expect(formatPartsValues).toEqual({ - hour: '', - minute: '', - second: '', - month: '1', - day: '1', - year: '2025', - dayPeriod: 'AM', // This is the default value for the day period since iso-8601 is 24h format - }); - }); + describe.each([undefined, new Date('invalid')])( + 'when the value is %p', + value => { + test('UTC returns the correct values', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.ISO_8601, + timeZone: 'UTC', + value, + }); + // January 1, 2025 00:00:00 UTC in UTC is January 1, 2025 00:00:00 (UTC) + expect(formatPartsValues).toEqual({ + hour: '', + minute: '', + second: '', + month: '1', + day: '1', + year: '2025', + dayPeriod: 'AM', // This is the default value for the day period since iso-8601 is 24h format + }); + }); - test('America/New_York', () => { - const formatPartsValues = getFormatPartsValues({ - locale: SupportedLocales.ISO_8601, - timeZone: 'America/New_York', - value, - }); - // January 1, 2025 00:00:00 UTC in America/New_York is December 31, 2024 19:00:00 (UTC-5 hours) - expect(formatPartsValues).toEqual({ - hour: '', - minute: '', - second: '', - month: '12', - day: '31', - year: '2024', - dayPeriod: 'AM', // This is the default value for the day period since iso is 24h format - }); - }); + test('America/New_York returns the correct values', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.ISO_8601, + timeZone: 'America/New_York', + value, + }); + // January 1, 2025 00:00:00 UTC in America/New_York is December 31, 2024 19:00:00 (UTC-5 hours) + expect(formatPartsValues).toEqual({ + hour: '', + minute: '', + second: '', + month: '12', + day: '31', + year: '2024', + dayPeriod: 'AM', // This is the default value for the day period since iso is 24h format + }); + }); - test('Pacific/Auckland', () => { - const formatPartsValues = getFormatPartsValues({ - locale: SupportedLocales.ISO_8601, - timeZone: 'Pacific/Auckland', - value, - }); - // January 1, 2025 00:00:00 UTC in Pacific/Auckland is January 1, 2025 (UTC+13 hours) - expect(formatPartsValues).toEqual({ - hour: '', - minute: '', - second: '', - month: '1', - day: '1', - year: '2025', - dayPeriod: 'AM', - }); - }); + test('Pacific/Auckland returns the correct values', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.ISO_8601, + timeZone: 'Pacific/Auckland', + value, }); - }, - ); + // January 1, 2025 00:00:00 UTC in Pacific/Auckland is January 1, 2025 (UTC+13 hours) + expect(formatPartsValues).toEqual({ + hour: '', + minute: '', + second: '', + month: '1', + day: '1', + year: '2025', + dayPeriod: 'AM', + }); + }); + }, + ); - describe('when the value is defined', () => { - const utcValue = new Date(Date.UTC(2025, Month.February, 20, 13, 30, 59)); // February 20, 2025 13:30:59 UTC + describe('when the value is defined', () => { + const utcValue = new Date(Date.UTC(2025, Month.February, 20, 13, 30, 59)); // February 20, 2025 13:30:59 UTC - describe('and the time zone is', () => { - describe('UTC', () => { - test('24 hour format', () => { - const formatPartsValues = getFormatPartsValues({ - locale: SupportedLocales.ISO_8601, - timeZone: 'UTC', - value: utcValue, - }); - expect(formatPartsValues).toEqual({ - hour: '13', - minute: '30', - second: '59', - month: '2', - day: '20', - year: '2025', - dayPeriod: 'AM', - }); + describe('and the time zone is', () => { + describe('UTC', () => { + test('24 hour format returns the correct values', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.ISO_8601, + timeZone: 'UTC', + value: utcValue, + }); + expect(formatPartsValues).toEqual({ + hour: '13', + minute: '30', + second: '59', + month: '2', + day: '20', + year: '2025', + dayPeriod: 'AM', }); + }); - test('12 hour format', () => { - const formatPartsValues = getFormatPartsValues({ - locale: SupportedLocales.en_US, - timeZone: 'UTC', - value: utcValue, - }); - expect(formatPartsValues).toEqual({ - hour: '1', - minute: '30', - second: '59', - month: '2', - day: '20', - year: '2025', - dayPeriod: 'PM', - }); + test('12 hour format returns the correct values', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.en_US, + timeZone: 'UTC', + value: utcValue, + }); + expect(formatPartsValues).toEqual({ + hour: '1', + minute: '30', + second: '59', + month: '2', + day: '20', + year: '2025', + dayPeriod: 'PM', }); }); + }); - describe('America/New_York', () => { - test('24 hour format', () => { - const formatPartsValues = getFormatPartsValues({ - locale: SupportedLocales.ISO_8601, - timeZone: 'America/New_York', - value: utcValue, - }); - // February 20, 2025 13:30:59 UTC in America/New_York is 08:30:59 (UTC-5 hours) - expect(formatPartsValues).toEqual({ - hour: '08', - minute: '30', - second: '59', - month: '2', - day: '20', - year: '2025', - dayPeriod: 'AM', - }); + describe('America/New_York', () => { + test('24 hour format returns the correct values', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.ISO_8601, + timeZone: 'America/New_York', + value: utcValue, }); - test('12 hour format', () => { - const formatPartsValues = getFormatPartsValues({ - locale: SupportedLocales.en_US, - timeZone: 'America/New_York', - value: utcValue, - }); - // February 20, 2025 13:30:59 UTC in America/New_York is 8:30:59 AM (UTC-5 hours) - expect(formatPartsValues).toEqual({ - hour: '8', - minute: '30', - second: '59', - month: '2', - day: '20', - year: '2025', - dayPeriod: 'AM', - }); + // February 20, 2025 13:30:59 UTC in America/New_York is 08:30:59 (UTC-5 hours) + expect(formatPartsValues).toEqual({ + hour: '08', + minute: '30', + second: '59', + month: '2', + day: '20', + year: '2025', + dayPeriod: 'AM', }); }); + test('12 hour format returns the correct values', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.en_US, + timeZone: 'America/New_York', + value: utcValue, + }); + // February 20, 2025 13:30:59 UTC in America/New_York is 8:30:59 AM (UTC-5 hours) + expect(formatPartsValues).toEqual({ + hour: '8', + minute: '30', + second: '59', + month: '2', + day: '20', + year: '2025', + dayPeriod: 'AM', + }); + }); + }); - describe('Pacific/Auckland', () => { - test('24 hour format', () => { - const formatPartsValues = getFormatPartsValues({ - locale: SupportedLocales.ISO_8601, - timeZone: 'Pacific/Auckland', - value: utcValue, - }); - // February 20, 2025 13:30:59 UTC in Pacific/Auckland is February 21, 2025 02:30:59 (UTC+13 hours) - expect(formatPartsValues).toEqual({ - hour: '02', - minute: '30', - second: '59', - month: '2', - day: '21', - year: '2025', - dayPeriod: 'AM', - }); + describe('Pacific/Auckland', () => { + test('24 hour format returns the correct values', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.ISO_8601, + timeZone: 'Pacific/Auckland', + value: utcValue, + }); + // February 20, 2025 13:30:59 UTC in Pacific/Auckland is February 21, 2025 02:30:59 (UTC+13 hours) + expect(formatPartsValues).toEqual({ + hour: '02', + minute: '30', + second: '59', + month: '2', + day: '21', + year: '2025', + dayPeriod: 'AM', + }); + }); + test('12 hour format returns the correct values', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.en_US, + timeZone: 'Pacific/Auckland', + value: utcValue, }); - test('12 hour format', () => { - const formatPartsValues = getFormatPartsValues({ - locale: SupportedLocales.en_US, - timeZone: 'Pacific/Auckland', - value: utcValue, - }); - // February 20, 2025 13:30:59 UTC in Pacific/Auckland is February 21, 2025 2:30:59 AM (UTC+13 hours) - expect(formatPartsValues).toEqual({ - hour: '2', - minute: '30', - second: '59', - month: '2', - day: '21', - year: '2025', - dayPeriod: 'AM', - }); + // February 20, 2025 13:30:59 UTC in Pacific/Auckland is February 21, 2025 2:30:59 AM (UTC+13 hours) + expect(formatPartsValues).toEqual({ + hour: '2', + minute: '30', + second: '59', + month: '2', + day: '21', + year: '2025', + dayPeriod: 'AM', }); }); }); diff --git a/packages/time-input/src/utils/getFormatPartsValues/getFormattedDateTimeParts/getFormattedDateTimeParts.spec.ts b/packages/time-input/src/utils/getFormatPartsValues/getFormattedDateTimeParts/getFormattedDateTimeParts.spec.ts index 219e13498f..fe2d3b4bc8 100644 --- a/packages/time-input/src/utils/getFormatPartsValues/getFormattedDateTimeParts/getFormattedDateTimeParts.spec.ts +++ b/packages/time-input/src/utils/getFormatPartsValues/getFormattedDateTimeParts/getFormattedDateTimeParts.spec.ts @@ -1,7 +1,7 @@ import { getFormattedDateTimeParts } from './getFormattedDateTimeParts'; describe('packages/time-input/utils/getFormattedDateTimeParts', () => { - test('returns the formatted date time parts with the default date time parts', () => { + test('returns the formatted date time parts with the default date time parts when not all date time parts are present', () => { const formattedDateTimeParts = getFormattedDateTimeParts([ { type: 'day', value: '12' }, { type: 'month', value: '01' }, @@ -18,7 +18,7 @@ describe('packages/time-input/utils/getFormattedDateTimeParts', () => { }); }); - test('returns the formatted time parts without the default time parts', () => { + test('returns the formatted date time parts without the default date time parts when all date time parts are present', () => { const formattedDateTimeParts = getFormattedDateTimeParts([ { type: 'hour', value: '12' }, { type: 'minute', value: '30' }, @@ -39,7 +39,7 @@ describe('packages/time-input/utils/getFormattedDateTimeParts', () => { }); }); - test('returns the formatted time parts with the default time parts when time parts is an empty array', () => { + test('returns the formatted date time parts with the default date time parts when date time parts is an empty array', () => { const formattedDateTimeParts = getFormattedDateTimeParts([]); expect(formattedDateTimeParts).toEqual({ hour: '', diff --git a/packages/time-input/src/utils/getFormatPartsValues/getFormattedDateTimeParts/getFormattedDateTimeParts.ts b/packages/time-input/src/utils/getFormatPartsValues/getFormattedDateTimeParts/getFormattedDateTimeParts.ts index 301659a153..3a516fe564 100644 --- a/packages/time-input/src/utils/getFormatPartsValues/getFormattedDateTimeParts/getFormattedDateTimeParts.ts +++ b/packages/time-input/src/utils/getFormatPartsValues/getFormattedDateTimeParts/getFormattedDateTimeParts.ts @@ -1,7 +1,12 @@ import defaultsDeep from 'lodash/defaultsDeep'; import { defaultDateTimeParts } from '../../../constants'; -import { DateTimePartKeys, DateTimeParts } from '../../../shared.types'; +import { + DateTimePartKeys, + DateTimePartKeysWithoutDayPeriod, + DateTimeParts, + DayPeriod, +} from '../../../shared.types'; /** * Returns the formatted date time parts. @@ -32,13 +37,15 @@ import { DateTimePartKeys, DateTimeParts } from '../../../shared.types'; export const getFormattedDateTimeParts = ( dateTimeParts: Array, ): DateTimeParts => { - const formattedDateTimeParts: DateTimeParts = dateTimeParts.reduce( - (acc, part) => { - acc[part.type as DateTimePartKeys] = part.value; - return acc; - }, - {} as DateTimeParts, - ); + const formattedDateTimeParts = dateTimeParts.reduce((acc, part) => { + if (part.type === 'dayPeriod') { + acc.dayPeriod = part.value as DayPeriod; + } else if (Object.values(DateTimePartKeys).includes(part.type as any)) { + acc[part.type as DateTimePartKeysWithoutDayPeriod] = part.value; + } + + return acc; + }, {} as DateTimeParts); const mergedTimeParts: DateTimeParts = defaultsDeep( formattedDateTimeParts, diff --git a/packages/time-input/src/utils/getFormatter/getFormatter.spec.ts b/packages/time-input/src/utils/getFormatter/getFormatter.spec.ts index cc2d0f2cf4..5ba408d353 100644 --- a/packages/time-input/src/utils/getFormatter/getFormatter.spec.ts +++ b/packages/time-input/src/utils/getFormatter/getFormatter.spec.ts @@ -28,15 +28,15 @@ describe('packages/time-input/utils/getFormatter', () => { expect(formatter).toBeUndefined(); }); - describe('formatter can ', () => { - test('format dates', () => { + describe('formatter methods', () => { + test('format() returns formatted date string', () => { const testDate = new Date('2025-01-15T14:30:00Z'); const formatter = getFormatter({ locale: SupportedLocales.en_US }); const formatted = formatter?.format(testDate); expect(formatted).toBe('1/15/2025'); }); - test('formatToParts', () => { + test('formatToParts() returns date parts array', () => { const testDate = new Date('2025-01-15T14:30:00Z'); const formatter = getFormatter({ locale: SupportedLocales.en_US }); const formatParts = formatter?.formatToParts(testDate); 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..9786166075 --- /dev/null +++ b/packages/time-input/src/utils/getNewUTCDateFromSegments/getNewUTCDateFromSegments.spec.ts @@ -0,0 +1,594 @@ +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', () => { + test('PM 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)), + ); + }); + + test('AM 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', () => { + test('returns the UTC date', () => { + const segments = { + hour: '14', + minute: '00', + second: '00', + }; + const newDate = getNewUTCDateFromSegments({ + segments, + is12HourFormat: false, + timeZone: 'UTC', + dayPeriod: 'AM', // This is not used for 24h format + dateValues: { + day: '20', + month: '02', + year: '2026', + }, + }); // February 20, 2026 14:00:00 UTC + + // February 20, 2026 14:00:00 in UTC is February 20, 2026 14:00:00 UTC + expect(newDate).toEqual( + new Date(Date.UTC(2026, Month.February, 20, 14, 0, 0)), + ); + }); + }); + }); + + describe('America/New_York', () => { + describe('EST (UTC-5)', () => { + describe('12h format', () => { + test('PM 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)), + ); + }); + test('AM 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', () => { + test('PM 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)), + ); + }); + test('AM 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', () => { + test('PM 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)), + ); + }); + test('AM 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('12h format', () => { + test('PM 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)), + ); + }); + + test('AM 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', () => { + test('invalid hour returns an invalid date object', () => { + 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('empty hour returns an invalid date object', () => { + 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', () => { + test('invalid hour returns an invalid date object', () => { + 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('empty hour returns an invalid date object', () => { + 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(); + }); + }); + + test('invalid minute returns an invalid date object', () => { + 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('empty minute returns an invalid date object', () => { + 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(); + }); + + test('invalid second returns an invalid date object', () => { + 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('empty second returns an invalid date object', () => { + 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..215ac59842 --- /dev/null +++ b/packages/time-input/src/utils/getNewUTCDateFromSegments/getNewUTCDateFromSegments.ts @@ -0,0 +1,68 @@ +import { newTZDate } from '@leafygreen-ui/date-utils'; + +import { DateParts, 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: DateParts; + dayPeriod: DayPeriod; +}) => { + const { day, month, year } = dateValues; + const { hour, minute, second } = segments; + + const converted12hTo24hHour = is12HourFormat + ? convert12hTo24h(Number(hour), dayPeriod) + : hour; + + /** + * All segments are empty + */ + if (!doesSomeSegmentExist(segments)) { + return null; + } + + /** + * Not all segments are filled or valid + */ + if ( + !isEverySegmentFilled(segments) || + !isEverySegmentValid({ segments, is12HourFormat }) + ) { + return new Date('invalid'); + } + + /** + * All segments are filled and valid (not necessarily explicit) + */ + return newTZDate({ + year: Number(year), + month: Number(month) - 1, + date: Number(day), + hours: Number(converted12hTo24hHour), + minutes: Number(minute), + seconds: Number(second), + timeZone, + }); +}; diff --git a/packages/time-input/src/utils/getNewUTCDateFromSegments/index.ts b/packages/time-input/src/utils/getNewUTCDateFromSegments/index.ts new file mode 100644 index 0000000000..f526cc20d2 --- /dev/null +++ b/packages/time-input/src/utils/getNewUTCDateFromSegments/index.ts @@ -0,0 +1 @@ +export { getNewUTCDateFromSegments } from './getNewUTCDateFromSegments'; 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..467396c40f --- /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 if all segments are not 0', () => { + 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/getPaddedTimeSegmentsFromDate/index.ts b/packages/time-input/src/utils/getPaddedTimeSegmentsFromDate/index.ts new file mode 100644 index 0000000000..0e0e77c8f5 --- /dev/null +++ b/packages/time-input/src/utils/getPaddedTimeSegmentsFromDate/index.ts @@ -0,0 +1 @@ +export { getPaddedTimeSegmentsFromDate } from './getPaddedTimeSegmentsFromDate'; diff --git a/packages/time-input/src/utils/getTimeSegmentRules/getTimeSegmentRules.spec.ts b/packages/time-input/src/utils/getTimeSegmentRules/getTimeSegmentRules.spec.ts index 9727ee8b0e..0f9792e641 100644 --- a/packages/time-input/src/utils/getTimeSegmentRules/getTimeSegmentRules.spec.ts +++ b/packages/time-input/src/utils/getTimeSegmentRules/getTimeSegmentRules.spec.ts @@ -3,7 +3,7 @@ import { TimeSegment } from '../../shared.types'; import { getTimeSegmentRules } from './getTimeSegmentRules'; describe('packages/time-input/utils/getTimeSegmentRules', () => { - test('returns the time segment rules', () => { + test('returns the time segment rules for 12 hour format', () => { const timeSegmentRules = getTimeSegmentRules({ is12HourFormat: true }); expect(timeSegmentRules).toEqual({ [TimeSegment.Hour]: { maxChars: 2, minExplicitValue: 2 }, diff --git a/packages/time-input/src/utils/hasDayPeriod/hasDayPeriod.ts b/packages/time-input/src/utils/hasDayPeriod/hasDayPeriod.ts index 6e198b0e92..063dda2290 100644 --- a/packages/time-input/src/utils/hasDayPeriod/hasDayPeriod.ts +++ b/packages/time-input/src/utils/hasDayPeriod/hasDayPeriod.ts @@ -31,7 +31,7 @@ export const hasDayPeriod = (locale: LocaleString) => { // Format a sample time and check for dayPeriod (AM/PM) const parts = formatter.formatToParts(new Date()); const hasDayPeriod = parts.some( - part => part.type === DateTimePartKeys.dayPeriod, + part => part.type === DateTimePartKeys.DayPeriod, ); return hasDayPeriod; diff --git a/packages/time-input/src/utils/index.ts b/packages/time-input/src/utils/index.ts index deb40e00d1..42d9154f15 100644 --- a/packages/time-input/src/utils/index.ts +++ b/packages/time-input/src/utils/index.ts @@ -1,8 +1,17 @@ +export { convert12hTo24h } from './convert12hTo24h'; +export { doesSomeSegmentExist } from './doesSomeSegmentExist'; +export { findUnitOptionByDayPeriod } from './findUnitOptionByDayPeriod'; export { getDefaultMax } from './getDefaultMax'; export { getDefaultMin } from './getDefaultMin'; export { getFormatParts } from './getFormatParts'; export { getFormatPartsValues } from './getFormatPartsValues'; export { getFormatter } from './getFormatter'; export { getLgIds } from './getLgIds'; +export { getNewUTCDateFromSegments } from './getNewUTCDateFromSegments'; +export { getPaddedTimeSegmentsFromDate } from './getPaddedTimeSegmentsFromDate'; export { getTimeSegmentRules } from './getTimeSegmentRules'; export { hasDayPeriod } from './hasDayPeriod'; +export { isEverySegmentFilled } from './isEverySegmentFilled'; +export { isEverySegmentValid } from './isEverySegmentValid'; +export { isEverySegmentValueExplicit } from './isEverySegmentValueExplicit'; +export { shouldSetValue } from './shouldSetValue'; diff --git a/packages/time-input/src/utils/isEverySegmentFilled/index.ts b/packages/time-input/src/utils/isEverySegmentFilled/index.ts new file mode 100644 index 0000000000..9e54a49314 --- /dev/null +++ b/packages/time-input/src/utils/isEverySegmentFilled/index.ts @@ -0,0 +1 @@ +export { isEverySegmentFilled } from './isEverySegmentFilled'; 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..ab1d4a7b6e --- /dev/null +++ b/packages/time-input/src/utils/isEverySegmentFilled/isEverySegmentFilled.ts @@ -0,0 +1,11 @@ +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) => { + return Object.values(segments).every(segment => segment !== ''); +}; diff --git a/packages/time-input/src/utils/isEverySegmentValid/index.ts b/packages/time-input/src/utils/isEverySegmentValid/index.ts new file mode 100644 index 0000000000..3dec1d2d9c --- /dev/null +++ b/packages/time-input/src/utils/isEverySegmentValid/index.ts @@ -0,0 +1 @@ +export { isEverySegmentValid } from './isEverySegmentValid'; 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..df146634da --- /dev/null +++ b/packages/time-input/src/utils/isEverySegmentValid/isEverySegmentValid.spec.ts @@ -0,0 +1,159 @@ +import { getDefaultMax } from '../getDefaultMax'; +import { getDefaultMin } from '../getDefaultMin'; + +import { isEverySegmentValid } from './isEverySegmentValid'; + +describe('isEverySegmentValueValid', () => { + describe('12 hour format', () => { + const defaultMinValues = getDefaultMin({ is12HourFormat: true }); + const defaultMaxValues = getDefaultMax({ is12HourFormat: true }); + + 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: defaultMinValues['hour'].toString(), + minute: defaultMinValues['minute'].toString(), + second: defaultMinValues['second'].toString(), + }, + is12HourFormat: true, + }), + ).toBe(true); + }); + + test('returns true if all segments are at the default max', () => { + expect( + isEverySegmentValid({ + segments: { + hour: defaultMaxValues['hour'].toString(), + minute: defaultMaxValues['minute'].toString(), + second: defaultMaxValues['second'].toString(), + }, + is12HourFormat: true, + }), + ).toBe(true); + }); + + describe('hour', () => { + test('returns false if hour is greater than the default max', () => { + expect( + isEverySegmentValid({ + segments: { + hour: (defaultMaxValues['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: (defaultMaxValues['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: (defaultMaxValues['second'] + 1).toString(), + }, + is12HourFormat: true, + }), + ).toBe(false); + }); + }); + }); + + describe('24 hour format', () => { + const defaultMaxValues = getDefaultMax({ is12HourFormat: false }); + + 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: (defaultMaxValues['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: (defaultMaxValues['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: (defaultMaxValues['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..edf22a31b0 --- /dev/null +++ b/packages/time-input/src/utils/isEverySegmentValid/isEverySegmentValid.ts @@ -0,0 +1,39 @@ +import { isValidValueForSegment } from '@leafygreen-ui/input-box'; + +import { TimeSegment, TimeSegmentsState } from '../../shared.types'; +import { getDefaultMax } from '../getDefaultMax'; +import { getDefaultMin } from '../getDefaultMin'; + +/** + * 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 defaultMinValues = getDefaultMin({ is12HourFormat }); + const defaultMaxValues = getDefaultMax({ is12HourFormat }); + + const isEverySegmentValid = ( + Object.entries(segments) as Array<[TimeSegment, string]> + ).every(([segment, value]) => { + const isSegmentValid = isValidValueForSegment({ + segment: segment, + value: value, + defaultMin: defaultMinValues[segment], + defaultMax: defaultMaxValues[segment], + segmentEnum: TimeSegment, + }); + + return isSegmentValid; + }); + + return isEverySegmentValid; +}; diff --git a/packages/time-input/src/utils/isEverySegmentValueExplicit/index.ts b/packages/time-input/src/utils/isEverySegmentValueExplicit/index.ts new file mode 100644 index 0000000000..f808d60b69 --- /dev/null +++ b/packages/time-input/src/utils/isEverySegmentValueExplicit/index.ts @@ -0,0 +1 @@ +export { isEverySegmentValueExplicit } from './isEverySegmentValueExplicit'; 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..563dfeac1c --- /dev/null +++ b/packages/time-input/src/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.spec.ts @@ -0,0 +1,157 @@ +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 segment', () => { + 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('for single digit hour 1', () => { + expect( + isEverySegmentValueExplicit({ + segments: { hour: '1', minute: '00', second: '00' }, + is12HourFormat: true, + }), + ).toBe(false); + }); + }); + + describe('returns true', () => { + test.each(range(2, 10))('for single digit hour %i', i => { + expect( + isEverySegmentValueExplicit({ + segments: { hour: i.toString(), minute: '00', second: '00' }, + is12HourFormat: true, + }), + ).toBe(true); + }); + + test.each(range(11, 13))('for double digit hour %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 segment', () => { + describe('returns false', () => { + test.each(range(0, 3))('for single digit hour %i', i => { + expect( + isEverySegmentValueExplicit({ + segments: { hour: i.toString(), minute: '00', second: '00' }, + is12HourFormat: false, + }), + ).toBe(false); + }); + }); + + describe('returns true', () => { + test.each(range(3, 10))('for single digit hour %i', i => { + expect( + isEverySegmentValueExplicit({ + segments: { hour: i.toString(), minute: '00', second: '00' }, + is12HourFormat: false, + }), + ).toBe(true); + }); + + test.each(range(10, 24))('for double digit hour %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', () => { + test.each(range(0, 6).map(i => [segment, i]))( + 'for single digit %s %i', + (segment, i) => { + expect( + isEverySegmentValueExplicit({ + segments: { + hour: '12', + minute: segment === 'minute' ? i.toString() : '00', + second: segment === 'second' ? i.toString() : '00', + }, + is12HourFormat: true, + }), + ).toBe(false); + }, + ); + }); + + describe('returns true', () => { + test.each(range(6, 10).map(i => [segment, i]))( + 'for single digit %s %i', + (segment, i) => { + expect( + isEverySegmentValueExplicit({ + segments: { + hour: '12', + minute: segment === 'minute' ? i.toString() : '00', + second: segment === 'second' ? i.toString() : '00', + }, + is12HourFormat: true, + }), + ).toBe(true); + }, + ); + + test.each(range(10, 60).map(i => [segment, i]))( + 'for double digit %s %i', + (segment, 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..51b860e239 --- /dev/null +++ b/packages/time-input/src/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts @@ -0,0 +1,37 @@ +import { createExplicitSegmentValidator } from '@leafygreen-ui/input-box'; + +import { TimeSegment, TimeSegmentsState } from '../../shared.types'; +import { getTimeSegmentRules } from '../getTimeSegmentRules'; + +export const createExplicitTimeSegmentValidator = (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 => { + const isExplicitSegmentValue = + createExplicitTimeSegmentValidator(is12HourFormat); + + return (Object.entries(segments) as Array<[TimeSegment, string]>).every( + ([segment, value]) => { + const isExplicit = isExplicitSegmentValue({ + segment: segment, + value, + allowZero: segment === TimeSegment.Hour ? !is12HourFormat : true, + }); + + return isExplicit; + }, + ); +}; diff --git a/packages/time-input/src/utils/shouldSetValue/index.ts b/packages/time-input/src/utils/shouldSetValue/index.ts new file mode 100644 index 0000000000..9c3736afa7 --- /dev/null +++ b/packages/time-input/src/utils/shouldSetValue/index.ts @@ -0,0 +1 @@ +export { shouldSetValue } from './shouldSetValue'; 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..69ab76740e --- /dev/null +++ b/packages/time-input/src/utils/shouldSetValue/shouldSetValue.spec.ts @@ -0,0 +1,150 @@ +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('when all the segments are explicit', () => { + test('12 hour format should return true', () => { + 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 should return true', () => { + 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('when not all the segments are explicit', () => { + test('12 hour format should return false', () => { + 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 should return false', () => { + 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', () => { + test('should return true 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('should return true 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); + }); + + test('should return false 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); + }); + }); + + test('should return true when the date is null', () => { + 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..4a7fb3181e --- /dev/null +++ b/packages/time-input/src/utils/shouldSetValue/shouldSetValue.ts @@ -0,0 +1,54 @@ +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'; +import { isEverySegmentValueExplicit } from '../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 => { + 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; +};