Powered by AppSignal & Oban Pro

A Tour of ExBooking

notebooks/tour.livemd

A Tour of ExBooking

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

deps =
  if File.exists?(Path.join(app_root, "mix.exs")) do
    # Running from a cloned repository: use the local checkout.
    [{:ex_booking, path: app_root}]
  else
    # Running from HexDocs or a downloaded copy: use the released package.
    [{:ex_booking, "~> 0.1"}]
  end

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

Why this library exists

ExBooking is a pure booking kernel: availability search, slot generation, conflict detection, resource assignment, and booking lifecycle transitions — all as deterministic functions. It owns none of your state. There is no database, no process tree, no HTTP, and crucially no clock: every function that depends on the current time takes now as an argument.

That one design rule buys a lot. Every decision in this notebook is reproducible — you can re-run any cell today, next year, or inside a replayed background job and get byte-identical answers. Where a real system needs a side effect (create a calendar event, send a notification), the kernel returns an intent describing it, and your application executes it.

This tour walks the whole surface in one sitting. The other notebooks in this folder dig into each layer: interval algebra, schedules & DST, availability & slotting, assignment & policy, and lifecycle & calendar interop.

The cast

A booking scenario needs three kinds of input, all plain structs you build from your own data:

  • ExBooking.MeetingType — what is being booked: duration, slot grid, participant mode, and lifecycle policies.
  • ExBooking.Resource — who or what can be booked: a timezone, busy time, and optional fairness counters.
  • ExBooking.AvailabilityRule — when a resource is offerable: weekly wall-time windows in a named timezone, plus policy limits.

A 30-minute intro call, offered on a 30-minute grid, with notice policies for rescheduling (4 hours) and cancellation (24 hours):

meeting_type = %ExBooking.MeetingType{
  id: "intro_call",
  duration_min: 30,
  slot_interval_min: 30,
  reschedule_policy: %{allowed: true, min_notice_min: 240},
  cancellation_policy: %{allowed: true, min_notice_min: 1440}
}

Two sales reps on different continents. The New York rep already has a one-hour meeting on the day we will search:

rep_stockholm = %ExBooking.Resource{id: "rep_sto", timezone: "Europe/Stockholm"}

rep_new_york = %ExBooking.Resource{
  id: "rep_ny",
  timezone: "America/New_York",
  busy: [ExBooking.Interval.new!(~U[2026-07-13 18:00:00Z], ~U[2026-07-13 19:00:00Z], kind: :busy)]
}

resources = [rep_stockholm, rep_new_york]

Each resource is paired positionally with an availability rule. Both reps work Mondays 09:00–17:00 in their own timezone; the Stockholm rep also requires two hours of lead time before any booking:

rule_stockholm = %ExBooking.AvailabilityRule{
  timezone: "Europe/Stockholm",
  windows: [%{weekday: 1, start_time: ~T[09:00:00], end_time: ~T[17:00:00]}],
  lead_time_min: 120
}

rule_new_york = %ExBooking.AvailabilityRule{
  timezone: "America/New_York",
  windows: [%{weekday: 1, start_time: ~T[09:00:00], end_time: ~T[17:00:00]}]
}

rules = [rule_stockholm, rule_new_york]

Searching availability

ExBooking.available_slots/4 expands each rule to UTC, subtracts busy time, cuts the free time into candidate slots, applies policies, and returns the union (this meeting books one of the reps). now and the search horizon are explicit inputs:

now = ~U[2026-07-06 09:00:00Z]

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

length(slots)
26

Monday 2026-07-13, 09:00–17:00 in Stockholm is 07:00–15:00 UTC; the same window in New York is 13:00–21:00 UTC. The union covers 07:00–20:30 starts on the half hour, minus the two starts blocked by the New York rep's meeting. Slots always come back sorted ascending by start:

slots |> Enum.take(3) |> Enum.map(&{&1.start_at, &1.end_at})
[
  {~U[2026-07-13 07:00:00Z], ~U[2026-07-13 07:30:00Z]},
  {~U[2026-07-13 07:30:00Z], ~U[2026-07-13 08:00:00Z]},
  {~U[2026-07-13 08:00:00Z], ~U[2026-07-13 08:30:00Z]}
]

The kernel works in UTC; rendering belongs to the caller. For an invitee in London:

slots
|> Enum.take(3)
|> Enum.map(fn slot ->
  slot.start_at |> DateTime.shift_zone!("Europe/London") |> Calendar.strftime("%a %H:%M %Z")
end)
["Mon 08:00 BST", "Mon 08:30 BST", "Mon 09:00 BST"]

Determinism, demonstrated

Same inputs, same output — always. No hidden clock read, no randomness, no process state to warm up:

{:ok, rerun} =
  ExBooking.available_slots(meeting_type, resources, rules,
    now: now,
    from: ~U[2026-07-13 00:00:00Z],
    until: ~U[2026-07-14 00:00:00Z]
  )

rerun == slots
true

Deciding a booking

The invitee picked 13:30 UTC — a time both reps can take. Who gets it? ExBooking.decide/5 validates the request, checks availability and policy, runs an assignment strategy, and returns an ExBooking.Decision. With :round_robin, explicit fairness counters decide — the New York rep has had fewer assignments:

chosen = Enum.find(slots, &(&1.start_at == ~U[2026-07-13 13:30:00Z]))

request = %ExBooking.Request{
  meeting_type_id: "intro_call",
  invitee_timezone: "Europe/London",
  slot: chosen,
  routing_context: %{source: "pricing_page"}
}

fair_resources = [
  %{rep_stockholm | fairness: %{assignments_count: 12}},
  %{rep_new_york | fairness: %{assignments_count: 8}}
]

{:ok, decision} =
  ExBooking.decide(request, meeting_type, fair_resources, rules,
    now: now,
    strategy: :round_robin
  )

{decision.status, decision.resource_ids}
{:ok, ["rep_ny"]}

A successful decision carries a canonical event for your analytics and billing layers:

Enum.map(decision.events, & &1.type)
[:booking_confirmed]

…and ordered intents — the side effects your application should now execute. The kernel never executes them itself:

Enum.map(decision.intents, &elem(&1, 0))
[:calendar_event, :notify, :emit]

Holds: two-phase booking

For checkout-style flows, pass a consumer-built ExBooking.Hold to reserve the slot first. The decision flips to :booking_reserved and asks you to persist the hold:

hold = %ExBooking.Hold{
  id: "hold_42",
  slot: chosen,
  resource_ids: ["rep_ny"],
  meeting_type_id: "intro_call",
  expires_at: ~U[2026-07-06 09:10:00Z]
}

{:ok, reserved} =
  ExBooking.decide(request, meeting_type, fair_resources, rules,
    now: now,
    strategy: :round_robin,
    hold: hold
  )

{hd(reserved.events).type, Enum.map(reserved.intents, &elem(&1, 0))}
{:booking_reserved, [:reserve, :emit]}

You own the clock, so you decide when a hold has expired — then ask the kernel for the canonical transition:

{:ok, expired} = ExBooking.expire_hold(hold, [])
{hd(expired.events).type, hd(expired.intents)}
{:booking_expired, {:release, "hold_42"}}

Rescheduling

ExBooking.reschedule/6 first checks the meeting type's reschedule notice policy against now, then runs a full decision for the new slot:

new_request = %{request | slot: Enum.find(slots, &(&1.start_at == ~U[2026-07-13 15:00:00Z]))}

{:ok, rescheduled} =
  ExBooking.reschedule(chosen, new_request, meeting_type, fair_resources, rules, now: now)

{rescheduled.status, hd(rescheduled.events).type}
{:ok, :booking_rescheduled}

Canceling

Cancellation policy is evaluated against the caller's now. One hour before the meeting is too late under a 24-hour notice policy:

booked = new_request.slot
ExBooking.evaluate_cancellation(booked, meeting_type, now: ~U[2026-07-13 14:00:00Z])
{:ok, %{allowed?: false, reason: :min_notice}}

Three days ahead is fine, and ExBooking.cancel/3 returns the full transition:

{:ok, canceled} =
  ExBooking.cancel(booked, meeting_type,
    now: ~U[2026-07-10 09:00:00Z],
    resource_ids: ["rep_ny"]
  )

{canceled.status, hd(canceled.events).type}
{:ok, :booking_canceled}

Where to go next

Each layer you just used has a dedicated notebook: