tc39 / proposal-temporal

Provides standard objects and functions for working with dates and times.
https://tc39.es/proposal-temporal/docs/
Other
3.32k stars 150 forks source link

`Duration::add` with relativeTo:ZonedDateTime intermediate DST bug? #2811

Closed arshaw closed 6 months ago

arshaw commented 6 months ago

In Duration::add, using a ZonedDateTime relativeTo value, it's possible for an intermediate ZDT to fall into a DST gap and affect the final result:

d0 = Temporal.Duration.from({ months: 6 })
d1 = Temporal.Duration.from({ months: 6 })
d2 = d0.add(d1, { relativeTo: '2023-09-10T02:00:00[America/New_York]' })
// { months: 12, hours: 1 }

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:

  1. converting the ZDT to a PlainDateTime
  2. adding y0, m0, w0, d0
  3. adding y1, m1, w1, d1
  4. converting to an epochNanoseconds
  5. combining time parts of both durations into a nanosecond value and adding to epochNanoseconds
  6. doing a ZDT diff between original ZDT and the new one to get the final balanced Duration

Steps 1-5 is inspired by ZonedDateTime::add, but with two durations instead of one.

justingrant commented 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.

ptomato commented 6 months ago

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.

arshaw commented 6 months ago

What's interesting is that if relativeTo is a PlainDate, the durations' time parts are handled separately from their y/m/w/d parts:

https://github.com/tc39/proposal-temporal/blob/a493578be5631ecfd924df4434505e235a33c8e0/polyfill/lib/ecmascript.mjs#L4585-L4603

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.

ptomato commented 6 months ago

But for the result, that doesn't matter, because PlainDate arithmetic has no disambiguation, right?

arshaw commented 6 months ago

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
}
arshaw commented 6 months ago

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!

ptomato commented 6 months ago

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.

justingrant commented 6 months ago

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:

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)

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

justingrant commented 6 months ago

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

ptomato commented 6 months ago

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.