Mix.install([
{:ex_tempo, "~> 2.0"},
{:kino, "~> 0.14"},
{:tz, "~> 0.28"}
])
Calendar.put_time_zone_database(Tz.TimeZoneDatabase)Setup
Run this cell next: every example below depends on it. It imports Tempo.Sigils, which provides the ~o sigil the examples use to write a Tempo value as ISO 8601 text — ~o"2026-06-15" is the whole day of 15 June 2026. The import brings in only sigil_o/2, no other functions.
It needs a cell of its own because Livebook compiles a whole cell before running it, so the setup cell above can't import a module that its own Mix.install/2 has yet to fetch.
import Tempo.SigilsThe thesis — time is an interval, not an instant
Most languages split time into three scalar types — Date, Time, DateTime — and leave you to figure out which interval a scalar "really means." Is ~D[2026-06-15] the instant of midnight, or the whole day? Your codebase probably has helpers like end_of_day/1 because the type can't say.
Tempo takes the opposite stance. Every Tempo value is a bounded span on the time line. One type. Every operation — iteration, comparison, set algebra — is defined uniformly across every resolution and every calendar.
Start with the simplest example. A plain day:
~o"2026-06-15"Now see its bounds:
{:ok, interval} = Tempo.to_interval(~o"2026-06-15")
Tempo.Interval.endpoints(interval)The span is [2026-06-15 00:00, 2026-06-16 00:00) — half-open, so adjacent days join with no gap and no overlap. Written as an interval, a day runs from itself through itself: an end that is a date is read through, and an end that is a time of day is the reading the span runs to.
The same rule applies at every resolution:
for value <- [~o"2026", ~o"2026-06", ~o"2026-06-15", ~o"2026-06-15T10"] do
{:ok, iv} = Tempo.to_interval(value)
{from, to} = Tempo.Interval.endpoints(iv)
%{input: inspect(value), from: inspect(from), to: inspect(to)}
end
|> Kino.DataTable.new()A year is a year long, a month a month, an hour an hour: the unit a value is written to is its span.
Explaining a value
If you hand a Tempo value to a colleague who's never seen it, what does it mean? Tempo has a built-in explainer:
Tempo.explain(~o"156X")Tempo.explain(~o"1984?/2004~")Tempo.Explain.lines/1 gives the same explanation as a list of its sentences, for a surface that lays them out itself:
Tempo.Explain.lines(~o"156X")Iteration
Every Tempo value is enumerable. A walk gives the unit below the one the value is written to — a year its months, a month its days.
Enum.take(~o"2026Y", 12)Enum.take(~o"2026-06", 5)A value with unspecified digits names several values, and a walk gives each. The 1560s:
Enum.take(~o"156X", 10)Comparison and predicates
Tempo implements Allen's interval algebra: Tempo.relation/2 names the precise relation (all thirteen, :precedes through :preceded_by), Tempo.Allen has a predicate for each, and the everyday questions have named predicates — overlaps?, contains?, before?, after?, adjacent?, within?, equal?, disjoint?.
a = ~o"2026-06-01/2026-06-15"
b = ~o"2026-06-10/2026-06-20"
%{
"a contains 2026-06-10": Tempo.contains?(a, ~o"2026-06-10"),
"a overlaps b": Tempo.overlaps?(a, b),
"a before 2026-07": Tempo.before?(a, ~o"2026-07")
}Set operations
Once every value is a bounded interval, the set-theoretic operations follow naturally.
Union — both windows kept distinct (member-preserving)
{:ok, both} = Tempo.union(a, b)
Tempo.IntervalSet.members(both)The union of
aandbkeeps both intervals as members, each with its metadata.
For the one continuous window they cover between them, coalesce:
{:ok, merged} = Tempo.IntervalSet.coalesce(both)
Tempo.IntervalSet.members(merged)One continuous window — June 1 through June 20 — after coalescing.
Intersection — the overlap between two windows
{:ok, overlap} = Tempo.intersection(a, b)
Tempo.IntervalSet.members(overlap)The intersection of
aandbis just the shared stretch — June 10 through June 15.
Difference — free time around a lunch break
work_day = ~o"2026-06-15T09/2026-06-15T17"
lunch = ~o"2026-06-15T12/2026-06-15T13"
{:ok, free} = Tempo.difference(work_day, lunch)
Tempo.IntervalSet.members(free)The workday minus lunch is my free time — two intervals, 09:00-12:00 and 13:00-17:00.
Two free intervals: 09:00-12:00 and 13:00-17:00.
Selecting — Tempo.select/3
Tempo.select/3 picks, from a base, the values a selector names, and answers a set of them. A selector is the workdays or the weekend of a territory (Tempo.workdays/2, Tempo.weekends/1), whole numbers for days of the month, or a value with no year: a date, a weekday, a time of day.
A selector that depends on a territory is made by Tempo.workdays/2 or Tempo.weekends/1 and given to select/3, which itself asks nothing of the locale.
Workdays of a month
{:ok, workdays} = Tempo.select(~o"2026-06", Tempo.workdays(:US))
Tempo.IntervalSet.members(workdays) |> length()The workdays of June 2026 in the United States are Monday to Friday: 22 days.
Pick specific days inside a month
{:ok, paydays} = Tempo.select(~o"2026-06", [1, 15])
Tempo.IntervalSet.map(paydays, &Tempo.day/1)Whole numbers select days of the month, in each month of the base.
Project a date pattern onto a larger base
{:ok, holidays} = Tempo.select(~o"2026", [~o"7M4D", ~o"12M25D"])
Tempo.IntervalSet.map(holidays, &{Tempo.month(&1), Tempo.day(&1)})A value with no year, such as
7M4D, selects that date in each year of the base: the 4th of July and the 25th of December 2026.
Different territory, different weekend
{:ok, us} = Tempo.select(~o"2026-02", Tempo.weekends(:US))
{:ok, saudi} = Tempo.select(~o"2026-02", Tempo.weekends(:SA))
{
Tempo.IntervalSet.map(us, &Tempo.day/1),
Tempo.IntervalSet.map(saudi, &Tempo.day/1)
}The US weekend is Sat+Sun, the Saudi weekend is Fri+Sat.
Tempo.weekends/1andTempo.workdays/2take a territory atom, a territory string, a locale string ("ar-SA"), or a%Localize.LanguageTag{}. With no argument they useApplication.get_env(:ex_tempo, :default_territory)→Localize.get_locale().
Compose with set operations
{:ok, june_workdays} = Tempo.select(~o"2026-06", Tempo.workdays(:US))
vacation = ~o"2026-06-15/2026-06-20"
{:ok, available} = Tempo.members_outside(june_workdays, vacation)
Tempo.IntervalSet.members(available)The workdays of June outside my vacation are the days I am available. A selection is a set, so it goes straight into the set operations.
Recurring events
Tempo reads RFC 5545's RRULE into the same recurrence ISO 8601-2 writes, so FREQ=DAILY;COUNT=10 and "the fourth Thursday of November, every year" go through one engine.
Each example reads a rule with Tempo.RRule.parse/2, starting at its DTSTART, and lists its occurrences with Tempo.to_interval/2.
Every Monday for 10 weeks
start_date = fn interval ->
start = Tempo.Interval.from(interval)
Date.new!(Tempo.year(start), Tempo.month(start), Tempo.day(start))
end
{:ok, mondays} = Tempo.RRule.parse("FREQ=WEEKLY;BYDAY=MO;COUNT=10", from: ~o"2026-06-01")
{:ok, occurrences} = Tempo.to_interval(mondays)
occurrences
|> Tempo.IntervalSet.members()
|> Enum.map(&%{date: start_date.(&1)})
|> Kino.DataTable.new()Thanksgiving (4th Thursday of November)
{:ok, rule} = Tempo.RRule.parse("FREQ=YEARLY;BYMONTH=11;BYDAY=4TH;COUNT=5", from: ~o"2022-11-24")
{:ok, thanksgivings} = Tempo.to_interval(rule)
thanksgivings |> Tempo.IntervalSet.members() |> Enum.map(start_date)US Presidential Election Day
"Every four years, the first Tuesday after a Monday in November":
{:ok, rule} =
Tempo.RRule.parse("FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8;COUNT=5",
from: ~o"1996-11-05"
)
{:ok, elections} = Tempo.to_interval(rule)
elections |> Tempo.IntervalSet.members() |> Enum.map(start_date)Every Friday the 13th
{:ok, rule} = Tempo.RRule.parse("FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13;COUNT=10", from: ~o"1998-02-13")
{:ok, f13s} = Tempo.to_interval(rule)
f13s |> Tempo.IntervalSet.members() |> Enum.map(start_date)Last weekday of every month
{:ok, rule} =
Tempo.RRule.parse("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1;COUNT=6", from: ~o"2026-06-01")
{:ok, last_weekdays} = Tempo.to_interval(rule)
last_weekdays |> Tempo.IntervalSet.members() |> Enum.map(start_date)BYSETPOS applies last, picking the Nth element of each month's candidate set.
Cross-calendar comparison
An expression is in a calendar. The RFC 9557 suffix [u-ca=NAME] says which — Hebrew, Islamic, Persian, Buddhist and the rest of CLDR's:
{:ok, hebrew} = Tempo.from_iso8601("5786-09-30[u-ca=hebrew]")
Tempo.overlaps?(hebrew, ~o"2026-06-15")Two expressions in different calendars are compared on the one time line they share.
Cross-timezone comparison
paris = Tempo.from_datetime(DateTime.new!(~D[2026-06-15], ~T[10:00:00], "Europe/Paris"))
utc_window = ~o"2026-06-15T07Z/2026-06-15T09Z"
Tempo.overlaps?(paris, utc_window)Both values are zoned — the Paris time by its zone, the window by Z. Comparing a zoned value with a floating one (no zone or offset) raises a Tempo.FrameError rather than assuming UTC; place the floating side in a zone first with Tempo.in_zone/2.
Paris 10:00 CEST is UTC 08:00 — inside the UTC 07:00-09:00 window. Tempo asks the configured time zone database at each call; the value keeps the reading it was written with.
Archaeological and approximate dates
ISO 8601-2 has marks for the vague dates of historical records.
"Sometime in the 1560s"
Enum.take(~o"156X", 10)"Approximately 2022"
value = ~o"2022~"
{:ok, iv} = Tempo.to_interval(value)
%{
value: inspect(value),
qualification: Tempo.qualification(value, :year),
span: iv
}The mark is information about the value and changes nothing of its span — this is still the year 2022, with a note that it is approximate.
Open-ended intervals
[~o"1985/..", ~o"../2024", ~o"../.."]
|> Enum.map(fn iv ->
{from, to} = Tempo.Interval.endpoints(iv)
%{input: inspect(iv), from: inspect(from), to: inspect(to)}
end)
|> Kino.DataTable.new()An open end is :open. An interval may be open at either end, or at both (../.., the whole time line).
Non-contiguous mask expansion
The 15th of every month in 1985:
{:ok, set} = Tempo.to_interval(~o"1985-XX-15")
set
|> Tempo.IntervalSet.map(fn interval ->
{from, to} = Tempo.Interval.endpoints(interval)
%{from: inspect(from), to: inspect(to)}
end)
|> Kino.DataTable.new()Twelve days: the unspecified month is each month of the year, and the 15th is that day of each.
Interval relations — Allen's algebra
Tempo.relation/2 returns one of 13 Allen-algebra relations between two intervals:
a = ~o"2026-06-01/2026-06-10"
b = ~o"2026-06-05/2026-06-15"
%{
"a overlaps b": Tempo.relation(a, b),
"a meets next day": Tempo.relation(~o"2026-06-15", ~o"2026-06-16"),
"year contains day": Tempo.relation(~o"2026Y", ~o"2026-06-15")
}Named predicates wrap the common checks — within?, adjacent?, before?, after? — and Tempo.Allen tests for one exact relation, such as Tempo.Allen.meets?/2:
window = ~o"2026-06-15T09/2026-06-15T17"
candidate = ~o"2026-06-15T10/2026-06-15T11"
Tempo.within?(candidate, window)Duration predicates
Five predicates cover the interval-length comparison lattice:
iv = ~o"2026-06-15T09/2026-06-15T10"
%{
duration: Tempo.duration(iv),
"≥ 1h": Tempo.at_least?(iv, ~o"PT1H"),
"≤ 1h": Tempo.at_most?(iv, ~o"PT1H"),
"exactly 1h": Tempo.exactly?(iv, ~o"PT1H"),
"> 30m": Tempo.longer_than?(iv, ~o"PT30M"),
"< 2h": Tempo.shorter_than?(iv, ~o"PT2H")
}Putting it together — find mutual free time ≥ 1 hour
A scenario that used to be awkward: two colleagues' calendars plus my working hours. Which windows can host a 1-hour meeting?
alice =
Tempo.IntervalSet.new!([
Tempo.Interval.new!(from: ~o"2026-06-15T10", to: ~o"2026-06-15T11", metadata: %{who: "Alice"}),
Tempo.Interval.new!(from: ~o"2026-06-15T14", to: ~o"2026-06-15T15", metadata: %{who: "Alice"})
])
bob =
Tempo.IntervalSet.new!([
Tempo.Interval.new!(from: ~o"2026-06-15T11", to: ~o"2026-06-15T12", metadata: %{who: "Bob"}),
Tempo.Interval.new!(from: ~o"2026-06-15T15:30", to: ~o"2026-06-15T16", metadata: %{who: "Bob"})
])
work_hours = ~o"2026-06-15T09/2026-06-15T17"
{:ok, alice_free} = Tempo.difference(work_hours, alice)
{:ok, bob_free} = Tempo.difference(work_hours, bob)
{:ok, mutual} = Tempo.intersection(alice_free, bob_free)
bookable = Tempo.IntervalSet.filter(mutual, &Tempo.at_least?(&1, ~o"PT1H"))
bookable
|> Tempo.IntervalSet.map(fn interval ->
{from, to} = Tempo.Interval.endpoints(interval)
%{from: inspect(from), to: inspect(to)}
end)
|> Kino.DataTable.new()Alice's free time is the workday minus her busy periods. Bob's is the same. Mutual free time is the intersection of theirs. Bookable windows are the mutual ones at least an hour long.
Three windows pass: 09:00-10:00, 12:00-14:00, and 16:00-17:00. The 15:00-15:30 gap (Alice free but Bob booked at 15:30) is correctly dropped.
Alternative — "does this exact candidate fit somewhere?"
When you have preferred times in mind rather than discovering free windows, Tempo.within?/2 reads the question naturally:
candidates = [
~o"2026-06-15T09/2026-06-15T10",
~o"2026-06-15T11/2026-06-15T12",
~o"2026-06-15T16/2026-06-15T17"
]
bookable =
Enum.filter(candidates, fn candidate ->
Enum.any?(Tempo.IntervalSet.members(mutual), &Tempo.within?(candidate, &1))
end)
bookable |> Enum.map(&inspect/1)A candidate is bookable if any mutual-free window contains it.
Two of the three candidates fit inside a mutual-free window. The 11:00-12:00 one is rejected because Bob is busy then.
What to read next
- Tempo cookbook — task-oriented recipes
- Set operations guide
- iCalendar integration — import
.icswith full RRULE/RDATE/EXDATE - ISO 8601 conformance — what parts of the standard are covered
Happy iterating.