lib/periods.ex

defmodule Periods do
  @moduledoc """
  Periods increments of time.

  Periods are designed to represent fractions of time from milliseconds to decades
  in order to help make working with various time based structs easier.

  Periods can be converted, added, and subtracted.

  By default the base value is a `second` you can choose a different base for your
  application by setting the `:default_value` in you Application config.

  The only limitation are months. Due to the inconsistency of a period of a month from 28-31
  days conversion and mathematical operations are limited.
  """

  alias Periods.Computation
  alias Periods.Conversion
  alias Periods.Conversion.ConversionError
  alias Periods.Formatter
  alias Periods.Period
  alias Periods.Parser
  alias Periods.Parser.ParserError

  @type amount :: integer() | String.t()
  @type computation_type :: Period.t() | Time.t() | Date.t() | DateTime.t() | NaiveDateTime.t()
  @type difference_type :: Time.t() | Date.t() | DateTime.t() | NaiveDateTime.t()
  @type error_type :: {:error, atom()} | {:error, {atom(), atom()}}
  @type parse_type ::
          %{amount: amount(), unit: unit()}
          | {amount(), unit()}
          | amount()
  @type unit :: atom() | String.t()

  @units [:millisecond, :second, :minute, :hour, :day, :week, :month, :year, :decade]

  @doc """
  Add a Period to a Period, Time, Date, DateTime, or NaiveDateTime.

  Note: `Months` have limited additive properties

  When performing addition of periods all values will be converted to the lowest
  unit value. For example if you add a Period of days to a Period of seconds then
  the result will be seconds.

  When adding a period to an Elixir date/time struct the return will be the Elixir
  date/time struct.

  Note: Since there is conversion involved you may loose fractional values such as
  adding 1 millisecond to a Date.

  ## Examples

      iex> Periods.add(%Period{amount: 10, unit: :day}, %Period{amount: 1000, unit: :second})
      %Period(amount: 865000, unit: :second)

      iex> DateTime.utc_now() |> Periods.add(%Period{amount: 1000, unit: :second})
      ~U[2023-07-09 14:14:50.896089Z]

      iex> Time.utc_now() |> Periods.add(%Period{amount: 1000, unit: :second})
      ~T[14:17:04.932992]

      iex> today = Date.utc_today()
      ~D[2023-07-09]
      iex> Periods.add(today, %Period{amount: 1, unit: :millisecond})
      ~D[2023-07-09]
  """
  @spec add(computation_type(), computation_type()) :: computation_type() | error_type()
  defdelegate add(value_1, value_2), to: Computation

  @doc """
  Returns a list of all the current values Periods supports

  ## Examples

      iex> Periods.all_units()
      [:millisecond, :second, :minute, :hour, :day, :week, :month, :year, :decade]
  """
  @spec all_units() :: list(atom())
  def all_units, do: @units

  @doc """
  Convert a Period from one unit to another unit.

  Note: `Months` have limited conversion properties

  When converting from a lower unit such as milliseconds to a higher unit such as seconds,
  since all return values are integers, you will loose precision due to rounding. In some
  conversion scenarios you may get a 0 value.

  ## Examples

      iex> Periods.convert(%Period{amount: 10, unit: :day}, :second)
      %Periods.Period{amount: 864000, unit: :second}

      iex> Periods.convert(%Period{amount: 864000, unit: :second}, :day)
      %Period{amount: 10, unit: :day}

      iex> Periods.convert(%Period{amount: 10, unit: :year}, :month)
      %Periods.Period{amount: 120, unit: :month}

      iex> Periods.convert(%Period{amount: 10, unit: :second}, :week)
      %Periods.Period{amount: 0, unit: :week}

      iex> Periods.convert(%Period{amount: 1000, unit: :second}, :month)
      {:error, {:cannot_convert_to_month, :month}}
  """
  @spec convert(Period.t(), atom() | String.t()) :: Period.t() | error_type()
  defdelegate convert(period, unit), to: Conversion

  @doc """
  Converts and returns a %Period{} or raise error.

  Same as `convert/2` excepts raises a `%ConversionError{}` when unsuccessful

  ## Examples

      iex> Periods.convert!(%Period{amount: 10, unit: :day}, :second)
      %Periods.Period{amount: 864000, unit: :second}

      iex> Periods.convert!(%Period{amount: 1000, unit: :second}, :month)
      ** (Periods.Conversion.ConversionError) unit: bad type
  """
  @spec convert!(Period.t(), atom() | String.t()) :: Period.t() | ConversionError
  defdelegate convert!(period, unit), to: Conversion

  @doc """
  Get the difference of Time, Date, DateTime, NaiveDateTime in a Period.

  Optional add unit for conversion. Some units will loose precision because fractional
  units are not supported. For example converting milliseconds to days will round to
  integer day.

  Default conversion units will be dependant upon the struct.
  - [Time](https://hexdocs.pm/elixir/Time.html) defaults to `:second`
  - [Date](https://hexdocs.pm/elixir/Date.html) defaults to `:day`
  - [DateTIme](https://hexdocs.pm/elixir/DateTime.html) defaults to `:second`
  - [NaiveDateTime](https://hexdocs.pm/elixir/NaiveDateTime.html) defaults to `:second`

  When getting the difference between two DateTimes with different timezones `diff/3`
  will convert both timezones to UTC then calculate the difference.

  For DateTime currently using the [Tz](https://hex.pm/packages/tz) hex package to
  help adjust timezones.

  **In order to use Timezone conversion** please add to your application config:
  ```elixir
  config :elixir, :time_zone_database, Tz.TimeZoneDatabase
  ```

  ## Examples

      iex> Periods.diff(~T[21:30:43], ~T[13:50:17])
      {:ok, %Periods.Period{amount: 27626, unit: :second}}

      iex> Periods.diff(~D[2000-03-15], ~D[2000-01-01])
      {:ok, %Periods.Period{amount: 74, unit: :day}}

      iex> Periods.diff(~D[2000-03-15], ~D[2000-01-01], :second)
      {:ok, %Periods.Period{amount: 6393600, unit: :second}}

      iex> Periods.diff(#DateTime<2023-05-21 18:23:45.023+09:00 JST Asia/Tokyo>, #DateTime<2023-04-13 13:50:07.003+07:00 +07 Asia/Bangkok>)
      {:ok, %Periods.Period{amount: 3292418, unit: :second}}

  """
  @spec diff(difference_type(), difference_type(), unit()) :: {:ok, Period.t()} | error_type()
  defdelegate diff(difference_type_1, difference_type2, unit \\ nil), to: Computation

  @doc """
  Returns the value set by your Application environment or the default :second

  ## Examples

      iex> Periods.default_unit()
      :second
  """
  @spec default_unit() :: atom()
  def default_unit, do: Application.get_env(Periods, :default_unit, :second)

  @doc """
  Create a new Period through a variety of input types.

  ## Examples

      iex> Periods.new(%{amount: 100, unit: :second})
      {:ok, %Periods.Period{amount: 100, unit: :second}}

      iex> Periods.new(%{amount: "100", unit: "second"})
      {:ok, %Periods.Period{amount: 100, unit: :second}}

      iex> Periods.new({100, "second"})
      {:ok, %Periods.Period{amount: 100, unit: :second}}

      iex> Periods.new("100")
      {:ok, %Periods.Period{amount: 100, unit: :second}}
  """
  @spec new(parse_type()) :: {:ok, Period.t()} | error_type()
  defdelegate new(value), to: Parser

  @doc """
  Creates a new Period or raises an error.

  Same as `new/1` excepts raises a `%ParserError{}` when unsuccessful

  ## Examples

      iex> Periods.new!({100, "second"})
      %Periods.Period{amount: 100, unit: :second}

      iex> Periods.new!(100, :fort_nights)
      ** (Periods.Conversion.ConversionError) unit: bad type
  """
  @spec new!(parse_type()) :: Period.t() | ParserError
  defdelegate new!(value), to: Parser

  @doc """
  Creates a new Period.

  ## Examples

      iex> Periods.new(100, :second)
      {:ok, %Periods.Period{amount: 100, unit: :second}}

      iex> Periods.new("100", "second")
      {:ok, %Periods.Period{amount: 100, unit: :second}}
  """
  @spec new(amount(), unit()) :: {:ok, Period.t()} | error_type()
  defdelegate new(amount, unit), to: Parser

  @doc """
  Subtract a Period from a Time, Date, DateTime, or NaiveDateTime.

  Note: `Months` have limited subtracting properties

  When performing subtraction of periods all values will be converted to the lowest
  unit value. For example if you subtract a Period of days from a Period of seconds then
  the result will be seconds.

  When subtracting a period from an Elixir date/time struct the return will be the Elixir
  date/time struct.

  Note: Since there is conversion involved you may loose fractional values such as
  subtracting 1 millisecond from a Date.

  ## Examples

      iex> Periods.subtract(%Period{amount: 10, unit: :day}, %Period{amount: 1000, unit: :second})
      %Periods.Period{amount: 863000, unit: :second}

      iex> DateTime.utc_now() |> Periods.subtract(%Period{amount: 1000, unit: :second})
      ~U[2023-07-09 14:08:32.916532Z]

      iex> Time.utc_now() |> Periods.subtract(%Period{amount: 1000, unit: :second})
      ~T[14:08:45.831176]

      iex> today = Date.utc_today()
      ~D[2023-07-10]
      iex> Periods.subtract(today, %Period{amount: 10000000000000, unit: :millisecond})
      ~D[1706-08-21]
      iex> Periods.subtract(today, %Period{amount: 3, unit: :year})
      ~D[2020-07-10]
  """
  @spec subtract(computation_type(), computation_type()) :: computation_type() | error_type()
  defdelegate subtract(value_1, value_2), to: Computation

  @doc """
  Output a Period as an Integer with optional conversion unit.

  ## Examples

      iex> Periods.to_integer(%Period{amount: 10, unit: :day}, :second)
      864000

      iex> Periods.to_integer(%Period{amount: 10, unit: :day})
      10

      iex> Periods.to_integer(%Period{amount: 1000, unit: :second}, :month)
      {:error, {:cannot_convert_to_month, :second}}
  """
  @spec to_integer(Period.t(), unit() | nil) :: integer() | error_type()
  defdelegate to_integer(period, convert_unit \\ nil), to: Formatter

  @doc """
  Output a Period as a String with optional conversion unit.

  ## Examples

      iex> Periods.to_string(%Period{amount: 10, unit: :day}, :second)
      "864000 seconds"

      iex> Periods.to_string(%Period{amount: 10, unit: :day})
      "10 days"

      iex> Periods.to_string(%Period{amount: 1, unit: :day})
      "1 day"

      iex> Periods.to_string(%Period{amount: 1000, unit: :second}, :month)
      {:error, {:cannot_convert_to_month, :second}}
  """
  @spec to_string(Period.t(), unit() | nil) :: String.t() | error_type()
  defdelegate to_string(period, convert_unit \\ nil), to: Formatter
end