Tempo

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. Everything positioned is an interval — a year, a month, an afternoon, a meeting, an archaeological period, a recurring event — each a bounded interval at some resolution. Collections of them, like a free-busy calendar, are derivations over that one type rather than new types; the sole companion is Duration, an unpositioned width from which an interval is formed once it is given a start. 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.

Features

Supported Elixir and OTP versions

Tempo supports Elixir 1.17 and later on Erlang/OTP 27 and later; OTP 26 is not supported.

Installation

def deps do
  [
    {:ex_tempo, "~> 2.1"},
    # Optional but recommended - needed for iCalendar import.
    {:ical, "~> 3.2"}
  ]
end

Tempo is published on hex as ex_tempo because the tempo name on hex was already taken; its modules are Tempo and Tempo.*, so in code it is tempo. Docs at https://hexdocs.pm/ex_tempo.

Quick start

Full ISO 8601-2 / EDTF / IXDTF support, calendar-aware arithmetic, cross-zone set operations. In fact, probably the only full implementation of 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 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: a day is a day long, and holds its hours
iex> ~o"2026-06-15"
~o"2026Y6M15D"

iex> Tempo.duration(~o"2026-06-15")
~o"P1D"

iex> Enum.take(~o"2026-06-15", 3)
[~o"2026Y6M15DT0H", ~o"2026Y6M15DT1H", ~o"2026Y6M15DT2H"]

iex> ~o"2026-06-15T10:30" in ~o"2026-06-15"
true

# Values in different zones are compared on the one time line
iex> paris = Tempo.from_elixir!(DateTime.new!(~D[2026-06-15], ~T[10:00:00], "Europe/Paris"))
iex> Tempo.within?(paris, ~o"2026-06-15T07Z/2026-06-15T09Z")
true

# Values in different calendars too, with no conversion by hand
iex> hebrew = Tempo.new!(year: 5786, month: 9, day: 30, calendar: Calendrical.Hebrew)
iex> Tempo.overlaps?(hebrew, ~o"2026-06-15")
true

"The fifteenth of June is a day long and holds twenty-four hours, half past ten among them. Ten o'clock in Paris is within seven to nine in UTC. The thirtieth of Sivan 5786 overlaps the fifteenth of June 2026."

Four 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 its units in any order and validates them against the calendar:

# A literal, read when the module compiles
iex> ~o"2026-06-15T14:30[Australia/Sydney]"
~o"2026Y6M15DT14H30M[Australia/Sydney]"

# Units at runtime, in any order
iex> Tempo.new!(day: 15, year: 2026, month: 6, hour: 14, minute: 30, zone: "Australia/Sydney")
~o"2026Y6M15DT14H30M[Australia/Sydney]"

# Elixir's own Date, Time, NaiveDateTime and DateTime
iex> Tempo.from_elixir(~U[2026-06-15 14:30:00Z])
{:ok, ~o"2026Y6M15DT14H30M0SZ"}

A rule, or anything said a piece at a time, is built a step at a time with Tempo.Builder, each step a complete expression:

iex> import Tempo.Builder
iex> every(:week) |> on(:monday) |> at(hour: 9) |> starting(~o"2026-06-01") |> times(10)
{:ok, ~o"R10/2026Y6M1D/P1W/FL1KT9HN"}

"Every week, on Monday, at nine, starting the 1st of June, ten times."

Tempo.new/1 validates its units against the calendar (a Gregorian February has 28 days in a common year, a Hebrew leap year a thirteenth month), returns {:ok, value} or {:error, exception}, and has a new!/1 that raises. Tempo.Interval.new/1 and Tempo.Duration.new/1 follow the same contract, as does every constructor, conversion, parser and set operation.

Now the playful side — what you actually get to write:

# "Sometime in the 1560s"
iex> Enum.take(~o"156X", 5)
[~o"1560Y", ~o"1561Y", ~o"1562Y", ~o"1563Y", ~o"1564Y"]

# "The 15th of some month of 1985": twelve days, not one span
iex> {:ok, fifteenths} = Tempo.to_interval(~o"1985-XX-15")
iex> Tempo.IntervalSet.count(fifteenths)
12

# "The last Friday of each month of 2026", written as ISO 8601-2 writes it
iex> {:ok, fridays} = Tempo.to_interval(~o"R12/2026-01-01/P1M/FL5K-1IN")
iex> Tempo.IntervalSet.map(fridays, &Tempo.day/1)
[30, 27, 27, 24, 29, 26, 31, 28, 25, 30, 27, 25]

A schedule read from iCalendar keeps each event's summary and location as metadata, on every interval that survives a set operation:

{:ok, schedule} = Tempo.ICal.parse_file("work.ics")
{:ok, free} = Tempo.difference(~o"2026-06-15T09/2026-06-15T17", schedule)

Tempo.IntervalSet.map(schedule, &Tempo.metadata(&1).summary)
#=> ["Design review", "1:1 with Ada", "Standup"]

"My free time is the working day minus my schedule."

And when you're looking at an unfamiliar value in iex, ask it to explain itself:

iex> Tempo.Explain.lines(~o"156X")
[
  "A value with unspecified digits, some one of the values they allow, written to the year: Jan 1560 – Dec 1569.",
  "It is one of 10 values.",
  "It is floating: a reading of a clock in no zone.",
  "Walking it gives its members."
]

iex> Tempo.Explain.lines(~o"1984?/2004~")
[
  "An interval: 1984 – 2004.",
  "It covers 1984Y? through 2004Y~.",
  "It is floating: a reading of a clock in no zone.",
  "Walking it gives its years."
]

Tempo.explain/1 joins the lines into one text for IO.puts/1, and Tempo.to_string/2 gives a locale's words for any value ("Jun 15, 2026", "15 juin 2026").

The ~o sigil also does pattern matching. In match?/2, case clauses, = and function heads, the same sigil is a pattern that matches every value whose units begin with the units written, and constrains nothing else:

iex> today = ~o"2026-04-24"
iex> match?(~o"2026", today)
true

iex> match?(~o"2026-04", today)
true

iex> match?(~o"2025", today)
false

iex> case today do
...>   ~o"2026-04" -> :this_april
...>   ~o"2026" -> :this_year
...>   _ -> :another_time
...> end
:this_april

More in the Pattern matching with sigils guide.

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:

  1. 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.

  2. 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.

  3. 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?, within?, contains?) all work on any combination of Tempo values, across resolutions, across timezones, across calendars.

Configuration

Time-zone database

Tempo asks Elixir's standard Calendar.TimeZoneDatabase behaviour for a zone's rules, and comes with :tz, which compiles the IANA data in and downloads nothing at run time. A named zone works with no configuration:

iex> Tempo.from_iso8601!("2026-06-15T09:00:00[Europe/Paris]")
~o"2026Y6M15DT9H0M0S[Europe/Paris]"

Tempo asks the database the application configures where it configures one: config :ex_tempo, :time_zone_database, ... for Tempo alone, else Elixir's own config :elixir, :time_zone_database, ... (or Calendar.put_time_zone_database/1), which DateTime reads too. Any implementation works there — :tz, :tzdata, :time_zone_info or :zoneinfo (reads the OS zone files) — and where neither names one, or Elixir's is its own UTC-only default, Tempo asks tz. Tempo.time_zone_database/0 returns the one Tempo asks, so a library built on Tempo reads a zone as Tempo does. A zone update reaches Tempo by updating the database package; Tempo keeps no zone data of its own.

A database configured under :ex_tempo that knows only UTC (Calendar.UTCOnlyTimeZoneDatabase) refuses a named zone where it is given — by the reader ([Europe/Paris]), Tempo.in_zone/2, Tempo.shift_zone/2 and Tempo.now/1 — with a Tempo.ZoneError whose reason is :no_database. UTC, a fixed offset ([+02:00]) and a time shift (+02:00) need no database. iCalendar import reads Elixir's setting rather than Tempo's: the upstream :ical library only populates an event's dtstart/dtend fields when a default Calendar.TimeZoneDatabase is installed, so set config :elixir, :time_zone_database, Tz.TimeZoneDatabase (or call Calendar.put_time_zone_database/1) before importing DTSTART;TZID=... events.

The most values at once

Tempo refuses a value that asks for more than 10,000 values at once — the members of a set, the spans a mask or a selection is converted to, the occurrences of a recurrence — so that a short text cannot ask for work without end: ~o"2026Y{1..12}M{1..28}DT{0..23}H{0..59}M{0..59}S" names 29 million seconds. The refusal is a Tempo.ConversionError whose reason is :too_many_values, and Enum and Stream still take such a value's values one at a time. The application may set the limit:

# config/config.exs
config :ex_tempo, max_values_at_once: 100_000

Livebooks

Interactive, runnable tours for Livebook:

Talks and background

The ElixirConf '22 talk introduces the core idea of a unified time type and builds toward intervals as the primary representation. The talk is still a good overview of the thesis; the library has evolved substantially since — full ISO 8601-2 support, set operations, cross-calendar comparisons, iCalendar import, and recurring events have all landed in the years that followed.

Prior art

Tempo draws on several bodies of work:

Tempo implements or interoperates with these standards:

Further reading:

Documentation

The full documentation, every module and function with the guides above, is on HexDocs.

License

Tempo is licensed under the Apache License 2.0; see LICENSE.md. Copyright © Kip Cole.