Skip to content

WIP solution to #3310, extra nudge#3318

Draft
arshaw wants to merge 1 commit into
tc39:mainfrom
fullcalendar:3310-zoned-skipped-day-nudge
Draft

WIP solution to #3310, extra nudge#3318
arshaw wants to merge 1 commit into
tc39:mainfrom
fullcalendar:3310-zoned-skipped-day-nudge

Conversation

@arshaw
Copy link
Copy Markdown
Contributor

@arshaw arshaw commented May 15, 2026

I was thinking same thing as @justingrant #3310 (comment):

Could the solultion be to extend the nudge window by one day until it's nonzero length?

For relative-duration math, we have the notion of a "nudge window" that contains the epoch-nanoseconds of the "destination" (relativeTo+duration). I visualize infinite windows emanating from relativeTo, as relativeTo + duration * n and the goal is to find the one that bounds the destination.

With the the test case from #3310 in mind, I prefer to first think of a case where the duration has some extra time units that push it within the nudge window rather that residing exactly on the start:

const relativeTo = Temporal.ZonedDateTime.from('2012-01-01T12:00:00[Pacific/Apia]');
const d = Temporal.Duration.from({ days: -1, seconds: -5 });
d.total({ unit: 'days', relativeTo });
// this PR's result: -2.0000578703703704
// Broken down into individual operations...

// first nudge window
const relativeTo = Temporal.ZonedDateTime.from('2012-01-01T12:00:00[Pacific/Apia]');
const destination = relativeTo.add({ days: -1, seconds: -5 })
const windowStart = relativeTo.add({ days: -1 })
const windowEnd = relativeTo.add({ days: -2 })
const numerator = relativeTo.epochNanoseconds - windowStart.epochNanoseconds // 86400000000000n
const denominator = windowEnd.epochNanoseconds - windowStart.epochNanoseconds // 0n
// ^invalid denominator so trying another nudge..

// second nudge window
const relativeTo = Temporal.ZonedDateTime.from('2012-01-01T12:00:00[Pacific/Apia]');
const destination = relativeTo.add({ days: -1, seconds: -5 })
const windowStart = relativeTo.add({ days: -2 })
const windowEnd = relativeTo.add({ days: -3 })
const numerator = destination.epochNanoseconds - windowStart.epochNanoseconds // -5000000000n
const denominator = windowEnd.epochNanoseconds - windowStart.epochNanoseconds // -86400000000000n
// fraction: 0.0000578703703704
// result: -2.0000578703703704

Now for the clean day: -1 case:

const relativeTo = Temporal.ZonedDateTime.from('2012-01-01T12:00:00[Pacific/Apia]');
const d = Temporal.Duration.from({ days: -1 });
d.total({ unit: 'days', relativeTo });
// this PR's result: -2
// Broken down into individual operations...

// first nudge window
const relativeTo = Temporal.ZonedDateTime.from('2012-01-01T12:00:00[Pacific/Apia]');
const destination = relativeTo.add({ days: -1 })
const windowStart = relativeTo.add({ days: -1 })
const windowEnd = relativeTo.add({ days: -2 })
const numerator = relativeTo.epochNanoseconds - windowStart.epochNanoseconds // 86400000000000n
const denominator = windowEnd.epochNanoseconds - windowStart.epochNanoseconds // 0n
// ^invalid denominator? (and definitely a weird sign for numerator)
// we could maybe say the `destination` landed in the zero-width nudge-window,
// and thus the fraction could be 0 and the result could be -1 + 0 ?
// This PR does NOT do this however. Let's be consistent with above,
// so let's trying another nudge..

// second nudge window
const relativeTo = Temporal.ZonedDateTime.from('2012-01-01T12:00:00[Pacific/Apia]');
const destination = relativeTo.add({ days: -1 })
const windowStart = relativeTo.add({ days: -2 })
const windowEnd = relativeTo.add({ days: -3 })
const numerator = destination.epochNanoseconds - windowStart.epochNanoseconds // 0n
const denominator = windowEnd.epochNanoseconds - windowStart.epochNanoseconds // -86400000000000n
// fraction: 0
// result: -2

Conclusion

This -2 funny business seems "correct" according to the original algorithm, but feels rather unintuitive on its face.

Maybe the -2 feels weird but is more consistent with other operations, so overall better. Need to play around with this more.

…itionalShift undo more scenarios that would error prior
@codecov
Copy link
Copy Markdown

codecov Bot commented May 15, 2026

Codecov Report

❌ Patch coverage is 82.35294% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.37%. Comparing base (4df4199) to head (b419ac8).

Files with missing lines Patch % Lines
polyfill/lib/ecmascript.mjs 82.35% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3318      +/-   ##
==========================================
- Coverage   98.39%   98.37%   -0.02%     
==========================================
  Files          22       22              
  Lines       10763    10774      +11     
  Branches     1866     1869       +3     
==========================================
+ Hits        10590    10599       +9     
- Misses        161      163       +2     
  Partials       12       12              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Collaborator

@ptomato ptomato left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks very much for taking a look at this Adam!

I've been working on this myself and I've come to some similar conclusions: we definitely need to handle additionalShift in the days and weeks cases, and like you I suspect we don't need the extra case when sign === 1.

I do find the -2 result kind of suspicious though. At least, it seems weird to start with a duration of exactly -1 day, round it to the nearest day, and get -2 days. That doesn't happen with normal DST changes, e.g.

relativeTo = Temporal.ZonedDateTime.from('2026-03-08T03:00-07:00[America/Vancouver]');
duration = Temporal.Duration.from({ hours: -1 })
duration.round({ smallestUnit: 'hours', relativeTo })
  // -1 h

even though the wall clock difference is 2 hours

relativeTo.toPlainDateTime().until(relativeTo.add({ hours: -1 }).toPlainDateTime())
  // -2 h

But then again, hours are time units and we do explicitly treat those differently than calendar units in ZonedDateTime arithmetic. So maybe it's justified. I don't know.

I've been using a bot to search for solutions that avoid the assertions while still getting a -1 result. It found one, at least one that passes all the tests I've been able to throw at it, but I've been hesitant to post it since I don't yet feel confident about whether it's correct. It adds a new if-clause inside the sign === -1 case and before the endEpochNs ≤ destEpochNs ≤ startEpochNs test, and if startEpochNs = endEpochNs it will basically compute an extended endEpochNs for the denominator, but not overwrite the actual nudge window's startEpochNs and endEpochNs. Seems vaguely iffy to me, like maybe the bot is overfitting to the test. I guess the part I don't understand is why rounding modes and total don't get messed up when we increase the size of the denominator.

In any case, I just updated the test262 PR (tc39/test262#5044) with all of the failing cases I've found over the past few days. Some of these I found by taking a solution that the bot claimed was correct and running it through the snapshot tests, to discover that there were still cases that would fail the assertions. But do note that I wrote the tests assuming the -1 result is correct and not the -2 result.

@justingrant
Copy link
Copy Markdown
Collaborator

Remind me what the original intent of the "nudge window" is? I often find that it's easier to resolve problems like this by going back to the first principles that caused us to create concepts and algorithms in the first place, and then to see if our new idea matches the original intent.

@ptomato
Copy link
Copy Markdown
Collaborator

ptomato commented May 18, 2026

Adam might have a better answer to this, but as I understand it the nudge window should be a pair of values bracketing the value to be rounded, and they should be exactly one rounding increment apart. Depending on the rounding mode, we round either to the left edge or the right edge of the nudge window.

It gets simpler to visualize if we pretend to be rounding plain numbers and not durations: for example the number to be rounded is 10.5, to the nearest increment of 3, the nudge window would be from 9 to 12. halfExpand would round to 12, halfTrunc would round to 9.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants