Repository navigation
Tempo version 2.0.0
[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/3holds a member both operands hold once (union(a, a)isa), 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/1joins 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"is2026Y1M5DT9H15M/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/1andnow/1returned a bare value. -
A value that names no calendar is in
Calendar.ISO, where it was inCalendrical.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:5Kis a Thursday in the Hebrew calendar and in[u-fw=sun], Friday6K;Tempo.day_of_week/2takes:mondayfor the day as ISO 8601 numbers it. -
Enumover 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/2of a value carries no:unit: the interval of a day walks its one day.Tempo.walk/3walks a span at a unit. -
Tempo.duration/1,2answers 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/3of 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?/3andafter?/3hold for two spans that meet. Allen's:precedesand its twelve siblings are inTempo.Allen. -
Tempo.today/1andutc_today/0are the date with no zone, where they were the zoned day. -
A floating value beside a zoned one is a
Tempo.FrameErrorin 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.
-
:withinreplaces:boundand 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.ConversionErrorwith reason:not_an_expressionin every public module, returned or raised as the function's other errors are, where most functions raisedFunctionClauseError. -
Tempo.select/3selects 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/3by a list of selectors or of days answers in the order of time, each span once, whatever the order of the list. -
Tempo.workdays/2answers aTempo.Workdays, and the workday functions are namedadd_workdays/3,next_workday/2,count_workdays/2and 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/20is 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/2gives the words. -
Tempo.round/2is to the nearest boundary, half way up, andTempo.trunc/2to a week gives the week. -
Tempo.shift_zone/2gives 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
UNTILis an inclusive bound, as RFC 5545 has it: an occurrence that starts at the time it names is the last. -
An RRULE's
WKSTother thanMOis 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/2of a century counts every day, and what would hold more than 10,000 values at once is aTempo.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,Iso8601EncodeErrorandInvalidCalendarError. -
Tempo.NetworkandTempo.Scheduleare in the companion packagetempo_network.
Removed
-
The typed parsers (
parse_date/2,parse_time/2,parse_datetime/2,parse_interval/2); useTempo.from_iso8601/2with:profile. -
The eleven certainty functions (
certainly_before?/2,overlap_certainty/2and their like); useTempo.certainly?/3,possibly?/3andcertainty/3. -
Tempo.anchor/2andmerge/2(useat/2oron/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/1andend_of_month/1; useTempo.trunc/2. -
The duplicates of
Tempo's functions onTempo.Interval,Tempo.Operations,Tempo.Select,Tempo.Format,Tempo.CompareandTempo.Explain, and the engine modules 1.6 documented. -
The modifiers of the
~osigil; 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-15T24is nine until midnight, written back as2026Y6M15DT9H/16DT0H. 24:00 alone is anInvalidDateErrorwith the reason:end_of_day. -
Tempo.from_iso8601/2takesoverflow: :carryto read a value's parts out of range as ISO 8601-2's Amendment 1 (2025) does:2018Y13Mis January 2019 and2020Y2M59Dis 30 March 2020. By default they are anInvalidDateError, as before. -
Tempo.Duration.canonical/2writes a duration in ISO 8601-2's canonical form (Amendment 1, 2025), a minute 60 seconds:PT1H-10MisPT50MandP7Y24MisP9Y.Tempo.Duration.compare/3counts years in months by the calendar, soP7Y24MequalsP9Ywhere 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/2andTempo.every/2's:selecttake a day of the week by its name (day_of_week: :friday), numbered in the expression's calendar:6Kin the Hebrew calendar.Tempo.selection/2takes a:calendar. -
Tempo.time_zone_database/0returns the time zone database Tempo asks, so a library built on Tempo reads a zone as Tempo does. -
tzis a dependency: a named zone ([Europe/Paris]) is read with no configuration, Tempo askingtzwhere neither:ex_temponor:elixirnames 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}NandTempo.selection(all: [...]): what each selects, taken together, soBYDAY=2MO,2WEis read and written back. -
Tempo.Allen: the thirteen relations as predicates,relation/2,inverse/1andcompose/2. -
Tempo.every/2,Tempo.selection/1andTempo.one_of/1build a recurrence, a selection and a one-of set from parts, so a program never writes text to have it read back. -
Tempo.every/2takes:calendarfor a recurrence with no start,:yearsfor a domain's even, odd, leap or common years, and:tofor one counted back from its end;Tempo.IntervalSet.new/2takes:except. -
Tempo.to_calendar/2converts a recurrence with no end as a rule, read in the other calendar, so one set holds rules of several calendars. -
Tempo.Builderbuilds 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/3andsome/2mark a value, name a group within it and leave a unit unspecified. -
Tempo.in_zone/2takes a fixed offset as IXDTF writes one ("+02:00"). -
Tempo.new/1takes a group (group: {2, [month: 3]},2026Y2G3MU) and:qualifications(%{month: :approximate},2026Y6~M). -
Tempo.Interval.instants/1gives the instants an interval runs between, its end the instant after its last date, for a store of timestamps. -
Tempo.members/2andTempo.walk/3: the spans of any expression, and any span walked at a unit. -
Tempo.metadata/1andput_metadata/2on every expression. -
Tempo.RecurrenceSetwithkeep_when/2andmove_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 underconfig :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!/2and the rest. -
Tempo.compare/3withby: :startcompares where two expressions begin alone, as 1.6'sTempo.Compare.compare_endpoints/2did. -
A quarter written
2026Y2qand a half written2026Y1h, Tempo's lower-case shorthand for 1.6's2Qand1H, read as ISO 8601-2's34Mand40M. -
A value that asks for more than
:max_values_at_oncevalues at once (10,000 by default) is aTempo.ConversionErrorwith reason:too_many_values. -
Tempo.Clock.Test.put/1makes the test clock the calling process's, so it needs no configuration; it raises aTempo.ClockErrorbefore a time is pinned. -
Tempo.Workdays, with days left out by:except, andTempo.roll_to_workday/3. -
Tempo.RRule.to_string/1,Tempo.ICal.parse/2andparse_file/2,Tempo.JSCalendar.parse/2. -
Tempo.Cron.parse/2reads the extensionsL,LW,15W(any day,31Wamong them),5Land5#2in its day fields. -
A rule rolls the days it selects to a weekday:
roll: :modified_nearestinTempo.selection/1, writtenL31D(modified-nearest)rN. The weekdays are those of the:localeasked in, or of the territory in force;Tempo.roll_to_workday/3takes:nearestand:modified_nearesttoo. -
The day an expression's weeks begin on is written
[u-fw=sun], BCP 47's first day of the week, and set withTempo.to_week_start/2;Tempo.week_start/1reads it. An RRULE'sWKSTand JSCalendar'sfirstDayOfWeekread to it where they change the weeks counted. -
RFC 7529 is read and written in an RRULE and a JSCalendar rule:
RSCALEis the calendar of the recurrence and its occurrences,BYMONTH=5La leap month, andSKIPwhat is taken for a day or a month a year lacks. A selection takesskip: :forward, written(forward)s. -
Tempo.Cron.parse/2with no:fromreads a rule with no start, asTempo.RRule.parse/2does; its firings are asked for with:within. -
Tempo.to_elixir/1of an interval of two dates is theirDate.Range, soTempo.from_elixir/2of a range comes back as it went. -
Tempo.in_territory/2, and the:territoryand:localeoptions ofTempo.from_iso8601/2,Tempo.parse/2andTempo.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_territoryor 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/2withleap_seconds: true,Tempo.LeapSeconds.spanned/1andcount/1. -
Tempo.Duration.multiply/2,exact?/1andto_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 anInvalidDateError, where the reader raised aFunctionClauseError. -
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:00is one hour, where the end was read as UTC and it was six. A start with no shift before an end with one is aTempo.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/2returns anArgumentErrorthat names why a document cannot be decoded, where it returned thejscalendarlibrary's bare reason. -
A selection's units are held to the bounds a unit has in any period of its calendar:
L32DN,LT25HNand an RRULE'sBYMONTH=13are errors, where they selected nothing;L31DNand aBYSECONDof 60 stay. -
An intersection of a recurrence with no end and an empty set is empty, where it was a
too_many_valueserror. -
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, where20C3Jdid not finish expanding. -
Tempo.to_interval/2andTempo.members/2place an interval with no year in a:withinwindow, from each start it holds to the next reading of its end (T22/T06in two days is two night shifts). -
Tempo.new/1refuses 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.25Sended at.7). -
Tempo.new/1returns aTempo.InvalidDateErrorfor 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 aTempo.ZoneErrorwith reason:inconsistent, as RFC 9557 §3.4 requires; without the flag, or withZ, the moment stands. -
+00:00is held apart fromZand written back asZ0H0M, where both were read asZ; they are still the same moment. -
Tempo.to_string/2andTempo.to_relative_string/2return anArgumentErrorfor 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/2counts 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/2brings a:fromof 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/1of an interval of quarters is three months for each:2026-33/2026-34isP6M, where it wasP18M. -
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/2andat_resolution/2return anArgumentErrorfor a unit that is not one, wheretrunc/2answered 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/3returns aTempo.InvalidDateErroror aTempo.ZoneError, where it returned bare reasons. -
Tempo.workday?/2andweekend?/2raise what they cannot answer, as predicates do, where they returned an error. -
trunc/2,round/2andat_resolution/2to a season or traditional month the value is not written in are{:not_written_in, unit}, where they answered the month. -
Tempo.at_resolution/2to 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/2extends 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/2reads aDateTimewhose time zone is an offset ("-05:00"), is UTC beside an offset, or is no zone of the database by its own offset, andTempo.to_datetime/1answers for a value in a fixed-offset zone ([-05:00]).Tempo.parse/2reads "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/1of such a day counts the hours the clock shows. -
Tempo.duration/1of 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 isPT30M. -
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:withinwindow 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
:withinfor 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?/2June. Two operands with no end need:within. -
A set has ISO 8601 text and is inspected as it, where
Tempo.to_iso8601/1raised on one. -
Tempo.IntervalSet.first/1of a recurrence with no end is its first occurrence, where it raised. -
Tempo.IntervalSet.new/2andTempo.RecurrenceSet.new/2return aTempo.ConversionErrorfor a member that is no expression, where they raised. -
Tempo.Interval.new/1refuses 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.ZoneErrorwith reason:no_database, where it was accepted and reading a time in it raised aMatchError. -
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 byTempo.to_iso8601/1did not read back. -
Tempo.every/2andTempo.RRule.parse/2give an:untilthe 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/1writes theUNTILof a rule in a zone or at UTC as a UTC time withZ, as RFC 5545 requires; it wrote the local reading with noZ, which read back floating.