Tempo

Tempo is an Elixir library that models time the way humans actually use it — as bounded spans on a shared timeline rather than as scalar instants. One type represents every temporal value you might deal with: a year, a month, an afternoon, a meeting, an archaeological period, a recurring event, a free-busy calendar. Every value is a bounded interval at some resolution, and every operation (iteration, comparison, set-theoretic combination) is defined uniformly.
This conceptual shift — time as interval, not instant — removes a surprising number of real-world bugs (off-by-one day errors, ambiguous "end of day", last day of month, last day of year, DST edge cases, "what date does this year mean?") while unlocking queries that are awkward or impossible in other libraries.
Installation
def deps do
[
{:ex_tempo, "~> 0.16"},
# Optional but recommended - needed for iCalendar import
{:ical, "~> 2.0"}
]
end
Why intervals, not instants
Every mainstream language treats date, time, and datetime as distinct scalar types. That fragmentation creates three classes of common bugs that Tempo eliminates by construction:
-
The "what does this value mean" ambiguity: Is ~D[2026-06-15] the instant of midnight on June 15, or the whole day? Most libraries can't say — the type is a scalar but it's being used as a span, with every developer inventing their own end_of_day/1 helper. In Tempo, ~o"2026-06-15" is the interval [2026-06-15T00:00, 2026-06-16T00:00). No helpers needed; the semantics are the type.
-
The type-per-resolution explosion: Date for days, Time for hours/minutes/seconds, DateTime for the combination — and conversions between them are lossy in confusing directions. Tempo's single %Tempo{} struct carries its own resolution. A year, a month, a meeting, a millennium — same type, different resolution.
-
The "I can't express that" ceiling: Archaeological dates ("sometime in the 1560s"), EDTF-qualified values ("approximately 2022"), open-ended intervals ("from 1985 onwards"), Hebrew-to-Gregorian queries, recurrences, free-busy spans — all awkward or impossible to express cleanly as scalar instants. All natural in Tempo.
Once every value is a bounded interval, set operations follow naturally: union, intersection, complement, difference, and predicates (overlaps?, subset?, contains?) all work on any combination of Tempo values, across resolutions, across timezones, across calendars.
What it looks like
Full ISO 8601-2 / EDTF / IXDTF support, calendar-aware arithmetic, cross-zone set operations. In fact, probably the only fully ISO 8601 Parts 1 and 2 in existence (really, I couldn't find one anywhere - please let me know if you know of one).
Every example below uses the ~o sigil to construct Tempo values at compile time. 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. Treat this line as a prerequisite for every code block that follows in this README.
# A date is an interval
iex> ~o"2026-06-15"
~o"2026Y6M15D"
# Its bounds are real — Tempo.to_interval materialises the span
iex> {:ok, iv} = Tempo.to_interval(~o"2026-06-15")
iex> {from, to} = Tempo.Interval.endpoints(iv)
iex> {Tempo.year(from), Tempo.month(from), Tempo.day(from), Tempo.hour(from), Tempo.year(to), Tempo.month(to), Tempo.day(to), Tempo.hour(to)}
{2026, 6, 15, 0, 2026, 6, 16, 0}
# Cross-zone set operations compare by UTC, preserve the first operand's zone
iex> paris = Tempo.from_elixir(DateTime.new!(~D[2026-06-15], ~T[10:00:00], "Europe/Paris"))
iex> utc_window = ~o"2026-06-15T07/2026-06-15T09" # UTC 07:00..09:00
iex> Tempo.overlaps?(paris, utc_window)
true # Paris 10:00 CEST == UTC 08:00 — inside the window
# Cross-calendar comparison, no manual conversion
iex> hebrew = Tempo.new!(year: 5786, month: 10, day: 30, calendar: Calendrical.Hebrew)
iex> Tempo.overlaps?(hebrew, ~o"2026-06-15")
true # Hebrew 5786-10-30 is Gregorian 2026-06-15
Three ways to construct a Tempo
The ~o sigil is ideal for literal values in source code. For runtime data — form inputs, database rows, API payloads — reach for Tempo.new/1, which takes any order of keyword components and validates them against the target calendar:
# Compile-time literal — the sigil
iex> ~o"2026-06-15T14:30[Australia/Sydney]"
# Runtime components — `new/1` reorders any input shape coarse-to-fine
iex> Tempo.new!(day: 15, year: 2026, month: 6, hour: 14, minute: 30, zone: "Australia/Sydney")
# Bridging stdlib — `from_elixir/1` accepts Date / Time / NaiveDateTime / DateTime
iex> Tempo.from_elixir(~U[2026-06-15 14:30:00Z])