Powered by AppSignal & Oban Pro

Availability & Slotting

availability-and-slotting.livemd

Availability & Slotting

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)

From free time to bookable slots

Availability assembly is a pipeline: expand rules to offerable UTC intervals, subtract buffered busy time, cut the remaining free time into candidate slots, filter by policy, and combine per the meeting's participant mode. This notebook walks each stage with ExBooking.Slotting and ExBooking.Availability, then the whole pipeline through ExBooking.available_slots/4.

Duration and step are independent

The single most common scheduling-library bug is coupling slot stepping to meeting duration. ExBooking keeps them orthogonal: duration_min is how long a meeting lasts, slot_interval_min (the step) is how often starts are offered.

A 30-minute meeting offered every 15 minutes in one free hour:

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

free
|> ExBooking.Slotting.generate_slots(30, 15)
|> Enum.map(& &1.start_at)
[~U[2026-07-13 09:00:00Z], ~U[2026-07-13 09:15:00Z], ~U[2026-07-13 09:30:00Z]]

Same free hour, same duration, hourly starts — a completely different offer density. Note every slot must fit entirely inside the free interval, which is why there is no 09:45 start above and only one slot here:

free
|> ExBooking.Slotting.generate_slots(30, 60)
|> Enum.map(& &1.start_at)
[~U[2026-07-13 09:00:00Z]]

Grid alignment

By default the grid anchors to the free interval's start. A meeting ending at 09:07 makes every subsequent offer look like 09:07, 09:22, … Real products usually want tidy starts — align: :clock snaps the grid to UTC clock boundaries instead:

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

for align <- [:free_start, :clock] do
  starts =
    ragged
    |> ExBooking.Slotting.generate_slots(30, 15, align: align)
    |> Enum.map(&Calendar.strftime(&1.start_at, "%H:%M"))

  {align, starts}
end
[free_start: ["09:07", "09:22", "09:37"], clock: ["09:15", "09:30"]]

generate_all/4 maps slotting over many free fragments, deduplicates, and returns one ascending list:

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

fragments
|> ExBooking.Slotting.generate_all(30, 30)
|> Enum.map(& &1.start_at)
[~U[2026-07-13 09:00:00Z], ~U[2026-07-13 09:30:00Z], ~U[2026-07-13 14:00:00Z],
 ~U[2026-07-13 14:30:00Z]]

The full pipeline, one resource

Now the real thing. One consultant, one 09:00–12:00 UTC window, one existing 10:00–10:30 booking, and a meeting type demanding a 10-minute buffer on both sides of any meeting. The buffer inflates the busy block to 09:50–10:40, so free time is 09:00–09:50 and 10:40–12:00 — and slotting can only fit starts at 09:00, 10:40, and 11:10:

meeting_type = %ExBooking.MeetingType{
  id: "consult",
  duration_min: 30,
  slot_interval_min: 30,
  buffers: %{before_min: 10, after_min: 10}
}

consultant = %ExBooking.Resource{
  id: "consultant_1",
  timezone: "Etc/UTC",
  busy: [ExBooking.Interval.new!(~U[2026-07-13 10:00:00Z], ~U[2026-07-13 10:30:00Z])]
}

rule = %ExBooking.AvailabilityRule{
  timezone: "Etc/UTC",
  windows: [%{weekday: 1, start_time: ~T[09:00:00], end_time: ~T[12:00:00]}]
}

{:ok, slots} =
  ExBooking.available_slots(meeting_type, [consultant], [rule],
    now: ~U[2026-07-06 09:00:00Z],
    from: ~U[2026-07-13 00:00:00Z],
    until: ~U[2026-07-14 00:00:00Z]
  )

Enum.map(slots, & &1.start_at)
[~U[2026-07-13 09:00:00Z], ~U[2026-07-13 10:40:00Z], ~U[2026-07-13 11:10:00Z]]

Policies filter slots relative to now. Move now to the same morning with a two-hour lead-time rule and the 09:00 start disappears:

guarded_rule = %{rule | lead_time_min: 120}

{:ok, slots} =
  ExBooking.available_slots(meeting_type, [consultant], [guarded_rule],
    now: ~U[2026-07-13 08:00:00Z],
    from: ~U[2026-07-13 00:00:00Z],
    until: ~U[2026-07-14 00:00:00Z]
  )

Enum.map(slots, & &1.start_at)
[~U[2026-07-13 10:40:00Z], ~U[2026-07-13 11:10:00Z]]

Participant modes

ExBooking.MeetingType supports three participant modes:

  • :one — any single free resource can take the slot (the default)
  • :collectiveevery resource must be free (host + interpreter, panel interviews)
  • :pool — capacity-aware: slots are offered while enough seats remain

For :collective, only the intersection of everyone's free time is offered. Host and interpreter overlap 10:00–12:00 only:

collective = %ExBooking.MeetingType{
  id: "onboarding",
  duration_min: 60,
  slot_interval_min: 60,
  participants: :collective
}

host = %ExBooking.Resource{id: "host", timezone: "Etc/UTC"}
interpreter = %ExBooking.Resource{id: "interpreter", timezone: "Etc/UTC"}

host_rule = %ExBooking.AvailabilityRule{
  timezone: "Etc/UTC",
  windows: [%{weekday: 1, start_time: ~T[09:00:00], end_time: ~T[12:00:00]}]
}

interpreter_rule = %ExBooking.AvailabilityRule{
  timezone: "Etc/UTC",
  windows: [%{weekday: 1, start_time: ~T[10:00:00], end_time: ~T[14:00:00]}]
}

{:ok, slots} =
  ExBooking.available_slots(collective, [host, interpreter], [host_rule, interpreter_rule],
    now: ~U[2026-07-06 09:00:00Z],
    from: ~U[2026-07-13 00:00:00Z],
    until: ~U[2026-07-14 00:00:00Z]
  )

Enum.map(slots, & &1.start_at)
[~U[2026-07-13 10:00:00Z], ~U[2026-07-13 11:00:00Z]]

For :pool, resources carry capacity, and ExBooking.Reservation values consume seats. A class needing 3 seats across two 2-seat rooms is offerable — until an existing reservation eats 2 seats of room A at 10:00:

class = %ExBooking.MeetingType{
  id: "class",
  duration_min: 60,
  slot_interval_min: 60,
  participants: :pool,
  capacity_required: 3
}

room_a = %ExBooking.Resource{
  id: "room_a",
  timezone: "Etc/UTC",
  capacity: 2,
  reservations: [
    %ExBooking.Reservation{
      interval: ExBooking.Interval.new!(~U[2026-07-13 10:00:00Z], ~U[2026-07-13 11:00:00Z]),
      capacity_consumed: 2
    }
  ]
}

room_b = %ExBooking.Resource{id: "room_b", timezone: "Etc/UTC", capacity: 2}

room_rule = %ExBooking.AvailabilityRule{
  timezone: "Etc/UTC",
  windows: [%{weekday: 1, start_time: ~T[09:00:00], end_time: ~T[12:00:00]}]
}

{:ok, slots} =
  ExBooking.available_slots(class, [room_a, room_b], [room_rule, room_rule],
    now: ~U[2026-07-06 09:00:00Z],
    from: ~U[2026-07-13 00:00:00Z],
    until: ~U[2026-07-14 00:00:00Z]
  )

Enum.map(slots, & &1.start_at)
[~U[2026-07-13 09:00:00Z], ~U[2026-07-13 11:00:00Z]]

When a pool booking is decided, the decision reports exactly how the seats were split:

request = %ExBooking.Request{
  meeting_type_id: "class",
  invitee_timezone: "Etc/UTC",
  slot: hd(slots)
}

{:ok, decision} =
  ExBooking.decide(request, class, [room_a, room_b], [room_rule, room_rule],
    now: ~U[2026-07-06 09:00:00Z]
  )

decision.seat_allocations
[%{capacity_consumed: 2, resource_id: "room_a"}, %{capacity_consumed: 1, resource_id: "room_b"}]

Next: Assignment & policy — once a slot is valid, who takes it?