Tempo.Interval (Tempo v0.5.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.

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 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}

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 greater than :to, 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