Lifecycle & Calendar Interop
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)
Anatomy of a decision
Every lifecycle entry point returns an ExBooking.Decision. The contract:
{:error, _} is reserved for malformed input — a rejected booking is still
a successful decision, with status, machine-readable reasons, and
alternatives you can show the invitee.
A fixed cast for this notebook:
meeting_type = %ExBooking.MeetingType{
id: "demo",
duration_min: 30,
slot_interval_min: 30,
cancellation_policy: %{allowed: true, min_notice_min: 1440}
}
alice = %ExBooking.Resource{
id: "alice",
timezone: "Etc/UTC",
busy: [ExBooking.Interval.new!(~U[2026-07-13 10:00:00Z], ~U[2026-07-13 11:00:00Z])]
}
rule = %ExBooking.AvailabilityRule{
timezone: "Etc/UTC",
windows: [%{weekday: 1, start_time: ~T[09:00:00], end_time: ~T[12:00:00]}],
lead_time_min: 60
}
now = ~U[2026-07-13 08:00:00Z]
horizon = [from: ~U[2026-07-13 00:00:00Z], until: ~U[2026-07-14 00:00:00Z]]
request = fn start_at, minutes ->
%ExBooking.Request{
meeting_type_id: "demo",
invitee_timezone: "Etc/UTC",
slot: ExBooking.Interval.new!(start_at, DateTime.add(start_at, minutes, :minute))
}
end
Status :ok — a bookable slot produces resource ids, one event, and
ordered intents:
{:ok, ok_decision} =
ExBooking.decide(request.(~U[2026-07-13 09:30:00Z], 30), meeting_type, [alice], [rule],
now: now
)
{ok_decision.status, ok_decision.resource_ids, Enum.map(ok_decision.intents, &elem(&1, 0))}
{:ok, ["alice"], [:calendar_event, :notify, :emit]}
Status :conflict — the slot collides with busy time. Pass the horizon
and the decision also assembles real alternatives:
{:ok, conflict} =
ExBooking.decide(
request.(~U[2026-07-13 10:00:00Z], 30),
meeting_type,
[alice],
[rule],
[now: now] ++ horizon
)
{conflict.status, conflict.reasons, Enum.map(conflict.alternatives, & &1.start_at)}
{:conflict,
[
{:conflict, "alice",
%ExBooking.Interval{
start_at: ~U[2026-07-13 10:00:00Z],
end_at: ~U[2026-07-13 11:00:00Z],
kind: nil,
meta: nil
}}
], [~U[2026-07-13 09:30:00Z], ~U[2026-07-13 09:00:00Z], ~U[2026-07-13 11:00:00Z]]}
Status :policy_reject — the slot is free and inside the window, but
violates policy: at 08:45 a 09:30 start is 15 minutes short of the rule's
60-minute lead time. The violation is quantified, ready for UI copy:
{:ok, reject} =
ExBooking.decide(request.(~U[2026-07-13 09:30:00Z], 30), meeting_type, [alice], [rule],
now: ~U[2026-07-13 08:45:00Z]
)
{reject.status, reject.reasons}
{:policy_reject, [lead_time: 15]}
Status :needs_routing — no candidate resource exists for the request at
all (here: an empty team); the consumer should route the request elsewhere
instead of showing a conflict:
{:ok, unroutable} =
ExBooking.decide(request.(~U[2026-07-13 09:30:00Z], 30), meeting_type, [], [], now: now)
{unroutable.status, Enum.map(unroutable.reasons, &elem(&1, 0))}
{:needs_routing, [:no_eligible_resource]}
The hold lifecycle
Two-phase booking: reserve with a hold at checkout, confirm on payment, release on expiry. The consumer owns hold ids, expiry timestamps, and the clock; the kernel returns the transitions.
Reserve — same decide/5, plus your hold:
slot_0930 = request.(~U[2026-07-13 09:30:00Z], 30).slot
hold = %ExBooking.Hold{
id: "hold_7",
slot: slot_0930,
resource_ids: ["alice"],
meeting_type_id: "demo",
expires_at: ~U[2026-07-13 08:10:00Z]
}
{:ok, reserved} =
ExBooking.decide(request.(~U[2026-07-13 09:30:00Z], 30), meeting_type, [alice], [rule],
now: now,
hold: hold
)
{hd(reserved.events).type, reserved.intents |> Enum.map(&elem(&1, 0))}
{:booking_reserved, [:reserve, :emit]}
Expire — you noticed expires_at has passed; the kernel hands back the
release intent and the canonical event:
{:ok, expired} = ExBooking.expire_hold(hold, [])
{expired.intents |> hd(), hd(expired.events).type}
{{:release, "hold_7"}, :booking_expired}
Reschedule, cancel, no-show
ExBooking.reschedule/6 moves an existing booking, optionally releasing the
old hold, and stamps the event with the from/to pair:
{:ok, moved} =
ExBooking.reschedule(
slot_0930,
request.(~U[2026-07-13 11:30:00Z], 30),
meeting_type,
[alice],
[rule],
now: now,
release_hold_id: "hold_7"
)
{moved.status, hd(moved.events).type, Enum.map(moved.intents, &elem(&1, 0))}
{:ok, :booking_rescheduled, [:release, :calendar_event, :notify, :emit]}
Cancellation is a pure policy question first (evaluate_cancellation/3),
a transition second (cancel/3). Under the 24-hour notice policy, same-day
cancellation is refused — as a decision, not an error:
{:ok, refused} = ExBooking.cancel(slot_0930, meeting_type, now: now, resource_ids: ["alice"])
{refused.status, refused.reasons}
{:policy_reject, [{:policy, :cancellation, :min_notice}]}
{:ok, canceled} =
ExBooking.cancel(slot_0930, meeting_type,
now: ~U[2026-07-10 08:00:00Z],
resource_ids: ["alice"]
)
{canceled.status, hd(canceled.events).type, Enum.map(canceled.intents, &elem(&1, 0))}
{:ok, :booking_canceled, [:calendar_event, :notify, :emit]}
No-show marking emits the canonical event for analytics and billing; fees and detection are consumer concerns:
{:ok, no_show} = ExBooking.mark_no_show(slot_0930, meeting_type, resource_ids: ["alice"])
hd(no_show.events).type
:booking_no_show
Recurrence: a small RRULE expander
ExBooking.expand_rrule/4 expands a dependency-free RFC 5545 subset —
FREQ (daily/weekly), INTERVAL, COUNT, UTC UNTIL, weekly BYDAY —
into UTC intervals over an explicit horizon. Unsupported parts fail loudly
instead of being ignored:
{:ok, standups} =
ExBooking.expand_rrule(
"FREQ=WEEKLY;COUNT=4;BYDAY=MO,WE",
~U[2026-07-13 09:00:00Z],
15,
from: ~U[2026-07-13 00:00:00Z],
until: ~U[2026-08-01 00:00:00Z]
)
Enum.map(standups, & &1.start_at)
[~U[2026-07-13 09:00:00Z], ~U[2026-07-15 09:00:00Z], ~U[2026-07-20 09:00:00Z],
~U[2026-07-22 09:00:00Z]]
ExBooking.expand_rrule("FREQ=MONTHLY;BYMONTHDAY=13", ~U[2026-07-13 09:00:00Z], 30,
from: ~U[2026-07-13 00:00:00Z],
until: ~U[2026-08-01 00:00:00Z]
)
{:error, {:unsupported, :rrule, "BYMONTHDAY"}}
Importing busy time: ICS and JSCalendar
ExBooking.import_ics_free_busy/1 parses FREEBUSY lines from
caller-supplied ICS text — including folded lines, start/duration periods,
and FBTYPE=FREE periods (validated but excluded from the busy result):
ics = """
BEGIN:VCALENDAR
BEGIN:VFREEBUSY
FREEBUSY:20260713T090000Z/20260713T093000Z,20260713T100000Z/PT1H
FREEBUSY;FBTYPE=FREE:20260713T120000Z/20260713T130000Z
END:VFREEBUSY
END:VCALENDAR
"""
{:ok, imported_busy} = ExBooking.import_ics_free_busy(ics)
Enum.map(imported_busy, &{&1.kind, &1.start_at, &1.end_at})
[
{:busy, ~U[2026-07-13 09:00:00Z], ~U[2026-07-13 09:30:00Z]},
{:busy, ~U[2026-07-13 10:00:00Z], ~U[2026-07-13 11:00:00Z]}
]
ExBooking.import_jscalendar_busy/1 maps decoded JSCalendar Event and
Group objects; cancelled and free events are skipped:
group = %{
"@type" => "Group",
"entries" => [
%{
"@type" => "Event",
"start" => "2026-07-13T14:00:00",
"timeZone" => "America/New_York",
"duration" => "PT1H"
},
%{
"@type" => "Event",
"start" => "2026-07-13T16:00:00",
"timeZone" => "America/New_York",
"status" => "cancelled"
}
]
}
{:ok, js_busy} = ExBooking.import_jscalendar_busy(group)
Enum.map(js_busy, &{&1.start_at, &1.end_at})
[{~U[2026-07-13 18:00:00Z], ~U[2026-07-13 19:00:00Z]}]
Closing the loop
Imported busy time is just ExBooking.Interval data — feed it straight back
into a resource and the whole pipeline respects the external calendar:
synced_alice = %{alice | busy: alice.busy ++ imported_busy}
{:ok, open} =
ExBooking.available_slots(meeting_type, [synced_alice], [rule],
[now: ~U[2026-07-06 09:00:00Z]] ++ horizon
)
Enum.map(open, & &1.start_at)
[~U[2026-07-13 09:30:00Z], ~U[2026-07-13 11:00:00Z], ~U[2026-07-13 11:30:00Z]]
That is the entire boundary story in one cell: external systems produce normalized data, the kernel produces decisions, and your application executes the intents. Back to the start: A Tour of ExBooking.