lib/kday.ex

defmodule Kday do
  @moduledoc """
  Functions to return the date of the first, last or nth day of the week
  on, nearest, before or after a given date.

  """

  @days_in_a_week 7

  @doc """
  Return the date of the `day_of_week` on or before the
  specified `date`.

  ## Arguments

  * `date` is `t:Date.t/0`, a `t:DateTime.t/0`, `t:NaiveDateTime.t/0` or
    Gregorian days since epoch.

  * `k` is an integer day of the week where Monday
    is represented by `1` and Sunday is represented by `7`.

  ## Returns

  * A `t:Date.t/0` in the calendar of the date provided as an argument

  ## Examples

      iex> Kday.kday_on_or_before(~D[2016-02-29], 2)
      ~D[2016-02-23]

      iex> Kday.kday_on_or_before(~D[2017-11-30], 1)
      ~D[2017-11-27]

      iex> Kday.kday_on_or_before(~D[2017-06-30], 6)
      ~D[2017-06-24]

      iex> Kday.kday_on_or_before(~D[2023-09-29], 5)
      ~D[2023-09-29]

  """
  @spec kday_on_or_before(Calendar.day() | Date.t(), Calendar.day_of_week()) ::
          Calendar.day() | Date.t()

  def kday_on_or_before(%{year: _, month: _, day: _, calendar: calendar} = date, k)
      when k in 1..@days_in_a_week do
    date
    |> Date.to_gregorian_days
    |> kday_on_or_before(k)
    |> Date.from_gregorian_days(calendar)
  end

  def kday_on_or_before(gregorian_days, k) when is_integer(gregorian_days) do
    gregorian_days - gregorian_days_to_day_of_week(gregorian_days - k)
  end

  @doc """
  Return the date of the `day_of_week` on or after the
  specified `date`.

  ## Arguments

  * `date` is `t:Date.t/0`, a `t:DateTime.t/0`, `t:NaiveDateTime.t/0` or
    Gregorian days since epoch.

  * `k` is an integer day of the week where Monday
    is represented by `1` and Sunday is represented by `7`.

  ## Returns

  * A `t:Date.t/0` in the calendar of the date provided as an argument

  ## Examples

      iex> Kday.kday_on_or_after(~D[2016-02-29], 2)
      ~D[2016-03-01]

      iex> Kday.kday_on_or_after(~D[2017-11-30], 1)
      ~D[2017-12-04]

      iex> Kday.kday_on_or_after(~D[2017-06-30], 6)
      ~D[2017-07-01]

  """
  @spec kday_on_or_after(Calendar.day() | Date.t(), Calendar.day_of_week()) ::
          Calendar.day() | Date.t()

  def kday_on_or_after(%{year: _, month: _, day: _, calendar: calendar} = date, k)
      when k in 1..@days_in_a_week do
    date
    |> Date.to_gregorian_days
    |> kday_on_or_after(k)
    |> Date.from_gregorian_days(calendar)
  end

  def kday_on_or_after(gregorian_days, k) when is_integer(gregorian_days) do
    kday_on_or_before(gregorian_days + 6, k)
  end

  @doc """
  Return the date of the `day_of_week` nearest the
  specified `date`.

  ## Arguments

  * `date` is `t:Date.t/0`, a `t:DateTime.t/0`, `t:NaiveDateTime.t/0` or
    Gregorian days since epoch.

  * `k` is an integer day of the week where Monday
    is represented by `1` and Sunday is represented by `7`.

  ## Returns

  * A `t:Date.t/0` in the calendar of the date provided as an argument

  ## Examples

      iex> Kday.kday_nearest(~D[2016-02-29], 2)
      ~D[2016-03-01]

      iex> Kday.kday_nearest(~D[2017-11-30], 1)
      ~D[2017-11-27]

      iex> Kday.kday_nearest(~D[2017-06-30], 6)
      ~D[2017-07-01]

  """
  @spec kday_nearest(Calendar.day() | Date.t(), Calendar.day_of_week()) ::
          Calendar.day() | Date.t()

  def kday_nearest(%{year: _, month: _, day: _, calendar: calendar} = date, k)
      when k in 1..@days_in_a_week do
    date
    |> Date.to_gregorian_days
    |> kday_nearest(k)
    |> Date.from_gregorian_days(calendar)
  end

  def kday_nearest(gregorian_days, k) when is_integer(gregorian_days) do
    kday_on_or_before(gregorian_days + 3, k)
  end

  @doc """
  Return the date of the `day_of_week` before the
  specified `date`.

  ## Arguments

  * `date` is `t:Date.t/0`, a `t:DateTime.t/0`, `t:NaiveDateTime.t/0` or
    Gregorian days since epoch.

  * `k` is an integer day of the week where Monday
    is represented by `1` and Sunday is represented by `7`.

  ## Returns

  * A `t:Date.t/0` in the calendar of the date provided as an argument

  ## Examples

      iex> Kday.kday_before(~D[2016-02-29], 2)
      ~D[2016-02-23]

      iex> Kday.kday_before(~D[2017-11-30], 1)
      ~D[2017-11-27]

      # 6 means Saturday.  Use either the integer value or the atom form.
      iex> Kday.kday_before(~D[2017-06-30], 6)
      ~D[2017-06-24]

  """
  @spec kday_before(Calendar.day() | Date.t(), Calendar.day_of_week()) ::
          Calendar.day() | Date.t()

  def kday_before(%{year: _, month: _, day: _, calendar: calendar} = date, k)
      when k in 1..@days_in_a_week do
    date
    |> Date.to_gregorian_days
    |> kday_before(k)
    |> Date.from_gregorian_days(calendar)
  end

  def kday_before(gregorian_days, k) do
    kday_on_or_before(gregorian_days - 1, k)
  end

  @doc """
  Return the date of the `day_of_week` after the
  specified `date`.

  ## Arguments

  * `date` is `t:Date.t/0`, a `t:DateTime.t/0`, `t:NaiveDateTime.t/0` or
    Gregorian days since epoch.

  * `k` is an integer day of the week where Monday
    is represented by `1` and Sunday is represented by `7`.

  ## Returns

  * A `t:Date.t/0` in the calendar of the date provided as an argument

  ## Examples

      iex> Kday.kday_after(~D[2016-02-29], 2)
      ~D[2016-03-01]

      iex> Kday.kday_after(~D[2017-11-30], 1)
      ~D[2017-12-04]

      iex> Kday.kday_after(~D[2017-06-30], 6)
      ~D[2017-07-01]

      iex> Kday.kday_after(~D[2021-03-28], 7)
      ~D[2021-04-04]

  """
  @spec kday_after(Calendar.day() | Date.t(), Calendar.day_of_week()) ::
          Calendar.day() | Date.t()

  def kday_after(%{year: _, month: _, day: _, calendar: calendar} = date, k)
      when k in 1..@days_in_a_week do
    date
    |> Date.to_gregorian_days()
    |> kday_after(k)
    |> Date.from_gregorian_days(calendar)
  end

  def kday_after(gregorian_days, k) do
    kday_on_or_after(gregorian_days + 1, k)
  end

  @doc """
  Return the date of the `nth` `day_of_week` on or before/after the
  specified `date`.

  ## Arguments

  * `date` is `t:Date.t/0`, a `t:DateTime.t/0`, `t:NaiveDateTime.t/0` or
    Gregorian days since epoch.

  * `n` is the cardinal number of `k` before (negative `n`) or after
    (positive `n`) the specified date

  * `k` is an integer day of the week where Monday
    is represented by `1` and Sunday is represented by `7`.

  ## Returns

  * A `t:Date.t/0` in the calendar of the date provided as an argument

  ## Examples

      # Thanksgiving in the US
      iex> Kday.nth_kday(~D[2017-11-01], 4, 4)
      ~D[2017-11-23]

      # Labor day in the US
      iex> Kday.nth_kday(~D[2017-09-01], 1, 1)
      ~D[2017-09-04]

      # Daylight savings time starts in the US
      iex> Kday.nth_kday(~D[2017-03-01], 2, 7)
      ~D[2017-03-12]

  """
  @spec nth_kday(Calendar.day() | Date.t(), integer(), Calendar.day_of_week()) ::
          Calendar.day() | Date.t()

  def nth_kday(%{year: _, month: _, day: _, calendar: calendar} = date, n, k)
      when k in 1..@days_in_a_week and is_integer(n) do
    date
    |> Date.to_gregorian_days
    |> nth_kday(n, k)
    |> Date.from_gregorian_days(calendar)
  end

  def nth_kday(gregorian_days, n, k) when is_integer(gregorian_days) and n > 0 do
    weeks_to_days(n) + kday_on_or_before(gregorian_days, k)
  end

  def nth_kday(gregorian_days, n, k) when is_integer(gregorian_days) do
    weeks_to_days(n) + kday_on_or_after(gregorian_days, k)
  end

  @doc """
  Return the date of the first `day_of_week` on or after the
  specified `date`.

  ## Arguments

  * `date` is `t:Date.t/0`, a `t:DateTime.t/0`, `t:NaiveDateTime.t/0` or
    Gregorian days since epoch.

  * `k` is an integer day of the week where Monday
    is represented by `1` and Sunday is represented by `7`.

  ## Returns

  * A `%Date{”}` in the calendar of the date provided as an argument

  ## Examples

      # US election day
      iex> Kday.first_kday(~D[2017-11-02], 2)
      ~D[2017-11-07]

      # US Daylight savings end
      iex> Kday.first_kday(~D[2017-11-01], 7)
      ~D[2017-11-05]

  """
  @spec first_kday(Calendar.day() | Date.t(), Calendar.day_of_week()) ::
          Calendar.day() | Date.t()

  def first_kday(%{year: _, month: _, day: _, calendar: calendar} = date, k)
      when k in 1..@days_in_a_week do
    date
    |> Date.to_gregorian_days
    |> first_kday(k)
    |> Date.from_gregorian_days(calendar)
  end

  def first_kday(gregorian_days, k) do
    nth_kday(gregorian_days, 1, k)
  end

  @doc """
  Return the date of the last `day_of_week` on or before the
  specified `date`.

  ## Arguments

  * `date` is `t:Date.t/0`, a `t:DateTime.t/0`, `t:NaiveDateTime.t/0` or
    Gregorian days since epoch.

  * `k` is an integer day of the week where Monday
    is represented by `1` and Sunday is represented by `7`.

  ## Returns

  * A `t:Date.t/0` in the calendar of the date provided as an argument.

  ## Example

      # Memorial Day in the US
      iex> Kday.last_kday(~D[2017-05-31], 1)
      ~D[2017-05-29]

  """
  @spec last_kday(Calendar.day() | Date.t(), Calendar.day_of_week()) ::
          Calendar.day() | Date.t()

  def last_kday(%{year: _, month: _, day: _, calendar: calendar} = date, k)
      when k in 1..@days_in_a_week do
    date
    |> Date.to_gregorian_days
    |> last_kday(k)
    |> Date.from_gregorian_days(calendar)
  end

  def last_kday(gregorian_days, k) do
    nth_kday(gregorian_days, -1, k)
  end

  @doc """
  Returns the day of the week for a given
  `gregorian_day_number`.

  ## Arguments

  * `gregorian_day_number` is the number of days since the start
    of the epoch.

  ## Returns

  * An integer representing a day of the week where Monday
    is represented by `1` and Sunday is represented by `7`.

  ## Examples

      iex> days = Date.to_gregorian_days ~D[2019-01-01]
      iex> Kday.gregorian_days_to_day_of_week(days) == 2
      true

  """
  @spec gregorian_days_to_day_of_week(integer()) :: Calendar.day_of_week()
  def gregorian_days_to_day_of_week(gregorian_day_number) when is_integer(gregorian_day_number) do
    Integer.mod(gregorian_day_number + 6, @days_in_a_week)
  end

  @doc """
  Returns the number of days in `n` weeks.

  ## Example

      iex> Kday.weeks_to_days(2)
      14

  """
  @spec weeks_to_days(integer) :: integer
  def weeks_to_days(n) do
    n * @days_in_a_week
  end
end