Tempo.Interval (Tempo v0.7.0)

Copy Markdown View Source

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.

t()

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

interval_like()

@type interval_like() :: Tempo.t() | t() | Tempo.IntervalSet.t()

Anything relation/2 can reduce to a single bounded interval.

relation()

@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.

t()

@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

adjacent?(a, b)

@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

after?(a, b)

@spec after?(interval_like(), interval_like()) :: boolean()

true when a starts strictly after b ends, with a gap (Allen's :preceded_by).

at_least?(interval, d)

@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

at_most?(interval, d)

@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

before?(a, b)

@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.

bounded?(interval)

@spec bounded?(t()) :: boolean()

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

duration(interval, opts \\ [])

@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 — when true, adds one second to the returned duration for each IERS leap-second insertion that falls inside [from, to). Defaults to false so behaviour matches DateTime, Time, and :calendar from Elixir/OTP (none of which count leap seconds). See Tempo.Interval.spans_leap_second?/1 and leap_seconds_spanned/1 for 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"

during?(a, b)

@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.

empty?(interval)

@spec empty?(t()) :: boolean()

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

endpoints(interval)

@spec endpoints(t()) :: {Tempo.t() | :undefined, Tempo.t() | :undefined}

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

  • interval is a t/0.

Returns

  • {from, to} where each endpoint is a Tempo.t/0 or :undefined for 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}

equivalent?(a, b)

@spec equivalent?(t(), t()) :: boolean()

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

  • a and b are t/0 values.

Returns

  • true if both intervals occupy the same temporal extent under UTC projection.

  • false otherwise.

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

exactly?(interval, d)

@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

from(interval)

@spec from(t()) :: Tempo.t() | :undefined

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

  • interval is a t/0.

Returns

  • The from endpoint as a Tempo.t/0 or :undefined for 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

inverse_relation(atom)

@spec inverse_relation(relation()) :: relation()

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

leap_seconds_spanned(iv)

@spec leap_seconds_spanned(t()) :: [{integer(), 1..12, 1..31}]

Return the list of IERS leap-second dates that fall inside [from, to).

Arguments

  • interval is a t/0 with both endpoints present.

Returns

  • A list of {year, month, day} tuples, each entry drawn from Tempo.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}]

longer_than?(interval, d)

@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

meets?(a, b)

@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.

metadata(interval)

@spec metadata(t()) :: map()

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

  • interval is a t/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)
%{}

new(options)

@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

  • options is a keyword list of construction options (see below).

Options

Returns

  • {:ok, t()} on success.

  • {:error, reason} when endpoints are invalid, :from is not strictly earlier than :to (a zero-extent interval is not a valid interval under the half-open convention; see Tempo.Interval.empty?/1 for 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

new!(options)

@spec new!(keyword()) :: t()

Bang variant of new/1. Raises on invalid input.

next_unit_boundary(tempo)

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.

relation(a, b)

@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:

RelationShape (X relative to Y)Condition
:precedesX ends strictly before Y startsx₂ < y₁
:meetsX ends exactly at Y's startx₂ = y₁
:overlapsX starts before Y, ends insidex₁ < y₁ < x₂ < y₂
:finished_byX contains Y, shared endx₁ < y₁ ∧ x₂ = y₂
:containsX strictly contains Yx₁ < y₁ ∧ x₂ > y₂
:startsShared start, X ends earlierx₁ = y₁ ∧ x₂ < y₂
:equalsIdentical endpointsx₁ = y₁ ∧ x₂ = y₂
:started_byShared start, X ends laterx₁ = y₁ ∧ x₂ > y₂
:duringX strictly inside Yx₁ > y₁ ∧ x₂ < y₂
:finishesX starts after Y, shared endx₁ > y₁ ∧ x₂ = y₂
:overlapped_byY starts before X, ends inside Xy₁ < x₁ < y₂ < x₂
:met_byX starts exactly at Y's endx₁ = y₂
:preceded_byX starts strictly after Y's endx₁ > y₂

Every pair of non-empty bounded intervals stands in exactly one of these relations.

Arguments

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 use Tempo.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

resolution(interval)

@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

  • interval is a t/0. Must be bounded (both endpoints present) — :undefined endpoints return :undefined.

Returns

  • A unit atom (:year, :month, :day, :hour, :minute, :second, …), or :undefined for 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

shorter_than?(interval, d)

@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

spans_leap_second?(iv)

@spec spans_leap_second?(t()) :: boolean()

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

  • interval is a t/0 with both endpoints present. Unbounded intervals always return false (open-ended to :undefined) and pre-Unix-era intervals pre-1972 return false (IERS leap seconds started in 1972).

Returns

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

to(interval)

@spec to(t()) :: Tempo.t() | :undefined

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

  • interval is a t/0.

Returns

  • The to endpoint as a Tempo.t/0 or :undefined for 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

within?(a, b)

@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