Resource-constrained scheduling for Tempo.
Tempo answers when is this free? This library answers what
should I book, and where? — it adds the named resources, the
attributes describing them, the places containing them, and the
requirements a session places on them.
The vocabulary
Resource — a named thing that can be allocated. People and rooms differ only in their attributes. See
Agenda.Resource.Place — a container of resources and other places, forming a tree. Travel between resources is derived from the tree rather than configured. See
Agenda.Place.Requirement — what a session demands, written in the predicate vocabulary of
Agenda.Predicate. SeeAgenda.Requirement.Session — something to be held: how long it runs, the window it must fall inside, and what it demands of resources. A session is a request, not a booking. See
Agenda.Session.Programme — sessions to be laid out together, with whatever relates them: tracks, orderings, preferences and interests. See
Agenda.Programme.Track — sessions that may not overlap each other, and that somebody following them must be able to travel between. Not overlapping is intrinsic to a track rather than an option on one: a set of sessions that may collide is already expressible, as a list. See
Agenda.Track.Interest — one resource would like a session with another. The demand side of the model: where interest is returned,
meetings/3writes the sessions. SeeAgenda.Interest.Arrangement — one workable way to hold a session: a particular interval, and the particular resources it would take. A proposal rather than a commitment —
plan/3returns many, and none of them is booked. SeeAgenda.Arrangement.Allocation — one resource, held for one session, over one interval. The unit a ledger stores, and what a
:taglabels. SeeAgenda.Allocation.Ledger — the record of what is allocated and to whom, and the only authority on it. Nothing stores availability:
free/2is open hours minus what the ledger holds, computed when asked, so a booking cannot drift out of step with the calendar it came from.A ledger is an ordinary immutable value rather than a running store, and that is deliberate — it is what lets
arrange/3fork one, try a layout, abandon it and try another. Durable shared state belongs in whatever persists the answer, not here. SeeAgenda.Ledger.
Reading a match aloud
iex> import Agenda.Predicate
iex> boardroom = Agenda.resource("Boardroom", seats: 8, video_conferencing: true)
iex> small = Agenda.resource("Meeting room 2", seats: 4)
iex> needs_a_room = Agenda.needs(:room, seats: at_least(8), video_conferencing: true)
iex> Agenda.eligible(needs_a_room, [boardroom, small]) |> Enum.map(& &1.name)
["Boardroom"]"Of the two rooms, only the boardroom seats eight and has video conferencing."
And when a resource does not qualify, the reason is a sentence:
iex> import Agenda.Predicate
iex> small = Agenda.resource("Meeting room 2", seats: 4)
iex> needs_a_room = Agenda.needs(:room, seats: at_least(8))
iex> Agenda.explain(needs_a_room, small)
"Meeting room 2: seats is 4 — needs at least 8"Status
First release. Resources and places, matching by description or by name, derived availability, single-session planning, whole-programme arrangement, ordering, tracks, load limits, preferences, the allocation ledger, recurrence, reconciliation, and meetings generated from mutual interest are all here, and each has a guide.
Two things are deliberately outside it. Booking — durable shared state, a clock, and concurrent claims — belongs in a sibling library, because the arranger's freedom to fork and abandon ledgers depends on a ledger being an ordinary value. And holiday calendars are accepted as data rather than resolved here, since jurisdiction rules have far broader use than scheduling. An adapter for Ash is planned; see the plan.
Summary
Functions
Record an arrangement. Delegates to Agenda.Ledger.allocate/3.
Find a placement for every session in a programme. Delegates to
Agenda.Arranger.arrange/3.
Rebuild what a ledger holds as arrangements, ready to pin. Delegates
to Agenda.Ledger.arrangements/3.
What each resource is already claimed for. Delegates to
Agenda.Ledger.busy/2.
Book one resource over one interval, refusing what it cannot honour.
Delegates to Agenda.Ledger.claim/4.
Turn a session's hold into a firm allocation. Delegates to
Agenda.Ledger.confirm/2.
The smallest set of things that cannot hold together — sessions for a programme, demands for a session.
How many allocations a ledger holds. Delegates to
Agenda.Ledger.count/1.
Keep only free time inside any of constraints. Delegates to
Agenda.Refine.during_any/2.
The resources satisfying requirement. Delegates to
Agenda.Requirement.eligible/2.
Expand a repeating session into its occurrences. Delegates to
Agenda.Series.expand/3.
Drop every hold that has lapsed by a moment. Delegates to
Agenda.Ledger.expire/2.
Describe an arrangement, a partial layout, or an infeasible result as a sentence.
Why resource does or does not satisfy requirement, as a sentence.
What each preference contributed, as sentences.
When a resource is open and not already taken. Delegates to
Agenda.Availability.free/2.
Read open hours from an RFC 7953 VAVAILABILITY. Delegates to
Agenda.Availability.from_ical/1.
Claim resources tentatively, until a moment. Delegates to
Agenda.Ledger.hold/3.
Every allocation that is still only a hold. Delegates to
Agenda.Ledger.holds/1.
Register that one resource would like a session with another.
Delegates to Agenda.Programme.interest/3.
Invite resources who may attend but are not required. Delegates to
Agenda.Session.invite/3.
Keep only windows of at least duration. Delegates to
Agenda.Refine.lasting_at_least/2.
An empty ledger. Delegates to Agenda.Ledger.new/0.
Turn returned interest into sessions. Delegates to
Agenda.Programme.meetings/3.
Build an attribute requirement. Delegates to
Agenda.Requirement.new/2.
Keep only free time inside constraint. Delegates to
Agenda.Refine.only_during/2.
Set when a resource is open. Delegates to
Agenda.Availability.open/2.
Open hours on a resource, raising on a pattern it cannot read.
Delegates to Agenda.Availability.open!/2.
Build a place. Delegates to Agenda.Place.new/2.
Rank the ways a session could be held. Delegates to
Agenda.Planner.plan/3.
Require that one session finishes before another starts. Delegates
to Agenda.Programme.precede/4.
Add a soft constraint to a programme. Delegates to
Agenda.Programme.prefer/3.
Build a programme. Delegates to Agenda.Programme.new/2.
Require that a delegate can get between consecutive track sessions.
Delegates to Agenda.Track.reachable/2.
What would change if a session moved. Delegates to
Agenda.Ledger.diff/3.
Whether a resource's claims account for the time it owed. Delegates
to Agenda.Reconciliation.reconcile/3.
Record one resource over one interval, checking nothing. Delegates
to Agenda.Ledger.record/4.
Free everything a session holds. Delegates to
Agenda.Ledger.release/2.
Free everything held by a whole series. Delegates to
Agenda.Ledger.release_series/3.
Build a resource. Delegates to Agenda.Resource.new/2.
Build a requirement naming specific resources. Delegates to
Agenda.Requirement.roster/2.
What a layout costs against a programme's preferences.
Build a session. Delegates to Agenda.Session.new/2.
Build a track — sessions that cannot clash with each other.
Delegates to Agenda.Track.new/2.
How long it takes to get from one resource to another.
Functions
@spec allocate(Agenda.Ledger.t(), Agenda.Arrangement.t(), keyword()) :: {:ok, Agenda.Ledger.t()}
Record an arrangement. Delegates to Agenda.Ledger.allocate/3.
Examples
iex> import Tempo.Sigils
iex> arrangement = %Agenda.Arrangement{
...> session: "Review",
...> interval: ~o"2026-06-16T10:00:00/2026-06-16T11:00:00",
...> allocations: %{room: [Agenda.resource("Boardroom")]}
...> }
iex> {:ok, ledger} = Agenda.allocate(Agenda.ledger(), arrangement)
iex> Agenda.count(ledger)
1Tagging what the time was for:
iex> import Tempo.Sigils
iex> arrangement = %Agenda.Arrangement{
...> session: "Discovery",
...> interval: ~o"2026-06-16T09:00:00/2026-06-16T17:00:00",
...> allocations: %{consultant: [Agenda.resource("Dana")]}
...> }
iex> {:ok, ledger} = Agenda.allocate(Agenda.ledger(), arrangement, tag: {:project, "ACME"})
iex> ledger |> Agenda.Ledger.to_list() |> Enum.map(& &1.tag)
[project: "ACME"]
@spec arrange(Agenda.Programme.t(), [Agenda.Resource.t()], keyword()) :: Agenda.Arranger.result()
Find a placement for every session in a programme. Delegates to
Agenda.Arranger.arrange/3.
Examples
iex> room = Agenda.resource("Hall", seats: 100)
iex> {:ok, room} = Agenda.open(room, "2026-09-15T09:00:00/2026-09-15T11:00:00")
iex> talk = Agenda.session("Keynote", duration: "PT1H")
...> |> Agenda.Session.needs(:room, seats: 100)
iex> programme =
...> Agenda.programme("Conf", across: "2026-09-15/2026-09-16")
...> |> Agenda.Programme.add_session(talk)
iex> {:ok, [only]} = Agenda.arrange(programme, [room])
iex> only.session
"Keynote"
@spec arrangements(Agenda.Ledger.t(), [Agenda.Resource.t()], keyword()) :: {:ok, [Agenda.Arrangement.t()]} | {:error, Agenda.Infeasible.t()}
Rebuild what a ledger holds as arrangements, ready to pin. Delegates
to Agenda.Ledger.arrangements/3.
Examples
iex> import Tempo.Sigils
iex> boardroom = Agenda.resource("Boardroom")
iex> arrangement = %Agenda.Arrangement{
...> session: "Review",
...> interval: ~o"2026-06-16T10:00:00/2026-06-16T11:00:00",
...> allocations: %{room: [boardroom]}
...> }
iex> {:ok, ledger} = Agenda.allocate(Agenda.ledger(), arrangement)
iex> {:ok, [pinned]} = Agenda.arrangements(ledger, [boardroom])
iex> pinned.session
"Review"
@spec busy( Agenda.Ledger.t(), keyword() ) :: %{optional(String.t()) => [Tempo.Interval.t()]}
What each resource is already claimed for. Delegates to
Agenda.Ledger.busy/2.
Examples
iex> Agenda.busy(Agenda.ledger())
%{}
@spec claim( Agenda.Ledger.t(), Agenda.Resource.t(), Agenda.Availability.pattern(), keyword() ) :: {:ok, Agenda.Ledger.t()} | {:error, term()}
Book one resource over one interval, refusing what it cannot honour.
Delegates to Agenda.Ledger.claim/4.
record/4 writes down what happened; claim/4 books what is to
happen, and checks. See Agenda.Ledger.claim/4 for why a timesheet
wants the first and a schedule wants the second.
Examples
iex> import Tempo.Sigils
iex> {:ok, dana} = Agenda.open(Agenda.resource("Dana"), "2026-08-10T09:00:00/2026-08-10T17:00:00")
iex> {:ok, ledger} = Agenda.claim(Agenda.ledger(), dana, ~o"2026-08-10T09:00:00/2026-08-10T12:00:00", tag: {:project, "ACME"})
iex> ledger |> Agenda.Ledger.to_list() |> Enum.map(& &1.tag)
[project: "ACME"]
iex> import Tempo.Sigils
iex> {:ok, dana} = Agenda.open(Agenda.resource("Dana"), "2026-08-10T09:00:00/2026-08-10T17:00:00")
iex> Agenda.claim(Agenda.ledger(), dana, ~o"2026-08-10T16:00:00/2026-08-10T19:00:00")
{:error, "Dana is not open for 2026Y8M10DT17H0M0S/T19H0M0S"}
@spec confirm(Agenda.Ledger.t(), String.t()) :: {:ok, Agenda.Ledger.t()}
Turn a session's hold into a firm allocation. Delegates to
Agenda.Ledger.confirm/2.
Examples
iex> {:ok, ledger} = Agenda.confirm(Agenda.ledger(), "Review")
iex> Agenda.count(ledger)
0
@spec conflict( Agenda.Programme.t() | Agenda.Session.t(), [Agenda.Resource.t()], keyword() ) :: {:ok, [term()]} | :none
The smallest set of things that cannot hold together — sessions for a programme, demands for a session.
Delegates to Agenda.Arranger.conflict/3 or
Agenda.Planner.conflict/3 according to what it is given. Reach
for it when arrange/3 or plan/3 has failed and the question is
what to change.
Arguments
subjectis aAgenda.Programme.t/0or aAgenda.Session.t/0.poolis the list ofAgenda.Resource.t/0to choose from.
Options
Returns
:nonewhen there is nothing to explain; or{:ok, conflict}— a minimal set of session names, or of demands.
Examples
iex> room = Agenda.resource("Hall", seats: 100)
iex> {:ok, room} = Agenda.open(room, "2026-09-15T09:00:00/2026-09-15T10:00:00")
iex> talk = fn name ->
...> Agenda.session(name, duration: "PT1H", window: "2026-09-15/2026-09-16")
...> |> Agenda.Session.needs(:room, seats: 100)
...> end
iex> programme =
...> Agenda.programme("Conf")
...> |> Agenda.Programme.add_session(talk.("Keynote"))
...> |> Agenda.Programme.add_session(talk.("Deep dive"))
iex> Agenda.conflict(programme, [room])
{:ok, ["Keynote", "Deep dive"]}
@spec count(Agenda.Ledger.t()) :: non_neg_integer()
How many allocations a ledger holds. Delegates to
Agenda.Ledger.count/1.
Examples
iex> Agenda.count(Agenda.ledger())
0
@spec during_any(Agenda.Refine.refinable(), [Agenda.Availability.pattern()]) :: {:ok, Tempo.IntervalSet.t()} | {:error, term()}
Keep only free time inside any of constraints. Delegates to
Agenda.Refine.during_any/2.
Examples
iex> {:ok, room} = Agenda.open(Agenda.resource("R"), "2027-03-02T09:00:00/2027-03-02T17:00:00")
iex> {:ok, staffed} =
...> room
...> |> Agenda.free(within: "2027-03-02/2027-03-03")
...> |> Agenda.during_any(["2027-03-02T09:00:00/2027-03-02T10:00:00"])
iex> Tempo.IntervalSet.count(staffed)
1
@spec eligible(Agenda.Requirement.t(), [Agenda.Resource.t()]) :: [Agenda.Resource.t()]
The resources satisfying requirement. Delegates to
Agenda.Requirement.eligible/2.
Examples
iex> boardroom = Agenda.resource("Boardroom", seats: 8)
iex> Agenda.eligible(Agenda.needs(:room, seats: 8), [boardroom])
...> |> Enum.map(& &1.name)
["Boardroom"]
@spec every(Agenda.Session.t(), Agenda.Availability.pattern(), keyword()) :: {:ok, [Agenda.Session.t()]} | {:error, term()}
Expand a repeating session into its occurrences. Delegates to
Agenda.Series.expand/3.
Examples
iex> standup = Agenda.session("Stand-up", duration: "PT15M")
iex> {:ok, occurrences} = Agenda.every(standup, "R3/2027-03-02T09:00:00/P1W")
iex> length(occurrences)
3
@spec expire(Agenda.Ledger.t(), Agenda.Availability.pattern()) :: {:ok, Agenda.Ledger.t()} | {:error, term()}
Drop every hold that has lapsed by a moment. Delegates to
Agenda.Ledger.expire/2.
Nothing expires on its own — this is what advances time, and it takes the moment as an argument so that no function in this library reads a clock.
Examples
iex> {:ok, ledger} = Agenda.expire(Agenda.ledger(), "2026-06-15T10:15:00")
iex> Agenda.count(ledger)
0
@spec explain(Agenda.Arrangement.t() | Agenda.Layout.t() | Agenda.Infeasible.t()) :: String.t()
Describe an arrangement, a partial layout, or an infeasible result as a sentence.
Arguments
resultis aAgenda.Arrangement.t/0, aAgenda.Layout.t/0, or aAgenda.Infeasible.t/0.
Returns
- a sentence.
Examples
iex> reason = Agenda.Infeasible.new("Review", ["no room seats 8"])
iex> Agenda.explain(reason)
"Review cannot be held: no room seats 8"
iex> reason = Agenda.Infeasible.new("Workshop", ["no room seats 8"])
iex> Agenda.explain(Agenda.Layout.new("Conf", [], [reason]))
"Conf: 0 of 1 sessions placed. Workshop cannot be held: no room seats 8"
@spec explain(Agenda.Requirement.t(), Agenda.Resource.t()) :: String.t()
Why resource does or does not satisfy requirement, as a sentence.
Arguments
requirementis aAgenda.Requirement.t/0.resourceis aAgenda.Resource.t/0.
Returns
- a sentence naming the resource and every unmet attribute, or stating that it qualifies.
Examples
iex> import Agenda.Predicate
iex> boardroom = Agenda.resource("Boardroom", seats: 8)
iex> Agenda.explain(Agenda.needs(:room, seats: at_least(8)), boardroom)
"Boardroom qualifies"
@spec explain_score( [Agenda.Arrangement.t()] | Agenda.Layout.t(), Agenda.Programme.t(), keyword() ) :: [ String.t() ]
What each preference contributed, as sentences.
A score is a number, and a number says a layout is worse without saying how. This says how.
Arguments
arrangementsis a list ofAgenda.Arrangement.t/0, or aAgenda.Layout.t/0.programmeis theAgenda.Programme.t/0whose preferences to score against.
Options
:poolis the resources the layout drew on. The default is[].
Returns
- one sentence per preference, in the order they were declared.
Examples
iex> {:ok, programme} =
...> Agenda.Programme.prefer(Agenda.programme("Conf"), :room_changes, weight: 10)
iex> Agenda.explain_score([], programme)
["room_changes: 0 × 10 = 0"]
@spec free( Agenda.Resource.t(), keyword() ) :: {:ok, Tempo.IntervalSet.t()} | {:error, term()}
When a resource is open and not already taken. Delegates to
Agenda.Availability.free/2.
Examples
iex> boardroom = Agenda.resource("Boardroom")
iex> {:ok, boardroom} = Agenda.open(boardroom, "2026-06-15T09:00:00/2026-06-15T17:00:00")
iex> {:ok, free} = Agenda.free(boardroom, within: "2026-06-15/2026-06-16")
iex> Tempo.IntervalSet.count(free)
1
@spec from_ical(String.t()) :: {:ok, Agenda.Availability.imported()} | {:error, :ical_not_available | String.t()}
Read open hours from an RFC 7953 VAVAILABILITY. Delegates to
Agenda.Availability.from_ical/1.
This is what a CalDAV server hands you when asked when someone is
available. The result is a pattern for open/2.
Examples
iex> ics = """
...> BEGIN:VCALENDAR
...> VERSION:2.0
...> BEGIN:VAVAILABILITY
...> UID:consulting-room
...> DTSTAMP:20260601T000000Z
...> BEGIN:AVAILABLE
...> UID:tuesdays
...> DTSTAMP:20260601T000000Z
...> DTSTART:20260602T090000Z
...> DTEND:20260602T170000Z
...> RRULE:FREQ=WEEKLY;BYDAY=TU
...> END:AVAILABLE
...> END:VAVAILABILITY
...> END:VCALENDAR
...> """
iex> {:ok, hours} = Agenda.from_ical(ics)
iex> {:ok, room} = Agenda.open(Agenda.resource("Consulting room"), hours)
iex> {:ok, free} = Agenda.free(room, within: "2026-06-01/2026-07-01")
iex> Tempo.IntervalSet.count(free)
5
@spec hold(Agenda.Ledger.t(), Agenda.Arrangement.t(), keyword()) :: {:ok, Agenda.Ledger.t()} | {:error, term()}
Claim resources tentatively, until a moment. Delegates to
Agenda.Ledger.hold/3.
Examples
iex> import Tempo.Sigils
iex> boardroom = Agenda.resource("Boardroom")
iex> arrangement = %Agenda.Arrangement{
...> session: "Review",
...> interval: ~o"2026-06-16T10:00:00/2026-06-16T11:00:00",
...> allocations: %{room: [boardroom]}
...> }
iex> {:ok, ledger} = Agenda.hold(Agenda.ledger(), arrangement,
...> until: "2026-06-15T10:15:00")
iex> Agenda.holds(ledger) |> length()
1
@spec holds(Agenda.Ledger.t()) :: [Agenda.Allocation.t()]
Every allocation that is still only a hold. Delegates to
Agenda.Ledger.holds/1.
Examples
iex> Agenda.holds(Agenda.ledger())
[]
@spec interest( Agenda.Programme.t(), Agenda.Resource.t() | String.t(), Agenda.Resource.t() | String.t() ) :: {:ok, Agenda.Programme.t()} | {:error, term()}
Register that one resource would like a session with another.
Delegates to Agenda.Programme.interest/3.
Interest is one-directional. A meeting is held where it is returned,
so both parties must say so — see meetings/3.
Examples
iex> {:ok, programme} = Agenda.interest(Agenda.programme("Show"), "Kim", "Harbour")
iex> {:ok, programme} = Agenda.interest(programme, "Harbour", "Kim")
iex> Agenda.Interest.mutual(programme.interests)
[{"Harbour", "Kim"}]
@spec invite(Agenda.Session.t(), atom(), [Agenda.Resource.t()]) :: Agenda.Session.t()
Invite resources who may attend but are not required. Delegates to
Agenda.Session.invite/3.
The counterpart to roster/2. An invitee never affects whether a
session can be held, only which time is best: Agenda.plan/3 scores
a placement higher when more of them are free, and the arrangement
records which of them it suits. They are not allocated — an optional
attendee that could cost some other session its placement would not
be optional.
Examples
iex> bob = Agenda.resource("Bob")
iex> session = Agenda.session("Review", duration: "PT1H")
iex> Agenda.invite(session, :optional, [bob]).invitees |> Keyword.keys()
[:optional]
@spec lasting_at_least(Agenda.Refine.refinable(), Agenda.Availability.pattern()) :: {:ok, Tempo.IntervalSet.t()} | {:error, term()}
Keep only windows of at least duration. Delegates to
Agenda.Refine.lasting_at_least/2.
Examples
iex> {:ok, room} = Agenda.open(Agenda.resource("R"), "2027-03-02T09:00:00/2027-03-02T09:30:00")
iex> {:ok, usable} =
...> room
...> |> Agenda.free(within: "2027-03-02/2027-03-03")
...> |> Agenda.lasting_at_least("PT1H")
iex> Tempo.IntervalSet.empty?(usable)
true
@spec ledger() :: Agenda.Ledger.t()
An empty ledger. Delegates to Agenda.Ledger.new/0.
Returns
- a
Agenda.Ledger.t/0holding no allocations.
Examples
iex> Agenda.count(Agenda.ledger())
0
@spec meetings(Agenda.Programme.t(), [Agenda.Resource.t()], keyword()) :: {:ok, Agenda.Programme.t()} | {:error, term()}
Turn returned interest into sessions. Delegates to
Agenda.Programme.meetings/3.
One session per mutually interested pair, each rostering both parties, so nobody being in two places at once is the constraint the library already enforces rather than one anybody writes.
Examples
iex> kim = Agenda.resource("Kim")
iex> harbour = Agenda.resource("Harbour")
iex> programme = Agenda.programme("Show", across: "2027-06-15/2027-06-17")
iex> {:ok, programme} = Agenda.interest(programme, kim, harbour)
iex> {:ok, programme} = Agenda.interest(programme, harbour, kim)
iex> {:ok, programme} = Agenda.meetings(programme, [kim, harbour], duration: "PT15M")
iex> length(programme.sessions)
1
@spec needs( atom(), keyword() ) :: Agenda.Requirement.t()
Build an attribute requirement. Delegates to
Agenda.Requirement.new/2.
Examples
iex> Agenda.needs(:room, seats: 8).name
:room
@spec only_during(Agenda.Refine.refinable(), Agenda.Availability.pattern()) :: {:ok, Tempo.IntervalSet.t()} | {:error, term()}
Keep only free time inside constraint. Delegates to
Agenda.Refine.only_during/2.
Examples
iex> {:ok, room} = Agenda.open(Agenda.resource("R"), "2027-03-02T09:00:00/2027-03-02T17:00:00")
iex> {:ok, clinic} =
...> room
...> |> Agenda.free(within: "2027-03-02/2027-03-03")
...> |> Agenda.only_during("2027-03-02T13:00:00/2027-03-02T16:00:00")
iex> Tempo.IntervalSet.count(clinic)
1
@spec open(Agenda.Resource.t(), Agenda.Availability.pattern()) :: {:ok, Agenda.Resource.t()} | {:error, term()}
Set when a resource is open. Delegates to
Agenda.Availability.open/2.
Examples
iex> boardroom = Agenda.resource("Boardroom")
iex> {:ok, boardroom} = Agenda.open(boardroom, "2026-06-15T09:00:00/2026-06-15T17:00:00")
iex> is_nil(boardroom.open)
false
@spec open!(Agenda.Resource.t(), Agenda.Availability.pattern()) :: Agenda.Resource.t()
Open hours on a resource, raising on a pattern it cannot read.
Delegates to Agenda.Availability.open!/2.
The ! companion to open/2, for a pattern that is a literal in the
source — a guide, a notebook, a fixture — where the tuple is ceremony
around what cannot fail except by typo.
Examples
iex> import Tempo.Sigils
iex> Agenda.open!(Agenda.resource("Boardroom"), ~o"2026-06-15T09:00:00/2026-06-15T17:00:00").open
~o"2026Y6M15DT9H0M0S/2026Y6M15DT17H0M0S"
@spec place( String.t(), keyword() ) :: Agenda.Place.t()
Build a place. Delegates to Agenda.Place.new/2.
Examples
iex> Agenda.place("Level 2").name
"Level 2"
@spec plan(Agenda.Session.t(), [Agenda.Resource.t()], keyword()) :: {:ok, [Agenda.Arrangement.t()]} | {:error, Agenda.Infeasible.t()}
Rank the ways a session could be held. Delegates to
Agenda.Planner.plan/3.
Examples
iex> boardroom = Agenda.resource("Boardroom", seats: 8)
iex> {:ok, boardroom} = Agenda.open(boardroom, "2026-06-15T09:00:00/2026-06-15T11:00:00")
iex> session =
...> Agenda.session("Review", duration: "PT1H", window: "2026-06-15/2026-06-16")
...> |> Agenda.Session.needs(:room, seats: 8)
iex> {:ok, [best | _]} = Agenda.plan(session, [boardroom])
iex> Agenda.explain(best)
"2026Y6M15DT9H0M0S/T10H0M0S — room: Boardroom"
@spec precede(Agenda.Programme.t(), String.t(), String.t(), keyword()) :: {:ok, Agenda.Programme.t()} | {:error, term()}
Require that one session finishes before another starts. Delegates
to Agenda.Programme.precede/4.
Examples
iex> programme =
...> Agenda.programme("Job")
...> |> Agenda.Programme.add_session(Agenda.session("Survey"))
...> |> Agenda.Programme.add_session(Agenda.session("Quote"))
iex> {:ok, programme} = Agenda.precede(programme, "Survey", "Quote", gap: "PT30M")
iex> length(programme.precedences)
1
@spec prefer(Agenda.Programme.t(), atom() | {atom(), function()}, keyword()) :: {:ok, Agenda.Programme.t()} | {:error, term()}
Add a soft constraint to a programme. Delegates to
Agenda.Programme.prefer/3.
Examples
iex> {:ok, programme} = Agenda.prefer(Agenda.programme("Conf"), :room_spread)
iex> Enum.map(programme.preferences, & &1.name)
[:room_spread]
@spec programme( String.t(), keyword() ) :: Agenda.Programme.t()
Build a programme. Delegates to Agenda.Programme.new/2.
Examples
iex> Agenda.programme("ElixirConf AU").name
"ElixirConf AU"
@spec reachable( Agenda.Track.t(), keyword() ) :: {:ok, Agenda.Track.t()} | {:error, {:missing_option, :within}}
Require that a delegate can get between consecutive track sessions.
Delegates to Agenda.Track.reachable/2.
Examples
iex> import Tempo.Sigils
iex> {:ok, track} = Agenda.track("Elixir") |> Agenda.reachable(within: ~o"PT10M")
iex> track.reachable_within
~o"PT10M"
@spec rearrange(Agenda.Ledger.t(), String.t(), Agenda.Arrangement.t()) :: [ Agenda.Ledger.change() ]
What would change if a session moved. Delegates to
Agenda.Ledger.diff/3.
Examples
iex> import Tempo.Sigils
iex> arrangement = %Agenda.Arrangement{
...> session: "Review",
...> interval: ~o"2026-06-16T10:00:00/2026-06-16T11:00:00",
...> allocations: %{room: [Agenda.resource("Boardroom")]}
...> }
iex> Agenda.rearrange(Agenda.ledger(), "Review", arrangement)
...> |> Enum.map(&elem(&1, 0))
[:allocate]
@spec reconcile(Agenda.Ledger.t(), Agenda.Resource.t(), keyword()) :: {:ok, Agenda.Reconciliation.t()} | {:error, term()}
Whether a resource's claims account for the time it owed. Delegates
to Agenda.Reconciliation.reconcile/3.
The schedule and the timesheet are the same ledger read forwards and
backwards, so reconciling is a set difference rather than a sum:
unaccounted names which time is missing, not merely how much.
Holidays are not this library's business — pass them in through
:excluding as an ordinary interval set, from wherever they are
resolved.
Examples
iex> import Tempo.Sigils
iex> {:ok, dana} =
...> Agenda.open(Agenda.resource("Dana"), ~o"2026-06-16T09:00:00/2026-06-16T17:00:00")
iex> arrangement = %Agenda.Arrangement{
...> session: "Acme",
...> interval: ~o"2026-06-16T09:00:00/2026-06-16T12:00:00",
...> allocations: %{consultant: [dana]}
...> }
iex> {:ok, ledger} = Agenda.allocate(Agenda.ledger(), arrangement, tag: {:project, "ACME"})
iex> {:ok, report} = Agenda.reconcile(ledger, dana, within: ~o"2026-06-16/2026-06-17")
iex> Agenda.Reconciliation.explain(report)
["Dana: 5 hours unaccounted — 2026Y6M16DT12H0M0S/T17H0M0S"]
@spec record( Agenda.Ledger.t(), Agenda.Resource.t(), Agenda.Availability.pattern(), keyword() ) :: {:ok, Agenda.Ledger.t()} | {:error, term()}
Record one resource over one interval, checking nothing. Delegates
to Agenda.Ledger.record/4.
The unchecked half of the pair claim/4 completes: a schedule is a
request and must be validated, a timesheet is a record and the world
is not obliged to match the contract. See Agenda.Ledger.record/4.
Examples
iex> import Tempo.Sigils
iex> {:ok, dana} = Agenda.open(Agenda.resource("Dana"), "2026-08-10T09:00:00/2026-08-10T17:00:00")
iex> {:ok, ledger} = Agenda.record(Agenda.ledger(), dana, ~o"2026-08-15T09:00:00/2026-08-15T12:00:00", tag: {:project, "ACME"})
iex> ledger |> Agenda.Ledger.to_list() |> Enum.map(& &1.tag)
[project: "ACME"]
@spec release(Agenda.Ledger.t(), String.t()) :: {:ok, Agenda.Ledger.t()}
Free everything a session holds. Delegates to
Agenda.Ledger.release/2.
Examples
iex> {:ok, ledger} = Agenda.release(Agenda.ledger(), "Review")
iex> Agenda.count(ledger)
0
@spec release_series(Agenda.Ledger.t(), String.t(), keyword()) :: {:ok, Agenda.Ledger.t()} | {:error, term()}
Free everything held by a whole series. Delegates to
Agenda.Ledger.release_series/3.
Examples
iex> Agenda.release_series(Agenda.ledger(), "Stand-up")
{:ok, Agenda.ledger()}
@spec resource( String.t(), keyword() ) :: Agenda.Resource.t()
Build a resource. Delegates to Agenda.Resource.new/2.
Examples
iex> Agenda.resource("Boardroom", seats: 8).attributes
%{seats: 8}
@spec roster(atom(), [Agenda.Resource.t()]) :: Agenda.Requirement.t()
Build a requirement naming specific resources. Delegates to
Agenda.Requirement.roster/2.
Examples
iex> alice = Agenda.resource("Alice")
iex> Agenda.roster(:attendees, [alice]).name
:attendees
@spec score( [Agenda.Arrangement.t()] | Agenda.Layout.t(), Agenda.Programme.t(), keyword() ) :: number()
What a layout costs against a programme's preferences.
arrange/3 already prefers a lower score, so this is for comparing
layouts you are choosing between yourself — or for reading the score
of an {:ok, arrangements} result, which returns the placements
without one.
Arguments
arrangementsis a list ofAgenda.Arrangement.t/0, or aAgenda.Layout.t/0.programmeis theAgenda.Programme.t/0whose preferences to score against.
Options
:poolis the resources the layout drew on, for preferences that consult them. The default is[].
Returns
- the total penalty, where
0is ideal.
Examples
iex> {:ok, programme} = Agenda.Programme.prefer(Agenda.programme("Conf"), :room_changes)
iex> Agenda.score([], programme)
0
@spec session( String.t(), keyword() ) :: Agenda.Session.t()
Build a session. Delegates to Agenda.Session.new/2.
Examples
iex> Agenda.session("Review", duration: "PT1H").name
"Review"
@spec track( String.t(), keyword() ) :: Agenda.Track.t()
Build a track — sessions that cannot clash with each other.
Delegates to Agenda.Track.new/2.
Examples
iex> Agenda.track("Elixir", of: [Agenda.session("Keynote")]).name
"Elixir"
@spec travel_time(Agenda.Resource.t(), Agenda.Resource.t(), keyword()) :: {:ok, Tempo.Duration.t()} | {:error, :unknown}
How long it takes to get from one resource to another.
Derived from the resources' separation in the place tree — the
further up you must climb to get from one to the other, the longer
the journey. Resources in unrelated trees, or with no place at all,
return {:error, :unknown} rather than a guess: nothing can be said
about a journey between two places that are not related.
Arguments
fromandtoare each aAgenda.Resource.t/0.
Options
:betweenis a keyword-style list of{{from_name, to_name}, duration}overrides, consulted in either direction before the table. Use it wherever the building disagrees with the geometry — two adjacent rooms separated by a locked fire door, for instance.:levelsis a map of separation to duration, replacing the default table (0→PT0M,1→PT5M,2→PT10M).:distantis the duration used for separations beyond the table. The default isPT20M.
Returns
{:ok, duration}wheredurationis aTempo.Duration.t/0; or{:error, :unknown}when the two resources share no place.
Examples
iex> import Tempo.Sigils
iex> sydney = Agenda.place("Sydney Convention Centre")
iex> level_2 = Agenda.place("Level 2", within: sydney)
iex> level_3 = Agenda.place("Level 3", within: sydney)
iex> boardroom = Agenda.resource("Boardroom", within: level_2)
iex> annexe = Agenda.resource("Annexe", within: level_3)
iex> Agenda.travel_time(boardroom, annexe)
{:ok, ~o"PT5M"}
iex> here = Agenda.resource("Here")
iex> there = Agenda.resource("There")
iex> Agenda.travel_time(here, there)
{:error, :unknown}