Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Time#change and #advance around the end of DST #46251

Conversation

jonathanhefner
Copy link
Member

@jonathanhefner jonathanhefner commented Oct 15, 2022

Prior to this commit, when Time#change or Time#advance constructed a time inside the final stretch of Daylight Saving Time (DST), the non-DST offset would always be chosen for local times:

ENV["TZ"] = "US/Eastern"

time = Time.local(2021, 11, 07, 00, 59, 59) + 1
# => 2021-11-07 01:00:00 -0400
time.change(day: 07)
# => 2021-11-07 01:00:00 -0500
time.advance(seconds: 0)
# => 2021-11-07 01:00:00 -0500

time = Time.local(2021, 11, 06, 01, 00, 00)
# => 2021-11-06 01:00:00 -0400
time.change(day: 07)
# => 2021-11-07 01:00:00 -0500
time.advance(days: 1)
# => 2021-11-07 01:00:00 -0500

And the DST offset would always be chosen for times with a TimeZone object:

Time.zone = "US/Eastern"

time = Time.new(2021, 11, 07, 02, 00, 00, Time.zone) - 3600
# => 2021-11-07 01:00:00 -0500
time.change(day: 07)
# => 2021-11-07 01:00:00 -0400
time.advance(seconds: 0)
# => 2021-11-07 01:00:00 -0400

time = Time.new(2021, 11, 8, 01, 00, 00, Time.zone)
# => 2021-11-08 01:00:00 -0500
time.change(day: 07)
# => 2021-11-07 01:00:00 -0400
time.advance(days: -1)
# => 2021-11-07 01:00:00 -0400

This commit fixes Time#change and Time#advance to choose the offset that matches the original time's offset when possible:

ENV["TZ"] = "US/Eastern"

time = Time.local(2021, 11, 07, 00, 59, 59) + 1
# => 2021-11-07 01:00:00 -0400
time.change(day: 07)
# => 2021-11-07 01:00:00 -0400
time.advance(seconds: 0)
# => 2021-11-07 01:00:00 -0400

time = Time.local(2021, 11, 06, 01, 00, 00)
# => 2021-11-06 01:00:00 -0400
time.change(day: 07)
# => 2021-11-07 01:00:00 -0400
time.advance(days: 1)
# => 2021-11-07 01:00:00 -0400

Time.zone = "US/Eastern"

time = Time.new(2021, 11, 07, 02, 00, 00, Time.zone) - 3600
# => 2021-11-07 01:00:00 -0500
time.change(day: 07)
# => 2021-11-07 01:00:00 -0500
time.advance(seconds: 0)
# => 2021-11-07 01:00:00 -0500

time = Time.new(2021, 11, 8, 01, 00, 00, Time.zone)
# => 2021-11-08 01:00:00 -0500
time.change(day: 07)
# => 2021-11-07 01:00:00 -0500
time.advance(days: -1)
# => 2021-11-07 01:00:00 -0500

Fixes #45055.
Closes #45556.
Closes #46248.


/cc @khall I've added you as co-author for your work on #46248.
/cc @takp I've added you as co-author for your work on #45556.

Prior to this commit, when `Time#change` or `Time#advance` constructed a
time inside the final stretch of Daylight Saving Time (DST), the non-DST
offset would always be chosen for local times:

  ```ruby
  # DST ended just before 2021-11-07 2:00:00 AM in US/Eastern.
  ENV["TZ"] = "US/Eastern"

  time = Time.local(2021, 11, 07, 00, 59, 59) + 1
  # => 2021-11-07 01:00:00 -0400
  time.change(day: 07)
  # => 2021-11-07 01:00:00 -0500
  time.advance(seconds: 0)
  # => 2021-11-07 01:00:00 -0500

  time = Time.local(2021, 11, 06, 01, 00, 00)
  # => 2021-11-06 01:00:00 -0400
  time.change(day: 07)
  # => 2021-11-07 01:00:00 -0500
  time.advance(days: 1)
  # => 2021-11-07 01:00:00 -0500
  ```

And the DST offset would always be chosen for times with a `TimeZone`
object:

  ```ruby
  Time.zone = "US/Eastern"

  time = Time.new(2021, 11, 07, 02, 00, 00, Time.zone) - 3600
  # => 2021-11-07 01:00:00 -0500
  time.change(day: 07)
  # => 2021-11-07 01:00:00 -0400
  time.advance(seconds: 0)
  # => 2021-11-07 01:00:00 -0400

  time = Time.new(2021, 11, 8, 01, 00, 00, Time.zone)
  # => 2021-11-08 01:00:00 -0500
  time.change(day: 07)
  # => 2021-11-07 01:00:00 -0400
  time.advance(days: -1)
  # => 2021-11-07 01:00:00 -0400
  ```

This commit fixes `Time#change` and `Time#advance` to choose the offset
that matches the original time's offset when possible:

  ```ruby
  ENV["TZ"] = "US/Eastern"

  time = Time.local(2021, 11, 07, 00, 59, 59) + 1
  # => 2021-11-07 01:00:00 -0400
  time.change(day: 07)
  # => 2021-11-07 01:00:00 -0400
  time.advance(seconds: 0)
  # => 2021-11-07 01:00:00 -0400

  time = Time.local(2021, 11, 06, 01, 00, 00)
  # => 2021-11-06 01:00:00 -0400
  time.change(day: 07)
  # => 2021-11-07 01:00:00 -0400
  time.advance(days: 1)
  # => 2021-11-07 01:00:00 -0400

  Time.zone = "US/Eastern"

  time = Time.new(2021, 11, 07, 02, 00, 00, Time.zone) - 3600
  # => 2021-11-07 01:00:00 -0500
  time.change(day: 07)
  # => 2021-11-07 01:00:00 -0500
  time.advance(seconds: 0)
  # => 2021-11-07 01:00:00 -0500

  time = Time.new(2021, 11, 8, 01, 00, 00, Time.zone)
  # => 2021-11-08 01:00:00 -0500
  time.change(day: 07)
  # => 2021-11-07 01:00:00 -0500
  time.advance(days: -1)
  # => 2021-11-07 01:00:00 -0500
  ```

It's worth noting that there is a somewhat dubious case when calling
`advance` with a mix of calendar and clock units (e.g. months + hours).
`advance` adds calendar units first, then adds clock units.  So a
non-DST time may first be advanced to a date within DST before any clock
units are applied.  For example:

  ```ruby
  # DST began on 2021-03-14 in US/Eastern.
  Time.zone = "US/Eastern"

  non_dst = Time.new(2021, 03, 07, 00, 00, 00, Time.zone)
  # => 2021-03-07 00:00:00 -0500

  non_dst.advance(months: 8, hours: 1)
  # => 2021-11-07 01:00:00 -0400
  ```

One could argue that the expected result is `2021-11-07 01:00:00 -0500`.
However, the difference between that and the result for `hours: 0` is
greater than 1 hour:

  ```ruby
  adjusted_result = non_dst.advance(months: 8, hours: 1) + 3600
  # => 2021-11-07 01:00:00 -0500

  adjusted_result - non_dst.advance(months: 8, hours: 0)
  # => 7200.0
  ```

Which might also be unexpected.

Furthermore, it may be difficult (or expensive) to apply such an
adjustment in a consistent way.  For example, the result for `hours: 2`
does have the expected `-0500` offset, so it might seem no adjustment is
necessary, but if we didn't adjust it too, it would conflict with the
adjusted `hours: 1` result:

  ```ruby
  non_dst.advance(months: 8, hours: 2)
  # => 2021-11-07 01:00:00 -0500
  ```

Therefore, the approach in this commit (which produces a `-0400` offset
for `hours: 1`) seems like a reasonable compromise.

Fixes rails#45055.
Closes rails#45556.
Closes rails#46248.

Co-authored-by: Kevin Hall <bigtoe416@yahoo.com>
Co-authored-by: Takayoshi Nishida <takayoshi.nishida@gmail.com>
@jonathanhefner jonathanhefner force-pushed the fix-time-change-and-advance-around-end-of-dst branch from 693fcbf to b191a65 Compare October 22, 2022 19:58
@jonathanhefner jonathanhefner marked this pull request as ready for review October 22, 2022 20:10
@jonathanhefner
Copy link
Member Author

jonathanhefner commented Oct 22, 2022

There is a somewhat dubious case when calling advance with a mix of calendar and clock units (e.g. months + hours). advance adds calendar units first, then adds clock units. So a non-DST time may first be advanced to a date within DST before any clock units are applied. For example:

# DST began on 2021-03-14 and ended just before 2021-11-07 2:00:00 AM in US/Eastern.
Time.zone = "US/Eastern"

non_dst = Time.new(2021, 03, 07, 00, 00, 00, Time.zone)
# => 2021-03-07 00:00:00 -0500

non_dst.advance(months: 8, hours: 1)
# => 2021-11-07 01:00:00 -0400

One could argue that the expected result is 2021-11-07 01:00:00 -0500. However, the difference between that and the result for hours: 0 is greater than 1 hour:

adjusted_result = non_dst.advance(months: 8, hours: 1) + 3600
# => 2021-11-07 01:00:00 -0500

adjusted_result - non_dst.advance(months: 8, hours: 0)
# => 7200.0

Which might also be unexpected.

Furthermore, it may be difficult (or expensive) to apply such an adjustment in a consistent way. For example, the result for hours: 2 does have the expected -0500 offset, so it might seem no adjustment is necessary, but if we didn't adjust it too, it would conflict with the adjusted hours: 1 result:

non_dst.advance(months: 8, hours: 2)
# => 2021-11-07 01:00:00 -0500

Therefore, I think the approach in this PR (which produces a -0400 offset for hours: 1) is a reasonable compromise. So I will merge soon unless there are any objections.

@jonathanhefner jonathanhefner merged commit 3a3276c into rails:main Oct 24, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Time#advance and Time#change behave incorrectly at fall Daylight Saving Time change
1 participant