Skip to content

Commit

Permalink
Editorial: Rewrite DifferenceISODate as a clearer algorithm
Browse files Browse the repository at this point in the history
The DifferenceISODate AO was a port from optimized JS to spec language,
but was difficult to understand. Replace it with an algorithm that is
written in an easier to understand way, but produces identical results
(verified by testing a 1:1 implementation of the old and new algorithms
on every possible pair of dates in a 4-year period.)

The reference code is also changed to match the AO more closely and be
easier to understand, although some optimizations are made.
  • Loading branch information
ptomato committed Jan 30, 2024
1 parent 35732cb commit 8b8ba20
Show file tree
Hide file tree
Showing 2 changed files with 116 additions and 131 deletions.
138 changes: 52 additions & 86 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3763,97 +3763,63 @@ export function RejectDuration(y, mon, w, d, h, min, s, ms, µs, ns) {
}
}

export function DifferenceISODate(y1, m1, d1, y2, m2, d2, largestUnit = 'days') {
switch (largestUnit) {
case 'year':
case 'month': {
const sign = -CompareISODate(y1, m1, d1, y2, m2, d2);
if (sign === 0) return { years: 0, months: 0, weeks: 0, days: 0 };

const start = { year: y1, month: m1, day: d1 };
const end = { year: y2, month: m2, day: d2 };

let years = end.year - start.year;
let mid = AddISODate(y1, m1, d1, years, 0, 0, 0, 'constrain');
let midSign = -CompareISODate(mid.year, mid.month, mid.day, y2, m2, d2);
if (midSign === 0) {
return largestUnit === 'year'
? { years, months: 0, weeks: 0, days: 0 }
: { years: 0, months: years * 12, weeks: 0, days: 0 };
}
let months = end.month - start.month;
if (midSign !== sign) {
years -= sign;
months += sign * 12;
}
mid = AddISODate(y1, m1, d1, years, months, 0, 0, 'constrain');
midSign = -CompareISODate(mid.year, mid.month, mid.day, y2, m2, d2);
if (midSign === 0) {
return largestUnit === 'year'
? { years, months, weeks: 0, days: 0 }
: { years: 0, months: months + years * 12, weeks: 0, days: 0 };
}
if (midSign !== sign) {
// The end date is later in the month than mid date (or earlier for
// negative durations). Back up one month.
months -= sign;
mid = AddISODate(y1, m1, d1, years, months, 0, 0, 'constrain');
}
function ISODateSurpasses(sign, y1, m1, d1, y2, m2, d2) {
const constrained = ConstrainISODate(y1, m1, d1);
const cmp = CompareISODate(constrained.year, constrained.month, constrained.day, y2, m2, d2);
return sign * cmp === 1;
}

let days = 0;
// If we get here, months and years are correct (no overflow), and `mid`
// is within the range from `start` to `end`. To count the days between
// `mid` and `end`, there are 3 cases:
// 1) same month: use simple subtraction
// 2) end is previous month from intermediate (negative duration)
// 3) end is next month from intermediate (positive duration)
if (mid.month === end.month) {
// 1) same month: use simple subtraction
days = end.day - mid.day;
} else if (sign < 0) {
// 2) end is previous month from intermediate (negative duration)
// Example: intermediate: Feb 1, end: Jan 30, DaysInMonth = 31, days = -2
days = -mid.day - (ISODaysInMonth(end.year, end.month) - end.day);
} else {
// 3) end is next month from intermediate (positive duration)
// Example: intermediate: Jan 29, end: Feb 1, DaysInMonth = 31, days = 3
days = end.day + (ISODaysInMonth(mid.year, mid.month) - mid.day);
}
function ISODateToEpochDays(y, m, d) {
// This is inefficient, but we use GetUTCEpochNanoseconds to avoid duplicating
// the workarounds for legacy Date. (see that function for explanation)
return GetUTCEpochNanoseconds(y, m, d, 0, 0, 0, 0, 0, 0).divide(DAY_NANOS).toJSNumber();
}

if (largestUnit === 'month') {
months += years * 12;
years = 0;
}
return { years, months, weeks: 0, days };
export function DifferenceISODate(y1, m1, d1, y2, m2, d2, largestUnit = 'days') {
const sign = -CompareISODate(y1, m1, d1, y2, m2, d2);
if (sign === 0) return { years: 0, months: 0, weeks: 0, days: 0 };

let years = 0;
let months = 0;
let intermediate;
if (largestUnit === 'year' || largestUnit === 'month') {
// We can skip right to the neighbourhood of the correct number of years,
// it'll be at least one less than y2 - y1 (unless it's zero)
let candidateYears = y2 - y1;
if (candidateYears !== 0) candidateYears -= sign;
// loops at most twice
while (!ISODateSurpasses(sign, y1 + candidateYears, m1, d1, y2, m2, d2)) {
years = candidateYears;
candidateYears += sign;
}
case 'week':
case 'day': {
let larger, smaller, sign;
if (CompareISODate(y1, m1, d1, y2, m2, d2) < 0) {
smaller = { year: y1, month: m1, day: d1 };
larger = { year: y2, month: m2, day: d2 };
sign = 1;
} else {
smaller = { year: y2, month: m2, day: d2 };
larger = { year: y1, month: m1, day: d1 };
sign = -1;
}
let days = DayOfYear(larger.year, larger.month, larger.day) - DayOfYear(smaller.year, smaller.month, smaller.day);
for (let year = smaller.year; year < larger.year; ++year) {
days += LeapYear(year) ? 366 : 365;
}
let weeks = 0;
if (largestUnit === 'week') {
weeks = MathFloor(days / 7);
days %= 7;
}
weeks *= sign;
days *= sign;
return { years: 0, months: 0, weeks, days };

let candidateMonths = sign;
intermediate = BalanceISOYearMonth(y1 + years, m1 + candidateMonths);
// loops at most 12 times
while (!ISODateSurpasses(sign, intermediate.year, intermediate.month, d1, y2, m2, d2)) {
months = candidateMonths;
candidateMonths += sign;
intermediate = BalanceISOYearMonth(intermediate.year, intermediate.month + sign);
}

if (largestUnit === 'month') {
months += years * 12;
years = 0;
}
default:
throw new Error('assert not reached');
}

intermediate = BalanceISOYearMonth(y1 + years, m1 + months);
const constrained = ConstrainISODate(intermediate.year, intermediate.month, d1);

let weeks = 0;
let days = ISODateToEpochDays(y2, m2, d2) - ISODateToEpochDays(constrained.year, constrained.month, constrained.day);

if (largestUnit === 'week') {
weeks = MathTrunc(days / 7);
days %= 7;
}

return { years, months, weeks, days };
}

export function DifferenceTime(h1, min1, s1, ms1, µs1, ns1, h2, min2, s2, ms2, µs2, ns2) {
Expand Down
109 changes: 64 additions & 45 deletions spec/plaindate.html
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,34 @@ <h1>
</emu-alg>
</emu-clause>

<emu-clause id="sec-temporal-isodatesurpasses" type="abstract operation">
<h1>
ISODateSurpasses (
_sign_: -1 or 1,
_y1_: an integer,
_m1_: an integer,
_d1_: an integer,
_y2_: an integer,
_m2_: an integer in the inclusive interval from 1 to 12,
_d2_: an integer in the inclusive interval from 1 to ISODaysInMonth(_m2_),
): a Boolean
</h1>
<dl class="header">
<dt>description</dt>
<dd>
The return value indicates whether the date denoted by _y1_, _m1_, _d1_ surpasses that denoted by _y2_, _m2_, _d2_ in the direction denoted by _sign_.
The former date does not have to exist.
Note that this operation is specific to date difference calculations and is not the same as CompareISODate.
</dd>
</dl>
<emu-alg>
1. Let _constrained_ be ! RegulateISODate(_y1_, _m1_, _d1_, *"constrain"*).
1. Let _comparison_ be CompareISODate(_constrained_.[[Year]], _constrained_.[[Month]], _constrained_.[[Day]], _y2_, _m2_, _d2_).
1. If _sign_ &times; _comparison_ is 1, return *true*.
1. Return *false*.
</emu-alg>
</emu-clause>

<emu-clause id="sec-temporal-differenceisodate" type="abstract operation">
<h1>
DifferenceISODate (
Expand All @@ -798,51 +826,42 @@ <h1>
</dd>
</dl>
<emu-alg>
1. Assert: IsValidISODate(y1, m1, d1) is true.
1. Assert: IsValidISODate(y2, m2, d2) is true.
1. If _largestUnit_ is *"year"* or *"month"*, then
1. Let _sign_ be -(! CompareISODate(_y1_, _m1_, _d1_, _y2_, _m2_, _d2_)).
1. If _sign_ is 0, return ! CreateDateDurationRecord(0, 0, 0, 0).
1. Let _start_ be the Record { [[Year]]: _y1_, [[Month]]: _m1_, [[Day]]: _d1_ }.
1. Let _end_ be the Record { [[Year]]: _y2_, [[Month]]: _m2_, [[Day]]: _d2_ }.
1. Let _years_ be _end_.[[Year]] - _start_.[[Year]].
1. Let _mid_ be ! AddISODate(_y1_, _m1_, _d1_, _years_, 0, 0, 0, *"constrain"*).
1. Let _midSign_ be -(! CompareISODate(_mid_.[[Year]], _mid_.[[Month]], _mid_.[[Day]], _y2_, _m2_, _d2_)).
1. If _midSign_ is 0, then
1. If _largestUnit_ is *"year"*, return ! CreateDateDurationRecord(_years_, 0, 0, 0).
1. Return ! CreateDateDurationRecord(0, _years_ &times; 12, 0, 0).
1. Let _months_ be _end_.[[Month]] - _start_.[[Month]].
1. If _midSign_ is not equal to _sign_, then
1. Set _years_ to _years_ - _sign_.
1. Set _months_ to _months_ + _sign_ &times; 12.
1. Set _mid_ to ! AddISODate(_y1_, _m1_, _d1_, _years_, _months_, 0, 0, *"constrain"*).
1. Set _midSign_ to -(! CompareISODate(_mid_.[[Year]], _mid_.[[Month]], _mid_.[[Day]], _y2_, _m2_, _d2_)).
1. If _midSign_ is 0, then
1. If _largestUnit_ is *"year"*, return ! CreateDateDurationRecord(_years_, _months_, 0, 0).
1. Return ! CreateDateDurationRecord(0, _months_ + _years_ &times; 12, 0, 0).
1. If _midSign_ is not equal to _sign_, then
1. Set _months_ to _months_ - _sign_.
1. Set _mid_ to ! AddISODate(_y1_, _m1_, _d1_, _years_, _months_, 0, 0, *"constrain"*).
1. If _mid_.[[Month]] = _end_.[[Month]], then
1. Assert: _mid_.[[Year]] = _end_.[[Year]].
1. Let _days_ be _end_.[[Day]] - _mid_.[[Day]].
1. Else,
1. If _sign_ &lt; 0, let _days_ be -_mid_.[[Day]] - (ISODaysInMonth(_end_.[[Year]], _end_.[[Month]]) - _end_.[[Day]]).
1. Else, let _days_ be _end_.[[Day]] + (ISODaysInMonth(_mid_.[[Year]], _mid_.[[Month]]) - _mid_.[[Day]]).
1. If _largestUnit_ is *"month"*, then
1. Set _months_ to _months_ + _years_ &times; 12.
1. Set _years_ to 0.
1. Return ! CreateDateDurationRecord(_years_, _months_, 0, _days_).
1. Else,
1. Assert: _largestUnit_ is *"day"* or *"week"*.
1. Let _epochDays1_ be ISODateToEpochDays(_y1_, _m1_ - 1, _d1_).
1. Let _epochDays2_ be ISODateToEpochDays(_y2_, _m2_ - 1, _d2_).
1. Let _days_ be _epochDays2_ - _epochDays1_.
1. Let _weeks_ be 0.
1. If _largestUnit_ is *"week"*, then
1. Set _weeks_ to truncate(_days_ / 7).
1. Set _days_ to remainder(_days_, 7).
1. Return ! CreateDateDurationRecord(0, 0, _weeks_, _days_).
1. Assert: IsValidISODate(_y1_, _m1_, _d1_) is *true*.
1. Assert: IsValidISODate(_y2_, _m2_, _d2_) is *true*.
1. Let _sign_ be -CompareISODate(_y1_, _m1_, _d1_, _y2_, _m2_, _d2_).
1. If _sign_ = 0, return ! CreateDateDurationRecord(0, 0, 0, 0).
1. Let _years_ be 0.
1. If _largestUnit_ is *"year"*, then
1. Let _candidateYears_ be _sign_.
1. Repeat, while ISODateSurpasses(_sign_, _y1_ + _candidateYears_, _m1_, _d1_, _y2_, _m2_, _d2_) is *false*,
1. Set _years_ to _candidateYears_.
1. Set _candidateYears_ to _candidateYears_ + _sign_.
1. Let _months_ be 0.
1. If _largestUnit_ is *"year"* or _largestUnit_ is *"month"*, then
1. Let _candidateMonths_ be _sign_.
1. Let _intermediate_ be BalanceISOYearMonth(_y1_ + _years_, _m1_ + _candidateMonths_).
1. Repeat, while ISODateSurpasses(_sign_, _intermediate_.[[Year]], _intermediate_.[[Month]], _d1_, _y2_, _m2_, _d2_) is *false*,
1. Set _months_ to _candidateMonths_.
1. Set _candidateMonths_ to _candidateMonths_ + _sign_.
1. Set _intermediate_ to BalanceISOYearMonth(_intermediate_.[[Year]], _intermediate_.[[Month]] + _sign_).
1. Set _intermediate_ to BalanceISOYearMonth(_y1_ + _years_, _m1_ + _months_).
1. Let _constrained_ be ! RegulateISODate(_intermediate_.[[Year]], _intermediate_.[[Month]], _d1_, *"constrain"*).
1. Let _weeks_ be 0.
1. If _largestUnit_ is *"week"*, then
1. Let _candidateWeeks_ be _sign_.
1. Set _intermediate_ to BalanceISODate(_constrained_.[[Year]], _constrained_.[[Month]], _constrained_.[[Day]] + 7 &times; _candidateWeeks_).
1. Repeat, while ISODateSurpasses(_sign_, _intermediate_.[[Year]], _intermediate_.[[Month]], _intermediate_.[[Day]], _y2_, _m2_, _d2_) is *false*,
1. Set _weeks_ to _candidateWeeks_.
1. Set _candidateWeeks_ to _candidateWeeks_ + sign.
1. Set _intermediate_ to BalanceISODate(_intermediate_.[[Year]], _intermediate_.[[Month]], _intermediate_.[[Day]] + 7 &times; _sign_).
1. Let _days_ be 0.
1. Let _candidateDays_ be _sign_.
1. Set _intermediate_ to BalanceISODate(_constrained_.[[Year]], _constrained_.[[Month]], _constrained_.[[Day]] + 7 &times; _weeks_ + _candidateDays_).
1. Repeat, while ISODateSurpasses(_sign_, _intermediate_.[[Year]], _intermediate_.[[Month]], _intermediate_.[[Day]], _y2_, _m2_, _d2_) is *false*,
1. Set _days_ to _candidateDays_.
1. Set _candidateDays_ to _candidateDays_ + _sign_.
1. Set _intermediate_ to BalanceISODate(_intermediate_.[[Year]], _intermediate_.[[Month]], _intermediate_.[[Day]] + _sign_).
1. Return ! CreateDateDurationRecord(_years_, _months_, _weeks_, _days_).
</emu-alg>
</emu-clause>

Expand Down

0 comments on commit 8b8ba20

Please sign in to comment.