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 +