iCalendar integration

Copy Markdown View Source

Tempo imports iCalendar (RFC 5545) data — the interchange format used by Google Calendar, Apple Calendar, Outlook, CalDAV servers, and every .ics file on disk — via Tempo.ICal.from_ical/2. Events convert to %Tempo.Interval{} values with their full metadata (summary, location, attendees, status, …) preserved and carried through every downstream operation, including set operations and enumeration.

This unlocks free/busy scheduling, schedule overlap analysis, and event-aware time queries as direct expressions over the same API you use for any other Tempo value.

Setup — required for every example

Every code example in this guide uses the ~o sigil from Tempo.Sigils. Before running any of them — in iex, a script, or a module — you must bring the sigil into scope:

import Tempo.Sigils

The import adds only sigil_o/2 and sigil_TEMPO/2 to the caller's namespace; no helper functions leak in.

1. A complete round-trip

ics = File.read!("~/my-schedule.ics")

{:ok, schedule} = Tempo.ICal.from_ical(ics)
# #Tempo.IntervalSet<[
#   #Tempo.Interval<~o"..." · Design review @ Room 101>,
#   #Tempo.Interval<~o"..." · 1:1 with Ada>,
#   ...
# ] · Work>

# What's on the schedule today?
today = ~o"2026-04-21"
{:ok, today_events} = Tempo.intersection(schedule, today)

# What time am I actually busy? (the covered-instant form)
busy = Tempo.IntervalSet.coalesce(schedule)
# Overlapping events merge into contiguous busy-time spans.

# When am I free during work hours?
work_hours = ~o"2026-04-21T09/2026-04-21T17"
{:ok, free} = Tempo.split_difference(work_hours, schedule)

Every result is a standard %Tempo.IntervalSet{}. Event metadata from the source schedule is preserved on whichever intervals came from those events.

2. Setup

Tempo.ICal depends on the ical library, declared optional: true on Tempo's side. Pull it into your own project:

def deps do
  [
    {:tempo, "~> 0.2"},
    {:ical, "~> 2.0"}
  ]
end

Time-zone database

The ical library needs a Calendar.TimeZoneDatabase installed to parse DTSTART;TZID=... and DTEND;TZID=... properties. Without one, those datetime fields come through as nil and Tempo silently drops the event. For most real .ics feeds (Google Calendar, iCloud, Outlook all emit zoned datetimes), this is not optional.

Configure a database in the host application's config/config.exs (or per-environment file):

# config/config.exs
config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase

Either :tzdata (which Tempo already depends on) or :tz works. Tempo's own dev and test environments pull in :tz and configure Tz.TimeZoneDatabase via config/dev.exs and config/test.exs, which is how the project's demo schedules (demo/calendars/*.ics) round-trip zoned events in mix test runs.

UTC-anchored datetimes (the 20260401T090000Z form) and floating/naive datetimes do not need a zone database — only the TZID=-parameterised form does.

3. What each event produces

A VEVENT becomes a %Tempo.Interval{}:

iCalendar propertyTempo placement
DTSTART (date)interval.from at day resolution
DTSTART (datetime)interval.from at datetime resolution
DTENDinterval.to at matching resolution
TZID (on DTSTART/DTEND)interval.from.extended.zone_id via Tempo.from_elixir/2
UIDmetadata.uid
SUMMARYmetadata.summary
DESCRIPTIONmetadata.description
LOCATIONmetadata.location
STATUSmetadata.status (atom like :tentative)
TRANSPARENCYmetadata.transparency
CATEGORIESmetadata.categories (list)
URLmetadata.url
CLASSmetadata.classification
PRIORITYmetadata.priority
ORGANIZERmetadata.organizer (name or inspected struct)
ATTENDEEsmetadata.attendees (list of names or inspected structs)
X-* custommetadata.custom (map)

The VCALENDAR envelope produces set-level metadata on the IntervalSet:

Envelope propertySet-level metadata
PRODIDmetadata.prodid
VERSIONmetadata.version
CALSCALEmetadata.scale
METHODmetadata.method
X-WR-CALNAMEmetadata.name

4. Metadata flows through set operations

The key promise: once an event is in Tempo, its metadata travels with any portion of it that survives set operations.

# A design review meeting
event = Tempo.Interval.new!(
  from: ~o"2026-04-21T10:30",
  to: ~o"2026-04-21T11:30",
  metadata: %{summary: "Design review", location: "Room 101"}
)

# Clip to work hours
{:ok, clipped} = Tempo.intersection(event, ~o"2026-04-21T09/2026-04-21T17")
[iv] = Tempo.IntervalSet.to_list(clipped)

Tempo.Interval.metadata(iv).summary
# "Design review"  — preserved through the intersection

The rule: result intervals inherit the A-operand's per-interval metadata. For intersection/2, every result fragment stays tagged with the source event's metadata. For difference/2, the uncovered portions of A keep A's metadata. Set-level metadata (calendar PRODID etc.) follows the first operand through all operations.

5. Overlapping events are preserved

Real schedules routinely have overlapping events — a travel event on top of a lunch meeting, an all-day conference covering several one-hour talks. Tempo.ICal.from_ical/2 returns an IntervalSet with overlaps preserved — the default member-preserving semantics of Tempo.IntervalSet keep each event as a distinct member so identity and metadata survive every subsequent set operation.

{:ok, set} = Tempo.ICal.from_ical(ics)
# The set may contain overlapping intervals — each one a
# distinct VEVENT with its own metadata.

When you want free/busy spans (canonical covered-instant form), coalesce explicitly:

busy = Tempo.IntervalSet.coalesce(set)

Tempo.IntervalSet.coalesce/1 merges touching and overlapping members under the half-open convention and drops the dropped members' metadata. Use it only when you need the covered-instant shape; the default member-preserving form is what downstream set operations on schedules expect.

6. Free/busy patterns

The most common iCal workflow:

# 1. Import the schedules
{:ok, work} = Tempo.ICal.from_ical_file("work.ics")
{:ok, personal} = Tempo.ICal.from_ical_file("personal.ics")

# 2. Merge into one busy-set
{:ok, all_busy} = Tempo.union(work, personal)

# 3. Find free time within a window
work_hours = ~o"2026-04-21T09/2026-04-21T17"
{:ok, free} = Tempo.difference(work_hours, all_busy)

# 4. Enumerate free slots
Tempo.IntervalSet.map(free, &Tempo.Interval.endpoints/1)

7. Recurrence expansion

Events with an RRULE materialise through a single pipeline:

%ICal.Recurrence{}  
                      Tempo.RRule.Expander.to_ast/3  %Tempo.Interval{}
RRULE string                                          
                                                               
                                                     Tempo.to_interval/2
                                                               
                                                               
                                                    Tempo.RRule.Selection
                                                               
                                                               
                                                     [%Tempo.Interval{}]

Every RFC 5545 BY-rule flows through one interpreter — there is no "simple core" and no "full expander" split. Each occurrence is a distinct %Tempo.Interval{} with the event's metadata attached.

Supported rule parts

PartSupport
FREQSECONDLY, MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY
INTERVALPositive integer; applied as DTSTART + i × INTERVAL
COUNTTerminates after N materialised occurrences (post-filter)
UNTILDate or UTC datetime; inclusive
BYMONTHLIMIT generally; EXPAND for FREQ=YEARLY
BYMONTHDAYLIMIT generally; EXPAND for FREQ=MONTHLY / YEARLY. Signed indexing (-1 = last day of the month)
BYYEARDAYLIMIT generally; EXPAND for FREQ=YEARLY. Signed
BYWEEKNOEXPAND for FREQ=YEARLY (ISO week, Monday-first). Signed
BYDAYLIMIT for DAILY and finer. EXPAND within the enclosing week / month / year for WEEKLY / MONTHLY / YEARLY. Ordinal prefixes (1MO, -1FR, 4TH) select the Nth weekday of the enclosing period via Calendrical.Kday.nth_kday/3. RFC Notes 1/2 downgrade to LIMIT when BYMONTHDAY / BYYEARDAY is co-present
BYHOUREXPAND when FREQ is coarser than hour; LIMIT otherwise
BYMINUTESame pattern at the minute unit
BYSECONDSame pattern at the second unit
BYSETPOSApplied last, across the per-period candidate set. Signed
WKSTWeek-start day (default Monday). Affects FREQ=WEEKLY + BYDAY week boundaries
RDATEExtra occurrences; carry the event's span
EXDATESubtracted from the combined set by start-moment match

Termination paths

  • COUNT=N — stop after N materialised occurrences (after BY-rule filtering / expansion).
  • UNTIL=<date-or-datetime> — stop when the next occurrence would start past UNTIL.
  • No COUNT or UNTIL — a :bound option is required at the call site. The rule expands within the bound.

Worked examples

# "Every Friday the 13th, 5 occurrences"
ics = "...RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13;COUNT=5..."
{:ok, set} = Tempo.ICal.from_ical(ics)
# => 1998-02-13, 1998-03-13, 1998-11-13, 1999-08-13, 2000-10-13

# "The 4th Thursday of November" (US Thanksgiving)
ics = "...RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=4TH;COUNT=3..."
{:ok, set} = Tempo.ICal.from_ical(ics)
# => 2022-11-24, 2023-11-23, 2024-11-28

# "Last weekday of every month"
ics = "...RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1;COUNT=3..."
{:ok, set} = Tempo.ICal.from_ical(ics)

# With RDATE + EXDATE
ics = """
...
RRULE:FREQ=WEEKLY;COUNT=3
RDATE:20220618T140000Z
EXDATE:20220608T090000Z
...
"""
{:ok, set} = Tempo.ICal.from_ical(ics)
# Weekly series has Jun 8 removed and Jun 18 14:00 added

DTSTART is always the first occurrence

Per RFC 5545, DTSTART is the first instance in the recurrence. When BY* rules EXPAND, they can legitimately produce candidates earlier than DTSTART in the same period — the resolver drops these automatically so the emitted list starts at (or after) DTSTART.

Calendar-aware throughout

Every arithmetic operation goes through the candidate's own calendar (calendar.day_of_week/4, calendar.days_in_month/2, calendar.iso_week_of_year/3, Date.add/2 with the calendar arg, Calendrical.Kday.nth_kday/3). A Hebrew-calendar VEVENT with FREQ=YEARLY;BYMONTHDAY=-1 expands correctly against Hebrew month lengths.

8. What's not in v1

  • EXRULE — RFC-deprecated (RFC 2445 → 5545) and not surfaced by the underlying ical library. If you need subtractive rules, use EXDATE for specific dates.

  • Multiple RRULE per VEVENT — RFC 5545 says SHOULD NOT. Some exports do it anyway; the ical library exposes only the first RRULE on event.rrule, so we materialise that one and silently ignore the rest.

  • Duration-only events. VEVENTs with DURATION but no DTEND — the ical library exposes the duration in its own record shape that doesn't line up with Tempo's %Tempo.Duration{}. Bridging the two is a small follow-up.

  • VTIMEZONE definitions. VTIMEZONE blocks in the input are used by the ical library to resolve zoned DTSTART/DTEND values, but Tempo itself relies on Tzdata for zone calculations. Zones not in Tzdata (historical / non-standard zones defined in the VTIMEZONE) may not round-trip cleanly.

  • Export. Tempo → iCalendar (going the other way) isn't implemented. Tempo emits RRULE via to_rrule/1 for individual values; a full to_ical/1 that produces a VCALENDAR envelope with VEVENTs is a future step.