diff --git a/docs/zoneddatetime.md b/docs/zoneddatetime.md index bb81ba32e1..3fcbedc985 100644 --- a/docs/zoneddatetime.md +++ b/docs/zoneddatetime.md @@ -772,13 +772,13 @@ Usage example: zdt = Temporal.ZonedDateTime.from('2020-03-08T00:00-08:00[America/Los_Angeles]'); // Add a day to get midnight on the day after DST starts laterDay = zdt.add({ days: 1 }); - // => 2020-03-09T00:00-07:00[America/Los_Angeles]; + // => 2020-03-09T00:00:00-07:00[America/Los_Angeles]; // Note that the new offset is different, indicating the result is adjusted for DST. laterDay.since(zdt, { largestUnit: 'hours' }).hours; // => 23, because one clock hour lost to DST laterHours = zdt.add({ hours: 24 }); - // => 2020-03-09T01:00-07:00[America/Los_Angeles] + // => 2020-03-09T01:00:00-07:00[America/Los_Angeles] // Adding time units doesn't adjust for DST. Result is 1:00AM: 24 real-world // hours later because a clock hour was skipped by DST. laterHours.since(zdt, { largestUnit: 'hours' }).hours; // => 24 @@ -836,13 +836,13 @@ Usage example: zdt = Temporal.ZonedDateTime.from('2020-03-09T00:00-07:00[America/Los_Angeles]'); // Add a day to get midnight on the day after DST starts earlierDay = zdt.subtract({ days: 1 }); - // => 2020-03-08T00:00-08:00[America/Los_Angeles] + // => 2020-03-08T00:00:00-08:00[America/Los_Angeles] // Note that the new offset is different, indicating the result is adjusted for DST. earlierDay.since(zdt, { largestUnit: 'hours' }).hours; // => -23, because one clock hour lost to DST earlierHours = zdt.subtract({ hours: 24 }); - // => 2020-03-07T23:00-08:00[America/Los_Angeles] + // => 2020-03-07T23:00:00-08:00[America/Los_Angeles] // Subtracting time units doesn't adjust for DST. Result is 11:00PM: 24 real-world // hours earlier because a clock hour was skipped by DST. earlierHours.since(zdt, { largestUnit: 'hours' }).hours; // => -24 diff --git a/polyfill/lib/ecmascript.mjs b/polyfill/lib/ecmascript.mjs index 6afff318e0..e95ba31910 100644 --- a/polyfill/lib/ecmascript.mjs +++ b/polyfill/lib/ecmascript.mjs @@ -2441,6 +2441,72 @@ export const ES = ObjectAssign({}, ES2020, { ES.RejectInstantRange(result); return result; }, + AddZonedDateTime: (instant, timeZone, calendar, years, months, weeks, days, h, min, s, ms, µs, ns, overflow) => { + // If only time is to be added, then use Instant math. It's not OK to fall + // through to the date/time code below because compatible disambiguation in + // the PlainDateTime=>Instant conversion will change the offset of any + // ZonedDateTtime in the repeated clock time after a backwards transition. + // When adding/subtracting time units and not dates, this disambiguation is + // not expected and so is avoided below via a fast path for time-only + // arithmetic. + // BTW, this behavior is similar in spirit to offset: 'prefer' in `with`. + if (ES.DurationSign(years, months, weeks, days, 0, 0, 0, 0, 0, 0) === 0) { + return ES.AddInstant(GetSlot(instant, EPOCHNANOSECONDS), h, min, s, ms, µs, ns); + } + + // RFC 5545 requires the date portion to be added in calendar days and the + // time portion to be added in exact time. + // FIXME: "op" and the dateAdd/dateSubtract conditional will not be needed + // after #993 lands, changing the order of operations to be the same for + // both addition and subtraction. + let dt = ES.GetTemporalDateTimeFor(timeZone, instant, calendar); + const TemporalDate = GetIntrinsic('%Temporal.Date%'); + const datePart = new TemporalDate(GetSlot(dt, ISO_YEAR), GetSlot(dt, ISO_MONTH), GetSlot(dt, ISO_DAY), calendar); + const addedDate = calendar.dateAdd(datePart, { years, months, weeks, days }, { overflow }, TemporalDate); + const TemporalDateTime = GetIntrinsic('%Temporal.DateTime%'); + const dtIntermediate = new TemporalDateTime( + GetSlot(addedDate, ISO_YEAR), + GetSlot(addedDate, ISO_MONTH), + GetSlot(addedDate, ISO_DAY), + GetSlot(dt, ISO_HOUR), + GetSlot(dt, ISO_MINUTE), + GetSlot(dt, ISO_SECOND), + GetSlot(dt, ISO_MILLISECOND), + GetSlot(dt, ISO_MICROSECOND), + GetSlot(dt, ISO_NANOSECOND), + calendar + ); + + // Note that 'compatible' is used below because this disambiguation behavior + // is required by RFC 5545. + const instantIntermediate = ES.GetTemporalInstantFor(timeZone, dtIntermediate, 'compatible'); + return ES.AddInstant(GetSlot(instantIntermediate, EPOCHNANOSECONDS), h, min, s, ms, µs, ns); + }, + SubtractZonedDateTime: (instant, timeZone, calendar, years, months, weeks, days, h, min, s, ms, µs, ns, overflow) => { + // FIXME: to be consolidated into AddZonedDateTime by #993 + if (ES.DurationSign(years, months, weeks, days, 0, 0, 0, 0, 0, 0) === 0) { + return ES.AddInstant(GetSlot(instant, EPOCHNANOSECONDS), -h, -min, -s, -ms, -µs, -ns); + } + let dt = ES.GetTemporalDateTimeFor(timeZone, instant, calendar); + const TemporalDate = GetIntrinsic('%Temporal.Date%'); + const datePart = new TemporalDate(GetSlot(dt, ISO_YEAR), GetSlot(dt, ISO_MONTH), GetSlot(dt, ISO_DAY), calendar); + const subtractedDate = calendar.dateSubtract(datePart, { years, months, weeks, days }, { overflow }, TemporalDate); + const TemporalDateTime = GetIntrinsic('%Temporal.DateTime%'); + const dtIntermediate = new TemporalDateTime( + GetSlot(subtractedDate, ISO_YEAR), + GetSlot(subtractedDate, ISO_MONTH), + GetSlot(subtractedDate, ISO_DAY), + GetSlot(dt, ISO_HOUR), + GetSlot(dt, ISO_MINUTE), + GetSlot(dt, ISO_SECOND), + GetSlot(dt, ISO_MILLISECOND), + GetSlot(dt, ISO_MICROSECOND), + GetSlot(dt, ISO_NANOSECOND), + calendar + ); + const instantIntermediate = ES.GetTemporalInstantFor(timeZone, dtIntermediate, 'compatible'); + return ES.AddInstant(GetSlot(instantIntermediate, EPOCHNANOSECONDS), -h, -min, -s, -ms, -µs, -ns); + }, RoundNumberToIncrement: (quantity, increment, mode) => { const quotient = quantity / increment; let round; diff --git a/polyfill/lib/zoneddatetime.mjs b/polyfill/lib/zoneddatetime.mjs index fca0d53532..e7fa1b08be 100644 --- a/polyfill/lib/zoneddatetime.mjs +++ b/polyfill/lib/zoneddatetime.mjs @@ -237,8 +237,28 @@ export class ZonedDateTime { ES.RejectDurationSign(years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds); options = ES.NormalizeOptionsObject(options); const overflow = ES.ToTemporalOverflow(options); - void overflow; - throw new Error('add() not implemented yet'); + const timeZone = GetSlot(this, TIME_ZONE); + const calendar = GetSlot(this, CALENDAR); + const epochNanoseconds = ES.AddZonedDateTime( + GetSlot(this, INSTANT), + timeZone, + calendar, + years, + months, + weeks, + days, + hours, + minutes, + seconds, + milliseconds, + microseconds, + nanoseconds, + overflow + ); + const Construct = ES.SpeciesConstructor(this, ZonedDateTime); + const result = new Construct(epochNanoseconds, timeZone, calendar); + if (!ES.IsTemporalZonedDateTime(result)) throw new TypeError('invalid result'); + return result; } subtract(temporalDurationLike, options = undefined) { if (!ES.IsTemporalZonedDateTime(this)) throw new TypeError('invalid receiver'); @@ -247,8 +267,28 @@ export class ZonedDateTime { ES.RejectDurationSign(years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds); options = ES.NormalizeOptionsObject(options); const overflow = ES.ToTemporalOverflow(options); - void overflow; - throw new Error('subtract() not implemented yet'); + const timeZone = GetSlot(this, TIME_ZONE); + const calendar = GetSlot(this, CALENDAR); + const epochNanoseconds = ES.SubtractZonedDateTime( + GetSlot(this, INSTANT), + timeZone, + calendar, + years, + months, + weeks, + days, + hours, + minutes, + seconds, + milliseconds, + microseconds, + nanoseconds, + overflow + ); + const Construct = ES.SpeciesConstructor(this, ZonedDateTime); + const result = new Construct(epochNanoseconds, timeZone, calendar); + if (!ES.IsTemporalZonedDateTime(result)) throw new TypeError('invalid result'); + return result; } until(other, options = undefined) { if (!ES.IsTemporalZonedDateTime(this)) throw new TypeError('invalid receiver'); diff --git a/polyfill/test/zoneddatetime.mjs b/polyfill/test/zoneddatetime.mjs index 5b3ab234f6..b16165d4d1 100644 --- a/polyfill/test/zoneddatetime.mjs +++ b/polyfill/test/zoneddatetime.mjs @@ -730,6 +730,138 @@ describe('ZonedDateTime', () => { }); }); + describe('date/time maths: hours overflow', () => { + it('subtract result', () => { + const later = ZonedDateTime.from('2019-10-29T10:46:38.271986102-03:00[America/Santiago]'); + const earlier = later.subtract({ hours: 12 }); + equal(`${earlier}`, '2019-10-28T22:46:38.271986102-03:00[America/Santiago]'); + }); + it('add result', () => { + const earlier = ZonedDateTime.from('2020-05-31T23:12:38.271986102-04:00[America/Santiago]'); + const later = earlier.add({ hours: 2 }); + equal(`${later}`, '2020-06-01T01:12:38.271986102-04:00[America/Santiago]'); + }); + it('symmetrical with regard to negative durations', () => { + equal( + `${ZonedDateTime.from('2019-10-29T10:46:38.271986102-03:00[America/Santiago]').add({ hours: -12 })}`, + '2019-10-28T22:46:38.271986102-03:00[America/Santiago]' + ); + equal( + `${ZonedDateTime.from('2020-05-31T23:12:38.271986102-04:00[America/Santiago]').subtract({ hours: -2 })}`, + '2020-06-01T01:12:38.271986102-04:00[America/Santiago]' + ); + }); + }); + describe('ZonedDateTime.add()', () => { + const zdt = ZonedDateTime.from('1969-12-25T12:23:45.678901234+00:00[UTC]'); + describe('cross epoch in ms', () => { + const one = zdt.subtract({ hours: 240, nanoseconds: 800 }); + const two = zdt.add({ hours: 240, nanoseconds: 800 }); + const three = two.subtract({ hours: 480, nanoseconds: 1600 }); + const four = one.add({ hours: 480, nanoseconds: 1600 }); + it(`(${zdt}).subtract({ hours: 240, nanoseconds: 800 }) = ${one}`, () => + equal(`${one}`, '1969-12-15T12:23:45.678900434+00:00[UTC]')); + it(`(${zdt}).add({ hours: 240, nanoseconds: 800 }) = ${two}`, () => + equal(`${two}`, '1970-01-04T12:23:45.678902034+00:00[UTC]')); + it(`(${two}).subtract({ hours: 480, nanoseconds: 1600 }) = ${one}`, () => assert(three.equals(one))); + it(`(${one}).add({ hours: 480, nanoseconds: 1600 }) = ${two}`, () => assert(four.equals(two))); + }); + it('zdt.add(durationObj)', () => { + const later = zdt.add(Temporal.Duration.from('PT240H0.000000800S')); + equal(`${later}`, '1970-01-04T12:23:45.678902034+00:00[UTC]'); + }); + it('casts argument', () => { + equal(`${zdt.add('PT240H0.000000800S')}`, '1970-01-04T12:23:45.678902034+00:00[UTC]'); + }); + const jan31 = ZonedDateTime.from('2020-01-31T15:00-08:00[America/Vancouver]'); + it('constrain when ambiguous result', () => { + equal(`${jan31.add({ months: 1 })}`, '2020-02-29T15:00:00-08:00[America/Vancouver]'); + equal(`${jan31.add({ months: 1 }, { overflow: 'constrain' })}`, '2020-02-29T15:00:00-08:00[America/Vancouver]'); + }); + it('symmetrical with regard to negative durations in the time part', () => { + equal(`${jan31.add({ minutes: -30 })}`, '2020-01-31T14:30:00-08:00[America/Vancouver]'); + equal(`${jan31.add({ seconds: -30 })}`, '2020-01-31T14:59:30-08:00[America/Vancouver]'); + }); + it('throw when ambiguous result with reject', () => { + throws(() => jan31.add({ months: 1 }, { overflow: 'reject' }), RangeError); + }); + it('invalid overflow', () => { + ['', 'CONSTRAIN', 'balance', 3, null].forEach((overflow) => + throws(() => zdt.add({ months: 1 }, { overflow }), RangeError) + ); + }); + it('mixed positive and negative values always throw', () => { + ['constrain', 'reject'].forEach((overflow) => + throws(() => zdt.add({ hours: 1, minutes: -30 }, { overflow }), RangeError) + ); + }); + it('options may only be an object or undefined', () => { + [null, 1, 'hello', true, Symbol('foo'), 1n].forEach((badOptions) => + throws(() => zdt.add({ years: 1 }, badOptions), TypeError) + ); + [{}, () => {}, undefined].forEach((options) => + equal(`${zdt.add({ years: 1 }, options)}`, '1970-12-25T12:23:45.678901234+00:00[UTC]') + ); + }); + it('object must contain at least one correctly-spelled property', () => { + throws(() => zdt.add({}), TypeError); + throws(() => zdt.add({ hour: 12 }), TypeError); + }); + it('incorrectly-spelled properties are ignored', () => { + equal(`${zdt.add({ hour: 1, minutes: 1 })}`, '1969-12-25T12:24:45.678901234+00:00[UTC]'); + }); + }); + describe('ZonedDateTime.subtract()', () => { + const zdt = ZonedDateTime.from('1969-12-25T12:23:45.678901234+00:00[UTC]'); + it('inst.subtract(durationObj)', () => { + const earlier = zdt.subtract(Temporal.Duration.from('PT240H0.000000800S')); + equal(`${earlier}`, '1969-12-15T12:23:45.678900434+00:00[UTC]'); + }); + it('casts argument', () => { + equal(`${zdt.subtract('PT240H0.000000800S')}`, '1969-12-15T12:23:45.678900434+00:00[UTC]'); + }); + const mar31 = ZonedDateTime.from('2020-03-31T15:00-07:00[America/Vancouver]'); + it('constrain when ambiguous result', () => { + equal(`${mar31.subtract({ months: 1 })}`, '2020-02-29T15:00:00-08:00[America/Vancouver]'); + equal( + `${mar31.subtract({ months: 1 }, { overflow: 'constrain' })}`, + '2020-02-29T15:00:00-08:00[America/Vancouver]' + ); + }); + it('symmetrical with regard to negative durations in the time part', () => { + equal(`${mar31.subtract({ minutes: -30 })}`, '2020-03-31T15:30:00-07:00[America/Vancouver]'); + equal(`${mar31.subtract({ seconds: -30 })}`, '2020-03-31T15:00:30-07:00[America/Vancouver]'); + }); + it('throw when ambiguous result with reject', () => { + throws(() => mar31.subtract({ months: 1 }, { overflow: 'reject' }), RangeError); + }); + it('invalid overflow', () => { + ['', 'CONSTRAIN', 'balance', 3, null].forEach((overflow) => + throws(() => zdt.subtract({ months: 1 }, { overflow }), RangeError) + ); + }); + it('mixed positive and negative values always throw', () => { + ['constrain', 'reject'].forEach((overflow) => + throws(() => zdt.add({ hours: 1, minutes: -30 }, { overflow }), RangeError) + ); + }); + it('options may only be an object or undefined', () => { + [null, 1, 'hello', true, Symbol('foo'), 1n].forEach((badOptions) => + throws(() => zdt.subtract({ years: 1 }, badOptions), TypeError) + ); + [{}, () => {}, undefined].forEach((options) => + equal(`${zdt.subtract({ years: 1 }, options)}`, '1968-12-25T12:23:45.678901234+00:00[UTC]') + ); + }); + it('object must contain at least one correctly-spelled property', () => { + throws(() => zdt.subtract({}), TypeError); + throws(() => zdt.subtract({ hour: 12 }), TypeError); + }); + it('incorrectly-spelled properties are ignored', () => { + equal(`${zdt.subtract({ hour: 1, minutes: 1 })}`, '1969-12-25T12:22:45.678901234+00:00[UTC]'); + }); + }); + describe('ZonedDateTime.round()', () => { const zdt = ZonedDateTime.from('1976-11-18T15:23:30.123456789+01:00[Europe/Vienna]'); it('throws without parameter', () => { @@ -1332,6 +1464,71 @@ describe('ZonedDateTime', () => { }); }); + describe('math order of operations and options', () => { + const breakoutUnits = (op, zdt, d, options) => + zdt[op]({ years: d.years }, options) + [op]({ months: d.months }, options) + [op]({ weeks: d.weeks }, options) + [op]({ days: d.days }, options) + [op]( + { + hours: d.hours, + minutes: d.minutes, + seconds: d.seconds, + milliseconds: d.milliseconds, + microseconds: d.microseconds, + nanoseconds: d.nanoseconds + }, + + options + ); + + it('order of operations: add / none', () => { + const zdt = ZonedDateTime.from('2020-01-31T00:00-08:00[America/Los_Angeles]'); + const d = Temporal.Duration.from({ months: 1, days: 1 }); + const options = undefined; + const result = zdt.add(d, options); + equal(result.toString(), '2020-03-01T00:00:00-08:00[America/Los_Angeles]'); + equal(breakoutUnits('add', zdt, d, options).toString(), result.toString()); + }); + it('order of operations: add / constrain', () => { + const zdt = ZonedDateTime.from('2020-01-31T00:00-08:00[America/Los_Angeles]'); + const d = Temporal.Duration.from({ months: 1, days: 1 }); + const options = { overflow: 'constrain' }; + const result = zdt.add(d, options); + equal(result.toString(), '2020-03-01T00:00:00-08:00[America/Los_Angeles]'); + equal(breakoutUnits('add', zdt, d, options).toString(), result.toString()); + }); + it('order of operations: add / reject', () => { + const zdt = ZonedDateTime.from('2020-01-31T00:00-08:00[America/Los_Angeles]'); + const d = Temporal.Duration.from({ months: 1, days: 1 }); + const options = { overflow: 'reject' }; + throws(() => zdt.add(d, options), RangeError); + }); + it.skip('order of operations: subtract / none', () => { + const zdt = ZonedDateTime.from('2020-03-31T00:00-07:00[America/Los_Angeles]'); + const d = Temporal.Duration.from({ months: 1, days: 1 }); + const options = undefined; + const result = zdt.subtract(d, options); + equal(result.toString(), '2020-02-28T00:00:00-08:00[America/Los_Angeles]'); + equal(breakoutUnits('subtract', zdt, d, options).toString(), result.toString()); + }); + it.skip('order of operations: subtract / constrain', () => { + const zdt = ZonedDateTime.from('2020-03-31T00:00-07:00[America/Los_Angeles]'); + const d = Temporal.Duration.from({ months: 1, days: 1 }); + const options = { overflow: 'constrain' }; + const result = zdt.subtract(d, options); + equal(result.toString(), '2020-02-28T00:00:00-08:00[America/Los_Angeles]'); + equal(breakoutUnits('subtract', zdt, d, options).toString(), result.toString()); + }); + it('order of operations: subtract / reject', () => { + const zdt = ZonedDateTime.from('2020-03-31T00:00-07:00[America/Los_Angeles]'); + const d = Temporal.Duration.from({ months: 1, days: 1 }); + const options = { overflow: 'reject' }; + throws(() => zdt.subtract(d, options), RangeError); + }); + }); + describe('ZonedDateTime.compare()', () => { const zdt1 = ZonedDateTime.from('1976-11-18T15:23:30.123456789+01:00[Europe/Vienna]'); const zdt2 = ZonedDateTime.from('2019-10-29T10:46:38.271986102+01:00[Europe/Vienna]'); diff --git a/spec/zoneddatetime.html b/spec/zoneddatetime.html index 1fcc0a1448..859d13405c 100644 --- a/spec/zoneddatetime.html +++ b/spec/zoneddatetime.html @@ -605,7 +605,9 @@
+ The abstract operation AddZonedDateTime adds a duration in various units to a number of nanoseconds _epochNanoseconds_ since the Unix epoch, subject to the rules of _timeZone_ and _calendar_. + As specified in RFC 5545, the date portion of the duration is added in calendar days, and the time portion is added in exact time. +
+