lib/format/duration/formatters/default.ex

defmodule Timex.Format.Duration.Formatters.Default do
  @moduledoc """
  Handles formatting Duration values as ISO 8601 durations as described below.

  Durations are represented by the format P[n]Y[n]M[n]DT[n]H[n]M[n]S.
  In this representation, the [n] is replaced by the value for each of the
  date and time elements that follow the [n]. Leading zeros are not required,
  but the maximum number of digits for each element should be agreed to by the
  communicating parties. The capital letters P, Y, M, W, D, T, H, M, and S are
  designators for each of the date and time elements and are not replaced.

  - P is the duration designator (historically called "period") placed at the start of the duration representation.
  - Y is the year designator that follows the value for the number of years.
  - M is the month designator that follows the value for the number of months.
  - D is the day designator that follows the value for the number of days.
  - T is the time designator that precedes the time components of the representation.
  - H is the hour designator that follows the value for the number of hours.
  - M is the minute designator that follows the value for the number of minutes.
  - S is the second designator that follows the value for the number of seconds.
  """
  use Timex.Format.Duration.Formatter
  alias Timex.Translator

  @minute 60
  @hour @minute * 60
  @day @hour * 24
  @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
      "PT2S"

      iex> use Timex
      ...> Duration.from_erl({0, 1, 1_000_100}) |> #{__MODULE__}.format
      "PT2.0001S"

      iex> use Timex
      ...> Duration.from_erl({0, 65, 0}) |> #{__MODULE__}.format
      "PT1M5S"

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

      iex> use Timex
      ...> Duration.from_erl({1435, 180354, 590264}) |> #{__MODULE__}.format
      "P45Y6M5DT21H12M34.590264S"

      iex> use Timex
      ...> Duration.from_erl({0, 0, 0}) |> #{__MODULE__}.format
      "PT0S"

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

  def lformat(%Duration{} = duration, _locale) do
    duration
    |> deconstruct
    |> do_format
  end

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

  defp do_format(components), do: do_format(components, <<?P>>)
  defp do_format([], "P"), do: "PT0S"
  defp do_format([], str), do: str

  defp do_format([{unit, _} = component | rest], str) do
    cond do
      unit in [:hours, :minutes, :seconds] && String.contains?(str, "T") ->
        do_format(rest, format_component(component, str))

      unit in [:hours, :minutes, :seconds] ->
        do_format(rest, format_component(component, str <> "T"))

      true ->
        do_format(rest, format_component(component, str))
    end
  end

  defp format_component({_, 0}, str), do: str
  defp format_component({:years, y}, str), do: str <> "#{y}Y"
  defp format_component({:months, m}, str), do: str <> "#{m}M"
  defp format_component({:days, d}, str), do: str <> "#{d}D"
  defp format_component({:hours, h}, str), do: str <> "#{h}H"
  defp format_component({:minutes, m}, str), do: str <> "#{m}M"
  defp format_component({:seconds, s}, str), do: str <> "#{s}S"

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

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

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

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

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

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

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

      true ->
        get_fractional_seconds(seconds, us, components)
    end
  end

  defp get_fractional_seconds(seconds, 0, components),
    do: deconstruct({0, 0}, [{:seconds, seconds} | components])

  defp get_fractional_seconds(seconds, micro, components) do
    millis =
      micro
      |> Duration.from_microseconds()
      |> Duration.to_milliseconds()

    cond do
      millis >= 1.0 ->
        deconstruct({0, 0}, [{:seconds, seconds + millis * :math.pow(10, -3)} | components])

      true ->
        deconstruct({0, 0}, [{:seconds, seconds + micro * :math.pow(10, -6)} | components])
    end
  end
end