zocam — composable time for Elixir
API Reference

Zocam.Intervals

The linear kernel: sets of concrete intervals on one axis, with union, intersection, complement, and difference.

AI SLOP An AI agent wrote the docstrings; mix docs generated this page. yuri4n, a senior engineer, gave the direction and did the review. The review is human, thus errors can stay.

Source on GitHub ↗

The linear kernel: sets of concrete intervals on one axis, with union, intersection, complement, and difference.

An interval is a plain map with four keys:

%{from: a, until: b, left: :closed, right: :open}

 from                       until
  │<────────── span ──────────>│
  ●────────────────────────────○
  left: :closed                right: :open
  (a is inside)                (b is outside)

A nil endpoint means "unbounded on this side": %{from: x, until: nil, ...} is the ray from x onward. A set of intervals is the %Zocam.Intervals{} struct; compress/1 keeps its list sorted and free of overlaps, and the bare literal %Zocam.Intervals{} is the empty set.

One rule for shapes

An operation on sets answers with a set; a piece constructor answers with a piece. union/2, intersect/2, diff/2, complement/1, and compress/1 accept a piece (a map or an option list), a list of pieces, or the struct — and always answer with the struct. new!/1 builds one piece and answers with the piece. overlaps?/2 asks a question and answers with a boolean.

Endpoints are Time (a daily wall window) or DateTime (a concrete window). The two kinds do not mix: not inside one interval, and not inside one operation — the kernel cannot compare a Time with a DateTime, so every door rejects the mix with an ArgumentError that names the way out (ground the daily window first, then operate on two DateTime sets). Abstract calendar values ("a Saturday", "May") do not live here: they belong to Zocam.Point, and Zocam.Span.ground/3 turns them into the concrete intervals of this module.

This module is the bottom layer of the library:

Zocam.Point ──▶ Zocam.Span ──▶ Zocam.Intervals  (this module)

A grounded span lands here, so the kernel operations compose with the calendar layers. Cut a busy morning out of a grounded Wednesday (Jan 7 2026 is a Wednesday):

iex> weds = Zocam.Span.of(Zocam.Point.weekday(:wednesday))
iex> horizon = %{from: ~U[2026-01-05 00:00:00Z], until: ~U[2026-01-12 00:00:00Z], left: :closed, right: :open}
iex> week = Zocam.Span.ground(weds, horizon, "Etc/UTC")
iex> busy = [%{from: ~U[2026-01-07 00:00:00Z], until: ~U[2026-01-07 12:00:00Z], left: :closed, right: :open}]
iex> Zocam.Intervals.diff(week, busy).intervals
[%{from: ~U[2026-01-07 12:00:00Z], until: ~U[2026-01-08 00:00:00Z], left: :closed, right: :open}]

Enumerate a set

A set is one value that holds pieces, so Enum walks it: the struct implements Enumerable and yields its concrete interval/0 pieces, in normal-form order.

The common call — read one field off every piece:

iex> free = Zocam.Intervals.diff(
...>   Zocam.Intervals.new!(from: ~T[08:00:00], until: ~T[18:00:00]),
...>   [%{from: ~T[09:00:00], until: ~T[10:00:00], left: :closed, right: :open}]
...> )
iex> Enum.map(free, & &1.from)
[~T[08:00:00], ~T[10:00:00]]

The pitfall: Enum.member?/2 asks "is this value one of the pieces in the list?" — it does NOT ask "is this instant covered by the set?". An instant inside a piece is still not a piece:

iex> jan7 = Zocam.Intervals.compress([
...>   [from: ~U[2026-01-07 00:00:00Z], until: ~U[2026-01-08 00:00:00Z]]
...> ])
iex> Enum.member?(jan7, ~U[2026-01-07 12:00:00Z])
false

To ask about coverage, keep the span and ask Zocam.Span.member?/2.

The edge: the empty set enumerates to nothing.

iex> Enum.to_list(%Zocam.Intervals{})
[]

The integrated use — ground a span, then walk the result:

iex> weds = Zocam.Span.of(Zocam.Point.weekday(:wednesday))
iex> horizon = %{from: ~U[2026-01-05 00:00:00Z], until: ~U[2026-01-19 00:00:00Z], left: :closed, right: :open}
iex> Zocam.Span.ground(weds, horizon, "Etc/UTC") |> Enum.map(& &1.from)
[~U[2026-01-07 00:00:00Z], ~U[2026-01-14 00:00:00Z]]

Types

at_least_one_valid

@type at_least_one_valid() :: valid_interval() | valid_intervals()

both

@type both() :: [
  from: timables(),
  until: timables(),
  left: closing(),
  right: closing()
]

closing

@type closing() :: :open | :closed

interval

@type interval() :: %{
  from: timables() | nil,
  until: timables() | nil,
  left: closing() | nil,
  right: closing() | nil
}

One concrete piece of the timeline: a plain map with exactly four keys. It happens once — nothing repeats here. A nil endpoint means "unbounded on this side" (a ray). The symbolic counterpart that does repeat is Zocam.Span.Arc.

interval_opts

@type interval_opts() :: only_from() | only_until() | both()

only_from

@type only_from() :: [from: timables(), left: closing(), right: closing()]

only_until

@type only_until() :: [until: timables(), left: closing(), right: closing()]

t

@type t() :: %Zocam.Intervals{intervals: [interval()]}

A concrete set of pieces: the struct wraps one list of interval/0 maps, which compress/1 keeps sorted and disjoint. An empty list inside is the empty set, and so is the bare literal %Zocam.Intervals{}. This is what Zocam.Span.ground/3 returns. The symbolic counterpart is t:Zocam.Span.t/0.

timables

@type timables() :: Time.t() | DateTime.t()

valid_interval

@type valid_interval() :: interval_opts() | interval()

valid_intervals

@type valid_intervals() :: [interval() | interval_opts()] | t()

Functions

check_and_extract_as_list!

@spec check_and_extract_as_list!(at_least_one_valid()) :: [interval()]

Normalize one operand into a plain list of interval maps. This is the funnel: every set operation pours its operands through it, so each operation handles exactly ONE shape, and every bad shape is stopped in exactly one place.

The funnel accepts exactly these shapes:

  • %Zocam.Intervals{} — a set; its pieces come out as the list.
  • One interval map with the four keys :from, :until, :left, and :right — what new!/1 builds.
  • One keyword list of interval options (from:, until:, left:, right:).
  • A list — empty, or holding interval maps and option lists in any mix.

Every other shape raises an ArgumentError that names the rejected shape and the accepted ones. A map with only some of the four keys is such a shape: it looks like an interval and is not. A map with the four keys and extra keys beside them is rejected too, and so is an improper list ([a | b] where b is not a list): each looks like an operand and is not exactly one.

iex> Zocam.Intervals.check_and_extract_as_list!(from: ~T[09:00:00], until: ~T[17:00:00])
[%{from: ~T[09:00:00], until: ~T[17:00:00], left: :closed, right: :open}]

iex> Zocam.Intervals.check_and_extract_as_list!(%Zocam.Intervals{})
[]

check_closings!

@spec check_closings!(interval()) :: interval()

Check the two closings, per side: a bounded side (endpoint present) must be :open or :closed; an unbounded side (endpoint nil) has no boundary, so its closing must be nil. Returns the interval unchanged; raises ArgumentError otherwise.

Why per side? A "dead" closing on an unbounded side describes a boundary that does not exist, and a bogus atom (left: :half) silently behaves as :open in every comparison. Both are mistakes the caller wants to hear about at the door.

This check is shared: Zocam.Span runs the same rule on its absolute intervals, so the two layers cannot drift apart.

iex> Zocam.Intervals.check_closings!(%{from: ~T[09:00:00], until: nil, left: :closed, right: nil})
%{from: ~T[09:00:00], until: nil, left: :closed, right: nil}

iex> Zocam.Intervals.check_closings!(%{from: ~T[09:00:00], until: nil, left: :half, right: nil})
** (ArgumentError) left: must be :open or :closed on a bounded side, nil on an unbounded side, got :half

check_endpoints!

@spec check_endpoints!(interval()) :: interval()

Check that each non-nil endpoint is a value the kernel can compare: a Time or a DateTime. Returns the interval unchanged; raises ArgumentError naming the side and the value otherwise.

Why so strict? The kernel orders endpoints with Time.compare/2 and DateTime.compare/2. A weekday atom crashes the first comparison. A NaiveDateTime or a Date is worse: it "works", but equality falls back to structural ==, which misses equal instants written differently — and then a real hole between two intervals is silently unioned away.

iex> Zocam.Intervals.check_endpoints!(%{from: ~T[09:00:00], until: nil, left: :closed, right: nil})
%{from: ~T[09:00:00], until: nil, left: :closed, right: nil}

iex> Zocam.Intervals.check_endpoints!(%{from: :saturday, until: nil, left: :closed, right: nil})
** (ArgumentError) from: must be a Time, a DateTime, or nil, got :saturday. Calendar values (:saturday, :may) belong to Zocam.Point, and Zocam.Span.ground/3 turns them into kernel intervals. A NaiveDateTime or a Date is rejected too: without a timezone it names no instant the kernel can compare.

check_interval_opts!

@spec check_interval_opts!(interval_opts()) :: interval_opts()

Check one interval option list: a proper keyword list, with from/until/left/right at most once each, and at least one of from/until present. Returns the options unchanged; raises ArgumentError otherwise.

iex> Zocam.Intervals.check_interval_opts!(from: ~T[09:00:00], from: ~T[10:00:00])
** (ArgumentError) Invalid options: from, until, left and right cannot be specified more than once

The pitfall: a stray element between the pairs is not an option. The check names it instead of failing deep inside Keyword:

iex> Zocam.Intervals.check_interval_opts!([{:from, ~T[09:00:00]}, :oops])
** (ArgumentError) Invalid options: an interval option list holds {key, value} pairs only, and :oops is not one. Got: [{:from, ~T[09:00:00]}, :oops]

check_order!

@spec check_order!(interval()) :: interval()

Check that the interval runs forward: when both endpoints are present, from must not be strictly after until. Returns the interval unchanged; raises ArgumentError otherwise.

Why so strict? An inverted interval covers no instant, but the ordering helpers read it as "before itself", and every law of the algebra breaks from there. The kernel is linear: a window that crosses midnight is not one backward interval, it is a wrapping arc — that concept lives one layer up, in Zocam.Span.arc!/1.

iex> Zocam.Intervals.check_order!(%{from: ~T[06:00:00], until: ~T[22:00:00], left: :closed, right: :open})
%{from: ~T[06:00:00], until: ~T[22:00:00], left: :closed, right: :open}

iex> Zocam.Intervals.check_order!(%{from: ~T[22:00:00], until: ~T[06:00:00], left: :closed, right: :open})
** (ArgumentError) from ~T[22:00:00] is after until ~T[06:00:00]: an interval runs forward. For a window that crosses midnight, such as 22:00..06:00, build a wrapping arc with Zocam.Span.arc!/1 instead.

complement

@spec complement(at_least_one_valid()) :: t()

Everything outside the operand, as a set. Each boundary closing flips (see opposite_closing/1): the instants [a, b] covers are exactly the instants (-inf, a) and (b, +inf) miss. One bounded interval complements to two rays:

iex> Zocam.Intervals.complement(
...>   %{from: ~T[09:00:00], until: ~T[17:00:00], left: :closed, right: :open}
...> ).intervals
[
  %{from: nil, until: ~T[09:00:00], left: nil, right: :open},
  %{from: ~T[17:00:00], until: nil, left: :closed, right: nil}
]

The edge: the empty set complements to the whole timeline — one interval, unbounded on both sides:

iex> Zocam.Intervals.complement([])
%Zocam.Intervals{intervals: [%{from: nil, until: nil, left: nil, right: nil}]}

completely_before?

@spec completely_before?(interval(), interval()) :: boolean()

Is lhs entirely before rhs, with no shared instant? Touching endpoints count as "before" only when at least one side is :open (the shared instant then belongs to at most one of them).

iex> Zocam.Intervals.completely_before?(
...>   %{from: ~T[09:00:00], until: ~T[12:00:00], left: :closed, right: :open},
...>   %{from: ~T[12:00:00], until: ~T[17:00:00], left: :closed, right: :open}
...> )
true

compress

@spec compress(at_least_one_valid()) :: t()

Bring one operand into its normal form: drop every piece that covers no instant ([t, t), (t, t), (t, t] — only the single-point [t, t] survives), fuse everything that overlaps or touches, then sort by the left endpoint (nil first: an unbounded left side starts before everything). Two operands with the same content compress to the same struct, which makes normal forms comparable with ==.

The edge: a zero-width piece with an open side holds nothing, so compressing it alone gives the empty set:

iex> Zocam.Intervals.compress([
...>   %{from: ~T[12:00:00], until: ~T[12:00:00], left: :closed, right: :open}
...> ])
%Zocam.Intervals{intervals: []}

iex> Zocam.Intervals.compress([
...>   %{from: ~T[13:00:00], until: ~T[17:00:00], left: :closed, right: :open},
...>   %{from: ~T[09:00:00], until: ~T[11:00:00], left: :closed, right: :open},
...>   %{from: ~T[10:00:00], until: ~T[13:00:00], left: :closed, right: :open}
...> ])
%Zocam.Intervals{intervals: [%{from: ~T[09:00:00], until: ~T[17:00:00], left: :closed, right: :open}]}

The edge: equality of instants follows the clock, not the struct. A different written precision is still the same instant — and here both sides are :open at 10:00, so the one-instant hole survives:

iex> Zocam.Intervals.compress([
...>   %{from: ~T[09:00:00], until: ~T[10:00:00], left: :open, right: :open},
...>   %{from: ~T[10:00:00.000000], until: ~T[11:00:00], left: :open, right: :open}
...> ]).intervals
[
  %{from: ~T[09:00:00], until: ~T[10:00:00], left: :open, right: :open},
  %{from: ~T[10:00:00.000000], until: ~T[11:00:00], left: :open, right: :open}
]

diff

@spec diff(at_least_one_valid(), at_least_one_valid()) :: t()

Set difference: the instants of this that are not in other, as a set. Computed piece by piece as this intersected with the complement of other, so the three operations stay consistent by construction.

Examples

The common call: cut the busy slots out of a working day. The subtrahends subtract one after the other from what remains:

iex> day = Zocam.Intervals.new!(from: ~T[08:00:00], until: ~T[18:00:00])
iex> busy = [
...>   %{from: ~T[09:00:00], until: ~T[10:00:00], left: :closed, right: :open},
...>   %{from: ~T[12:00:00], until: ~T[13:00:00], left: :closed, right: :open}
...> ]
iex> Zocam.Intervals.diff(day, busy).intervals
[
  %{from: ~T[08:00:00], until: ~T[09:00:00], left: :closed, right: :open},
  %{from: ~T[10:00:00], until: ~T[12:00:00], left: :closed, right: :open},
  %{from: ~T[13:00:00], until: ~T[18:00:00], left: :closed, right: :open}
]

Subtracting a middle slice cuts one interval into two pieces of one set:

iex> block = %{from: ~T[09:00:00], until: ~T[17:00:00], left: :closed, right: :open}
iex> Zocam.Intervals.diff(block, %{from: ~T[12:00:00], until: ~T[13:00:00], left: :closed, right: :open}).intervals
[
  %{from: ~T[09:00:00], until: ~T[12:00:00], left: :closed, right: :open},
  %{from: ~T[13:00:00], until: ~T[17:00:00], left: :closed, right: :open}
]

The edge: "nothing remains" is the empty set — the struct with no pieces, never nil:

iex> block = %{from: ~T[09:00:00], until: ~T[17:00:00], left: :closed, right: :open}
iex> Zocam.Intervals.diff(block, block)
%Zocam.Intervals{intervals: []}

intersect

@spec intersect(at_least_one_valid(), at_least_one_valid()) :: t()

The instants shared by both operands, as a set. The tighter bound wins on each side, closing included: [a, b] meets (a, c) in (a, b]:

iex> Zocam.Intervals.intersect(
...>   %{from: ~T[09:00:00], until: ~T[12:00:00], left: :closed, right: :closed},
...>   %{from: ~T[09:00:00], until: ~T[17:00:00], left: :open, right: :open}
...> )
%Zocam.Intervals{intervals: [%{from: ~T[09:00:00], until: ~T[12:00:00], left: :open, right: :closed}]}

The edge: two operands that share nothing meet in the empty set — the struct with no pieces, never nil:

iex> Zocam.Intervals.intersect(
...>   %{from: ~T[09:00:00], until: ~T[12:00:00], left: :closed, right: :open},
...>   %{from: ~T[14:00:00], until: ~T[15:00:00], left: :closed, right: :open}
...> )
%Zocam.Intervals{intervals: []}

interval_from_opts

@spec interval_from_opts(interval_opts()) :: interval()

Build the interval map from an option list. A missing side stays nil (unbounded). The default closings are :closed on the left and :open on the right, the half-open convention that lets adjacent intervals tile without overlap; an absent side gets nil.

iex> Zocam.Intervals.interval_from_opts(from: ~T[09:00:00])
%{from: ~T[09:00:00], until: nil, left: :closed, right: nil}

is_interval

macro

new!

@spec new!(interval_opts()) :: interval()

Build ONE interval — a piece, not a set. This is the piece constructor: an operation on sets answers with a set, and a piece constructor answers with a piece (see "One rule for shapes" in the moduledoc).

The common call, with the default closings (:closed left, :open right):

iex> Zocam.Intervals.new!(from: ~T[09:00:00], until: ~T[17:00:00])
%{from: ~T[09:00:00], until: ~T[17:00:00], left: :closed, right: :open}

The edge: a missing side stays nil — the piece is a ray:

iex> Zocam.Intervals.new!(from: ~T[09:00:00])
%{from: ~T[09:00:00], until: nil, left: :closed, right: nil}

The pitfall: new!/1 does not build a set. To turn pieces into a set, pour them through a set operation, for example compress/1:

iex> Zocam.Intervals.compress([
...>   Zocam.Intervals.new!(from: ~T[09:00:00], until: ~T[12:00:00]),
...>   Zocam.Intervals.new!(from: ~T[12:00:00], until: ~T[17:00:00])
...> ])
%Zocam.Intervals{intervals: [%{from: ~T[09:00:00], until: ~T[17:00:00], left: :closed, right: :open}]}

Raises ArgumentError on an empty specification:

iex> Zocam.Intervals.new!([])
** (ArgumentError) Invalid options: from or until must be specified

overlaps?

@spec overlaps?(at_least_one_valid(), at_least_one_valid()) :: boolean()

Do the two operands share at least one instant? Both operands may be pieces or whole sets (any funnel shape); two sets overlap when any pair of their members does. This is a question, so the answer is a boolean, not a set.

The pitfall: touching is not overlapping. [09:00, 12:00) and [12:00, 17:00) tile without a shared instant, because the right side is :open. Close it and the instant 12:00 is shared:

iex> Zocam.Intervals.overlaps?(
...>   %{from: ~T[09:00:00], until: ~T[12:00:00], left: :closed, right: :open},
...>   %{from: ~T[12:00:00], until: ~T[17:00:00], left: :closed, right: :open}
...> )
false

iex> Zocam.Intervals.overlaps?(
...>   %{from: ~T[09:00:00], until: ~T[12:00:00], left: :closed, right: :closed},
...>   %{from: ~T[12:00:00], until: ~T[17:00:00], left: :closed, right: :open}
...> )
true

union

@spec union(at_least_one_valid(), at_least_one_valid()) :: t()

The union of the two operands, as one normal-form set (see compress/1): overlapping and touching pieces fuse into one spanning interval.

iex> Zocam.Intervals.union(
...>   %{from: ~T[09:00:00], until: ~T[11:00:00], left: :closed, right: :open},
...>   %{from: ~T[10:00:00], until: ~T[13:00:00], left: :closed, right: :open}
...> )
%Zocam.Intervals{intervals: [%{from: ~T[09:00:00], until: ~T[13:00:00], left: :closed, right: :open}]}

Touching pieces fuse too, because the shared instant is covered:

iex> Zocam.Intervals.union(
...>   [%{from: ~T[09:00:00], until: ~T[12:00:00], left: :closed, right: :open}],
...>   %{from: ~T[12:00:00], until: ~T[17:00:00], left: :closed, right: :open}
...> )
%Zocam.Intervals{intervals: [%{from: ~T[09:00:00], until: ~T[17:00:00], left: :closed, right: :open}]}

The edge: when both touching sides are :open, the shared instant is in neither interval. A hole of one instant remains, so the two do not fuse:

iex> Zocam.Intervals.union(
...>   [%{from: ~T[09:00:00], until: ~T[12:00:00], left: :open, right: :open}],
...>   %{from: ~T[12:00:00], until: ~T[17:00:00], left: :open, right: :open}
...> ).intervals |> length()
2

Consult api-reference.md for complete listing

Copyright © 2026