From 876e28d9fc92529b4d8b6db3e0e8efa0013c7591 Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Fri, 28 Aug 2026 18:41:57 -0400 Subject: [PATCH] Add calendar-aware Humanized.format/2 to fix UTC offset change artifact 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: - https://github.com/radar/distance_of_time_in_words/issues/63 (dst? flag instead of comparing actual UTC offsets, Europe/Dublin) - https://github.com/radar/distance_of_time_in_words/issues/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> --- lib/format/duration/formatters/humanized.ex | 75 +++++++++++++++++++++ test/format_duration_humanized_test.exs | 31 +++++++++ 2 files changed, 106 insertions(+) diff --git a/lib/format/duration/formatters/humanized.ex b/lib/format/duration/formatters/humanized.ex index 0ea172e8..ee7b3e12 100644 --- a/lib/format/duration/formatters/humanized.ex +++ b/lib/format/duration/formatters/humanized.ex @@ -6,6 +6,7 @@ defmodule Timex.Format.Duration.Formatters.Humanized do """ use Timex.Format.Duration.Formatter alias Timex.Translator + alias Timex.Types @minute 60 @hour @minute * 60 @@ -18,6 +19,7 @@ defmodule Timex.Format.Duration.Formatters.Humanized do @doc """ Return a human readable string representing the absolute value of duration (i.e. would + return the same output for both negative and positive representations of a given duration) ## Examples @@ -47,6 +49,31 @@ defmodule Timex.Format.Duration.Formatters.Humanized do def format(%Duration{} = duration), do: lformat(duration, Translator.current_locale()) def format(_), do: {:error, :invalid_duration} + @doc """ + Return a human readable string representing the calendar distance between two + datetimes, computing years/months from actual calendar arithmetic rather than + fixed 365/30-day buckets. + + Naively bucketing the raw elapsed duration (as `format/1` does) misattributes + any UTC offset change that occurs between `start` and `finish` (a DST + transition, or a timezone's UTC offset changing permanently, as happened for + `Pacific/Norfolk` on October 4, 2015) as spurious trailing hours/minutes. This + function avoids that by first advancing `start` by the exact number of + calendar years and months, and only converting the true remainder to a + duration. + + ## Examples + + iex> use Timex + ...> start = Timex.to_datetime({{2015, 1, 15}, {0, 0, 0}}, "Pacific/Norfolk") + ...> finish = Timex.to_datetime({{2016, 3, 15}, {0, 0, 0}}, "Pacific/Norfolk") + ...> #{__MODULE__}.format(start, finish) + "1 year, 2 months" + + """ + @spec format(Types.valid_datetime(), Types.valid_datetime()) :: String.t() | {:error, term} + def format(start, finish), do: lformat(start, finish, Translator.current_locale()) + @doc """ Return a human readable string representing the time interval, translated to the given locale @@ -70,6 +97,54 @@ defmodule Timex.Format.Duration.Formatters.Humanized do def lformat(_, _locale), do: {:error, :invalid_duration} + @doc """ + Same as `format/2`, except it also accepts a locale to translate to. + + ## Examples + + iex> use Timex + ...> start = Timex.to_datetime({{2015, 1, 15}, {0, 0, 0}}, "Pacific/Norfolk") + ...> finish = Timex.to_datetime({{2016, 3, 15}, {0, 0, 0}}, "Pacific/Norfolk") + ...> #{__MODULE__}.lformat(start, finish, "ru") + "1 год, 2 месяца" + + """ + @spec lformat(Types.valid_datetime(), Types.valid_datetime(), String.t()) :: + String.t() | {:error, term} + def lformat(start, finish, locale) do + with {:ok, years} <- to_int(Timex.diff(finish, start, :years)), + {:ok, after_years} <- shifted(start, years: years), + {:ok, months} <- to_int(Timex.diff(finish, after_years, :months)), + {:ok, after_months} <- shifted(after_years, months: months), + {:ok, remainder_us} <- to_int(Timex.diff(finish, after_months, :microseconds)) do + calendar_components = for {unit, count} <- [year: years, month: months], count != 0, do: {unit, count} + + remainder_components = + Duration.from_microseconds(remainder_us) + |> deconstruct() + |> Enum.reject(fn {_unit, count} -> count == 0 end) + + case calendar_components ++ remainder_components do + [] -> do_format([{:microsecond, 0}], locale) + components -> do_format(components, locale) + end + else + {:error, _} = err -> err + end + end + + defp to_int(n) when is_integer(n), do: {:ok, n} + defp to_int({:error, _} = err), do: err + + defp shifted(datetime, [{_unit, 0}]), do: {:ok, datetime} + + defp shifted(datetime, opts) do + case Timex.shift(datetime, opts) do + {:error, _} = err -> err + shifted -> {:ok, shifted} + end + end + defp do_format(components, locale), do: do_format(components, <<>>, locale) diff --git a/test/format_duration_humanized_test.exs b/test/format_duration_humanized_test.exs index 44c164a7..e31ac246 100644 --- a/test/format_duration_humanized_test.exs +++ b/test/format_duration_humanized_test.exs @@ -22,4 +22,35 @@ defmodule DurationFormatHumanizedTest do test "format zero duration" do assert "0 microseconds" = format(0, 0, 0) end + + test "format/2 across Pacific/Norfolk permanent UTC offset change" do + # Pacific/Norfolk permanently changed its UTC offset from +11:30 to +11:00 + # on 4 October 2015 (a one-time tzdata rule change, not a recurring DST + # transition). format/1, given only a raw elapsed Duration, has no way to + # know the true offset delta and misattributes it as spurious trailing + # hours/minutes (see format_duration_humanized_bug_test.exs). format/2 + # takes both datetimes and computes years/months via real calendar + # arithmetic, so the distance comes out clean. + {: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) + + assert "1 year, 2 months" = Formatters.Humanized.format(start, finish) + end + + test "format/2 across Europe/Dublin DST transition" do + # 2024-10-27 00:59:30 UTC is 01:59:30 IST in Dublin, one second before the + # "fall back" transition to GMT. + {:ok, dstart_utc} = DateTime.new(~D[2024-10-27], ~T[00:59:30], "Etc/UTC") + {:ok, dstart} = DateTime.shift_zone(dstart_utc, "Europe/Dublin", Tzdata.TimeZoneDatabase) + dfinish_utc = DateTime.add(dstart_utc, 60, :second) + {:ok, dfinish} = DateTime.shift_zone(dfinish_utc, "Europe/Dublin", Tzdata.TimeZoneDatabase) + + assert dstart.zone_abbr == "IST" + assert dfinish.zone_abbr == "GMT" + assert "1 minute" = Formatters.Humanized.format(dstart, dfinish) + end end +