Skip to content

Commit 5389f12

Browse files
authored
Adds parseDuration functionality to @internationalized/date package (#3766)
1 parent f3de960 commit 5389f12

File tree

3 files changed

+308
-3
lines changed

3 files changed

+308
-3
lines changed

packages/@internationalized/date/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export {
7272
parseTime,
7373
parseAbsolute,
7474
parseAbsoluteToLocal,
75-
parseZonedDateTime
75+
parseZonedDateTime,
76+
parseDuration
7677
} from './string';
7778
export {DateFormatter} from './DateFormatter';

packages/@internationalized/date/src/string.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {AnyDateTime, Disambiguation} from './types';
13+
import {AnyDateTime, DateTimeDuration, Disambiguation} from './types';
1414
import {CalendarDate, CalendarDateTime, Time, ZonedDateTime} from './CalendarDate';
1515
import {epochFromDate, fromAbsolute, possibleAbsolutes, toAbsolute, toCalendar, toCalendarDateTime, toTimeZone} from './conversion';
1616
import {getLocalTimeZone} from './queries';
@@ -22,6 +22,10 @@ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
2222
const DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?$/;
2323
const ZONED_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?(?:([+-]\d{2})(?::(\d{2}))?)?\[(.*?)\]$/;
2424
const ABSOLUTE_RE = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?(?:(?:([+-]\d{2})(?::(\d{2}))?)|Z)$/;
25+
const DATE_TIME_DURATION_RE =
26+
/^((?<negative>-)|\+)?P((?<years>\d*)Y)?((?<months>\d*)M)?((?<weeks>\d*)W)?((?<days>\d*)D)?((?<time>T)((?<hours>\d*[.,]?\d{1,9})H)?((?<minutes>\d*[.,]?\d{1,9})M)?((?<seconds>\d*[.,]?\d{1,9})S)?)?$/;
27+
const requiredDurationTimeGroups = ['hours', 'minutes', 'seconds'];
28+
const requiredDurationGroups = ['years', 'months', 'weeks', 'days', ...requiredDurationTimeGroups];
2529

2630
/** Parses an ISO 8601 time string. */
2731
export function parseTime(value: string): Time {
@@ -195,3 +199,70 @@ function offsetToString(offset: number) {
195199
export function zonedDateTimeToString(date: ZonedDateTime): string {
196200
return `${dateTimeToString(date)}${offsetToString(date.offset)}[${date.timeZone}]`;
197201
}
202+
203+
/**
204+
* Parses an ISO 8601 duration string (e.g. "P3Y6M6W4DT12H30M5S").
205+
* @param value An ISO 8601 duration string.
206+
* @returns A DateTimeDuration object.
207+
*/
208+
export function parseDuration(value: string): Required<DateTimeDuration> {
209+
const match = value.match(DATE_TIME_DURATION_RE);
210+
211+
if (!match) {
212+
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
213+
}
214+
215+
const parseDurationGroup = (
216+
group: string | undefined,
217+
isNegative: boolean,
218+
min: number,
219+
max: number
220+
): number => {
221+
if (!group) {
222+
return 0;
223+
}
224+
try {
225+
const sign = isNegative ? -1 : 1;
226+
return sign * parseNumber(group.replace(',', '.'), min, max);
227+
} catch {
228+
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
229+
}
230+
};
231+
232+
const isNegative = !!match.groups?.negative;
233+
234+
const hasRequiredGroups = requiredDurationGroups.some(group => match.groups?.[group]);
235+
236+
if (!hasRequiredGroups) {
237+
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
238+
}
239+
240+
const durationStringIncludesTime = match.groups?.time;
241+
242+
if (durationStringIncludesTime) {
243+
const hasRequiredDurationTimeGroups = requiredDurationTimeGroups.some(group => match.groups?.[group]);
244+
if (!hasRequiredDurationTimeGroups) {
245+
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
246+
}
247+
}
248+
249+
const duration: Mutable<DateTimeDuration> = {
250+
years: parseDurationGroup(match.groups?.years, isNegative, 0, 9999),
251+
months: parseDurationGroup(match.groups?.months, isNegative, 0, 12),
252+
weeks: parseDurationGroup(match.groups?.weeks, isNegative, 0, Infinity),
253+
days: parseDurationGroup(match.groups?.days, isNegative, 0, 31),
254+
hours: parseDurationGroup(match.groups?.hours, isNegative, 0, 23),
255+
minutes: parseDurationGroup(match.groups?.minutes, isNegative, 0, 59),
256+
seconds: parseDurationGroup(match.groups?.seconds, isNegative, 0, 59)
257+
};
258+
259+
if (((duration.hours % 1) !== 0) && (duration.minutes || duration.seconds)) {
260+
throw new Error(`Invalid ISO 8601 Duration string: ${value} - only the smallest unit can be fractional`);
261+
}
262+
263+
if (((duration.minutes % 1) !== 0) && duration.seconds) {
264+
throw new Error(`Invalid ISO 8601 Duration string: ${value} - only the smallest unit can be fractional`);
265+
}
266+
267+
return duration as Required<DateTimeDuration>;
268+
}

packages/@internationalized/date/tests/string.test.js

Lines changed: 234 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {CalendarDate, CalendarDateTime, parseAbsolute, parseDate, parseDateTime, parseTime, parseZonedDateTime, Time, ZonedDateTime} from '../';
13+
import {CalendarDate, CalendarDateTime, parseAbsolute, parseDate, parseDateTime, parseDuration, parseTime, parseZonedDateTime, Time, ZonedDateTime} from '../';
1414

1515
describe('string conversion', function () {
1616
describe('parseTime', function () {
@@ -352,4 +352,237 @@ describe('string conversion', function () {
352352
expect(date.toAbsoluteString()).toBe('2020-02-03T22:32:45.000Z');
353353
});
354354
});
355+
356+
describe('parseDuration', function () {
357+
it('parses an ISO 8601 duration string that contains years, months, weeks, days, hours, minutes, and seconds and returns a DateTimeDuration object', function () {
358+
const duration = parseDuration('P3Y6M6W4DT12H30M5S');
359+
expect(duration).toStrictEqual({
360+
years: 3,
361+
months: 6,
362+
weeks: 6,
363+
days: 4,
364+
hours: 12,
365+
minutes: 30,
366+
seconds: 5
367+
});
368+
});
369+
370+
it('parses an ISO 8601 duration string that contains years, months, weeks, days, hours, minutes, and fractional values for seconds expressed with a period and returns a DateTimeDuration object', function () {
371+
const duration = parseDuration('P3Y6M6W4DT12H30M5.5S');
372+
expect(duration).toStrictEqual({
373+
years: 3,
374+
months: 6,
375+
weeks: 6,
376+
days: 4,
377+
hours: 12,
378+
minutes: 30,
379+
seconds: 5.5
380+
});
381+
});
382+
383+
it('parses an ISO 8601 duration string that contains years, months, weeks, days, hours, minutes, and fractional values for seconds expressed with a comma and returns a DateTimeDuration object', function () {
384+
const duration = parseDuration('P3Y6M6W4DT12H30M5,5S');
385+
expect(duration).toStrictEqual({
386+
years: 3,
387+
months: 6,
388+
weeks: 6,
389+
days: 4,
390+
hours: 12,
391+
minutes: 30,
392+
seconds: 5.5
393+
});
394+
});
395+
396+
it('parses an ISO 8601 duration string that contains years, months, weeks, days, hours, and fractional values for minutes expressed with a period and returns a DateTimeDuration object', function () {
397+
const duration = parseDuration('P3Y6M6W4DT12H30.5M');
398+
expect(duration).toStrictEqual({
399+
years: 3,
400+
months: 6,
401+
weeks: 6,
402+
days: 4,
403+
hours: 12,
404+
minutes: 30.5,
405+
seconds: 0
406+
});
407+
});
408+
409+
it('parses an ISO 8601 duration string that contains years, months, weeks, days, hours, and fractional values for minutes expressed with a comma and returns a DateTimeDuration object', function () {
410+
const duration = parseDuration('P3Y6M6W4DT12H30,5M');
411+
expect(duration).toStrictEqual({
412+
years: 3,
413+
months: 6,
414+
weeks: 6,
415+
days: 4,
416+
hours: 12,
417+
minutes: 30.5,
418+
seconds: 0
419+
});
420+
});
421+
422+
it('parses an ISO 8601 duration string that contains years, months, weeks, days, and fractional values for hours expressed with a period and returns a DateTimeDuration object', function () {
423+
const duration = parseDuration('P3Y6M6W4DT12.5H');
424+
expect(duration).toStrictEqual({
425+
years: 3,
426+
months: 6,
427+
weeks: 6,
428+
days: 4,
429+
hours: 12.5,
430+
minutes: 0,
431+
seconds: 0
432+
});
433+
});
434+
435+
it('parses an ISO 8601 duration string that contains years, months, weeks, days, and fractional values for hours expressed with a comma and returns a DateTimeDuration object', function () {
436+
const duration = parseDuration('P3Y6M6W4DT12.5H');
437+
expect(duration).toStrictEqual({
438+
years: 3,
439+
months: 6,
440+
weeks: 6,
441+
days: 4,
442+
hours: 12.5,
443+
minutes: 0,
444+
seconds: 0
445+
});
446+
});
447+
448+
it('parses a negative ISO 8601 duration string that contains years, months, weeks, days, hours, minutes, and seconds and returns a DateTimeDuration object', function () {
449+
const duration = parseDuration('-P3Y6M6W4DT12H30M5S');
450+
expect(duration).toStrictEqual({
451+
years: -3,
452+
months: -6,
453+
weeks: -6,
454+
days: -4,
455+
hours: -12,
456+
minutes: -30,
457+
seconds: -5
458+
});
459+
});
460+
461+
it('parses an ISO 8601 duration string that contains years, months, weeks, days, hours, minutes, and seconds with a preceding + sign and returns a DateTimeDuration object', function () {
462+
const duration = parseDuration('+P3Y6M6W4DT12H30M5S');
463+
expect(duration).toStrictEqual({
464+
years: 3,
465+
months: 6,
466+
weeks: 6,
467+
days: 4,
468+
hours: 12,
469+
minutes: 30,
470+
seconds: 5
471+
});
472+
});
473+
474+
it('parses an ISO 8601 duration string that contains hours, minutes, and seconds and returns a DateTimeDuration object', function () {
475+
const duration = parseDuration('PT20H35M15S');
476+
expect(duration).toStrictEqual({
477+
years: 0,
478+
months: 0,
479+
weeks: 0,
480+
days: 0,
481+
hours: 20,
482+
minutes: 35,
483+
seconds: 15
484+
});
485+
});
486+
487+
it('parses an ISO 8601 duration string that contains years, months, weeks, and days and returns a DateTimeDuration object', function () {
488+
const duration = parseDuration('P7Y8M14W6D');
489+
expect(duration).toStrictEqual({
490+
years: 7,
491+
months: 8,
492+
weeks: 14,
493+
days: 6,
494+
hours: 0,
495+
minutes: 0,
496+
seconds: 0
497+
});
498+
});
499+
500+
it('parses an ISO 8601 duration string that contains years, months, hours, and seconds and returns a DateTimeDuration object', function () {
501+
const duration = parseDuration('P18Y7MT20H15S');
502+
expect(duration).toStrictEqual({
503+
years: 18,
504+
months: 7,
505+
weeks: 0,
506+
days: 0,
507+
hours: 20,
508+
minutes: 0,
509+
seconds: 15
510+
});
511+
});
512+
513+
it('throws an error when passed an improperly formatted ISO 8601 duration string', function () {
514+
expect(() => {
515+
parseDuration('+-P18Y7MT20H15S');
516+
}).toThrow('Invalid ISO 8601 Duration string: +-P18Y7MT20H15S');
517+
expect(() => {
518+
parseDuration('-+P18Y7MT20H15S');
519+
}).toThrow('Invalid ISO 8601 Duration string: -+P18Y7MT20H15S');
520+
expect(() => {
521+
parseDuration('--P18Y7MT20H15S');
522+
}).toThrow('Invalid ISO 8601 Duration string: --P18Y7MT20H15S');
523+
expect(() => {
524+
parseDuration('++P18Y7MT20H15S');
525+
}).toThrow('Invalid ISO 8601 Duration string: ++P18Y7MT20H15S');
526+
expect(() => {
527+
parseDuration('P18Y7MT');
528+
}).toThrow('Invalid ISO 8601 Duration string: P18Y7MT');
529+
expect(() => {
530+
parseDuration('P18Y7MT30H15S');
531+
}).toThrow('Invalid ISO 8601 Duration string: P18Y7MT30H15S');
532+
expect(() => {
533+
parseDuration('7Y6D85');
534+
}).toThrow('Invalid ISO 8601 Duration string: 7Y6D85');
535+
expect(() => {
536+
parseDuration('P1Y1M1W1DT1H1M1.123456789123S');
537+
}).toThrow('Invalid ISO 8601 Duration string: P1Y1M1W1DT1H1M1.123456789123S');
538+
expect(() => {
539+
parseDuration('P0.5Y');
540+
}).toThrow('Invalid ISO 8601 Duration string: P0.5Y');
541+
expect(() => {
542+
parseDuration('P1Y0,5M');
543+
}).toThrow('Invalid ISO 8601 Duration string: P1Y0,5M');
544+
expect(() => {
545+
parseDuration('P1Y1M0.5W');
546+
}).toThrow('Invalid ISO 8601 Duration string: P1Y1M0.5W');
547+
expect(() => {
548+
parseDuration('P1Y1M1W0,5D');
549+
}).toThrow('Invalid ISO 8601 Duration string: P1Y1M1W0,5D');
550+
expect(() => {
551+
parseDuration('P1Y1M1W1DT0.5H5S');
552+
}).toThrow('Invalid ISO 8601 Duration string: P1Y1M1W1DT0.5H5S - only the smallest unit can be fractional');
553+
expect(() => {
554+
parseDuration('P1Y1M1W1DT1.5H0,5M');
555+
}).toThrow('Invalid ISO 8601 Duration string: P1Y1M1W1DT1.5H0,5M - only the smallest unit can be fractional');
556+
expect(() => {
557+
parseDuration('P1Y1M1W1DT1H0.5M0.5S');
558+
}).toThrow('Invalid ISO 8601 Duration string: P1Y1M1W1DT1H0.5M0.5S - only the smallest unit can be fractional');
559+
expect(() => {
560+
parseDuration('P');
561+
}).toThrow('Invalid ISO 8601 Duration string: P');
562+
expect(() => {
563+
parseDuration('PT');
564+
}).toThrow('Invalid ISO 8601 Duration string: PT');
565+
expect(() => {
566+
parseDuration('-P');
567+
}).toThrow('Invalid ISO 8601 Duration string: -P');
568+
expect(() => {
569+
parseDuration('-PT');
570+
}).toThrow('Invalid ISO 8601 Duration string: -PT');
571+
expect(() => {
572+
parseDuration('+P');
573+
}).toThrow('Invalid ISO 8601 Duration string: +P');
574+
expect(() => {
575+
parseDuration('+PT');
576+
}).toThrow('Invalid ISO 8601 Duration string: +PT');
577+
expect(() => {
578+
parseDuration('P1Y1M1W1DT1H1M1.01Sjunk');
579+
}).toThrow('Invalid ISO 8601 Duration string: P1Y1M1W1DT1H1M1.01Sjunk');
580+
expect(() => {
581+
parseDuration('P-1Y1M');
582+
}).toThrow('Invalid ISO 8601 Duration string: P-1Y1M');
583+
expect(() => {
584+
parseDuration('P1Y-1M');
585+
}).toThrow('Invalid ISO 8601 Duration string: P1Y-1M');
586+
});
587+
});
355588
});

0 commit comments

Comments
 (0)