kip

kip

ex_cldr Core Team

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!

Showing Posts 1 to 10

aseigo

aseigo

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!

nathanl

nathanl

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

kip

kip OP

ex_cldr Core Team

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
Post #3
jswanner

jswanner

Amazing work, @kip!

christhekeele

christhekeele

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: tempo/lib/tempo.ex at v0.2.0 · kipcole9/tempo · GitHub

christhekeele

christhekeele

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.

christhekeele

christhekeele

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.

kip

kip OP

ex_cldr Core Team

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.

kip

kip OP

ex_cldr Core Team

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…

christhekeele

christhekeele

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!

Where Next? Top

Trending in Announcing Top

wojtekmach
Hey everyone! Req is an HTTP client for Elixir that I’ve been working on for quite some time. There is already a lot of HTTP clients out...
New
handnot2
Samly can be used to enable SAML 2.0 Single Sign On in a Plug/Phoenix application. This library uses Erlang esaml to provide plug enabl...
New
woylie
Flop is an Elixir library that applies filtering, ordering and pagination parameters to your Ecto queries. offset-based pagination with...
New
MRdotB
I needed to reuse React components from my Chrome extension in my Phoenix/LiveView backend. I noticed that for Svelte/Vue, there are live...
New
garrison
Hobbes is a low-level distributed database for the Elixir programming language. Hobbes provides a simple, safe, and scalable storage lay...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
fuelen
Hi all! I want to present a small library which provides a mix task for generating an Entity-Relationship Diagram for Ecto schemas. You...
New

Other Trending Topics Top

mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
AstonJ
This showed up on my feed.. anyone heard of it? Just hype? Ox Alpha is a reasoning model designed for coding, sustained ag...
New
sergio
It’s not that it’s vocabulary is too advanced. It’s something worse. I get lost trying to follow even a paragraph written by Claude. It’...
New
sorenone
Today we’re releasing Oban for Python. Not an Oban client in Python. Not a pythonx wrapper embedded in Elixir. Nope, it’s a fully operati...
New
akoutmos
@hugobarauna, Dr. Dimitrios Koutmos (my brother) and I (Alex Koutmos) have been hard at work on writing a book on how you can use Elixir ...
New
pferriby
Introductory paragraph I’ll be looking for a keen junior or someone that has a couple of years experience in the real world (so you’ve be...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews