defmodule Timex.Interval do
@moduledoc """
This module is used for creating and manipulating date/time intervals.
## Examples
iex> use Timex
...> Interval.new(from: ~D[2016-03-03], until: [days: 3])
%#{__MODULE__}{from: ~N[2016-03-03 00:00:00], left_open: false, right_open: true, step: [days: 1], until: ~N[2016-03-06 00:00:00]}
iex> use Timex
...> Interval.new(from: ~D[2016-03-03], until: ~N[2016-03-10 01:23:45])
%Timex.Interval{from: ~N[2016-03-03 00:00:00], left_open: false, right_open: true, step: [days: 1], until: ~N[2016-03-10 01:23:45]}
iex> use Timex
...> ~N[2016-03-04 12:34:56] in Interval.new(from: ~D[2016-03-03], until: [days: 3])
true
iex> use Timex
...> ~D[2016-03-01] in Interval.new(from: ~D[2016-03-03], until: [days: 3])
false
iex> use Timex
...> Interval.overlaps?(Interval.new(from: ~D[2016-03-01], until: [days: 5]), Interval.new(from: ~D[2016-03-03], until: [days: 3]))
true
iex> use Timex
...> Interval.overlaps?(Interval.new(from: ~D[2016-03-01], until: [days: 1]), Interval.new(from: ~D[2016-03-03], until: [days: 3]))
false
"""
alias Timex.Duration
defmodule FormatError do
@moduledoc """
Thrown when an error occurs with formatting an Interval
"""
defexception message: "Unable to format interval!"
def exception(message: message) do
%FormatError{message: message}
end
end
@type t :: %__MODULE__{}
@type valid_step_unit ::
:microseconds
| :milliseconds
| :seconds
| :minutes
| :hours
| :days
| :weeks
| :months
| :years
@type valid_interval_step :: {valid_step_unit, integer}
@type valid_interval_steps :: [valid_interval_step]
@enforce_keys [:from, :until]
defstruct from: nil,
until: nil,
left_open: false,
right_open: true,
step: [days: 1]
@valid_step_units [
:microseconds,
:milliseconds,
:seconds,
:minutes,
:hours,
:days,
:weeks,
:months,
:years
]
@doc """
Create a new Interval struct.
**Note:** By default intervals are left closed, i.e. they include the `from` date/time,
and exclude the `until` date/time. Put another way, `from <= x < until`. This behavior
matches that of other popular date/time libraries, such as Joda Time, as well as the SQL
behavior of the `overlaps` keyword.
Options:
- `from`: The date the interval starts at. Should be a `(Naive)DateTime`.
- `until`: Either a `(Naive)DateTime`, or a time shift that will be applied to the `from` date.
This value _must_ be greater than `from`, otherwise an error will be returned.
- `left_open`: Whether the interval is left open. See explanation below.
- `right_open`: Whether the interval is right open. See explanation below.
- `step`: The step to use when iterating the interval, defaults to `[days: 1]`
The terms `left_open` and `right_open` come from the mathematical concept of intervals. You
can see more detail on the theory [on Wikipedia](https://en.wikipedia.org/wiki/Interval_(mathematics)),
but it can be more intuitively thought of like so:
- An "open" bound is exclusive, and a "closed" bound is inclusive
- So a left-closed interval includes the `from` value, and a left-open interval does not.
- Likewise, a right-closed interval includes the `until` value, and a right-open interval does not.
- An open interval is both left and right open, conversely, a closed interval is both left and right closed.
**Note:** `until` shifts delegate to `Timex.shift`, so the options provided should match its valid options.
## Examples
iex> use Timex
...> Interval.new(from: ~D[2014-09-22], until: ~D[2014-09-29])
...> |> Interval.format!("%Y-%m-%d", :strftime)
"[2014-09-22, 2014-09-29)"
iex> use Timex
...> Interval.new(from: ~D[2014-09-22], until: [days: 7])
...> |> Interval.format!("%Y-%m-%d", :strftime)
"[2014-09-22, 2014-09-29)"
iex> use Timex
...> Interval.new(from: ~D[2014-09-22], until: [days: 7], left_open: true, right_open: false)
...> |> Interval.format!("%Y-%m-%d", :strftime)
"(2014-09-22, 2014-09-29]"
iex> use Timex
...> Interval.new(from: ~N[2014-09-22T15:30:00], until: [minutes: 20], right_open: false)
...> |> Interval.format!("%H:%M", :strftime)
"[15:30, 15:50]"
"""
@spec new(Keyword.t()) ::
t
| {:error, :invalid_until}
| {:error, :invalid_step}
def new(options \\ []) do
from =
case Keyword.get(options, :from) do
nil ->
Timex.Protocol.NaiveDateTime.now()
%NaiveDateTime{} = d ->
d
d ->
Timex.to_naive_datetime(d)
end
left_open = Keyword.get(options, :left_open, false)
right_open = Keyword.get(options, :right_open, true)
step = Keyword.get(options, :step, days: 1)
until =
case Keyword.get(options, :until, days: 1) do
{:error, _} = err ->
err
x when is_list(x) ->
Timex.shift(from, x)
%NaiveDateTime{} = d ->
d
d ->
Timex.to_naive_datetime(d)
end
cond do
invalid_step?(step) ->
{:error, :invalid_step}
invalid_until?(until) ->
{:error, :invalid_until}
Timex.compare(until, from) <= 0 ->
{:error, :invalid_until}
:else ->
%__MODULE__{
from: from,
until: until,
step: step,
left_open: left_open,
right_open: right_open
}
end
end
defp invalid_until?({:error, _}), do: true
defp invalid_until?(_), do: false
defp invalid_step?([]), do: false
defp invalid_step?([{unit, n} | rest]) when unit in @valid_step_units and is_integer(n) do
invalid_step?(rest)
end
defp invalid_step?(_), do: true
@doc """
Return the interval duration, given a unit.
When the unit is one of `:seconds`, `:minutes`, `:hours`, `:days`, `:weeks`, `:months`, `:years`, the result is an `integer`.
When the unit is `:duration`, the result is a `Duration` struct.
## Example
iex> use Timex
...> Interval.new(from: ~D[2014-09-22], until: [months: 5])
...> |> Interval.duration(:months)
5
iex> use Timex
...> Interval.new(from: ~N[2014-09-22T15:30:00], until: [minutes: 20])
...> |> Interval.duration(:duration)
Duration.from_minutes(20)
"""
def duration(%__MODULE__{until: until, from: from}, :duration) do
Timex.diff(until, from, :microseconds) |> Duration.from_microseconds()
end
def duration(%__MODULE__{until: until, from: from}, unit) do
Timex.diff(until, from, unit)
end
@doc """
Change the step value for the provided interval.
The step should be a keyword list valid for use with `Timex.Date.shift`.
## Examples
iex> use Timex
...> Interval.new(from: ~D[2014-09-22], until: [days: 3], right_open: true)
...> |> Interval.with_step([days: 1]) |> Enum.map(&Timex.format!(&1, "%Y-%m-%d", :strftime))
["2014-09-22", "2014-09-23", "2014-09-24"]
iex> use Timex
...> Interval.new(from: ~D[2014-09-22], until: [days: 3], right_open: false)
...> |> Interval.with_step([days: 1]) |> Enum.map(&Timex.format!(&1, "%Y-%m-%d", :strftime))
["2014-09-22", "2014-09-23", "2014-09-24", "2014-09-25"]
iex> use Timex
...> Interval.new(from: ~D[2014-09-22], until: [days: 3], right_open: false)
...> |> Interval.with_step([days: 2]) |> Enum.map(&Timex.format!(&1, "%Y-%m-%d", :strftime))
["2014-09-22", "2014-09-24"]
iex> use Timex
...> Interval.new(from: ~D[2014-09-22], until: [days: 3], right_open: false)
...> |> Interval.with_step([days: 3]) |> Enum.map(&Timex.format!(&1, "%Y-%m-%d", :strftime))
["2014-09-22", "2014-09-25"]
"""
@spec with_step(t, valid_interval_steps) :: t | {:error, :invalid_step}
def with_step(%__MODULE__{} = interval, step) do
if invalid_step?(step) do
{:error, :invalid_step}
else
%__MODULE__{interval | step: step}
end
end
@doc """
Formats the interval as a human readable string.
## Examples
iex> use Timex
...> Interval.new(from: ~D[2014-09-22], until: [days: 3])
...> |> Interval.format!("%Y-%m-%d %H:%M", :strftime)
"[2014-09-22 00:00, 2014-09-25 00:00)"
iex> use Timex
...> Interval.new(from: ~D[2014-09-22], until: [days: 3])
...> |> Interval.format!("%Y-%m-%d", :strftime)
"[2014-09-22, 2014-09-25)"
"""
def format(%__MODULE__{} = interval, format, formatter \\ nil) do
case Timex.format(interval.from, format, formatter) do
{:error, _} = err ->
err
{:ok, from} ->
case Timex.format(interval.until, format, formatter) do
{:error, _} = err ->
err
{:ok, until} ->
lopen = if interval.left_open, do: "(", else: "["
ropen = if interval.right_open, do: ")", else: "]"
{:ok, "#{lopen}#{from}, #{until}#{ropen}"}
end
end
end
@doc """
Same as `format/3`, but raises a `Timex.Interval.FormatError` on failure.
"""
def format!(%__MODULE__{} = interval, format, formatter \\ nil) do
case format(interval, format, formatter) do
{:ok, str} ->
str
{:error, e} ->
raise FormatError, message: "#{inspect(e)}"
end
end
@doc """
Returns true if the first interval includes every point in time the second includes.
## Examples
iex> #{__MODULE__}.contains?(#{__MODULE__}.new(from: ~D[2018-01-01], until: ~D[2018-01-31]), #{
__MODULE__
}.new(from: ~D[2018-01-01], until: ~D[2018-01-30]))
true
iex> #{__MODULE__}.contains?(#{__MODULE__}.new(from: ~D[2018-01-01], until: ~D[2018-01-30]), #{
__MODULE__
}.new(from: ~D[2018-01-01], until: ~D[2018-01-31]))
false
iex> #{__MODULE__}.contains?(#{__MODULE__}.new(from: ~D[2018-01-01], until: ~D[2018-01-10]), #{
__MODULE__
}.new(from: ~D[2018-01-05], until: ~D[2018-01-15]))
false
"""
@spec contains?(__MODULE__.t(), __MODULE__.t()) :: boolean()
def contains?(%__MODULE__{} = a, %__MODULE__{} = b) do
Timex.compare(min(a), min(b)) <= 0 && Timex.compare(max(a), max(b)) >= 0
end
@doc """
Returns true if the first interval shares any point(s) in time with the second.
## Examples
iex> #{__MODULE__}.overlaps?(#{__MODULE__}.new(from: ~D[2016-03-04], until: [days: 1]), #{
__MODULE__
}.new(from: ~D[2016-03-03], until: [days: 3]))
true
iex> #{__MODULE__}.overlaps?(#{__MODULE__}.new(from: ~D[2016-03-07], until: [days: 1]), #{
__MODULE__
}.new(from: ~D[2016-03-03], until: [days: 3]))
false
"""
@spec overlaps?(__MODULE__.t(), __MODULE__.t()) :: boolean()
def overlaps?(%__MODULE__{} = a, %__MODULE__{} = b) do
cond do
Timex.compare(max(a), min(b)) < 0 ->
# a is completely before b
false
Timex.compare(max(b), min(a)) < 0 ->
# b is completely before a
false
:else ->
# a and b have overlapping elements
true
end
end
@doc """
Removes one interval from another which may reduce, split, or
eliminate the original interval. Returns a (possibly empty) list
of intervals representing the remaining time.
## Graphs
The following textual graphs show all the ways that the original interval and
the removal can relate to each other, and the action that `difference/2` will
take in each case.
The original interval is drawn with `O`s and the removal interval with `X`s.
# return original
OO
XX
# trim end
OOO
XXX
# trim end
OOOOO
XXX
# split
OOOOOOO
XXX
# eliminate
OO
XX
# eliminate
OO
XXXX
# trim beginning
OOOO
XX
# eliminate
OO
XXXXXX
# eliminate
OO
XXXXXX
# trim beginning
OOO
XXX
# return original
OO
XX
## Examples
iex> #{__MODULE__}.difference(#{__MODULE__}.new(from: ~N[2018-01-01 02:00:00.000], until: ~N[2018-01-01 04:00:00.000]), #{
__MODULE__
}.new(from: ~N[2018-01-01 03:00:00.000], until: ~N[2018-01-01 05:00:00.000]))
[%#{__MODULE__}{from: ~N[2018-01-01 02:00:00.000], left_open: false, right_open: true, step: [days: 1], until: ~N[2018-01-01 03:00:00.000]}]
iex> #{__MODULE__}.difference(#{__MODULE__}.new(from: ~N[2018-01-01 01:00:00.000], until: ~N[2018-01-01 05:00:00.000]), #{
__MODULE__
}.new(from: ~N[2018-01-01 02:00:00.000], until: ~N[2018-01-01 03:00:00.000]))
[%#{__MODULE__}{from: ~N[2018-01-01 01:00:00.000], left_open: false, right_open: true, step: [days: 1], until: ~N[2018-01-01 02:00:00.000]}, %#{
__MODULE__
}{from: ~N[2018-01-01 03:00:00.000], left_open: false, right_open: true, step: [days: 1], until: ~N[2018-01-01 05:00:00.000]}]
iex> #{__MODULE__}.difference(#{__MODULE__}.new(from: ~N[2018-01-01 02:00:00.000], until: ~N[2018-01-01 04:00:00.000]), #{
__MODULE__
}.new(from: ~N[2018-01-01 01:00:00.000], until: ~N[2018-01-01 05:00:00.000]))
[]
"""
@spec difference(__MODULE__.t(), __MODULE__.t()) :: [__MODULE__.t()]
def difference(%__MODULE__{} = original, %__MODULE__{} = removal) do
cond do
contains?(removal, original) ->
# eliminate
[]
!overlaps?(removal, original) ->
# return original
[original]
Timex.compare(min(removal), min(original)) <= 0 ->
# trim start
[Map.put(original, :from, Map.get(removal, :until))]
Timex.compare(max(original), max(removal)) <= 0 ->
# trim end
[Map.put(original, :until, Map.get(removal, :from))]
true ->
# split
part_before = Map.put(original, :until, Map.get(removal, :from))
part_after = Map.put(original, :from, Map.get(removal, :until))
[part_before, part_after]
end
end
@doc false
def min(interval)
def min(%__MODULE__{from: from, left_open: false}), do: from
def min(%__MODULE__{from: from, step: step}) do
case Timex.shift(from, step) do
{:error, {:unknown_shift_unit, unit}} ->
raise FormatError, message: "Invalid step unit for interval: #{inspect(unit)}"
d ->
d
end
end
@doc false
def max(interval)
def max(%__MODULE__{until: until, right_open: false}), do: until
def max(%__MODULE__{until: until}), do: Timex.shift(until, microseconds: -1)
defimpl Enumerable, for: Timex.Interval do
alias Timex.Interval
def reduce(%Interval{until: until, right_open: open?, step: step} = i, acc, fun) do
do_reduce({Interval.min(i), until, open?, step}, acc, fun)
end
defp do_reduce(_state, {:halt, acc}, _fun),
do: {:halted, acc}
defp do_reduce(state, {:suspend, acc}, fun),
do: {:suspended, acc, &do_reduce(state, &1, fun)}
defp do_reduce({current_date, end_date, right_open, step}, {:cont, acc}, fun) do
if has_interval_ended?(current_date, end_date, right_open) do
{:done, acc}
else
case Timex.shift(current_date, step) do
{:error, {:unknown_shift_unit, unit}} ->
raise FormatError, message: "Invalid step unit for interval: #{inspect(unit)}"
{:error, err} ->
raise FormatError,
message: "Failed to shift to next element in interval: #{inspect(err)}"
next_date ->
do_reduce({next_date, end_date, right_open, step}, fun.(current_date, acc), fun)
end
end
end
defp has_interval_ended?(current_date, end_date, _right_open = true),
do: Timex.compare(current_date, end_date) >= 0
defp has_interval_ended?(current_date, end_date, _right_open = false),
do: Timex.compare(current_date, end_date) > 0
def member?(%Interval{} = interval, value) do
result =
cond do
before?(interval, value) ->
false
after?(interval, value) ->
false
:else ->
true
end
{:ok, result}
end
defp before?(%Interval{from: from, left_open: true}, value),
do: Timex.compare(value, from) <= 0
defp before?(%Interval{from: from, left_open: false}, value),
do: Timex.compare(value, from) < 0
defp after?(%Interval{until: until, right_open: true}, value),
do: Timex.compare(value, until) >= 0
defp after?(%Interval{until: until, right_open: false}, value),
do: Timex.compare(value, until) > 0
def count(_interval) do
{:error, __MODULE__}
end
def slice(_interval) do
{:error, __MODULE__}
end
end
end