# `Koyomi.Recurrence`
[🔗](https://github.com/hattoriai/koyomi/blob/v0.2.1/lib/koyomi/recurrence.ex#L1)

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"

# `freq`

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

# `t`

```elixir
@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`

```elixir
@type weekday() :: 1..7
```

# `between`

```elixir
@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`

```elixir
@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`

# `equal?`

# `new`

```elixir
@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`

```elixir
@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`

```elixir
@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!`

```elixir
@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`

```elixir
@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"

---

*Consult [api-reference.md](api-reference.md) for complete listing*
