RFC 5545 RRULE Conformance

Copy Markdown View Source

Tempo reads the recurrence rule of RFC 5545 §3.3.10 into a recurrence — the same %Tempo{} an ISO 8601-2 repeating interval reads to — which is walked, listed within a window, and combined with the rest of Tempo's set algebra. This guide says precisely what is read, how each part is taken, and what is not.

Tempo.RRule.parse/2 is Tempo's own reader of the rule's text. For whole iCalendar files, with their events, RDATEs and EXDATEs, Tempo hands the file to the ical library and gives what it finds to the same engine, so a rule means the same whether it came from a hand-written string or a calendar feed.

Setup — required for every example

import Tempo.Sigils

What Tempo does with a rule

iex> rule = Tempo.RRule.parse!("FREQ=MONTHLY;BYDAY=-1FR;COUNT=3", from: ~o"2026-01-01")
iex> Enum.map(rule, &Tempo.Interval.from/1)
[~o"2026Y1M30D", ~o"2026Y2M27D", ~o"2026Y3M27D"]

iex> Tempo.RRule.to_string(rule)
{:ok, "FREQ=MONTHLY;COUNT=3;BYDAY=-1FR"}

iex> Tempo.to_iso8601(rule)
{:ok, "R3/2026Y1M1D/P1M/FL5K-1IN"}

"The last Friday of each month, three times."

Part by part

PartReadNotes
FREQ✓SECONDLY, MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY.
INTERVAL✓A positive whole number; 1 where none is written.
COUNT✓Not beside UNTIL. Counts from the rule's start.
UNTIL✓A date or a date and time; the last occurrence may begin on it.
BYSECOND, BYMINUTE, BYHOUR✓
BYDAY✓Weekdays, numbered weekdays (2MO, -1FR, 1MO,3MO), and numbered weekdays of several days (2MO,2WE, 1MO,-1FR), read as a set of selections, L{1K2I,3K2I}N, one for each weekday.
BYMONTHDAY✓From 1, or from -1 back from the end of the month.
BYYEARDAY✓From 1, or from -1 back from the end of the year.
BYWEEKNO✓ISO 8601 weeks; from -1 back from the year's last.
BYMONTH✓
BYSETPOS✓Applied last, to what the other parts select in each period.
WKSTany dayA week begins on Monday unless the rule says otherwise. Where the day is significant (a weekly rule of every second week or more that names weekdays, and a yearly rule that names weeks of the year) the recurrence is of the weeks that begin on it, written [u-fw=sun]; where it is not, it is read and not kept.

Parts coarser and finer than the frequency

RFC 5545 has each BY… part either add occurrences within a period of the frequency or keep some of the periods, by whether the part is finer or coarser than the frequency. Tempo's engine does the same from one rule: the parts are selected in each period, and a period that lies within what a coarser part names is kept whole.

iex> first = fn rule, from, count ->
...>   rule |> Tempo.RRule.parse!(from: from) |> Enum.take(count) |> Enum.map(&Tempo.Interval.from/1)
...> end
iex> first.("FREQ=YEARLY;BYMONTH=3,6", ~o"2026-01-15", 3)
[~o"2026Y3M15D", ~o"2026Y6M15D", ~o"2027Y3M15D"]

iex> first.("FREQ=DAILY;BYMONTH=1", ~o"2026-12-30", 3)
[~o"2027Y1M1D", ~o"2027Y1M2D", ~o"2027Y1M3D"]

iex> first.("FREQ=DAILY;BYDAY=MO", ~o"2026-01-01", 3)
[~o"2026Y1M5D", ~o"2026Y1M12D", ~o"2026Y1M19D"]

"Yearly in March and June, on the start's 15th. Daily in January: every day of it. Daily on Mondays."

Parts that name a day hold at once, so the days selected are those every one of them names:

iex> first.("FREQ=YEARLY;BYMONTH=3,4;BYYEARDAY=80,100", ~o"2026-01-01", 2)
[~o"2026Y3M21D", ~o"2026Y4M10D"]

iex> first.("FREQ=MONTHLY;BYMONTHDAY=13;BYDAY=FR", ~o"1998-02-13", 3)
[~o"1998Y2M13D", ~o"1998Y3M13D", ~o"1998Y11M13D"]

"The 80th and 100th days of the year that are in March or April. A 13th that is a Friday."

A day of the month in a yearly rule with no BYMONTH is that day of every month, the part adding to the year as RFC 5545's table has it:

iex> first.("FREQ=YEARLY;BYMONTHDAY=15", ~o"2026-03-10", 3)
[~o"2026Y3M15D", ~o"2026Y4M15D", ~o"2026Y5M15D"]

What a rule takes from DTSTART

A rule takes from its start what it does not say: a weekly rule with no BYDAY its weekday, a monthly rule with neither BYMONTHDAY nor BYDAY its day of the month, a yearly rule its month and day, a rule that names a week and no day its day of the week. The recurrence read states each, as ISO 8601-2's Annex C.4 has a conversion do, so Tempo.RRule.to_string/1 writes FREQ=MONTHLY from the 31st back as FREQ=MONTHLY;BYMONTHDAY=31, which RFC 5545 reads as the rule it was.

iex> Tempo.RRule.parse!("FREQ=YEARLY;BYWEEKNO=20", from: ~o"2026-05-11") |> Enum.take(2)
[~o"2026Y20W1K/20W1K", ~o"2027Y20W1K/20W1K"]

"The Monday of week 20, the start being a Monday."

A time of day in the start is kept by every occurrence. A rule with no start (:from not given) is read, written and listed within a window, taking nothing from a start: FREQ=YEARLY;BYWEEKNO=24 is week 24 of each year.

How long an occurrence is

An occurrence is as long as its start is precise: a day for a date, which is the length RFC 5545 gives an event with no DTEND, and an hour for a time written to the hour. An event's DTEND or DURATION is given as :duration and sets the length otherwise.

iex> Tempo.RRule.parse!("FREQ=DAILY", from: ~o"2026-06-01") |> Enum.take(1)
[~o"2026Y6M1D/2026Y6M1D"]

iex> Tempo.RRule.parse!("FREQ=DAILY", from: ~o"2026-06-01T10") |> Enum.take(1)
[~o"2026Y6M1DT10H/T11H"]

iex> Tempo.RRule.parse!("FREQ=DAILY", from: ~o"2026-06-01T10:00", duration: ~o"PT45M") |> Enum.take(1)
[~o"2026Y6M1DT10H0M/T10H45M"]

Dates that do not exist

RFC 5545 §3.3.10 has a rule's instances on a date that does not exist ignored, and not counted. A day a month lacks is passed over, and so is a time of day a zone's clock skips, and a day a zone left out:

iex> first.("FREQ=MONTHLY", ~o"2026-01-31", 3)
[~o"2026Y1M31D", ~o"2026Y3M31D", ~o"2026Y5M31D"]

iex> first.("FREQ=DAILY;BYHOUR=2;BYMINUTE=30", ~o"2024-03-09T00:00[America/New_York]", 2)
[~o"2024Y3M9DT2H30M[America/New_York]", ~o"2024Y3M11DT2H30M[America/New_York]"]

iex> first.("FREQ=MONTHLY;BYMONTHDAY=30", ~o"2011-11-30[Pacific/Apia]", 2)
[~o"2011Y11M30D[Pacific/Apia]", ~o"2012Y1M30D[Pacific/Apia]"]

"Monthly on the 31st passes February over. Half past two in New York has no occurrence on the night the clocks go from two to three. The 30th of December 2011 never came in Samoa."

A rule with no end needs a bound to be listed

A rule with no COUNT and no UNTIL has no last occurrence. It is walked as far as it is taken, and listed within a window:

iex> daily = Tempo.RRule.parse!("FREQ=DAILY", from: ~o"2026-06-01")
iex> {:error, %Tempo.ConversionError{reason: :unbounded}} = Tempo.to_interval(daily)
iex> {:ok, june} = Tempo.to_interval(daily, within: ~o"2026-06")
iex> Tempo.IntervalSet.count(june)
30

A window keeps the occurrences that overlap it, and never changes which they are: a COUNT counts from the rule's start, so the first three days of 2000 are none of them in 2026.

iex> long_ago = Tempo.RRule.parse!("FREQ=DAILY;COUNT=3", from: ~o"2000-01-01")
iex> Tempo.to_interval(long_ago, within: ~o"2026")
{:ok, ~o"{}"}

A window with no zone, beside a rule in one, is read on the rule's clock: June is the June of the rule's zone.

Listing answers at most 10,000 occurrences at once (the application's :max_values_at_once), and more than that is a Tempo.ConversionError with the reason :too_many_values, rather than some of them.

Rules far between, and rules with nothing

A rule whose parts leave its occurrences far apart for its frequency is not asked of every period. Where a period selects nothing, the walk goes on from the next value the parts name:

iex> first.("FREQ=SECONDLY;BYHOUR=9", ~o"2026-01-01T00:00:00", 2)
[~o"2026Y1M1DT9H0M0S", ~o"2026Y1M1DT9H0M1S"]

iex> first.("FREQ=DAILY;BYMONTH=2;BYMONTHDAY=29;BYDAY=MO", ~o"2026-01-01", 2)
[~o"2044Y2M29D", ~o"2072Y2M29D"]

"Every second of the nine o'clock hour, reached without counting the seconds before it. Each 29th of February that is a Monday, decades apart."

A rule that has no occurrence has none, and its walk ends:

iex> first.("FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=31;COUNT=2", ~o"2026-01-01", 2)
[]

iex> first.("FREQ=DAILY;INTERVAL=7;BYDAY=MO", ~o"2026-01-06", 1)
[]

iex> first.("FREQ=DAILY;BYSETPOS=2", ~o"2026-01-01", 1)
[]

"The 31st of April. Every seventh day from a Tuesday that is a Monday. The second of a day's one."

A rule is given up once it has selected nothing in a whole cycle of its calendar's years — four hundred in the Gregorian calendar, in which every date and weekday it could name comes round — or in a thousand years in a calendar with no such cycle. A rule that names times of day alone is given up after two years. A rule that repeats by an interval its cycle does not divide selects in other months in each cycle than in the last, and is walked until its periods come round:

iex> first.("FREQ=MONTHLY;INTERVAL=7;BYMONTH=2;BYMONTHDAY=29;BYDAY=FR", ~o"2025-03-01", 1)
[~o"2504Y2M29D"]

"Every seventh month's Friday the 29th of February: none for more than a cycle of four hundred years, and then one in 2504."

Other calendars

RFC 5545 is Gregorian. A rule is counted in the calendar of its start, so the same text from a start in another calendar steps by that calendar's months and years:

iex> passover = Tempo.RRule.parse!("FREQ=YEARLY;COUNT=3", from: ~o"5786-07-15[u-ca=hebrew]")
iex> Enum.map(passover, fn occurrence ->
...>   Tempo.to_calendar!(Tempo.Interval.from(occurrence), Calendrical.Gregorian)
...> end)
[~o"2026Y4M2D[u-ca=gregorian]", ~o"2027Y4M22D[u-ca=gregorian]", ~o"2028Y4M11D[u-ca=gregorian]"]

"Every year on the 15th of Nisan: the 2nd of April 2026, the 22nd of April 2027, the 11th of April 2028."

A yearly rule takes the month of its start as the calendar names it, so it keeps to Nisan in a year that has a leap month before it, as 5787 has.

RFC 7529 names the calendar in the rule itself, with RSCALE, and says with SKIP what is taken for a day or a month a year lacks. The start is then given in any calendar and the occurrences are in the one named:

iex> anniversary = Tempo.RRule.parse!("RSCALE=HEBREW;FREQ=YEARLY;BYMONTH=5L;BYMONTHDAY=8;SKIP=FORWARD;COUNT=3", from: ~o"2014-02-08")
iex> Tempo.to_iso8601!(anniversary)
"R3/5774Y6M8D/P1Y/FL5+m8D(forward)sN[u-ca=hebrew]"

iex> Enum.map(anniversary, fn occurrence ->
...>   Tempo.to_calendar!(Tempo.Interval.from(occurrence), Calendar.ISO)
...> end)
[~o"2014Y2M8D", ~o"2015Y2M27D", ~o"2016Y2M17D"]

"Every year on the 8th of Adar I, the leap month, and in a year that has none on the 8th of Adar, the month after: the 8th of February 2014, the 27th of February 2015, the 17th of February 2016."

Tempo.RRule.to_string/1 writes a recurrence of another calendar that counts in its months, its years or their days with RSCALE, by the name CLDR gives the calendar, and one that steps by weeks, days or the clock and selects by weekday and time of day as the plain rule it reads as in any calendar. A calendar with no such name (a fiscal calendar) is a Tempo.ConversionError ({:no_rrule, {:calendar, module}}).

What is written, and what is refused

Tempo.RRule.to_string/1 writes a recurrence only as a rule RFC 5545 reads as that recurrence. Each of these is a Tempo.ConversionError whose reason is {:no_rrule, what}:

iex> refused = fn expression ->
...>   {:error, %Tempo.ConversionError{reason: {:no_rrule, what}}} = Tempo.RRule.to_string(expression)
...>   what
...> end
iex> refused.(~o"R/2026-01-01/P1W/FL15DN")
{:part_at, :day, :week}

iex> refused.(~o"R/2026-01-01/P1M/FL20WN")
{:part_at, :week, :month}

iex> refused.(~o"R/2026-01-31/P1M")
:month_end

iex> refused.(~o"R/2026-01-01/P1Y/FL(easter)eN")
{:part, :event}
  • A part RFC 5545 forbids the frequency: a day of the month in a weekly rule, a day of the year in a daily, weekly or monthly one, a week of the year in any but a yearly one, a numbered weekday outside a monthly rule and a yearly rule with no BYWEEKNO, a BYSETPOS with no other part.

  • A recurrence with no rule that lands on a month's last day. R/2026-01-31/P1M goes to the 28th of February where FREQ=MONTHLY from the 31st passes February over, so no one rule says it.

  • A part with no BY…: an event the calendar computes, a traditional month, a selection within the window of another.

  • A cadence of several units, and a recurrence of another calendar, as above.

An occurrence's length is no part of an RRULE and is not written; iCalendar carries it beside the rule.

Not read

Tempo.RRule.parse/2 returns {:error, %ArgumentError{}} that names what it does not read, rather than a rule that is nearly it:

  • EXRULE, which RFC 5545 deprecated in favour of EXDATE.

RDATE and EXDATE are properties of an event rather than parts of a rule. Tempo.ICal.parse/2 adds the one and removes the other, and Tempo.RecurrenceSet holds rules and dates together for the same purpose in code.

Test coverage

test/tempo/rrule_test.exs holds the worked examples of RFC 5545 §3.8.5.3 — Thanksgiving, US election day, Friday the 13th, the last weekday of a month and the rest — each asserted as its occurrences, as the ISO 8601-2 text it writes, and as the rule read back from that text. test/tempo/calendars_test.exs holds events with RDATE and EXDATE read from iCalendar and JSCalendar.

Acknowledgement

Tempo's reading of iCalendar files relies on the excellent ical library. The division of labour is clean: ical reads the wire format, and Tempo makes of what it finds something to walk, relate and combine.