An explicit bounded span on the time line.
Every Tempo value is an interval at some resolution; a bare
%Tempo{} materialises to an %Tempo.Interval{} via
Tempo.to_interval/1. %Tempo.Interval{} carries explicit
from and to endpoints plus optional recurrence metadata
(recurrence, duration, repeat_rule) for RRULE-style
values.
Tempo uses the half-open [from, to) convention: from is
inclusive, to is exclusive. Adjacent intervals concatenate
cleanly — [a, b) ++ [b, c) == [a, c).
Comparing intervals
relation/2 classifies two intervals by Allen's interval
algebra, returning one of 13 mutually-exclusive relations.
See the function docs for the full table.
Domain semantics: continuous time, discrete-style intervals
Tempo's underlying time line is continuous: endpoints project
to gregorian seconds (a real number, via Erlang's
:calendar.datetime_to_gregorian_seconds/1) and cross-zone
comparison uses real-number ordering. The set of representable
endpoint positions is dense within Tempo's resolution range
(currently down to one-second granularity).
Interval boundary semantics are nonetheless treated in the
discrete style — the :to endpoint is exclusive, so two
intervals [a, b) and [b, c) meet at the shared boundary b
with empty geometric intersection. This is the same convention
Rust's allen-intervals crate uses for its discrete integer
domain, and matches Hayes' open-connected-subsets model that
Grüninger and Li cite (TIME 2017, §2.2). It contrasts with the
closed-interval / continuous-domain convention (e.g., Allen's
original 1983 paper, which treats intervals as closed and lets
meets happen at a single shared point of inclusion); Tempo's
half-open choice keeps adjacency unambiguous and lets coalescing
be a pure operation on endpoints without a "do these touch?"
predicate.
In practical terms: if you need to model an event that includes
both endpoint moments (a true closed interval), encode it as
[a, b + ε) where ε is one unit at the value's resolution.
Library code never imposes this — the half-open convention is the
contract.
Summary
Types
Anything relation/2 can reduce to a single bounded interval.
One of Allen's 13 interval relations — jointly exhaustive and
pairwise disjoint under the half-open [from, to) convention.
Functions
true when the two intervals touch at a single boundary —
either a meets b or b meets a (Allen's
:meets | :met_by).
true when a starts strictly after b ends, with a gap
(Allen's :preceded_by).
true when the interval is at least as long as the given
duration.
true when the interval is at most as long as the given
duration.
true when a ends strictly before b starts, with a gap
(Allen's :precedes). Use adjacent?/2 to include the
no-gap case.
true when both endpoints are concrete %Tempo{} values —
neither :undefined nor nil. Useful as a guard before set
operations or duration checks.
Return the interval's length as a %Tempo.Duration{} in
seconds. Returns :infinity for unbounded intervals (one or
both endpoints :undefined).
true when a is strictly inside b — both endpoints of
a lie strictly within b (Allen's :during). Shared-
endpoint cases (:starts, :finishes) return false; use
within?/2 for the inclusive version.
true when the interval has zero or negative length —
from == to (degenerate instant) or from > to (inverted
span).
Return the interval's endpoints as a {from, to} tuple.
true when two intervals describe the same temporal extent,
regardless of calendar, zone display, or attached metadata.
true when the interval's length equals the given duration
exactly.
Return the interval's from endpoint.
The inverse Allen relation.
Return the list of IERS leap-second dates that fall inside
[from, to).
true when the interval's length is strictly greater than
the given duration.
true when a's end coincides exactly with b's start
(Allen's :meets). Under the half-open convention this means
the intervals share no point but have no gap.
Return the metadata map attached to the interval.
Construct a Tempo.Interval.t/0 from a keyword list of options.
Bang variant of new/1. Raises on invalid input.
Given a fully-resolved %Tempo{}, compute the two endpoints of
its implicit span under the half-open [from, to) convention.
Classify the Allen relation between two interval-like values.
Return the interval's span resolution — the coarsest unit at
which from and to differ.
true when the interval's length is strictly less than the
given duration.
Return true when the interval [from, to) contains at least
one IERS-announced positive leap second.
Return the interval's to endpoint.
true when a lies inside b inclusive of shared
endpoints (Allen's :equals | :starts | :during | :finishes).
The canonical "does this fit inside that window?" predicate.
Types
@type interval_like() :: Tempo.t() | t() | Tempo.IntervalSet.t()
Anything relation/2 can reduce to a single bounded interval.
@type relation() ::
:precedes
| :meets
| :overlaps
| :finished_by
| :contains
| :starts
| :equals
| :started_by
| :during
| :finishes
| :overlapped_by
| :met_by
| :preceded_by
One of Allen's 13 interval relations — jointly exhaustive and
pairwise disjoint under the half-open [from, to) convention.
@type t() :: %Tempo.Interval{ direction: 1 | -1, duration: Tempo.Duration.t() | nil, from: Tempo.t() | Tempo.Duration.t() | :undefined | nil, metadata: map(), recurrence: pos_integer() | :infinity, repeat_rule: Tempo.t() | nil, to: Tempo.t() | :undefined | nil }
Functions
@spec adjacent?(interval_like(), interval_like()) :: boolean()
true when the two intervals touch at a single boundary —
either a meets b or b meets a (Allen's
:meets | :met_by).
Examples
iex> Tempo.Interval.adjacent?(~o"2026-06-15", ~o"2026-06-16")
true
iex> Tempo.Interval.adjacent?(~o"2026-06-15", ~o"2026-06-17")
false
@spec after?(interval_like(), interval_like()) :: boolean()
true when a starts strictly after b ends, with a gap
(Allen's :preceded_by).
@spec at_least?(t(), Tempo.Duration.t()) :: boolean()
true when the interval is at least as long as the given
duration.
Unbounded intervals (:undefined endpoint) satisfy any finite
minimum — an infinite span is trivially "at least" any
duration.
Examples
iex> iv = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T11"}
iex> Tempo.Interval.at_least?(iv, ~o"PT1H")
true
iex> Tempo.Interval.at_least?(iv, ~o"PT3H")
false
@spec at_most?(t(), Tempo.Duration.t()) :: boolean()
true when the interval is at most as long as the given
duration.
Unbounded intervals return false — an infinite span exceeds
any finite maximum.
Examples
iex> iv = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T10"}
iex> Tempo.Interval.at_most?(iv, ~o"PT1H")
true
iex> Tempo.Interval.at_most?(iv, ~o"PT30M")
false
@spec before?(interval_like(), interval_like()) :: boolean()
true when a ends strictly before b starts, with a gap
(Allen's :precedes). Use adjacent?/2 to include the
no-gap case.
Returns false on any error or non-matching relation.
true when both endpoints are concrete %Tempo{} values —
neither :undefined nor nil. Useful as a guard before set
operations or duration checks.
Examples
iex> Tempo.Interval.bounded?(%Tempo.Interval{from: ~o"2026-06-01", to: ~o"2026-06-10"})
true
iex> Tempo.Interval.bounded?(%Tempo.Interval{from: ~o"2026-06-01", to: :undefined})
false
@spec duration( t(), keyword() ) :: Tempo.Duration.t() | :infinity
Return the interval's length as a %Tempo.Duration{} in
seconds. Returns :infinity for unbounded intervals (one or
both endpoints :undefined).
The result is calendar- and zone-aware — it goes through
Tempo.Compare.to_utc_seconds/1 so cross-zone intervals
compute a correct wall-clock delta.
Options
:leap_seconds— whentrue, adds one second to the returned duration for each IERS leap-second insertion that falls inside[from, to). Defaults tofalseso behaviour matchesDateTime,Time, and:calendarfrom Elixir/OTP (none of which count leap seconds). SeeTempo.Interval.spans_leap_second?/1andleap_seconds_spanned/1for detection without arithmetic.
Examples
iex> Tempo.Interval.duration(%Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T10"})
~o"PT3600S"
iex> Tempo.Interval.duration(%Tempo.Interval{from: ~o"2026-06-15", to: :undefined})
:infinity
iex> iv = %Tempo.Interval{from: ~o"2016-12-31T23:59:00Z", to: ~o"2017-01-01T00:01:00Z"}
iex> Tempo.Interval.duration(iv)
~o"PT120S"
iex> Tempo.Interval.duration(iv, leap_seconds: true)
~o"PT121S"
@spec during?(interval_like(), interval_like()) :: boolean()
true when a is strictly inside b — both endpoints of
a lie strictly within b (Allen's :during). Shared-
endpoint cases (:starts, :finishes) return false; use
within?/2 for the inclusive version.
true when the interval has zero or negative length —
from == to (degenerate instant) or from > to (inverted
span).
Under the half-open [from, to) convention, an interval with
from >= to contains no real instants. Empty intervals pass
bounded?/1 but have no span; inverted intervals are treated
as empty rather than as a span with "negative" duration.
Examples
iex> Tempo.Interval.empty?(%Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-15"})
true
iex> Tempo.Interval.empty?(%Tempo.Interval{from: ~o"2026-06-20", to: ~o"2026-06-15"})
true
iex> Tempo.Interval.empty?(%Tempo.Interval{from: ~o"2026-06-01", to: ~o"2026-06-10"})
false
Return the interval's endpoints as a {from, to} tuple.
A named helper so callers never have to reach into the struct fields in user-facing code.
Arguments
intervalis at/0.
Returns
{from, to}where each endpoint is aTempo.t/0or:undefinedfor open-ended intervals.
Examples
iex> iv = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-20"}
iex> {from, to} = Tempo.Interval.endpoints(iv)
iex> {Tempo.day(from), Tempo.day(to)}
{15, 20}
true when two intervals describe the same temporal extent,
regardless of calendar, zone display, or attached metadata.
Standard == compares all struct fields, including :metadata,
:calendar, and the zone-display details on the endpoints, so the
same span shown in two different zones compares unequal.
equivalent?/2 projects endpoints to UTC and compares only the
temporal positions — matching the equivalence notion of the
T_bounded_meeting ontology of Grüninger and Li (TIME 2017), under
which intervals are individuated by their position in the structure
of meets, not by labels.
Recurrence-related fields (:recurrence, :direction,
:repeat_rule, :duration) must match structurally: two recurring
intervals with different rules describe different extents.
Arguments
aandbaret/0values.
Returns
trueif both intervals occupy the same temporal extent under UTC projection.falseotherwise.
Examples
iex> a = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"}
iex> b = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"}
iex> Tempo.Interval.equivalent?(a, b)
true
iex> a = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"}
iex> b = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-17"}
iex> Tempo.Interval.equivalent?(a, b)
false
@spec exactly?(t(), Tempo.Duration.t()) :: boolean()
true when the interval's length equals the given duration
exactly.
Unbounded intervals always return false.
Examples
iex> iv = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T10"}
iex> Tempo.Interval.exactly?(iv, ~o"PT1H")
true
iex> Tempo.Interval.exactly?(iv, ~o"PT2H")
false
Return the interval's from endpoint.
A named helper so callers never have to reach into the struct
fields in user-facing code. Compose with Tempo.day/1, Tempo.year/1,
etc. to extract components of the starting point.
Arguments
intervalis at/0.
Returns
- The
fromendpoint as aTempo.t/0or:undefinedfor open-ended intervals.
Examples
iex> iv = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-20"}
iex> Tempo.Interval.from(iv) |> Tempo.day()
15
The inverse Allen relation.
If relation(a, b) returns r, then relation(b, a) returns
inverse_relation(r).
Examples
iex> Tempo.Interval.inverse_relation(:contains)
:during
iex> Tempo.Interval.inverse_relation(:precedes)
:preceded_by
iex> Tempo.Interval.inverse_relation(:equals)
:equals
Return the list of IERS leap-second dates that fall inside
[from, to).
Arguments
intervalis at/0with both endpoints present.
Returns
- A list of
{year, month, day}tuples, each entry drawn fromTempo.LeapSeconds.dates/0. Empty list when no leap second falls inside the span, or when either endpoint is:undefined.
Examples
iex> iv = %Tempo.Interval{from: ~o"2015-01-01", to: ~o"2017-12-31"}
iex> Tempo.Interval.leap_seconds_spanned(iv)
[{2015, 6, 30}, {2016, 12, 31}]
@spec longer_than?(t(), Tempo.Duration.t()) :: boolean()
true when the interval's length is strictly greater than
the given duration.
Examples
iex> iv = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T11"}
iex> Tempo.Interval.longer_than?(iv, ~o"PT1H")
true
iex> Tempo.Interval.longer_than?(iv, ~o"PT2H")
false
@spec meets?(interval_like(), interval_like()) :: boolean()
true when a's end coincides exactly with b's start
(Allen's :meets). Under the half-open convention this means
the intervals share no point but have no gap.
Return the metadata map attached to the interval.
A named helper so callers never have to reach into the struct
fields in user-facing code. Metadata is free-form and is
preserved across set operations — intervals that survive a
union, intersection, or difference inherit the surviving
operand's metadata, so this accessor is the intended way to
read iCal SUMMARY, LOCATION, event UIDs, and any other
application-attached per-interval data.
Arguments
intervalis at/0.
Returns
- The metadata map. An interval constructed without metadata
returns
%{}.
Examples
iex> iv = Tempo.Interval.new!(
...> from: ~o"2026-06-15T09",
...> to: ~o"2026-06-15T10",
...> metadata: %{summary: "Stand-up"}
...> )
iex> Tempo.Interval.metadata(iv)
%{summary: "Stand-up"}
iex> iv = Tempo.Interval.new!(from: ~o"2026-06-15", to: ~o"2026-06-20")
iex> Tempo.Interval.metadata(iv)
%{}
@spec new(keyword()) :: {:ok, t()} | {:error, Exception.t()}
Construct a Tempo.Interval.t/0 from a keyword list of options.
The companion to ~o interval sigils and Tempo.to_interval/1.
Use this when you have the endpoints as runtime values (e.g. two
%Tempo{} structs) rather than an ISO 8601 string.
At least one of :from, :to, or :duration must be supplied.
Arguments
optionsis a keyword list of construction options (see below).
Options
:fromis aTempo.t/0or the atom:undefined(open start).:tois aTempo.t/0or the atom:undefined(open end).:durationis aTempo.Duration.t/0. When combined with:from, the:toendpoint is derived lazily byTempo.to_interval/1.:recurrenceis apos_integer()or:infinity.:repeat_ruleis aTempo.RRule.Rule.t/0orTempo.t/0.:metadatais a free-form map carried through set operations.
Returns
{:ok, t()}on success.{:error, reason}when endpoints are invalid,:fromis not strictly earlier than:to(a zero-extent interval is not a valid interval under the half-open convention; seeTempo.Interval.empty?/1for the predicate that detects malformed struct literals), or required fields are missing.
Examples
iex> {:ok, iv} = Tempo.Interval.new(
...> from: Tempo.new!(year: 2026, month: 6, day: 15, hour: 9),
...> to: Tempo.new!(year: 2026, month: 6, day: 15, hour: 17)
...> )
iex> iv.from.time
[year: 2026, month: 6, day: 15, hour: 9]
iex> {:ok, iv} = Tempo.Interval.new(
...> from: Tempo.new!(year: 1985),
...> to: :undefined
...> )
iex> iv.to
:undefined
Bang variant of new/1. Raises on invalid input.
Given a fully-resolved %Tempo{}, compute the two endpoints of
its implicit span under the half-open [from, to) convention.
Returns {:ok, {lower, upper}} where both are %Tempo{} values,
or {:error, reason} when the input has no finer unit that could
produce a bounded span (e.g. a fully-specified second-resolution
datetime).
The lower bound is the input's time extended with the minimum of
the next-finer unit (so [year: 2022] becomes [year: 2022, month: 1] on a month-based calendar). The upper bound is the
lower bound incremented by one unit at the input's own resolution,
carrying via the calendar module. Masked values widen to the
coarsest un-masked prefix and use the internal mask-bounds
helper to determine the enclosing span.
@spec relation(interval_like(), interval_like()) :: relation() | {:error, term()}
Classify the Allen relation between two interval-like values.
Returns one of 13 mutually exclusive relations from Allen's
interval algebra — a richer answer than stdlib's ternary
compare/2 (:lt / :eq / :gt), which collapses intervals
to their start points and loses the containment and overlap
distinctions that interval algebra captures. Hence the name
relation rather than compare.
For intervals X = [x₁, x₂) and Y = [y₁, y₂) under Tempo's
half-open convention:
| Relation | Shape (X relative to Y) | Condition |
|---|---|---|
:precedes | X ends strictly before Y starts | x₂ < y₁ |
:meets | X ends exactly at Y's start | x₂ = y₁ |
:overlaps | X starts before Y, ends inside | x₁ < y₁ < x₂ < y₂ |
:finished_by | X contains Y, shared end | x₁ < y₁ ∧ x₂ = y₂ |
:contains | X strictly contains Y | x₁ < y₁ ∧ x₂ > y₂ |
:starts | Shared start, X ends earlier | x₁ = y₁ ∧ x₂ < y₂ |
:equals | Identical endpoints | x₁ = y₁ ∧ x₂ = y₂ |
:started_by | Shared start, X ends later | x₁ = y₁ ∧ x₂ > y₂ |
:during | X strictly inside Y | x₁ > y₁ ∧ x₂ < y₂ |
:finishes | X starts after Y, shared end | x₁ > y₁ ∧ x₂ = y₂ |
:overlapped_by | Y starts before X, ends inside X | y₁ < x₁ < y₂ < x₂ |
:met_by | X starts exactly at Y's end | x₁ = y₂ |
:preceded_by | X starts strictly after Y's end | x₁ > y₂ |
Every pair of non-empty bounded intervals stands in exactly one of these relations.
Arguments
aandbare each one of:a
Tempo.t/0point (materialised via its implicit span).a
Tempo.IntervalSet.t/0with exactly one member.
Returns
One of the 13 relation atoms.
{:error, reason}when either operand is a multi-member IntervalSet, an open-ended interval, or otherwise can't be reduced to a single bounded interval. For multi-member sets useTempo.IntervalSet.relation_matrix/2.
Examples
iex> a = Tempo.Interval.new!(from: ~o"2026-06-01", to: ~o"2026-06-10")
iex> b = Tempo.Interval.new!(from: ~o"2026-06-05", to: ~o"2026-06-15")
iex> Tempo.Interval.relation(a, b)
:overlaps
iex> Tempo.Interval.relation(~o"2026Y", ~o"2026-06-15")
:contains
@spec resolution(t()) :: Tempo.time_unit() | :undefined
Return the interval's span resolution — the coarsest unit at
which from and to differ.
Under the half-open [from, to) convention, this is the unit
that "ticks forward" across the span. [2026-06-15, 2026-06-16)
ticks at the day; [2026-06-01, 2026-07-01) ticks at the month;
[2026, 2027) ticks at the year.
Unlike Tempo.resolution/1 on a filled endpoint (which would
report the finest unit present on the time keyword list after
Tempo.to_interval/1 has padded missing units with their
minimums), this function reports the span's resolution —
the authoritative scale of the interval itself.
Arguments
intervalis at/0. Must be bounded (both endpoints present) —:undefinedendpoints return:undefined.
Returns
- A unit atom (
:year,:month,:day,:hour,:minute,:second, …), or:undefinedfor open-ended intervals.
Examples
iex> iv = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"}
iex> Tempo.Interval.resolution(iv)
:day
iex> iv = %Tempo.Interval{from: ~o"2026-06", to: ~o"2026-07"}
iex> Tempo.Interval.resolution(iv)
:month
@spec shorter_than?(t(), Tempo.Duration.t()) :: boolean()
true when the interval's length is strictly less than the
given duration.
Examples
iex> iv = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T10"}
iex> Tempo.Interval.shorter_than?(iv, ~o"PT2H")
true
iex> Tempo.Interval.shorter_than?(iv, ~o"PT1H")
false
Return true when the interval [from, to) contains at least
one IERS-announced positive leap second.
A historical predicate: it doesn't affect any other Tempo operation. Use it when you want to know if an elapsed-time calculation needs leap-second correction, or to flag intervals for a scientific/astronomy pipeline.
Arguments
intervalis at/0with both endpoints present. Unbounded intervals always returnfalse(open-ended to:undefined) and pre-Unix-era intervals pre-1972 returnfalse(IERS leap seconds started in 1972).
Returns
truewhen at least one entry fromTempo.LeapSeconds.dates/0falls inside[from, to).falseotherwise.
Examples
iex> iv = %Tempo.Interval{from: ~o"2016-12-31T23:00:00Z", to: ~o"2017-01-01T01:00:00Z"}
iex> Tempo.Interval.spans_leap_second?(iv)
true
iex> iv = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-16"}
iex> Tempo.Interval.spans_leap_second?(iv)
false
Return the interval's to endpoint.
Under half-open [from, to) semantics, this is the exclusive
upper bound — the first instant outside the span.
Arguments
intervalis at/0.
Returns
- The
toendpoint as aTempo.t/0or:undefinedfor open-ended intervals.
Examples
iex> iv = %Tempo.Interval{from: ~o"2026-06-15", to: ~o"2026-06-20"}
iex> Tempo.Interval.to(iv) |> Tempo.day()
20
@spec within?(interval_like(), interval_like()) :: boolean()
true when a lies inside b inclusive of shared
endpoints (Allen's :equals | :starts | :during | :finishes).
The canonical "does this fit inside that window?" predicate.
Examples
iex> a = %Tempo.Interval{from: ~o"2026-06-15T10", to: ~o"2026-06-15T11"}
iex> window = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T17"}
iex> Tempo.Interval.within?(a, window)
true
iex> # Candidate shares the window's start — still inside
iex> a2 = %Tempo.Interval{from: ~o"2026-06-15T09", to: ~o"2026-06-15T10"}
iex> Tempo.Interval.within?(a2, window)
true