Tempo.IntervalSet (Tempo v0.20.0)

Copy Markdown View Source

A sorted, non-overlapping, coalesced list of Tempo.Interval.t/0 values — the multi-interval counterpart to Tempo.Interval.

IntervalSet is the operational form for set operations. Every AST shape that expands to a disjoint list of bounded spans (non-contiguous masks, stepped ranges, iterated groups, bounded recurrences, all-of sets) materialises to an IntervalSet via Tempo.to_interval/1.

Invariants

The constructor new/1 guarantees:

  • Intervals are sorted ascending by from.

  • Adjacent or overlapping intervals are coalesced. Half-open semantics means [a, b) ++ [b, c) == [a, c) — the coalesce pass merges both overlap and touch cases.

  • No :undefined endpoints. (Open-ended intervals cannot participate in a set; the caller must bound them first.)

Counting

A set answers three different "how much" questions, and they return very different numbers — reach for the one that matches intent:

  • How many windows (member spans)? — count/1, the set's cardinality.

  • How many sub-points (days, hours, … at the members' resolution)? — Enum.count/1. The Enumerable protocol walks into each member and yields its stepped points, so this totals across every window, not the number of windows.

  • How long (elapsed time)? — Tempo.duration/1 on the whole set (the members' total) or on a member, or Tempo.at_least?/2 to keep only windows of a given length. Never count sub-points for this: across a DST boundary the walk skips the spring-forward hour and emits the fall-back hour twice, so Enum.count deliberately diverges from elapsed time.

A two-window set — January and March — is 2 windows but 62 sub-points (31 + 31 days):

iex> {:ok, free} = Tempo.union(~o"2026Y1M", ~o"2026Y3M")
iex> Tempo.IntervalSet.count(free)
2
iex> Enum.count(free)
62

Timezone handling

An IntervalSet preserves the wall-clock + zone form of its member intervals on the struct. Any set operation that needs to compare endpoints across zones derives a UTC projection on demand; no UTC cache is stored on the struct. This keeps results stable when Tzdata updates — re-running the operation simply uses whatever zone rules are current at the time of the call.

See guides/enumeration-semantics.md for the full discussion of wall-clock-vs-UTC authority.

Summary

Functions

Merge touching or overlapping member intervals into larger spans, returning a new t/0 in canonical instant-set form.

Return the number of member intervals in the set.

true when any member interval of set covers point.

Total covered duration of the set — the sum of every member's length.

Keep only the member intervals for which fun returns true, returning a new t/0.

Apply fun to each member interval and return the results as a plain list.

Construct a t/0 from a list of intervals.

Raising version of new/1.

Build the Allen-relation matrix between every member of a and every member of b.

Cut each member into consecutive fixed-length bookable slots.

Return the member intervals as a plain list.

Total duration covered by the set's members, as a Tempo.Duration.t/0.

Types

t()

@type t() :: %Tempo.IntervalSet{intervals: [Tempo.Interval.t()], metadata: map()}

Functions

coalesce(set)

@spec coalesce(t()) :: t()

Merge touching or overlapping member intervals into larger spans, returning a new t/0 in canonical instant-set form.

IntervalSet preserves member identity by default — each interval stays a distinct member with its own metadata. That shape is right for event management, bookings, and any query that asks about individual members.

Some questions are about the instants covered by the set, not the members: "is this point covered?", "what's the total duration?", "are these two schedules equivalent?". For those, the canonical instant-set form is the right shape — two touching intervals merge into one, and the set has exactly one member per contiguous covered region.

Under the half-open [from, to) convention, intervals merge when the later one's from is at or before the earlier one's to. Touching ([a, b) ++ [b, c) == [a, c)) and overlapping cases both merge.

Metadata

When two members merge, the earlier member's metadata is kept on the merged span and the later member's is dropped. If metadata matters for your query, filter or project before coalescing.

Arguments

Returns

  • A t/0 with touching and overlapping intervals merged.

Examples

iex> set = Tempo.IntervalSet.new!([
...>   %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"},
...>   %Tempo.Interval{from: ~o"2026-06-16", to: ~o"2026-06-17"}
...> ])
iex> Tempo.IntervalSet.count(set)
2
iex> coalesced = Tempo.IntervalSet.coalesce(set)
iex> Tempo.IntervalSet.count(coalesced)
1

count(interval_set)

@spec count(t()) :: non_neg_integer()

Return the number of member intervals in the set.

A named helper so callers never have to write length(set.intervals) or length(to_list(set)) in user-facing code.

Arguments

Returns

  • The count of member intervals as a non-negative integer.

Examples

iex> set = Tempo.IntervalSet.new!([
...>   %Tempo.Interval{from: ~o"2026-06-01", to: ~o"2026-06-10"},
...>   %Tempo.Interval{from: ~o"2026-07-01", to: ~o"2026-07-10"}
...> ])
iex> Tempo.IntervalSet.count(set)
2

covered?(interval_set, point)

@spec covered?(t(), Tempo.t()) :: boolean()

true when any member interval of set covers point.

Coalesces internally — a point is "covered" iff it falls inside at least one member span. For the common booking/scheduling question "is this slot occupied?", this is the right predicate.

Arguments

Returns

  • true or false.

Examples

iex> set = Tempo.IntervalSet.new!([
...>   %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-20"}
...> ])
iex> Tempo.IntervalSet.covered?(set, ~o"2026-06-17")
true
iex> Tempo.IntervalSet.covered?(set, ~o"2026-06-25")
false

duration(interval_set)

@spec duration(t()) :: Tempo.Duration.t()

Total covered duration of the set — the sum of every member's length.

Each member's length is measured on the UTC time line (the same measurement as Tempo.Interval.duration/1), so DST transitions inside a member are accounted for. Members are disjoint by construction when produced by the set operations; if the set was built with overlapping members deliberately preserved, the overlap is counted once per member.

Arguments

Returns

  • A Tempo.Duration.t/0 holding the total in seconds. The empty set has a duration of zero seconds.

Examples

iex> {:ok, free} = Tempo.union(~o"2026-06-01T09/2026-06-01T12", ~o"2026-06-01T14/2026-06-01T17")
iex> Tempo.IntervalSet.duration(free)
~o"PT21600S"

filter(set, fun)

@spec filter(t(), (Tempo.Interval.t() -> as_boolean(any()))) :: t()

Keep only the member intervals for which fun returns true, returning a new t/0.

Arguments

Returns

  • A new t/0 containing only the members where fun returned a truthy value. The input's invariants (sorted, coalesced) are preserved — filtering cannot create overlap.

Examples

iex> set = Tempo.IntervalSet.new!([
...>   %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"},
...>   %Tempo.Interval{from: ~o"2026-06-20", to: ~o"2026-06-25"}
...> ])
iex> long = Tempo.IntervalSet.filter(set, &Tempo.at_least?(&1, ~o"P2D"))
iex> Tempo.IntervalSet.count(long)
1

map(interval_set, fun)

@spec map(t(), (Tempo.Interval.t() -> any())) :: [any()]

Apply fun to each member interval and return the results as a plain list.

Unlike the Enumerable protocol for IntervalSet — which walks each sub-point inside every interval at the next-finer resolution — map/2 operates on the member intervals themselves. It's the set-as-sequence-of-spans view.

The result is a plain list, not an IntervalSet, because the mapper may return anything (integers, tuples, arbitrary values).

Arguments

Returns

  • A list of whatever fun returns, in the set's sort order.

Examples

iex> set = Tempo.IntervalSet.new!([
...>   %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"},
...>   %Tempo.Interval{from: ~o"2026-06-20", to: ~o"2026-06-21"}
...> ])
iex> Tempo.IntervalSet.map(set, &Tempo.day/1)
[15, 20]

new(intervals, opts \\ [])

@spec new(
  [Tempo.Interval.t()],
  keyword()
) :: {:ok, t()} | {:error, term()}

Construct a t/0 from a list of intervals.

The input list is sorted ascending by from endpoint and coalesced — adjacent or overlapping intervals are merged under the half-open [from, to) convention.

Arguments

  • intervals is a list of Tempo.Interval.t/0 values. Open- ended intervals (from: :undefined or to: :undefined) are rejected.

Returns

  • {:ok, interval_set} where interval_set is a t/0, or

  • {:error, reason} when an input interval is open-ended or otherwise cannot participate in a set.

Examples

iex> {:ok, a} = Tempo.to_interval(~o"2022Y1M")
iex> {:ok, b} = Tempo.to_interval(~o"2022Y3M")
iex> {:ok, set} = Tempo.IntervalSet.new([b, a])
iex> length(set.intervals)
2
iex> hd(set.intervals).from.time
[year: 2022, month: 1, day: 1]

new!(intervals, opts \\ [])

@spec new!(
  [Tempo.Interval.t()],
  keyword()
) :: t()

Raising version of new/1.

relation_matrix(a, b)

@spec relation_matrix(
  t() | Tempo.Interval.t() | Tempo.t(),
  t() | Tempo.Interval.t() | Tempo.t()
) ::
  [
    {non_neg_integer(), non_neg_integer(),
     Tempo.Interval.relation() | {:error, term()}}
  ]
  | {:error, term()}

Build the Allen-relation matrix between every member of a and every member of b.

Allen's algebra is defined on pairs of intervals, not sets — two multi-member sets can relate several different ways simultaneously. relation_matrix/2 returns the complete per-pair classification so you can reason about mixed conflicts, merge logic, or scheduling visualisations.

Arguments

  • a and b are t/0 (single intervals and Tempo points are coerced to single-member sets for convenience).

Returns

  • [{a_index, b_index, relation}] — one tuple per pair. Indexes are 0-based into each set's .intervals list. The relation is one of Tempo.Interval.relation/0.

  • {:error, reason} when either input can't be reduced to an IntervalSet of bounded intervals.

Examples

iex> a = Tempo.IntervalSet.new!([
...>   %Tempo.Interval{from: ~o"2026-06-01", to: ~o"2026-06-03"},
...>   %Tempo.Interval{from: ~o"2026-06-05", to: ~o"2026-06-07"}
...> ], coalesce: false)
iex> b = Tempo.IntervalSet.new!([
...>   %Tempo.Interval{from: ~o"2026-06-04", to: ~o"2026-06-06"}
...> ], coalesce: false)
iex> Tempo.IntervalSet.relation_matrix(a, b)
[{0, 0, :precedes}, {1, 0, :overlapped_by}]

slots(set, duration, options \\ [])

@spec slots(t() | Tempo.Interval.t(), Tempo.Duration.t(), keyword()) :: t()

Cut each member into consecutive fixed-length bookable slots.

Turns a region of free time into the discrete slots you could actually book within it. Within each member interval, slots of length duration are emitted starting at the member's start and spaced by :every (a slot every duration by default — back-to-back, non-overlapping); a slot is kept only if it fits entirely inside the member. The members stay distinct (the result is not coalesced).

This is the slicing counterpart to the set operations: where difference/intersection give you the free regions, slots/3 cuts those regions into the fixed-length windows a booking UI offers.

Arguments

Options

  • :every is the spacing between slot starts, a Tempo.Duration.t/0. Defaults to duration (adjacent slots). A smaller value yields overlapping slots; a larger value leaves gaps. Must be positive.

Returns

  • a t/0 of the fitting slots, in order.

Examples

iex> work = Tempo.Interval.new!(from: ~o"2026-06-15T09:00:00", to: ~o"2026-06-15T12:00:00")
iex> work |> Tempo.IntervalSet.slots(~o"PT1H") |> Tempo.IntervalSet.count()
3

iex> work = Tempo.Interval.new!(from: ~o"2026-06-15T09:00:00", to: ~o"2026-06-15T12:00:00")
iex> work |> Tempo.IntervalSet.slots(~o"PT1H", every: ~o"PT30M") |> Tempo.IntervalSet.count()
5

to_list(interval_set)

@spec to_list(t()) :: [Tempo.Interval.t()]

Return the member intervals as a plain list.

The Enumerable protocol implementation for an IntervalSet walks every sub-point inside each interval (consistent with the Tempo and Tempo.Interval Enumerable implementations — every Tempo value is a span, iteration walks its sub-points at the next-finer resolution).

When you want to operate on the member intervals instead — filter them, count them, map them — to_list/1 gives you a plain list you can pipe into Enum.

Examples

iex> {:ok, set} = Tempo.IntervalSet.new([
...>   %Tempo.Interval{from: ~o"2026-06-01", to: ~o"2026-06-10"},
...>   %Tempo.Interval{from: ~o"2026-07-01", to: ~o"2026-07-10"}
...> ])
iex> set |> Tempo.IntervalSet.to_list() |> length()
2

Pair with the interval predicates for expressive scheduling:

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

total_duration(set)

@spec total_duration(t()) :: Tempo.Duration.t()

Total duration covered by the set's members, as a Tempo.Duration.t/0.

Coalesces internally so overlapping members are not double-counted — the returned duration is the length of the union of covered instants, not the sum of individual member durations. For the "sum of member durations" semantics, use map(set, &Tempo.Interval.duration/1) |> Enum.sum() with explicit arithmetic.

Arguments

Returns

Examples

iex> set = Tempo.IntervalSet.new!([
...>   %Tempo.Interval{from: ~o"2026-06-15T09:00:00", to: ~o"2026-06-15T10:00:00"},
...>   %Tempo.Interval{from: ~o"2026-06-15T11:00:00", to: ~o"2026-06-15T12:00:00"}
...> ])
iex> Tempo.IntervalSet.total_duration(set)
~o"PT7200S"