Skip to content

Fix Humanized.format/2 crash when finish precedes start - #794

Open
dblock wants to merge 2 commits into
bitwalker:mainfrom
dblock:fix-humanized-format2-negative-order
Open

Fix Humanized.format/2 crash when finish precedes start#794
dblock wants to merge 2 commits into
bitwalker:mainfrom
dblock:fix-humanized-format2-negative-order

Conversation

@dblock

@dblock dblock commented Aug 28, 2026

Copy link
Copy Markdown

Problem

Humanized.format/2 (added in #793, not yet merged) assumed start <= finish and passed the resulting negative :years/:months counts straight into Gettext's plural translation, which raises a FunctionClauseError because it requires n >= 0:

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

Timex.Format.Duration.Formatters.Humanized.format(start, finish)
# ** (FunctionClauseError) no function clause matching in Gettext.dpngettext/7

format/1 has always returned the same output regardless of the sign of the underlying duration (see the existing "format negative duration" test). format/2 should behave the same way when finish is chronologically before start.

Fix

Normalize the order of start/finish (swap them if finish precedes start) before doing the calendar-aware diff/shift arithmetic, matching format/1's sign-independent behavior.

Found this while going back through distance_of_time_in_words's test suite to check whether Timex's new format/2 (from #793) covers the same range of edge cases dotiw does -- it didn't have a reversed-order case.

Note: this is a follow-up to #793 and is based on that branch, since format/2 doesn't exist on main yet.

Note: This fix was AI-generated (with human review/testing along the way). Please review carefully.

dblock and others added 2 commits August 28, 2026 18:41
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>
format/2 (added in bitwalker#793) assumed start <= finish and passed the
resulting negative :years/:months counts straight into Gettext's
plural translation, which raises a FunctionClauseError because it
requires n >= 0.

format/1 has always returned the same output regardless of the sign
of the underlying duration (see the 'format negative duration' test),
so format/2 should behave the same way when finish is chronologically
before start. Fix by normalizing the order (swapping start/finish when
needed) before doing the calendar-aware diff/shift arithmetic.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
dblock added a commit to dblock/tz_test that referenced this pull request Aug 28, 2026
Timex is largely unmaintained (last push mid-2025, 70+ open issues).
humanizer's relative_time/2,3 is clean on both the Norfolk and Dublin
cases, and also handles reversed argument order correctly, unlike
Timex's format/2 (bitwalker/timex#794).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
dblock added a commit to dblock/code.dblock.org that referenced this pull request Aug 28, 2026
Also links the format/2 reversed-argument-order fix (bitwalker/timex#794).

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