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. 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, github: "kipcole9/tempo"},
    # 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:

  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?, 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).

# A date is an interval
iex> ~o"2026-06-15"
~o"2026Y6M15D"

# Its bounds are real — Tempo.Interval construction, not a helper
iex> {:ok, %Tempo.Interval{from: from, to: to}} = Tempo.to_interval(~o"2026-06-15")
iex> {from.time, to.time}
{[year: 2026, month: 6, day: 15, hour: 0], [year: 2026, month: 6, day: 16, hour: 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])

Tempo.new/1 validates components against the target calendar (Gregorian's February only has 28 days in non-leap years, Hebrew has a 13th month in leap years, etc.), returns {:ok, tempo} or {:error, exception}, and has a new!/1 bang variant that raises on invalid input. Tempo.Interval.new/1 and Tempo.Duration.new/1 follow the same contract.

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 every month in 1985" — not one span, a real set of days
iex> {:ok, set} = Tempo.to_interval(~o"1985-XX-15")
iex> Tempo.IntervalSet.count(set)
12

# Free time, accounting for meetings in a real schedule
iex> {:ok, schedule} = Tempo.ICal.from_ical_file("~/work.ics")
iex> {:ok, free} = Tempo.difference(~o"2026-06-15T09/2026-06-15T17", schedule)

The inspect output carries metadata inline — iCalendar events show their summary and location on every interval that survives set operations:

iex> ics = File.read!("~/work.ics")
iex> {:ok, schedule} = Tempo.ICal.from_ical(ics)
iex> schedule
#Tempo.IntervalSet<[
  #Tempo.Interval<~o"2026Y6M15DT10HZ/2026Y6M15DT11HZ" · Design review @ Room 101>,
  #Tempo.Interval<~o"2026Y6M16DT14HZ/2026Y6M16DT15HZ" · 1:1 with Ada>,
  #Tempo.Interval<~o"2026Y6M17DT09HZ/2026Y6M17DT09HZ30MZ" · Standup>
] · Work>

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

iex> Tempo.explain(~o"156X")
"""
A masked year spanning the 1560s.
Span: [1560-01-01, 1570-01-01).
Iterates at :month granularity.
Materialise as an interval with `Tempo.to_interval/1`.
"""

iex> Tempo.explain(~o"1984?/2004~")
"""
A closed interval.
From: 1984-01-01.
To:   2004-01-01 (exclusive — half-open `[from, to)`).
"""

Tempo.Explain.explain/1 returns a structured form with semantic part tags (:headline, :span, :qualification, :metadata, …); to_string/1, to_ansi/1, and to_iodata/1 format it for terminal, coloured terminal, and HTML/visualizer surfaces respectively.

Objectives

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:

A hex release is imminent. The package name is ex_tempo because the tempo name on hex was already taken; the library is imported as use Tempo / alias Tempo and feels like tempo in code. Docs at https://hexdocs.pm/ex_tempo.

Time-zone database

Any application that works with zoned datetimes needs a Calendar.TimeZoneDatabase. Tempo itself already depends on :tzdata, and most Tempo APIs accept a zone database explicitly where one is needed — so you can use Tempo without any global configuration.

iCalendar import is the exception: the upstream :ical library only populates an event's dtstart/dtend fields when a default Calendar.TimeZoneDatabase is installed at the Elixir application level. If your .ics files use the DTSTART;TZID=... form (which most calendar tools produce), configure a database in your host application:

# config/config.exs
config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase

Either :tzdata or :tz works. Tempo's own dev and test environments pull in :tz (compile-time data, no runtime downloads) and wire Tz.TimeZoneDatabase via config/dev.exs and config/test.exs. Without a configured database, iCalendar events using TZID= come through with dtstart: nil and Tempo silently drops them — expect to see an empty IntervalSet if this isn't set up.

Guides

Related links

Licence

See LICENSE.md. Copyright © Kip Cole.