Import iCalendar (RFC 5545) data into %Tempo.IntervalSet{}.
This module wraps the ical
parser and translates each VEVENT into a %Tempo.Interval{}
with full event metadata (summary, description, location,
attendees, status, …) attached to the interval's :metadata
map. The VCALENDAR envelope's metadata (product id, version,
calendar scale, method) attaches to the IntervalSet's
:metadata map.
Every pipeline step that accepts a Tempo value — set
operations, Enum.take/2, resolution alignment — preserves
the metadata through to the result. That lets free/busy and
scheduling queries stay connected to their source events.
The ical dependency is declared optional: true in mix.exs.
Tempo.ICal is only compiled when ical is available in the
dependency tree; projects that don't import calendar data don't
pay the compile cost.
Availability
VEVENTs say what is taken. available_from_ical/2 reads the
complementary RFC 7953
VAVAILABILITY components, which say what is offered — a
resource's open hours, with AVAILABLE subcomponents repeating
under their own RRULE and PRIORITY resolving overlaps. A
scheduler usually wants both, and they compose directly:
{:ok, free} = Tempo.ICal.available_from_ical(ics, within: week)
{:ok, busy} = Tempo.ICal.from_ical(ics, bound: week)
{:ok, bookable} = Tempo.difference(free, busy)Required reading
- RFC 5545 — the iCalendar spec.
- RFC 7953 — calendar availability.
guides/set-operations.mdfor how the imported data is used downstream.
RFC 5545 coverage
| Property | Status |
|---|---|
RRULE | Fully supported — every BY* part, WKST, and |
BYSETPOS flow through the interpreter. | |
RDATE | Supported — extra occurrences with the event's |
| own span. | |
EXDATE | Supported — start-moment match removes the |
| corresponding occurrence. | |
EXRULE | Not surfaced by the underlying ical library, so |
| not implementable at this layer. EXRULE is also | |
| RFC-deprecated (RFC 2445 → 5545). | |
| Multiple | RFC 5545 says SHOULD NOT; some exports do it |
RRULE | anyway. The ical library exposes only the |
| per | first RRULE on event.rrule, so we materialise |
VEVENT | that one and silently ignore the rest. |
DURATION | Supported as the alternative to DTEND, on |
VEVENT, VAVAILABILITY and AVAILABLE alike. |
Zoned, UTC, and floating times
A DATE-TIME value takes one of three RFC 5545 §3.3.5 forms, and
each maps to a faithful %Tempo{}:
- UTC (
20220615T100000Z) → a zoned value withextended.zone_id: "Etc/UTC". - Zoned (
TZID=Europe/Paris:20220615T100000) → a zoned value carrying that IANA zone. - Floating (
20220615T100000, noZand noTZID) → a genuinely zone-less value (extended: nil) — the same wall clock in whatever zone the reader is in.
Tempo supports ical ~> 2.0 or ~> 3.0. The zone-less mapping of
floating times holds on ical 3.0+, which surfaces them as
NaiveDateTime; on 2.x the parser coerced floating times to a
zoned DateTime, so they arrive already anchored to a zone.
Tempo maps whatever the installed parser produces — it does not
itself invent a zone.
Summary
Functions
The available time an already-parsed calendar declares.
Materialise the available time an iCalendar's VAVAILABILITY
components declare, as a %Tempo.IntervalSet{}.
Parse an iCalendar string and return a %Tempo.IntervalSet{}.
Parse an iCalendar file and return a %Tempo.IntervalSet{}.
Functions
@spec available( ICal.t(), keyword() ) :: {:ok, Tempo.IntervalSet.t()} | {:error, term()}
The available time an already-parsed calendar declares.
The %ICal{} counterpart of available_from_ical/2, for when the
calendar has been parsed once and is being asked several
questions.
Arguments
calendaris an%ICal{}struct.optionsis a keyword list of options.
Options
Returns
{:ok, interval_set}or{:error, reason}.
Examples
iex> calendar = Elixir.ICal.from_ics("""
...> BEGIN:VCALENDAR
...> VERSION:2.0
...> BEGIN:VAVAILABILITY
...> UID:clinic
...> DTSTAMP:20260601T000000Z
...> BEGIN:AVAILABLE
...> UID:tuesday-clinic
...> DTSTAMP:20260601T000000Z
...> DTSTART:20260602T090000Z
...> DTEND:20260602T170000Z
...> END:AVAILABLE
...> END:VAVAILABILITY
...> END:VCALENDAR
...> """)
iex> {:ok, free} = Tempo.ICal.available(calendar, within: ~o"2026Y6M1D/2026Y6M8D")
iex> [tuesday] = Tempo.IntervalSet.to_list(free)
iex> {tuesday.from.time[:day], tuesday.from.time[:hour], tuesday.to.time[:hour]}
{2, 9, 17}
@spec available_from_ical( binary(), keyword() ) :: {:ok, Tempo.IntervalSet.t()} | {:error, term()}
Materialise the available time an iCalendar's VAVAILABILITY
components declare, as a %Tempo.IntervalSet{}.
Where from_ical/2 reads VEVENTs — time that is taken —
this reads RFC 7953
availability: time that is offered. The two are complements,
not variants, and a scheduler usually wants both: open hours from
here, existing claims from there.
How a VAVAILABILITY is read
A VAVAILABILITY covers a period — DTSTART to DTEND, either
end optional and unbounded when absent. Inside that period the
default is busy, and each AVAILABLE subcomponent carves out free
time, repeating under its own RRULE, RDATE and EXDATE. An
AVAILABLE is shaped exactly like a VEVENT, so it materialises
through the same expander — the occurrence span, the RDATE extras
and the EXDATE removals all behave identically.
Outside every component's period nothing is asserted, so nothing is returned: absence of a statement is not a statement of absence.
Priority
Where two components overlap, PRIORITY decides which applies —
1 is the highest and 9 the lowest, while 0 or absent ranks below
all of them. The winner decides its whole period, including the
parts where it offers nothing: a high-priority component with no
AVAILABLE covering Tuesday makes Tuesday unavailable, whatever a
lower-priority component says.
Arguments
icsis iCalendar data as a string.optionsis a keyword list of options.
Options
:withinis the query window, aTempo.Interval.t/0or any valueTempo.to_interval/2accepts. Required: anAVAILABLEcarrying an unboundedRRULEhas no materialisation without one.
Returns
{:ok, interval_set}of the available time; or{:error, reason}.
Examples
iex> ics = """
...> BEGIN:VCALENDAR
...> VERSION:2.0
...> BEGIN:VAVAILABILITY
...> UID:office-hours
...> DTSTAMP:20260601T000000Z
...> DTSTART:20260601T000000Z
...> DTEND:20260608T000000Z
...> BEGIN:AVAILABLE
...> UID:weekday-mornings
...> DTSTAMP:20260601T000000Z
...> DTSTART:20260601T090000Z
...> DTEND:20260601T120000Z
...> RRULE:FREQ=DAILY;COUNT=5
...> END:AVAILABLE
...> END:VAVAILABILITY
...> END:VCALENDAR
...> """
iex> {:ok, free} = Tempo.ICal.available_from_ical(ics, within: ~o"2026Y6M1D/2026Y6M8D")
iex> Tempo.IntervalSet.count(free)
5
@spec from_ical( binary(), keyword() ) :: {:ok, Tempo.IntervalSet.t()} | {:error, term()}
Parse an iCalendar string and return a %Tempo.IntervalSet{}.
Every VEVENT becomes one %Tempo.Interval{} in the result.
All-day events (DTSTART as a Date) use day-resolution
endpoints; datetime events use the matching datetime
resolution. The event's SUMMARY, DESCRIPTION, LOCATION,
UID, STATUS, TRANSPARENCY, CATEGORIES, attendees, and
organizer all flow into the interval's :metadata map.
The calendar-level metadata (PRODID, VERSION, CALSCALE,
METHOD, and the user-visible name from X-WR-CALNAME when
present) attaches to the IntervalSet's :metadata map.
Arguments
icsis an iCalendar string (the contents of an.icsfile).
Options
:bound— aTempo.t/0,Tempo.Interval.t/0, orTempo.IntervalSet.t/0within which recurring events (those with anRRULE) are expanded. Required when any event in the input has a recurrence rule; ignored when there are none. An unbounded recurrence is infinite and refused at set-op time.
Returns
{:ok, interval_set}— sorted, coalesced IntervalSet of the events.{:error, reason}when parsing fails or a recurring event requires a:boundthat wasn't supplied.
Examples
iex> ics = """
...> BEGIN:VCALENDAR
...> VERSION:2.0
...> PRODID:-//Test//Test//EN
...> BEGIN:VEVENT
...> UID:test-1
...> DTSTAMP:20220101T000000Z
...> DTSTART:20220615T100000Z
...> DTEND:20220615T110000Z
...> SUMMARY:Test meeting
...> LOCATION:Paris
...> END:VEVENT
...> END:VCALENDAR
...> """
iex> {:ok, set} = Tempo.ICal.from_ical(ics)
iex> Tempo.IntervalSet.count(set)
1
iex> [iv] = Tempo.IntervalSet.to_list(set)
iex> iv.metadata.summary
"Test meeting"
iex> iv.metadata.location
"Paris"
@spec from_ical_file( binary(), keyword() ) :: {:ok, Tempo.IntervalSet.t()} | {:error, term()}
Parse an iCalendar file and return a %Tempo.IntervalSet{}.
Wraps from_ical/2 with File.read/1.
Arguments
pathis a path to an.icsfile.
Options
See from_ical/2.
Returns
{:ok, interval_set}or{:error, reason}.