When a resource is open, and what of that is still free.
Two ideas carry the whole module:
Open hours are a Tempo value, so a resource is open for a single interval, a recurring pattern, or any set algebra over them — and the calendar, timezone, and DST correctness comes from Tempo rather than from anything written here.
Free time is derived, never stored.
free/2isopen − busy, computed on demand. There is no free/busy record to go stale, and no release step that can be forgotten: a resource stops being busy the moment nothing claims it.
Stating open hours
ISO 8601 is the preferred spelling — it is what Tempo stores and what
inspect/1 returns — but open/2 accepts an RFC 5545 RRULE too,
since that is what most calendar systems emit:
iex> boardroom = Agenda.Resource.new("Boardroom")
iex> {:ok, boardroom} = Agenda.Availability.open(boardroom, "R5/2026-06-15/P1D")
iex> {:ok, free} = Agenda.Availability.free(boardroom, within: "2026-06-15/2026-06-17")
iex> Tempo.IntervalSet.count(free)
2"The boardroom is open for five days from the 15th; across the 15th and 16th that leaves two free days."
Summary
Types
Availability imported from an RFC 7953 VAVAILABILITY, held
unmaterialised until a query window is known — exactly as a
recurrence is.
Anything that can be read as a span: a Tempo value, a string in
ISO 8601 (preferred) or RFC 5545 RRULE form, or the result of
from_ical/1.
A Tempo value that already denotes a span.
Functions
The time resource is open and not already taken.
Read a resource's open hours from an RFC 7953 VAVAILABILITY.
Read pattern as a Tempo value.
Set when resource is open.
Open hours on a resource, raising on a pattern it cannot read.
Types
@type imported() :: {:vavailability, term()}
Availability imported from an RFC 7953 VAVAILABILITY, held
unmaterialised until a query window is known — exactly as a
recurrence is.
Anything that can be read as a span: a Tempo value, a string in
ISO 8601 (preferred) or RFC 5545 RRULE form, or the result of
from_ical/1.
@type span() :: Tempo.t() | Tempo.Interval.t() | Tempo.Duration.t() | Tempo.IntervalSet.t()
A Tempo value that already denotes a span.
Functions
@spec free( Agenda.Resource.t(), keyword() ) :: {:ok, Tempo.IntervalSet.t()} | {:error, term()}
The time resource is open and not already taken.
free = open − busy, clipped to the query window. Computed on
demand, so it cannot go stale.
Arguments
resourceis aAgenda.Resource.t/0.
Options
:withinis the query window — a Tempo value or ISO 8601 string. Required: an unbounded recurrence has no materialisation without one.:busyis what already claims the resource — a Tempo value, a string, anTempo.IntervalSet.t/0, or a list of them. The default is[].
Returns
{:ok, interval_set}of the free spans; or{:error, reason}when the window or a pattern cannot be read.
Examples
iex> import Tempo.Sigils
iex> boardroom = Agenda.Resource.new("Boardroom")
iex> {:ok, boardroom} = Agenda.Availability.open(boardroom, "2026-06-15T09:00:00/2026-06-15T17:00:00")
iex> {:ok, free} = Agenda.Availability.free(boardroom,
...> within: "2026-06-15/2026-06-16",
...> busy: "2026-06-15T12:00:00/2026-06-15T13:00:00")
iex> Tempo.IntervalSet.members(free)
[~o"2026Y6M15DT9H0M0S/T12H0M0S",
~o"2026Y6M15DT13H0M0S/T17H0M0S"]A resource with no open hours is never free:
iex> Agenda.Resource.new("Boardroom")
...> |> Agenda.Availability.free(within: "2026-06-15/2026-06-16")
...> |> then(fn {:ok, free} -> Tempo.IntervalSet.empty?(free) end)
true
Read a resource's open hours from an RFC 7953 VAVAILABILITY.
This is how a calendar system states availability, and it is what a
CalDAV server will hand you. The result is a pattern for open/2,
held unmaterialised until a query window is known — a
VAVAILABILITY whose AVAILABLE subcomponents recur has no extent
of its own, exactly as an ISO 8601 recurrence has none.
VEVENTs in the same document are ignored. They are what is
taken, not what is offered, and belong in free/2's :busy
rather than in a resource's open hours.
Requires the optional ical dependency.
Arguments
icsis iCalendar data as a string.
Returns
{:ok, pattern}to hand toopen/2; or{:error, reason}when the data cannot be read, or whenicalis not available.
Examples
iex> ics = """
...> BEGIN:VCALENDAR
...> VERSION:2.0
...> BEGIN:VAVAILABILITY
...> UID:clinic
...> DTSTAMP:20260601T000000Z
...> BEGIN:AVAILABLE
...> UID:weekday-clinic
...> DTSTAMP:20260601T000000Z
...> DTSTART:20260601T090000Z
...> DTEND:20260601T170000Z
...> RRULE:FREQ=DAILY;COUNT=5
...> END:AVAILABLE
...> END:VAVAILABILITY
...> END:VCALENDAR
...> """
iex> {:ok, hours} = Agenda.Availability.from_ical(ics)
iex> {:ok, clinic} = Agenda.open(Agenda.resource("Clinic"), hours)
iex> {:ok, free} = Agenda.free(clinic, within: "2026-06-01/2026-06-08")
iex> Tempo.IntervalSet.count(free)
5
Read pattern as a Tempo value.
Tempo values pass through untouched. Strings are tried as ISO 8601
first — the preferred spelling — and then as an RFC 5545 RRULE, so
a calendar system's own recurrence rule is accepted at the interface
without the caller translating it.
Arguments
patternis a Tempo value or a string.
Returns
{:ok, value}; or{:error, :unreadable_pattern}when it is neither.
Examples
iex> {:ok, value} = Agenda.Availability.normalise("R5/2026-06-15/P1D")
iex> value.recurrence
5
iex> {:ok, value} = Agenda.Availability.normalise("FREQ=DAILY;COUNT=3")
iex> value.recurrence
3
@spec open(Agenda.Resource.t(), pattern()) :: {:ok, Agenda.Resource.t()} | {:error, term()}
Set when resource is open.
Arguments
resourceis aAgenda.Resource.t/0.patternis a Tempo value, an ISO 8601 string (preferred), or an RFC 5545RRULEstring.
Returns
{:ok, resource}with its open hours set; or{:error, reason}whenpatterncannot be read as a span.
Examples
iex> import Tempo.Sigils
iex> boardroom = Agenda.Resource.new("Boardroom")
iex> {:ok, boardroom} = Agenda.Availability.open(boardroom, "2026-06-15T09:00:00/2026-06-15T17:00:00")
iex> boardroom.open
~o"2026Y6M15DT9H0M0S/T17H0M0S"
iex> boardroom = Agenda.Resource.new("Boardroom")
iex> Agenda.Availability.open(boardroom, "not a time")
{:error, :unreadable_pattern}
@spec open!(Agenda.Resource.t(), pattern()) :: Agenda.Resource.t()
Open hours on a resource, raising on a pattern it cannot read.
The ! companion to open/2, for the case where the pattern is a
literal in the source rather than data: a guide, a notebook, or a
fixture. There the tuple is pure ceremony — an unreadable literal is
a typo, not a condition to handle — and unwrapping it at every setup
line buries what the example is actually about.
Reach for open/2 wherever the pattern comes from outside the
program, which is most of an application.
Arguments
resourceis aAgenda.Resource.t/0.patternis as foropen/2.
Returns
- the resource with its open hours set; or raises
ArgumentError.
Examples
iex> import Tempo.Sigils
iex> boardroom = Agenda.Availability.open!(Agenda.resource("Boardroom"), "2026-06-15T09:00:00/2026-06-15T17:00:00")
iex> boardroom.open
~o"2026Y6M15DT9H0M0S/2026Y6M15DT17H0M0S"
iex> Agenda.Availability.open!(Agenda.resource("Boardroom"), "not a pattern")
** (ArgumentError) Boardroom: cannot read :unreadable_pattern as open hours