Skip to content

Commit

Permalink
Add Duration.compare()
Browse files Browse the repository at this point in the history
Implements a Duration.compare() static method with a relativeTo option
for comparing durations with date units.

See: #856
  • Loading branch information
ptomato committed Nov 10, 2020
1 parent b7ed5cb commit 9262141
Show file tree
Hide file tree
Showing 6 changed files with 274 additions and 1 deletion.
48 changes: 48 additions & 0 deletions docs/duration.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,54 @@ d = Temporal.Duration.from('P0D'); // => PT0S
d = Temporal.Duration.from({ hours: 1, minutes: -30 }); // throws
```

### Temporal.Duration.**compare**(_one_: Temporal.Duration | object | string, _two_: Temporal.Duration | object | string, _options_?: object) : number

**Parameters:**

- `one` (`Temporal.Duration` or value convertible to one): First duration to compare.
- `two` (`Temporal.Duration` or value convertible to one): Second duration to compare.
- `options` (object): An object with properties representing options for the operation.
The following option is recognized:
- `relativeTo` (`Temporal.PlainDateTime`, `Temporal.ZonedDateTime`, or value convertible to one of those): The starting point to use when converting between years, months, weeks, and days.

**Returns:** −1, 0, or 1.

Compares two `Temporal.Duration` objects.
Returns an integer indicating whether `one` is shorter or longer or is equal to `two`.

- −1 if `one` is shorter than `two`;
- 0 if `one` and `two` are equally long;
- 1 if `one` is longer than `two`.

If `one` and `two` are not `Temporal.Duration` objects, then they will be converted to one as if they were passed to `Temporal.Duration.from()`.

If any of the `years`, `months`, or `weeks` properties of either of the durations are nonzero, then the `relativeTo` option is required, since comparing durations with years, months, or weeks requires a point on the calendar to figure out how long they are.

Negative durations are treated as the same as negative numbers for comparison purposes: they are "less" (shorter) than zero.

The `relativeTo` option may be a `Temporal.ZonedDateTime` in which case time zone offset changes will be taken into account when comparing days with hours. If `relativeTo` is a `Temporal.PlainDateTime`, then days are always considered equal to 24 hours.

If `relativeTo` is neither a `Temporal.PlainDateTime` nor a `Temporal.ZonedDateTime`, then it will be converted to one of the two, as if it were first attempted with `Temporal.ZonedDateTime.from()` and then with `Temporal.PlainDateTime.from()`.
This means that an ISO 8601 string with a time zone name annotation in it, or a property bag with a `timeZone` property, will be converted to a `Temporal.ZonedDateTime`, and an ISO 8601 string without a time zone name or a property bag without a `timeZone` property will be converted to a `Temporal.PlainDateTime`.

This function can be used to sort arrays of `Temporal.Duration` objects.
For example:

```javascript
one = Temporal.Duration.from({ hours: 79, minutes: 10 });
two = Temporal.Duration.from({ days: 3, hours: 7, seconds: 630 });
three = Temporal.Duration.from({ days: 3, hours: 6, minutes: 50 });
sorted = [one, two, three].sort(Temporal.Duration.compare);
sorted.join(' ');
// => P3DT6H50M PT79H10M P3DT7H630S

// Sorting relative to a date, taking DST changes into account:
relativeTo = Temporal.ZonedDateTime.from('2020-11-01T00:00-07:00[America/Los_Angeles]');
sorted = [one, two, three].sort((one, two) => Temporal.Duration.compare(one, two, {relativeTo}));
sorted.join(' ');
// => PT79H10M P3DT6H50M P3DT7H630S
```

## Properties

### duration.**years** : number
Expand Down
32 changes: 32 additions & 0 deletions polyfill/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,33 @@ export namespace Temporal {
relativeTo?: Temporal.PlainDateTime | DateTimeLike | string;
}

/**
* Options to control behavior of `Duration.compare()`
*/
export interface DurationCompareOptions {
/**
* The starting point to use when variable-length units (years, months,
* weeks depending on the calendar) are involved. This option is required if
* either of the durations has a nonzero value for `weeks` or larger units.
*
* This value must be either a `Temporal.PlainDateTime`, a
* `Temporal.ZonedDateTime`, or a string or object value that can be passed
* to `from()` of those types. Examples:
* - `'2020-01'01T00:00-08:00[America/Los_Angeles]'`
* - `'2020-01'01'`
* - `Temporal.PlainDate.from('2020-01-01')`
*
* `Temporal.ZonedDateTime` will be tried first because it's more
* specific, with `Temporal.PlainDateTime` as a fallback.
*
* If the value resolves to a `Temporal.ZonedDateTime`, then operation will
* adjust for DST and other time zone transitions. Otherwise (including if
* this option is omitted), then the operation will ignore time zone
* transitions and all days will be assumed to be 24 hours long.
*/
relativeTo?: Temporal.ZonedDateTime | Temporal.PlainDateTime | ZonedDateTimeLike | DateTimeLike | string;
}

export type DurationLike = {
years?: number;
months?: number;
Expand All @@ -454,6 +481,11 @@ export namespace Temporal {
*/
export class Duration implements DurationFields {
static from(item: Temporal.Duration | DurationLike | string): Temporal.Duration;
static compare(
one: Temporal.Duration | DurationLike | string,
two: Temporal.Duration | DurationLike | string,
options?: DurationCompareOptions
): ComparisonResult;
constructor(
years?: number,
months?: number,
Expand Down
37 changes: 36 additions & 1 deletion polyfill/lib/duration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ export class Duration {
return ES.TemporalDurationToString(this);
}
valueOf() {
throw new TypeError('not possible to compare Temporal.Duration');
throw new TypeError('use compare() to compare Temporal.Duration');
}
static from(item) {
if (ES.IsTemporalDuration(item)) {
Expand Down Expand Up @@ -567,6 +567,41 @@ export class Duration {
}
return ES.ToTemporalDuration(item, this);
}
static compare(one, two, options = undefined) {
one = ES.ToTemporalDuration(one, Duration);
two = ES.ToTemporalDuration(two, Duration);
options = ES.NormalizeOptionsObject(options);
const relativeTo = ES.ToRelativeTemporalObject(options);
const y1 = GetSlot(one, YEARS);
const mon1 = GetSlot(one, MONTHS);
const w1 = GetSlot(one, WEEKS);
let d1 = GetSlot(one, DAYS);
const h1 = GetSlot(one, HOURS);
const min1 = GetSlot(one, MINUTES);
const s1 = GetSlot(one, SECONDS);
const ms1 = GetSlot(one, MILLISECONDS);
const µs1 = GetSlot(one, MICROSECONDS);
let ns1 = GetSlot(one, NANOSECONDS);
const y2 = GetSlot(two, YEARS);
const mon2 = GetSlot(two, MONTHS);
const w2 = GetSlot(two, WEEKS);
let d2 = GetSlot(two, DAYS);
const h2 = GetSlot(two, HOURS);
const min2 = GetSlot(two, MINUTES);
const s2 = GetSlot(two, SECONDS);
const ms2 = GetSlot(two, MILLISECONDS);
const µs2 = GetSlot(two, MICROSECONDS);
let ns2 = GetSlot(two, NANOSECONDS);
const shift1 = ES.CalculateOffsetShift(relativeTo, y1, mon1, w1, d1, h1, min1, s1, ms1, µs1, ns1);
const shift2 = ES.CalculateOffsetShift(relativeTo, y2, mon2, w2, d2, h2, min2, s2, ms2, µs2, ns2);
if (y1 !== 0 || y2 !== 0 || mon1 !== 0 || mon2 !== 0 || w1 !== 0 || w2 !== 0) {
({ days: d1 } = ES.UnbalanceDurationRelative(y1, mon1, w1, d1, 'days', relativeTo));
({ days: d2 } = ES.UnbalanceDurationRelative(y2, mon2, w2, d2, 'days', relativeTo));
}
ns1 = ES.TotalDurationNanoseconds(d1, h1, min1, s1, ms1, µs1, ns1, shift1);
ns2 = ES.TotalDurationNanoseconds(d2, h2, min2, s2, ms2, µs2, ns2, shift2);
return ES.ComparisonResult(ns1.minus(ns2).toJSNumber());
}
}

MakeIntrinsicClass(Duration, 'Temporal.Duration');
14 changes: 14 additions & 0 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2155,6 +2155,20 @@ export const ES = ObjectAssign({}, ES2020, {

return { years, months, weeks, days };
},
CalculateOffsetShift: (relativeTo, y, mon, w, d, h, min, s, ms, µs, ns) => {
if (ES.IsTemporalZonedDateTime(relativeTo)) {
const instant = GetSlot(relativeTo, INSTANT);
const timeZone = GetSlot(relativeTo, TIME_ZONE);
const calendar = GetSlot(relativeTo, CALENDAR);
const offsetBefore = ES.GetOffsetNanosecondsFor(timeZone, instant);
const after = ES.AddZonedDateTime(instant, timeZone, calendar, y, mon, w, d, h, min, s, ms, µs, ns, 'constrain');
const TemporalInstant = GetIntrinsic('%Temporal.Instant%');
const instantAfter = new TemporalInstant(after);
const offsetAfter = ES.GetOffsetNanosecondsFor(timeZone, instantAfter);
return offsetAfter - offsetBefore;
}
return 0;
},

ConstrainToRange: (value, min, max) => Math.min(max, Math.max(min, value)),
ConstrainDate: (year, month, day) => {
Expand Down
99 changes: 99 additions & 0 deletions polyfill/test/duration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ describe('Duration', () => {
equal(typeof Duration.prototype.round, 'function');
});
});
it('Duration.compare is a Function', () => {
equal(typeof Duration.compare, 'function');
});
});
describe('Construction', () => {
it('positive duration, sets fields', () => {
Expand Down Expand Up @@ -1380,6 +1383,102 @@ describe('Duration', () => {
equal(d.total({ unit: 'nanosecond', relativeTo }), d.total({ unit: 'nanoseconds', relativeTo }));
});
});
describe('Duration.compare', () => {
describe('time units only', () => {
const d1 = new Duration(0, 0, 0, 0, 5, 5, 5, 5, 5, 5);
const d2 = new Duration(0, 0, 0, 0, 5, 4, 5, 5, 5, 5);
it('equal', () => equal(Duration.compare(d1, d1), 0));
it('smaller/larger', () => equal(Duration.compare(d2, d1), -1));
it('larger/smaller', () => equal(Duration.compare(d1, d2), 1));
it('negative/negative equal', () => equal(Duration.compare(d1.negated(), d1.negated()), 0));
it('negative/negative smaller/larger', () => equal(Duration.compare(d2.negated(), d1.negated()), 1));
it('negative/negative larger/smaller', () => equal(Duration.compare(d1.negated(), d2.negated()), -1));
it('negative/positive', () => equal(Duration.compare(d1.negated(), d2), -1));
it('positive/negative', () => equal(Duration.compare(d1, d2.negated()), 1));
});
describe('date units', () => {
const d1 = new Duration(5, 5, 5, 5, 5, 5, 5, 5, 5, 5);
const d2 = new Duration(5, 5, 5, 5, 5, 4, 5, 5, 5, 5);
const relativeTo = Temporal.PlainDateTime.from('2017-01-01');
it('relativeTo is required', () => throws(() => Duration.compare(d1, d2)), RangeError);
it('equal', () => equal(Duration.compare(d1, d1, { relativeTo }), 0));
it('smaller/larger', () => equal(Duration.compare(d2, d1, { relativeTo }), -1));
it('larger/smaller', () => equal(Duration.compare(d1, d2, { relativeTo }), 1));
it('negative/negative equal', () => equal(Duration.compare(d1.negated(), d1.negated(), { relativeTo }), 0));
it('negative/negative smaller/larger', () =>
equal(Duration.compare(d2.negated(), d1.negated(), { relativeTo }), 1));
it('negative/negative larger/smaller', () =>
equal(Duration.compare(d1.negated(), d2.negated(), { relativeTo }), -1));
it('negative/positive', () => equal(Duration.compare(d1.negated(), d2, { relativeTo }), -1));
it('positive/negative', () => equal(Duration.compare(d1, d2.negated(), { relativeTo }), 1));
});
it('casts first argument', () => {
equal(Duration.compare({ hours: 12 }, new Duration()), 1);
equal(Duration.compare('PT12H', new Duration()), 1);
});
it('casts second argument', () => {
equal(Duration.compare(new Duration(), { hours: 12 }), -1);
equal(Duration.compare(new Duration(), 'PT12H'), -1);
});
it('object must contain at least one correctly-spelled property', () => {
throws(() => Duration.compare({ hour: 12 }, new Duration()), TypeError);
throws(() => Duration.compare(new Duration(), { hour: 12 }), TypeError);
});
it('ignores incorrect properties', () => {
equal(Duration.compare({ hours: 12, minute: 5 }, { hours: 12, day: 5 }), 0);
});
it('relativeTo affects year length', () => {
const oneYear = new Duration(1);
const days365 = new Duration(0, 0, 0, 365);
equal(Duration.compare(oneYear, days365, { relativeTo: Temporal.PlainDateTime.from('2017-01-01') }), 0);
equal(Duration.compare(oneYear, days365, { relativeTo: Temporal.PlainDateTime.from('2016-01-01') }), 1);
});
it('relativeTo affects month length', () => {
const oneMonth = new Duration(0, 1);
const days30 = new Duration(0, 0, 0, 30);
equal(Duration.compare(oneMonth, days30, { relativeTo: Temporal.PlainDateTime.from('2018-04-01') }), 0);
equal(Duration.compare(oneMonth, days30, { relativeTo: Temporal.PlainDateTime.from('2018-03-01') }), 1);
equal(Duration.compare(oneMonth, days30, { relativeTo: Temporal.PlainDateTime.from('2018-02-01') }), -1);
});
const oneDay = new Duration(0, 0, 0, 1);
const hours24 = new Duration(0, 0, 0, 0, 24);
it('relativeTo not required for days', () => {
equal(Duration.compare(oneDay, hours24), 0);
});
it('relativeTo does not affect days if PlainDateTime', () => {
const relativeTo = Temporal.PlainDateTime.from('2017-01-01');
equal(Duration.compare(oneDay, hours24, { relativeTo }), 0);
});
it('relativeTo does not affect days if ZonedDateTime, and duration encompasses no DST change', () => {
const relativeTo = Temporal.ZonedDateTime.from('2017-01-01T00:00[America/Montevideo]');
equal(Duration.compare(oneDay, hours24, { relativeTo }), 0);
});
it('relativeTo does affect days if ZonedDateTime, and duration encompasses DST change', () => {
const relativeTo = Temporal.ZonedDateTime.from('2019-11-03T00:00[America/Vancouver]');
equal(Duration.compare(oneDay, hours24, { relativeTo }), 1);
});
it('casts relativeTo to ZonedDateTime if possible', () => {
equal(Duration.compare(oneDay, hours24, { relativeTo: '2019-11-03T00:00[America/Vancouver]' }), 1);
equal(
Duration.compare(oneDay, hours24, {
relativeTo: { year: 2019, month: 11, day: 3, timeZone: 'America/Vancouver' }
}),
1
);
});
it('casts relativeTo to PlainDateTime if possible', () => {
equal(Duration.compare(oneDay, hours24, { relativeTo: '2019-11-03T00:00' }), 0);
equal(Duration.compare(oneDay, hours24, { relativeTo: { year: 2019, month: 11, day: 3 } }), 0);
});
it('at least the required properties must be present in relativeTo', () => {
throws(() => Duration.compare(oneDay, hours24, { relativeTo: { month: 11, day: 3 } }), TypeError);
throws(() => Duration.compare(oneDay, hours24, { relativeTo: { year: 2019, month: 11 } }), TypeError);
throws(() => Duration.compare(oneDay, hours24, { relativeTo: { year: 2019, day: 3 } }), TypeError);
});
it('does not lose precision when totaling everything down to nanoseconds', () => {
notEqual(Duration.compare({ days: 200 }, { days: 200, nanoseconds: 1 }), 0);
});
});
});

import { normalize } from 'path';
Expand Down
45 changes: 45 additions & 0 deletions spec/duration.html
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,35 @@ <h1>Temporal.Duration.from ( _item_ )</h1>
1. Return ? ToTemporalDuration(_item_, _constructor_).
</emu-alg>
</emu-clause>

<emu-clause id="sec-temporal.duration.compare">
<h1>Temporal.Duration.compare ( _one_, _two_ [ , _options_ ] )</h1>
<p>
The `compare` method takes three arguments, _one_, _two_, and _options_.
The following steps are taken:
</p>
<emu-alg>
1. Set _one_ to ? ToTemporalDuration(_one_).
1. Set _two_ to ? ToTemporalDuration(_two_).
1. Set _options_ to ? NormalizeOptionsObject(_options_).
1. Let _relativeTo_ be ? ToRelativeTemporalObject(_options_).
1. Let _shift1_ be ! CalculateOffsetShift(_relativeTo_, _one_.[[Years]], _one_.[[Months]], _one_.[[Weeks]], _one_.[[Days]], _one_.[[Hours]], _one_.[[Minutes]], _one_.[[Seconds]], _one_.[[Milliseconds]], _one_.[[Microseconds]], _one_.[[Nanoseconds]]).
1. Let _shift2_ be ! CalculateOffsetShift(_relativeTo_, _two_.[[Years]], _two_.[[Months]], _two_.[[Weeks]], _two_.[[Days]], _two_.[[Hours]], _two_.[[Minutes]], _two_.[[Seconds]], _two_.[[Milliseconds]], _two_.[[Microseconds]], _two_.[[Nanoseconds]]).
1. If any of _one_.[[Years]], _two_.[[Years]], _one_.[[Months]], _two_.[[Months]], _one_.[[Weeks]], or _two_.[[Weeks]] are not 0, then
1. Let _balanceResult1_ be ? UnbalanceDurationRelative(_one_.[[Years]], _one_.[[Months]], _one_.[[Weeks]], _one_.[[Days]], *"days"*, _relativeTo_).
1. Let _balanceResult2_ be ? UnbalanceDurationRelative(_two_.[[Years]], _two_.[[Months]], _two_.[[Weeks]], _two_.[[Days]], *"days"*, _relativeTo_).
1. Let _days1_ be _balanceResult1_.[[Days]].
1. Let _days2_ be _balanceResult2_.[[Days]].
1. Else,
1. Let _days1_ be _one_.[[Days]].
1. Let _days2_ be _two_.[[Days]].
1. Let _ns1_ be ! TotalDurationNanoseconds(_days1_, _one_.[[Hours]], _one_.[[Minutes]], _one_.[[Seconds]], _one_.[[Milliseconds]], _one_.[[Microseconds]], _one_.[[Nanoseconds]], _shift1_).
1. Let _ns2_ be ! TotalDurationNanoseconds(_days2_, _two_.[[Hours]], _two_.[[Minutes]], _two_.[[Seconds]], _two_.[[Milliseconds]], _two_.[[Microseconds]], _two_.[[Nanoseconds]], _shift2_).
1. If _ns1_ &gt; _ns2_, return 1.
1. If _ns1_ &lt; _ns2_, return −1.
1. Return 0.
</emu-alg>
</emu-clause>
</emu-clause>

<emu-clause id="sec-properties-of-the-temporal-duration-prototype-object">
Expand Down Expand Up @@ -817,6 +846,22 @@ <h1>CreateTemporalDurationFromStatic ( _constructor_, _years_, _months_, _weeks_
</emu-alg>
</emu-clause>

<emu-clause id="sec-temporal-calculateoffsetshift" aoid="CalculateOffsetShift">
<h1>CalculateOffsetShift ( _relativeTo_, _y_, _mon_, _d_, _h_, _min_, _s_, _ms_, _mus_, _ns_ )</h1>
<p>
The abstract operation CalculateOffsetShift returns the difference in nanoseconds between the time zone offset at the time of _relativeTo_, and the time zone offset at the time of _relativeTo_ plus the given duration.
</p>
<emu-alg>
1. If Type(_relativeTo_) is not Object or _relativeTo_ does not have an [[InitializedTemporalZonedDateTime]] internal slot, return 0.
1. Let _instant_ be ? CreateTemporalInstant(_relativeTo_.[[Nanoseconds]]).
1. Let _offsetBefore_ be ? GetOffsetNanosecondsFor(_relativeTo_.[[TimeZone]], _instant_).
1. Let _after_ be ? AddZonedDateTime(_relativeTo_.[[Nanoseconds]], _relativeTo_.[[TimeZone]], _relativeTo_.[[Calendar]], _y_, _mon_, _d_, _h_, _min_, _s_, _ms_, _mus_, _ns_, *"constrain"*).
1. Let _instantAfter_ be ? CreateTemporalInstant(_after_).
1. Let _offsetAfter_ be ? GetOffsetNanosecondsFor(_relativeTo_.[[TimeZone]], _instantAfter_).
1. Return _offsetAfter__offsetBefore_.
</emu-alg>
</emu-clause>

<emu-clause id="sec-temporal-totaldurationnanoseconds" aoid="TotalDurationNanoseconds">
<h1>TotalDurationNanoseconds ( _days_, _hours_, _minutes_, _seconds_, _milliseconds_, _microseconds_, _nanoseconds_, _offsetShift_ )</h1>
<p>
Expand Down

0 comments on commit 9262141

Please sign in to comment.