defmodule Tempus do
@moduledoc """
`Tempus` is a library to deal with timeslots.
It aims to be a fast yet easy to use implementation of a schedule of any type,
including but not limited to free/busy time schedules.
The example of it might be a calendar software, where slots might be marked as
free, or busy. It also allows simple arithmetics with schedules, like adding
five days or subtracting 7 hours 30 minutes from now, considering busy slots.
"""
use Tempus.Telemetria
require Tempus.Slot
alias Tempus.{Sigils.NilParser, Slot, Slots}
import Tempus.Guards
@typedoc "Direction for slots navigation"
@type direction :: :fwd | :bwd
@typedoc "Number of slots (`:stream` means lazy folding, unknown upfront)"
@type count :: :infinity | :stream | non_neg_integer()
@typedoc "Navigation option"
@type option ::
{:origin, Slot.origin()} | {:count, count() | neg_integer()} | {:direction, direction()}
@typedoc "Argument containing navigation options"
@type options :: [option()]
@typep options_tuple :: {Slot.origin(), count(), 1 | -1}
defdelegate slot(from, to), to: Tempus.Slot, as: :new
defdelegate slot!(from, to), to: Tempus.Slot, as: :new!
@doc """
Syntactic sugar for `|> Enum.into(%Slots{})`.
## Examples
iex> [
...> Tempus.Slot.wrap(~D|2020-08-07|),
...> Tempus.Slot.wrap(~D|2020-08-10|),
...> Tempus.Slot.wrap(~D|2020-08-12|)
...> ] |> Tempus.slots()
%Tempus.Slots{slots: %Tempus.Slots.List{slots: [
%Tempus.Slot{from: ~U[2020-08-07 00:00:00.000000Z], to: ~U[2020-08-07 23:59:59.999999Z]},
%Tempus.Slot{from: ~U[2020-08-10 00:00:00.000000Z], to: ~U[2020-08-10 23:59:59.999999Z]},
%Tempus.Slot{from: ~U[2020-08-12 00:00:00.000000Z], to: ~U[2020-08-12 23:59:59.999999Z]}]}}
"""
defdelegate slots(enum), to: Slots, as: :wrap
@doc """
Helper to instantiate slot from any known format, by wrapping the argument.
## Examples
iex> Tempus.guess("2023-04-10")
{:ok, Tempus.Slot.wrap(~D[2023-04-10])}
iex> Tempus.guess(nil)
{:ok, %Tempus.Slot{from: nil, to: nil}}
iex> Tempus.guess("∞")
{:ok, %Tempus.Slot{from: nil, to: nil}}
iex> Tempus.guess("")
{:ok, %Tempus.Slot{from: nil, to: nil}}
iex> Tempus.guess("2023-04-10T10:00:00Z")
{:ok, Tempus.Slot.wrap(~U[2023-04-10T10:00:00Z])}
iex> Tempus.guess("10:00:00")
{:ok, Date.utc_today() |> DateTime.new!(Time.from_erl!({10, 0, 0})) |> Tempus.Slot.wrap()}
iex> Tempus.guess("20230410T235007.123+0230")
if Version.compare(System.version(), "1.14.0") == :lt do
{:error, :invalid_format}
else
{:ok, Tempus.Slot.wrap(~U[2023-04-10T21:20:07.123Z])}
end
iex> Tempus.guess("2023-04-10-15")
{:error, :invalid_format}
"""
@spec guess(input :: nil | binary()) :: {:ok, Slot.t()} | {:error, any()}
def guess(input) do
input
|> do_guess()
|> case do
{:ok, nil} -> {:ok, Slot.id()}
{:ok, origin} -> {:ok, Slot.wrap(origin)}
{:error, reason} -> {:error, reason}
end
end
@doc """
Helper to instantiate slot from any known format, by joining the arguments.
## Examples
iex> import Tempus.Sigils
iex> Tempus.guess("2023-04-10", "2023-04-12")
{:ok, ~I[2023-04-10 00:00:00.000000Z|2023-04-12 23:59:59.999999Z]}
iex> Tempus.guess("2023-04-10", nil)
{:ok, ~I[2023-04-10 00:00:00.000000Z|2023-04-10T23:59:59.999999Z]}
iex> Tempus.guess("2023-04-10T10:00:00Z", "2023-04-12")
{:ok, ~I[2023-04-10 10:00:00Z|2023-04-12 23:59:59.999999Z]}
iex> Tempus.guess("20230410T235007.123+0230", "2023-04-12")
if Version.compare(System.version(), "1.14.0") == :lt do
{:error, {:invalid_arguments, [from: :invalid_format]}}
else
{:ok, ~I[2023-04-10 21:20:07.123Z|2023-04-12 23:59:59.999999Z]}
end
iex> Tempus.guess("2023-04-10", :ok)
{:error, {:invalid_arguments, [to: :invalid_argument]}}
iex> Tempus.guess(:ok, "2023-04-10")
{:error, {:invalid_arguments, [from: :invalid_argument]}}
iex> Tempus.guess("2023-04-10-15", :ok)
{:error, {:invalid_arguments, [from: :invalid_format, to: :invalid_argument]}}
iex> Tempus.guess("2023-20-40", "10:70:80")
{:error, {:invalid_arguments, [from: :invalid_date, to: :invalid_time]}}
"""
@spec guess(from :: nil | binary(), to :: nil | binary()) :: {:ok, Slot.t()} | {:error, any()}
def guess(from, to) do
[from, to]
|> Enum.map(&guess/1)
|> case do
[{:ok, from}, {:ok, to}] -> {:ok, Slot.join(from, to)}
[{:error, from}, {:error, to}] -> {:error, {:invalid_arguments, from: from, to: to}}
[{:error, from}, _] -> {:error, {:invalid_arguments, from: from}}
[_, {:error, to}] -> {:error, {:invalid_arguments, to: to}}
end
end
defp do_guess("∞"), do: {:ok, nil}
defp do_guess(""), do: {:ok, nil}
defp do_guess(nil), do: {:ok, nil}
defp do_guess(<<_::binary-size(4), ?-, _::binary-size(2), ?-, _::binary-size(2)>> = date),
do: Date.from_iso8601(date)
defp do_guess(<<_::binary-size(2), ?:, _::binary-size(2), ?:, _::binary-size(2)>> = time),
do: Time.from_iso8601(time)
if Version.compare(System.version(), "1.14.0") == :lt do
defp do_guess(input) when is_binary(input) do
[
&with({:ok, value, _} <- DateTime.from_iso8601(&1), do: {:ok, value}),
&Date.from_iso8601/1,
&Time.from_iso8601/1,
&NilParser.from_iso8601/1
]
|> do_guess_reduce(input)
end
else
defp do_guess(input) when is_binary(input) do
[
&with({:ok, value, _} <- DateTime.from_iso8601(&1, :extended), do: {:ok, value}),
&with({:ok, value, _} <- DateTime.from_iso8601(&1, :basic), do: {:ok, value}),
&Date.from_iso8601/1,
&Time.from_iso8601/1,
&NilParser.from_iso8601/1
]
|> do_guess_reduce(input)
end
end
defp do_guess(_), do: {:error, :invalid_argument}
defp do_guess_reduce(attempts, input) do
Enum.reduce_while(attempts, {:error, :invalid_format}, fn guesser, acc ->
case guesser.(input) do
{:ok, result} -> {:halt, {:ok, result}}
{:error, :invalid_date} -> {:halt, {:error, :invalid_date}}
{:error, :invalid_time} -> {:halt, {:error, :invalid_time}}
_ -> {:cont, acc}
end
end)
end
@spec free?(slots :: Slots.t(), slot :: Slot.origin()) :: boolean()
@doc """
Checks whether the slot is disjoined against slots.
### Examples
iex> slots = [
...> Tempus.Slot.wrap(~D|2020-08-07|),
...> Tempus.Slot.wrap(~D|2020-08-10|)
...> ] |> Enum.into(%Tempus.Slots{})
iex> Tempus.free?(slots, ~D|2020-08-07|)
false
iex> Tempus.free?(slots, ~D|2020-08-08|)
true
iex> Tempus.free?(slots, ~U|2020-08-09T23:59:59.999999Z|)
true
iex> Tempus.free?(slots, DateTime.add(~U|2020-08-09T23:59:59.999999Z|, 1, :microsecond))
false
"""
def free?(%Slots{} = slots, origin) when is_origin(origin) do
origin = Slot.wrap(origin)
slots
|> Slots.drop_until(origin)
|> Enum.take(1)
|> case do
[] -> true
[slot] when is_joint(slot, origin) -> false
_ -> true
end
end
@typedoc """
The type defining how slicing is to be applied.
When `:greedy`, overlapping boundary slots would be included,
`:reluctant` would take only those fully contained in the interval.
"""
@type slice_type :: :greedy | :reluctant
@spec slice(
slots :: Slots.t(),
from :: Slots.locator(),
to :: Slots.locator(),
type :: slice_type()
) :: Slots.t()
@doc since: "0.7.0"
@doc """
Slices the `%Slots{}` based on origins `from` and `to` and an optional type
(default: `:reluctant`.) Returns sliced `%Slots{}` back.
### Examples
iex> slots = [
...> Tempus.Slot.wrap(~D|2020-08-07|),
...> Tempus.Slot.wrap(~D|2020-08-10|)
...> ] |> Enum.into(%Tempus.Slots{})
iex> slots |> Tempus.slice(~D|2020-08-07|, ~D|2020-08-11|) |> Enum.count()
1
"""
def slice(slots, from, to, type \\ :reluctant)
def slice(slots, nil, nil, _), do: slots
def slice(slots, from, nil, type),
do: Slots.drop_until(slots, from, greedy: type == :greedy)
def slice(slots, nil, to, type),
do: Slots.take_until(slots, to, greedy: type == :greedy)
def slice(slots, from, to, type) do
slots
|> slice(from, nil, type)
|> slice(nil, to, type)
end
@spec drop_while(slots :: Slots.t(), fun :: (Slot.t() -> boolean())) :: Slots.t()
@doc since: "0.7.0"
@doc """
Drops slots at the beginning of the `%Slots{}` struct while `fun` returns a truthy value.
### Examples
iex> import Tempus.Guards
...> slots = [
...> Tempus.Slot.wrap(~D|2020-08-07|),
...> Tempus.Slot.wrap(~D|2020-08-10|)
...> ] |> Enum.into(%Tempus.Slots{})
iex> slots
...> |> Tempus.drop_while(&is_slot_coming_before(&1, Tempus.Slot.wrap(~D|2020-08-09|)))
...> |> Enum.count()
1
"""
def drop_while(%Slots{} = slots, fun) do
Slots.drop_until(slots, &(not fun.(&1)))
end
@spec take_while(slots :: Slots.t(), fun :: (Slot.t() -> boolean())) :: Slots.t()
@doc since: "0.7.0"
@doc """
Takes slots at the beginning of the `%Slots{}` struct while `fun` returns a truthy value.
### Examples
iex> import Tempus.Guards
...> slots = [
...> Tempus.Slot.wrap(~D|2020-08-07|),
...> Tempus.Slot.wrap(~D|2020-08-10|)
...> ] |> Enum.into(%Tempus.Slots{})
iex> slots
...> |> Tempus.take_while(&is_slot_coming_before(&1, Tempus.Slot.wrap(~D|2020-08-09|)))
...> |> Enum.count()
1
"""
def take_while(%Slots{} = slots, fun) do
Slots.take_until(slots, &(not fun.(&1)))
end
@spec days_add(slots :: Slots.t(), opts :: options()) :: [Date.t()]
@doc since: "0.2.0"
@doc """
Returns the reversed list of free days after origin.
### Examples
iex> slots = [
...> Tempus.Slot.wrap(~D|2020-08-07|),
...> Tempus.Slot.wrap(~D|2020-08-10|)
...> ] |> Enum.into(%Tempus.Slots{})
iex> Tempus.days_add(slots, origin: ~D|2020-08-07|, count: 3) |> hd()
~D|2020-08-12|
iex> Tempus.days_add(slots, origin: ~D|2020-08-07|, count: 3, direction: :fwd) |> hd()
~D|2020-08-12|
iex> Tempus.days_add(slots, origin: ~D|2020-08-07|, count: -3, direction: :bwd) |> hd()
~D|2020-08-12|
iex> Tempus.days_add(slots, origin: ~D|2020-08-12|, count: -4) |> hd()
~D|2020-08-06|
iex> Tempus.days_add(slots, origin: ~D|2020-08-12|, count: 4, direction: :bwd) |> hd()
~D|2020-08-06|
iex> Tempus.days_add(slots, origin: ~D|2020-08-12|, count: -4, direction: :fwd) |> hd()
~D|2020-08-06|
"""
def days_add(slots, opts \\ []) do
{origin, count, iterator} = options(opts)
(iterator == -1)
|> if(do: origin.from, else: origin.to)
|> DateTime.to_date()
|> Stream.iterate(&Date.add(&1, iterator))
|> Enum.reduce_while([], fn
_date, acc when length(acc) > count -> {:halt, acc}
date, acc -> {:cont, if(free?(slots, date), do: [date | acc], else: acc)}
end)
end
@doc deprecated: "Use days_add/2 instead"
@spec days_ahead(slots :: Slots.t(), origin :: Date.t(), count :: integer()) :: [Date.t()]
@doc """
Returns the reversed list of free days after origin.
### Examples
iex> slots = [
...> Tempus.Slot.wrap(~D|2020-08-07|),
...> Tempus.Slot.wrap(~D|2020-08-10|)
...> ] |> Enum.into(%Tempus.Slots{})
iex> Tempus.days_ahead(slots, ~D|2020-08-07|, 0)
[~D|2020-08-08|]
iex> Tempus.days_ahead(slots, ~D|2020-08-07|, 3) |> hd()
~D|2020-08-12|
"""
def days_ahead(slots, origin, count) when is_integer(count) and count >= 0,
do: days_add(slots, origin: origin, count: count, direction: :fwd)
@doc deprecated: "Use days_add/2 with negative count or `:bwd` forth parameter instead"
@spec days_ago(slots :: Slots.t(), origin :: Date.t(), count :: integer()) :: [Date.t()]
@doc """
Returns the reversed list of free days after origin.
### Examples
iex> slots = [
...> Tempus.Slot.wrap(~D|2020-08-07|),
...> Tempus.Slot.wrap(~D|2020-08-10|)
...> ] |> Enum.into(%Tempus.Slots{})
iex> Tempus.days_ago(slots, ~D|2020-08-07|, 0)
[~D|2020-08-06|]
iex> Tempus.days_ago(slots, ~D|2020-08-12|, 4) |> hd()
~D|2020-08-06|
"""
def days_ago(slots, origin, count) when is_integer(count) and count >= 0,
do: days_add(slots, origin: origin, count: count, direction: :bwd)
@spec next_busy(Slots.t(), options()) :: [Slot.t()] | Slot.t() | nil | no_return
@doc deprecated: "Use `slice/3` instead"
@doc """
Returns the next **busy** slot from the slots passed as a first argument,
that immediately follows `origin`. If slots are overlapped, the overlapped
one gets returned.
### Examples
iex> slots = [
...> Tempus.Slot.wrap(~D|2020-08-07|),
...> Tempus.Slot.wrap(~D|2020-08-10|)
...> ] |> Enum.into(%Tempus.Slots{})
iex> Tempus.next_busy(slots, origin: %Tempus.Slot{from: ~U|2020-08-08 23:00:00Z|, to: ~U|2020-08-09 12:00:00Z|})
%Tempus.Slot{from: ~U[2020-08-10 00:00:00.000000Z], to: ~U[2020-08-10 23:59:59.999999Z]}
iex> Tempus.next_busy(slots, origin: %Tempus.Slot{from: ~U|2020-08-07 11:00:00Z|, to: ~U|2020-08-07 12:00:00Z|}, count: 2) |> hd()
%Tempus.Slot{from: ~U[2020-08-07 00:00:00.000000Z], to: ~U[2020-08-07 23:59:59.999999Z]}
iex> Tempus.next_busy(slots, origin: %Tempus.Slot{from: ~U|2020-08-07 11:00:00Z|, to: ~U|2020-08-08 12:00:00Z|})
%Tempus.Slot{from: ~U[2020-08-07 00:00:00.000000Z], to: ~U[2020-08-07 23:59:59.999999Z]}
iex> Tempus.next_busy(slots, origin: %Tempus.Slot{from: ~U|2020-08-07 11:00:00Z|, to: ~U|2020-08-10 12:00:00Z|})
%Tempus.Slot{from: ~U[2020-08-07 00:00:00.000000Z], to: ~U[2020-08-07 23:59:59.999999Z]}
iex> Tempus.next_busy(slots, origin: ~D|2020-08-07|)
%Tempus.Slot{from: ~U[2020-08-07 00:00:00.000000Z], to: ~U[2020-08-07 23:59:59.999999Z]}
iex> Tempus.next_busy(slots, origin: ~D|2020-08-08|)
%Tempus.Slot{from: ~U[2020-08-10 00:00:00.000000Z], to: ~U[2020-08-10 23:59:59.999999Z]}
iex> Tempus.next_busy(slots, origin: ~D|2020-08-08|, direction: :bwd)
%Tempus.Slot{from: ~U[2020-08-07 00:00:00.000000Z], to: ~U[2020-08-07 23:59:59.999999Z]}
iex> Tempus.next_busy(slots, origin: ~D|2020-08-10|, direction: :bwd)
%Tempus.Slot{from: ~U[2020-08-10 00:00:00.000000Z], to: ~U[2020-08-10 23:59:59.999999Z]}
iex> Tempus.next_busy(slots, origin: ~D|2020-08-10|, direction: :bwd, count: :infinity)
slots
iex> Tempus.next_busy(slots, origin: ~D|2020-08-10|, count: 3.1415)
** (Tempus.ArgumentError) invalid argument: expected ‹Elixir.Integer›, got: ‹3.1415›
iex> Tempus.next_busy(slots, origin: ~D|2020-08-10|, direction: :bwd, count: 2)
Enum.to_list(slots)
iex> Tempus.next_busy(slots, origin: ~D|2020-08-10|, direction: :bwd, count: 1)
Enum.drop(slots, 1)
iex> Tempus.next_busy(slots, origin: %Tempus.Slot{from: ~U|2020-08-11 11:00:00Z|, to: ~U|2020-08-11 12:00:00Z|})
nil
iex> Tempus.next_busy(slots, origin: %Tempus.Slot{from: ~U|2020-08-06 11:00:00Z|, to: ~U|2020-08-06 12:00:00Z|}, direction: :bwd)
nil
iex> Tempus.next_busy(%Tempus.Slots{})
nil
"""
@telemetria level: :debug
def next_busy(slots, opts \\ [])
def next_busy(%Slots{} = slots, opts) do
{origin, count, iterator} = options(opts)
do_next_busy(slots, Slot.wrap(origin), count, iterator)
end
defp do_next_busy(slots, origin, :infinity, 1) do
Slots.drop_until(slots, origin, greedy: true)
end
defp do_next_busy(slots, origin, :infinity, -1) do
Slots.take_until(slots, origin, greedy: true)
end
defp do_next_busy(slots, origin, 0, 1) do
slots
|> Slots.drop_until(origin, greedy: true)
|> Enum.take(1)
|> List.last()
end
defp do_next_busy(slots, origin, 0, -1) do
slots
|> Slots.drop_until(origin, adjustment: -1, greedy: true)
|> Enum.take(2)
|> then(fn
[_, joint] when is_joint(joint, origin) -> joint
[slot | _] when is_slot_coming_before(slot, origin) -> slot
_ -> nil
end)
end
defp do_next_busy(slots, origin, count, 1) do
slots
|> Slots.drop_until(origin, greedy: true)
|> Enum.take(count)
end
defp do_next_busy(slots, origin, count, -1) do
slots
|> Slots.drop_until(origin, adjustment: -count + 1, greedy: true)
# |> Slots.take_while(count)
|> Enum.take(count)
end
@spec next_free(Slots.t(), options()) :: [Slot.t()] | Slot.t() | no_return
@doc deprecated: "Use `slice/3` instead"
@doc """
Returns the next **free** slot from the slots passed as a first argument,
that immediately follows `origin`. If slots are overlapped, the overlapped
one gets returned.
### Examples
iex> import Tempus.Sigils
iex> slots = [
...> Tempus.Slot.wrap(~D|2020-08-07|),
...> Tempus.Slot.wrap(~D|2020-08-10|),
...> Tempus.Slot.wrap(~D|2020-08-12|),
...> Tempus.Slot.wrap(~D|2020-08-14|),
...> Tempus.Slot.wrap(~D|2030-08-14|)
...> ] |> Enum.into(%Tempus.Slots{})
iex> Tempus.next_free(slots, origin: %Tempus.Slot{from: ~U|2020-08-08 23:00:00Z|, to: ~U|2020-08-09 12:00:00Z|})
~I[2020-08-08 00:00:00.000000Z → 2020-08-09 23:59:59.999999Z]
iex> Tempus.next_free(slots, origin: %Tempus.Slot{from: ~U|2020-08-06 11:00:00Z|, to: ~U|2020-08-06 12:00:00Z|})
~I[∞ → 2020-08-06 23:59:59.999999Z]nu
iex> Tempus.next_free(slots, origin: ~U|2020-08-13 01:00:00.000000Z|)
~I[2020-08-13 00:00:00.000000Z → 2020-08-13 23:59:59.999999Z]
iex> Tempus.next_free(slots, origin: ~D|2020-08-13|)
~I[2020-08-13 00:00:00.000000Z → 2020-08-13 23:59:59.999999Z]
iex> Tempus.next_free(slots, origin: ~D|2020-08-14|)
~I[2020-08-15 00:00:00.000000Z → 2030-08-13 23:59:59.999999Z]
iex> Tempus.next_free(slots)
~I[2020-08-15 00:00:00.000000Z → 2030-08-13 23:59:59.999999Z]
"""
@telemetria level: :debug
def next_free(slots, opts \\ [])
def next_free(%Slots{} = slots, opts) do
slots
|> Slots.inverse()
|> next_busy(opts)
end
@doc """
Adds an amount of units to the origin, considering slots given.
### Examples
iex> slots = [
...> ~D|2020-08-07|,
...> ~D|2020-08-10|,
...> ~D|2020-08-11|,
...> ~D|2020-08-14|
...> ] |> Enum.into(Tempus.Slots.new(:stream, []))
iex> Tempus.add(slots, ~U|2020-08-11 23:00:00Z|, 0, :second)
~U[2020-08-12 00:00:00Z]
iex> Tempus.add(slots, ~U|2020-08-12 01:00:00Z|, 0, :second)
~U[2020-08-12 01:00:00Z]
iex> Tempus.add(slots, ~U|2020-08-11 23:00:00Z|, -10*60+1, :second)
~U[2020-08-09 23:50:00Z]
iex> Tempus.add(slots, ~U|2020-08-12 00:09:00Z|, -10*60, :second)
~U[2020-08-09 23:59:00Z]
iex> Tempus.add(slots, ~U|2020-08-12 00:10:00Z|, -10*60, :second)
~U[2020-08-12 00:00:00Z]
iex> Tempus.add(slots, ~U|2020-08-12 00:10:00Z|, -10*60-1, :second)
~U[2020-08-09 23:59:59Z]
iex> Tempus.add(slots, ~U|2020-08-11 23:00:00Z|, 10*60, :second)
~U[2020-08-12 00:10:00Z]
iex> Tempus.add(slots, ~U|2020-08-12 00:00:00Z|, 10*60, :second)
~U[2020-08-12 00:10:00Z]
iex> Tempus.add(slots, ~U|2020-08-09 23:55:00Z|, 10*60, :second)
~U[2020-08-12 00:05:00Z]
iex> Tempus.add(slots, ~U|2020-08-08 23:55:00Z|, 10*60, :second)
~U[2020-08-09 00:05:00Z]
iex> Tempus.add(slots, ~U|2020-08-06 23:55:00Z|, 2*3600*24 + 10*60, :second)
~U[2020-08-12 00:05:00Z]
iex> slots = Tempus.Slots.new(:stream, [])
iex> Tempus.add(slots, ~U|2020-08-12 01:00:00Z|, 0, :second)
~U[2020-08-12 01:00:00Z]
iex> Tempus.add(slots, ~U|2020-08-11 23:00:00Z|, 5*60+1, :second)
~U[2020-08-11 23:05:01.000000Z]
iex> Tempus.add(slots, ~U|2020-08-11 23:00:00Z|, -10*60+1, :second)
~U[2020-08-11 22:50:01Z]
iex> slots = Tempus.Slots.new(:list, [])
iex> Tempus.add(slots, ~U|2020-08-12 01:00:00Z|, 0, :second)
~U[2020-08-12 01:00:00Z]
iex> Tempus.add(slots, ~U|2020-08-11 23:00:00Z|, 5*60+1, :second)
~U[2020-08-11 23:05:01Z]
iex> Tempus.add(slots, ~U|2020-08-11 23:00:00Z|, -10*60+1, :second)
~U[2020-08-11 22:50:01Z]
iex> slots |> Tempus.add(1) |> DateTime.to_date()
Date.utc_today()
"""
@spec add(
slots :: Slots.t(),
origin :: DateTime.t(),
amount_to_add :: integer(),
unit :: System.time_unit()
) :: DateTime.t()
@telemetria level: :debug
def add(slots, origin \\ DateTime.utc_now(), amount_to_add, unit \\ :second)
def add(%Slots{slots: %Slots.List{slots: []}}, origin, amount_to_add, unit) do
DateTime.add(origin, amount_to_add, unit)
end
def add(slots, origin, 0, unit) do
case next_free(slots, origin: origin) do
%{from: %DateTime{} = from} ->
[from, origin]
|> Enum.max(DateTime)
|> DateTime.truncate(unit)
_ ->
origin
end
end
def add(slots, origin, amount_to_add, unit) when amount_to_add > 0 do
amount_in_microseconds = System.convert_time_unit(amount_to_add, unit, :microsecond)
slots
|> next_free(origin: origin, count: :infinity, direction: :fwd)
|> Enum.reduce_while({origin, amount_in_microseconds}, fn
%Slot{from: from, to: to}, {dt, ms} ->
from = [from, dt] |> Enum.reject(&is_nil/1) |> Enum.max(DateTime)
if is_nil(to) or
Slot.duration(%Slot{from: from, to: to}, :microsecond) > ms do
{:halt, DateTime.add(from, ms, :microsecond)}
else
{:cont, {to, ms - Slot.duration(%Slot{from: from, to: to}, :microsecond)}}
end
end)
|> case do
%DateTime{} = result ->
DateTime.truncate(result, unit)
{dt, rest} when is_integer(rest) ->
DateTime.add(dt, rest, :microsecond)
end
end
def add(slots, origin, amount_to_add, unit) when amount_to_add < 0 do
amount_in_microseconds = System.convert_time_unit(-amount_to_add, unit, :microsecond)
slots
|> Slots.inverse()
|> Enum.reduce_while([], fn
%Slot{from: nil} = slot, slots ->
{:cont, [slot | slots]}
%Slot{from: from} = _slot, collected when is_coming_before(origin, from) ->
{:halt, do_calc_subtract(collected, amount_in_microseconds)}
%Slot{to: to} = slot, collected when is_coming_before(to, origin) ->
collected =
[slot | collected]
|> Enum.reduce_while({[], 0}, fn
_slot, {collected, ms} when ms >= amount_in_microseconds ->
{:halt, {collected, ms}}
slot, {collected, ms} ->
case Slot.duration(slot, :microsecond) do
:infinity -> {:halt, {[slot | collected], ms}}
duration when is_integer(duration) -> {:cont, {[slot | collected], ms + duration}}
end
end)
|> elem(0)
|> Enum.reverse()
{:cont, collected}
%Slot{from: from}, collected ->
slot = %Slot{from: from, to: origin}
{:halt, do_calc_subtract([slot | collected], amount_in_microseconds)}
end)
|> case do
%DateTime{} = dt -> DateTime.truncate(dt, unit)
[] -> DateTime.add(origin, amount_to_add, unit)
nil -> nil
[%Slot{from: nil, to: nil}] -> DateTime.add(origin, amount_to_add, unit)
end
end
@doc """
Syntactic sugar for `Tempus.Slots.Stream.iterate/3` with default options `return_as: :slots, join: true`.
### Examples
iex> import Tempus.Sigils
...> Tempus.stream(~D|2024-01-20|, &Tempus.Slot.shift(&1, by: rem(&1.from.day, 2) + 1, unit: :day)) |> Enum.take(3)
[~I(2024-01-20T00:00:00.000000Z → 2024-01-21T23:59:59.999999Z),
~I(2024-01-23T00:00:00.000000Z → 2024-01-23T23:59:59.999999Z),
~I(2024-01-25T00:00:00.000000Z → 2024-01-25T23:59:59.999999Z)]
"""
@spec stream(Slot.origin(), (Slot.t() -> Slot.t())) :: Slots.t(Slots.Stream)
@dialyzer {:nowarn_function, [stream: 2]}
def stream(start_value, next_fun) do
Slots.Stream.iterate(start_value, next_fun, join: true, return_as: :slots)
end
@doc """
Takes an amount of elements from the beginning of the `Tempus.Slots` structure, returning `Tempus.Slots.List` instance.
If amount is 0, it returns `Tempus.Slots.List.new/0`.
### Examples
iex> import Tempus.Sigils
...> Tempus.stream(~D|2024-01-20|, &Tempus.Slot.shift(&1, by: rem(&1.from.day, 2) + 1, unit: :day)) |> Tempus.take(3)
Tempus.Slots.new(:list, [~I(2024-01-20T00:00:00.000000Z → 2024-01-21T23:59:59.999999Z),
~I(2024-01-23T00:00:00.000000Z → 2024-01-23T23:59:59.999999Z),
~I(2024-01-25T00:00:00.000000Z → 2024-01-25T23:59:59.999999Z)])
"""
@spec take(Slots.t(), non_neg_integer()) :: Slots.t(Slots.List)
def take(%Slots{} = slots, count) do
Slots.new(:list, Enum.take(slots, count))
end
@doc """
Creates the new instance of `Tempus.Slots.t(Tempus.Slots.Stream.t())` backed by stream.
This is a convinience wrapper for `Tempus.Slots.new(:stream, data)`.
"""
@spec new(data :: Slots.container()) :: Slots.t(Slots.Stream)
def new(data), do: Slots.new(:stream, data)
defp do_calc_subtract(slots, amount) do
Enum.reduce_while(slots, amount, fn %Slot{} = slot, ms ->
duration = Slot.duration(slot, :microsecond)
if duration < ms,
do: {:cont, ms - duration},
else: {:halt, DateTime.add(slot.to, -ms, :microsecond)}
end)
end
@doc """
Creates a `Tempus.Slots.Stream.t()` slots stream by a cron-like definition.
### Examples
iex> import Tempus.Sigils
iex> ~U[2024-06-07 12:00:00Z] |> Tempus.parse_cron("10-30/15 */4 1 */1 6,7") |> Enum.take(5)
[
~I(2024-06-08T00:10:00Z → 2024-06-08T00:10:00Z),
~I(2024-06-08T00:25:00Z → 2024-06-08T00:25:00Z),
~I(2024-06-08T04:10:00Z → 2024-06-08T04:10:00Z),
~I(2024-06-08T04:25:00Z → 2024-06-08T04:25:00Z),
~I(2024-06-08T08:10:00Z → 2024-06-08T08:10:00Z)
]
"""
@spec parse_cron(origin :: Slot.origin() | nil, cron :: binary() | Tempus.Crontab.t()) ::
Tempus.Slots.t(Stream)
def parse_cron(nil, cron), do: parse_cron(DateTime.utc_now(), cron)
def parse_cron(origin, cron) do
require Tempus.Slots.Stream
slots =
origin
|> Tempus.Crontab.next_as_stream(cron)
|> Stream.map(&Keyword.fetch!(&1, :next))
|> Enum.into(Tempus.Slots.Stream.slots())
%Tempus.Slots{slots: slots}
end
@spec options(opts :: options()) :: options_tuple()
defp options(opts) when is_list(opts) do
origin =
opts
|> Keyword.get(:origin, DateTime.utc_now())
|> Slot.wrap()
case Keyword.get(opts, :count, 0) do
atom when is_atom(atom) ->
{origin, atom, if(Keyword.get(opts, :direction, :fwd) == :bwd, do: -1, else: 1)}
count when is_integer(count) ->
iterator =
if :erlang.xor(count < 0, Keyword.get(opts, :direction, :fwd) == :bwd), do: -1, else: 1
{origin, abs(count), iterator}
other ->
raise(Tempus.ArgumentError, expected: Integer, passed: other)
end
end
end