From 2da243405a5da7b88a11185d23aa4f415ef747f9 Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Tue, 23 Apr 2024 18:33:14 +0200 Subject: [PATCH] feat: add Duration (#55) --- src/dateTime/dateTime.ts | 25 +- src/datemath/datemath.test.ts | 5 +- src/datemath/datemath.ts | 4 +- src/duration/__tests__/createDuration.ts | 99 +++++++ src/duration/__tests__/format.ts | 180 ++++++++++++ src/duration/__tests__/getters.ts | 72 +++++ src/duration/__tests__/math.ts | 139 +++++++++ src/duration/__tests__/parse.ts | 95 ++++++ src/duration/__tests__/set.ts | 35 +++ src/duration/__tests__/units.ts | 302 +++++++++++++++++++ src/duration/createDuration.ts | 66 +++++ src/duration/duration.ts | 351 +++++++++++++++++++++++ src/duration/index.ts | 2 + src/duration/normalize.ts | 231 +++++++++++++++ src/index.ts | 3 +- src/timeZone/timeZone.ts | 31 +- src/typings/dateTime.ts | 13 +- src/typings/duration.ts | 104 +++++++ src/typings/index.ts | 1 + src/utils/duration.ts | 66 ----- src/utils/index.ts | 1 - src/utils/locale.ts | 35 +++ src/utils/utils.ts | 68 +++-- 23 files changed, 1800 insertions(+), 128 deletions(-) create mode 100644 src/duration/__tests__/createDuration.ts create mode 100644 src/duration/__tests__/format.ts create mode 100644 src/duration/__tests__/getters.ts create mode 100644 src/duration/__tests__/math.ts create mode 100644 src/duration/__tests__/parse.ts create mode 100644 src/duration/__tests__/set.ts create mode 100644 src/duration/__tests__/units.ts create mode 100644 src/duration/createDuration.ts create mode 100644 src/duration/duration.ts create mode 100644 src/duration/index.ts create mode 100644 src/duration/normalize.ts create mode 100644 src/typings/duration.ts delete mode 100644 src/utils/duration.ts create mode 100644 src/utils/locale.ts diff --git a/src/dateTime/dateTime.ts b/src/dateTime/dateTime.ts index 6339761..8f065a1 100644 --- a/src/dateTime/dateTime.ts +++ b/src/dateTime/dateTime.ts @@ -1,11 +1,13 @@ import {STRICT, UtcTimeZone} from '../constants'; import dayjs from '../dayjs'; +import {duration} from '../duration'; import {settings} from '../settings'; import {fixOffset, guessUserTimeZone, normalizeTimeZone, timeZoneOffset} from '../timeZone'; import type { AllUnit, DateTime, DateTimeInput, + DurationInput, DurationUnit, FormatInput, SetObject, @@ -14,7 +16,6 @@ import type { } from '../typings'; import { daysInMonth, - getDuration, normalizeComponent, normalizeDateComponents, objToTS, @@ -117,11 +118,11 @@ class DateTimeImpl implements DateTime { return createDateTime({ts, timeZone: zone, offset, locale: this._locale}); } - add(amount: DateTimeInput, unit?: DurationUnit): DateTime { + add(amount: DurationInput, unit?: DurationUnit): DateTime { return this.addSubtract(amount, unit, 1); } - subtract(amount: DateTimeInput, unit?: DurationUnit): DateTime { + subtract(amount: DurationInput, unit?: DurationUnit): DateTime { return this.addSubtract(amount, unit, -1); } @@ -303,6 +304,7 @@ class DateTimeImpl implements DateTime { const dateComponents = tsToObject(this._timestamp, this._offset); const newComponents = normalizeDateComponents( typeof unit === 'object' ? unit : {[unit]: amount}, + normalizeComponent, ); const settingWeekStuff = @@ -470,7 +472,7 @@ class DateTimeImpl implements DateTime { toString(): string { return this._date.toString(); } - private addSubtract(amount: DateTimeInput, unit: DurationUnit | undefined, sign: 1 | -1) { + private addSubtract(amount: DurationInput, unit: DurationUnit | undefined, sign: 1 | -1) { if (!this.isValid()) { return this; } @@ -479,11 +481,16 @@ class DateTimeImpl implements DateTime { let ts = this.valueOf(); let offset = this._offset; - const duration = getDuration(amount, unit); + const dur = duration(amount, unit); const dateComponents = tsToObject(ts, offset); - const monthsInput = absRound(duration.months); - const daysInput = absRound(duration.days); + const monthsInput = absRound(dur.months() + dur.quarters() * 3 + dur.years() * 12); + const daysInput = absRound(dur.days() + dur.weeks() * 7); + const milliseconds = + dur.milliseconds() + + dur.seconds() * 1000 + + dur.minutes() * 60 * 1000 + + dur.hours() * 60 * 60 * 1000; if (monthsInput || daysInput) { const month = dateComponents.month + sign * monthsInput; @@ -498,8 +505,8 @@ class DateTimeImpl implements DateTime { } } - if (duration.milliseconds) { - ts += sign * duration.milliseconds; + if (milliseconds) { + ts += sign * milliseconds; if (timeZone !== UtcTimeZone) { offset = timeZoneOffset(timeZone, ts); } diff --git a/src/datemath/datemath.test.ts b/src/datemath/datemath.test.ts index 34bc019..03863a0 100644 --- a/src/datemath/datemath.test.ts +++ b/src/datemath/datemath.test.ts @@ -1,7 +1,6 @@ // Copyright 2015 Grafana Labs // Copyright 2021 YANDEX LLC -import each from 'lodash/each'; import sinon from 'sinon'; import type {SinonFakeTimers} from 'sinon'; @@ -76,7 +75,7 @@ describe('DateMath', () => { anchored = dateTime({input: anchor}); }); - each(spans, (span) => { + spans.forEach((span) => { const nowEx = 'now-5' + span; const thenEx = anchor + '||-5' + span; @@ -106,7 +105,7 @@ describe('DateMath', () => { now = dateTime(); }); - each(spans, (span) => { + spans.forEach((span) => { it('should round now to the beginning of the ' + span, () => { expect(dateMath.parse('now/' + span)?.format(format)).toEqual( now.startOf(span).format(format), diff --git a/src/datemath/datemath.ts b/src/datemath/datemath.ts index 0d3f333..5b8b606 100644 --- a/src/datemath/datemath.ts +++ b/src/datemath/datemath.ts @@ -1,8 +1,6 @@ // Copyright 2015 Grafana Labs // Copyright 2021 YANDEX LLC -import includes from 'lodash/includes'; - import {dateTime} from '../dateTime'; import type {DateTime, DurationUnit, TimeZone} from '../typings'; @@ -106,7 +104,7 @@ export function parseDateMath( const unit = strippedMathString.charAt(i++) as DurationUnit; - if (includes(units, unit)) { + if (units.includes(unit)) { if (type === 0) { if (roundUp) { resultTime = resultTime.endOf(unit); diff --git a/src/duration/__tests__/createDuration.ts b/src/duration/__tests__/createDuration.ts new file mode 100644 index 0000000..6a43680 --- /dev/null +++ b/src/duration/__tests__/createDuration.ts @@ -0,0 +1,99 @@ +// Copyright 2019 JS Foundation and other contributors +// Copyright 2024 YANDEX LLC + +import {duration} from '../'; + +test('Duration sets all the values', () => { + const dur = duration({ + years: 1, + months: 2, + days: 3, + hours: 4, + minutes: 5, + seconds: 6, + milliseconds: 7, + }); + expect(dur.years()).toBe(1); + expect(dur.months()).toBe(2); + expect(dur.days()).toBe(3); + expect(dur.hours()).toBe(4); + expect(dur.minutes()).toBe(5); + expect(dur.seconds()).toBe(6); + expect(dur.milliseconds()).toBe(7); +}); + +test('Duration sets all the fractional values', () => { + const dur = duration({ + years: 1, + months: 2, + days: 3, + hours: 4.5, + minutes: 5, + }); + expect(dur.years()).toBe(1); + expect(dur.months()).toBe(2); + expect(dur.days()).toBe(3); + expect(dur.hours()).toBe(4.5); + expect(dur.minutes()).toBe(5); + expect(dur.seconds()).toBe(0); + expect(dur.milliseconds()).toBe(0); +}); + +test('Duration sets all the values from the object having string type values', () => { + const dur = duration({ + years: '1', + months: '2', + days: '3', + hours: '4', + minutes: '5', + seconds: '6', + milliseconds: '7', + }); + expect(dur.years()).toBe(1); + expect(dur.months()).toBe(2); + expect(dur.days()).toBe(3); + expect(dur.hours()).toBe(4); + expect(dur.minutes()).toBe(5); + expect(dur.seconds()).toBe(6); + expect(dur.milliseconds()).toBe(7); +}); + +test('Duration({}) constructs zero duration', () => { + const dur = duration({}); + expect(dur.years()).toBe(0); + expect(dur.months()).toBe(0); + expect(dur.days()).toBe(0); + expect(dur.hours()).toBe(0); + expect(dur.minutes()).toBe(0); + expect(dur.seconds()).toBe(0); + expect(dur.milliseconds()).toBe(0); +}); + +test('Duration throws if the initial object has invalid keys', () => { + // @ts-expect-error + expect(() => duration({foo: 0})).toThrow(); + // @ts-expect-error + expect(() => duration({years: 1, foo: 0})).toThrow(); +}); + +test('Duration throws if the initial object has invalid values', () => { + // @ts-expect-error + expect(() => duration({years: {}})).toThrow(); + expect(() => duration({months: 'some'})).toThrow(); + expect(() => duration({days: NaN})).toThrow(); + // @ts-expect-error + expect(() => duration({hours: true})).toThrow(); + // @ts-expect-error + expect(() => duration({minutes: false})).toThrow(); + expect(() => duration({seconds: ''})).toThrow(); +}); + +it('Duration returns passed Duration', () => { + const durFromObject = duration({hours: 1}); + const dur = duration(durFromObject); + expect(dur).toStrictEqual(durFromObject); +}); + +it('Duration throws on invalid input', () => { + expect(() => duration('foo')).toThrow(); +}); diff --git a/src/duration/__tests__/format.ts b/src/duration/__tests__/format.ts new file mode 100644 index 0000000..7e74fd2 --- /dev/null +++ b/src/duration/__tests__/format.ts @@ -0,0 +1,180 @@ +// Copyright 2019 JS Foundation and other contributors +// Copyright 2024 YANDEX LLC + +import {duration} from '..'; +import {settings} from '../../settings'; + +const dur = () => + duration({ + years: 1, + months: 2, + weeks: 1, + days: 3, + hours: 4, + minutes: 5, + seconds: 6, + milliseconds: 7, + }); + +//------ +// #toISOString() +//------ + +test('Duration#toISOString fills out every field', () => { + expect(dur().toISOString()).toBe('P1Y2M1W3DT4H5M6.007S'); +}); + +test('Duration#toISOString fills out every field with fractional', () => { + const dur = duration({ + years: 1.1, + months: 2.2, + weeks: 1.1, + days: 3.3, + hours: 4.4, + minutes: 5.5, + seconds: 6.6, + milliseconds: 7, + }); + expect(dur.toISOString()).toBe('P1.1Y2.2M1.1W3.3DT4.4H5.5M6.607S'); +}); + +test('Duration#toISOString creates a minimal string', () => { + expect(duration({years: 3, seconds: 45}).toISOString()).toBe('P3YT45S'); + expect(duration({months: 4, seconds: 45}).toISOString()).toBe('P4MT45S'); + expect(duration({months: 5}).toISOString()).toBe('P5M'); + expect(duration({minutes: 5}).toISOString()).toBe('PT5M'); +}); + +test('Duration#toISOString handles negative durations', () => { + expect(duration({years: -3, seconds: -45}).toISOString()).toBe('P-3YT-45S'); +}); + +test('Duration#toISOString handles mixed negative/positive durations', () => { + expect(duration({years: 3, seconds: -45}).toISOString()).toBe('P3YT-45S'); + expect(duration({years: 0, seconds: -45}).toISOString()).toBe('PT-45S'); + expect(duration({years: -5, seconds: 34}).toISOString()).toBe('P-5YT34S'); +}); + +test('Duration#toISOString handles zero durations', () => { + expect(duration(0).toISOString()).toBe('PT0S'); +}); + +// test('Duration#toISOString returns null for invalid durations', () => { +// expect(Duration.invalid('because').toISOString()).toBe(null); +// }); + +test('Duration#toISOString handles milliseconds duration', () => { + expect(duration({milliseconds: 7}).toISOString()).toBe('PT0.007S'); +}); + +test('Duration#toISOString handles seconds/milliseconds duration', () => { + expect(duration({seconds: 17, milliseconds: 548}).toISOString()).toBe('PT17.548S'); +}); + +test('Duration#toISOString handles negative seconds/milliseconds duration', () => { + expect(duration({seconds: -17, milliseconds: -548}).toISOString()).toBe('PT-17.548S'); +}); + +test('Duration#toISOString handles mixed negative/positive numbers in seconds/milliseconds durations', () => { + expect(duration({seconds: 17, milliseconds: -548}).toISOString()).toBe('PT16.452S'); + expect(duration({seconds: -17, milliseconds: 548}).toISOString()).toBe('PT-16.452S'); +}); + +//------ +// #toJSON() +//------ + +test('Duration#toJSON returns the ISO representation', () => { + expect(dur().toJSON()).toBe(dur().toISOString()); +}); + +//------ +// #toString() +//------ + +test('Duration#toString returns the ISO representation', () => { + expect(dur().toString()).toBe(dur().toISOString()); +}); +//------ +// #humanizeIntl() +//------ + +test('Duration#humanizeIntl formats out a list', () => { + expect(dur().humanizeIntl()).toEqual( + '1 year, 2 months, 1 week, 3 days, 4 hours, 5 minutes, 6 seconds, 7 milliseconds', + ); +}); + +test('Duration#humanizeIntl only shows the units you have', () => { + expect(duration({years: 3, hours: 4}).humanizeIntl()).toEqual('3 years, 4 hours'); +}); + +test('Duration#humanizeIntl accepts a listStyle', () => { + expect(dur().humanizeIntl({listStyle: 'long'})).toEqual( + '1 year, 2 months, 1 week, 3 days, 4 hours, 5 minutes, 6 seconds, and 7 milliseconds', + ); +}); + +test('Duration#humanizeIntl accepts number format opts', () => { + expect(dur().humanizeIntl({unitDisplay: 'short'})).toEqual( + '1 yr, 2 mths, 1 wk, 3 days, 4 hr, 5 min, 6 sec, 7 ms', + ); +}); + +test('Duration#humanizeIntl works in differt languages', () => { + expect(dur().locale('fr').humanizeIntl()).toEqual( + '1 an, 2 mois, 1 semaine, 3 jours, 4 heures, 5 minutes, 6 secondes, 7 millisecondes', + ); +}); + +//------ +// #humanize() +//------ + +test('Duration#humanize', () => { + expect(duration({seconds: 44}).humanize()).toBe('a few seconds'); + expect(duration({seconds: 45}).humanize()).toBe('a minute'); + expect(duration({seconds: 89}).humanize()).toBe('a minute'); + expect(duration({seconds: 90}).humanize()).toBe('2 minutes'); + expect(duration({minutes: 44}).humanize()).toBe('44 minutes'); + expect(duration({minutes: 45}).humanize()).toBe('an hour'); + expect(duration({minutes: 89}).humanize()).toBe('an hour'); + expect(duration({minutes: 90}).humanize()).toBe('2 hours'); + expect(duration({hours: 5}).humanize()).toBe('5 hours'); + expect(duration({hours: 21}).humanize()).toBe('21 hours'); + expect(duration({hours: 22}).humanize()).toBe('a day'); + expect(duration({hours: 35}).humanize()).toBe('a day'); + expect(duration({hours: 36}).humanize()).toBe('2 days'); + expect(duration({days: 1}).humanize()).toBe('a day'); + expect(duration({days: 5}).humanize()).toBe('5 days'); + expect(duration({weeks: 1}).humanize()).toBe('7 days'); + expect(duration({days: 25}).humanize()).toBe('25 days'); + expect(duration({days: 26}).humanize()).toBe('a month'); + expect(duration({days: 30}).humanize()).toBe('a month'); + expect(duration({days: 45}).humanize()).toBe('a month'); + expect(duration({days: 46}).humanize()).toBe('2 months'); + expect(duration({days: 74}).humanize()).toBe('2 months'); + expect(duration({days: 77}).humanize()).toBe('3 months'); + expect(duration({months: 1}).humanize()).toBe('a month'); + expect(duration({months: 5}).humanize()).toBe('5 months'); + expect(duration({days: 344}).humanize()).toBe('a year'); + expect(duration({days: 345}).humanize()).toBe('a year'); + expect(duration({days: 547}).humanize()).toBe('a year'); + expect(duration({days: 548}).humanize()).toBe('2 years'); + expect(duration({years: 1}).humanize()).toBe('a year'); + expect(duration({years: 5}).humanize()).toBe('5 years'); + expect(duration(7200000).humanize()).toBe('2 hours'); +}); + +test('Duration#humanize with suffix', () => { + expect(duration({seconds: 44}).humanize(true)).toBe('in a few seconds'); + expect(duration({seconds: -44}).humanize(true)).toBe('a few seconds ago'); + expect(duration({seconds: +44}).humanize(true)).toBe('in a few seconds'); +}); + +test('Duration#humanize ru language', async () => { + await settings.loadLocale('ru'); + expect(duration({seconds: 44}, {lang: 'ru'}).humanize(true)).toBe('через несколько секунд'); + expect(duration({seconds: -44}, {lang: 'ru'}).humanize(true)).toBe('несколько секунд назад'); + expect(duration({seconds: +44}, {lang: 'ru'}).humanize(true)).toBe('через несколько секунд'); +}); diff --git a/src/duration/__tests__/getters.ts b/src/duration/__tests__/getters.ts new file mode 100644 index 0000000..6e37e73 --- /dev/null +++ b/src/duration/__tests__/getters.ts @@ -0,0 +1,72 @@ +// Copyright 2019 JS Foundation and other contributors +// Copyright 2024 YANDEX LLC + +import {duration} from '..'; + +const dur = duration({ + years: 1, + quarters: 2, + months: 2, + days: 3, + hours: 4, + minutes: 5, + seconds: 6, + milliseconds: 7, + weeks: 8, +}); + +//------ +// years/months/days/hours/minutes/seconds/milliseconds +//------ + +test('Duration#years returns the years', () => { + expect(dur.years()).toBe(1); +}); + +test('Duration#quarters returns the quarters', () => { + expect(dur.quarters()).toBe(2); +}); + +test('Duration#months returns the (1-indexed) months', () => { + expect(dur.months()).toBe(2); +}); + +test('Duration#days returns the days', () => { + expect(dur.days()).toBe(3); +}); + +test('Duration#hours returns the hours', () => { + expect(dur.hours()).toBe(4); +}); + +test('Duration#hours returns the fractional hours', () => { + const localDur = duration({ + years: 1, + quarters: 2, + months: 2, + days: 3, + hours: 4.5, + minutes: 5, + seconds: 6, + milliseconds: 7, + weeks: 8, + }); + + expect(localDur.hours()).toBe(4.5); +}); + +test('Duration#minutes returns the minutes', () => { + expect(dur.minutes()).toBe(5); +}); + +test('Duration#seconds returns the seconds', () => { + expect(dur.seconds()).toBe(6); +}); + +test('Duration#milliseconds returns the milliseconds', () => { + expect(dur.milliseconds()).toBe(7); +}); + +test('Duration#weeks returns the weeks', () => { + expect(dur.weeks()).toBe(8); +}); diff --git a/src/duration/__tests__/math.ts b/src/duration/__tests__/math.ts new file mode 100644 index 0000000..10e0560 --- /dev/null +++ b/src/duration/__tests__/math.ts @@ -0,0 +1,139 @@ +// Copyright 2019 JS Foundation and other contributors +// Copyright 2024 YANDEX LLC + +import {duration} from '..'; + +//------ +// #add() + +//------ +test('Duration#add add straightforward durations', () => { + const first = duration({hours: 4, minutes: 12, seconds: 2}), + second = duration({hours: 1, seconds: 6, milliseconds: 14}), + result = first.add(second); + + expect(result.hours()).toBe(5); + expect(result.minutes()).toBe(12); + expect(result.seconds()).toBe(8); + expect(result.milliseconds()).toBe(14); +}); + +test('Duration#add add fractional durations', () => { + const first = duration({hours: 4.2, minutes: 12, seconds: 2}), + second = duration({hours: 1, seconds: 6.8, milliseconds: 14}), + result = first.add(second); + + expect(result.hours()).toBeCloseTo(5.2, 8); + expect(result.minutes()).toBe(12); + expect(result.seconds()).toBeCloseTo(8.8, 8); + expect(result.milliseconds()).toBe(14); +}); + +test('Duration#add noops empty druations', () => { + const first = duration({hours: 4, minutes: 12, seconds: 2}), + second = duration({}), + result = first.add(second); + + expect(result.hours()).toBe(4); + expect(result.minutes()).toBe(12); + expect(result.seconds()).toBe(2); +}); + +test('Duration#add adds negatives', () => { + const first = duration({hours: 4, minutes: -12, seconds: -2}), + second = duration({hours: -5, seconds: 6, milliseconds: 14}), + result = first.add(second); + + expect(result.hours()).toBe(-1); + expect(result.minutes()).toBe(-12); + expect(result.seconds()).toBe(4); + expect(result.milliseconds()).toBe(14); +}); + +test('Duration#add adds single values', () => { + const first = duration({hours: 4, minutes: 12, seconds: 2}), + result = first.add({minutes: 5}); + + expect(result.hours()).toBe(4); + expect(result.minutes()).toBe(17); + expect(result.seconds()).toBe(2); +}); + +test('Duration#add adds number as milliseconds', () => { + const first = duration({minutes: 11, seconds: 22}), + result = first.add(333); + + expect(result.minutes()).toBe(11); + expect(result.seconds()).toBe(22); + expect(result.milliseconds()).toBe(333); +}); + +// test('Duration#add maintains invalidity', () => { +// const dur = Duration.invalid('because').add({minutes: 5}); +// expect(dur.isValid).toBe(false); +// expect(dur.invalidReason).toBe('because'); +// }); + +test('Duration#add results in the superset of units', () => { + let dur = duration({hours: 1, minutes: 0}).add({seconds: 3, milliseconds: 0}); + expect(dur.toObject()).toEqual({hours: 1, minutes: 0, seconds: 3, milliseconds: 0}); + + dur = duration({hours: 1, minutes: 0}).add({}); + expect(dur.toObject()).toEqual({hours: 1, minutes: 0}); +}); + +test('Duration#add throws with invalid parameter', () => { + expect(() => duration({}).add('invalid')).toThrow(); +}); + +//------ +// #subtract() +//------ +test('Duration#subtract subtracts durations', () => { + const first = duration({hours: 4, minutes: 12, seconds: 2}), + second = duration({hours: 1, seconds: 6, milliseconds: 14}), + result = first.subtract(second); + + expect(result.hours()).toBe(3); + expect(result.minutes()).toBe(12); + expect(result.seconds()).toBe(-4); + expect(result.milliseconds()).toBe(-14); +}); + +test('Duration#subtract subtracts fractional durations', () => { + const first = duration({hours: 4.2, minutes: 12, seconds: 2}), + second = duration({hours: 1, seconds: 6, milliseconds: 14}), + result = first.subtract(second); + + expect(result.hours()).toBeCloseTo(3.2, 8); + expect(result.minutes()).toBe(12); + expect(result.seconds()).toBe(-4); + expect(result.milliseconds()).toBe(-14); +}); + +test('Duration#subtract subtracts single values', () => { + const first = duration({hours: 4, minutes: 12, seconds: 2}), + result = first.subtract({minutes: 5}); + + expect(result.hours()).toBe(4); + expect(result.minutes()).toBe(7); + expect(result.seconds()).toBe(2); +}); + +//------ +// #negate() +//------ + +test('Duration#negate flips all the signs', () => { + const dur = duration({hours: 4, minutes: -12, seconds: 2}), + result = dur.negate(); + expect(result.hours()).toBe(-4); + expect(result.minutes()).toBe(12); + expect(result.seconds()).toBe(-2); +}); + +test("Duration#negate doesn't mutate", () => { + const orig = duration({hours: 8}); + orig.negate(); + expect(orig.hours()).toBe(8); +}); diff --git a/src/duration/__tests__/parse.ts b/src/duration/__tests__/parse.ts new file mode 100644 index 0000000..f30e0ea --- /dev/null +++ b/src/duration/__tests__/parse.ts @@ -0,0 +1,95 @@ +// Copyright 2019 JS Foundation and other contributors +// Copyright 2024 YANDEX LLC + +import {duration} from '..'; + +const check = (s: any, ob: any) => { + expect(duration(s).toObject()).toEqual(ob); +}; + +test('Duration can parse a variety of ISO formats', () => { + check('P5Y3M', {years: 5, months: 3}); + check('PT54M32S', {minutes: 54, seconds: 32}); + check('P3DT54M32S', {days: 3, minutes: 54, seconds: 32}); + check('P1YT34000S', {years: 1, seconds: 34000}); + check('P1W1DT13H23M34S', {weeks: 1, days: 1, hours: 13, minutes: 23, seconds: 34}); + check('P2W', {weeks: 2}); + check('PT10000000000000000000.999S', {seconds: 10000000000000000000, milliseconds: 999}); +}); + +test('Duration can parse mixed or negative durations', () => { + check('P-5Y-3M', {years: -5, months: -3}); + check('PT-54M32S', {minutes: -54, seconds: 32}); + check('P-3DT54M-32S', {days: -3, minutes: 54, seconds: -32}); + check('P1YT-34000S', {years: 1, seconds: -34000}); + check('P-1W1DT13H23M34S', {weeks: -1, days: 1, hours: 13, minutes: 23, seconds: 34}); + check('P-2W', {weeks: -2}); + check('-P1D', {days: -1}); + check('-P5Y3M', {years: -5, months: -3}); + check('-P-5Y-3M', {years: 5, months: 3}); + check('-P-1W1DT13H-23M34S', {weeks: 1, days: -1, hours: -13, minutes: 23, seconds: -34}); + check('PT-1.5S', {seconds: -1, milliseconds: -500}); + check('PT-0.5S', {milliseconds: -500}); + check('PT1.5S', {seconds: 1, milliseconds: 500}); + check('PT0.5S', {milliseconds: 500}); +}); + +test('Duration can parse fractions of seconds', () => { + expect(duration('PT54M32.5S').toObject()).toEqual({ + minutes: 54, + seconds: 32, + milliseconds: 500, + }); + expect(duration('PT54M32.53S').toObject()).toEqual({ + minutes: 54, + seconds: 32, + milliseconds: 530, + }); + expect(duration('PT54M32.534S').toObject()).toEqual({ + minutes: 54, + seconds: 32, + milliseconds: 534, + }); + expect(duration('PT54M32.5348S').toObject()).toEqual({ + minutes: 54, + seconds: 32, + milliseconds: 534, + }); + expect(duration('PT54M32.034S').toObject()).toEqual({ + minutes: 54, + seconds: 32, + milliseconds: 34, + }); +}); + +test('Duration can parse fractions', () => { + expect(duration('P1.5Y').toObject()).toEqual({ + years: 1.5, + }); + expect(duration('P1.5M').toObject()).toEqual({ + months: 1.5, + }); + expect(duration('P1.5W').toObject()).toEqual({ + weeks: 1.5, + }); + expect(duration('P1.5D').toObject()).toEqual({ + days: 1.5, + }); + expect(duration('PT9.5H').toObject()).toEqual({ + hours: 9.5, + }); +}); + +const rejects = (s: string) => { + expect(() => duration(s)).toThrow(); +}; + +test('Duration rejects junk', () => { + rejects('poop'); + rejects('PTglorb'); + rejects('P5Y34S'); + rejects('5Y'); + rejects('P34S'); + rejects('P34K'); + rejects('P5D2W'); +}); diff --git a/src/duration/__tests__/set.ts b/src/duration/__tests__/set.ts new file mode 100644 index 0000000..238b6e4 --- /dev/null +++ b/src/duration/__tests__/set.ts @@ -0,0 +1,35 @@ +// Copyright 2019 JS Foundation and other contributors +// Copyright 2024 YANDEX LLC + +import {duration} from '..'; + +const dur = () => + duration({ + years: 1, + months: 1, + days: 1, + hours: 1, + minutes: 1, + seconds: 1, + milliseconds: 1, + }); + +test('Duration#set sets the values', () => { + expect(dur().set({years: 2}).years()).toBe(2); + expect(dur().set({months: 2}).months()).toBe(2); + expect(dur().set({days: 2}).days()).toBe(2); + expect(dur().set({hours: 4}).hours()).toBe(4); + expect(dur().set({hours: 4.5}).hours()).toBe(4.5); + expect(dur().set({minutes: 16}).minutes()).toBe(16); + expect(dur().set({seconds: 45}).seconds()).toBe(45); + expect(dur().set({milliseconds: 86}).milliseconds()).toBe(86); +}); + +test('Duration#set throws for metadata', () => { + // @ts-expect-error + expect(() => dur.set({locale: 'be'})).toThrow(); + // @ts-expect-error + expect(() => dur.set({numberingSystem: 'thai'})).toThrow(); + // @ts-expect-error + expect(() => dur.set({invalid: 42})).toThrow(); +}); diff --git a/src/duration/__tests__/units.ts b/src/duration/__tests__/units.ts new file mode 100644 index 0000000..a9b9e2a --- /dev/null +++ b/src/duration/__tests__/units.ts @@ -0,0 +1,302 @@ +// Copyright 2019 JS Foundation and other contributors +// Copyright 2024 YANDEX LLC + +import {duration} from '..'; + +//------ +// #shiftTo() + +//------- +test('Duration#shiftTo rolls milliseconds up hours and minutes', () => { + const dur = duration(5760000); + expect(dur.shiftTo(['hours']).hours()).toBe(1.6); + + const mod = dur.shiftTo(['hours', 'minutes']); + expect(mod.toObject()).toEqual({hours: 1, minutes: 36}); +}); + +test('Duration#shiftTo boils hours down milliseconds', () => { + const dur = duration({hours: 1}).shiftTo(['milliseconds']); + expect(dur.milliseconds()).toBe(3600000); +}); + +test('Duration boils hours down shiftTo minutes and milliseconds', () => { + const dur = duration({hours: 1, seconds: 30}).shiftTo(['minutes', 'milliseconds']); + expect(dur.toObject()).toEqual({minutes: 60, milliseconds: 30000}); +}); + +test('Duration#shiftTo boils down and then rolls up', () => { + const dur = duration({years: 2, hours: 5000}).shiftTo(['months', 'days', 'minutes'], { + roundUp: true, + }); + expect(dur.toObject()).toEqual({months: 30, days: 25, minutes: 1025}); +}); + +test('Duration#shiftTo throws on invalid units', () => { + expect(() => { + // @ts-expect-error + duration({years: 2, hours: 5000}).shiftTo('months', 'glorp'); + }).toThrow(); +}); + +test('Duration#shiftTo tacks decimals onto the end', () => { + const dur = duration({minutes: 73}).shiftTo(['hours']); + expect(dur.isValid()).toBe(true); + expect(dur.hours()).toBeCloseTo(1.2167, 4); +}); + +test('Duration#shiftTo deconstructs decimal inputs', () => { + const dur = duration({hours: 2.3}).shiftTo(['hours', 'minutes']); + expect(dur.isValid()).toBe(true); + expect(dur.hours()).toBe(2); + expect(dur.minutes()).toBeCloseTo(18, 8); +}); + +test('Duration#shiftTo deconstructs in cascade and tacks decimal onto the end', () => { + const dur = duration({hours: 1.17}).shiftTo(['hours', 'minutes', 'seconds']); + + expect(dur.isValid()).toBe(true); + expect(dur.hours()).toBe(1); + expect(dur.minutes()).toBe(10); + expect(dur.seconds()).toBeCloseTo(12, 8); +}); + +test('Duration#shiftTo without any units no-ops', () => { + const dur = duration({years: 3}).shiftTo([]); + expect(dur.isValid()).toBe(true); + expect(dur.toObject()).toEqual({years: 3}); +}); + +test('Duration#shiftTo accumulates when rolling up', () => { + expect( + duration({minutes: 59, seconds: 183}).shiftTo(['hours', 'minutes', 'seconds']).toObject(), + ).toEqual({hours: 1, minutes: 2, seconds: 3}); +}); + +test('Duration#shiftTo keeps unnecessary higher-order negative units 0', () => { + expect( + duration({milliseconds: -100}).shiftTo(['hours', 'minutes', 'seconds']).toObject(), + ).toEqual({hours: 0, minutes: 0, seconds: -0.1}); +}); + +test('Duration#shiftTo does not normalize values', () => { + // Normalizing would convert to { quarters: 4, months: 1, days: 10 } + // which would be converted back to 404 days instead + expect(duration({quarters: 0, months: 0, days: 400}).shiftTo(['days']).toObject()).toEqual({ + days: 400, + }); +}); + +test('Duration#shiftTo boils hours down to hours and minutes', () => { + const dur = duration({hour: 2.4}); + expect(dur.shiftTo(['hours', 'minutes']).toObject()).toEqual({ + hours: 2, + minutes: 24, + }); +}); + +test('Duration#shiftTo handles mixed units', () => { + const dur = duration({weeks: -1, days: 14}); + expect(dur.shiftTo(['years', 'months', 'weeks']).toObject()).toEqual({ + years: 0, + months: 0, + weeks: 1, + }); +}); + +test('Duration#shiftTo does not produce unnecessary fractions in higher order units', () => { + const dur = duration({years: 2.5, weeks: -1}); + const shifted = dur.shiftTo(['years', 'weeks', 'minutes']).toObject(); + expect(shifted.years).toBe(2); + expect(shifted.weeks).toBe(25); + expect(shifted.minutes).toBeCloseTo(894.6, 5); +}); + +//------ +// #normalize() +//------- +test('Duration#normalize rebalances negative units', () => { + const dur = duration({years: 2, days: -2}).normalize({roundUp: true}); + expect(dur.toObject()).toEqual({years: 1, days: 363}); +}); + +test('Duration#normalize de-overflows', () => { + const dur = duration({years: 2, days: 5000}).normalize({roundUp: true}); + expect(dur.years()).toBe(15); + expect(dur.days()).toBe(252); + expect(dur.toObject()).toEqual({years: 15, days: 252}); +}); + +test('Duration#normalize handles fully negative durations', () => { + const dur = duration({years: -2, days: -5000}).normalize({roundUp: true}); + expect(dur.toObject()).toEqual({years: -15, days: -252}); +}); + +test('Duration#normalize handles the full grid partially negative durations', () => { + const sets = [ + [ + {months: 1, days: 32}, + {months: 2, days: 2}, + ], + [ + {months: 1, days: 28}, + {months: 1, days: 28}, + ], + [ + {months: 1, days: -32}, + {months: 0, days: -2}, + ], + [ + {months: 1, days: -28}, + {months: 0, days: 2}, + ], + [ + {months: -1, days: 32}, + {months: 0, days: 2}, + ], + [ + {months: -1, days: 28}, + {months: 0, days: -2}, + ], + [ + {months: -1, days: -32}, + {months: -2, days: -2}, + ], + [ + {months: -1, days: -28}, + {months: -1, days: -28}, + ], + [ + {months: 0, days: 32}, + {months: 1, days: 2}, + ], + [ + {months: 0, days: 28}, + {months: 0, days: 28}, + ], + [ + {months: 0, days: -32}, + {months: -1, days: -2}, + ], + [ + {months: 0, days: -28}, + {months: 0, days: -28}, + ], + [ + {hours: 96, minutes: 0, seconds: -10}, + {hours: 95, minutes: 59, seconds: 50}, + ], + ]; + + sets.forEach(([from, to]) => { + expect(duration(from).normalize({roundUp: true}).toObject()).toEqual(to); + }); +}); + +test('Duration#normalize can convert all unit pairs', () => { + const units = [ + 'years', + 'quarters', + 'months', + 'weeks', + 'days', + 'hours', + 'minutes', + 'seconds', + 'milliseconds', + ] as const; + + for (let i = 0; i < units.length; i++) { + for (let j = i + 1; j < units.length; j++) { + const dur = duration({[units[i]]: 1, [units[j]]: 2}); + const normalizedDuration = dur.normalize().toObject(); + expect(normalizedDuration[units[i]]).not.toBe(NaN); + expect(normalizedDuration[units[j]]).not.toBe(NaN); + } + } +}); + +test('Duration#normalize moves fractions to lower-order units', () => { + expect(duration({years: 2.5, days: 0, hours: 0}).normalize({roundUp: true}).toObject()).toEqual( + { + years: 2, + days: 182, + hours: 15, + }, + ); + expect( + duration({years: -2.5, days: 0, hours: 0}).normalize({roundUp: true}).toObject(), + ).toEqual({ + years: -2, + days: -182, + hours: -15, + }); + expect( + duration({years: 2.5, days: 12, hours: 0}).normalize({roundUp: true}).toObject(), + ).toEqual({ + years: 2, + days: 194, + hours: 15, + }); + expect( + duration({years: 2.5, days: 12.25, hours: 0}).normalize({roundUp: true}).toObject(), + ).toEqual({ + years: 2, + days: 194, + hours: 21, + }); +}); + +test('Duration#normalize does not produce fractions in higher order units when rolling up negative lower order unit values', () => { + const normalized = duration({years: 100, months: 0, weeks: -1, days: 0}).normalize().toObject(); + expect(normalized.years).toBe(99); + expect(normalized.months).toBe(11); + expect(normalized.weeks).toBe(3); + expect(normalized.days).toBeCloseTo(2.436875, 7); +}); + +//------ +// #rescale() +//------- +test('Duration#rescale normalizes, shifts to all units and remove units with a value of 0', () => { + const sets = [ + [{milliseconds: 90000}, {minutes: 1, seconds: 30}], + [ + {minutes: 70, milliseconds: 12100}, + {hours: 1, minutes: 10, seconds: 12, milliseconds: 100}, + ], + [{months: 2, days: -146097.0 / 4800}, {months: 1}], + ]; + + sets.forEach(([from, to]) => { + expect(duration(from).rescale().toObject()).toEqual(to); + }); +}); + +//------ +// #as() +//------- + +test('Duration#as shifts to one unit and returns it', () => { + const dur = duration(5760000); + expect(dur.as('hours')).toBe(1.6); +}); + +//------ +// #valueOf() +//------- + +test('Duration#valueOf value of zero duration', () => { + const dur = duration({}); + expect(dur.valueOf()).toBe(0); +}); + +test('Duration#valueOf returns as millisecond value (lower order units)', () => { + const dur = duration({hours: 1, minutes: 36, seconds: 0}); + expect(dur.valueOf()).toBe(5760000); +}); + +test('Duration#valueOf value of the duration with lower and higher order units', () => { + const dur = duration({days: 2, seconds: 1}); + expect(dur.valueOf()).toBe(172801000); +}); diff --git a/src/duration/createDuration.ts b/src/duration/createDuration.ts new file mode 100644 index 0000000..11c5811 --- /dev/null +++ b/src/duration/createDuration.ts @@ -0,0 +1,66 @@ +// Copyright 2019 JS Foundation and other contributors +// Copyright 2024 YANDEX LLC + +import type {Duration, DurationInput, DurationInputObject, DurationUnit} from '../typings'; +import {normalizeDateComponents, normalizeDurationUnit} from '../utils/utils'; + +import {DurationImpl, isDuration} from './duration'; +import {removeZeros} from './normalize'; + +const isoRegex = + /^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9]+)(?:[.,]([0-9]+)?)?S)?)?$/; + +interface Options { + lang?: string; +} +export function createDuration( + amount: DurationInput, + unit?: DurationUnit, + options?: Options, +): Duration; +export function createDuration(amount: DurationInput, options?: Options): Duration; +export function createDuration( + amount: DurationInput, + unit?: DurationUnit | Options, + options: Options = {}, +): Duration { + let duration: DurationInputObject = {}; + let match: RegExpExecArray | null = null; + const {lang} = unit && typeof unit === 'object' ? unit : options; + const durationUnit = typeof unit === 'string' ? unit : 'milliseconds'; + if (isDuration(amount)) { + return amount; + } else if (!isNaN(Number(amount))) { + duration[durationUnit] = Number(amount); + } else if (typeof amount === 'string' && (match = isoRegex.exec(amount))) { + const sign = match[1] === '-' ? -1 : 1; + const secondsSign = match[8] && match[8][0] === '-' ? -1 : 1; + duration = removeZeros({ + y: parseIso(match[2]) * sign, + M: parseIso(match[3]) * sign, + w: parseIso(match[4]) * sign, + d: parseIso(match[5]) * sign, + h: parseIso(match[6]) * sign, + m: parseIso(match[7]) * sign, + s: parseIso(match[8]) * sign, + ms: + Math.floor(parseIso(match[9] ? `0.${match[9]}` : match[9]) * 1000) * + secondsSign * + sign, + }); + } else if (amount && typeof amount === 'object') { + duration = amount; + } else { + throw new Error(`Unknown duration: ${amount}`); + } + + return new DurationImpl({ + values: normalizeDateComponents(duration, normalizeDurationUnit), + locale: lang, + }); +} + +function parseIso(inp: string | undefined) { + const res = inp ? parseFloat(inp.replace(',', '.')) : 0; + return isNaN(res) ? 0 : res; +} diff --git a/src/duration/duration.ts b/src/duration/duration.ts new file mode 100644 index 0000000..66bc3cc --- /dev/null +++ b/src/duration/duration.ts @@ -0,0 +1,351 @@ +// Copyright 2019 JS Foundation and other contributors +// Copyright 2024 YANDEX LLC + +import {dateTime} from '../dateTime'; +import {settings} from '../settings'; +import type {Duration, DurationInput, DurationInputObject, DurationUnit} from '../typings'; +import {normalizeDateComponents, normalizeDurationUnit} from '../utils'; +import {getListFormat, getNumberFormat} from '../utils/locale'; + +import {createDuration} from './createDuration'; +import {normalizeValues, orderedUnits, rescale, shiftTo} from './normalize'; + +const IS_DURATION = Symbol('isDuration'); + +export type NormalizedUnit = (typeof orderedUnits)[number]; + +export type DurationValues = Partial>; + +export class DurationImpl implements Duration { + static isDuration(o: unknown): o is Duration { + return (typeof o === 'object' && o && IS_DURATION in o && o[IS_DURATION] === true) || false; + } + + [IS_DURATION] = true; + private _values: DurationValues; + private _locale: string; + private _isValid: boolean; + + constructor(options: {values: DurationValues; locale?: string; isValid?: boolean}) { + this._values = options.values; + this._locale = options.locale || settings.getLocale(); + this._isValid = options.isValid || true; + } + + get(unit: DurationUnit): number { + if (!this.isValid()) { + return NaN; + } + const name = normalizeDurationUnit(unit); + + return this._values[name] || 0; + } + + set(values: DurationInputObject): Duration { + if (!this.isValid()) { + return this; + } + const newValues = { + ...this._values, + ...normalizeDateComponents(values, normalizeDurationUnit), + }; + return new DurationImpl({values: newValues, locale: this._locale}); + } + + as(unit: DurationUnit): number { + if (!this.isValid()) { + return NaN; + } + const name = normalizeDurationUnit(unit); + + // handle milliseconds separately because of floating point math errors + const days = + this.days() + + this.weeks() * 7 + + this.hours() / 24 + + this.minutes() / 1440 + + this.seconds() / 86400; + const months = this.months() + this.quarters() * 3 + this.years() * 12; + const milliseconds = this.milliseconds(); + if (name === 'months' || name === 'quarters' || name === 'years') { + const monthsWithDays = months + daysToMonths(days + milliseconds / 86400000); + switch (name) { + case 'months': + return monthsWithDays; + case 'quarters': + return monthsWithDays / 3; + case 'years': + return monthsWithDays / 12; + } + } + const daysWithMonths = days + monthsToDays(months); + switch (name) { + case 'weeks': + return daysWithMonths / 7 + milliseconds / 6048e5; + case 'days': + return daysWithMonths + milliseconds / 864e5; + case 'hours': + return daysWithMonths * 24 + milliseconds / 36e5; + case 'minutes': + return daysWithMonths * 1440 + milliseconds / 6e4; + case 'seconds': + return daysWithMonths * 86400 + milliseconds / 1000; + // Math.floor prevents floating point math errors here + case 'milliseconds': + return Math.floor(daysWithMonths * 864e5) + milliseconds; + default: + throw new Error('Unknown unit ' + name); + } + } + + milliseconds(): number { + return this.isValid() ? this._values.milliseconds || 0 : NaN; + } + asMilliseconds(): number { + return this.as('milliseconds'); + } + seconds(): number { + return this.isValid() ? this._values.seconds || 0 : NaN; + } + asSeconds(): number { + return this.as('seconds'); + } + minutes(): number { + return this.isValid() ? this._values.minutes || 0 : NaN; + } + asMinutes(): number { + return this.as('minutes'); + } + hours(): number { + return this.isValid() ? this._values.hours || 0 : NaN; + } + asHours(): number { + return this.as('hours'); + } + days(): number { + return this.isValid() ? this._values.days || 0 : NaN; + } + asDays(): number { + return this.as('days'); + } + weeks(): number { + return this.isValid() ? this._values.weeks || 0 : NaN; + } + asWeeks(): number { + return this.as('weeks'); + } + months(): number { + return this.isValid() ? this._values.months || 0 : NaN; + } + asMonths(): number { + return this.as('months'); + } + quarters(): number { + return this.isValid() ? this._values.quarters || 0 : NaN; + } + asQuarters(): number { + return this.as('quarters'); + } + years(): number { + return this.isValid() ? this._values.years || 0 : NaN; + } + asYears(): number { + return this.as('years'); + } + + add(amount: DurationInput, unit?: DurationUnit | undefined): Duration { + if (!this.isValid()) { + return this; + } + + const newValues = this.toObject(); + const addValues = createDuration(amount, unit).toObject(); + for (const [key, value] of Object.entries(addValues)) { + const k = key as keyof DurationValues; + newValues[k] = (newValues[k] || 0) + value; + } + + return new DurationImpl({values: newValues, locale: this._locale}); + } + + subtract(amount: DurationInput, unit?: DurationUnit | undefined): Duration { + const subtractDuration = createDuration(amount, unit).negate(); + return this.add(subtractDuration); + } + + negate() { + const values: DurationValues = {}; + for (const [key, value] of Object.entries(this._values)) { + values[key as keyof DurationValues] = value ? -value : 0; + } + + return new DurationImpl({values, locale: this._locale}); + } + + normalize(options?: {roundUp?: boolean}): Duration { + if (!this.isValid()) { + return this; + } + return new DurationImpl({ + values: normalizeValues(this._values, options), + locale: this._locale, + }); + } + + shiftTo(units: DurationUnit[], options?: {roundUp?: boolean}) { + if (!this.isValid()) { + return this; + } + const normalizedUnits = units.map((u) => normalizeDurationUnit(u)); + return new DurationImpl({ + values: shiftTo(this._values, normalizedUnits, options), + locale: this._locale, + }); + } + + rescale(options?: {roundUp?: boolean}) { + if (!this.isValid()) { + return this; + } + return new DurationImpl({ + values: rescale(this._values, options), + locale: this._locale, + }); + } + + toISOString(): string { + if (!this.isValid()) { + return 'Invalid Duration'; + } + + let s = 'P'; + if (this.years() !== 0) { + s += this.years() + 'Y'; + } + if (this.months() !== 0 || this.quarters() !== 0) { + s += this.months() + this.quarters() * 3 + 'M'; + } + if (this.weeks() !== 0) { + s += this.weeks() + 'W'; + } + if (this.days() !== 0) { + s += this.days() + 'D'; + } + if ( + this.hours() !== 0 || + this.minutes() !== 0 || + this.seconds() !== 0 || + this.milliseconds() !== 0 + ) { + s += 'T'; + } + if (this.hours() !== 0) { + s += this.hours() + 'H'; + } + if (this.minutes() !== 0) { + s += this.minutes() + 'M'; + } + if (this.seconds() !== 0 || this.milliseconds() !== 0) { + s += Math.round(1000 * this.seconds() + this.milliseconds()) / 1000 + 'S'; + } + if (s === 'P') s += 'T0S'; + return s; + } + + toJSON(): string { + return this.toISOString(); + } + + toObject() { + if (!this.isValid()) { + return {}; + } + + return {...this._values}; + } + + toString(): string { + return this.toISOString(); + } + + valueOf(): number { + return this.asMilliseconds(); + } + + /** + * Returns a string representation of this Duration appropriate for the REPL. + * @return {string} + */ + [Symbol.for('nodejs.util.inspect.custom')]() { + if (this.isValid()) { + return `Duration { values: ${JSON.stringify(this._values)} }`; + } else { + return `Duration { Invalid Duration }`; + } + } + + humanize(withSuffix?: boolean) { + if (!this.isValid()) { + return 'Invalid Duration'; + } + return dateTime({lang: this._locale}).add(this.valueOf(), 'ms').fromNow(!withSuffix); + } + + humanizeIntl( + options: { + listStyle?: 'long' | 'short' | 'narrow'; + unitDisplay?: Intl.NumberFormatOptions['unitDisplay']; + } = {}, + ): string { + if (!this.isValid()) { + return 'Invalid Duration'; + } + const l = orderedUnits + .map((unit) => { + const val = this._values[unit]; + if (val === undefined) { + return null; + } + return getNumberFormat(this._locale, { + style: 'unit', + unitDisplay: 'long', + ...options, + unit: unit.slice(0, -1), + }).format(val); + }) + .filter(Boolean) as string[]; + + return getListFormat(this._locale, { + type: 'conjunction', + style: options.listStyle || 'narrow', + }).format(l); + } + + isValid(): boolean { + return this._isValid; + } + + locale(): string; + locale(locale: string): Duration; + locale(locale?: string): string | Duration { + if (!locale) { + return this._locale; + } + return new DurationImpl({values: this._values, locale}); + } +} + +export function isDuration(value: unknown): value is Duration { + return DurationImpl.isDuration(value); +} + +function daysToMonths(days: number) { + // 400 years have 146097 days (taking into account leap year rules) + // 400 years have 12 months === 4800 + return (days * 4800) / 146097; +} + +function monthsToDays(months: number) { + // the reverse of daysToMonths + return (months * 146097) / 4800; +} diff --git a/src/duration/index.ts b/src/duration/index.ts new file mode 100644 index 0000000..d9fd48b --- /dev/null +++ b/src/duration/index.ts @@ -0,0 +1,2 @@ +export {createDuration as duration} from './createDuration'; +export {isDuration} from './duration'; diff --git a/src/duration/normalize.ts b/src/duration/normalize.ts new file mode 100644 index 0000000..b4e5d16 --- /dev/null +++ b/src/duration/normalize.ts @@ -0,0 +1,231 @@ +// Copyright 2019 JS Foundation and other contributors +// Copyright 2024 YANDEX LLC + +import type {DurationValues} from './duration'; + +const daysInYearAccurate = 146097.0 / 400; +const daysInMonthAccurate = 146097.0 / 4800; + +const lowOrderMatrix = { + weeks: { + days: 7, + hours: 7 * 24, + minutes: 7 * 24 * 60, + seconds: 7 * 24 * 60 * 60, + milliseconds: 7 * 24 * 60 * 60 * 1000, + }, + days: { + hours: 24, + minutes: 24 * 60, + seconds: 24 * 60 * 60, + milliseconds: 24 * 60 * 60 * 1000, + }, + hours: {minutes: 60, seconds: 60 * 60, milliseconds: 60 * 60 * 1000}, + minutes: {seconds: 60, milliseconds: 60 * 1000}, + seconds: {milliseconds: 1000}, +}; + +const matrix = { + years: { + quarters: 4, + months: 12, + weeks: daysInYearAccurate / 7, + days: daysInYearAccurate, + hours: daysInYearAccurate * 24, + minutes: daysInYearAccurate * 24 * 60, + seconds: daysInYearAccurate * 24 * 60 * 60, + milliseconds: daysInYearAccurate * 24 * 60 * 60 * 1000, + }, + quarters: { + months: 3, + weeks: daysInYearAccurate / 28, + days: daysInYearAccurate / 4, + hours: (daysInYearAccurate * 24) / 4, + minutes: (daysInYearAccurate * 24 * 60) / 4, + seconds: (daysInYearAccurate * 24 * 60 * 60) / 4, + milliseconds: (daysInYearAccurate * 24 * 60 * 60 * 1000) / 4, + }, + months: { + weeks: daysInMonthAccurate / 7, + days: daysInMonthAccurate, + hours: daysInMonthAccurate * 24, + minutes: daysInMonthAccurate * 24 * 60, + seconds: daysInMonthAccurate * 24 * 60 * 60, + milliseconds: daysInMonthAccurate * 24 * 60 * 60 * 1000, + }, + ...lowOrderMatrix, +}; + +export const orderedUnits = [ + 'years', + 'quarters', + 'months', + 'weeks', + 'days', + 'hours', + 'minutes', + 'seconds', + 'milliseconds', +] as const; + +const reverseUnits = orderedUnits.slice(0).reverse(); + +export function normalizeValues(values: DurationValues, {roundUp}: {roundUp?: boolean} = {}) { + const newValues: DurationValues = {...values}; + + const factor = durationToMilliseconds(values) < 0 ? -1 : 1; + + let previous = null; + for (let i = 0; i < reverseUnits.length; i++) { + const current = reverseUnits[i]; + if (newValues[current] === undefined || newValues[current] === null) { + continue; + } + if (!previous) { + previous = current; + continue; + } + + const previousVal = (newValues[previous] ?? 0) * factor; + // @ts-expect-error + const conv = matrix[current][previous]; + + // if (previousVal < 0): + // lower order unit is negative (e.g. { years: 2, days: -2 }) + // normalize this by reducing the higher order unit by the appropriate amount + // and increasing the lower order unit + // this can never make the higher order unit negative, because this function only operates + // on positive durations, so the amount of time represented by the lower order unit cannot + // be larger than the higher order unit + // else: + // lower order unit is positive (e.g. { years: 2, days: 450 } or { years: -2, days: 450 }) + // in this case we attempt to convert as much as possible from the lower order unit into + // the higher order one + // + // Math.floor takes care of both of these cases, rounding away from 0 + // if previousVal < 0 it makes the absolute value larger + // if previousVal >= it makes the absolute value smaller + const rollUp = Math.floor(previousVal / conv); + newValues[current] = (newValues[current] ?? 0) + rollUp * factor; + newValues[previous] = (newValues[previous] ?? 0) - rollUp * conv * factor; + previous = current; + } + + // try to convert any decimals into smaller units if possible + // for example for { years: 2.5, days: 0, seconds: 0 } we want to get { years: 2, days: 182, hours: 12 } + previous = null; + for (let i = 0; i < orderedUnits.length; i++) { + const current = orderedUnits[i]; + if (newValues[current] === undefined || newValues[current] === null) { + continue; + } + if (!previous) { + previous = current; + continue; + } + + const fraction = (newValues[previous] ?? 0) % 1; + newValues[previous] = (newValues[previous] ?? 0) - fraction; + // @ts-expect-error + newValues[current] = (newValues[current] ?? 0) + fraction * matrix[previous][current]; + previous = current; + } + + if (roundUp && previous && newValues[previous]) { + newValues[previous] = Math.round(newValues[previous] ?? 0); + } + + return newValues; +} + +function durationToMilliseconds(values: DurationValues) { + let sum = values.milliseconds ?? 0; + for (const unit of reverseUnits.slice(1)) { + const v = values[unit]; + if (v) { + // @ts-expect-error + sum += v * matrix[unit]['milliseconds']; + } + } + return sum; +} + +export function removeZeros>(values: Partial) { + const newValues: Partial = {}; + for (const [key, value] of Object.entries(values)) { + if (value !== 0) { + newValues[key as keyof T] = value; + } + } + return newValues; +} + +export function shiftTo( + values: DurationValues, + units: (keyof DurationValues)[], + options?: {roundUp?: boolean}, +) { + if (!units.length) { + return values; + } + const newValues: DurationValues = {}; + const accumulated: DurationValues = {}; + let lastUnit; + + for (const unit of orderedUnits) { + if (!units.includes(unit)) { + if (values[unit]) { + accumulated[unit] = values[unit]; + } + continue; + } + lastUnit = unit; + + let own = 0; + + // anything we haven't boiled down yet should get boiled to this unit + for (const ak of Object.keys(accumulated)) { + // @ts-expect-error + own += matrix[ak][unit] * accumulated[ak]; + accumulated[ak as keyof DurationValues] = 0; + } + + // plus anything that's already in this unit + const v = values[unit]; + if (v) { + own += v; + } + + // only keep the integer part for now in the hopes of putting any decimal part + // into a smaller unit later + const i = Math.trunc(own); + newValues[unit] = i; + accumulated[unit] = (own * 1000 - i * 1000) / 1000; + } + + // anything leftover becomes the decimal for the last unit + // lastUnit must be defined since units is not empty + for (const [key, value] of Object.entries(accumulated)) { + if (value !== 0) { + newValues[lastUnit as keyof DurationValues] = + (newValues[lastUnit as keyof DurationValues] ?? 0) + + (key === lastUnit + ? value + : // @ts-expect-error + value / matrix[lastUnit][key]); + } + } + + return normalizeValues(newValues, options); +} + +export function rescale(values: DurationValues, options?: {roundUp?: boolean}): DurationValues { + const newValues = removeZeros( + shiftTo( + normalizeValues(values), + ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'], + options, + ), + ); + return newValues; +} diff --git a/src/index.ts b/src/index.ts index c3cad98..9a3d09d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,5 +7,6 @@ export {dateTime, dateTimeUtc, isDateTime} from './dateTime'; export {parse as defaultRelativeParse, isLikeRelative as defaultIsLikeRelative} from './datemath'; export {dateTimeParse, isValid, isLikeRelative} from './parser'; export {getTimeZonesList, guessUserTimeZone, isValidTimeZone, timeZoneOffset} from './timeZone'; -export type {DateTime, DateTimeInput} from './typings'; +export type {DateTime, DateTimeInput, Duration, DurationInput} from './typings'; export {UtcTimeZone} from './constants'; +export {duration, isDuration} from './duration'; diff --git a/src/timeZone/timeZone.ts b/src/timeZone/timeZone.ts index f529fe6..dfd17a0 100644 --- a/src/timeZone/timeZone.ts +++ b/src/timeZone/timeZone.ts @@ -1,6 +1,7 @@ import {UtcTimeZone} from '../constants'; import dayjs from '../dayjs'; import type {TimeZone} from '../typings'; +import {getDateTimeFormat} from '../utils/locale'; /** * Returns the user's time zone. @@ -34,24 +35,6 @@ export function isValidTimeZone(zone: string) { } } -const dateTimeFormatCache: Record = {}; -function makeDateTimeFormat(zone: TimeZone) { - if (!dateTimeFormatCache[zone]) { - dateTimeFormatCache[zone] = new Intl.DateTimeFormat('en-US', { - hour12: false, - timeZone: zone === 'system' ? undefined : zone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - era: 'short', - }); - } - return dateTimeFormatCache[zone]; -} - const dateFields = [ 'year', 'month', @@ -76,7 +59,17 @@ export function timeZoneOffset(zone: TimeZone, ts: number) { return -date.getTimezoneOffset(); } - const dtf = makeDateTimeFormat(zone); + const dtf = getDateTimeFormat('en-US', { + hour12: false, + timeZone: zone === 'system' ? undefined : zone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + era: 'short', + }); const formatted = dtf.formatToParts(date); const parts: DateParts = { year: 1, diff --git a/src/typings/dateTime.ts b/src/typings/dateTime.ts index 9f1c515..299fc31 100644 --- a/src/typings/dateTime.ts +++ b/src/typings/dateTime.ts @@ -1,3 +1,5 @@ +import type {DurationInput, DurationUnit} from './duration'; + export type DateTimeInput = | InputObject | Date @@ -8,8 +10,7 @@ export type DateTimeInput = | null | undefined; export type FormatInput = string | undefined; -export type DurationInput = number | string | DurationInputObject | null | undefined; -type BaseUnit = +export type BaseUnit = | 'year' | 'years' | 'y' @@ -32,12 +33,11 @@ type BaseUnit = | 'milliseconds' | 'ms'; -type QuarterUnit = 'quarter' | 'quarters' | 'Q'; -type WeekUnit = 'week' | 'weeks' | 'w'; +export type QuarterUnit = 'quarter' | 'quarters' | 'Q'; +export type WeekUnit = 'week' | 'weeks' | 'w'; type IsoWeekUnit = 'isoWeek' | 'isoWeeks'; // | 'W'; - not supported; type DateUnit = 'date' | 'dates' | 'D'; export type StartOfUnit = BaseUnit | QuarterUnit | WeekUnit | IsoWeekUnit | DateUnit; -export type DurationUnit = BaseUnit | QuarterUnit | WeekUnit; export type AllUnit = | BaseUnit | QuarterUnit @@ -49,7 +49,6 @@ export type AllUnit = | 'E'; export type InputObject = Partial>; -export type DurationInputObject = Partial>; export type SetObject = Partial< Record< | BaseUnit @@ -63,7 +62,7 @@ export type SetObject = Partial< | 'isoWeekday' | 'isoWeekdays' | 'E', - number + number | string > >; diff --git a/src/typings/duration.ts b/src/typings/duration.ts new file mode 100644 index 0000000..dd37f8d --- /dev/null +++ b/src/typings/duration.ts @@ -0,0 +1,104 @@ +import type {DurationValues} from '../duration/duration'; + +import type {BaseUnit, QuarterUnit, WeekUnit} from './dateTime'; + +export type DurationUnit = BaseUnit | QuarterUnit | WeekUnit; +export type DurationInputObject = Partial>; +export type DurationInput = Duration | number | string | DurationInputObject | null | undefined; + +export interface Duration { + /** Return the length of the duration in the specified unit. */ + as(unit: DurationUnit): number; + + /** Get the value of unit. */ + get(unit: DurationUnit): number; + + /** Set the values of specified units. Return a newly-constructed Duration. */ + set(values: DurationInputObject): Duration; + + /** Get the milliseconds. */ + milliseconds(): number; + /** Return the length of the duration in the milliseconds. */ + asMilliseconds(): number; + + /** Get the seconds. */ + seconds(): number; + /** Return the length of the duration in the seconds. */ + asSeconds(): number; + + /** Get the minutes. */ + minutes(): number; + /** Return the length of the duration in the minutes. */ + asMinutes(): number; + + /** Get the hours. */ + hours(): number; + /** Return the length of the duration in the hours. */ + asHours(): number; + + /** Get the days. */ + days(): number; + /** Return the length of the duration in the days. */ + asDays(): number; + + /** Get the weeks. */ + weeks(): number; + /** Return the length of the duration in the weeks. */ + asWeeks(): number; + + /** Get the months. */ + months(): number; + /** Return the length of the duration in the months. */ + asMonths(): number; + + /** Get the quarters. */ + quarters(): number; + /** Return the length of the duration in the quarters. */ + asQuarters(): number; + + /** Get the years. */ + years(): number; + /** Return the length of the duration in the years. */ + asYears(): number; + + /** Make this Duration longer by the specified amount. Return a newly-constructed Duration. */ + add(amount: DurationInput, unit?: DurationUnit): Duration; + + /** Make this Duration shorter by the specified amount. Return a newly-constructed Duration. */ + subtract(amount: DurationInput, unit?: DurationUnit): Duration; + + /** Return the negative of this Duration. */ + negate(): Duration; + + locale(): string; + locale(locale: string): Duration; + + /** Returns an ISO 8601-compliant string representation of this Duration. */ + toISOString(): string; + + /** Returns an ISO 8601 representation of this Duration appropriate for use in JSON. */ + toJSON(): string; + + /** Returns a JavaScript object with this Duration's values. */ + toObject(): DurationValues; + + /** Returns a string representation of a Duration in `dateTime.from` format. */ + humanize(withSuffix?: boolean): string; + + /** Returns a string representation of a Duration with all units included. */ + humanizeIntl(options?: { + listStyle?: 'long' | 'short' | 'narrow'; // Intl.ListFormatStyle + unitDisplay?: Intl.NumberFormatOptions['unitDisplay']; + }): string; + + /** Reduce this Duration to its canonical representation in its current units. */ + normalize(options?: {roundUp?: boolean}): Duration; + + /** Convert this Duration into its representation in a different set of units. */ + shiftTo(units: DurationUnit[], options?: {roundUp?: boolean}): Duration; + + /** Rescale units to its largest representation */ + rescale(options?: {roundUp?: boolean}): Duration; + + isValid(): boolean; +} diff --git a/src/typings/index.ts b/src/typings/index.ts index d9ad988..fd070f1 100644 --- a/src/typings/index.ts +++ b/src/typings/index.ts @@ -2,3 +2,4 @@ export * from './common'; export * from './dateTime'; export * from './timeZone'; export * from './parser'; +export * from './duration'; diff --git a/src/utils/duration.ts b/src/utils/duration.ts deleted file mode 100644 index 24f2bec..0000000 --- a/src/utils/duration.ts +++ /dev/null @@ -1,66 +0,0 @@ -import isNumber from 'lodash/isNumber'; - -import {isDateTime} from '../dateTime'; -import type {DateTimeInput, DurationInputObject, DurationUnit, InputObject} from '../typings'; - -import {normalizeDateComponents} from './utils'; - -const isoRegex = - /^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/; - -export interface DurationObject { - milliseconds: number; - days: number; - months: number; -} -// eslint-disable-next-line complexity -export function getDuration(amount: DateTimeInput, unit?: DurationUnit): DurationObject { - let duration: DurationInputObject = {}; - let match: RegExpExecArray | null = null; - if (amount === null || amount === undefined) { - } else if (isDateTime(amount)) { - duration[unit ? unit : 'milliseconds'] = amount.valueOf(); - } else if (isNumber(amount) || !isNaN(Number(amount))) { - duration[unit ? unit : 'milliseconds'] = Number(amount); - } else if (typeof amount === 'string' && (match = isoRegex.exec(amount))) { - const sign = match[1] === '-' ? -1 : 1; - duration = { - y: parseIso(match[2]) * sign, - M: parseIso(match[3]) * sign, - w: parseIso(match[4]) * sign, - d: parseIso(match[5]) * sign, - h: parseIso(match[6]) * sign, - m: parseIso(match[7]) * sign, - s: parseIso(match[8]) * sign, - }; - } else if (typeof amount === 'object') { - duration = amount as InputObject; - } - - const normalizedInput = normalizeDateComponents(duration); - const years = normalizedInput.year || 0; - const quarters = normalizedInput.quarter || 0; - const months = normalizedInput.month || 0; - const weeks = normalizedInput.weekNumber || normalizedInput.isoWeekNumber || 0; - const days = normalizedInput.day || 0; - const hours = normalizedInput.hour || 0; - const minutes = normalizedInput.minute || 0; - const seconds = normalizedInput.second || 0; - const milliseconds = normalizedInput.millisecond || 0; - - const _milliseconds = - milliseconds + seconds * 1000 + minutes * 1000 * 60 + hours * 1000 * 60 * 60; - const _days = Number(days) + weeks * 7; - const _months = Number(months) + quarters * 3 + years * 12; - - return { - milliseconds: _milliseconds, - days: _days, - months: _months, - }; -} - -function parseIso(inp: string) { - const res = inp ? parseFloat(inp.replace(',', '.')) : 0; - return isNaN(res) ? 0 : res; -} diff --git a/src/utils/index.ts b/src/utils/index.ts index b2ea06b..04bca77 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1 @@ -export * from './duration'; export * from './utils'; diff --git a/src/utils/locale.ts b/src/utils/locale.ts new file mode 100644 index 0000000..1cc51e7 --- /dev/null +++ b/src/utils/locale.ts @@ -0,0 +1,35 @@ +const dateTimeFormatCache = new Map(); +export function getDateTimeFormat(locale: string, options: Intl.DateTimeFormatOptions = {}) { + const key = JSON.stringify([locale, options]); + let dateTimeFormat = dateTimeFormatCache.get(key); + if (!dateTimeFormat) { + dateTimeFormat = new Intl.DateTimeFormat(locale, options); + dateTimeFormatCache.set(key, dateTimeFormat); + } + return dateTimeFormat; +} + +// @ts-expect-error +const listFormatCache = new Map(); +// @ts-expect-error +export function getListFormat(locale: string, options: Intl.ListFormatOptions = {}) { + const key = JSON.stringify([locale, options]); + let listFormat = listFormatCache.get(key); + if (!listFormat) { + // @ts-expect-error + listFormat = new Intl.ListFormat(locale, options); + listFormatCache.set(key, listFormat); + } + return listFormat; +} + +const numberFormatCache = new Map(); +export function getNumberFormat(locale: string, options: Intl.NumberFormatOptions = {}) { + const key = JSON.stringify([locale, options]); + let numberFormat = numberFormatCache.get(key); + if (!numberFormat) { + numberFormat = new Intl.NumberFormat(locale, options); + numberFormatCache.set(key, numberFormat); + } + return numberFormat; +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 248f6ce..3a02d0f 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,5 +1,3 @@ -import type {SetObject} from '../typings'; - export type CompareStringsOptions = { ignoreCase?: boolean; }; @@ -63,19 +61,46 @@ export function objToTS(obj: Record, number> return ts; } -type NormalizedUnit = - | 'year' - | 'month' - | 'date' - | 'day' - | 'hour' - | 'minute' - | 'second' - | 'millisecond' - | 'quarter' - | 'weekNumber' - | 'isoWeekNumber' - | 'isoWeekday'; +const durationNormalizedUnits = { + y: 'years', + year: 'years', + years: 'years', + Q: 'quarters', + quarter: 'quarters', + quarters: 'quarters', + M: 'months', + month: 'months', + months: 'months', + w: 'weeks', + week: 'weeks', + weeks: 'weeks', + d: 'days', + day: 'days', + days: 'days', + h: 'hours', + hour: 'hours', + hours: 'hours', + m: 'minutes', + minute: 'minutes', + minutes: 'minutes', + s: 'seconds', + second: 'seconds', + seconds: 'seconds', + ms: 'milliseconds', + millisecond: 'milliseconds', + milliseconds: 'milliseconds', +} as const; + +export function normalizeDurationUnit(component: string) { + const unit = ['d', 'D', 'm', 'M', 'w', 'W', 'E', 'Q'].includes(component) + ? component + : component.toLowerCase(); + if (unit in durationNormalizedUnits) { + return durationNormalizedUnits[unit as keyof typeof durationNormalizedUnits]; + } + + throw new Error(`Invalid unit ${component}`); +} const normalizedUnits = { y: 'year', @@ -140,11 +165,16 @@ function asNumber(value: unknown) { return numericValue; } -export function normalizeDateComponents(components: SetObject) { - const normalized: Partial> = {}; +export function normalizeDateComponents( + components: Partial>, + normalizer: (unit: string) => To, +) { + const normalized: Partial> = {}; for (const [c, v] of Object.entries(components)) { - if (v === undefined || v === null) continue; - normalized[normalizeComponent(c)] = asNumber(v); + if (v === undefined || v === null) { + continue; + } + normalized[normalizer(c)] = asNumber(v); } return normalized; }