Tempo v0.2 — time as an interval, now with set operations and now on hex.
When we left off Tempo at ElixirConf '22 we demonstrated that a unified time type — a type that treats every temporal value as a bounded interval on a shared time line — removes a whole class of foot-guns that standard date libraries share. “End of day” ambiguity. Off-by-one at midnight. Last day of month. Cross-calendar comparisons. The awkward shuffle between Date, Time, NaiveDateTime, DateTime.
The basics still hold:
# A day IS an interval.
iex> ~o"2026-06-15"
~o"2026Y6M15D"
# Enumerating a month yields its days. No `Enum.map(1..days_in_month(…))`.
iex> Enum.take(~o"2026-06", 3)
[~o"2026Y6M1D", ~o"2026Y6M2D", ~o"2026Y6M3D"]
# The last day of June. No calendar arithmetic, no days_in_month, no
# special-casing February. Just a negative index, per ISO 8601-2 §4.4.1.
iex> {:ok, last} = Tempo.select(~o"2026-06", ~o"-1D")
iex> last
#Tempo.IntervalSet<[~o"2026Y6M30D/2026Y7M1D"]>
# Same idea across a leap-year boundary.
iex> {:ok, set} = Tempo.select(~o"2024-02", ~o"-1D")
iex> Tempo.IntervalSet.count(set)
1 # and the interval is Feb 29 — 2024 is a leap year
# How many hours were there in Sydney on the day DST started?
iex> Enum.count(~o"2026-10-04[Australia/Sydney]")
23 # not 24. The clock jumps 02:00 → 03:00; that hour never ticks.
# And on the day DST ended?
iex> Enum.count(~o"2026-04-05[Australia/Sydney]")
25 # 02:00 happens twice. Both are emitted, with distinct UTC offsets.
Tempo v0.2 — the other half of the thesis
Once every value is a bounded interval, set operations on time follow naturally — union, intersection, difference, symmetric difference, complement, and the corresponding predicates (overlaps?, within?, disjoint?, adjacent?). v0.2 ships all of it, across zones, across calendars, across resolutions, with metadata that survives the operation.
Which turns a surprising number of hard questions into short programs.
How many workdays in June 2026, in Australia, net of public holidays?
ics = Req.get!("https://www.officeholidays.com/ics/australia").body
{:ok, holidays} = Tempo.ICal.from_ical(ics)
June = ~o"2026-06"
{:ok, workdays} = Tempo.select(june, Tempo.workdays(:AU))
{:ok, net} = Tempo.difference(workdays, holidays)
Tempo.IntervalSet.count(net)
#=> 20 # June 2026 has 22 workdays; 2 are holidays in AU.
Swap :AU for :SA and you get the Saudi working week instead — Sunday to Thursday, with Saudi holidays. CLDR data is baked in; the set algebra is the same.
When are Bruce and Shiela both booked next month?
Meet Bruce and Shiela. Their two .ics files are in the repo (demo/calendars/). Loading them:
{:ok, bruce} = Tempo.ICal.from_ical(File.read!("demo/calendars/bruce.ics"))
{:ok, shiela} = Tempo.ICal.from_ical(File.read!("demo/calendars/shiela.ics"))
Every RRULE in those feeds — the standups, the yoga classes, the partners’ meetings — is fully expanded into concrete day-resolution intervals with the event’s summary, location, and attendee metadata attached. Bruce has 83 events; Shiela has 67.
# How much of April do they have at the same time?
iex> {:ok, clashes} = Tempo.intersection(bruce, shiela)
iex> Tempo.IntervalSet.count(clashes)
35 # 4 are shared social events; 31 are accidental work overlaps.
# Find Shiela an hour when Bruce is busy
iex> {:ok, window} = Tempo.intersection(~o"2026-04-23T14/2026-04-23T15", bruce)
iex> Tempo.IntervalSet.count(window)
1 # Bruce is in a product-strategy workshop until 16:00. Try later.
No custom scheduling engine. No hand-rolled sweep-line. Just intervals on a time line and set algebra — the same operators you’d reach for if you were intersecting two sets of integers, applied to two sets of [from, to) moments.
Q3 Mondays, excluding public holidays
q3 = ~o"2026-07/2026-10"
# day-of-week 1 = Monday
{:ok, mondays} = Tempo.select(q3, ~o"1K")
{:ok, net} = Tempo.difference(mondays, holidays)
Tempo.IntervalSet.count(net)
#=> 12
~o"1K" is ISO 8601-2 selection syntax — day-of-week selector. If you’re wondering what else you can shove in there, the built-in visualizer has a syntax-reference sidebar that explains every form; paste anything into the input and watch it decompose into coloured segments with plain-English labels.
When was I both in Japan and enrolled at university?
Two intervals, two calendars, one question:
japan = ~o"2018-05-01/2019-03-31[Asia/Tokyo]"
enrolled = ~o"2015-09/2020-06"
{:ok, both} = Tempo.intersection(japan, enrolled)
Does this dig layer overlap with that dynasty?
han_dynasty = ~o"-0202/0220" # 202 BCE to 220 CE
dig_layer_iii = ~o"-0150/0050~" # approximate end (EDTF ~)
Tempo.overlaps?(han_dynasty, dig_layer_iii)
#=> true
The approximate-end qualification qualifier ships through.
In this release
Set operators
Tempo.union/2,
intersection/2,
difference/2,
complement/2,
symmetric_difference/2.
Predicates
overlaps?,
within?,
disjoint?,
adjacent?,
- plus the full Allen algebra via
Tempo.Interval.compare/2.
Tempo.select/2
The composition primitive. Narrow any base span by an integer-list, range, Tempo projection, day-of-week pattern, or function. Negative indices count from the end. Returns an IntervalSet that plugs straight into the set ops.
Tempo.workdays/1,
Tempo.weekend/1.
These are Territory-aware, CLDR-backed. workdays(t) ++ weekend(t) partitions the seven days of the week.
iCalendar import
- Full RFC 5545 RRULE support — every
BY* rule, BYSETPOS, WKST, RDATE, EXDATE. thanks to the wonderful iCal.
- Event metadata (summary, location, attendees, status) travels through every downstream set operation.
DST-aware enumeration
Gap hours are skipped; fold hours are emitted twice with distinct offsets. The wall clock is authoritative; UTC projects on demand. No silent drift when Tzdata ships a rule change for a future zone.
Leap-second metadata
spans_leap_second?/1, leap_seconds_spanned/1, and opt-in leap-aware duration on intervals.
- The 27 IERS-announced historical leap seconds are validated at parse time (
23:59:60 is accepted only on those dates).
IXDTF (RFC 9557)
- Zone suffixes, calendar suffixes (
[u-ca=hebrew]), and arbitrary tagged suffixes parsed and round-tripped.
ISO 8601 Parts 1 & 2 + EDTF Levels 0–2
-
100% of the unt-libraries/edtf-validate corpus passes.
-
Archaeological masks, uncertain/approximate qualifications, long years with exponents (Y17E8), significant-digits notation — all parseable and queryable.
Web visualizer
Tempo.Visualizer.Standalone.start(port: 4001) and paste any ISO 8601 / EDTF / IXDTF string. Every character is coloured by role (numbers, literals, qualifiers, syntax, separators), each component gets its own labelled box with a plain-English description, and a permanent sidebar explains the syntax with copy-pasteable examples.
The visualizer is most definitely still a work in progress.
Thanks
Tempo stands on the shoulders of Calendrical, Localize, Tzdata, Astro, and ical — each of which does the hard calendrical / locale / zone / recurrence work that Tempo’s set algebra builds on.