Closed arshaw closed 6 months ago
Good find. Avoiding intermediate values triggering DST disambiguation seems like a good thing.
And I agree that your proposed algorithm seems both correct and better.
On the other hand, I think in #856 we intentionally defined dur1.add(dur2, { relativeTo })
as relativeTo.until(relativeTo.add(dur1).add(dur2))
. If that needs to be relitigated now, I'd rather remove Duration.p.add/subtract from the proposal and punt them to Temporal v2.
What's interesting is that if relativeTo is a PlainDate, the durations' time parts are handled separately from their y/m/w/d parts:
This is very similar to the alternate approach to ZonedDateTime's that I described in my original post.
(though of course there's a big with that reported in #2812, but it can be easily resolved)
@ptomato, in summary, the "relativeTo.until(relativeTo.add(dur1).add(dur2))
" approach isn't leveraged for both relativeTo scenarios in the current spec anyway.
But for the result, that doesn't matter, because PlainDate arithmetic has no disambiguation, right?
Regardless of TZ disambiguation, the current algorithm for PlainDates (stripping duration time parts and then reapplying after) is still different that the "relativeTo.until(relativeTo.add(dur1).add(dur2))
" approach because applying the durations' time parts in the middle of the process vs end of the process could yield different result due to constraining. Example:
const relativeTo = Temporal.PlainDate.from('2023-01-01')
const dur1 = Temporal.Duration.from({ days: 29, hours: 24 })
const dur2 = Temporal.Duration.from({ months: 1 })
// Approach: naive adding and diffing
//
// Jan1 -> Jan30 -> Jan31 -> Feb28
{
const sum = relativeTo.until(relativeTo.add(dur1).add(dur2))
console.log(sum.toString()) // P58D
}
// Approach: saving time parts until end (currently used for PlainDates)
// https://github.com/tc39/proposal-temporal/blob/a493578be5631ecfd924df4434505e235a33c8e0/polyfill/lib/ecmascript.mjs#L4585-L4603
//
// Run this in playground!
// https://tc39.es/proposal-temporal/docs/
//
// Jan1 -> Jan30 -> Feb29 -> Mar1
{
const sum = dur1.add(dur2, { relativeTo })
console.log(sum.toString()) // P1M28D
}
To summarize, there are two approaches to adding durations:
A) naive adding and diffing B) saving time parts until end
Currently A is used for ZonedDateTimes and B is used for PlainDates. This strikes me as odd and I feel the same approach should be used for both scenarios.
My opinions about each
A - easy to explain to someone. easier to implement B - feels more consistent w/ how intermediate ZDT disambiguation is avoided in other parts of the code (more "correct").
Either way works for me!
I'm not convinced this inconsistency is so bad, but I noticed today that if we fixed the bug in #2812 by adopting A for both PlainDate and ZonedDateTime, it'd have the nice bonus of making these consistent.
(Note also PlainDate relativeTo
has to be converted to PlainDateTime for the addition, otherwise the time units are dropped.)
I also thought about proposing a "less naive" adding and diffing, still simple to explain and implement but without the intermediate point. Basically making d1.add(d2, {relativeTo})
roughly equivalent to
relativeTo.until(relativeTo.add(new Temporal.Duration(
d1.years + d2.years,
d1.months + d2.months,
...
d1.nanoseconds + d2.nanoseconds
))
But unfortunately that only works when the two durations have the same sign.
After our previous long discussion about this issue, I think it may be helpful to take a step back to discuss the use cases for Duration.p.add
. I dug into as many use cases as I could find, and it was clear that they fit into two patterns:
Examples of standalone durations:
Examples of incrementing durations:
Duration.p.add
couldn't handle. These "close but not close enough" cases included:
Here's a more structured way to understand the two types:
Standalone Durations | Incrementing Durations | |
---|---|---|
Primary Use | Storing as-is or displaying to users | Adding to a date/time value |
Prevalence | Common | Rare |
Balancing | Required (i.e. displaying "2h 80m" is an obvious bug) | Custom (e.g. treat every month as 30 days) or unnecessary |
DST Impact | None (never added to a ZDT) | Problematic (e.g. this issue!) |
Needs relativeTo ? |
Usually not | Maybe |
Impact of removing relativeTo ? |
Probably small, because W/M/Y cases are unusual | Probably small, because W/M/Y cases often need custom balancing |
With the above framework in mind, here's some options to resolve this issue:
Option 1: Leave as-is, accept DST oddity
Option 2: Change duration addition algorithm to remove DST oddity
Option 3: Remove relativeTo
, leave other behavior unchanged
Throw if non-zero units present in weeks, months, or years (current behavior treats days as 24 hours if no relativeTo
)
relativeTo
-using code and testsrelativeTo
, and any code that didn't use it works exactly the same".Option 4: Remove relativeTo
, week/month/year units are arithmetically added
Throw if non-zero units present in weeks, months, or years AND if durations have opposite signs
from
and with
where the unbalanced parts are provided explicitly by the user so that unbalancing is less surprising.Option 1: Leave as-is, accept DST oddity
Option 2: Change duration addition algorithm to remove DST oddity
Option 3: Remove relativeTo
, leave other behavior unchanged
Option 4: Remove relativeTo
, week/month/year units are arithmetically added
Meeting 2023-04-18: Consensus is for Option 3
I've written up the conclusion in #2825. Let's close this, since the solution now encompasses more than just the original bug in this thread.
In
Duration::add
, using a ZonedDateTimerelativeTo
value, it's possible for an intermediate ZDT to fall into a DST gap and affect the final result:The intermediate ZDT falls into a DST gap at 2024-03-10T02:00:00 and gets pushed forward an hour.
We've been avoiding intermediate-value DST in other places of the spec. Is this in a similar vein? Is this considered a bug?
Other behavior we might consider:
Steps 1-5 is inspired by
ZonedDateTime::add
, but with two durations instead of one.