Skip to content

Tempo version 2.0.0

Choose a tag to compare

@kipcole9 kipcole9 released this 28 Oct 05:26
· 36 commits to main since this release

[v2.0.0] — 2026-10-26

Tempo 2.0 is a new implementation on one struct and nine stated laws. The migration guide takes each change below with its 1.6 form and its 2.0 form.

Breaking changes

  • Tempo.union/3 holds a member both operands hold once (union(a, a) is a), where it held it twice; members that differ in form or metadata stay two.

  • An intersection, a difference and a symmetric difference end a result where the member it comes from ends, so each keeps its member's metadata; Tempo.IntervalSet.coalesce/1 joins them.

  • An interval written to a date runs through that date: ~o"2026-06-15/2026-06-20" is six days and holds the 20th. An end written to a time of day is still a boundary.

  • An interval's end with a time of day is written from its hour: ~o"2026-01-05T09:15/2026-01-05T09:30" is 2026Y1M5DT9H15M/T9H30M, where 1.6 wrote /T30M.

  • Every expression is a %Tempo{} with a :form. %Tempo.Interval{}, %Tempo.IntervalSet{}, %Tempo.Set{} and %Tempo.Range{} are no longer structs; their modules keep their functions.

  • Every function that can fail returns {:ok, value} or {:error, exception}, and every constructor, conversion, parser and set operation has a ! form. shift/3, trunc/2, round/2, to_iso8601/1, to_string/2, from_elixir/2, today/1 and now/1 returned a bare value.

  • A value that names no calendar is in Calendar.ISO, where it was in Calendrical.Gregorian.

  • Tempo runs on Calendrical 2.0: the six Gregorian calendars whose weeks begin on another day are made with Calendrical.Calendar.Shifted, and every calendar question goes through the kernel and the derived API. It requires Calendrical 2.1, Localize 1.4 and Astro 2.9 or later.

  • A day of the week (K) is counted from the day the expression's weeks begin on, everywhere: 5K is a Thursday in the Hebrew calendar and in [u-fw=sun], Friday 6K; Tempo.day_of_week/2 takes :monday for the day as ISO 8601 numbers it.

  • Enum over a plural expression (a set, a recurrence, a value with a list in it) gives its members. Enum.count(~o"R5/2022-01-01/P1M") is 5, where it was 151.

  • Tempo.to_interval/2 of a value carries no :unit: the interval of a day walks its one day. Tempo.walk/3 walks a span at a unit.

  • Tempo.duration/1,2 answers in the unit its ends are written in, where it answered in seconds, and the duration of a set is the time it covers.

  • Tempo.shift/3 of a zoned value counts hours, minutes and seconds as elapsed time, where it added them to the wall clock. A shift by a unit that is none is an error.

  • Tempo.before?/3 and after?/3 hold for two spans that meet. Allen's :precedes and its twelve siblings are in Tempo.Allen.

  • Tempo.today/1 and utc_today/0 are the date with no zone, where they were the zoned day.

  • A floating value beside a zoned one is a Tempo.FrameError in every operation, where some read the floating one as UTC.

  • An IXDTF suffix is written once, after the whole expression. An interval is in one calendar and one zone, and a set is brought to its first member's.

  • :within replaces :bound and keeps every occurrence that overlaps the window, where a recurrence with a start read it as an upper limit.

  • An option a function does not take is an ArgumentError, where it was passed over.

  • An argument that is no Tempo expression is a Tempo.ConversionError with reason :not_an_expression in every public module, returned or raised as the function's other errors are, where most functions raised FunctionClauseError.

  • Tempo.select/3 selects in every period of its base and answers values of the selector's resolution, where it selected in the first period and answered intervals.

  • Tempo.select/3 by a list of selectors or of days answers in the order of time, each span once, whatever the order of the list.

  • Tempo.workdays/2 answers a Tempo.Workdays, and the workday functions are named add_workdays/3, next_workday/2, count_workdays/2 and their like.

  • A week date, an ordinal date and a season are kept as written (~o"2026Y25W2K", ~o"2026Y166O", ~o"2026Y21M"), where they were resolved as they were read. A season has no dates.

  • An end of one number takes the start's last unit: 2026-06-15/20 is the 15th through the 20th, where it read a century.

  • Interpolating a value writes its ISO 8601 text, where it wrote the locale's words. Tempo.to_string/2 gives the words.

  • Tempo.round/2 is to the nearest boundary, half way up, and Tempo.trunc/2 to a week gives the week.

  • Tempo.shift_zone/2 gives the span a value names on the other zone's clock, where it gave the second it starts at.

  • A whole second converts to an Elixir value with a precision of zero, where it had six digits.

  • A recurrence's UNTIL is an inclusive bound, as RFC 5545 has it: an occurrence that starts at the time it names is the last.

  • An RRULE's WKST other than MO is kept only where RFC 5545 says it is significant, as the recurrence's week start, and is read and dropped elsewhere.

  • A numbered weekday in an RRULE beside times of day (BYDAY=2MO;BYHOUR=9,10) is those times on that weekday, as RFC 5545 has it, where a position counted among the times.

  • A bound on work is a refusal and never a part of the answer: Tempo.count_workdays/2 of a century counts every day, and what would hold more than 10,000 values at once is a Tempo.ConversionError.

  • A recurring interval's occurrences are consecutive, as ISO 8601-1 3.1.1.11 defines one. A rule read from an RRULE with a start is read as RFC 5545 reads it: a monthly rule from the 31st passes over the months with no 31st.

  • Twenty-four exception modules are eight, each with a :reason: Tempo.ParseError, InvalidDateError, ZoneError, FrameError, IntervalEndpointsError, ConversionError, Iso8601EncodeError and InvalidCalendarError.

  • Tempo.Network and Tempo.Schedule are in the companion package tempo_network.

Removed

  • The typed parsers (parse_date/2, parse_time/2, parse_datetime/2, parse_interval/2); use Tempo.from_iso8601/2 with :profile.

  • The eleven certainty functions (certainly_before?/2, overlap_certainty/2 and their like); use Tempo.certainly?/3, possibly?/3 and certainty/3.

  • Tempo.anchor/2 and merge/2 (use at/2 or on/2), grounded?/1 (zoned?/1), subset?/3 (within?/3), to_interval_set/2 (to_interval/2), to_rrule/1 (Tempo.RRule.to_string/1), from_date_range/2 (from_elixir/2), extend_resolution/2 (at_resolution/2).

  • beginning_of_day/1, beginning_of_week/1, beginning_of_month/1, end_of_day/1 and end_of_month/1; use Tempo.trunc/2.

  • The duplicates of Tempo's functions on Tempo.Interval, Tempo.Operations, Tempo.Select, Tempo.Format, Tempo.Compare and Tempo.Explain, and the engine modules 1.6 documented.

  • The modifiers of the ~o sigil; write a calendar as a [u-ca=…] suffix.

Added

  • An interval may start or end at 24:00, the end of its day, as ISO 8601-1's Amendment 1 (2022) restores it: 2026-06-15T09/2026-06-15T24 is nine until midnight, written back as 2026Y6M15DT9H/16DT0H. 24:00 alone is an InvalidDateError with the reason :end_of_day.

  • Tempo.from_iso8601/2 takes overflow: :carry to read a value's parts out of range as ISO 8601-2's Amendment 1 (2025) does: 2018Y13M is January 2019 and 2020Y2M59D is 30 March 2020. By default they are an InvalidDateError, as before.

  • Tempo.Duration.canonical/2 writes a duration in ISO 8601-2's canonical form (Amendment 1, 2025), a minute 60 seconds: PT1H-10M is PT50M and P7Y24M is P9Y. Tempo.Duration.compare/3 counts years in months by the calendar, so P7Y24M equals P9Y where it raised.

  • A signed year of five or more digits followed by - is read as an expanded year (+12345-06-15), as ISO 8601-1's Amendment 1 (2022) allows; a signed six-digit year reads as before.

  • Tempo.new/1, Tempo.selection/2 and Tempo.every/2's :select take a day of the week by its name (day_of_week: :friday), numbered in the expression's calendar: 6K in the Hebrew calendar. Tempo.selection/2 takes a :calendar.

  • Tempo.time_zone_database/0 returns the time zone database Tempo asks, so a library built on Tempo reads a zone as Tempo does.

  • tz is a dependency: a named zone ([Europe/Paris]) is read with no configuration, Tempo asking tz where neither :ex_tempo nor :elixir names a time zone database other than Elixir's UTC-only default. A database configured is asked as before.

  • A guide to selection: every part of a selection, written and built with Tempo.selection/1, with positions, nesting, sets, events, rolls, skips and zones.

  • A guide to the design decisions of 2.0: for each, the question, the alternatives, the choice and why.

  • A set of selections, L{1K2I,3K2I}N and Tempo.selection(all: [...]): what each selects, taken together, so BYDAY=2MO,2WE is read and written back.

  • Tempo.Allen: the thirteen relations as predicates, relation/2, inverse/1 and compose/2.

  • Tempo.every/2, Tempo.selection/1 and Tempo.one_of/1 build a recurrence, a selection and a one-of set from parts, so a program never writes text to have it read back.

  • Tempo.every/2 takes :calendar for a recurrence with no start, :years for a domain's even, odd, leap or common years, and :to for one counted back from its end; Tempo.IntervalSet.new/2 takes :except.

  • Tempo.to_calendar/2 converts a recurrence with no end as a rule, read in the other calendar, so one set holds rules of several calendars.

  • Tempo.Builder builds an expression a step at a time, each step an expression: every(:week) |> on(:monday) |> at(hour: 9) |> starting(~o"2026-06-01") |> times(10). See the building expressions guide.

  • Tempo.Builder.uncertain/2, approximate/2, grouped/3 and some/2 mark a value, name a group within it and leave a unit unspecified.

  • Tempo.in_zone/2 takes a fixed offset as IXDTF writes one ("+02:00").

  • Tempo.new/1 takes a group (group: {2, [month: 3]}, 2026Y2G3MU) and :qualifications (%{month: :approximate}, 2026Y6~M).

  • Tempo.Interval.instants/1 gives the instants an interval runs between, its end the instant after its last date, for a store of timestamps.

  • Tempo.members/2 and Tempo.walk/3: the spans of any expression, and any span walked at a unit.

  • Tempo.metadata/1 and put_metadata/2 on every expression.

  • Tempo.RecurrenceSet with keep_when/2 and move_when/2, for a holiday observed on another day.

  • Tempo.Event: named days a calendar computes (Easter, the solar terms), written in a rule as (easter)e.

  • Tempo.Event.Resolver: an application's own computed events, registered under config :ex_tempo, :event_resolvers.

  • A ! form for each constructor, conversion, parser and set operation that had none: to_date!/1, shift_zone!/2, members!/2, complement!/2, Tempo.IntervalSet.coalesce!/2, Tempo.ICal.parse!/2 and the rest.

  • Tempo.compare/3 with by: :start compares where two expressions begin alone, as 1.6's Tempo.Compare.compare_endpoints/2 did.

  • A quarter written 2026Y2q and a half written 2026Y1h, Tempo's lower-case shorthand for 1.6's 2Q and 1H, read as ISO 8601-2's 34M and 40M.

  • A value that asks for more than :max_values_at_once values at once (10,000 by default) is a Tempo.ConversionError with reason :too_many_values.

  • Tempo.Clock.Test.put/1 makes the test clock the calling process's, so it needs no configuration; it raises a Tempo.ClockError before a time is pinned.

  • Tempo.Workdays, with days left out by :except, and Tempo.roll_to_workday/3.

  • Tempo.RRule.to_string/1, Tempo.ICal.parse/2 and parse_file/2, Tempo.JSCalendar.parse/2.

  • Tempo.Cron.parse/2 reads the extensions L, LW, 15W (any day, 31W among them), 5L and 5#2 in its day fields.

  • A rule rolls the days it selects to a weekday: roll: :modified_nearest in Tempo.selection/1, written L31D(modified-nearest)rN. The weekdays are those of the :locale asked in, or of the territory in force; Tempo.roll_to_workday/3 takes :nearest and :modified_nearest too.

  • The day an expression's weeks begin on is written [u-fw=sun], BCP 47's first day of the week, and set with Tempo.to_week_start/2; Tempo.week_start/1 reads it. An RRULE's WKST and JSCalendar's firstDayOfWeek read to it where they change the weeks counted.

  • RFC 7529 is read and written in an RRULE and a JSCalendar rule: RSCALE is the calendar of the recurrence and its occurrences, BYMONTH=5L a leap month, and SKIP what is taken for a day or a month a year lacks. A selection takes skip: :forward, written (forward)s.

  • Tempo.Cron.parse/2 with no :from reads a rule with no start, as Tempo.RRule.parse/2 does; its firings are asked for with :within.

  • Tempo.to_elixir/1 of an interval of two dates is their Date.Range, so Tempo.from_elixir/2 of a range comes back as it went.

  • Tempo.in_territory/2, and the :territory and :locale options of Tempo.from_iso8601/2, Tempo.parse/2 and Tempo.to_interval/2, give a season of 21 to 24 its three months in a hemisphere. With none named it has them in the application's :default_territory or the current locale's.

  • A season of a hemisphere (25 to 32) is kept as it is written and runs from its equinox or solstice to the next, where 1.6 read it into dates. A season is moved by whole years only.

  • Tempo.duration/2 with leap_seconds: true, Tempo.LeapSeconds.spanned/1 and count/1.

  • Tempo.Duration.multiply/2, exact?/1 and to_microseconds/1.

  • ISO 8601-1's alternative duration format (P0002-01-10T22:33:55) is read.

Fixed

  • A day of the month written as zero (2020-03-00) is an InvalidDateError, where the reader raised a FunctionClauseError.

  • An interval's end written with no time shift takes its start's, as ISO 8601-1 §5.5.1 has it: 2018-01-15T12:00+05:00/T13:00 is one hour, where the end was read as UTC and it was six. A start with no shift before an end with one is a Tempo.FrameError.

  • A rule repeating by an interval its calendar's cycle does not divide is walked until its periods come round, where it was ended after one quiet cycle: every seventh month's Friday the 29th of February from March 2025 is 29 February 2504, not none.

  • Tempo.JSCalendar.parse/2 returns an ArgumentError that names why a document cannot be decoded, where it returned the jscalendar library's bare reason.

  • A selection's units are held to the bounds a unit has in any period of its calendar: L32DN, LT25HN and an RRULE's BYMONTH=13 are errors, where they selected nothing; L31DN and a BYSECOND of 60 stay.

  • An intersection of a recurrence with no end and an empty set is empty, where it was a too_many_values error.

  • A year written after a decade or a century (202J2026Y) is a year it names, and one it does not name is an error, as is a decade after a century, where 20C3J did not finish expanding.

  • Tempo.to_interval/2 and Tempo.members/2 place an interval with no year in a :within window, from each start it holds to the next reading of its end (T22/T06 in two days is two night shifts).

  • Tempo.new/1 refuses a margin, an exponent or significant digits on a unit other than a year, as the reader reads them on a year alone.

  • A shift, or an interval's end, given by a duration with a finer part of a second than the value's is written to that part, where it was truncated (…00.5/PT0.25S ended at .7).

  • Tempo.new/1 returns a Tempo.InvalidDateError for a unit given text, a float or a name (hour: "09"), where it built a value the writer could not write.

  • A yearly RRULE that counts a weekday in named months (FREQ=YEARLY;BYMONTH=11;BYDAY=4TH) is written back as it was read, where it became a monthly rule.

  • An hourly rule across an hour a zone's clock shows twice gives each hour that passes, where it searched for ever; the end of a span at the second showing of a reading is written with its shift.

  • A time of day selected on a day a zone's clock goes back is its first showing, as RFC 5545 has it, and the minutes of an hour the clock shows again are found (Chatham's 03:59 on 5 April 2026 was missing).

  • An offset with seconds, as a local mean time has (-07:52:58), keeps them in the shift Tempo makes of it (Z-7H52M58S), where they were dropped.

  • A time shift a critical zone does not have then (2022-07-08T00:14:07+00:00[!Europe/London]) is a Tempo.ZoneError with reason :inconsistent, as RFC 9557 §3.4 requires; without the flag, or with Z, the moment stands.

  • +00:00 is held apart from Z and written back as Z0H0M, where both were read as Z; they are still the same moment.

  • Tempo.to_string/2 and Tempo.to_relative_string/2 return an ArgumentError for an option they do not take, options that are no keyword list or a unit that is not counted in, where they passed it over or raised.

  • Tempo.to_relative_string/2 counts weeks and quarters by the calendar's own (1 July is next quarter from 30 June), and hours, minutes and seconds by those that pass across a change of a zone's clock.

  • Tempo.to_relative_string/2 brings a :from of another calendar to the expression's, where a Hebrew date from a Gregorian one was thousands of years away.

  • A yearly RRULE of every second year or more that counts a weekday in named months (FREQ=YEARLY;INTERVAL=2;BYMONTH=10;BYDAY=-1SU) keeps its interval, where it recurred every year.

  • Tempo.duration/1 of an interval of quarters is three months for each: 2026-33/2026-34 is P6M, where it was P18M.

  • An intersection or a difference of a set written at UTC and one in a named zone writes each end at UTC; an end cut from the zoned operand had no offset, and was a floating value.

  • A zone that is a fixed offset is within a day of UTC, as a written shift is: [+25:00] is refused, where it was read.

  • Tempo.trunc/2, round/2 and at_resolution/2 return an ArgumentError for a unit that is not one, where trunc/2 answered the value unchanged.

  • A value extended to the microsecond, or built with an integer :microsecond, holds {microseconds, 6} and is written, where the writer raised.

  • Tempo.Event.date/3 returns a Tempo.InvalidDateError or a Tempo.ZoneError, where it returned bare reasons.

  • Tempo.workday?/2 and weekend?/2 raise what they cannot answer, as predicates do, where they returned an error.

  • trunc/2, round/2 and at_resolution/2 to a season or traditional month the value is not written in are {:not_written_in, unit}, where they answered the month.

  • Tempo.at_resolution/2 to a unit on another axis is the period of it the value starts in (June at the week is the week of 1 June), where it wrote both units.

  • Tempo.at_resolution/2 extends a weekday or a day of the year through the hour, a year to a quarter, and a century to a decade, where they skipped units or raised.

  • An offset behind UTC by less than an hour (-00:30) is read as behind; it was read as ahead.

  • Tempo.from_elixir/2 reads a DateTime whose time zone is an offset ("-05:00"), is UTC beside an offset, or is no zone of the database by its own offset, and Tempo.to_datetime/1 answers for a value in a fixed-offset zone ([-05:00]). Tempo.parse/2 reads "EST" and "GMT+5" as the shifts they are.

  • An hour or a minute in a zone runs from where the clock comes to it to where the clock leaves it. Where a clock changes by half an hour or at a quarter to (Lord Howe Island, the Chatham Islands) the hours were each sixty minutes and overlapped; Enum.count/1 of such a day counts the hours the clock shows.

  • Tempo.duration/1 of a span of the clock that is not whole units of its resolution has the finer units that say it: an hour a zone's clock half skips is PT30M.

  • An interval with an open end is read as a member of a set ({2026-06-03/..}), as "and later" is written back; it was misread, and raised when asked for its spans.

  • A leap month (5+m, Adar I) is the month it is where a value is given its day; it was read as the month before it. A yearly rule from a start in a lunisolar calendar keeps to the month as it is named, so Passover stays in Nisan in a leap year.

  • A reading a zone's clock skips is refused wherever a value is read, built or placed, and an hour a clock shows twice is two values in a walk.

  • A rule that selects nothing ends, where a counted rule with no occurrence did not return.

  • What needs every member of a set with no last member (a selection over a span with no end) is a Tempo.ConversionError, where some operations did not return; a :within window bounds such a set.

  • A set operation with an operand that has no end is as bounded as it makes its answer: an intersection lies within the other operand, and a union or a difference from the endless operand is a set with no last member. Two operands with no end have a union and need :within for anything else.

  • A predicate with an operand that has no end is answered where the answer follows from the ends: every Monday for ever is not within?/2 June. Two operands with no end need :within.

  • A set has ISO 8601 text and is inspected as it, where Tempo.to_iso8601/1 raised on one.

  • Tempo.IntervalSet.first/1 of a recurrence with no end is its first occurrence, where it raised.

  • Tempo.IntervalSet.new/2 and Tempo.RecurrenceSet.new/2 return a Tempo.ConversionError for a member that is no expression, where they raised.

  • Tempo.Interval.new/1 refuses an interval or a set as an end, where it built an interval with an interval in it.

  • A named zone given where no time zone database is configured is a Tempo.ZoneError with reason :no_database, where it was accepted and reading a time in it raised a MatchError.

  • The occurrences of a recurrence with no rule carry its metadata, as those of one with a rule did.

  • A set whose member is a recurrence with an open start ({R/../P1Y/FL12M25DN}) is read; the reader took the start's .. for a range, so such a set written by Tempo.to_iso8601/1 did not read back.

  • Tempo.every/2 and Tempo.RRule.parse/2 give an :until the calendar and zone of the recurrence's start, as the reader gives a suffix's to every value before it.

  • A recurrence whose start's year is a set (R3/{2026,2028}Y6M15D/P1D) is read; the reader took the set for a domain, so such a recurrence was written and not read.

  • Tempo.RRule.to_string/1 writes the UNTIL of a rule in a zone or at UTC as a UTC time with Z, as RFC 5545 requires; it wrote the local reading with no Z, which read back floating.