Skip to content

Commit

Permalink
Updates to ZonedDateTime diffing algorithm and dayCorrection.
Browse files Browse the repository at this point in the history
Fixes bug mentioned here:
#2760 (comment)
  • Loading branch information
arshaw authored and ptomato committed Apr 2, 2024
1 parent a280b61 commit 4af3d80
Show file tree
Hide file tree
Showing 2 changed files with 75 additions and 49 deletions.
122 changes: 74 additions & 48 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3900,45 +3900,66 @@ export function DifferenceZonedDateTime(
norm: TimeDuration.ZERO
};
}
const sign = nsDiff.lt(0) ? -1 : 1;

// Find the difference in dates only.
// Convert start/end instants to datetimes
const TemporalInstant = GetIntrinsic('%Temporal.Instant%');
const start = new TemporalInstant(ns1);
const end = new TemporalInstant(ns2);
const dtStart = precalculatedDtStart ?? GetPlainDateTimeFor(timeZoneRec, start, calendarRec.receiver);
const dtEnd = GetPlainDateTimeFor(timeZoneRec, end, calendarRec.receiver);

// Simulate moving ns1 as many years/months/weeks/days as possible without
// surpassing ns2. This value is stored in intermediateDateTime/intermediate.
// surpassing ns2. This value is stored in intermediateDateTime/intermediateInstant/intermediateNs.
// We do not literally move years/months/weeks/days with calendar arithmetic,
// but rather assume intermediateDateTime will have the same time-parts as
// dtStart and the date-parts from dtEnd, and move backward from there.
//
// This loop will run 3 times max:
// 1. initial run
// 2. backoff if the time-parts of intermediateDateTime have conflicting sign
// with overall diff direction, just like how DifferenceISODateTime works
// 3. backoff if intermediateDateTime fell into a DST gap and was pushed in a
// direction that would make the diff of the time-parts conflict with the
// sign of the overall direction. (Only possible when sign is +1, because
// a DST gap uses 'compatible' disambiguation resolution and can only move
// the intermediate forward)
//
// The number of days we move backward is stored in dayCorrection.
// Credit to Adam Shaw for devising this algorithm.
const sign = nsDiff.lt(0) ? -1 : 1;
const maxTries = sign === 1 ? 3 : 2;
for (let dayCorrection = 0; dayCorrection < maxTries; dayCorrection++) {
const correctedEndDate = BalanceISODate(
let dayCorrection = 0;
let intermediateDateTime;
let norm;

// The max number of allowed day corrections depends on the direction of travel.
// Both directions allow for 1 day correction due to an ISO wall-clock overshoot (see below).
// Only the forward direction allows for an additional 1 day correction caused by a push-forward
// 'compatible' DST transition causing the wall-clock to overshoot again.
// This max value is inclusive.
let maxDayCorrection = sign === 1 ? 2 : 1;

// Detect ISO wall-clock overshoot.
// If the diff of the ISO wall-clock times is opposite to the overall diff's sign,
// we are guaranteed to need at least one day correction.
let timeDuration = DifferenceTime(
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 (timeDuration.sign() === -sign) {
dayCorrection++;
}

for (; dayCorrection <= maxDayCorrection; dayCorrection++) {
const intermediateDate = BalanceISODate(
GetSlot(dtEnd, ISO_YEAR),
GetSlot(dtEnd, ISO_MONTH),
GetSlot(dtEnd, ISO_DAY) - dayCorrection * sign
);

// Incorporate time parts from dtStart
const intermediateDateTime = CreateTemporalDateTime(
correctedEndDate.year,
correctedEndDate.month,
correctedEndDate.day,
intermediateDateTime = CreateTemporalDateTime(
intermediateDate.year,
intermediateDate.month,
intermediateDate.day,
GetSlot(dtStart, ISO_HOUR),
GetSlot(dtStart, ISO_MINUTE),
GetSlot(dtStart, ISO_SECOND),
Expand All @@ -3947,36 +3968,41 @@ export function DifferenceZonedDateTime(
GetSlot(dtStart, ISO_NANOSECOND),
calendarRec.receiver
);
const intermediate = GetInstantFor(timeZoneRec, intermediateDateTime, 'compatible');
// may disambiguate
const intermediateNs = GetSlot(intermediate, EPOCHNANOSECONDS);

// Did intermediateNs surpass ns2?
const norm = TimeDuration.fromEpochNsDiff(ns2, intermediateNs);
const timeSign = norm.sign();
if (sign === 0 || timeSign == 0 || sign === timeSign) {
// sign of timeDuration now compatible with the overall sign

// Similar to what happens in DifferenceISODateTime with date parts only:
const date1 = TemporalDateTimeToDate(dtStart);
const date2 = TemporalDateTimeToDate(intermediateDateTime);
const dateLargestUnit = LargerOfTwoTemporalUnits('day', largestUnit);
const untilOptions = SnapshotOwnProperties(options, null);
untilOptions.largestUnit = dateLargestUnit;
const dateDifference = DifferenceDate(calendarRec, date1, date2, untilOptions);
const years = GetSlot(dateDifference, YEARS);
const months = GetSlot(dateDifference, MONTHS);
const weeks = GetSlot(dateDifference, WEEKS);
const days = GetSlot(dateDifference, DAYS);

CombineDateAndNormalizedTimeDuration(years, months, weeks, days, norm);
return { years, months, weeks, days, norm };
// Convert intermediate datetime to epoch-nanoseconds (may disambiguate)
const intermediateInstant = GetInstantFor(timeZoneRec, intermediateDateTime, 'compatible');
const intermediateNs = GetSlot(intermediateInstant, EPOCHNANOSECONDS);

// Compute the nanosecond diff between the intermediate instant and the final destination
norm = TimeDuration.fromEpochNsDiff(ns2, intermediateNs);

// Did intermediateNs NOT surpass ns2?
// If so, exit the loop with success (without incrementing dayCorrection past maxDayCorrection)
if (norm.sign() !== -sign) {
break;
}
// Else, keep backing off...
}
throw new RangeError(
`inconsistent return from calendar or time zone method: more than ${maxTries - 1} days correction needed`
);

if (dayCorrection > maxDayCorrection) {
throw new RangeError(
`inconsistent return from calendar or time zone method: more than ${maxDayCorrection} day correction needed`
);
}

// Similar to what happens in DifferenceISODateTime with date parts only:
const date1 = TemporalDateTimeToDate(dtStart);
const date2 = TemporalDateTimeToDate(intermediateDateTime);
const dateLargestUnit = LargerOfTwoTemporalUnits('day', largestUnit);
const untilOptions = SnapshotOwnProperties(options, null);
untilOptions.largestUnit = dateLargestUnit;
const dateDifference = DifferenceDate(calendarRec, date1, date2, untilOptions);
const years = GetSlot(dateDifference, YEARS);
const months = GetSlot(dateDifference, MONTHS);
const weeks = GetSlot(dateDifference, WEEKS);
const days = GetSlot(dateDifference, DAYS);

CombineDateAndNormalizedTimeDuration(years, months, weeks, days, norm);
return { years, months, weeks, days, norm };
}

export function GetDifferenceSettings(op, options, group, disallowed, fallbackSmallest, smallestLargestDefaultUnit) {
Expand Down
2 changes: 1 addition & 1 deletion polyfill/test262

0 comments on commit 4af3d80

Please sign in to comment.