From df63308d7de9e2441d10f9806f89d9fb57aabad7 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Wed, 24 Apr 2024 09:41:45 -0700 Subject: [PATCH] WIP: Optimizations https://gist.github.com/arshaw/36d3152c21482bcb78ea2c69591b20e0 https://github.com/tc39/proposal-temporal/issues/2792 --- polyfill/lib/duration.mjs | 13 +- polyfill/lib/ecmascript.mjs | 1275 +++++++++++++++++---------------- polyfill/lib/timeduration.mjs | 12 +- 3 files changed, 665 insertions(+), 635 deletions(-) diff --git a/polyfill/lib/duration.mjs b/polyfill/lib/duration.mjs index 916b52f99..5e42f6596 100644 --- a/polyfill/lib/duration.mjs +++ b/polyfill/lib/duration.mjs @@ -27,6 +27,7 @@ import { import { TimeDuration } from './timeduration.mjs'; const MathAbs = Math.abs; +const NumberIsNaN = Number.isNaN; const ObjectCreate = Object.create; export class Duration { @@ -345,9 +346,7 @@ export class Duration { ES.DifferenceZonedDateTimeWithRounding( relativeEpochNs, targetEpochNs, - plainRelativeTo, calendarRec, - zonedRelativeTo, timeZoneRec, precalculatedPlainDateTime, ObjectCreate(null), @@ -399,7 +398,7 @@ export class Duration { if (ES.IsCalendarUnit(smallestUnit)) { throw new RangeError(`a starting point is required for ${smallestUnit}s rounding`); } - ({ days, norm } = ES.RoundDuration(0, 0, 0, days, norm, roundingIncrement, smallestUnit, roundingMode)); + ({ days, norm } = ES.RoundDuration(days, norm, roundingIncrement, smallestUnit, roundingMode)); ({ days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds } = ES.BalanceTimeDuration( norm.add24HourDays(days), largestUnit @@ -468,9 +467,7 @@ export class Duration { const { total } = ES.DifferenceZonedDateTimeWithRounding( relativeEpochNs, targetEpochNs, - plainRelativeTo, calendarRec, - zonedRelativeTo, timeZoneRec, precalculatedPlainDateTime, ObjectCreate(null), @@ -479,6 +476,7 @@ export class Duration { unit, 'trunc' ); + if (NumberIsNaN(total)) throw new Error('assertion failed: total hit unexpected code path'); return total; } @@ -513,6 +511,7 @@ export class Duration { unit, 'trunc' ); + if (NumberIsNaN(total)) throw new Error('assertion failed: total hit unexpected code path'); return total; } @@ -524,7 +523,7 @@ export class Duration { throw new RangeError(`a starting point is required for ${unit}s total`); } norm = norm.add24HourDays(days); - const { total } = ES.RoundDuration(0, 0, 0, 0, norm, 1, unit, 'trunc'); + const { total } = ES.RoundDuration(0, norm, 1, unit, 'trunc'); return total; } toString(options = undefined) { @@ -563,7 +562,7 @@ export class Duration { microseconds, nanoseconds ); - ({ norm } = ES.RoundDuration(0, 0, 0, 0, norm, increment, unit, roundingMode)); + ({ norm } = ES.RoundDuration(0, norm, increment, unit, roundingMode)); let deltaDays; ({ days: deltaDays, diff --git a/polyfill/lib/ecmascript.mjs b/polyfill/lib/ecmascript.mjs index c8cf5c05d..5413d976f 100644 --- a/polyfill/lib/ecmascript.mjs +++ b/polyfill/lib/ecmascript.mjs @@ -3264,119 +3264,6 @@ export function BalanceTime(hour, minute, second, millisecond, microsecond, nano }; } -export function NormalizedTimeDurationToDays(norm, zonedRelativeTo, timeZoneRec, precalculatedPlainDateTime) { - // getOffsetNanosecondsFor and getPossibleInstantsFor must be looked up - const TemporalInstant = GetIntrinsic('%Temporal.Instant%'); - const sign = norm.sign(); - if (sign === 0) return { days: 0, norm, dayLengthNs: DAY_NANOS }; - - const startNs = GetSlot(zonedRelativeTo, EPOCHNANOSECONDS); - const start = GetSlot(zonedRelativeTo, INSTANT); - const endNs = norm.addToEpochNs(startNs); - const end = new TemporalInstant(endNs); - const calendar = GetSlot(zonedRelativeTo, CALENDAR); - - // Find the difference in days only. Inline DifferenceISODateTime because we - // don't need the path that potentially calls calendar methods. - const dtStart = precalculatedPlainDateTime ?? GetPlainDateTimeFor(timeZoneRec, start, 'iso8601'); - const dtEnd = GetPlainDateTimeFor(timeZoneRec, end, 'iso8601'); - const date1 = TemporalDateTimeToDate(dtStart); - const date2 = TemporalDateTimeToDate(dtEnd); - let days = DaysUntil(date1, date2); - - const timeSign = CompareTemporalTime( - GetSlot(dtStart, ISO_HOUR), - GetSlot(dtStart, ISO_MINUTE), - GetSlot(dtStart, ISO_SECOND), - GetSlot(dtStart, ISO_MILLISECOND), - GetSlot(dtStart, ISO_MICROSECOND), - GetSlot(dtStart, ISO_NANOSECOND), - GetSlot(dtEnd, ISO_HOUR), - GetSlot(dtEnd, ISO_MINUTE), - GetSlot(dtEnd, ISO_SECOND), - GetSlot(dtEnd, ISO_MILLISECOND), - GetSlot(dtEnd, ISO_MICROSECOND), - GetSlot(dtEnd, ISO_NANOSECOND) - ); - - if (days > 0 && timeSign > 0) { - days--; - } else if (days < 0 && timeSign < 0) { - days++; - } - - let relativeResult = AddDaysToZonedDateTime(start, dtStart, timeZoneRec, calendar, days); - // may disambiguate - - // If clock time after addition was in the middle of a skipped period, the - // endpoint was disambiguated to a later clock time. So it's possible that - // the resulting disambiguated result is later than endNs. If so, then back - // up one day and try again. Repeat if necessary (some transitions are - // > 24 hours) until either there's zero days left or the date duration is - // back inside the period where it belongs. Note that this case only can - // happen for positive durations because the only direction that - // `disambiguation: 'compatible'` can change clock time is forwards. - if (sign === 1 && days > 0 && relativeResult.epochNs.greater(endNs)) { - days--; - relativeResult = AddDaysToZonedDateTime(start, dtStart, timeZoneRec, calendar, days); - // may do disambiguation - if (days > 0 && relativeResult.epochNs.greater(endNs)) { - throw new RangeError('inconsistent result from custom time zone getInstantFor()'); - } - } - norm = TimeDuration.fromEpochNsDiff(endNs, relativeResult.epochNs); - - // calculate length of the next day (day that contains the time remainder) - let oneDayFarther = AddDaysToZonedDateTime( - relativeResult.instant, - relativeResult.dateTime, - timeZoneRec, - calendar, - sign - ); - let dayLengthNs = TimeDuration.fromEpochNsDiff(oneDayFarther.epochNs, relativeResult.epochNs); - const oneDayLess = norm.subtract(dayLengthNs); - let isOverflow = oneDayLess.sign() * sign >= 0; - if (isOverflow) { - norm = oneDayLess; - relativeResult = oneDayFarther; - days += sign; - - // ensure there was no more overflow - oneDayFarther = AddDaysToZonedDateTime( - relativeResult.instant, - relativeResult.dateTime, - timeZoneRec, - calendar, - sign - ); - - dayLengthNs = TimeDuration.fromEpochNsDiff(oneDayFarther.epochNs, relativeResult.epochNs); - isOverflow = norm.subtract(dayLengthNs).sign() * sign >= 0; - if (isOverflow) throw new RangeError('inconsistent result from custom time zone getPossibleInstantsFor()'); - } - if (days !== 0 && MathSign(days) != sign) { - throw new RangeError('Time zone or calendar converted nanoseconds into a number of days with the opposite sign'); - } - if (sign === -1) { - if (norm.sign() === 1) { - throw new RangeError('Time zone or calendar ended up with a remainder of nanoseconds with the opposite sign'); - } - } else if (norm.sign() === -1) { - throw new Error('assert not reached'); - } - if (norm.abs().cmp(dayLengthNs.abs()) >= 0) { - throw new Error('assert not reached'); - } - const daylen = dayLengthNs.abs().totalNs.toJSNumber(); - if (!NumberIsSafeInteger(daylen)) { - const h = daylen / 3600e9; - throw new RangeError(`Time zone calculated a day length of ${h} h, longer than ~2502 h causes precision loss`); - } - if (MathAbs(days) > NumberMaxSafeInteger / 86400) throw new Error('assert not reached'); - return { days, norm, dayLengthNs: daylen }; -} - export function BalanceTimeDuration(norm, largestUnit) { const sign = norm.sign(); let nanoseconds = norm.abs().subsec; @@ -3467,47 +3354,6 @@ export function BalanceTimeDuration(norm, largestUnit) { return { days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds }; } -export function BalanceTimeDurationRelative( - days, - norm, - largestUnit, - zonedRelativeTo, - timeZoneRec, - precalculatedPlainDateTime -) { - const startNs = GetSlot(zonedRelativeTo, EPOCHNANOSECONDS); - const startInstant = GetSlot(zonedRelativeTo, INSTANT); - - let intermediateNs = startNs; - if (days !== 0) { - precalculatedPlainDateTime ??= GetPlainDateTimeFor(timeZoneRec, startInstant, 'iso8601'); - intermediateNs = AddDaysToZonedDateTime( - startInstant, - precalculatedPlainDateTime, - timeZoneRec, - 'iso8601', - days - ).epochNs; - } - - const endNs = AddInstant(intermediateNs, norm); - norm = TimeDuration.fromEpochNsDiff(endNs, startNs); - if (norm.isZero()) { - return { days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 0, microseconds: 0, nanoseconds: 0 }; - } - - if (IsCalendarUnit(largestUnit) || largestUnit === 'day') { - precalculatedPlainDateTime ??= GetPlainDateTimeFor(timeZoneRec, startInstant, 'iso8601'); - ({ days, norm } = NormalizedTimeDurationToDays(norm, zonedRelativeTo, timeZoneRec, precalculatedPlainDateTime)); - largestUnit = 'hour'; - } else { - days = 0; - } - - const { hours, minutes, seconds, milliseconds, microseconds, nanoseconds } = BalanceTimeDuration(norm, largestUnit); - return { days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds }; -} - export function UnbalanceDateDurationRelative(years, months, weeks, days, plainRelativeTo, calendarRec) { // calendarRec must have looked up dateAdd, unless calendar units 0 if (years === 0 && months === 0 && weeks === 0) return days; @@ -3519,91 +3365,6 @@ export function UnbalanceDateDurationRelative(years, months, weeks, days, plainR return days + yearsMonthsWeeksInDays; } -export function BalanceDateDurationRelative( - years, - months, - weeks, - days, - largestUnit, - smallestUnit, - plainRelativeTo, - calendarRec -) { - // calendarRec must have looked up dateAdd and dateUntil - const TemporalDuration = GetIntrinsic('%Temporal.Duration%'); - - // If no nonzero calendar units, then there's nothing to balance. - // If largestUnit is 'day' or lower, then the balance is a no-op. - // In both cases, return early. Anything after this requires a calendar. - if ( - (years === 0 && months === 0 && weeks === 0 && days === 0) || - (largestUnit !== 'year' && largestUnit !== 'month' && largestUnit !== 'week') - ) { - return { years, months, weeks, days }; - } - - const untilOptions = ObjectCreate(null); - untilOptions.largestUnit = largestUnit; - - switch (largestUnit) { - case 'year': { - // There is a special case for smallestUnit === week, because months and - // years aren't equal to an integer number of weeks. We don't want "1 year - // and 5 weeks" to balance to "1 year, 1 month, and 5 days" which would - // contravene the requested smallestUnit. - if (smallestUnit === 'week') { - // balance months up to years - const later = AddDate(calendarRec, plainRelativeTo, new TemporalDuration(years, months)); - const untilResult = CalendarDateUntil(calendarRec, plainRelativeTo, later, untilOptions); - return { - years: GetSlot(untilResult, YEARS), - months: GetSlot(untilResult, MONTHS), - weeks, - days: 0 - }; - } - // balance weeks, months and days up to years - const later = AddDate(calendarRec, plainRelativeTo, new TemporalDuration(years, months, weeks, days)); - const untilResult = CalendarDateUntil(calendarRec, plainRelativeTo, later, untilOptions); - return { - years: GetSlot(untilResult, YEARS), - months: GetSlot(untilResult, MONTHS), - weeks: GetSlot(untilResult, WEEKS), - days: GetSlot(untilResult, DAYS) - }; - } - case 'month': { - // Same special case for rounding to weeks as above; in this case we - // don't need to balance. - if (smallestUnit === 'week') { - return { years: 0, months, weeks, days: 0 }; - } - // balance weeks and days up to months - const later = AddDate(calendarRec, plainRelativeTo, new TemporalDuration(0, months, weeks, days)); - const untilResult = CalendarDateUntil(calendarRec, plainRelativeTo, later, untilOptions); - return { - years: 0, - months: GetSlot(untilResult, MONTHS), - weeks: GetSlot(untilResult, WEEKS), - days: GetSlot(untilResult, DAYS) - }; - } - case 'week': { - // balance days up to weeks - const later = AddDate(calendarRec, plainRelativeTo, new TemporalDuration(0, 0, weeks, days)); - const untilResult = CalendarDateUntil(calendarRec, plainRelativeTo, later, untilOptions); - return { - years: 0, - months: 0, - weeks: GetSlot(untilResult, WEEKS), - days: GetSlot(untilResult, DAYS) - }; - } - default: - // not reached - } -} - export function CreateNegatedTemporalDuration(duration) { const TemporalDuration = GetIntrinsic('%Temporal.Duration%'); return new TemporalDuration( @@ -3800,7 +3561,7 @@ export function DifferenceTime(h1, min1, s1, ms1, µs1, ns1, h2, min2, s2, ms2, export function DifferenceInstant(ns1, ns2, increment, smallestUnit, roundingMode) { const diff = TimeDuration.fromEpochNsDiff(ns2, ns1); - return RoundDuration(0, 0, 0, 0, diff, increment, smallestUnit, roundingMode); + return RoundDuration(0, diff, increment, smallestUnit, roundingMode); } export function DifferenceDate(calendarRec, plainDate1, plainDate2, options) { @@ -4000,6 +3761,563 @@ export function DifferenceZonedDateTime( return { years, months, weeks, days, norm }; } +// Epoch-nanosecond bounding technique where the start/end of the calendar-unit +// interval are converted to epoch-nanosecond times and destEpochNs is nudged to +// either one. +function NudgeToCalendarUnit( + years, + months, + weeks, + days, + plainDateTime, + calendarRec, + timeZoneRec, + destEpochNs, + smallestUnit, + roundingIncrement, + roundingMode +) { + // smallestUnit must be day, week, month, or year + // timeZoneRec may be undefined + const sign = DurationSign(years, months, weeks, days, 0, 0, 0, 0, 0, 0) < 0 ? -1 : 1; + + // Create a duration with smallestUnit trunc'd towards zero + // Create a separate duration that incorporates roundingIncrement + let startDuration, endDuration; + switch (smallestUnit) { + case 'year': + years = RoundNumberToIncrement(years, roundingIncrement, 'trunc'); + startDuration = { years, months: 0, weeks: 0, days: 0 }; + endDuration = { ...startDuration, years: years + roundingIncrement * sign }; + break; + case 'month': + months = RoundNumberToIncrement(months, roundingIncrement, 'trunc'); + startDuration = { years, months, weeks: 0, days: 0 }; + endDuration = { ...startDuration, months: months + roundingIncrement * sign }; + break; + case 'week': + weeks = RoundNumberToIncrement(weeks, roundingIncrement, 'trunc'); + startDuration = { years, months, weeks, days: 0 }; + endDuration = { ...startDuration, weeks: weeks + roundingIncrement * sign }; + break; + case 'day': + days = RoundNumberToIncrement(days, roundingIncrement, 'trunc'); + startDuration = { years, months, weeks, days }; + endDuration = { ...startDuration, days: days + roundingIncrement * sign }; + break; + default: + throw new Error('assert not reached'); + } + + // Apply to origin, output PlainDateTimes + const start = AddDateTime( + GetSlot(plainDateTime, ISO_YEAR), + GetSlot(plainDateTime, ISO_MONTH), + GetSlot(plainDateTime, ISO_DAY), + GetSlot(plainDateTime, ISO_HOUR), + GetSlot(plainDateTime, ISO_MINUTE), + GetSlot(plainDateTime, ISO_SECOND), + GetSlot(plainDateTime, ISO_MILLISECOND), + GetSlot(plainDateTime, ISO_MICROSECOND), + GetSlot(plainDateTime, ISO_NANOSECOND), + calendarRec, + startDuration.years, + startDuration.months, + startDuration.weeks, + startDuration.days, + TimeDuration.ZERO + ); + const end = AddDateTime( + GetSlot(plainDateTime, ISO_YEAR), + GetSlot(plainDateTime, ISO_MONTH), + GetSlot(plainDateTime, ISO_DAY), + GetSlot(plainDateTime, ISO_HOUR), + GetSlot(plainDateTime, ISO_MINUTE), + GetSlot(plainDateTime, ISO_SECOND), + GetSlot(plainDateTime, ISO_MILLISECOND), + GetSlot(plainDateTime, ISO_MICROSECOND), + GetSlot(plainDateTime, ISO_NANOSECOND), + calendarRec, + endDuration.years, + endDuration.months, + endDuration.weeks, + endDuration.days, + TimeDuration.ZERO + ); + + // Convert to epoch-nanoseconds + let startEpochNs, endEpochNs; + if (timeZoneRec) { + const startDateTime = CreateTemporalDateTime( + start.year, + start.month, + start.day, + start.hour, + start.minute, + start.second, + start.millisecond, + start.microsecond, + start.nanosecond, + calendarRec.receiver + ); + startEpochNs = GetSlot(GetInstantFor(timeZoneRec, startDateTime, 'compatible'), EPOCHNANOSECONDS); + const endDateTime = CreateTemporalDateTime( + end.year, + end.month, + end.day, + end.hour, + end.minute, + end.second, + end.millisecond, + end.microsecond, + end.nanosecond, + calendarRec.receiver + ); + endEpochNs = GetSlot(GetInstantFor(timeZoneRec, endDateTime, 'compatible'), EPOCHNANOSECONDS); + } else { + startEpochNs = GetUTCEpochNanoseconds( + start.year, + start.month, + start.day, + start.hour, + start.minute, + start.second, + start.millisecond, + start.microsecond, + start.nanosecond + ); + endEpochNs = GetUTCEpochNanoseconds( + end.year, + end.month, + end.day, + end.hour, + end.minute, + end.second, + end.millisecond, + end.microsecond, + end.nanosecond + ); + } + + // Round the smallestUnit within the epoch-nanosecond span + if (endEpochNs.equals(startEpochNs)) { + throw new RangeError(`custom calendar reported a ${smallestUnit} that is 0 days long`); + } + const numerator = TimeDuration.fromEpochNsDiff(destEpochNs, startEpochNs); + const denominator = TimeDuration.fromEpochNsDiff(endEpochNs, startEpochNs); + const r1 = startDuration[PLURAL_FOR.get(smallestUnit)]; + const r2 = r1 + roundingIncrement * sign; + const mode = GetUnsignedRoundingMode(roundingMode, sign < 0 ? 'negative' : 'positive'); + const cmp = numerator.add(numerator).abs().subtract(denominator.abs()).sign(); + const even = (r1 / (roundingIncrement * sign)) % 2 === 0; + const roundedUnit = numerator.isZero() ? r1 : ApplyUnsignedRoundingMode(r1, r2, cmp, even, mode); + startDuration[PLURAL_FOR.get(smallestUnit)] = roundedUnit; + + // Trick to minimize rounding error, due to the lack of fma() in JS + const fakeNumerator = new TimeDuration( + denominator.totalNs.times(r1).add(numerator.totalNs.times(roundingIncrement * sign)) + ); + const total = fakeNumerator.ratioOf(denominator); + + // Determine whether expanded or contracted + const didExpand = MathSign(roundedUnit - total) === sign; + ({ years, months, weeks, days } = didExpand ? endDuration : startDuration); + + return { + years, + months, + weeks, + days, + norm: TimeDuration.ZERO, + total, + epochNs: didExpand ? endEpochNs : startEpochNs, + didExpand + }; +} + +// Attempts rounding of time units within a time zone's day, but if the rounding +// causes time to exceed the total time within the day, rerun rounding in next +// day. +function NudgeToZonedTime( + years, + months, + weeks, + days, + norm, + plainDateTime, + calendarRec, + timeZoneRec, + smallestUnit, + roundingIncrement, + roundingMode +) { + // smallestUnit must be hour or lower + + const sign = norm.sign() < 0 ? -1 : 1; + // Apply to origin, output start/end of the day as PlainDateTimes + const start = AddDateTime( + GetSlot(plainDateTime, ISO_YEAR), + GetSlot(plainDateTime, ISO_MONTH), + GetSlot(plainDateTime, ISO_DAY), + GetSlot(plainDateTime, ISO_HOUR), + GetSlot(plainDateTime, ISO_MINUTE), + GetSlot(plainDateTime, ISO_SECOND), + GetSlot(plainDateTime, ISO_MILLISECOND), + GetSlot(plainDateTime, ISO_MICROSECOND), + GetSlot(plainDateTime, ISO_NANOSECOND), + calendarRec, + years, + months, + weeks, + days, + norm + ); + const startDateTime = CreateTemporalDateTime( + start.year, + start.month, + start.day, + start.hour, + start.minute, + start.second, + start.millisecond, + start.microsecond, + start.nanosecond, + calendarRec.receiver + ); + const endDate = BalanceISODate(start.year, start.month, start.day + sign); + const endDateTime = CreateTemporalDateTime( + endDate.year, + endDate.month, + endDate.day, + start.hour, + start.minute, + start.second, + start.millisecond, + start.microsecond, + start.nanosecond, + calendarRec.receiver + ); + + // Compute the epoch-nanosecond start/end of the final whole-day interval + // If duration has negative sign, startEpochNs will be after endEpochNs + const startEpochNs = GetSlot(GetInstantFor(timeZoneRec, startDateTime, 'compatible'), EPOCHNANOSECONDS); + const endEpochNs = GetSlot(GetInstantFor(timeZoneRec, endDateTime, 'compatible'), EPOCHNANOSECONDS); + + // The signed amount of time from the start of the whole-day interval to the end + const daySpan = TimeDuration.fromEpochNsDiff(endEpochNs, startEpochNs); + if (daySpan.sign() !== sign) throw new RangeError('time zone returned inconsistent Instants'); + + // Compute time parts of the duration to nanoseconds and round + // Result could be negative + let roundedNorm = norm.round(NS_PER_TIME_UNIT.get(smallestUnit) * roundingIncrement, roundingMode); + + // Does the rounded time exceed the time-in-day? + const beyondDayNs = roundedNorm.subtract(daySpan); + const didRoundBeyondDay = beyondDayNs.sign() !== -sign; + + let dayDelta, nudgedEpochNs; + if (didRoundBeyondDay) { + // If rounded into next day, use the day-end as the local origin and rerun + // the rounding + dayDelta = sign; + roundedNorm = beyondDayNs.round(NS_PER_TIME_UNIT.get(smallestUnit) * roundingIncrement, roundingMode); + nudgedEpochNs = roundedNorm.addToEpochNs(endEpochNs); + } else { + // Otherwise, if time not rounded beyond day, use the day-start as the local + // origin + dayDelta = 0; + nudgedEpochNs = roundedNorm.addToEpochNs(startEpochNs); + } + + return { + years, + months, + weeks, + days: days + dayDelta, + norm: roundedNorm, + total: NaN, // total() never hits this path. This is asserted later on + epochNs: nudgedEpochNs, + didExpand: didRoundBeyondDay + }; +} + +// Converts all fields to nanoseconds and does integer rounding. +function NudgeToDayOrTime( + years, + months, + weeks, + days, + norm, + destEpochNs, + smallestUnit, + roundingIncrement, + roundingMode +) { + // smallestUnit must be hour or smaller + + norm = norm.add24HourDays(days); + days = 0; + // Convert to nanoseconds and round + const divisor = NS_PER_TIME_UNIT.get(smallestUnit); + const total = norm.fdiv(divisor); + const roundedNorm = norm.round(roundingIncrement * divisor, roundingMode); + const diffNorm = roundedNorm.subtract(norm); + + // Determine if whole days expanded + const { quotient: wholeDays } = norm.divmod(DAY_NANOS); + const { quotient: roundedWholeDays } = roundedNorm.divmod(DAY_NANOS); + const didExpandDays = MathSign(roundedWholeDays - wholeDays) === norm.sign(); + + const nudgedEpochNs = diffNorm.addToEpochNs(destEpochNs); + + return { + years, + months, + weeks, + days, + norm: roundedNorm, + total, + epochNs: nudgedEpochNs, + didExpand: didExpandDays + }; +} + +// Given a potentially bottom-heavy duration, bubble up smaller units to larger +// units. Any units smaller than smallestUnit are already zeroed-out. +function BubbleRelativeDuration( + years, + months, + weeks, + days, + norm, + nudgedEpochNs, + plainDateTime, + calendarRec, + timeZoneRec, + largestUnit, + smallestUnit +) { + // smallestUnit is day or larger + + if (smallestUnit === 'year') return { years, months, weeks, days, norm }; + + const sign = DurationSign(years, months, weeks, days, 0, 0, 0, 0, 0, 0) < 0 ? -1 : 1; + // Check to see if nudgedEpochNs has hit the boundary of any units higher than + // smallestUnit, in which case increment the higher unit and clear smaller + // units. + const smallestUnitIndex = UNITS_DESCENDING.indexOf(smallestUnit); + const largestUnitIndex = UNITS_DESCENDING.indexOf(largestUnit); + for (let unitIndex = smallestUnitIndex - 1; unitIndex >= largestUnitIndex; unitIndex--) { + // The only situation where days and smaller bubble-up into weeks is when + // largestUnit is 'week' (not to be confused with the situation where + // smallestUnit is 'week', in which case days and smaller are ROUNDED-up + // into weeks, but that has already happened by the time this function + // executes) + // So, if days and smaller are NOT bubbled-up into weeks, and the current + // unit is weeks, skip. + const unit = UNITS_DESCENDING[unitIndex]; + if (unit === 'week' && largestUnit !== 'week') { + continue; + } + + let endDuration; + switch (unit) { + case 'year': + endDuration = { years: years + sign, months: 0, weeks: 0, days: 0, norm: TimeDuration.ZERO }; + break; + case 'month': + endDuration = { years, months: months + sign, weeks: 0, days: 0, norm: TimeDuration.ZERO }; + break; + case 'week': + endDuration = { years, months, weeks: weeks + sign, days: 0, norm: TimeDuration.ZERO }; + break; + case 'day': + endDuration = { years, months, weeks, days: days + sign, norm: TimeDuration.ZERO }; + break; + default: + throw new Error('assert not reached'); + } + + // Compute end-of-unit in epoch-nanoseconds + const end = AddDateTime( + GetSlot(plainDateTime, ISO_YEAR), + GetSlot(plainDateTime, ISO_MONTH), + GetSlot(plainDateTime, ISO_DAY), + GetSlot(plainDateTime, ISO_HOUR), + GetSlot(plainDateTime, ISO_MINUTE), + GetSlot(plainDateTime, ISO_SECOND), + GetSlot(plainDateTime, ISO_MILLISECOND), + GetSlot(plainDateTime, ISO_MICROSECOND), + GetSlot(plainDateTime, ISO_NANOSECOND), + calendarRec, + endDuration.years, + endDuration.months, + endDuration.weeks, + endDuration.days, + endDuration.norm + ); + let endEpochNs; + if (timeZoneRec) { + const endDateTime = CreateTemporalDateTime( + end.year, + end.month, + end.day, + end.hour, + end.minute, + end.second, + end.millisecond, + end.microsecond, + end.nanosecond, + calendarRec.receiver + ); + endEpochNs = GetSlot(GetInstantFor(timeZoneRec, endDateTime, 'compatible'), EPOCHNANOSECONDS); + } else { + endEpochNs = GetUTCEpochNanoseconds( + end.year, + end.month, + end.day, + end.hour, + end.minute, + end.second, + end.millisecond, + end.microsecond, + end.nanosecond + ); + } + + let beyondEnd = TimeDuration.fromEpochNsDiff(nudgedEpochNs, endEpochNs); + let didExpandToEnd = beyondEnd.sign() !== -sign; + + // Is nudgedEpochNs at the end-of-unit? This means it should bubble-up to + // the next highest unit (and possibly further...) + if (didExpandToEnd) { + ({ years, months, weeks, days, norm } = endDuration); + } else { + // NOT at end-of-unit. Stop looking for bubbling + break; + } + } + + return { years, months, weeks, days, norm }; +} + +function RoundRelativeDuration( + years, + months, + weeks, + days, + norm, + destEpochNs, + plainDateTime, + calendarRec, + timeZoneRec, + largestUnit, + smallestUnit, + roundingIncrement, + roundingMode +) { + // The duration must already be balanced. This should be achieved by calling + // one of the non-rounding since/until internal methods prior. It's okay to + // have a bottom-heavy weeks because weeks don't bubble-up into months. It's + // okay to have >24 hour day assuming the final day of relativeTo+duration has + // >24 hours in its timezone. (should automatically end up like this if using + // non-rounding since/until internal methods prior) + + let rec; + const irregularLengthUnit = IsCalendarUnit(smallestUnit) || (timeZoneRec && smallestUnit === 'day'); + if (irregularLengthUnit) { + // Rounding an irregular-length unit? Use epoch-nanosecond-bounding technique + rec = NudgeToCalendarUnit( + years, + months, + weeks, + days, + plainDateTime, + calendarRec, + timeZoneRec, + destEpochNs, + smallestUnit, + roundingIncrement, + roundingMode + ); + } else if (timeZoneRec) { + // Special-case for rounding time units within a zoned day + rec = NudgeToZonedTime( + years, + months, + weeks, + days, + norm, + plainDateTime, + calendarRec, + timeZoneRec, + smallestUnit, + roundingIncrement, + roundingMode + ); + } else { + // Rounding uniform-length days/hours/minutes/etc units. Simple nanosecond + // math + rec = NudgeToDayOrTime( + years, + months, + weeks, + days, + norm, + destEpochNs, + smallestUnit, + roundingIncrement, + roundingMode + ); + } + + let total, nudgedEpochNs, didExpandCalendarUnit; + ({ + // The duration after expanding/contracting, though NOT yet rebalanced + years, + months, + weeks, + days, + norm, + // Fraction for returning from total() + total, + // The destEpochNs after expanding/contracting + epochNs: nudgedEpochNs, + // Did nudging cause the duration to expand to the next day or larger? + didExpand: didExpandCalendarUnit + } = rec); + + // Bubble-up smaller calendar units into higher ones, except for weeks, which + // don't balance up into months + if (didExpandCalendarUnit && smallestUnit !== 'week') { + ({ years, months, weeks, days, norm } = BubbleRelativeDuration( + years, + months, + weeks, + days, + norm, + nudgedEpochNs, + plainDateTime, + calendarRec, + timeZoneRec, + largestUnit, // where to STOP bubbling + LargerOfTwoTemporalUnits(smallestUnit, 'day') // where to START bubbling-up from + )); + } + + const { + days: deltaDays, + hours, + minutes, + seconds, + milliseconds, + microseconds, + nanoseconds + } = BalanceTimeDuration(norm, largestUnit); + days += deltaDays; + return { years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds, total }; +} + export function DifferencePlainDateTimeWithRounding( plainDate1, h1, @@ -4079,44 +4397,29 @@ export function DifferencePlainDateTimeWithRounding( return { years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds, total }; } - let total; - ({ years, months, weeks, days, norm, total } = RoundDuration( + const plainDateTime = CreateTemporalDateTime(y1, mon1, d1, h1, min1, s1, ms1, µs1, ns1, calendarRec.receiver); + const destEpochNs = GetUTCEpochNanoseconds(y2, mon2, d2, h2, min2, s2, ms2, µs2, ns2); + return RoundRelativeDuration( years, months, weeks, days, norm, - roundingIncrement, - smallestUnit, - roundingMode, - plainDate1, - calendarRec - )); - const normWithDays = norm.add24HourDays(days); - let hours, minutes, seconds, milliseconds, microseconds, nanoseconds; - ({ days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds } = BalanceTimeDuration( - normWithDays, - largestUnit - )); - ({ years, months, weeks, days } = BalanceDateDurationRelative( - years, - months, - weeks, - days, + destEpochNs, + plainDateTime, + calendarRec, + null, largestUnit, smallestUnit, - plainDate1, - calendarRec - )); - return { years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds, total }; + roundingIncrement, + roundingMode + ); } export function DifferenceZonedDateTimeWithRounding( ns1, ns2, - plainRelativeTo, calendarRec, - zonedDateTime, timeZoneRec, precalculatedPlainDateTime, resolvedOptions, @@ -4161,50 +4464,21 @@ export function DifferenceZonedDateTimeWithRounding( return { years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds, total }; } - let total; - ({ years, months, weeks, days, norm, total } = RoundDuration( + return RoundRelativeDuration( years, months, weeks, days, norm, - roundingIncrement, - smallestUnit, - roundingMode, - plainRelativeTo, - calendarRec, - zonedDateTime, - timeZoneRec, - precalculatedPlainDateTime - )); - ({ years, months, weeks, days, norm } = AdjustRoundedDurationDays( - years, - months, - weeks, - days, - norm, - roundingIncrement, - smallestUnit, - roundingMode, - zonedDateTime, + ns2, + precalculatedPlainDateTime, calendarRec, timeZoneRec, - precalculatedPlainDateTime - )); - ({ years, months, weeks, days } = BalanceDateDurationRelative( - years, - months, - weeks, - days, largestUnit, smallestUnit, - plainRelativeTo, - calendarRec - )); - CombineDateAndNormalizedTimeDuration(years, months, weeks, days, norm); - const { hours, minutes, seconds, milliseconds, microseconds, nanoseconds } = BalanceTimeDuration(norm, 'hour'); - - return { years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds, total }; + roundingIncrement, + roundingMode + ); } export function GetDifferenceSettings(op, options, group, disallowed, fallbackSmallest, smallestLargestDefaultUnit) { @@ -4317,27 +4591,43 @@ export function DifferenceTemporalPlainDate(operation, plainDate, other, options const roundingIsNoop = settings.smallestUnit === 'day' && settings.roundingIncrement === 1; if (!roundingIsNoop) { - ({ years, months, weeks, days } = RoundDuration( + const destEpochNs = GetUTCEpochNanoseconds( + GetSlot(other, ISO_YEAR), + GetSlot(other, ISO_MONTH), + GetSlot(other, ISO_DAY), + 0, + 0, + 0, + 0, + 0, + 0 + ); + const plainDateTime = CreateTemporalDateTime( + GetSlot(plainDate, ISO_YEAR), + GetSlot(plainDate, ISO_MONTH), + GetSlot(plainDate, ISO_DAY), + 0, + 0, + 0, + 0, + 0, + 0, + calendarRec.receiver + ); + ({ years, months, weeks, days } = RoundRelativeDuration( years, months, weeks, days, TimeDuration.ZERO, - settings.roundingIncrement, - settings.smallestUnit, - settings.roundingMode, - plainDate, - calendarRec - )); - ({ years, months, weeks, days } = BalanceDateDurationRelative( - years, - months, - weeks, - days, + destEpochNs, + plainDateTime, + calendarRec, + null, settings.largestUnit, settings.smallestUnit, - plainDate, - calendarRec + settings.roundingIncrement, + settings.roundingMode )); } @@ -4435,16 +4725,7 @@ export function DifferenceTemporalPlainTime(operation, plainTime, other, options GetSlot(other, ISO_NANOSECOND) ); if (settings.smallestUnit !== 'nanosecond' || settings.roundingIncrement !== 1) { - ({ norm } = RoundDuration( - 0, - 0, - 0, - 0, - norm, - settings.roundingIncrement, - settings.smallestUnit, - settings.roundingMode - )); + ({ norm } = RoundDuration(0, norm, settings.roundingIncrement, settings.smallestUnit, settings.roundingMode)); } const { hours, minutes, seconds, milliseconds, microseconds, nanoseconds } = BalanceTimeDuration( norm, @@ -4500,27 +4781,43 @@ export function DifferenceTemporalPlainYearMonth(operation, yearMonth, other, op let { years, months } = CalendarDateUntil(calendarRec, thisDate, otherDate, resolvedOptions); if (settings.smallestUnit !== 'month' || settings.roundingIncrement !== 1) { - ({ years, months } = RoundDuration( - years, - months, + const destEpochNs = GetUTCEpochNanoseconds( + GetSlot(otherDate, ISO_YEAR), + GetSlot(otherDate, ISO_MONTH), + GetSlot(otherDate, ISO_DAY), 0, 0, - TimeDuration.ZERO, - settings.roundingIncrement, - settings.smallestUnit, - settings.roundingMode, - thisDate, - calendarRec - )); - ({ years, months } = BalanceDateDurationRelative( + 0, + 0, + 0, + 0 + ); + const plainDateTime = CreateTemporalDateTime( + GetSlot(thisDate, ISO_YEAR), + GetSlot(thisDate, ISO_MONTH), + GetSlot(thisDate, ISO_DAY), + 0, + 0, + 0, + 0, + 0, + 0, + calendarRec.receiver + ); + ({ years, months } = RoundRelativeDuration( years, months, 0, 0, + TimeDuration.ZERO, + destEpochNs, + plainDateTime, + calendarRec, + null, settings.largestUnit, settings.smallestUnit, - thisDate, - calendarRec + settings.roundingIncrement, + settings.roundingMode )); } @@ -4587,15 +4884,12 @@ export function DifferenceTemporalZonedDateTime(operation, zonedDateTime, other, GetSlot(zonedDateTime, INSTANT), calendarRec.receiver ); - const plainRelativeTo = TemporalDateTimeToDate(precalculatedPlainDateTime); ({ years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds } = DifferenceZonedDateTimeWithRounding( ns1, ns2, - plainRelativeTo, calendarRec, - zonedDateTime, timeZoneRec, precalculatedPlainDateTime, resolvedOptions, @@ -5302,295 +5596,24 @@ export function DaysUntil(earlier, later) { ).days; } -export function MoveRelativeDate(calendarRec, relativeTo, duration) { - // dateAdd must be looked up if years, months, weeks != 0 - const later = AddDate(calendarRec, relativeTo, duration); - const days = DaysUntil(relativeTo, later); - return { relativeTo: later, days }; -} - -export function MoveRelativeZonedDateTime( - relativeTo, - calendarRec, - timeZoneRec, - years, - months, - weeks, - days, - precalculatedPlainDateTime -) { - // getOffsetNanosecondsFor and getPossibleInstantsFor must be looked up - // dateAdd must be looked up if years, months, weeks != 0 - const intermediateNs = AddZonedDateTime( - GetSlot(relativeTo, INSTANT), - timeZoneRec, - calendarRec, - years, - months, - weeks, - days, - TimeDuration.ZERO, - precalculatedPlainDateTime - ); - return CreateTemporalZonedDateTime(intermediateNs, timeZoneRec.receiver, calendarRec.receiver); -} - -export function AdjustRoundedDurationDays( - years, - months, - weeks, - days, - norm, - increment, - unit, - roundingMode, - zonedRelativeTo, - calendarRec, - timeZoneRec, - precalculatedPlainDateTime -) { - // both dateAdd and dateUntil must be looked up if unit <= hour, any rounding - // is requested, and any of years...weeks != 0 - if (IsCalendarUnit(unit) || unit === 'day' || (unit === 'nanosecond' && increment === 1)) { - return { years, months, weeks, days, norm }; - } - - // There's one more round of rounding possible: if relativeTo is a - // ZonedDateTime, the time units could have rounded up into enough hours - // to exceed the day length. If this happens, grow the date part by a - // single day and re-run exact time rounding on the smaller remainder. DO - // NOT RECURSE, because once the extra hours are sucked up into the date - // duration, there's no way for another full day to come from the next - // round of rounding. And if it were possible (e.g. contrived calendar - // with 30-minute-long "days") then it'd risk an infinite loop. - const direction = norm.sign(); - - const calendar = GetSlot(zonedRelativeTo, CALENDAR); - // requires dateAdd if years...weeks != 0 - const dayStart = AddZonedDateTime( - GetSlot(zonedRelativeTo, INSTANT), - timeZoneRec, - calendarRec, - years, - months, - weeks, - days, - TimeDuration.ZERO, - precalculatedPlainDateTime - ); - const TemporalInstant = GetIntrinsic('%Temporal.Instant%'); - const dayStartInstant = new TemporalInstant(dayStart); - const dayStartDateTime = GetPlainDateTimeFor(timeZoneRec, dayStartInstant, calendar); - const dayEnd = AddDaysToZonedDateTime(dayStartInstant, dayStartDateTime, timeZoneRec, calendar, direction).epochNs; - const dayLength = TimeDuration.fromEpochNsDiff(dayEnd, dayStart); - - const oneDayLess = norm.subtract(dayLength); - if (oneDayLess.sign() * direction >= 0) { - // requires dateAdd and dateUntil if years...weeks != 0 - ({ years, months, weeks, days } = AddDuration( - years, - months, - weeks, - days, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - direction, - 0, - 0, - 0, - 0, - 0, - 0, - /* plainRelativeTo = */ undefined, - zonedRelativeTo, - calendarRec, - timeZoneRec, - precalculatedPlainDateTime - )); - ({ norm } = RoundDuration(0, 0, 0, 0, oneDayLess, increment, unit, roundingMode)); - } - CombineDateAndNormalizedTimeDuration(years, months, weeks, days, norm); - return { years, months, weeks, days, norm }; -} - -export function RoundDuration( - years, - months, - weeks, - days, - norm, - increment, - unit, - roundingMode, - plainRelativeTo = undefined, - calendarRec = undefined, - zonedRelativeTo = undefined, - timeZoneRec = undefined, - precalculatedPlainDateTime = undefined -) { - // dateAdd and dateUntil must be looked up - const TemporalDuration = GetIntrinsic('%Temporal.Duration%'); - - if (IsCalendarUnit(unit) && !plainRelativeTo) { - throw new RangeError(`A starting point is required for ${unit}s rounding`); - } - - // First convert time units up to days, if rounding to days or higher units. - // If rounding relative to a ZonedDateTime, then some days may not be 24h. - let dayLengthNs; - if (IsCalendarUnit(unit) || unit === 'day') { - let deltaDays; - if (zonedRelativeTo) { - const intermediate = MoveRelativeZonedDateTime( - zonedRelativeTo, - calendarRec, - timeZoneRec, - years, - months, - weeks, - days, - precalculatedPlainDateTime - ); - ({ days: deltaDays, norm, dayLengthNs } = NormalizedTimeDurationToDays(norm, intermediate, timeZoneRec)); - } else { - ({ quotient: deltaDays, remainder: norm } = norm.divmod(DAY_NANOS)); - dayLengthNs = DAY_NANOS; - } - days += deltaDays; - } +export function RoundDuration(days, norm, increment, unit, roundingMode) { + // unit must not be a calendar unit let total; - switch (unit) { - case 'year': { - // convert months and weeks to days by calculating difference( - // relativeTo + years, relativeTo + { years, months, weeks }) - const yearsDuration = new TemporalDuration(years); - const yearsLater = AddDate(calendarRec, plainRelativeTo, yearsDuration); - const yearsMonthsWeeks = new TemporalDuration(years, months, weeks); - const yearsMonthsWeeksLater = AddDate(calendarRec, plainRelativeTo, yearsMonthsWeeks); - const monthsWeeksInDays = DaysUntil(yearsLater, yearsMonthsWeeksLater); - plainRelativeTo = yearsLater; - days += monthsWeeksInDays; - - const isoResult = BalanceISODate( - GetSlot(plainRelativeTo, ISO_YEAR), - GetSlot(plainRelativeTo, ISO_MONTH), - GetSlot(plainRelativeTo, ISO_DAY) + days - ); - const wholeDaysLater = CreateTemporalDate(isoResult.year, isoResult.month, isoResult.day, calendarRec.receiver); - const untilOptions = ObjectCreate(null); - untilOptions.largestUnit = 'year'; - const yearsPassed = GetSlot(DifferenceDate(calendarRec, plainRelativeTo, wholeDaysLater, untilOptions), YEARS); - years += yearsPassed; - const yearsPassedDuration = new TemporalDuration(yearsPassed); - let daysPassed; - ({ relativeTo: plainRelativeTo, days: daysPassed } = MoveRelativeDate( - calendarRec, - plainRelativeTo, - yearsPassedDuration - )); - days -= daysPassed; - const oneYear = new TemporalDuration(days < 0 ? -1 : 1); - let { days: oneYearDays } = MoveRelativeDate(calendarRec, plainRelativeTo, oneYear); - - oneYearDays = MathAbs(oneYearDays); - if (oneYearDays === 0) throw new RangeError('custom calendar reported that a year is 0 days long'); - total = years + (days + norm.fdiv(dayLengthNs)) / oneYearDays; - years = RoundNumberToIncrement(total, increment, roundingMode); - months = weeks = days = 0; - norm = TimeDuration.ZERO; - break; - } - case 'month': { - // convert weeks to days by calculating difference(relativeTo + - // { years, months }, relativeTo + { years, months, weeks }) - const yearsMonths = new TemporalDuration(years, months); - const yearsMonthsLater = AddDate(calendarRec, plainRelativeTo, yearsMonths); - const yearsMonthsWeeks = new TemporalDuration(years, months, weeks); - const yearsMonthsWeeksLater = AddDate(calendarRec, plainRelativeTo, yearsMonthsWeeks); - const weeksInDays = DaysUntil(yearsMonthsLater, yearsMonthsWeeksLater); - plainRelativeTo = yearsMonthsLater; - days += weeksInDays; - - const isoResult = BalanceISODate( - GetSlot(plainRelativeTo, ISO_YEAR), - GetSlot(plainRelativeTo, ISO_MONTH), - GetSlot(plainRelativeTo, ISO_DAY) + days - ); - const wholeDaysLater = CreateTemporalDate(isoResult.year, isoResult.month, isoResult.day, calendarRec.receiver); - const untilOptions = ObjectCreate(null); - untilOptions.largestUnit = 'month'; - const monthsPassed = GetSlot(DifferenceDate(calendarRec, plainRelativeTo, wholeDaysLater, untilOptions), MONTHS); - months += monthsPassed; - const monthsPassedDuration = new TemporalDuration(0, monthsPassed); - let daysPassed; - ({ relativeTo: plainRelativeTo, days: daysPassed } = MoveRelativeDate( - calendarRec, - plainRelativeTo, - monthsPassedDuration - )); - days -= daysPassed; - const oneMonth = new TemporalDuration(0, days < 0 ? -1 : 1); - let { days: oneMonthDays } = MoveRelativeDate(calendarRec, plainRelativeTo, oneMonth); - - oneMonthDays = MathAbs(oneMonthDays); - if (oneMonthDays === 0) throw new RangeError('custom calendar reported that a month is 0 days long'); - total = months + (days + norm.fdiv(dayLengthNs)) / oneMonthDays; - months = RoundNumberToIncrement(total, increment, roundingMode); - weeks = days = 0; - norm = TimeDuration.ZERO; - break; - } - case 'week': { - const isoResult = BalanceISODate( - GetSlot(plainRelativeTo, ISO_YEAR), - GetSlot(plainRelativeTo, ISO_MONTH), - GetSlot(plainRelativeTo, ISO_DAY) + days - ); - const wholeDaysLater = CreateTemporalDate(isoResult.year, isoResult.month, isoResult.day, calendarRec.receiver); - const untilOptions = ObjectCreate(null); - untilOptions.largestUnit = 'week'; - const weeksPassed = GetSlot(DifferenceDate(calendarRec, plainRelativeTo, wholeDaysLater, untilOptions), WEEKS); - weeks += weeksPassed; - const weeksPassedDuration = new TemporalDuration(0, 0, weeksPassed); - let daysPassed; - ({ relativeTo: plainRelativeTo, days: daysPassed } = MoveRelativeDate( - calendarRec, - plainRelativeTo, - weeksPassedDuration - )); - days -= daysPassed; - const oneWeek = new TemporalDuration(0, 0, days < 0 ? -1 : 1); - let { days: oneWeekDays } = MoveRelativeDate(calendarRec, plainRelativeTo, oneWeek); - - oneWeekDays = MathAbs(oneWeekDays); - if (oneWeekDays === 0) throw new RangeError('custom calendar reported that a week is 0 days long'); - total = weeks + (days + norm.fdiv(dayLengthNs)) / oneWeekDays; - weeks = RoundNumberToIncrement(total, increment, roundingMode); - days = 0; - norm = TimeDuration.ZERO; - break; - } - case 'day': - total = days + norm.fdiv(dayLengthNs); - days = RoundNumberToIncrement(total, increment, roundingMode); - norm = TimeDuration.ZERO; - break; - default: { - const divisor = NS_PER_TIME_UNIT.get(unit); - total = norm.fdiv(divisor); - norm = norm.round(divisor * increment, roundingMode); - } + if (unit === 'day') { + // First convert time units up to days + const { quotient, remainder } = norm.divmod(DAY_NANOS); + days += quotient; + total = days + remainder.fdiv(DAY_NANOS); + days = RoundNumberToIncrement(total, increment, roundingMode); + norm = TimeDuration.ZERO; + } else { + const divisor = NS_PER_TIME_UNIT.get(unit); + total = norm.fdiv(divisor); + norm = norm.round(divisor * increment, roundingMode); } - CombineDateAndNormalizedTimeDuration(years, months, weeks, days, norm); - return { years, months, weeks, days, norm, total }; + CombineDateAndNormalizedTimeDuration(0, 0, 0, days, norm); + return { days, norm, total }; } export function CompareISODate(y1, m1, d1, y2, m2, d2) { diff --git a/polyfill/lib/timeduration.mjs b/polyfill/lib/timeduration.mjs index a7b5b0d75..c2f830382 100644 --- a/polyfill/lib/timeduration.mjs +++ b/polyfill/lib/timeduration.mjs @@ -75,8 +75,8 @@ export class TimeDuration { return { quotient: q, remainder: r }; } - fdiv(n) { - if (n === 0) throw new Error('division by zero'); + #longDivision(n) { + if (n.isZero()) throw new Error('division by zero'); let { quotient, remainder } = this.totalNs.divmod(n); // Perform long division to calculate the fractional part of the quotient @@ -93,10 +93,18 @@ export class TimeDuration { return sign * Number(quotient.abs().toString() + '.' + decimalDigits.join('')); } + fdiv(n) { + return this.#longDivision(bigInt(n)); + } + isZero() { return this.totalNs.isZero(); } + ratioOf(other) { + return this.#longDivision(other.totalNs); + } + round(increment, mode) { const { quotient, remainder } = this.totalNs.divmod(increment); const sign = this.totalNs.lt(0) ? 'negative' : 'positive';