lib/tarearbol/extras/calendar.ex

defmodule Tarearbol.Calendar do
  @moduledoc """
  Handy functions to work with dates.
  """

  use Boundary

  @days_in_week 7

  @doc """
  Returns a `DateTime` instance of the beginning of the period given as third
  parameters, related to the `DateTime` instance given as the first parameter
  (defaulted to `DateTime.utc_now/0`).

  The number of periods to shift might be given as the second parameter
  (defaults to `0`.)

  _Examples:_

      iex> dt = DateTime.from_unix!(1567091960)
      ~U[2019-08-29 15:19:20Z]
      iex> Tarearbol.Calendar.beginning_of(dt, :year)
      ~U[2019-01-01 00:00:00Z]
      iex> Tarearbol.Calendar.beginning_of(dt, -2, :year)
      ~U[2017-01-01 00:00:00Z]
      iex> Tarearbol.Calendar.beginning_of(dt, :month)
      ~U[2019-08-01 00:00:00Z]
      iex> Tarearbol.Calendar.beginning_of(dt, 6, :month)
      ~U[2020-02-01 00:00:00Z]
      iex> Tarearbol.Calendar.beginning_of(dt, 4, :day)
      ~U[2019-09-02 00:00:00Z]
      iex> Tarearbol.Calendar.beginning_of(dt, :week)
      ~U[2019-08-26 00:00:00Z]
      iex> Tarearbol.Calendar.beginning_of(dt, -34, :week)
      ~U[2018-12-31 00:00:00Z]
      iex> Tarearbol.Calendar.beginning_of(dt, :hour)
      ~U[2019-08-29 15:00:00Z]
      iex> Tarearbol.Calendar.beginning_of(dt, -28*24-16, :hour)
      ~U[2019-07-31 23:00:00Z]
      iex> Tarearbol.Calendar.end_of(dt, :hour)
      ~U[2019-08-29 15:59:59Z]
      iex> Tarearbol.Calendar.end_of(dt, 5, :month)
      ~U[2020-01-31 23:59:59Z]
      iex> Tarearbol.Calendar.end_of(dt, -1, :year)
      ~U[2018-12-31 23:59:59Z]

  """
  @spec beginning_of(dt :: DateTime.t() | nil, count :: integer(), atom()) :: DateTime.t()
  def beginning_of(dt \\ nil, count \\ 0, what)

  ###################################  YEAR  ###################################

  def beginning_of(dt, count, :year) do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt

    %DateTime{nullify(dt) | year: dt.year + count, month: 1, day: 1}
  end

  ###################################  MONTH  ##################################

  def beginning_of(dt, 0, :month) do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt

    %DateTime{nullify(dt) | year: dt.year, month: dt.month, day: 1}
  end

  def beginning_of(dt, 1, :month) do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt

    {year, month} =
      if dt.month == dt.calendar.months_in_year(dt.year),
        do: {dt.year + 1, 1},
        else: {dt.year, dt.month + 1}

    %DateTime{nullify(dt) | year: year, month: month, day: 1}
  end

  def beginning_of(dt, -1, :month) do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt

    {year, month} =
      if dt.month == 1,
        do: {dt.year - 1, dt.calendar.months_in_year(dt.year - 1)},
        else: {dt.year, dt.month - 1}

    %DateTime{nullify(dt) | year: year, month: month, day: 1}
  end

  ###################################  WEEK  ###################################

  def beginning_of(dt, 0, :week) do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt
    beginning_of(dt, 1 - dt.calendar.day_of_week(dt.year, dt.month, dt.day), :day)
  end

  def beginning_of(dt, n, :week) when is_integer(n) do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt

    dt
    |> beginning_of(0, :week)
    |> beginning_of(n * @days_in_week, :day)
  end

  ################################  DAY OF WEEK  ###############################

  def beginning_of(dt, dow, :day_of_week) when is_integer(dow) and dow > 0 and dow < 8 do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt
    diff = dow - dt.calendar.day_of_week(dt.year, dt.month, dt.day)

    beginning_of(dt, if(diff > 0, do: diff, else: 7 + diff), :day)
  end

  ###################################  DAY  ####################################

  def beginning_of(dt, 0, :day) do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt
    %DateTime{nullify(dt) | year: dt.year, month: dt.month, day: dt.day}
  end

  def beginning_of(dt, 1, :day) do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt

    days_in_this_month = dt.calendar.days_in_month(dt.year, dt.month)
    months_in_this_year = dt.calendar.months_in_year(dt.year)

    {year, month, day} =
      case {dt.day, dt.month} do
        {^days_in_this_month, ^months_in_this_year} ->
          {dt.year + 1, 1, 1}

        {^days_in_this_month, month} ->
          {dt.year, month + 1, 1}

        {day, month} ->
          {dt.year, month, day + 1}
      end

    %DateTime{nullify(dt) | year: year, month: month, day: day}
  end

  def beginning_of(dt, -1, :day) do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt

    {year, month, day} =
      case {dt.day, dt.month} do
        {1, 1} ->
          month = dt.calendar.months_in_year(dt.year - 1)
          {dt.year - 1, month, dt.calendar.days_in_month(dt.year, month)}

        {1, month} ->
          {dt.year, month - 1, dt.calendar.days_in_month(dt.year, month - 1)}

        {day, month} ->
          {dt.year, month, day - 1}
      end

    %DateTime{nullify(dt) | year: year, month: month, day: day}
  end

  ###################################  HOUR  ###################################

  def beginning_of(dt, 0, :hour) do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt
    %DateTime{nullify(dt) | year: dt.year, month: dt.month, day: dt.day, hour: dt.hour}
  end

  def beginning_of(dt, -1, :hour) do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt

    {year, month, day, hour} =
      case {dt.hour, dt.day, dt.month} do
        {0, 1, 1} ->
          month = dt.calendar.months_in_year(dt.year - 1)
          {dt.year - 1, month, dt.calendar.days_in_month(dt.year, month), 23}

        {0, 1, month} ->
          {dt.year, month - 1, dt.calendar.days_in_month(dt.year, month - 1), 23}

        {0, day, month} ->
          {dt.year, month, day - 1, 23}

        {hour, day, month} ->
          {dt.year, month, day, hour - 1}
      end

    %DateTime{nullify(dt) | year: year, month: month, day: day, hour: hour}
  end

  def beginning_of(dt, 1, :hour) do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt

    days_in_this_month = dt.calendar.days_in_month(dt.year, dt.month)
    months_in_this_year = dt.calendar.months_in_year(dt.year)

    {year, month, day, hour} =
      case {dt.hour, dt.day, dt.month} do
        {23, ^days_in_this_month, ^months_in_this_year} ->
          {dt.year + 1, 1, 1, 0}

        {23, ^days_in_this_month, month} ->
          {dt.year, month + 1, 1, 0}

        {23, day, month} ->
          {dt.year, month, day + 1, 0}

        {hour, day, month} ->
          {dt.year, month, day, hour + 1}
      end

    %DateTime{nullify(dt) | year: year, month: month, day: day, hour: hour}
  end

  ##################################  GENERICS  ################################

  def beginning_of(_dt, count, period)
      when period not in [:year, :month, :week, :day, :hour, :minute],
      do: {:error, {period, count}}

  def beginning_of(dt, count, period) when count > 1 do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt
    Enum.reduce(1..count, dt, fn _, acc -> beginning_of(acc, 1, period) end)
  end

  def beginning_of(dt, count, period) when count < 1 do
    dt = if is_nil(dt), do: DateTime.utc_now(), else: dt
    Enum.reduce(1..-count, dt, fn _, acc -> beginning_of(acc, -1, period) end)
  end

  ###################################  END_OF  #################################

  @spec end_of(dt :: DateTime.t() | nil, count :: integer(), atom()) :: DateTime.t()
  def end_of(dt \\ nil, n \\ 0, period)

  def end_of(dt, n, period) do
    dt
    |> beginning_of(n, period)
    |> DateTime.add(period_in_seconds(dt, period) - 1)
  end

  ##################################  INTERNALS  ###############################

  defp period_in_seconds(dt \\ nil, period)

  defp period_in_seconds(_, :minute), do: 60
  defp period_in_seconds(_, :hour), do: 60 * 60
  defp period_in_seconds(_, :day), do: 60 * 60 * 24

  defp period_in_seconds(dt, :month),
    do: dt.calendar.days_in_month(dt.year, dt.month) * period_in_seconds(:day)

  defp period_in_seconds(dt, :year) do
    Enum.reduce(1..dt.calendar.months_in_year(dt.year), 0, fn month, acc ->
      acc + dt.calendar.days_in_month(dt.year, month) * period_in_seconds(:day)
    end)
  end

  defp nullify(%DateTime{} = dt) do
    %DateTime{
      dt
      | year: 0,
        month: 0,
        day: 0,
        hour: 0,
        minute: 0,
        second: 0,
        microsecond: {0, 0}
    }
  end
end