kip

kip

ex_cldr Core Team

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!

Most Liked

kip

kip

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 #4
kip

kip

ex_cldr Core Team

It’s been a busy few weeks on Tempo with some interesting headline features. Turns out that when you model time as intervals — not instants — some more difficult problems in time become more straight forward.

Uncertainty is first-class (ISO 8601-2)

Mark a value uncertain (?), approximate (~), or both (%), per component and it round-trips: These annotations are particularly useful for historians and researchers.

iex> {:ok, t} = Tempo.from_iso8601("2004-06~-11")
iex> t.qualifications`
%{month: :approximate, year: :approximate}

Business days & working calendars

Territory-aware weekends (using Localize) and business-day arithmetic, computed in the value’s own calendar:

iex> Tempo.weekend?(~o"2026-06-12", :SA) 
true    # Friday is a weekend in Saudi Arabia

iex> Tempo.weekend?(~o"2026-06-12", :US)   
false

iex> Tempo.add_working_days(~o"2026-06-12", 3, :US)
~o"2026Y6M17D"

Chronological networks — Tempo.Network

Tempo.Network is a chronological-network solver (the ChronoLog scheme from archaeology): describe periods by their start, end, and duration — each of which may be exact, bounded, ranged, or unknown — link them with sequences and relations, and it derives the tightest dates consistent with everything (or reports a contradiction).

Give it the Egyptian 26th dynasty as one anchor (Psammetichus I acceded 664 BCE) plus each reign’s length in succession, and it works out every accession date:

iex> dynasty =
...>  Network.new()
...>  |> Network.add_period(:psammetichus_i, start: ~o"-664Y", duration: ~o"P54Y")
...>  |> Network.add_period(:necho_ii, duration: ~o"P15Y")
...>  |> Network.add_period(:psammetichus_ii, duration: ~o"P6Y")
...>  |> Network.add_period(:apries, duration: ~o"P19Y")
...>  |> Network.add_period(:amasis_ii, duration: ~o"P44Y")
...>  |> Network.add_period(:psammetichus_iii, duration: ~o"P1Y")
...>  |> Network.add_sequence([`
...>    :psammetichus_i, :necho_ii, :psammetichus_ii,
...>    :apries, :amasis_ii, :psammetichus_iii
...>  ])`

iex> {:ok, solved} = Network.Solver.tighten(dynasty)
iex> solved.periods[:amasis_ii].earliest_start       
~o"-570Y"   # Amasis II acceded 570 BCE`

iex> solved.periods[:psammetichus_iii].earliest_end  
~o"-525Y"   # dynasty ends 525 BCE

The dynasty’s dates are fully known here, so the solver just derives and confirms them — but the same model comes into its own when they aren’t: swap an exact duration for {:at_least, ~o"P20Y"}, add a relation like :starts_during or :overlaps, and tighten/1 still returns each period’s narrowest possible window (with trace/3 explaining why a bound holds), while consistent?/1 flags any contradiction. Negative years are BCE throughout.

Critical Path Scheduling — Tempo.Schedule

The interval model turned out to a very good fit for a constraint solver. Tempo.Schedule is critical-path project planning: declare tasks, durations and dependencies, then solve for each task’s window and slack. It uses Tempo.Network as the supporting platform.

iex> {:ok, plan} =
...>  Tempo.Schedule.new()
...>  |> Tempo.Schedule.task(:design, duration: ~o"P2D", start: ~o"2026-06-01")
...>  |> Tempo.Schedule.task(:build, duration: ~o"P3D", after: :design)
...>  |> Tempo.Schedule.task(:docs, duration: ~o"P1D", after: :design)
...>  |> Tempo.Schedule.task(:ship, duration: ~o"P2D", after: [:build, :docs], deadline: ~o"2026-06-08")
...>  |> Tempo.Schedule.solve()`

iex> plan[:ship].start                    
~o"2026Y6M6D"

iex> plan[:ship].critical?                
true

iex> Tempo.Schedule.critical_path(plan)
[:design, :build, :ship]

Other recent enhancements

  • Sub-second (microsecond) resolution, precision-preserving — .120.12
  • Cron, extended — multi-year fields, W nearest-weekday, POSIX dom/dow OR
  • ISO 8601-2 expanded years (+12022)
  • Enumerable for %Tempo{} — DST-aware count/slice
  • Free/busy → bookable IntervalSet.slots/3, and IXDTF offset/zone validation (RFC 9557)

Updated Guides

As always, feedback and bug reports very welcome!

christhekeele

christhekeele

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

Where Next?

Popular in Announcing Top

danschultzer
In short Plug n’ play OAuth 2.0 provider library. Just set up a resource owner schema with Ecto (your user schema), install the dependen...
New
mischov
import Meeseeks.CSS html = HTTPoison.get!("https://news.ycombinator.com/").body for story &lt;- Meeseeks.all(html, css("tr.athing")) do...
New
Azolo
Hey everyone, I just released WebSockex which is a Elixir WebSocket client. WebSockex strives to work as a OTP special process, be RFC6...
New
treble37
Just looking for a little feedback on a tiny helper library I built - Sometimes I find the need to convert maps with atom keys to maps w...
New
Jskalc
Hi! Today, after a couple weeks of development I’ve released v0.1 of LiveVue. It’s a seamless integration of Vue and Phoenix LiveView, i...
New
Hal9000
Here is my first stab at this. README pasted below. https://github.com/Hal9000/elixir_random Comments and critiques are welcome. Thank...
New
michalmuskala
Hello everybody. I have just released Jason - a new JSON library. You might be wondering, why do we need a new library? The primary foc...
New
archan937
It is a well-know topic within the Elixir community: “To mock or not to mock? :)” Every alchemist probably has his / her own opinion con...
New
wfgilman
I’ve cleaned up and open sourced three financial libraries I was using for my company. They are bindings for the APIs of these three comp...
New
trisolaran
Hi! :waving_hand: I would like to present LiveSelect, a little library that I wrote to easily add a dynamic selection input to your LV f...
198 10954 107
New

Other popular topics Top

AstonJ
Posting this to see if we can make things easier for people to get into Neovim. If you use Neovim and have a favourite distro please let ...
New
marius95
Hello everyone, I try to use an Javascript Event Handler in my root.html.leex file. Therefore I created a function in the app.js file: ...
New
greenz1
I have a phoenix application from which a user can download multiple(5-6) files of size 1MB. I couldn’t find anything related to sending ...
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod – where is this set? Thanks.
New
nobody
Hi! In PHP: $_SERVER[‘SERVER_ADDR’] - in Elixir? Searched the docs for ip address and the web, no good results. Thanks!
New
jason.o
In the code below, if the create action is not set to accept “extra_key” as an input, it errors out with a message shown above. Is there ...
New
saif
Hello everyone, Long time lurker first time poster here. I’ve recently begun working on Elixir full-time again! :raised_hands: It’s been...
New
rms.mrcs
Hi, I need to transform a list of numbers into a map where the keys are the indexes and the values are the original values of the list. ...
New
nsuchy
Hi. I’ve noticed that Windows Powershell has it’s own IEX command and you cannot access Elixir’s IEX due to the conflict. This isn’t a cr...
New
JakeBecker
TL;DR: I’ve just released an implementation of Microsoft’s IDE-independent Language Server Protocol for Elixir. It adds language support ...
1144 54120 245
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement