Packages
CalendarRecurrence is an Elixir library for working with recurring dates.
Current section
Files
Jump to
Current section
Files
lib/calendar_recurrence.ex
defmodule CalendarRecurrence do
@moduledoc """
Stream of recurring dates.
Options:
* `start` - The start of the recurrence
* `stop` - When to stop the recurrence. Defaults to `:never`
* `unit` - The interval for each recurrence, Defaults to `:day`
* `step` - The count of how many units to apply for each recurrence. Defaults to `1`
When the `:start` is an Elixir `DateTime` struct with a timezone other than "Etc/UTC" the recurrence will be calculated in that timezone,
so that the wall clock time is stable even when switching between summer and winter time. That means the time will be the same even when
the day has a duration of 23h or 25h.
## Examples
iex> recurrence = CalendarRecurrence.new(start: ~D[2018-01-01])
iex> Enum.take(recurrence, 3)
[~D[2018-01-01], ~D[2018-01-02], ~D[2018-01-03]]
iex> recurrence = CalendarRecurrence.new(start: ~N[2018-01-01 12:00:00])
iex> Enum.take(recurrence, 3)
[~N[2018-01-01 12:00:00], ~N[2018-01-02 12:00:00], ~N[2018-01-03 12:00:00]]
iex> recurrence = CalendarRecurrence.new(start: ~U[2018-01-01 12:00:00Z])
iex> Enum.take(recurrence, 3)
[~U[2018-01-01 12:00:00Z], ~U[2018-01-02 12:00:00Z], ~U[2018-01-03 12:00:00Z]]
iex> recurrence = CalendarRecurrence.new(start: ~U[2018-01-01 12:00:00Z], unit: :hour)
iex> Enum.take(recurrence, 3)
[~U[2018-01-01 12:00:00Z], ~U[2018-01-01 13:00:00Z], ~U[2018-01-01 14:00:00Z]]
iex> recurrence = CalendarRecurrence.new(start: ~U[2018-01-01 12:00:00Z], unit: :hour, step: 2)
iex> Enum.take(recurrence, 3)
[~U[2018-01-01 12:00:00Z], ~U[2018-01-01 14:00:00Z], ~U[2018-01-01 16:00:00Z]]
iex> recurrence = CalendarRecurrence.new(start: ~D[2018-01-01], stop: {:count, 3})
iex> Enum.to_list(recurrence)
[~D[2018-01-01], ~D[2018-01-02], ~D[2018-01-03]]
iex> recurrence = CalendarRecurrence.new(start: ~D[2018-01-01], stop: {:until, ~D[2018-01-03]})
iex> Enum.to_list(recurrence)
[~D[2018-01-01], ~D[2018-01-02], ~D[2018-01-03]]
iex> recurrence = CalendarRecurrence.new(start: ~D[2018-01-01], step: fn _ -> 2 end)
iex> Enum.take(recurrence, 3)
[~D[2018-01-01], ~D[2018-01-03], ~D[2018-01-05]]
"""
@enforce_keys [:start]
defstruct start: nil,
step: 1,
stop: :never,
unit: :day
@type date() :: Date.t() | NaiveDateTime.t() | DateTime.t() | CalendarRecurrence.T.t()
@type stepper() :: (current :: date() -> pos_integer())
@type unit() :: :day | :hour | :minute | System.time_unit()
@type t() :: %CalendarRecurrence{
start: date(),
stop: :never | {:until, date()} | {:count, non_neg_integer()},
step: pos_integer() | stepper(),
unit: unit()
}
@spec new(keyword()) :: t()
def new(opts) when is_list(opts) do
struct!(__MODULE__, opts)
end
defimpl Enumerable do
def count(%CalendarRecurrence{stop: {:count, count}}), do: {:ok, count}
def count(%CalendarRecurrence{start: start, stop: {:until, until}, step: step, unit: unit})
when is_integer(step),
do: {:ok, round((CalendarRecurrence.T.diff(until, start, unit) + 1) / step)}
def count(_), do: {:error, __MODULE__}
def member?(_, _), do: {:error, __MODULE__}
def reduce(recurrence, acc, fun) do
do_reduce(recurrence.start, 1, recurrence, acc, fun)
end
def slice(_), do: {:error, __MODULE__}
defp do_reduce(_start, _count, _recurrence, {:halt, acc}, _fun) do
{:halted, acc}
end
defp do_reduce(_start, _count, _recurrence, {:suspend, acc}, _fun) do
{:suspended, acc}
end
defp do_reduce(current, count, recurrence, {:cont, acc}, fun) do
if continue?(current, count, recurrence) do
next = CalendarRecurrence.T.add(current, step(recurrence, current), recurrence.unit)
do_reduce(next, count + 1, recurrence, fun.(current, acc), fun)
else
{:halted, acc}
end
end
defp step(%CalendarRecurrence{step: step}, _current) when is_integer(step), do: step
defp step(%CalendarRecurrence{step: stepper}, current) when is_function(stepper, 1),
do: stepper.(current)
defp continue?(_current, _count, %CalendarRecurrence{stop: :never}), do: true
defp continue?(_current, count, %CalendarRecurrence{stop: {:count, max}}) when max >= 0,
do: count <= max
defp continue?(current, _count, %CalendarRecurrence{stop: {:until, date}}),
do: CalendarRecurrence.T.continue?(current, date)
end
end
defprotocol CalendarRecurrence.T do
def continue?(t1, t2)
def add(t, count, unit)
def diff(t1, t2, unit)
end
defimpl CalendarRecurrence.T, for: Date do
def continue?(date1, date2) do
Date.compare(date1, date2) in [:lt, :eq]
end
def add(date, step, _unit), do: Date.add(date, step)
def diff(date, step, _unit), do: Date.diff(date, step)
end
defimpl CalendarRecurrence.T, for: NaiveDateTime do
def continue?(date1, date2) do
NaiveDateTime.compare(date1, date2) in [:lt, :eq]
end
defdelegate add(date, step, unit), to: NaiveDateTime
defdelegate diff(date1, date2, unit), to: NaiveDateTime
end
defimpl CalendarRecurrence.T, for: DateTime do
def continue?(date1, date2) do
DateTime.compare(date1, date2) in [:lt, :eq]
end
def add(%DateTime{time_zone: "Etc/UTC"} = date, step, unit), do: DateTime.add(date, step, unit)
def add(date, step, unit) do
date
|> DateTime.to_naive()
|> NaiveDateTime.add(step, unit)
|> dt_from_naive(step, unit, date.time_zone)
end
defdelegate diff(date1, date2, unit), to: DateTime
defp dt_from_naive(%NaiveDateTime{} = ndt, step, unit, timezone) do
case DateTime.from_naive(ndt, timezone) do
{:ok, dt} ->
dt
{:ambiguous, first_dt, _second_dt} ->
first_dt
{:gap, _gap_start, _gap_end} ->
ndt
|> NaiveDateTime.add(step, unit)
|> dt_from_naive(step, unit, timezone)
{:error, reason} ->
raise ArgumentError,
"Could not convert date #{ndt} to DateTime with timezone #{timezone}, reason: #{reason}"
end
end
end