Powered by AppSignal & Oban Pro

Scheduling with Tempo

livebooks/scheduling-workbook.livemd

Scheduling with Tempo

Mix.install([
  {:ex_tempo, "~> 1.0"},
  {:tz, "~> 0.28"}
])

Calendar.put_time_zone_database(Tz.TimeZoneDatabase)
import Tempo.Sigils

Free time is set algebra

Scheduling questions are set-theory questions in disguise. When is Alice free? is the workday minus her meetings. When are Alice and Bob both free? is the intersection of their free time. No cursors, no sorting by start time — just the algebra:

work = ~o"2026-06-15T09:00:00/2026-06-15T17:00:00"

alice_busy =
  Tempo.IntervalSet.new!([
    Tempo.to_interval!(~o"2026-06-15T10:00:00/2026-06-15T11:00:00"),
    Tempo.to_interval!(~o"2026-06-15T14:00:00/2026-06-15T15:00:00")
  ])

bob_busy =
  Tempo.IntervalSet.new!([
    Tempo.to_interval!(~o"2026-06-15T09:30:00/2026-06-15T10:30:00"),
    Tempo.to_interval!(~o"2026-06-15T16:00:00/2026-06-15T17:00:00")
  ])

{:ok, alice_free} = Tempo.difference(work, alice_busy)
{:ok, bob_free} = Tempo.difference(work, bob_busy)
{:ok, mutual} = Tempo.intersection(alice_free, bob_free)
mutual

“Alice’s free time is the workday minus her meetings; Bob’s is the same. Mutual free time is the intersection of theirs.”

From regions to bookable slots

Free time gives the regions where a meeting could go. A booking page offers discrete slots — cut the regions with slots/2 and keep only the windows long enough to book:

mutual
|> Tempo.IntervalSet.slots(~o"PT1H")
|> Tempo.IntervalSet.to_list()
|> Enum.filter(&Tempo.at_least?(&1, ~o"PT1H"))

When will it be done? — shifting through free time

The dual question — given a task and a busy calendar, when does it finish? — is Tempo.shift/3 with skipping:. The duration is consumed from free time only; busy spans cost nothing to cross:

meeting = ~o"2026-06-15T10:00/2026-06-15T11:00"
Tempo.shift(~o"2026-06-15T09:30", ~o"PT1H", skipping: meeting)

“One hour of work starting 09:30, skipping the 10:00 meeting, finishes at 11:30 — thirty minutes before, thirty after.”

The busy set can be unbounded. Tempo.weekends/1 is a lazy, territory-aware stream of weekend days, and the walk consumes only as much of it as the shift needs:

Tempo.shift(~o"2026-06-18T16:00", ~o"P3D", skipping: Tempo.weekends(from: ~o"2026-06-18"))

“Three days of processing starting Thursday 16:00, skipping every weekend, finishes Tuesday 16:00.”

Working days

Business-day arithmetic is the same idea with the calendar’s own vocabulary. Which days count as the weekend is territory-aware CLDR data — Saudi Arabia weekends on Friday–Saturday:

{Tempo.add_working_days(~o"2026-06-18", 3), Tempo.working_days_in(Tempo.to_interval!(~o"2026-06"), :US)}

June 19 2026 is a Friday — a workday in the United States, the weekend in Saudi Arabia:

{Tempo.weekend?(~o"2026-06-19"), Tempo.weekend?(~o"2026-06-19", :SA)}

Recurrence — RFC 5545 RRULE

Recurring events parse from standard RRULE strings into the same interval model, so occurrences are just another set to do algebra on:

{:ok, standup} = Tempo.RRule.parse("FREQ=WEEKLY;BYDAY=TU;COUNT=4", from: ~o"2026-06-01")
{:ok, tuesdays} = Tempo.to_interval(standup)
tuesdays

An unbounded recurrence (FREQ=WEEKLY with no COUNT/UNTIL) refuses to materialise without a bound — supply one and it expands exactly as far as you asked:

{:ok, weekly} = Tempo.RRule.parse("FREQ=WEEKLY;BYDAY=FR", from: ~o"2026-06-01")
{:ok, fridays} = Tempo.to_interval(weekly, bound: ~o"2026-07")
Tempo.IntervalSet.count(fridays)

Putting it together

Occurrences are just another set, so every predicate applies. Does June 9 fall on a standup day? Which standups land in the first half of the month?

{Tempo.overlaps?(~o"2026-06-09", tuesdays), Tempo.IntervalSet.covered?(tuesdays, ~o"2026-06-10")}
{:ok, early_june} = Tempo.members_overlapping(tuesdays, ~o"2026-06-01/2026-06-15")
early_june

Where next