lib/tox/date.ex

defmodule Tox.Date do
  @moduledoc """
  A set of functions to work with `Date`.
  """

  @doc """
  Shifts the `date` by the given `duration`.


  The `durations` is a keyword list of one or more durations of the type
  `Tox.duration` e.g. `[year: 1, month: 5, day: 500]`. All values will be
  shifted from the largest to the smallest unit.

  ## Examples

      iex> date = ~D[1980-11-01]
      iex> Tox.Date.shift(date, year: 2)
      ~D[1982-11-01]
      iex> Tox.Date.shift(date, year: -2, month: 1, day: 40)
      ~D[1979-01-10]
      # time units will be ignored
      iex> Tox.Date.shift(date, hour: 100, minute: 10, second: 10)
      ~D[1980-11-01]

  Adding a month at the end of the month can update the day too.

      iex> Tox.Date.shift(~D[2000-01-31], month: 1)
      ~D[2000-02-29]

  For that reason it is important to know that all values will be shifted from the
  largest to the smallest unit.

      iex> date = ~D[2000-01-30]
      iex> Tox.Date.shift(date, month: 1, day: 1)
      ~D[2000-03-01]
      iex> date |> Tox.Date.shift(month: 1) |> Tox.Date.shift(day: 1)
      ~D[2000-03-01]
      iex> date |> Tox.Date.shift(day: 1) |> Tox.Date.shift(month: 1)
      ~D[2000-02-29]

  Using `shift/2` with a different calendar.

      iex> ~D[2000-12-30]
      ...> |> Date.convert!(Cldr.Calendar.Coptic)
      ...> |> Tox.Date.shift(day: 3)
      %Date{year: 1717, month: 4, day: 24, calendar: Cldr.Calendar.Coptic}

  """
  @spec shift(Calendar.date(), [Tox.duration()]) :: Date.t()
  def shift(date, durations) do
    date
    |> shift_years(Keyword.get(durations, :year, 0))
    |> shift_months(Keyword.get(durations, :month, 0))
    |> Date.add(
      Keyword.get(durations, :day, 0) + Keyword.get(durations, :week, 0) * Tox.days_per_week()
    )
  end

  @doc """
  Returns an `{year, week}` representing the ISO week number for the specified
  date.

  This function is just defined for dates with `Calendar.ISO`.

  ## Example

      iex> Tox.Date.week(~D[2017-01-01])
      {2016, 52}
      iex> Tox.Date.week(~D[2020-01-01])
      {2020, 1}
      iex> Tox.Date.week(~D[2019-12-31])
      {2020, 1}
      iex> ~D[2020-06-04]
      ...> |> Date.convert(Cldr.Calendar.Ethiopic)
      ...> |> Tox.Date.week()
      ** (FunctionClauseError) no function clause matching in Tox.Date.week/1

  """
  @spec week(Calendar.date()) :: {Calendar.year(), non_neg_integer}
  def week(%{calendar: Calendar.ISO} = date), do: Tox.week(date)

  @doc """
  Returns true if `date1` occurs after `date2`.

  ## Examples

      iex> Tox.Date.after?(~D[2020-06-14], ~D[2020-06-22])
      false

      iex> Tox.Date.after?(~D[2020-07-14], ~D[2020-06-22])
      true

      iex> Tox.Date.after?(~D[2020-01-01], ~D[2020-01-01])
      false

      iex> Tox.Date.after?(
      ...>   Date.convert!(~D[2000-01-22], Cldr.Calendar.Coptic),
      ...>   Date.convert!(~D[2000-01-01], Cldr.Calendar.Coptic)
      ...> )
      true

  """
  defmacro after?(date1, date2) do
    quote do
      Date.compare(unquote(date1), unquote(date2)) == :gt
    end
  end

  @doc """
  Returns true if `date1` occurs after `date2` or both dates are equal.

  ## Examples

      iex> Tox.Date.after_or_equal?(~D[2020-06-14], ~D[2020-06-22])
      false

      iex> Tox.Date.after_or_equal?(~D[2020-07-14], ~D[2020-06-22])
      true

      iex> Tox.Date.after_or_equal?(~D[2020-01-01], ~D[2020-01-01])
      true

      iex> Tox.Date.after_or_equal?(
      ...>   Date.convert!(~D[2000-01-22], Cldr.Calendar.Ethiopic),
      ...>   Date.convert!(~D[2000-01-01], Cldr.Calendar.Ethiopic)
      ...> )
      true

  """
  defmacro after_or_equal?(date1, date2) do
    quote do
      Date.compare(unquote(date1), unquote(date2)) in [:gt, :eq]
    end
  end

  @doc """
  Returns true if both datets are equal.

  ## Examples

      iex> Tox.Date.equal?(~D[2020-07-14], ~D[2020-06-22])
      false

      iex> Tox.Date.equal?(~D[2020-01-01], ~D[2020-01-01])
      true

      iex> ethiopic = Date.convert!(~D[2000-01-01], Cldr.Calendar.Ethiopic)
      %Date{year: 1992, month: 4, day: 22, calendar: Cldr.Calendar.Ethiopic}
      iex> coptic = Date.convert!(~D[2000-01-01], Cldr.Calendar.Coptic)
      %Date{year: 1716, month: 4, day: 22, calendar: Cldr.Calendar.Coptic}
      iex> Tox.Date.equal?(ethiopic, coptic)
      true

  """
  defmacro equal?(date1, date2) do
    quote do
      Date.compare(unquote(date1), unquote(date2)) == :eq
    end
  end

  @doc """
  Returns true if `date1` occurs before `date2`.

  ## Examples

      iex> Tox.Date.before?(~D[2020-06-14], ~D[2020-06-22])
      true

      iex> Tox.Date.before?(~D[2020-07-14], ~D[2020-06-22])
      false

      iex> Tox.Date.before?(~D[2020-01-01], ~D[2020-01-01])
      false

      iex> Tox.Date.before?(
      ...>   Date.convert!(~D[2000-01-22], Cldr.Calendar.Ethiopic),
      ...>   Date.convert!(~D[2000-06-01], Cldr.Calendar.Ethiopic)
      ...> )
      true

  """
  defmacro before?(date1, date2) do
    quote do
      Date.compare(unquote(date1), unquote(date2)) == :lt
    end
  end

  @doc """
  Returns true if `date1` occurs before `date2` or both dates are equal.

  ## Examples

      iex> Tox.Date.before_or_equal?(~D[2020-06-14], ~D[2020-06-22])
      true

      iex> Tox.Date.before_or_equal?(~D[2020-07-14], ~D[2020-06-22])
      false

      iex> Tox.Date.before_or_equal?(~D[2020-01-01], ~D[2020-01-01])
      true

      iex> Tox.Date.before_or_equal?(
      ...>   Date.convert!(~D[2000-01-22], Cldr.Calendar.Ethiopic),
      ...>   Date.convert!(~D[2000-06-01], Cldr.Calendar.Ethiopic)
      ...> )
      true

  """
  defmacro before_or_equal?(date1, date2) do
    quote do
      Date.compare(unquote(date1), unquote(date2)) in [:lt, :eq]
    end
  end

  @doc """
  Returns a boolean indicating whether `date` occurs between `from` and `to`.
  The optional `boundaries` specifies whether `from` and `to` are included or
  not. The possible value for `boundaries` are:

  * `:open`: `from` and `to` are excluded
  * `:closed`: `from` and `to` are included
  * `:left_open`: `from` is excluded and `to` is included
  * `:right_open`: `from` is included and `to` is excluded

  ## Examples

      iex> from = ~D[2020-02-01]
      iex> to   = ~D[2020-03-01]
      iex> Tox.Date.between?(~D[2020-01-01], from, to)
      false
      iex> Tox.Date.between?(~D[2020-02-05], from, to)
      true
      iex> Tox.Date.between?(~D[2020-03-05], from, to)
      false
      iex> Tox.Date.between?(~D[2020-02-01], from, to)
      true
      iex> Tox.Date.between?(~D[2020-03-01], from, to)
      false
      iex> Tox.Date.between?(~D[2020-02-01], from, to, :open)
      false
      iex> Tox.Date.between?(~D[2020-03-01], from, to, :open)
      false
      iex> Tox.Date.between?(~D[2020-02-01], from, to, :closed)
      true
      iex> Tox.Date.between?(~D[2020-03-01], from, to, :closed)
      true
      iex> Tox.Date.between?(~D[2020-02-01], from, to, :left_open)
      false
      iex> Tox.Date.between?(~D[2020-03-01], from, to, :left_open)
      true
      iex> Tox.Date.between?(~D[2000-01-01], to, from)
      ** (ArgumentError) from is equal or greater as to

  """
  @spec between?(Calendar.date(), Calendar.date(), Calendar.date(), Tox.boundaries()) ::
          boolean()
  def between?(date, from, to, boundaries \\ :right_open)
      when boundaries in [:closed, :left_open, :right_open, :open] do
    if Date.compare(from, to) in [:gt, :eq],
      do: raise(ArgumentError, "from is equal or greater as to")

    case {Date.compare(date, from), Date.compare(date, to), boundaries} do
      {:lt, _, _} -> false
      {_, :gt, _} -> false
      {:eq, _, :closed} -> true
      {:eq, _, :right_open} -> true
      {_, :eq, :closed} -> true
      {_, :eq, :left_open} -> true
      {:gt, :lt, _} -> true
      {_, _, _} -> false
    end
  end

  @doc """
  Returns a date representing the start of the year.

  ## Examples

      iex> Tox.Date.beginning_of_year(~D[2020-11-11])
      ~D[2020-01-01]

  """
  @spec beginning_of_year(Calendar.date()) :: Calendar.date()
  def beginning_of_year(date), do: %{date | month: 1, day: 1}

  @doc """
  Returns a date representing the start of the month.

  ## Examples

      iex> Tox.Date.beginning_of_month(~D[2020-11-11])
      ~D[2020-11-01]

  """
  @spec beginning_of_month(Calendar.date()) :: Calendar.date()
  def beginning_of_month(date), do: %{date | day: 1}

  @doc """
  Returns a date representing the start of the week.

  ## Examples

      iex> Tox.Date.beginning_of_week(~D[2020-11-13])
      ~D[2020-11-09]

  """
  @spec beginning_of_week(Calendar.date()) :: Calendar.date()
  def beginning_of_week(date) do
    shift(date, day: Tox.Calendar.beginning_of_week(date))
  end

  @doc """
  Returns a date representing the end of the year.
  If the date cannot be determined, `{:error, reason}` is returned.

  ## Examples

      iex> Tox.Date.end_of_year(~D[2020-11-11])
      ~D[2020-12-31]

      iex> ~D[2020-11-11]
      iex> |> Date.convert!(Cldr.Calendar.Coptic)
      iex> |> Tox.Date.end_of_year()
      %Date{year: 1737, month: 13, day: 5, calendar: Cldr.Calendar.Coptic}

  """
  @spec end_of_year(Calendar.date()) :: Calendar.date()
  def end_of_year(%{calendar: calendar, year: year} = date) do
    month = calendar.months_in_year(year)
    day = calendar.days_in_month(year, month)
    %{date | month: month, day: day}
  end

  @doc """
  Returns a date representing the end of the month.

  ## Examples

      iex> Tox.Date.end_of_month(~D[2020-11-11])
      ~D[2020-11-30]

      iex> ~D[2020-12-31]
      ...> |> Date.convert!(Cldr.Calendar.Coptic)
      ...> |> Tox.Date.shift(day: 1)
      ...> |> Tox.Date.end_of_month()
      %Date{year: 1737, month: 4, day: 30, calendar: Cldr.Calendar.Coptic}

  """
  @spec end_of_month(Calendar.date()) :: Calendar.date()
  def end_of_month(%{calendar: calendar, year: year, month: month} = date) do
    day = calendar.days_in_month(year, month)
    %{date | day: day}
  end

  @doc """
  Returns a date representing the end of the week.

  ## Examples

      iex> Tox.Date.end_of_week(~D[2020-11-11])
      ~D[2020-11-15]

      iex> ~D[2020-11-11]
      ...> |> Date.convert!(Cldr.Calendar.Ethiopic)
      ...> |> Tox.Date.end_of_week()
      %Date{year: 2013, month: 3, day: 6, calendar: Cldr.Calendar.Ethiopic}

  """
  @spec end_of_week(Calendar.date()) :: Calendar.date()
  def end_of_week(%{calendar: calendar, year: year, month: month, day: day} = date) do
    day = Tox.days_per_week() - Tox.day_of_week(calendar, year, month, day)
    shift(date, day: day)
  end

  ## Helpers

  defp shift_years(date, 0), do: date

  defp shift_years(
         %{calendar: calendar, year: year, month: month, day: day} = date,
         years
       ) do
    updated_year = year + years
    updated_day = update_day(updated_year, month, day, calendar)

    %{date | year: updated_year, day: updated_day}
  end

  defp shift_months(date, 0), do: date

  defp shift_months(
         %{calendar: calendar, month: month, year: year, day: day} = date,
         months
       ) do
    {updated_year, updated_month} = shift_months(months, year, month, calendar)
    updated_day = update_day(updated_year, updated_month, day, calendar)

    %{date | year: updated_year, month: updated_month, day: updated_day}
  end

  defp shift_months(months, year, month, calendar) do
    months_per_year = calendar.months_in_year(year)

    updated_year = year + div(months, months_per_year)
    updated_month = month + rem(months, months_per_year)

    cond do
      updated_month <= 0 ->
        {updated_year - 1, months_per_year + updated_month}

      updated_month > months_per_year ->
        {updated_year + 1, updated_month - months_per_year}

      true ->
        {updated_year, updated_month}
    end
  end

  defp update_day(year, month, day, calendar), do: min(day, calendar.days_in_month(year, month))
end