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.

t()

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

kind()

@type kind() :: :busy | :available | :blackout | :hold | nil

Classification of an interval; nil when irrelevant.

t()

@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

clip(interval, bounds)

@spec clip(t(), t()) :: t() | nil

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]}

contains?(outer, inner)

@spec contains?(t(), t()) :: boolean()

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

duration_min(interval)

@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

inflate(interval, before_min, after_min)

@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]}

intersect(left, right)

@spec intersect([t()], [t()]) :: [t()]

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]}

merge(intervals)

@spec merge([t()]) :: [t()]

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]}

new(start_at, end_at, opts \\ [])

@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}}

new!(start_at, end_at, opts \\ [])

@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

overlaps?(a, b)

@spec overlaps?(t(), t()) :: boolean()

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

subtract(a, b)

@spec subtract(t(), t()) :: [t()]

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]}

subtract_all(minuends, subtrahends)

@spec subtract_all([t()], [t()]) :: [t()]

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]}

validate(arg1)

@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