lib/holidefs/date_calculator.ex

defmodule Holidefs.DateCalculator do
  @moduledoc """
  Some functions to calculate dynamic holiday dates.
  """

  @doc """
  Returns the date of Easter for the given `year`.

  ## Examples

      iex> Holidefs.DateCalculator.gregorian_easter(2016)
      ~D[2016-03-27]

      iex> Holidefs.DateCalculator.gregorian_easter(2015)
      ~D[2015-04-05]

  """
  @spec gregorian_easter(integer) :: Date.t()
  def gregorian_easter(year) do
    y = year
    a = rem(y, 19)
    b = div(y, 100)
    c = rem(y, 100)
    d = div(b, 4)
    e = rem(b, 4)
    f = div(b + 8, 25)
    g = div(b - f + 1, 3)
    h = rem(19 * a + b - d - g + 15, 30)
    i = div(c, 4)
    k = rem(c, 4)
    l = rem(32 + 2 * e + 2 * i - h - k, 7)
    m = div(a + 11 * h + 22 * l, 451)

    month = div(h + l - 7 * m + 114, 31)
    day = rem(h + l - 7 * m + 114, 31) + 1

    {:ok, date} = Date.new(year, month, day)
    date
  end

  @doc """
  Returns the date of Orthodox Easter for the given `year`.

  ## Examples

      iex> Holidefs.DateCalculator.gregorian_orthodox_easter(2016)
      ~D[2016-05-01]

      iex> Holidefs.DateCalculator.gregorian_orthodox_easter(2015)
      ~D[2015-04-12]

  """
  @spec gregorian_orthodox_easter(integer) :: Date.t()
  def gregorian_orthodox_easter(year) do
    j_date = julian_orthodox_easter(year)

    offset =
      case year do
        # between the years 1583 and 1699 10 days are added to the julian day count
        _y when year >= 1583 and year <= 1699 ->
          10

        # after 1700, 1 day is added for each century, except if the century year is
        # exactly divisible by 400 (in which case no days are added).
        # Safe until 4100 AD, when one leap day will be removed.
        year when year >= 1700 ->
          div(year - 1600, 100) - div(year - 1600, 400) + 10

        # up until 1582, julian and gregorian easter dates were identical
        _ ->
          0
      end

    Date.add(j_date, offset)
  end

  @doc """
  Returns the date of Orthodox Easter for the given `year`.

  ## Examples

      iex> Holidefs.DateCalculator.julian_orthodox_easter(2016)
      ~D[2016-04-18]

      iex> Holidefs.DateCalculator.julian_orthodox_easter(2015)
      ~D[2015-03-30]

  """
  @spec julian_orthodox_easter(integer) :: Date.t()
  def julian_orthodox_easter(year) do
    y = year
    g = rem(y, 19)
    i = rem(19 * g + 15, 30)
    j = rem(year + div(year, 4) + i, 7)
    j_month = 3 + div(i - j + 40, 44)
    j_day = i - j + 28 - 31 * div(j_month, 4)

    {:ok, date} = Date.new(year, j_month, j_day)
    date
  end

  @doc """
  Returns the nth day of the week.
  """
  @spec nth_day_of_week(integer, integer, integer, integer) :: Date.t()
  def nth_day_of_week(year, month, -1, weekday) do
    year
    |> end_of_month(month)
    |> previous_day_of_week(weekday)
  end

  def nth_day_of_week(year, month, 1, weekday) do
    year
    |> beginning_of_month(month)
    |> next_day_of_week(weekday)
  end

  def nth_day_of_week(year, month, week, weekday) when week < -1 do
    year
    |> nth_day_of_week(month, week + 1, weekday)
    |> Date.add(-7)
  end

  def nth_day_of_week(year, month, week, weekday) when week > 1 do
    year
    |> nth_day_of_week(month, week - 1, weekday)
    |> Date.add(7)
  end

  @doc """
  Returns the next day of week after the given day.
  """
  @spec next_day_of_week(Date.t(), integer) :: Date.t()
  def next_day_of_week(date, day_of_week) do
    diff = day_of_week - Date.day_of_week(date)

    if diff < 0 do
      Date.add(date, diff + 7)
    else
      Date.add(date, diff)
    end
  end

  @doc """
  Returns the previous day of week after the given day.
  """
  @spec previous_day_of_week(Date.t(), integer) :: Date.t()
  def previous_day_of_week(date, day_of_week) do
    diff = day_of_week - Date.day_of_week(date)

    if diff > 0 do
      Date.add(date, diff - 7)
    else
      Date.add(date, diff)
    end
  end

  @doc """
  Returns the first day of the given month on the given year.
  """
  @spec beginning_of_month(integer, integer) :: Date.t()
  def beginning_of_month(year, month) do
    {:ok, first} = Date.new(year, month, 1)
    first
  end

  defp next_beginning_of_month(year, 12), do: beginning_of_month(year + 1, 1)
  defp next_beginning_of_month(year, month), do: beginning_of_month(year, month + 1)

  defp end_of_month(year, month) do
    year
    |> next_beginning_of_month(month)
    |> Date.add(-1)
  end
end