Building expressions

Copy Markdown View Source

An expression can be written, as ISO 8601 writes it, or built from its parts. A literal in source code is written with the ~o sigil. A value whose units arrive at runtime (a form, a database row, an API payload) is built with Tempo.new/1. A rule, or any expression said a piece at a time, is built with the steps of Tempo.Builder: each step refines the expression the step before made, so the pipeline reads as the sentence it means.

A program that holds the parts of an expression builds it from them. It never writes the parts into ISO 8601 text to have the text read back.

Setup — required for every example

import Tempo.Sigils
import Tempo.Builder

A sentence, a step at a time

iex> every(:week)
...> |> on(:monday)
...> |> at(hour: 9)
...> |> starting(~o"2026-06-01")
...> |> times(10)
{:ok, ~o"R10/2026Y6M1D/P1W/FL1KT9HN"}

"Every week, on Monday, at nine, starting the 1st of June, ten times."

The result is the expression R10/2026-06-01/P1W/FL1KT9HN reads to, written in Tempo's explicit form, and it is used as any expression is:

iex> {:ok, standups} =
...>   every(:week) |> on(:monday) |> at(hour: 9) |> starting(~o"2026-06-01") |> times(10)
iex> standups |> Tempo.IntervalSet.members() |> Enum.take(3) |> Enum.map(&Tempo.Interval.from/1)
[~o"2026Y6M1DT9H", ~o"2026Y6M8DT9H", ~o"2026Y6M15DT9H"]

Each step is an expression

Every step returns {:ok, expression}, and what it returns is complete: the step before the last is as usable as the last.

iex> every(:week)
{:ok, ~o"R/../P1W"}

iex> every(:week) |> on(:monday)
{:ok, ~o"R/../P1W/FL1KN"}

"Every week." "Every week, on Monday."

A recurrence with no start of its own is placed by a window, so a step part-way through a pipeline can be asked what it holds:

iex> {:ok, mondays} = every(:week) |> on(:monday)
iex> mondays |> Tempo.to_interval!(within: ~o"2026-06") |> Tempo.IntervalSet.count()
5

The order of the steps is free where they say different things. These two pipelines build one expression:

iex> every(:week) |> on(:monday) |> at(hour: 9)
{:ok, ~o"R/../P1W/FL1KT9HN"}

iex> every(:week) |> at(hour: 9) |> on(:monday)
{:ok, ~o"R/../P1W/FL1KT9HN"}

What repeats

every/1 takes a unit, a duration, or a duration's units:

iex> every(:month)
{:ok, ~o"R/../P1M"}

iex> every(week: 2)
{:ok, ~o"R/../P2W"}

iex> every(~o"PT90M")
{:ok, ~o"R/../PT90M"}

Choosing days

on/3 selects days: a weekday by its name, several of them, or the parts of a selection.

iex> every(:week) |> on([:monday, :wednesday, :friday])
{:ok, ~o"R/../P1W/FL{1,3,5}KN"}

iex> every(:year) |> on(month: 12, day: 25)
{:ok, ~o"R/../P1Y/FL12M25DN"}

iex> every(:month) |> on(day: -1)
{:ok, ~o"R/../P1M/FL-1DN"}

iex> every(:year) |> on(event: "easter")
{:ok, ~o"R/../P1Y/FL(easter)eN"}

"Every week, on Monday, Wednesday and Friday." "Every year, on the 25th of December." "Every month, on its last day." "Every year, on Easter Sunday."

Its :nth option says which of the days in each repeat: the second, or the last.

iex> every(:month) |> on(:monday, nth: 2)
{:ok, ~o"R/../P1M/FL1K2IN"}

iex> every(:month) |> on(:friday, nth: -1)
{:ok, ~o"R/../P1M/FL5K-1IN"}

"Every month, on its second Monday." "Every month, on its last Friday."

Weekdays here are named days, Monday to Sunday. Which days a territory works is its own, and Tempo.workdays/1 knows it.

A selection made beforehand, with Tempo.selection/1, is given as it is. This one is the Friday before Easter:

iex> {:ok, good_friday} =
...>   Tempo.selection(selection: [event: "easter"], window: ~o"-P7D", day_of_week: 5, instance: -1)
iex> {:ok, every_good_friday} = every(:year) |> on(good_friday)
iex> every_good_friday |> Tempo.to_interval!(within: ~o"2026") |> Tempo.IntervalSet.map(&Tempo.Interval.from/1)
[~o"2026Y4M3D"]

A day can be counted from another the expression already selects. :within gives the window it is counted in, forward or, with a negative length, back:

iex> every(:year) |> on(event: "easter") |> on(:sunday, nth: -1, within: [day: 50])
{:ok, ~o"R/../P1Y/FLL(easter)eN/P50D7K-1IN"}

iex> every(:year) |> on(event: "easter") |> on(:friday, nth: 1, within: [day: -3])
{:ok, ~o"R/../P1Y/FLL(easter)eN/P-3D5K1IN"}

"Pentecost, the last Sunday in the fifty days from Easter." "Good Friday, the Friday in the three days before it."

It is how a holiday falling on a weekend is moved. Christmas on a Sunday, kept on the Monday in the eight days from it:

iex> every(:year) |> on(month: 12, day: 25) |> on(:sunday) |> on(:monday, nth: -1, within: [day: 8])
{:ok, ~o"R/../P1Y/FLL12M25D7KN/P8D1K-1IN"}

lasting/2 makes each day selected the start of a stretch: a holiday of several days.

iex> every(:year) |> on(month: 8, day: 16) |> lasting(day: 2)
{:ok, ~o"R/../P1Y/FLL8M16DN/P2DN"}

Times of day

at/2 selects a time of day, given as its parts or written as a value:

iex> every(:day) |> at(hour: 9, minute: 30)
{:ok, ~o"R/../P1D/FLT9H30MN"}

iex> every(:day) |> at(~o"T17:00")
{:ok, ~o"R/../P1D/FLT17H0MN"}

A time given to a day counted by its position is a time on that day, so the second Monday at nine is nine o'clock of each second Monday:

iex> every(:month) |> on(:monday, nth: 2) |> at(hour: 9)
{:ok, ~o"R/../P1M/FLL1K2INT9HN"}

What a value says of itself

uncertain/2 and approximate/2 mark a value as ISO 8601-2's ? and ~ do: the whole of it, or the units named. Both together are %.

iex> uncertain(~o"2026-06-15")
{:ok, ~o"2026Y6M15D?"}

iex> ~o"2026-06-15" |> uncertain(:month) |> approximate(:day)
{:ok, ~o"2026Y6?M15~D"}

"The 15th of June 2026, perhaps." "The 15th of June 2026: the month uncertain, the day approximate."

An interval's marks are on its ends, so each end is marked before the two are joined:

iex> {:ok, from} = uncertain(~o"1984")
iex> {:ok, to} = approximate(~o"2004")
iex> until(from, to)
{:ok, ~o"1984Y?/2004Y~"}

grouped/3 names the nth group of a length within a value, and some/2 a unit the value leaves unspecified:

iex> grouped(~o"2026", 2, month: 3)
{:ok, ~o"2026Y2G3MU"}

iex> some(~o"2026-06", :day)
{:ok, ~o"2026Y6MX*D"}

"The second three months of 2026." "Some day of June 2026."

A mask (202X), a margin (2026±2Y) and significant digits (1950S2Y) are rarer, and Tempo.new/1 builds them from what each unit holds.

Where it begins and ends

starting/2 gives a recurrence its first repeat, and until/2 its last; times/2 counts its occurrences instead, and ending/2 counts them back from where they end.

iex> every(:day) |> starting(~o"2026-06-01") |> until(~o"2026-06-30")
{:ok, ~o"R/2026Y6M1D/P1D/2026Y6M30D"}

iex> every(:month) |> ending(~o"2026-06-01") |> times(5)
{:ok, ~o"R5/P1M/2026Y6M1D"}

"Every day from the 1st of June until the 30th." "Five months, the last ending on the 1st of June."

A recurrence starts once and ends once: a second start, or a count beside an until, is refused rather than either one kept.

until/2 given a value is the interval from it through the value it runs until:

iex> until(~o"2026-06-01", ~o"2026-06-12")
{:ok, ~o"2026Y6M1D/12D"}

Its frame

in_zone/2 places an expression in a time zone, or at a fixed offset, as a suffix written after it does:

iex> every(:week) |> on(:monday) |> at(hour: 9) |> in_zone("Europe/Paris")
{:ok, ~o"R/../P1W/FL1KT9HN[Europe/Paris]"}

A start given after the zone is read on its clock, so the order of the two does not matter:

iex> every(:week) |> in_zone("Europe/Paris") |> starting(~o"2026-06-01T09:00")
{:ok, ~o"R/2026Y6M1DT9H0M/P1W[Europe/Paris]"}

in_calendar/2 counts a recurrence with no start in another calendar. A start given to it after is brought to that calendar, and a start given to a recurrence whose calendar is still the default gives it its own:

iex> every(:year) |> on(month: 7, day: 1) |> in_calendar(Calendrical.Hebrew)
{:ok, ~o"R/../P1Y/FL7M1DN[u-ca=hebrew]"}

iex> every(:year) |> in_calendar(Calendrical.Hebrew) |> starting(~o"2026-06-01")
{:ok, ~o"R/5786Y9M16D/P1Y[u-ca=hebrew]"}

"Every year, on the first of Tishri." "Every year from the 1st of June 2026, which is the 16th of Sivan 5786."

weeks_beginning/2 begins its weeks on another day, as an RRULE's WKST does. A day of the week is numbered from the day its week begins on, so in weeks that begin on Sunday a Tuesday is day 3 and a Thursday day 5, whichever step comes first:

iex> every(week: 2) |> on([:tuesday, :thursday]) |> weeks_beginning(:sunday)
{:ok, ~o"R/../P2W/FL{3,5}KN[u-fw=sun]"}

The years it repeats in

in_years/3 gives a recurrence the years it repeats in: a year, a range of them, the years from one on or up to one, or a list of those, with years left out:

iex> every(:year) |> on(month: 9, day: 15) |> in_years(from: 2005)
{:ok, ~o"R/{2005Y/..}/P1Y/FL9M15DN"}

iex> every(:year) |> on(month: 4, day: 29) |> in_years(1949..1988)
{:ok, ~o"R/{1949Y..1988Y}/P1Y/FL4M29DN"}

iex> every(:year) |> on(month: 3, day: 1) |> in_years(through: 2016, from: 2018)
{:ok, ~o"R/{../2016Y,2018Y/..}/P1Y/FL3M1DN"}

iex> every(:year) |> on(month: 6, day: 19) |> in_years([from: 1961], except: 2011)
{:ok, ~o"R/{1961Y/..,^2011Y}/P1Y/FL6M19DN"}

"Every 15th of September from 2005." "Every 29th of April from 1949 to 1988." "Every 1st of March up to 2016, and from 2018." "Every 19th of June from 1961, but not in 2011."

Or it keeps the years that are :even, :odd, :leap or :common, of every year or of the years it was given:

iex> every(:year) |> on(month: 2, day: 29) |> in_years(:leap)
{:ok, ~o"R/..l/P1Y/FL2M29DN"}

The years are of the recurrence's calendar:

iex> every(:year) |> on(traditional_month: 2, day: 12) |> in_calendar(Calendrical.Hebrew) |> in_years(from: 5758)
{:ok, ~o"R/{5758Y/..}/P1Y/FL2m12DN[u-ca=hebrew]"}

Refining a value

The steps refine a value as they refine a recurrence. A month, on Mondays, at nine, is the Mondays of that month at nine:

iex> {:ok, june_mondays} = ~o"2026-06" |> on(:monday) |> at(hour: 9)
iex> june_mondays
~o"2026Y6ML1KT9HN"
iex> june_mondays |> Tempo.to_interval!() |> Tempo.IntervalSet.map(&Tempo.day/1)
[1, 8, 15, 22, 29]

"The Mondays of June 2026, at nine: the 1st, 8th, 15th, 22nd and 29th."

A set's members are each refined:

iex> on(~o"{2026-06,2026-08}", :monday)
{:ok, ~o"{2026Y6ML1KN,2026Y8ML1KN}"}

An interval is a stretch of time rather than a period to select in; Tempo.select/3 selects in one.

Several together

all_of/1 makes a set of what several pipelines build: a holiday and the day it is moved to.

iex> all_of([
...>   every(:year) |> on(month: 12, day: 25, day_of_week: 1..6),
...>   every(:year) |> on(month: 12, day: 25) |> on(:sunday) |> on(:monday, nth: -1, within: [day: 8])
...> ])
{:ok, ~o"{R/../P1Y/FL12M25D{1..6}KN,R/../P1Y/FLL12M25D7KN/P8D1K-1IN}"}

"Christmas, on the 25th of December if it is not a Sunday, and on the Monday after if it is."

Leaving out, and carrying with it

except/2 leaves values out, as a set of the expression and its exclusions:

iex> every(:week) |> starting(~o"2026-06-01") |> except(~o"2026-06-08")
{:ok, ~o"{R/2026Y6M1D/P1W,^2026Y6M8D}"}

"Every week from the 1st of June, but not the 8th."

with_metadata/2 gives the expression a map of the caller's, which each occurrence carries:

iex> {:ok, standup} =
...>   every(:day) |> starting(~o"2026-06-01") |> times(2) |> with_metadata(%{title: "Standup"})
iex> standup |> Tempo.IntervalSet.members() |> Enum.map(&Tempo.metadata/1)
[%{title: "Standup"}, %{title: "Standup"}]

When a step cannot be built

A step that cannot be built returns {:error, exception}, and every step after it passes the error on untouched, so a pipeline answers with the first thing that was wrong:

iex> {:error, %ArgumentError{message: message}} =
...>   every(:week) |> on(:monday) |> on(:friday) |> at(hour: 9)
iex> message
":day_of_week is already selected (1)"

Monday and Friday are given together, on([:monday, :friday]); given one after the other, the second would say the first was wrong.

From data

A program whose parts arrive at runtime builds each from them: Tempo.new/1 for a value, and the steps for the rest. A meeting booked in a form, its weekday and hour chosen from lists and its first date from a calendar control:

iex> chosen = %{weekday: :thursday, hour: 14, first: ~D[2026-06-04], weeks: 6}
iex> {:ok, first} = Tempo.from_elixir(chosen.first)
iex> every(:week)
...> |> on(chosen.weekday)
...> |> at(hour: chosen.hour)
...> |> starting(first)
...> |> times(chosen.weeks)
{:ok, ~o"R6/2026Y6M4D/P1W/FL4KT14HN"}
  • Selection — every part a selection has, each written form beside the keyword list that builds it.

  • Scheduling — recurrences, free and busy time.

  • ISO 8601 conformance — the written forms these build.