Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions lib/format/duration/formatters/humanized.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -70,6 +97,64 @@ 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
case Timex.compare(start, finish) do
1 -> lformat(finish, start, locale)
_ -> do_lformat(start, finish, locale)
end
end

# Like `format/1`, this returns the same output regardless of which of the
# two datetimes is earlier -- `lformat/3` normalizes the (start, finish)
# order before calling this, so `start` is always <= `finish` here.
defp do_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)

Expand Down
47 changes: 47 additions & 0 deletions test/format_duration_humanized_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,51 @@ 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

test "format/2 is order-independent, like format/1" do
# format/1 always returns the same string for a duration regardless of
# sign (see "format negative duration" above). format/2 should behave the
# same way regardless of whether `finish` is chronologically before
# `start` -- previously this raised, because a negative :years/:months
# count was passed straight into Gettext's plural translation, which
# requires n >= 0.
{: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(finish, start)
end
end