Powered by AppSignal & Oban Pro

Interval Algebra

notebooks/interval-algebra.livemd

Interval Algebra

app_root = Path.expand("..", __DIR__)

deps =
  if File.exists?(Path.join(app_root, "mix.exs")) do
    [{:ex_booking, path: app_root}]
  else
    [{:ex_booking, "~> 0.1"}]
  end

Mix.install(deps)
Calendar.put_time_zone_database(Tz.TimeZoneDatabase)

The foundation

Everything in ExBooking — blackout clipping, busy-time subtraction, buffers, slot containment, free-time merging — reduces to algebra over ExBooking.Interval, a half-open UTC interval: [start_at, end_at).

Half-open is the load-bearing choice. An interval that ends at 10:00 does not overlap one that starts at 10:00, so back-to-back bookings are legal by construction, with no ±1 second fudging anywhere in the codebase.

Creating intervals

ExBooking.Interval.new/3 validates and normalizes; new!/3 raises on bad input. The struct carries an optional kind (:busy, :available, :blackout, :hold) and free-form meta:

{:ok, interval} = ExBooking.Interval.new(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 10:00:00Z])
interval
%ExBooking.Interval{
  start_at: ~U[2026-07-13 09:00:00Z],
  end_at: ~U[2026-07-13 10:00:00Z],
  kind: nil,
  meta: nil
}

Empty and reversed intervals are rejected as data errors, not silently swapped:

ExBooking.Interval.new(~U[2026-07-13 10:00:00Z], ~U[2026-07-13 09:00:00Z])
{:error, {:invalid, :interval, :empty_or_reversed}}

Inputs in any timezone are normalized to UTC at the boundary. A 09:00 meeting in New York is stored as 13:00 UTC:

start_local = DateTime.new!(~D[2026-07-13], ~T[09:00:00], "America/New_York")
end_local = DateTime.new!(~D[2026-07-13], ~T[10:00:00], "America/New_York")

local_meeting = ExBooking.Interval.new!(start_local, end_local)
{local_meeting.start_at, local_meeting.end_at}
{~U[2026-07-13 13:00:00Z], ~U[2026-07-13 14:00:00Z]}

Half-open semantics at work

Touching intervals do not overlap — this is the property that makes back-to-back bookings work:

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

Containment is how slot validity is checked — a candidate slot must fit entirely inside offerable time:

working_day = ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 17:00:00Z])
ExBooking.Interval.contains?(working_day, first)
true

Subtraction: how busy time carves up a day

Subtracting a lunch break from a working day leaves two free fragments:

lunch = ExBooking.Interval.new!(~U[2026-07-13 12:00:00Z], ~U[2026-07-13 13:00:00Z])

working_day
|> ExBooking.Interval.subtract(lunch)
|> Enum.map(&{&1.start_at, &1.end_at})
[
  {~U[2026-07-13 09:00:00Z], ~U[2026-07-13 12:00:00Z]},
  {~U[2026-07-13 13:00:00Z], ~U[2026-07-13 17:00:00Z]}
]

subtract_all/2 is the workhorse behind availability: a set of free intervals minus a set of busy intervals. Overlapping busy blocks are handled correctly:

meetings = [
  ExBooking.Interval.new!(~U[2026-07-13 10:00:00Z], ~U[2026-07-13 10:30:00Z]),
  ExBooking.Interval.new!(~U[2026-07-13 10:15:00Z], ~U[2026-07-13 11:00:00Z]),
  ExBooking.Interval.new!(~U[2026-07-13 15:00:00Z], ~U[2026-07-13 16:00:00Z])
]

[working_day]
|> ExBooking.Interval.subtract_all(meetings)
|> Enum.map(&{&1.start_at, &1.end_at})
[
  {~U[2026-07-13 09:00:00Z], ~U[2026-07-13 10:00:00Z]},
  {~U[2026-07-13 11:00:00Z], ~U[2026-07-13 15:00:00Z]},
  {~U[2026-07-13 16:00:00Z], ~U[2026-07-13 17:00:00Z]}
]

Merging: normal form

merge/1 sorts and coalesces. Both overlapping and touching intervals collapse — the two 10:xx meetings above become one block:

meetings
|> ExBooking.Interval.merge()
|> Enum.map(&{&1.start_at, &1.end_at})
[
  {~U[2026-07-13 10:00:00Z], ~U[2026-07-13 11:00:00Z]},
  {~U[2026-07-13 15:00:00Z], ~U[2026-07-13 16:00:00Z]}
]
[first, second]
|> ExBooking.Interval.merge()
|> Enum.map(&{&1.start_at, &1.end_at})
[{~U[2026-07-13 09:00:00Z], ~U[2026-07-13 10:00:00Z]}]

Clipping and intersection

clip/2 bounds one interval by another and returns nil when they are disjoint — this is how blackouts and horizons trim offerable time:

morning = ExBooking.Interval.new!(~U[2026-07-13 00:00:00Z], ~U[2026-07-13 12:00:00Z])
clipped = ExBooking.Interval.clip(working_day, morning)
{clipped.start_at, clipped.end_at}
{~U[2026-07-13 09:00:00Z], ~U[2026-07-13 12:00:00Z]}

intersect/2 runs over whole interval sets in normal form — used when a collective meeting needs every resource free at once:

host_free = [
  ExBooking.Interval.new!(~U[2026-07-13 09:00:00Z], ~U[2026-07-13 11:00:00Z]),
  ExBooking.Interval.new!(~U[2026-07-13 14:00:00Z], ~U[2026-07-13 17:00:00Z])
]

room_free = [ExBooking.Interval.new!(~U[2026-07-13 10:00:00Z], ~U[2026-07-13 15:00:00Z])]

ExBooking.Interval.intersect(host_free, room_free)
|> Enum.map(&{&1.start_at, &1.end_at})
[
  {~U[2026-07-13 10:00:00Z], ~U[2026-07-13 11:00:00Z]},
  {~U[2026-07-13 14:00:00Z], ~U[2026-07-13 15:00:00Z]}
]

Buffers via inflate

inflate/3 widens an interval by minutes on each side. Availability assembly uses it to apply meeting buffers to busy time — a 15-minute prep buffer and a 10-minute wrap-up buffer make a 30-minute meeting block 55 minutes of the calendar:

busy = ExBooking.Interval.new!(~U[2026-07-13 10:00:00Z], ~U[2026-07-13 10:30:00Z])
padded = ExBooking.Interval.inflate(busy, 15, 10)
{padded.start_at, padded.end_at}
{~U[2026-07-13 09:45:00Z], ~U[2026-07-13 10:40:00Z]}

Duration

ExBooking.Interval.duration_min(padded)
55

Next: Schedules & DST — how weekly wall-time windows become these UTC intervals, including on the two worst days of the year.