Agenda.Availability (Agenda v0.1.0)

Copy Markdown View Source

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/2 is open − 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

imported()

@type imported() :: {:vavailability, term()}

Availability imported from an RFC 7953 VAVAILABILITY, held unmaterialised until a query window is known — exactly as a recurrence is.

pattern()

@type pattern() :: span() | String.t() | imported()

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.

span()

A Tempo value that already denotes a span.

Functions

free(resource, options)

@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

Options

  • :within is the query window — a Tempo value or ISO 8601 string. Required: an unbounded recurrence has no materialisation without one.

  • :busy is what already claims the resource — a Tempo value, a string, an Tempo.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

from_ical(ics)

@spec from_ical(String.t()) ::
  {:ok, imported()} | {:error, :ical_not_available | String.t()}

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

  • ics is iCalendar data as a string.

Returns

  • {:ok, pattern} to hand to open/2; or

  • {:error, reason} when the data cannot be read, or when ical is 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

normalise(imported)

@spec normalise(term()) :: {:ok, span() | imported()} | {:error, :unreadable_pattern}

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

  • pattern is 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

open(resource, pattern)

@spec open(Agenda.Resource.t(), pattern()) ::
  {:ok, Agenda.Resource.t()} | {:error, term()}

Set when resource is open.

Arguments

  • resource is a Agenda.Resource.t/0.

  • pattern is a Tempo value, an ISO 8601 string (preferred), or an RFC 5545 RRULE string.

Returns

  • {:ok, resource} with its open hours set; or

  • {:error, reason} when pattern cannot 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}

open!(resource, pattern)

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

Returns

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