lib/format/duration/formatters/humanized.ex

defmodule Timex.Format.Duration.Formatters.Humanized do
  @moduledoc """
  Handles formatting timestamp values as human readable strings.
  For formatting timestamps as points in time rather than intervals,
  use `Timex.format`
  """
  use Timex.Format.Duration.Formatter
  alias Timex.Translator

  @minute 60
  @hour @minute * 60
  @day @hour * 24
  @week @day * 7
  @month @day * 30
  @year @day * 365

  @microsecond 1_000_000

  @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

      iex> use Timex
      ...> Duration.from_erl({0, 1, 1_000_000}) |> #{__MODULE__}.format
      "2 seconds"

      iex> use Timex
      ...> Duration.from_erl({0, 1, 1_000_100}) |> #{__MODULE__}.format
      "2 seconds, 100 microseconds"

      iex> use Timex
      ...> Duration.from_erl({0, 65, 0}) |> #{__MODULE__}.format
      "1 minute, 5 seconds"

      iex> use Timex
      ...> Duration.from_erl({0, -65, 0}) |> #{__MODULE__}.format
      "1 minute, 5 seconds"

      iex> use Timex
      ...> Duration.from_erl({1435, 180354, 590264}) |> #{__MODULE__}.format
      "45 years, 6 months, 5 days, 21 hours, 12 minutes, 34 seconds, 590.264 milliseconds"

  """
  @spec format(Duration.t()) :: String.t() | {:error, term}
  def format(%Duration{} = duration), do: lformat(duration, Translator.current_locale())
  def format(_), do: {:error, :invalid_duration}

  @doc """
  Return a human readable string representing the time interval, translated to the given locale

  ## Examples

      iex> use Timex
      ...> Duration.from_erl({0, 65, 0}) |> #{__MODULE__}.lformat("ru")
      "1 минута, 5 секунд"

      iex> use Timex
      ...> Duration.from_erl({1435, 180354, 590264}) |> #{__MODULE__}.lformat("ru")
      "45 лет, 6 месяцев, 5 дней, 21 час, 12 минут, 34 секунды, 590.264 миллисекунд"

  """
  @spec lformat(Duration.t(), String.t()) :: String.t() | {:error, term}
  def lformat(%Duration{} = duration, locale) do
    duration
    |> deconstruct
    |> do_format(locale)
  end

  def lformat(_, _locale), do: {:error, :invalid_duration}

  defp do_format(components, locale),
    do: do_format(components, <<>>, locale)

  defp do_format([], str, _locale),
    do: str

  defp do_format([{unit, value} | rest], str, locale) do
    unit = Atom.to_string(unit)
    count = trunc(value)

    unit_with_value =
      Translator.translate_plural(locale, "units", "%{count} #{unit}", "%{count} #{unit}s", count)
      |> String.replace(to_string(count), to_string(value))

    separator = Translator.translate(locale, "symbols", ",")

    case str do
      <<>> -> do_format(rest, "#{unit_with_value}", locale)
      _ -> do_format(rest, str <> "#{separator} #{unit_with_value}", locale)
    end
  end

  defp deconstruct(duration) do
    micros = Duration.to_microseconds(duration) |> abs
    deconstruct({div(micros, @microsecond), rem(micros, @microsecond)}, [])
  end

  defp deconstruct({0, 0}, []),
    do: deconstruct({0, 0}, microsecond: 0)

  defp deconstruct({0, 0}, components),
    do: Enum.reverse(components)

  defp deconstruct({seconds, us}, components) when seconds > 0 do
    cond do
      seconds >= @year ->
        deconstruct({rem(seconds, @year), us}, [{:year, div(seconds, @year)} | components])

      seconds >= @month ->
        deconstruct({rem(seconds, @month), us}, [{:month, div(seconds, @month)} | components])

      seconds >= @week ->
        deconstruct({rem(seconds, @week), us}, [{:week, div(seconds, @week)} | components])

      seconds >= @day ->
        deconstruct({rem(seconds, @day), us}, [{:day, div(seconds, @day)} | components])

      seconds >= @hour ->
        deconstruct({rem(seconds, @hour), us}, [{:hour, div(seconds, @hour)} | components])

      seconds >= @minute ->
        deconstruct({rem(seconds, @minute), us}, [{:minute, div(seconds, @minute)} | components])

      true ->
        deconstruct({0, us}, [{:second, seconds} | components])
    end
  end

  defp deconstruct({0, micro}, components) do
    millis =
      micro
      |> Duration.from_microseconds()
      |> Duration.to_milliseconds()

    cond do
      millis >= 1 -> deconstruct({0, 0}, [{:millisecond, millis} | components])
      true -> deconstruct({0, 0}, [{:microsecond, micro} | components])
    end
  end
end