Tempo.ICal (Tempo v1.3.0)

Copy Markdown View Source

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.md for how the imported data is used downstream.

RFC 5545 coverage

PropertyStatus
RRULEFully supported — every BY* part, WKST, and
BYSETPOS flow through the interpreter.
RDATESupported — extra occurrences with the event's
own span.
EXDATESupported — start-moment match removes the
corresponding occurrence.
EXRULENot surfaced by the underlying ical library, so
not implementable at this layer. EXRULE is also
RFC-deprecated (RFC 2445 → 5545).
MultipleRFC 5545 says SHOULD NOT; some exports do it
RRULEanyway. The ical library exposes only the
perfirst RRULE on event.rrule, so we materialise
VEVENTthat one and silently ignore the rest.
DURATIONSupported 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 with extended.zone_id: "Etc/UTC".
  • Zoned (TZID=Europe/Paris:20220615T100000) → a zoned value carrying that IANA zone.
  • Floating (20220615T100000, no Z and no TZID) → 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

available(calendar, options \\ [])

@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

  • calendar is an %ICal{} struct.

  • options is a keyword list of options.

Options

See available_from_ical/2.

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}

available_from_ical(ics, options \\ [])

@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

  • ics is iCalendar data as a string.

  • options is a keyword list of options.

Options

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

from_ical(ics, options \\ [])

@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

  • ics is an iCalendar string (the contents of an .ics file).

Options

  • :bound — a Tempo.t/0, Tempo.Interval.t/0, or Tempo.IntervalSet.t/0 within which recurring events (those with an RRULE) 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 :bound that 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"

from_ical_file(path, options \\ [])

@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

  • path is a path to an .ics file.

Options

See from_ical/2.

Returns

  • {:ok, interval_set} or {:error, reason}.