Koyomi.Recurrence (Koyomi v0.2.1)

Copy Markdown View Source

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, :monthly or :yearly.
  • :interval - the number of periods between two periods with occurrences, 1 by default.
  • :count - the number of occurrences, the start included, or nil.
  • :until - the last day (a Date) or instant (a DateTime in UTC) that an occurrence may fall on, inclusive, or nil.
  • :by_day - weekdays, as {ordinal, weekday} tuples: the weekday from 1 (Monday) to 7 (Sunday), the ordinal nil for every such day, or a position in the month or year (1 the first, -1 the last).
  • :by_month_day - days of the month, 1 to 31, or -1 (the last) to -31.
  • :by_month - months, 1 to 12.
  • :by_set_pos - positions in the set of days of each period, 1 to 366 or -1 to -366.
  • :week_start - the first day of the week, 1 (Monday, the default) to 7. It changes which days fall in a week, so it matters for a :weekly rule 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.Recurrence

Examples

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.

Builds a rule from options, with the same checks as parse/1.

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

freq()

@type freq() :: :daily | :weekly | :monthly | :yearly

t()

@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()
}

weekday()

@type weekday() :: 1..7

Functions

between(rule, start, from, to, opts \\ [])

@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]]

describe(rule, locale \\ "en", opts \\ [])

@spec describe(t(), String.t(), keyword()) :: String.t()

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 (a Date, NaiveDateTime or DateTime), 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"

embed_as(_)

Callback implementation for Ecto.Type.embed_as/1.

equal?(term1, term2)

Callback implementation for Ecto.Type.equal?/2.

new(opts)

@spec new(keyword()) :: {:ok, t()} | {:error, String.t()}

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"}

occurrences(rule, start, opts \\ [])

@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]]

parse(text)

@spec parse(String.t()) :: {:ok, t()} | {:error, String.t()}

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"}

parse!(text)

@spec parse!(String.t()) :: t()

Parses a rule text, or raises ArgumentError.

Examples

iex> Koyomi.Recurrence.parse!("FREQ=DAILY;INTERVAL=2").interval
2

to_string(rule)

@spec to_string(t()) :: String.t()

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"