kip
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
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:60is 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-validatecorpus 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.
kip
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,
Wnearest-weekday, POSIX dom/dow OR - ISO 8601-2 expanded years (
+12022) Enumerablefor%Tempo{}— DST-awarecount/slice- Free/busy → bookable
IntervalSet.slots/3, and IXDTF offset/zone validation (RFC 9557)
Updated Guides
As always, feedback and bug reports very welcome!
Popular in Announcing
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance










