ExBooking. Interval
(ExBooking v0.1.0)
View Source
Half-open UTC intervals and interval algebra.
Intervals use [start_at, end_at) semantics. That makes back-to-back
bookings legal because an interval ending at 10:00 does not overlap one
starting at 10:00. The algebra is the foundation for blackout clipping,
busy-time subtraction, buffers, slot containment, and free-time merging.
Example
iex> first = ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 09:30:00Z])
...> second = ExBooking.Interval.new!(~U[2026-07-13 09:30:00Z], ~U[2026-07-13 10:00:00Z])
...> ExBooking.Interval.overlaps?(first, second)
false
Summary
Types
Classification of an interval; nil when irrelevant.
A half-open interval [start_at, end_at) between UTC datetimes.
Functions
Intersects interval with bounds, returning nil when disjoint.
Whether outer fully contains inner.
Whole minutes between the interval's endpoints.
Widens an interval by minutes on each side. Used to apply buffers to busy time and requested booking time.
Intersects two interval sets in normal form.
Coalesces overlapping and touching intervals into normal form: sorted,
disjoint, non-adjacent. Merged intervals keep the earliest interval's
kind and meta.
Builds an interval, validating that start_at precedes end_at.
Like new/3, but raises ArgumentError on invalid bounds.
Whether two intervals share any instant. Touching intervals do not overlap.
Subtracts b from a, returning zero, one, or two remainder intervals.
Subtracts every interval in subtrahends from every interval in minuends.
Validates a caller-built interval has increasing UTC DateTime endpoints.
Types
@type kind() :: :busy | :available | :blackout | :hold | nil
Classification of an interval; nil when irrelevant.
@type t() :: %ExBooking.Interval{ end_at: DateTime.t(), kind: kind(), meta: map() | nil, start_at: DateTime.t() }
A half-open interval [start_at, end_at) between UTC datetimes.
Functions
Intersects interval with bounds, returning nil when disjoint.
Examples
iex> a = ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 12:00:00Z])
...> bounds = ExBooking.Interval.new!(~U[2026-07-13 10:00:00Z], ~U[2026-07-13 14:00:00Z])
...> clipped = ExBooking.Interval.clip(a, bounds)
...> {clipped.start_at, clipped.end_at}
{~U[2026-07-13 10:00:00Z], ~U[2026-07-13 12:00:00Z]}
Whether outer fully contains inner.
Examples
iex> outer = ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 12:00:00Z])
...> inner = ExBooking.Interval.new!(~U[2026-07-13 10:00:00Z], ~U[2026-07-13 11:00:00Z])
...> ExBooking.Interval.contains?(outer, inner)
true
@spec duration_min(t()) :: non_neg_integer()
Whole minutes between the interval's endpoints.
Examples
iex> a = ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 09:45:00Z])
...> ExBooking.Interval.duration_min(a)
45
@spec inflate(t(), non_neg_integer(), non_neg_integer()) :: t()
Widens an interval by minutes on each side. Used to apply buffers to busy time and requested booking time.
Examples
iex> a = ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 10:00:00Z])
...> inflated = ExBooking.Interval.inflate(a, 10, 5)
...> {inflated.start_at, inflated.end_at}
{~U[2026-07-13 08:50:00Z], ~U[2026-07-13 10:05:00Z]}
Intersects two interval sets in normal form.
Inputs are normalized before a two-pointer walk, so the result is sorted and
merged without constructing the Cartesian product. Clipping keeps the left
interval's kind and meta, consistently with clip/2.
Examples
iex> available = [ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 12:00:00Z])]
...> staffed = [ExBooking.Interval.new!(~U[2026-07-13 10:00:00Z], ~U[2026-07-13 11:00:00Z])]
...> [common] = ExBooking.Interval.intersect(available, staffed)
...> {common.start_at, common.end_at}
{~U[2026-07-13 10:00:00Z], ~U[2026-07-13 11:00:00Z]}
Coalesces overlapping and touching intervals into normal form: sorted,
disjoint, non-adjacent. Merged intervals keep the earliest interval's
kind and meta.
Examples
iex> a = ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 10:00:00Z])
...> b = ExBooking.Interval.new!(~U[2026-07-13 10:00:00Z], ~U[2026-07-13 11:00:00Z])
...> [merged] = ExBooking.Interval.merge([b, a])
...> {merged.start_at, merged.end_at}
{~U[2026-07-13 09:00:00Z], ~U[2026-07-13 11:00:00Z]}
@spec new(DateTime.t(), DateTime.t(), keyword()) :: {:ok, t()} | {:error, {:invalid, :interval, :datetime_required | :empty_or_reversed}}
Builds an interval, validating that start_at precedes end_at.
Examples
iex> {:ok, interval} =
...> ExBooking.Interval.new(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 10:00:00Z])
...>
...> interval.start_at
~U[2026-07-13 09:00:00Z]
iex> ExBooking.Interval.new(~U[2026-07-13 10:00:00Z], ~U[2026-07-13 10:00:00Z])
{:error, {:invalid, :interval, :empty_or_reversed}}
@spec new!(DateTime.t(), DateTime.t(), keyword()) :: t()
Like new/3, but raises ArgumentError on invalid bounds.
Examples
iex> ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 10:00:00Z]).kind
nil
Whether two intervals share any instant. Touching intervals do not overlap.
Examples
iex> a = ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 10:00:00Z])
...> b = ExBooking.Interval.new!(~U[2026-07-13 10:00:00Z], ~U[2026-07-13 11:00:00Z])
...> ExBooking.Interval.overlaps?(a, b)
false
iex> a = ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 10:00:00Z])
...> b = ExBooking.Interval.new!(~U[2026-07-13 09:30:00Z], ~U[2026-07-13 10:30:00Z])
...> ExBooking.Interval.overlaps?(a, b)
true
Subtracts b from a, returning zero, one, or two remainder intervals.
Remainders keep a's kind and meta.
Examples
iex> a = ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 12:00:00Z])
...> b = ExBooking.Interval.new!(~U[2026-07-13 10:00:00Z], ~U[2026-07-13 11:00:00Z])
...> [left, right] = ExBooking.Interval.subtract(a, b)
...> {left.end_at, right.start_at}
{~U[2026-07-13 10:00:00Z], ~U[2026-07-13 11:00:00Z]}
Subtracts every interval in subtrahends from every interval in minuends.
Returns a sorted, non-overlapping list.
Examples
iex> free = [ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 12:00:00Z])]
...> busy = [ExBooking.Interval.new!(~U[2026-07-13 09:30:00Z], ~U[2026-07-13 10:00:00Z])]
...> [a, b] = ExBooking.Interval.subtract_all(free, busy)
...> {a.end_at, b.start_at}
{~U[2026-07-13 09:30:00Z], ~U[2026-07-13 10:00:00Z]}
@spec validate(t()) :: :ok | {:error, {:invalid, :interval, :datetime_required | :empty_or_reversed | :not_utc}}
Validates a caller-built interval has increasing UTC DateTime endpoints.
Constructors normalize zones, while this boundary validator rejects structs that bypassed them so downstream algebra has one temporal invariant.
Examples
iex> interval = ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 10:00:00Z])
...> ExBooking.Interval.validate(interval)
:ok