Tempo - a unified time type that models time as interval sets, not instants

In 2021 I started a new library called Tempo with the objective of modelling time as a set of intervals - not as instants. In 2022 I gave a talk on it at ElixirConf - its on YouTube. If you haven’t seen it you might find it a bit of fun.

After that, I made a lot of promises and didn’t keep any of them. Localize (ex_cldr), ExMoney, Image, Color, Unicode, Astro - there was always some other project to help me avoid the hard R&D work necessary to move Tempo along. Turns out modelling the complexities of time as interval sets and then implementing the set operators wasn’t so easy (for me).

Slow forward 5 years and here we are.

Tempo is coming (it will be ex_tempo on hex - I was too slow!). I’ll launch it on hex,pm this Thursday. 100% commitment on that.

Modelling time as an interval unlocks some very cool patterns and removes the cause of whole classes of bugs.

Along the way the only fully ISO8601 part 1 and part 2 compliant parser came into being (really, I purchased the ISO standards and went looking for prior art. Couldn’t find any in any language).

Importing iCalendar events as interval sets was straight forward thanks to the iCal library.

And lots of other fun features you’ll see on Thursday!

36 Likes

So glad you are finding ICal useful!

And Ive been watching Tempo, too! :wink: Very excited to see it hit hex …

Oh, it’s not you. Time is a demon. One of the “final boss” topics of devel … ha!

2 Likes

@kip Woo, very exciting! That talk was amazing when I saw it back in 2022, and I’m really glad the ideas are coming to fruition.

2 Likes

Tempo v0.2 — time as an interval, now with set operations and now on hex.

When we left off Tempo at ElixirConf '22 we demonstrated that a unified time type — a type that treats every temporal value as a bounded interval on a shared time line — removes a whole class of foot-guns that standard date libraries share. “End of day” ambiguity. Off-by-one at midnight. Last day of month. Cross-calendar comparisons. The awkward shuffle between Date, Time, NaiveDateTime, DateTime.

The basics still hold:

# A day IS an interval.
iex> ~o"2026-06-15"
~o"2026Y6M15D"

# Enumerating a month yields its days. No `Enum.map(1..days_in_month(…))`.
iex> Enum.take(~o"2026-06", 3)
[~o"2026Y6M1D", ~o"2026Y6M2D", ~o"2026Y6M3D"]

# The last day of June. No calendar arithmetic, no days_in_month, no
# special-casing February. Just a negative index, per ISO 8601-2 §4.4.1.
iex> {:ok, last} = Tempo.select(~o"2026-06", ~o"-1D")
iex> last
#Tempo.IntervalSet<[~o"2026Y6M30D/2026Y7M1D"]>

# Same idea across a leap-year boundary.
iex> {:ok, set} = Tempo.select(~o"2024-02", ~o"-1D")
iex> Tempo.IntervalSet.count(set)
1   # and the interval is Feb 29 — 2024 is a leap year

# How many hours were there in Sydney on the day DST started?
iex> Enum.count(~o"2026-10-04[Australia/Sydney]")
23   # not 24. The clock jumps 02:00 → 03:00; that hour never ticks.

# And on the day DST ended?
iex> Enum.count(~o"2026-04-05[Australia/Sydney]")
25   # 02:00 happens twice. Both are emitted, with distinct UTC offsets.

Tempo v0.2 — the other half of the thesis

Once every value is a bounded interval, set operations on time follow naturally — union, intersection, difference, symmetric difference, complement, and the corresponding predicates (overlaps?, within?, disjoint?, adjacent?). v0.2 ships all of it, across zones, across calendars, across resolutions, with metadata that survives the operation.

Which turns a surprising number of hard questions into short programs.

How many workdays in June 2026, in Australia, net of public holidays?

ics = Req.get!("https://www.officeholidays.com/ics/australia").body
{:ok, holidays} = Tempo.ICal.from_ical(ics)

June = ~o"2026-06"
{:ok, workdays} = Tempo.select(june, Tempo.workdays(:AU))
{:ok, net} = Tempo.difference(workdays, holidays)

Tempo.IntervalSet.count(net)
#=> 20    # June 2026 has 22 workdays; 2 are holidays in AU.

Swap :AU for :SA and you get the Saudi working week instead — Sunday to Thursday, with Saudi holidays. CLDR data is baked in; the set algebra is the same.

When are Bruce and Shiela both booked next month?

Meet Bruce and Shiela. Their two .ics files are in the repo (demo/calendars/). Loading them:

{:ok, bruce}  = Tempo.ICal.from_ical(File.read!("demo/calendars/bruce.ics"))
{:ok, shiela} = Tempo.ICal.from_ical(File.read!("demo/calendars/shiela.ics"))

Every RRULE in those feeds — the standups, the yoga classes, the partners’ meetings — is fully expanded into concrete day-resolution intervals with the event’s summary, location, and attendee metadata attached. Bruce has 83 events; Shiela has 67.

# How much of April do they have at the same time?
iex> {:ok, clashes} = Tempo.intersection(bruce, shiela)
iex> Tempo.IntervalSet.count(clashes)
35    # 4 are shared social events; 31 are accidental work overlaps.

# Find Shiela an hour when Bruce is busy
iex> {:ok, window}  = Tempo.intersection(~o"2026-04-23T14/2026-04-23T15", bruce)
iex> Tempo.IntervalSet.count(window)
1     # Bruce is in a product-strategy workshop until 16:00. Try later.

No custom scheduling engine. No hand-rolled sweep-line. Just intervals on a time line and set algebra — the same operators you’d reach for if you were intersecting two sets of integers, applied to two sets of [from, to) moments.

Q3 Mondays, excluding public holidays

q3 = ~o"2026-07/2026-10"
# day-of-week 1 = Monday
{:ok, mondays} = Tempo.select(q3, ~o"1K")         
{:ok, net}     = Tempo.difference(mondays, holidays)
Tempo.IntervalSet.count(net)
#=> 12

~o"1K" is ISO 8601-2 selection syntax — day-of-week selector. If you’re wondering what else you can shove in there, the built-in visualizer has a syntax-reference sidebar that explains every form; paste anything into the input and watch it decompose into coloured segments with plain-English labels.

When was I both in Japan and enrolled at university?

Two intervals, two calendars, one question:

japan       = ~o"2018-05-01/2019-03-31[Asia/Tokyo]"
enrolled    = ~o"2015-09/2020-06"

{:ok, both} = Tempo.intersection(japan, enrolled)

Does this dig layer overlap with that dynasty?

han_dynasty   = ~o"-0202/0220"              # 202 BCE to 220 CE
dig_layer_iii = ~o"-0150/0050~"             # approximate end (EDTF ~)

Tempo.overlaps?(han_dynasty, dig_layer_iii)
#=> true

The approximate-end qualification qualifier ships through.

In this release

Set operators

  • Tempo.union/2,
  • intersection/2,
  • difference/2,
  • complement/2,
  • symmetric_difference/2.

Predicates

  • overlaps?,
  • within?,
  • disjoint?,
  • adjacent?,
  • plus the full Allen algebra via Tempo.Interval.compare/2.

Tempo.select/2

The composition primitive. Narrow any base span by an integer-list, range, Tempo projection, day-of-week pattern, or function. Negative indices count from the end. Returns an IntervalSet that plugs straight into the set ops.

  • Tempo.workdays/1,
  • Tempo.weekend/1.

These are Territory-aware, CLDR-backed. workdays(t) ++ weekend(t) partitions the seven days of the week.

iCalendar import

  • Full RFC 5545 RRULE support — every BY* rule, BYSETPOS, WKST, RDATE, EXDATE. thanks to the wonderful iCal.
  • Event metadata (summary, location, attendees, status) travels through every downstream set operation.

DST-aware enumeration

Gap hours are skipped; fold hours are emitted twice with distinct offsets. The wall clock is authoritative; UTC projects on demand. No silent drift when Tzdata ships a rule change for a future zone.

Leap-second metadata

  • spans_leap_second?/1, leap_seconds_spanned/1, and opt-in leap-aware duration on intervals.
  • The 27 IERS-announced historical leap seconds are validated at parse time (23:59:60 is accepted only on those dates).

IXDTF (RFC 9557)

  • Zone suffixes, calendar suffixes ([u-ca=hebrew]), and arbitrary tagged suffixes parsed and round-tripped.

ISO 8601 Parts 1 & 2 + EDTF Levels 0–2

  • 100% of the unt-libraries/edtf-validate corpus passes.

  • Archaeological masks, uncertain/approximate qualifications, long years with exponents (Y17E8), significant-digits notation — all parseable and queryable.

Web visualizer

Tempo.Visualizer.Standalone.start(port: 4001) and paste any ISO 8601 / EDTF / IXDTF string. Every character is coloured by role (numbers, literals, qualifiers, syntax, separators), each component gets its own labelled box with a plain-English description, and a permanent sidebar explains the syntax with copy-pasteable examples.

The visualizer is most definitely still a work in progress.

Thanks

Tempo stands on the shoulders of Calendrical, Localize, Tzdata, Astro, and ical — each of which does the hard calendrical / locale / zone / recurrence work that Tempo’s set algebra builds on.

22 Likes

Amazing work, @kip!

This looks amazing!

I noticed in the documentation you cite tsrange/tsmultirange as part of your inspiration, my first question on hearing about Tempo was if support for corresponding postgres Ecto.Types would be viable for Tempo/`Tempo.Interval/Tempo.IntervalSet structs.

EDIT: if ecto types are implemented/provided/available externally, this part of the guides would be a good place to link to them.


A couple of notes on easily-remediable just-released issues:

Installation issue

This does not work as advertised:

Mix.install([
  {:ex_tempo, github: "kipcole9/tempo"},
  # Optional but recommended - needed for iCalendar import
  {:ical, "~> 2.0"}
])

It fails with

error: module Plug.Router is not loaded and could not be found
    │
 50 │     use Plug.Router
    │     ^^^^^^^^^^^^^^^
    │
    └─ lib/tempo/visualizer.ex:50: Tempo.Visualizer (module)

I would have the defmodule Tempo.Visualizer be wrapped in the usual if Code.ensure_loaded?(Plug) check and mention Plug being optional too in the documentation.

This works as expected:

Mix.install([
  {:ex_tempo, github: "kipcole9/tempo"},
  # Optional dependencies
  {:ical, "~> 2.0"},
  {:plug, ">= 0.0.0"}
])
Sigil issue

I found I have to add this line for most examples to work, but needing it is not documented anywhere (that I could find):

import Tempo.Sigil, only: [sigil_o: 2, sigil_TEMPO: 2]

I notice you use import Tempo.Sigil to make this work in ex. doctests. I would recommend making a use Tempo.Sigil __using__ macro to auto-import just the sigils (not the bare import, as you’ve put extra functions in that module). I would also mention using it at the start of the README and each individual guide, and even consider adding it as the first line of all doctest examples (and removing it from the doctest setup) as well for rigor. Make it impossible for someone to copy-paste some example code and experience an error!

(Actually while the public API is still fresh and malleable, I’d recommend naming the module in a pluralized form called Tempo.Sigils with just the sigils and put the helper non-sigil function somewhere else so that module could be pleasantly imported as import Tempo.Sigils. And then still create a __using__ macro for Tempo itself to do the sigil import, so you can install that callback hook in more apps early on if you ever decide to do other similar things that would benefit from it. But that’s a more opinionated take.)

Source code linking issue

You have not pushed the v0.2.0 git tag to github, so source code links from the exdocs do not work, ex: https://github.com/kipcole9/tempo/blob/v0.2.0/lib/tempo.ex#L3427

3 Likes

Another thought while the API is malleable: I might consider renaming the Tempo/Tempo.Interval compare/2 function.

Providing a compare/2 function that returns :lt | :eq | :gt is the standard signature for custom sorting of structural types in the ecosystem and plays nicely with Enum.sort/2 (module variation) . Providing a similar function for your types with different return values is surprising: you probably want the function to work with sort/2 or be named something different. Additionally, any future work (conventions, libraries, stdlib) to make developer-defined types work with the comparison operators will almost certainly hinge upon structs with modules with functions with this signature.

So I’d avoid “squatting” on the conventional name with a non-idiomatic return type. Perhaps Tempo.relation/2 instead? This would be more harmonious with Tempo.IntervalSet.relation_matrix/2 anyhow. And, if you find that you can condense the Allen relations to some reasonable approximation of a one-size-fits-all :lt | :eq | :gt (which I am uncertain of), wrap Tempo.relation/2 into a compare/2 function for that purpose.

Library users can always invoke Enum.sort/2 (functional variation) with an anonymous function that resolves the Tempo.relation/2 into :lt | :eq | :gt as appropriate for their domain.

3 Likes

Worth pointing out you depict the internal representation of your structs in the Holidays guide > Expressing the Q3 window.

I definitely recommend making sure at no point in your documentation do you recommend or demonstrate constructing or pattern matching via your structs fields directly; always prefer a sigil/functional construction interface in case your struct internal representation changes (they’re very public private fields, library struct fields).

This famously caused a lot more pain than needed when Range’s internal representation changed. If there isn’t a clean, example-friendly functional API to build your structs the way your examples require, that’s a sign that a new construction helper function would be valuable. But def try to avoid leaking internals so people following your examples in an earlier version don’t have their code break on upgrade!

In the same vein, I’d make sure that your code comments hide this structure, like using ~o"2026Y7M8D/2026Y7M9D" as the display of target at this point in the guide.

1 Like

I definitely recommend making sure at no point in your documentation do you recommend or demonstrate constructing or pattern matching via your structs fields directly

Couldn’t agree more. I thought I had eradicated all of those so definitely a lack of precision on my part. During development the API surface evolved in part to avoid this precise point.

Thanks too for all the great feedback, truly appreciated. I will get a new version out in the next few hours addressing all your very valuable points.

2 Likes

You have not pushed the v0.2.0 git tag to github

Done.

Another thought while the API is malleable: I might consider renaming the Tempo /Tempo.Interval compare/2 function.

On it, good callout.

I’d recommend naming the module in a pluralized form called Tempo.Sigils with just the sigils and put the helper non-sigil functionsomewhere else so that module could be pleasantly imported as import Tempo.Sigils .

Agreed, will do.

I noticed in the documentation you cite tsrange /tsmultirange as part of your inspiration, my first question on hearing about Tempo was if support for corresponding postgres Ecto.Type s would be viable for Tempo /``Tempo.Interval /Tempo.IntervalSet` structs.

I definitely have that objective but I haven’t got a clear strategy in mind yet since not all intervals map directly given the variable resolution of a tempo interval. Suggestions most welcome.

I would have the defmodule Tempo.Visualizer be wrapped in the usual if Code.ensure_loaded?(Plug) check and mention Plug being optional too in the documentation.

I’ll make sure it’s fully optional. It should be, so definitely a bug. Is supposed to require Bandit and Plug as a compilation guard on the module.

And lots of doc improvements you kindly pointed on. Sleep and coffee and then back on to it…

1 Like

The best solution that springs to mind is having an Ecto parameterized type with a resolution config.

On cast from Tempo → type, you’d want some method of either raising if given more precision, lossily discarding extra precision, some sort of rounding strategy, or configuration to select between the three. Similarly on load from database → type.

I’ll probably play with this some in a side project that’s been wanting something like Tempo when I find the time, if you don’t beat me to it!

I’ve been working on an implementation this morning my time (UTC+10) and have been following the same approach. I should have something you can hack on in a few hours. I’m also going to define a custom composite type option that serialises resolution and metadata along with the tstzrange/tstzmultirange.

2 Likes

Narrator: @kip did, indeed, “beat him to it”.

6 Likes

Ha, well, it was on my list but I was thinking that no one would ask for that for a while. And then you asked…

I’ve published tempo version 0.3.0 that addresses all the issues you raised (I think).

And there’s an experimental Ecto integration library you can try out at GitHub - kipcole9/tempo_sql: Ecto types for Tempo and Postgres · GitHub. It has ecto types for native tstzrange and tstzmultirange types with a :resolution parameter which will truncate the loaded data to the desired resolution.

It also has custom composite types which build on the native types but also preserve resolution and metadata. I think those would be my preference noting they will take more space.

There are tests, but it needs more exercising before its ready for hex release.

1 Like
<dadjoke>
  It's about time!
</dadjoke>
2 Likes

I have a fancy idea of utilizing the fact that macros know whether they were called in :match context, and provide a dedicated implementation for that particular case.

That said, one can actually do ~TEMPO[2026Y] to match any interval fitting within 2026, and so forth. Even more, that version might accept modifiers, so that ~TEMPO[2026Y]MD would match anything happened in 2026 and expose month and day local variables to the scope.

@kip if you are interested in this feature, let me know, I’ll provide a PR:

1 Like

I’m not really sure I understand the intent or problem space yet, but any time @mudasobwa has an idea I’d be crazy to not accept a contribution and see where it goes! Anything is possible in releases < 1.0.

2 Likes

Consider it done.

And thanks, I appreciate these words from you specifically.

1 Like

Here you go.

2 Likes

Great work @mudasobwa, thanks very much. A great addition. Just have to work out why we have a locale setup mismatch (which is what is causing the formatting errors). I’ve made a comment in the PR.

Thanks again, will be part of the next published release.

1 Like