Fix UTC offset change artifact in Humanized duration formatter - #793
Open
dblock wants to merge 1 commit into
Open
Fix UTC offset change artifact in Humanized duration formatter#793dblock wants to merge 1 commit into
dblock wants to merge 1 commit into
Conversation
Timex.Format.Duration.Formatters.Humanized.format/1 only ever receives a raw elapsed Duration (a plain second/microsecond count), so it has no way to know whether the underlying calendar distance between two datetimes is clean. Any UTC offset change spanned by the interval (a DST transition, or a timezone's UTC offset changing permanently) gets misattributed as spurious trailing hours/minutes, because years/months/days are bucketed from fixed 365/30-day constants rather than real calendar arithmetic. For example, formatting the distance between 2015-01-15 and 2016-03-15 in Pacific/Norfolk (whose UTC offset permanently changed from +11:30 to +11:00 on 4 October 2015) currently produces "1 year, 2 months, 30 minutes" instead of the correct "1 year, 2 months". This adds format/2 and lformat/3, which accept both datetimes directly. Years/months are computed via Timex.diff/3 + Timex.shift/2 (real calendar arithmetic that already correctly ignores offset noise), and only the true leftover duration is passed to the existing week/day/hour/minute bucketing logic. format/1 (duration-only) is unchanged and still available for callers who only have an elapsed duration and no reference datetimes. This is the same class of bug (and the same fix strategy) as two issues found and fixed in Ruby's distance_of_time_in_words gem: - radar/distance_of_time_in_words#63 (dst? flag instead of comparing actual UTC offsets, Europe/Dublin) - radar/distance_of_time_in_words#153 (assuming any offset change is a recurring +/-1 hour DST transition, breaking on Pacific/Norfolk's permanent offset change) Write-up with full technical detail on both original bugs, and cross-language testing across JS, Python, Go, Rust, PHP, C#, Java, and Elixir that led to finding this Timex bug: https://code.dblock.org/2026/08/28/adventures-in-daylight-saving-norfolk-island-and-time-zone-math-in-ruby.html Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Timex.Format.Duration.Formatters.Humanized.format/1only ever receives a raw elapsedDuration(a plain second/microsecond count), so it has no way to know whether the underlying calendar distance between two datetimes is clean. Any UTC offset change spanned by the interval (a DST transition, or a timezone's UTC offset changing permanently) gets misattributed as spurious trailing hours/minutes, because years/months/days are bucketed from fixed 365/30-day constants rather than real calendar arithmetic.For example, formatting the distance between
2015-01-15and2016-03-15inPacific/Norfolk(whose UTC offset permanently changed from +11:30 to +11:00 on 4 October 2015) currently produces:instead of the correct
"1 year, 2 months".Fix
This adds
format/2andlformat/3, which accept both datetimes directly instead of a pre-computedDuration. Years/months are computed viaTimex.diff/3+Timex.shift/2(real calendar arithmetic that already correctly ignores offset noise), and only the true leftover duration is passed to the existing week/day/hour/minute bucketing logic:format/1(duration-only) is unchanged and still available for callers who only have an elapsed duration and no reference datetimes -- there's no way to fix the artifact from a duration alone, since the calendar context is already lost by that point.Tests added in
test/format_duration_humanized_test.exscover both the Norfolk Island offset-change case and aEurope/DublinDST-transition case (which was already correct, and remains correct with the new API).Background
This is the same class of bug (and the same fix strategy) as two issues found and fixed in Ruby's
distance_of_time_in_wordsgem:dst?flag instead of comparing actual UTC offsets, breaking onEurope/Dublin(whose tz database entry is inverted: winter is DST)Pacific/Norfolk's permanent offset changeI wrote up the full technical detail on both original bugs, plus cross-language testing across JS, Python, Go, Rust, PHP, C#, Java, and Elixir that led to finding this bug in Timex specifically, here:
Adventures in Daylight Saving, Norfolk Island, and Time Zone Math (in Ruby)
Reproduction scripts for all the languages/libraries tested (including the ones that were clean) are here: https://github.com/dblock/tz_test
Note: This fix was AI-generated (with human review/testing along the way) as part of an investigation into whether this class of bug shows up in other "humanize a duration" libraries. Please review carefully -- I've tried to validate the approach and tests, but the implementation deserves a critical look before merging, particularly around edge cases in
Timex.shift/ambiguous local times that I may not have fully covered.