Skip to content

Fix UTC offset change artifact in Humanized duration formatter - #793

Open
dblock wants to merge 1 commit into
bitwalker:mainfrom
dblock:fix-humanized-duration-offset-change-bug
Open

Fix UTC offset change artifact in Humanized duration formatter#793
dblock wants to merge 1 commit into
bitwalker:mainfrom
dblock:fix-humanized-duration-offset-change-bug

Conversation

@dblock

@dblock dblock commented Aug 28, 2026

Copy link
Copy Markdown

Problem

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:

{:ok, start} = DateTime.new(~D[2015-01-15], ~T[00:00:00], "Pacific/Norfolk", Tzdata.TimeZoneDatabase)
{:ok, finish} = DateTime.new(~D[2016-03-15], ~T[00:00:00], "Pacific/Norfolk", Tzdata.TimeZoneDatabase)

Timex.Format.Duration.Formatters.Humanized.format(Timex.Duration.from_seconds(DateTime.diff(finish, start)))
# => "1 year, 2 months, 30 minutes"

instead of the correct "1 year, 2 months".

Fix

This adds format/2 and lformat/3, which accept both datetimes directly instead of a pre-computed Duration. 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:

Timex.Format.Duration.Formatters.Humanized.format(start, finish)
# => "1 year, 2 months"

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.exs cover both the Norfolk Island offset-change case and a Europe/Dublin DST-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_words gem:

  • #63 -- used the dst? flag instead of comparing actual UTC offsets, breaking on Europe/Dublin (whose tz database entry is inverted: winter is DST)
  • #153 -- assumed any offset change is a recurring +/-1 hour DST transition, breaking on Pacific/Norfolk's permanent offset change

I 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.

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

1 participant