A recurrence rule: a subset of the RFC 5545 RRULE, with the same
parsing, serialization, occurrences and descriptions as the browser
module rrule.ts.
A Koyomi.Recurrence struct has:
:freq-:daily,:weekly,:monthlyor:yearly.:interval- the number of periods between two periods with occurrences,1by default.:count- the number of occurrences, the start included, ornil.:until- the last day (aDate) or instant (aDateTimein UTC) that an occurrence may fall on, inclusive, ornil.:by_day- weekdays, as{ordinal, weekday}tuples: the weekday from 1 (Monday) to 7 (Sunday), the ordinalnilfor every such day, or a position in the month or year (1the first,-1the last).:by_month_day- days of the month,1to31, or-1(the last) to-31.:by_month- months,1to12.:by_set_pos- positions in the set of days of each period,1to366or-1to-366.:week_start- the first day of the week,1(Monday, the default) to7. It changes which days fall in a week, so it matters for a:weeklyrule with an interval above one, or with:by_set_pos.
The lists are sorted and without duplicates: the weekdays in ISO order,
then by ordinal; numbers from 1 up, then from -1 down.
The rule text
parse/1 reads the parts FREQ (DAILY, WEEKLY, MONTHLY or
YEARLY), INTERVAL, COUNT, UNTIL (20271231 or
20271231T235959Z), BYDAY (MO, or with an ordinal for a monthly or
yearly rule: 1MO, -1FR), BYMONTHDAY, BYMONTH, BYSETPOS and
WKST, in any order and any case, with or without a leading RRULE:.
Any other part is an error that names it, and so are COUNT with
UNTIL, BYMONTHDAY in a weekly rule, and BYSETPOS without another
BY part.
to_string/1 writes the parts in one order, FREQ, INTERVAL (left
out when 1), BYMONTH, BYMONTHDAY, BYDAY, BYSETPOS, WKST (left
out when Monday), then COUNT or UNTIL, so that one rule has one text.
Occurrences
occurrences/3 follows RFC 5545. The start is DTSTART: it is the
first occurrence, and COUNT counts it. Each BY part expands or limits
the days of a period as the RFC's table says: BYMONTH expands a yearly
rule and limits the others; BYMONTHDAY expands a monthly or yearly
rule and limits a daily one; BYDAY expands a weekly, monthly or yearly
rule, unless BYMONTHDAY is there, and then limits it. An ordinal
counts in the month for a monthly rule, and for a yearly rule with
BYMONTH; in the year otherwise. BYSETPOS then picks from the sorted
days of each period.
A part that the rule leaves out comes from the start: the weekday of a weekly rule, the day of the month of a monthly rule, and the day and month of a yearly one. A monthly rule that starts on the 31st skips the months without one, as the RFC asks.
Descriptions
describe/3 writes a sentence in English, European Portuguese,
Spanish, French, German, Italian, Dutch or Japanese, the same as
describeRule in the browser.
Ecto
Koyomi.Recurrence is also an Ecto.Type, stored as the rule text in a
string column. cast/1 takes a string or a struct; an empty string casts
to nil, and a rule that does not parse gives the changeset its error.
field :repeat, Koyomi.RecurrenceExamples
iex> {:ok, rule} = Koyomi.Recurrence.parse("RRULE:FREQ=WEEKLY;BYDAY=WE,MO;COUNT=4")
iex> to_string(rule)
"FREQ=WEEKLY;BYDAY=MO,WE;COUNT=4"
iex> rule |> Koyomi.Recurrence.occurrences(~D[2027-03-15]) |> Enum.to_list()
[~D[2027-03-15], ~D[2027-03-17], ~D[2027-03-22], ~D[2027-03-24]]
iex> Koyomi.Recurrence.describe(rule)
"Weekly on Monday and Wednesday, 4 times"
Summary
Functions
The occurrences of a rule from a start whose day falls between two
dates, both included, as a list. The options are those of
occurrences/3.
A sentence that describes the rule.
Callback implementation for Ecto.Type.embed_as/1.
Callback implementation for Ecto.Type.equal?/2.
The occurrences of a rule from a start, as a lazy stream of the start's
type: Date, NaiveDateTime or DateTime.
Parses a rule text.
Parses a rule text, or raises ArgumentError.
Writes the rule text, in the canonical order of the parts.
Types
@type freq() :: :daily | :weekly | :monthly | :yearly
@type t() :: %Koyomi.Recurrence{ by_day: [{integer() | nil, weekday()}], by_month: [1..12], by_month_day: [integer()], by_set_pos: [integer()], count: pos_integer() | nil, freq: freq(), interval: pos_integer(), until: Date.t() | DateTime.t() | nil, week_start: weekday() }
@type weekday() :: 1..7
Functions
@spec between( t(), Date.t() | NaiveDateTime.t() | DateTime.t(), Date.t(), Date.t(), keyword() ) :: [ Date.t() | NaiveDateTime.t() | DateTime.t() ]
The occurrences of a rule from a start whose day falls between two
dates, both included, as a list. The options are those of
occurrences/3.
Examples
iex> Koyomi.Recurrence.parse!("FREQ=YEARLY")
...> |> Koyomi.Recurrence.between(~D[2024-02-29], ~D[2025-01-01], ~D[2032-12-31])
[~D[2028-02-29], ~D[2032-02-29]]
A sentence that describes the rule.
The locale is a language tag: "en", "pt" (European Portuguese),
"es", "fr", "de", "it", "nl" or "ja". A region variant
("pt-BR", "en_GB") takes its language, and any other language takes
English.
The options:
:start- the start of the occurrences (aDate,NaiveDateTimeorDateTime), to name the days that the rule takes from it: the weekday of a weekly rule, the day of a monthly one, the date of a yearly one.
Examples
iex> rule = Koyomi.Recurrence.parse!("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=10")
iex> Koyomi.Recurrence.describe(rule)
"Every 2 weeks on Monday and Wednesday, 10 times"
iex> Koyomi.Recurrence.describe(rule, "pt-PT")
"A cada 2 semanas à segunda-feira e à quarta-feira, 10 vezes"
iex> Koyomi.Recurrence.parse!("FREQ=YEARLY;UNTIL=20271231")
...> |> Koyomi.Recurrence.describe("en", start: ~D[2026-03-15])
"Every year on 15 March until 31 December 2027"
Callback implementation for Ecto.Type.embed_as/1.
Callback implementation for Ecto.Type.equal?/2.
Builds a rule from options, with the same checks as parse/1.
The options are the fields of the struct. :by_day also takes bare
weekdays ([1, 3] for Monday and Wednesday), and :until a DateTime
in any time zone, which is shifted to UTC.
Examples
iex> {:ok, rule} = Koyomi.Recurrence.new(freq: :weekly, interval: 2, by_day: [3, 1])
iex> to_string(rule)
"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE"
iex> Koyomi.Recurrence.new(freq: :weekly, by_month_day: [1])
{:error, "BYMONTHDAY cannot be used with FREQ=WEEKLY"}
@spec occurrences(t(), Date.t() | NaiveDateTime.t() | DateTime.t(), keyword()) :: Enumerable.t()
The occurrences of a rule from a start, as a lazy stream of the start's
type: Date, NaiveDateTime or DateTime.
The start is the first occurrence. Each other one has the start's time
of day. For a DateTime, that is the wall clock time in the start's
time zone: a time that the clocks skip moves forward by the length of
the gap, and a time that happens twice takes the earlier instant. The
option :time_zone_database names the database for this
(Calendar.get_time_zone_database/0 by default).
UNTIL is inclusive. A date UNTIL ends on that day, whatever the
time; a UTC UNTIL compares with the instant of a DateTime, with the
wall clock of a NaiveDateTime (taken as UTC), and with the day of a
Date. A rule that stops producing (BYMONTH=2;BYMONTHDAY=30) ends
after 400 years without an occurrence, or at the year 9999.
Examples
iex> Koyomi.Recurrence.parse!("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1;COUNT=3")
...> |> Koyomi.Recurrence.occurrences(~N[2027-01-29 09:30:00])
...> |> Enum.to_list()
[~N[2027-01-29 09:30:00], ~N[2027-02-26 09:30:00], ~N[2027-03-31 09:30:00]]
Parses a rule text.
Examples
iex> {:ok, rule} = Koyomi.Recurrence.parse("FREQ=MONTHLY;BYDAY=-1FR")
iex> rule.by_day
[{-1, 5}]
iex> Koyomi.Recurrence.parse("FREQ=HOURLY")
{:error, "FREQ=HOURLY is not supported"}
iex> Koyomi.Recurrence.parse("FREQ=DAILY;BYHOUR=9")
{:error, "BYHOUR is not supported"}
Parses a rule text, or raises ArgumentError.
Examples
iex> Koyomi.Recurrence.parse!("FREQ=DAILY;INTERVAL=2").interval
2
Writes the rule text, in the canonical order of the parts.
Examples
iex> Koyomi.Recurrence.parse!("byday=fr;freq=monthly;bysetpos=-1;interval=1") |> Koyomi.Recurrence.to_string()
"FREQ=MONTHLY;BYDAY=FR;BYSETPOS=-1"