Skip to content

Commit cb29115

Browse files
committed
feat(react-timepicker-preview): add time utilities
1 parent 417e8a3 commit cb29115

5 files changed

Lines changed: 663 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export type {
2+
Hour,
3+
HourCycle,
4+
TimeFormatOptions,
5+
TimePickerErrorType,
6+
TimePickerOption,
7+
TimeStringValidationResult,
8+
} from './types';
9+
export {
10+
dateToKey,
11+
formatDateToTimeString,
12+
getDateEndAnchor,
13+
getDateFromTimeString,
14+
getDateStartAnchor,
15+
getResolvedHourCycle,
16+
getTimesBetween,
17+
keyToDate,
18+
} from './timeMath';
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
import {
2+
dateToKey,
3+
keyToDate,
4+
formatDateToTimeString,
5+
getDateEndAnchor,
6+
getDateStartAnchor,
7+
getTimesBetween,
8+
getDateFromTimeString,
9+
getResolvedHourCycle,
10+
} from './timeMath';
11+
12+
describe('Time Utilities', () => {
13+
describe('dateToKey', () => {
14+
it('should return empty string for null date', () => {
15+
expect(dateToKey(null)).toBe('');
16+
});
17+
18+
it('should return "invalid" for invalid dates', () => {
19+
const invalidDate = new Date('invalid-date');
20+
expect(dateToKey(invalidDate)).toBe('invalid');
21+
});
22+
23+
it('should return ISO string for valid dates', () => {
24+
const date = new Date(2023, 9, 6);
25+
expect(dateToKey(date)).toBe(date.toISOString());
26+
});
27+
});
28+
29+
describe('keyToDate', () => {
30+
it('should return null for empty string', () => {
31+
expect(keyToDate('')).toBeNull();
32+
});
33+
34+
it('should return null for "invalid" string', () => {
35+
expect(keyToDate('invalid')).toBeNull();
36+
});
37+
38+
it('should return date for valid ISO string', () => {
39+
const date = new Date(2023, 9, 6);
40+
expect(keyToDate(date.toISOString())).toEqual(date);
41+
});
42+
});
43+
44+
describe('dateToKey and keyToDate correspondence', () => {
45+
it('should be inverses of each other for valid dates', () => {
46+
const originalDate = new Date(2023, 9, 6, 23, 45, 12);
47+
const key = dateToKey(originalDate);
48+
const revertedDate = keyToDate(key);
49+
50+
expect(revertedDate?.getTime()).toEqual(originalDate.getTime());
51+
});
52+
53+
it('should be inverses of each other for null date', () => {
54+
const originalDate = null;
55+
const key = dateToKey(originalDate);
56+
const revertedDate = keyToDate(key);
57+
58+
expect(revertedDate).toEqual(originalDate);
59+
});
60+
61+
it('should be inverses of each other for invalid dates', () => {
62+
const originalDate = new Date('invalid-date');
63+
const key = dateToKey(originalDate);
64+
const revertedDate = keyToDate(key);
65+
66+
expect(revertedDate).toBeNull();
67+
});
68+
});
69+
70+
describe('formatDateToTimeString', () => {
71+
const testDate = new Date(2023, 9, 6, 23, 45, 12);
72+
73+
it('should format time in 24-hour format without seconds', () => {
74+
expect(formatDateToTimeString(testDate, { hourCycle: 'h23' })).toBe('23:45');
75+
});
76+
77+
it('should format time in 24-hour format with seconds', () => {
78+
expect(formatDateToTimeString(testDate, { showSeconds: true, hourCycle: 'h23' })).toBe('23:45:12');
79+
});
80+
81+
it('should format time in 12-hour format with seconds', () => {
82+
expect(formatDateToTimeString(testDate, { showSeconds: true, hourCycle: 'h11' })).toBe('11:45:12 PM');
83+
});
84+
85+
it('should format midnight correctly in 24-hour format', () => {
86+
const midnight = new Date(2023, 9, 7, 0, 0, 0);
87+
expect(formatDateToTimeString(midnight, { hourCycle: 'h23' })).toBe('00:00');
88+
});
89+
90+
it('should format time in Japanese locale', () => {
91+
const { toLocaleTimeString } = Date.prototype;
92+
const toLocaleTimeStringMock = jest.spyOn(Date.prototype, 'toLocaleTimeString');
93+
// Mock toLocaleTimeString to simulate running in a Japanese locale
94+
toLocaleTimeStringMock.mockImplementationOnce(function (this: Date, _locales, options) {
95+
return toLocaleTimeString.call(this, 'ja-JP', { ...options, timeZone: 'Japan' });
96+
});
97+
98+
expect(
99+
formatDateToTimeString(new Date(Date.UTC(2023, 9, 6, 14, 45, 12)), { showSeconds: true, hourCycle: 'h11' }),
100+
).toBe('午後11:45:12');
101+
102+
toLocaleTimeStringMock.mockClear();
103+
});
104+
105+
it('should format time without prefix 0 in US local', () => {
106+
const { toLocaleTimeString } = Date.prototype;
107+
const toLocaleTimeStringMock = jest.spyOn(Date.prototype, 'toLocaleTimeString');
108+
// Mock toLocaleTimeString to simulate running in PST locale
109+
toLocaleTimeStringMock.mockImplementationOnce(function (this: Date, _locales, options) {
110+
return toLocaleTimeString.call(this, 'en-US', { ...options, timeZone: 'America/Los_Angeles' });
111+
});
112+
113+
expect(formatDateToTimeString(new Date(Date.UTC(2023, 9, 6, 15, 45, 12)))).toBe('8:45 AM');
114+
115+
toLocaleTimeStringMock.mockClear();
116+
});
117+
});
118+
119+
describe('Anchor Date Calculations', () => {
120+
it('should calculate the correct start anchor date', () => {
121+
const date = new Date(2023, 9, 6);
122+
const result = getDateStartAnchor(date, 5);
123+
expect(result.getHours()).toBe(5);
124+
});
125+
126+
it('should calculate the correct end anchor date (same day)', () => {
127+
const date = new Date(2023, 9, 6);
128+
const result = getDateEndAnchor(date, 5, 10);
129+
expect(result.getHours()).toBe(10);
130+
});
131+
132+
it('should calculate the correct end anchor date (next day)', () => {
133+
const date = new Date(2023, 9, 6);
134+
const result = getDateEndAnchor(date, 10, 5);
135+
expect(result.getHours()).toBe(5);
136+
expect(result.getDate()).toBe(7);
137+
});
138+
139+
it('should handle the endHour being 24 correctly', () => {
140+
const date = new Date(2023, 9, 6);
141+
const result = getDateEndAnchor(date, 10, 24);
142+
expect(result.getHours()).toBe(0);
143+
expect(result.getDate()).toBe(7);
144+
});
145+
});
146+
147+
describe('getTimesBetween', () => {
148+
it('should return correct Date objects with 15-minute increment', () => {
149+
const start = new Date('January 1, 2023 10:00:00');
150+
const end = new Date('January 1, 2023 11:00:00');
151+
const result = getTimesBetween(start, end, 15);
152+
153+
expect(result.length).toBe(4);
154+
result.forEach((date, i) => expect(date.getMinutes()).toBe(15 * i));
155+
});
156+
157+
it('should return correct Date objects spanning across midnight with 30-minute increment', () => {
158+
const start = new Date('January 1, 2023 23:30:00');
159+
const end = new Date('January 2, 2023 00:30:00');
160+
const result = getTimesBetween(start, end, 30);
161+
162+
expect(result.length).toBe(2);
163+
expect([result[0].getHours(), result[0].getMinutes()]).toEqual([23, 30]);
164+
expect([result[1].getHours(), result[1].getMinutes()]).toEqual([0, 0]);
165+
});
166+
167+
it('should return correct Date objects for day light saving', () => {
168+
const start = new Date('2023-11-05T01:00:00-07:00'); // UTC-7 is PDT
169+
const end = new Date('2023-11-05T03:00:00-08:00'); // UTC-8 is PST
170+
const result = getTimesBetween(start, end, 60);
171+
172+
expect(result.length).toBe(3);
173+
expect(
174+
result.map(date => date.toLocaleString('en-US', { timeZone: 'America/Los_Angeles', timeZoneName: 'short' })),
175+
).toMatchInlineSnapshot(`
176+
Array [
177+
"11/5/2023, 1:00:00 AM PDT",
178+
"11/5/2023, 1:00:00 AM PST",
179+
"11/5/2023, 2:00:00 AM PST",
180+
]
181+
`);
182+
});
183+
184+
it.each([0, -30, 0.5, NaN, Infinity])('should return no dates and log an error for increment %p', increment => {
185+
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
186+
const start = new Date('January 1, 2023 10:00:00');
187+
const end = new Date('January 1, 2023 11:00:00');
188+
189+
expect(getTimesBetween(start, end, increment)).toEqual([]);
190+
expect(consoleError).toHaveBeenCalledTimes(1);
191+
192+
consoleError.mockRestore();
193+
});
194+
195+
it('should return only the start when the increment is larger than the range', () => {
196+
const start = new Date('January 1, 2023 10:00:00');
197+
const end = new Date('January 1, 2023 11:00:00');
198+
199+
expect(getTimesBetween(start, end, 90)).toEqual([start]);
200+
});
201+
});
202+
203+
describe('getDateFromTimeString', () => {
204+
const dateStartAnchor = new Date('November 25, 2023 12:00:00');
205+
const dateEndAnchor = new Date('November 26, 2023 12:00:00');
206+
207+
it('returns a valid date when given a valid time string', () => {
208+
const result = getDateFromTimeString('2:30 PM', dateStartAnchor, dateEndAnchor, {
209+
hourCycle: 'h11',
210+
showSeconds: false,
211+
});
212+
expect(result.date?.getHours()).toBe(14);
213+
expect(result.date?.getMinutes()).toBe(30);
214+
expect(result.errorType).toBeUndefined();
215+
});
216+
217+
it('returns an errorType when no time string is provided', () => {
218+
const result = getDateFromTimeString(undefined, dateStartAnchor, dateEndAnchor, {});
219+
expect(result.date).toBeNull();
220+
expect(result.errorType).toBe('required-input');
221+
});
222+
223+
it('returns an errorType for an invalid time string', () => {
224+
const result = getDateFromTimeString('25:30', dateStartAnchor, dateEndAnchor, {});
225+
expect(result.date).toBeNull();
226+
expect(result.errorType).toBe('invalid-input');
227+
});
228+
229+
it('returns a date in the next day and an out-of-bounds errorType when the time is before the dateStartAnchor', () => {
230+
const result = getDateFromTimeString('11:30 AM', dateStartAnchor, new Date('November 25, 2023 13:00:00'), {
231+
hourCycle: 'h11',
232+
showSeconds: false,
233+
});
234+
expect(result.date?.getDate()).toBe(26);
235+
expect(result.date?.getHours()).toBe(11);
236+
expect(result.date?.getMinutes()).toBe(30);
237+
expect(result.errorType).toBe('out-of-bounds');
238+
});
239+
240+
it('returns an out-of-bounds errorType when the time is same as the dateEndAnchor', () => {
241+
const result = getDateFromTimeString('1:00 PM', dateStartAnchor, new Date('November 25, 2023 13:00:00'), {
242+
hourCycle: 'h11',
243+
showSeconds: false,
244+
});
245+
expect(result.date?.getHours()).toBe(13);
246+
expect(result.date?.getMinutes()).toBe(0);
247+
expect(result.errorType).toBe('out-of-bounds');
248+
});
249+
250+
describe('resolved hour cycle', () => {
251+
it('uses the hourCycle option when set', () => {
252+
expect(getResolvedHourCycle('h24')).toBe('h24');
253+
});
254+
255+
it('falls back to the locale hour cycle', () => {
256+
const expected = (
257+
new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions() as { hourCycle?: string }
258+
).hourCycle;
259+
expect(getResolvedHourCycle()).toBe(expected);
260+
});
261+
262+
it('round-trips the default formatter output without an explicit hourCycle', () => {
263+
const date = new Date('November 25, 2023 14:30:00');
264+
const text = formatDateToTimeString(date);
265+
const result = getDateFromTimeString(text, dateStartAnchor, dateEndAnchor, {});
266+
expect(result.errorType).toBeUndefined();
267+
expect(result.date?.getTime()).toBe(date.getTime());
268+
});
269+
270+
it('round-trips the default formatter output with seconds', () => {
271+
const date = new Date('November 25, 2023 14:30:45');
272+
const text = formatDateToTimeString(date, { showSeconds: true });
273+
const result = getDateFromTimeString(text, dateStartAnchor, dateEndAnchor, { showSeconds: true });
274+
expect(result.date?.getTime()).toBe(date.getTime());
275+
});
276+
});
277+
278+
describe('accepted input per hour cycle', () => {
279+
const day = new Date('January 1, 2023 00:00:00');
280+
const nextDay = new Date('January 2, 2023 00:00:00');
281+
const parse = (time: string, hourCycle: 'h11' | 'h12' | 'h23' | 'h24', showSeconds = false) =>
282+
getDateFromTimeString(time, day, nextDay, { hourCycle, showSeconds });
283+
284+
it.each`
285+
time | hourCycle | hours | minutes
286+
${'0:30'} | ${'h23'} | ${0} | ${30}
287+
${'23:30'} | ${'h23'} | ${23} | ${30}
288+
${'1:30'} | ${'h24'} | ${1} | ${30}
289+
${'24:30'} | ${'h24'} | ${0} | ${30}
290+
${'12:30 AM'} | ${'h12'} | ${0} | ${30}
291+
${'12:30 PM'} | ${'h12'} | ${12} | ${30}
292+
${'1:30 pm'} | ${'h12'} | ${13} | ${30}
293+
${'0:30 AM'} | ${'h11'} | ${0} | ${30}
294+
${'0:30 PM'} | ${'h11'} | ${12} | ${30}
295+
${'11:30 PM'} | ${'h11'} | ${23} | ${30}
296+
${' 9:15 '} | ${'h23'} | ${9} | ${15}
297+
`('accepts $time in $hourCycle', ({ time, hourCycle, hours, minutes }) => {
298+
const result = parse(time, hourCycle);
299+
expect(result.errorType).toBeUndefined();
300+
expect(result.date?.getDate()).toBe(1);
301+
expect([result.date?.getHours(), result.date?.getMinutes()]).toEqual([hours, minutes]);
302+
});
303+
304+
it.each`
305+
time | hourCycle
306+
${'24:00'} | ${'h23'}
307+
${'0:30'} | ${'h24'}
308+
${'0:30 AM'} | ${'h12'}
309+
${'13:30 PM'} | ${'h12'}
310+
${'12:30 PM'} | ${'h11'}
311+
${'2:30'} | ${'h12'}
312+
${'2:30\tPM'} | ${'h12'}
313+
${'2:30 PM'} | ${'h12'}
314+
${'2.30'} | ${'h23'}
315+
${'2:30:15'} | ${'h23'}
316+
${'2:30 PM'} | ${'h23'}
317+
`('rejects $time in $hourCycle', ({ time, hourCycle }) => {
318+
expect(parse(time, hourCycle)).toEqual({ date: null, errorType: 'invalid-input' });
319+
});
320+
321+
it('requires seconds only with showSeconds', () => {
322+
expect(parse('2:30:15', 'h23', true).date?.getSeconds()).toBe(15);
323+
expect(parse('2:30', 'h23', true).errorType).toBe('invalid-input');
324+
expect(parse('2:30:15 PM', 'h12', true).date?.getHours()).toBe(14);
325+
});
326+
});
327+
328+
describe('daylight saving transitions', () => {
329+
const inLosAngeles = Intl.DateTimeFormat().resolvedOptions().timeZone === 'America/Los_Angeles';
330+
const itInLosAngeles = inLosAngeles ? it : it.skip;
331+
332+
itInLosAngeles('keeps the clock time when it does not exist on the anchor day but does on the next', () => {
333+
// 02:30 does not exist on March 12, 2023 in Los Angeles (spring forward).
334+
const start = new Date('2023-03-12T12:00:00-08:00');
335+
const end = new Date('2023-03-13T12:00:00-07:00');
336+
const result = getDateFromTimeString('2:30', start, end, { hourCycle: 'h23' });
337+
expect(result.errorType).toBeUndefined();
338+
expect(result.date?.toLocaleString('en-US', { timeZone: 'America/Los_Angeles' })).toBe('3/13/2023, 2:30:00 AM');
339+
});
340+
341+
itInLosAngeles('moves a clock time that does not exist on the anchor day to the next day', () => {
342+
const start = new Date('2023-03-12T00:00:00-08:00');
343+
const end = new Date('2023-03-12T12:00:00-07:00');
344+
const result = getDateFromTimeString('2:30', start, end, { hourCycle: 'h23' });
345+
expect(result.errorType).toBe('out-of-bounds');
346+
expect(result.date?.toLocaleString('en-US', { timeZone: 'America/Los_Angeles' })).toBe('3/13/2023, 2:30:00 AM');
347+
});
348+
349+
itInLosAngeles('resolves a repeated clock time to the occurrence inside the range', () => {
350+
// 01:45 occurs twice on November 5, 2023 in Los Angeles (fall back); only the PST one is in range.
351+
const start = new Date('2023-11-05T01:30:00-08:00');
352+
const end = new Date('2023-11-05T03:00:00-08:00');
353+
const result = getDateFromTimeString('1:45', start, end, { hourCycle: 'h23' });
354+
expect(result.errorType).toBeUndefined();
355+
expect(result.date?.toLocaleString('en-US', { timeZone: 'America/Los_Angeles', timeZoneName: 'short' })).toBe(
356+
'11/5/2023, 1:45:00 AM PST',
357+
);
358+
});
359+
});
360+
});
361+
});

0 commit comments

Comments
 (0)