Powered by AppSignal & Oban Pro

A tour of Tempo

livebook/tempo_tour.livemd

A tour of Tempo

Mix.install([
  {:ex_tempo, github: "kipcole9/tempo"},
  {:kino, "~> 0.14"}
])

import Tempo.Sigil

The 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 concatenate cleanly ([a, b) ++ [b, c) == [a, c)). No off-by-one. No ambiguity.

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 implicitly spans one year. A month spans one month. An hour spans one hour. The next-finer unit sets the span — there’s no guessing.

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~")

For richer UI surfaces (HTML, coloured terminals) use Tempo.Explain.explain/1 — it returns a %Tempo.Explanation{} with tagged parts that to_string/1, to_ansi/1, or to_iodata/1 render differently:

explanation = Tempo.Explain.explain(~o"156X")
explanation.parts

Iteration

Every Tempo value is enumerable. The iteration unit is “the next-higher-resolution below what’s stated” — a year yields months, a month yields days, etc.

Enum.take(~o"2026Y", 12)
Enum.take(~o"2026-06", 5)

Masks iterate over the values they represent. The 1560s:

Enum.take(~o"156X", 10)

Comparison and predicates

Tempo implements Allen’s interval algebra — overlaps?, contains?, precedes?, meets?, starts?, finishes?, equal?, and the inverse relations.

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 precedes 2026-07": Tempo.precedes?(a, ~o"2026-07")
}

Set operations

Once every value is a bounded interval, the set-theoretic operations follow naturally.

Union — merge two overlapping windows into one

{:ok, merged} = Tempo.union(a, b)
Tempo.IntervalSet.to_list(merged)

> The union of a and b is one continuous window — June 1 through June 20.

Intersection — the overlap between two windows

{:ok, overlap} = Tempo.intersection(a, b)
Tempo.IntervalSet.to_list(overlap)

> The intersection of a and b is 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.to_list(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 sub-spans — Tempo.select/3

Tempo.select/3 is a composition primitive: it narrows a base span by a selector and returns the matched sub-spans as an IntervalSet. The selector vocabulary covers locale-dependent queries (:workdays, :weekend), integer indices applied at the next-finer unit, projection of a Tempo or Interval onto a larger base, and arbitrary functions.

> Runtime resolution warning. Locale-dependent selectors (:workdays, :weekend) read the ambient locale at call time. Never capture such calls in a module attribute — the compile machine’s locale would get baked in. Always invoke them from a function body at runtime.

Workdays of a month

{:ok, workdays} = Tempo.select(~o"2026-06", :workdays)
Tempo.IntervalSet.to_list(workdays) |> length()

> Workdays of June 2026 are Monday through Friday — 22 day-resolution intervals, locale-resolved via Localize.Calendar.

Pick specific days inside a month

{:ok, paydays} = Tempo.select(~o"2026-06", [1, 15])
Tempo.IntervalSet.map(paydays, &amp;Tempo.day/1)

> Integer selectors apply at the next-finer unit below the base’s resolution — on a month base that’s day.

Project a date pattern onto a larger base

{:ok, holidays} = Tempo.select(~o"2026", [~o"07-04", ~o"12-25"])
Tempo.IntervalSet.map(holidays, &amp;{Tempo.month(&amp;1), Tempo.day(&amp;1)})

> Project the constraint months/days 07-04 and 12-25 onto the base year — July 4 and Dec 25 of 2026.

Territory override for locale-dependent selectors

{:ok, us}    = Tempo.select(~o"2026-02", :weekend)
{:ok, saudi} = Tempo.select(~o"2026-02", :weekend, territory: :SA)

{
  Tempo.IntervalSet.map(us, &amp;Tempo.day/1),
  Tempo.IntervalSet.map(saudi, &amp;Tempo.day/1)
}

> The US weekend is Sat+Sun, the Saudi weekend is Fri+Sat. The territory chain is: explicit territory: → explicit locale: → IXDTF u-rg=XX tag → Application.get_env(:ex_tempo, :default_territory) → Localize locale default. Passing locale: "ar-SA" instead of territory: :SA works the same way — the locale gets reduced to its territory.

Compose with set operations

{:ok, june_workdays} = Tempo.select(~o"2026-06", :workdays)
{:ok, vacation}      = Tempo.to_interval_set(~o"2026-06-15/2026-06-20")
{:ok, available}     = Tempo.difference(june_workdays, vacation)

Tempo.IntervalSet.to_list(available)

> Workdays of June minus my vacation yields the days I’m available. Because select/3 returns an IntervalSet, it drops straight into union/2, intersection/2, difference/2, and symmetric_difference/2.

Recurring events

Tempo implements the full RFC 5545 RRULE vocabulary. Everything from FREQ=DAILY;COUNT=10 to "4th Thursday of November, every year" goes through the same interpreter.

Every Monday for 10 weeks

alias Tempo.RRule.{Rule, Expander}

rule = %Rule{freq: :week, interval: 1, byday: [{nil, 1}], count: 10}
{:ok, occurrences} = Expander.expand(rule, ~o"2026-06-01")

occurrences
|> Enum.map(fn iv ->
  start = Tempo.Interval.from(iv)
  {Tempo.year(start), Tempo.month(start), Tempo.day(start)}
end)
|> Kino.DataTable.new()

Thanksgiving (4th Thursday of November)

rule = %Rule{
  freq: :year,
  interval: 1,
  bymonth: [11],
  byday: [{4, 4}],
  count: 5
}

{:ok, thanksgivings} = Expander.expand(rule, ~o"2022-11-24")

thanksgivings
|> Enum.map(fn iv ->
  start = Tempo.Interval.from(iv)
  {Tempo.year(start), Tempo.month(start), Tempo.day(start)}
end)

US Presidential Election Day

“Every four years, the first Tuesday after a Monday in November”:

rule = %Rule{
  freq: :year,
  interval: 4,
  bymonth: [11],
  byday: [{nil, 2}],
  bymonthday: [2, 3, 4, 5, 6, 7, 8],
  count: 5
}

{:ok, elections} = Expander.expand(rule, ~o"1996-11-05")

elections
|> Enum.map(fn iv ->
  start = Tempo.Interval.from(iv)
  {Tempo.year(start), Tempo.month(start), Tempo.day(start)}
end)

Every Friday the 13th

rule = %Rule{
  freq: :month,
  interval: 1,
  byday: [{nil, 5}],
  bymonthday: [13],
  count: 10
}

{:ok, f13s} = Expander.expand(rule, ~o"1998-02-13")

f13s
|> Enum.map(fn iv ->
  start = Tempo.Interval.from(iv)
  {Tempo.year(start), Tempo.month(start), Tempo.day(start)}
end)

Last weekday of every month

rule = %Rule{
  freq: :month,
  interval: 1,
  byday: [{nil, 1}, {nil, 2}, {nil, 3}, {nil, 4}, {nil, 5}],
  bysetpos: [-1],
  count: 6
}

{:ok, last_weekdays} = Expander.expand(rule, ~o"2026-06-01")

last_weekdays
|> Enum.map(fn iv ->
  start = Tempo.Interval.from(iv)
  {Tempo.year(start), Tempo.month(start), Tempo.day(start)}
end)

BYSETPOS applies last, picking the Nth element of each month’s candidate set.

Cross-calendar comparison

Tempo values are calendar-aware. The IXDTF [u-ca=NAME] suffix attaches a calendar to any value at parse time — Hebrew, Islamic, Persian, Buddhist, and the rest resolve to their Calendrical.* modules automatically:

{:ok, hebrew} = Tempo.from_iso8601("5786-10-30[u-ca=hebrew]")
Tempo.overlaps?(hebrew, ~o"2026-06-15")

Tempo converts via Date.convert!/2 internally, so the operation is calendar-neutral. See Calendrical.supported_cldr_calendar_types/0 for the full list of recognised CLDR calendar identifiers.

Cross-timezone comparison

paris = Tempo.from_elixir(DateTime.new!(~D[2026-06-15], ~T[10:00:00], "Europe/Paris"))
utc_window = ~o"2026-06-15T07/2026-06-15T09"

Tempo.overlaps?(paris, utc_window)

Paris 10:00 CEST is UTC 08:00 — inside the UTC 07:00-09:00 window. Tempo projects to UTC via Tzdata per-call; the wall-clock representation is preserved on the struct.

Archaeological and approximate dates

EDTF (ISO 8601-2) gives us qualifiers for vague historical references.

“Sometime in the 1560s”

Enum.take(~o"156X", 10)

“Approximately 2022”

value = ~o"2022~"
{:ok, iv} = Tempo.to_interval(value)

%{
  value: inspect(value),
  qualification: value.qualification,
  span: Tempo.Interval.endpoints(iv)
}

The qualifier decorates the value but doesn’t shift its bounds — this is still the year 2022, just with a note that the author wasn’t certain.

Open-ended intervals

[~o"1985/..", ~o"../2024", ~o"../.."]
|> Enum.map(fn iv -> %{input: inspect(iv), from: inspect(iv.from), to: inspect(iv.to)} end)
|> Kino.DataTable.new()

:undefined is the parsed shape for an open endpoint. Ranges and intervals with one open side are legal; both open (../..) is the “entire time line” identity.

Non-contiguous mask expansion

The 15th of every month in 1985:

{:ok, set} = Tempo.to_interval(~o"1985-XX-15")

set
|> Tempo.IntervalSet.map(&amp;Tempo.Interval.endpoints/1)
|> Kino.DataTable.new()

Twelve distinct day-intervals. The masked XX month expands to each valid value (1..12); the concrete day-15 pins each resulting interval.

Interval relations — Allen’s algebra

Tempo.compare/2 returns one of 13 relations between two intervals:

a = %Tempo.Interval{from: ~o"2026-06-01", to: ~o"2026-06-10"}
b = %Tempo.Interval{from: ~o"2026-06-05", to: ~o"2026-06-15"}

%{
  "a overlaps b":      Tempo.compare(a, b),
  "a meets next day":  Tempo.compare(~o"2026-06-15", ~o"2026-06-16"),
  "year contains day": Tempo.compare(~o"2026Y", ~o"2026-06-15")
}

Named predicates wrap the common checks — within?, adjacent?, before?, after?, meets?, during?:

window = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T17"}
candidate = %Tempo.Interval{from: ~o"2026-06-15T10", to: ~o"2026-06-15T11"}

Tempo.within?(candidate, window)

Duration predicates

Five predicates cover the interval-length comparison lattice:

iv = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"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{
  intervals: [
    %Tempo.Interval{from: ~o"2026-06-15T10", to: ~o"2026-06-15T11", metadata: %{who: "Alice"}},
    %Tempo.Interval{from: ~o"2026-06-15T14", to: ~o"2026-06-15T15", metadata: %{who: "Alice"}}
  ]
}

bob = %Tempo.IntervalSet{
  intervals: [
    %Tempo.Interval{from: ~o"2026-06-15T11", to: ~o"2026-06-15T12", metadata: %{who: "Bob"}},
    %Tempo.Interval{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, &amp;Tempo.at_least?(&amp;1, ~o"PT1H"))

bookable
|> Tempo.IntervalSet.map(&amp;Tempo.Interval.endpoints/1)
|> 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.to_list(mutual), &amp;Tempo.within?(candidate, &amp;1))
  end)

bookable |> Enum.map(&amp;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

Happy iterating.