# A tour of Tempo

```elixir
Mix.install([
  {:ex_tempo, "~> 2.0"},
  {:kino, "~> 0.14"},
  {:tz, "~> 0.28"}
])

Calendar.put_time_zone_database(Tz.TimeZoneDatabase)
```

## Setup

Run this cell next: every example below depends on it. It imports `Tempo.Sigils`, which provides the `~o` sigil the examples use to write a Tempo value as ISO 8601 text — `~o"2026-06-15"` is the whole day of 15 June 2026. The import brings in only `sigil_o/2`, no other functions.

It needs a cell of its own because Livebook compiles a whole cell before running it, so the setup cell above can't import a module that its own `Mix.install/2` has yet to fetch.

```elixir
import Tempo.Sigils
```

## The thesis — time is an interval, not an instant

Most languages split time into three scalar types — `Date`, `Time`, `DateTime` — and leave you to figure out which interval a scalar "really means." Is `~D[2026-06-15]` the instant of midnight, or the whole day? Your codebase probably has helpers like `end_of_day/1` because the type can't say.

Tempo takes the opposite stance. Every Tempo value *is* a bounded span on the time line. One type. Every operation — iteration, comparison, set algebra — is defined uniformly across every resolution and every calendar.

Start with the simplest example. A plain day:

```elixir
~o"2026-06-15"
```

Now see its bounds:

```elixir
{:ok, interval} = Tempo.to_interval(~o"2026-06-15")
Tempo.Interval.endpoints(interval)
```

The span is `[2026-06-15 00:00, 2026-06-16 00:00)` — half-open, so adjacent days join with no gap and no overlap. Written as an interval, a day runs from itself *through* itself: an end that is a date is read through, and an end that is a time of day is the reading the span runs to.

The same rule applies at every resolution:

```elixir
for value <- [~o"2026", ~o"2026-06", ~o"2026-06-15", ~o"2026-06-15T10"] do
  {:ok, iv} = Tempo.to_interval(value)
  {from, to} = Tempo.Interval.endpoints(iv)
  %{input: inspect(value), from: inspect(from), to: inspect(to)}
end
|> Kino.DataTable.new()
```

A year is a year long, a month a month, an hour an hour: the unit a value is written to is its span.

## Explaining a value

If you hand a Tempo value to a colleague who's never seen it, what does it *mean*? Tempo has a built-in explainer:

```elixir
Tempo.explain(~o"156X")
```

```elixir
Tempo.explain(~o"1984?/2004~")
```

`Tempo.Explain.lines/1` gives the same explanation as a list of its sentences, for a surface that lays them out itself:

```elixir
Tempo.Explain.lines(~o"156X")
```

## Iteration

Every Tempo value is enumerable. A walk gives the unit below the one the value is written to — a year its months, a month its days.

```elixir
Enum.take(~o"2026Y", 12)
```

```elixir
Enum.take(~o"2026-06", 5)
```

A value with unspecified digits names several values, and a walk gives each. The 1560s:

```elixir
Enum.take(~o"156X", 10)
```

## Comparison and predicates

Tempo implements Allen's interval algebra: `Tempo.relation/2` names the precise relation (all thirteen, `:precedes` through `:preceded_by`), `Tempo.Allen` has a predicate for each, and the everyday questions have named predicates — `overlaps?`, `contains?`, `before?`, `after?`, `adjacent?`, `within?`, `equal?`, `disjoint?`.

```elixir
a = ~o"2026-06-01/2026-06-15"
b = ~o"2026-06-10/2026-06-20"

%{
  "a contains 2026-06-10": Tempo.contains?(a, ~o"2026-06-10"),
  "a overlaps b": Tempo.overlaps?(a, b),
  "a before 2026-07": Tempo.before?(a, ~o"2026-07")
}
```

## Set operations

Once every value is a bounded interval, the set-theoretic operations follow naturally.

### Union — both windows kept distinct (member-preserving)

```elixir
{:ok, both} = Tempo.union(a, b)
Tempo.IntervalSet.members(both)
```

> The **union** of `a` and `b` keeps both intervals as members, each with its metadata.

For the one continuous window they cover between them, coalesce:

```elixir
{:ok, merged} = Tempo.IntervalSet.coalesce(both)
Tempo.IntervalSet.members(merged)
```

> One continuous window — June 1 through June 20 — after coalescing.

### Intersection — the overlap between two windows

```elixir
{:ok, overlap} = Tempo.intersection(a, b)
Tempo.IntervalSet.members(overlap)
```

> The **intersection** of `a` and `b` is just the shared stretch — June 10 through June 15.

### Difference — free time around a lunch break

```elixir
work_day = ~o"2026-06-15T09/2026-06-15T17"
lunch    = ~o"2026-06-15T12/2026-06-15T13"

{:ok, free} = Tempo.difference(work_day, lunch)
Tempo.IntervalSet.members(free)
```

> The **workday minus lunch** is my **free** time — two intervals, 09:00-12:00 and 13:00-17:00.

Two free intervals: 09:00-12:00 and 13:00-17:00.

## Selecting — `Tempo.select/3`

`Tempo.select/3` picks, from a base, the values a **selector** names, and answers a set of them. A selector is the workdays or the weekend of a territory (`Tempo.workdays/2`, `Tempo.weekends/1`), whole numbers for days of the month, or a value with no year: a date, a weekday, a time of day.

A selector that depends on a territory is made by `Tempo.workdays/2` or `Tempo.weekends/1` and given to `select/3`, which itself asks nothing of the locale.

### Workdays of a month

```elixir
{:ok, workdays} = Tempo.select(~o"2026-06", Tempo.workdays(:US))
Tempo.IntervalSet.members(workdays) |> length()
```

> The **workdays** of **June 2026** in the **United States** are Monday to Friday: 22 days.

### Pick specific days inside a month

```elixir
{:ok, paydays} = Tempo.select(~o"2026-06", [1, 15])
Tempo.IntervalSet.map(paydays, &Tempo.day/1)
```

> Whole numbers select **days of the month**, in each month of the base.

### Project a date pattern onto a larger base

```elixir
{:ok, holidays} = Tempo.select(~o"2026", [~o"7M4D", ~o"12M25D"])
Tempo.IntervalSet.map(holidays, &{Tempo.month(&1), Tempo.day(&1)})
```

> A value with no year, such as `7M4D`, selects that date in each year of the base: the 4th of July and the 25th of December 2026.

### Different territory, different weekend

```elixir
{:ok, us}    = Tempo.select(~o"2026-02", Tempo.weekends(:US))
{:ok, saudi} = Tempo.select(~o"2026-02", Tempo.weekends(:SA))

{
  Tempo.IntervalSet.map(us, &Tempo.day/1),
  Tempo.IntervalSet.map(saudi, &Tempo.day/1)
}
```

> The US weekend is Sat+Sun, the Saudi weekend is Fri+Sat. `Tempo.weekends/1` and `Tempo.workdays/2` take a territory atom, a territory string, a locale string (`"ar-SA"`), or a `%Localize.LanguageTag{}`. With no argument they use `Application.get_env(:ex_tempo, :default_territory)` → `Localize.get_locale()`.

### Compose with set operations

```elixir
{:ok, june_workdays} = Tempo.select(~o"2026-06", Tempo.workdays(:US))
vacation = ~o"2026-06-15/2026-06-20"
{:ok, available} = Tempo.members_outside(june_workdays, vacation)

Tempo.IntervalSet.members(available)
```

> The **workdays** of June **outside** my **vacation** are the days I am **available**. A selection is a set, so it goes straight into the set operations.

## Recurring events

Tempo reads RFC 5545's RRULE into the same recurrence ISO 8601-2 writes, so `FREQ=DAILY;COUNT=10` and "the fourth Thursday of November, every year" go through one engine.

Each example reads a rule with `Tempo.RRule.parse/2`, starting at its `DTSTART`, and lists its occurrences with `Tempo.to_interval/2`.

### Every Monday for 10 weeks

```elixir
start_date = fn interval ->
  start = Tempo.Interval.from(interval)
  Date.new!(Tempo.year(start), Tempo.month(start), Tempo.day(start))
end

{:ok, mondays} = Tempo.RRule.parse("FREQ=WEEKLY;BYDAY=MO;COUNT=10", from: ~o"2026-06-01")
{:ok, occurrences} = Tempo.to_interval(mondays)

occurrences
|> Tempo.IntervalSet.members()
|> Enum.map(&%{date: start_date.(&1)})
|> Kino.DataTable.new()
```

### Thanksgiving (4th Thursday of November)

```elixir
{:ok, rule} = Tempo.RRule.parse("FREQ=YEARLY;BYMONTH=11;BYDAY=4TH;COUNT=5", from: ~o"2022-11-24")
{:ok, thanksgivings} = Tempo.to_interval(rule)

thanksgivings |> Tempo.IntervalSet.members() |> Enum.map(start_date)
```

### US Presidential Election Day

"Every four years, the first Tuesday after a Monday in November":

```elixir
{:ok, rule} =
  Tempo.RRule.parse("FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8;COUNT=5",
    from: ~o"1996-11-05"
  )

{:ok, elections} = Tempo.to_interval(rule)

elections |> Tempo.IntervalSet.members() |> Enum.map(start_date)
```

### Every Friday the 13th

```elixir
{:ok, rule} = Tempo.RRule.parse("FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13;COUNT=10", from: ~o"1998-02-13")
{:ok, f13s} = Tempo.to_interval(rule)

f13s |> Tempo.IntervalSet.members() |> Enum.map(start_date)
```

### Last weekday of every month

```elixir
{:ok, rule} =
  Tempo.RRule.parse("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1;COUNT=6", from: ~o"2026-06-01")

{:ok, last_weekdays} = Tempo.to_interval(rule)

last_weekdays |> Tempo.IntervalSet.members() |> Enum.map(start_date)
```

BYSETPOS applies last, picking the Nth element of each month's candidate set.

## Cross-calendar comparison

An expression is in a calendar. The RFC 9557 suffix `[u-ca=NAME]` says which — Hebrew, Islamic, Persian, Buddhist and the rest of CLDR's:

```elixir
{:ok, hebrew} = Tempo.from_iso8601("5786-09-30[u-ca=hebrew]")
Tempo.overlaps?(hebrew, ~o"2026-06-15")
```

Two expressions in different calendars are compared on the one time line they share.

## Cross-timezone comparison

```elixir
paris = Tempo.from_datetime(DateTime.new!(~D[2026-06-15], ~T[10:00:00], "Europe/Paris"))
utc_window = ~o"2026-06-15T07Z/2026-06-15T09Z"

Tempo.overlaps?(paris, utc_window)
```

Both values are *zoned* — the Paris time by its zone, the window by `Z`. Comparing a zoned value with a floating one (no zone or offset) raises a `Tempo.FrameError` rather than assuming UTC; place the floating side in a zone first with `Tempo.in_zone/2`.

Paris 10:00 CEST is UTC 08:00 — inside the UTC 07:00-09:00 window. Tempo asks the configured time zone database at each call; the value keeps the reading it was written with.

## Archaeological and approximate dates

ISO 8601-2 has marks for the vague dates of historical records.

### "Sometime in the 1560s"

```elixir
Enum.take(~o"156X", 10)
```

### "Approximately 2022"

```elixir
value = ~o"2022~"
{:ok, iv} = Tempo.to_interval(value)

%{
  value: inspect(value),
  qualification: Tempo.qualification(value, :year),
  span: iv
}
```

The mark is information about the value and changes nothing of its span — this is still the year 2022, with a note that it is approximate.

### Open-ended intervals

```elixir
[~o"1985/..", ~o"../2024", ~o"../.."]
|> Enum.map(fn iv ->
  {from, to} = Tempo.Interval.endpoints(iv)
  %{input: inspect(iv), from: inspect(from), to: inspect(to)}
end)
|> Kino.DataTable.new()
```

An open end is `:open`. An interval may be open at either end, or at both (`../..`, the whole time line).

## Non-contiguous mask expansion

The 15th of every month in 1985:

```elixir
{:ok, set} = Tempo.to_interval(~o"1985-XX-15")

set
|> Tempo.IntervalSet.map(fn interval ->
  {from, to} = Tempo.Interval.endpoints(interval)
  %{from: inspect(from), to: inspect(to)}
end)
|> Kino.DataTable.new()
```

Twelve days: the unspecified month is each month of the year, and the 15th is that day of each.

## Interval relations — Allen's algebra

`Tempo.relation/2` returns one of 13 Allen-algebra relations between two intervals:

```elixir
a = ~o"2026-06-01/2026-06-10"
b = ~o"2026-06-05/2026-06-15"

%{
  "a overlaps b":      Tempo.relation(a, b),
  "a meets next day":  Tempo.relation(~o"2026-06-15", ~o"2026-06-16"),
  "year contains day": Tempo.relation(~o"2026Y", ~o"2026-06-15")
}
```

Named predicates wrap the common checks — `within?`, `adjacent?`, `before?`, `after?` — and `Tempo.Allen` tests for one exact relation, such as `Tempo.Allen.meets?/2`:

```elixir
window = ~o"2026-06-15T09/2026-06-15T17"
candidate = ~o"2026-06-15T10/2026-06-15T11"

Tempo.within?(candidate, window)
```

## Duration predicates

Five predicates cover the interval-length comparison lattice:

```elixir
iv = ~o"2026-06-15T09/2026-06-15T10"

%{
  duration:       Tempo.duration(iv),
  "≥ 1h":         Tempo.at_least?(iv, ~o"PT1H"),
  "≤ 1h":         Tempo.at_most?(iv, ~o"PT1H"),
  "exactly 1h":   Tempo.exactly?(iv, ~o"PT1H"),
  "> 30m":        Tempo.longer_than?(iv, ~o"PT30M"),
  "< 2h":         Tempo.shorter_than?(iv, ~o"PT2H")
}
```

## Putting it together — find mutual free time ≥ 1 hour

A scenario that used to be awkward: two colleagues' calendars plus my working hours. Which windows can host a 1-hour meeting?

```elixir
alice =
  Tempo.IntervalSet.new!([
    Tempo.Interval.new!(from: ~o"2026-06-15T10", to: ~o"2026-06-15T11", metadata: %{who: "Alice"}),
    Tempo.Interval.new!(from: ~o"2026-06-15T14", to: ~o"2026-06-15T15", metadata: %{who: "Alice"})
  ])

bob =
  Tempo.IntervalSet.new!([
    Tempo.Interval.new!(from: ~o"2026-06-15T11", to: ~o"2026-06-15T12", metadata: %{who: "Bob"}),
    Tempo.Interval.new!(from: ~o"2026-06-15T15:30", to: ~o"2026-06-15T16", metadata: %{who: "Bob"})
  ])

work_hours = ~o"2026-06-15T09/2026-06-15T17"

{:ok, alice_free} = Tempo.difference(work_hours, alice)
{:ok, bob_free}   = Tempo.difference(work_hours, bob)
{:ok, mutual}     = Tempo.intersection(alice_free, bob_free)

bookable = Tempo.IntervalSet.filter(mutual, &Tempo.at_least?(&1, ~o"PT1H"))

bookable
|> Tempo.IntervalSet.map(fn interval ->
  {from, to} = Tempo.Interval.endpoints(interval)
  %{from: inspect(from), to: inspect(to)}
end)
|> Kino.DataTable.new()
```

> Alice's free time is the **workday minus** her **busy** periods. Bob's is the same. **Mutual** free time is the **intersection** of theirs. **Bookable** windows are the mutual ones **at least an hour** long.

Three windows pass: 09:00-10:00, 12:00-14:00, and 16:00-17:00. The 15:00-15:30 gap (Alice free but Bob booked at 15:30) is correctly dropped.

### Alternative — "does this exact candidate fit somewhere?"

When you have preferred times in mind rather than discovering free windows, `Tempo.within?/2` reads the question naturally:

```elixir
candidates = [
  ~o"2026-06-15T09/2026-06-15T10",
  ~o"2026-06-15T11/2026-06-15T12",
  ~o"2026-06-15T16/2026-06-15T17"
]

bookable =
  Enum.filter(candidates, fn candidate ->
    Enum.any?(Tempo.IntervalSet.members(mutual), &Tempo.within?(candidate, &1))
  end)

bookable |> Enum.map(&inspect/1)
```

> A **candidate** is **bookable** if **any** mutual-free window **contains** it.

Two of the three candidates fit inside a mutual-free window. The 11:00-12:00 one is rejected because Bob is busy then.

## What to read next

* [Tempo cookbook](https://hexdocs.pm/ex_tempo/cookbook.html) — task-oriented recipes
* [Set operations guide](https://hexdocs.pm/ex_tempo/set-operations.html)
* [iCalendar integration](https://hexdocs.pm/ex_tempo/ical-integration.html) — import `.ics` with full RRULE/RDATE/EXDATE
* [ISO 8601 conformance](https://hexdocs.pm/ex_tempo/iso8601-conformance.html) — what parts of the standard are covered

Happy iterating.
