├── .iex.exs ├── test ├── test_helper.exs ├── calendar_recurrence │ ├── calendar_interval_test.exs │ └── rrule_test.exs └── calendar_recurrence_test.exs ├── .formatter.exs ├── CHANGELOG.md ├── lib ├── calendar_recurrence │ ├── calendar_interval.ex │ ├── rrule_parser.ex.exs │ └── rrule.ex └── calendar_recurrence.ex ├── .gitignore ├── mix.exs ├── README.md ├── .github └── workflows │ └── ci.yml └── mix.lock /.iex.exs: -------------------------------------------------------------------------------- 1 | alias CalendarRecurrence.RRULE 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/calendar_recurrence/calendar_interval_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CalendarIntervalCalendarRecurrenceTest do 2 | use ExUnit.Case, async: true 3 | use CalendarInterval 4 | 5 | test "recurrence" do 6 | recurrence = CalendarRecurrence.new(start: ~I"2018-01-01", stop: {:until, ~I"2018-01-03"}) 7 | assert Enum.to_list(recurrence) == [~I"2018-01-01", ~I"2018-01-02", ~I"2018-01-03"] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v0.2.0 (2025-11-12) 4 | 5 | - [CalendarRecurrence.RRULE](https://hexdocs.pm/calendar_recurrence/CalendarRecurrence.RRULE.html) add `String.Chars` protocol (#16) 6 | - [CalendarRecurrence.RRULE](https://hexdocs.pm/calendar_recurrence/CalendarRecurrence.RRULE.html) allow optional UTC suffix in UNTIL rule (#8) 7 | 8 | This is potentially a **breaking change** as before we would coerce the UNTIL rule to a UTC DateTime 9 | 10 | ## v0.1.0 (2025-05-12) 11 | 12 | - Initial release 13 | -------------------------------------------------------------------------------- /lib/calendar_recurrence/calendar_interval.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(CalendarInterval) do 2 | defimpl CalendarRecurrence.T, for: CalendarInterval do 3 | def add(interval, count, _unit) do 4 | :day = interval.precision 5 | CalendarInterval.next(interval, count) 6 | end 7 | 8 | def continue?(interval1, interval2) do 9 | :day = interval1.precision 10 | CalendarInterval.relation(interval1, interval2) in [:preceds, :meets, :equal] 11 | end 12 | 13 | def diff(_, _, _), do: raise("diff/3 not implemented for CalendarInterval") 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | calendar_recurrence-*.tar 24 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule CalendarRecurrence.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.2.0" 5 | @source_url "https://github.com/wojtekmach/calendar_recurrence" 6 | 7 | def project do 8 | [ 9 | app: :calendar_recurrence, 10 | version: @version, 11 | elixir: "~> 1.14", 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | aliases: aliases(), 15 | package: [ 16 | description: "CalendarRecurrence is an Elixir library for working with recurring dates.", 17 | licenses: ["Apache-2.0"], 18 | links: %{ 19 | "GitHub" => @source_url, 20 | "Changelog" => "https://hexdocs.pm/calendar_recurrence/changelog.html" 21 | } 22 | ], 23 | docs: [ 24 | source_url: @source_url, 25 | source_ref: "v#{@version}" 26 | ] 27 | ] 28 | end 29 | 30 | def application do 31 | [ 32 | extra_applications: [:logger] 33 | ] 34 | end 35 | 36 | def cli do 37 | [ 38 | preferred_envs: [ 39 | docs: :docs, 40 | "hex.publish": :docs 41 | ] 42 | ] 43 | end 44 | 45 | defp deps do 46 | [ 47 | {:nimble_parsec, "~> 1.3", only: [:dev, :docs, :test]}, 48 | {:calendar_interval, "~> 0.2.0", optional: true}, 49 | {:ex_doc, "~> 0.31", only: :docs, runtime: false}, 50 | {:zoneinfo, "~> 0.1.8", only: [:test]} 51 | ] 52 | end 53 | 54 | defp aliases do 55 | [ 56 | "compile.rrule": ["nimble_parsec.compile lib/calendar_recurrence/rrule_parser.ex.exs"] 57 | ] 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CalendarRecurrence 2 | 3 | [![CI](https://github.com/wojtekmach/calendar_recurrence/actions/workflows/ci.yml/badge.svg)](https://github.com/wojtekmach/calendar_recurrence/actions/workflows/ci.yml) 4 | 5 | CalendarRecurrence is an Elixir library for working with recurring dates. 6 | 7 | ## Examples 8 | 9 | ```elixir 10 | iex> recurrence = CalendarRecurrence.new(start: ~D[2018-01-01], step: 2) 11 | iex> Enum.take(recurrence, 3) 12 | [~D[2018-01-01], ~D[2018-01-03], ~D[2018-01-05]] 13 | ``` 14 | 15 | CalendarRecurrence additionally includes a RRULE parser: 16 | 17 | ```elixir 18 | iex> alias CalendarRecurrence.RRULE 19 | 20 | iex> RRULE.parse("FREQ=DAILY;COUNT=10") 21 | iex> {:ok, %RRULE{freq: :daily, count: 10}} 22 | ``` 23 | 24 | ```elixir 25 | iex> RRULE.to_recurrence("FREQ=WEEKLY;COUNT=4;BYDAY=MO,WE", ~D[2018-01-01]) |> Enum.to_list() 26 | [~D[2018-01-01], ~D[2018-01-03], ~D[2018-01-08], ~D[2018-01-10]] 27 | ``` 28 | 29 | It also implements the `String.Chars` protocol: 30 | 31 | ```elixir 32 | iex> %RRULE{freq: :daily, bysecond: [5, 10]} |> to_string() 33 | iex> "FREQ=DAILY;BYSECOND=5,10" 34 | ``` 35 | 36 | Currently a small subset of RRULE grammar is implemented, more support coming soon. 37 | 38 | ## Installation 39 | 40 | Add to `mix.exs`: 41 | 42 | ```elixir 43 | defp deps do 44 | [ 45 | {:calendar_recurrence, "~> 0.2.0"} 46 | ] 47 | end 48 | ``` 49 | 50 | ## License 51 | 52 | Copyright 2018 Wojciech Mach 53 | 54 | Licensed under the Apache License, Version 2.0 (the "License"); 55 | you may not use this file except in compliance with the License. 56 | You may obtain a copy of the License at 57 | 58 | http://www.apache.org/licenses/LICENSE-2.0 59 | 60 | Unless required by applicable law or agreed to in writing, software 61 | distributed under the License is distributed on an "AS IS" BASIS, 62 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 63 | See the License for the specific language governing permissions and 64 | limitations under the License. 65 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-24.04 14 | env: 15 | MIX_ENV: test 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | include: 20 | - pair: 21 | elixir: "1.14" 22 | otp: "25.3.2.12" 23 | - pair: 24 | elixir: "1.18" 25 | otp: "27.3.4" 26 | - pair: 27 | elixir: "1.19.2" 28 | otp: "28.1" 29 | lint: lint 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | - uses: erlef/setup-beam@v1 34 | with: 35 | otp-version: ${{ matrix.pair.otp }} 36 | elixir-version: ${{ matrix.pair.elixir }} 37 | version-type: strict 38 | 39 | - name: Cache deps 40 | id: cache-deps 41 | uses: actions/cache@v4 42 | env: 43 | cache-name: cache-elixir-deps 44 | with: 45 | path: deps 46 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 47 | restore-keys: | 48 | ${{ runner.os }}-mix-${{ env.cache-name }}- 49 | 50 | - name: Cache compiled build 51 | id: cache-build 52 | uses: actions/cache@v4 53 | env: 54 | cache-name: cache-compiled-build 55 | with: 56 | path: _build 57 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 58 | restore-keys: | 59 | ${{ runner.os }}-mix-${{ env.cache-name }}- 60 | ${{ runner.os }}-mix- 61 | 62 | - run: mix deps.get --check-locked 63 | 64 | - run: mix format --check-formatted 65 | if: ${{ matrix.lint }} 66 | 67 | - run: mix deps.unlock --check-unused 68 | if: ${{ matrix.lint }} 69 | 70 | - run: mix deps.compile 71 | 72 | - run: mix compile --no-optional-deps --warnings-as-errors 73 | if: ${{ matrix.lint }} 74 | 75 | - name: Run tests 76 | run: mix test 77 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "calendar_interval": {:hex, :calendar_interval, "0.2.0", "2b253b1e37ee1d4344639a3cbfb12abd0e996e4a8181537eb33c3e93fdfaffd9", [:mix], [], "hexpm", "c13d5e0108e61808a38f622987e1c5e881d96d28945213d3efe6dd06c28ba7b0"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 4 | "ex_doc": {:hex, :ex_doc, "0.39.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"}, 5 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 7 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 9 | "zoneinfo": {:hex, :zoneinfo, "0.1.8", "add41f44e6f19d4d16d9eebe3261d6307c58bdbe7cc61ffb7b18cad346b0467f", [:mix], [], "hexpm", "3999755971bbf85f0c8c75a724be560199bb63406660585849f0eb680e2333f7"}, 10 | } 11 | -------------------------------------------------------------------------------- /lib/calendar_recurrence/rrule_parser.ex.exs: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(NimbleParsec) do 2 | defmodule CalendarRecurrence.RRULE.ParserHelpers do 3 | @moduledoc false 4 | 5 | import NimbleParsec 6 | 7 | def part(name, combinator) do 8 | string(name) 9 | |> ignore(string("=")) 10 | |> concat(combinator) 11 | end 12 | 13 | def with_separator(combinator, separator_string) do 14 | separator = separator_string |> string() |> ignore() |> label(separator_string) 15 | concat(separator, combinator) 16 | end 17 | 18 | def non_empty_list(combinator, separator_string \\ ",") do 19 | combinator 20 | |> repeat(with_separator(combinator, separator_string)) 21 | |> wrap() 22 | end 23 | 24 | def any_of(enumerable, fun) when is_function(fun, 1) do 25 | enumerable 26 | |> Enum.map(&replace(fun.(&1), &1)) 27 | |> choice() 28 | end 29 | 30 | def zero_pad(val, count) when val >= 0 do 31 | num = Integer.to_string(val) 32 | :binary.copy("0", count - byte_size(num)) <> num 33 | end 34 | 35 | def zero_pad(val, count) do 36 | "-" <> zero_pad(-val, count) 37 | end 38 | end 39 | end 40 | 41 | defmodule CalendarRecurrence.RRULE.Parser do 42 | @moduledoc false 43 | 44 | # parsec:CalendarRecurrence.RRULE.Parser 45 | import NimbleParsec 46 | import CalendarRecurrence.RRULE.ParserHelpers 47 | 48 | freqs = ~w(SECONDLY MINUTELY HOURLY DAILY WEEKLY MONTHLY YEARLY) 49 | 50 | year = integer(4) 51 | month = any_of(1..12, &string(zero_pad(&1, 2))) 52 | day = any_of(1..31, &string(zero_pad(&1, 2))) 53 | hour = any_of(0..23, &string(zero_pad(&1, 2))) 54 | minute = any_of(0..59, &string(zero_pad(&1, 2))) 55 | second = any_of(0..59, &string(zero_pad(&1, 2))) 56 | 57 | date = 58 | year 59 | |> concat(month) 60 | |> concat(day) 61 | |> label("date") 62 | 63 | time = 64 | hour 65 | |> concat(minute) 66 | |> concat(second) 67 | |> label("time") 68 | 69 | datetime = 70 | date 71 | |> ignore(string("T")) 72 | |> concat(time) 73 | |> optional(string("Z")) 74 | |> label("datetime") 75 | 76 | freq = part("FREQ", any_of(freqs, &string/1)) 77 | 78 | until = part("UNTIL", wrap(choice([datetime, date]))) 79 | 80 | count = part("COUNT", integer(min: 1)) 81 | 82 | interval = part("INTERVAL", integer(min: 1)) 83 | 84 | seconds = any_of(59..0//-1, &string(to_string(&1))) 85 | bysecond = part("BYSECOND", non_empty_list(seconds)) 86 | 87 | minutes = any_of(59..0//-1, &string(to_string(&1))) 88 | byminute = part("BYMINUTE", non_empty_list(minutes)) 89 | 90 | hours = any_of(23..0//-1, &string(to_string(&1))) 91 | byhour = part("BYHOUR", non_empty_list(hours)) 92 | 93 | days = any_of(~w(SU MO TU WE TH FR SA), &string(to_string(&1))) 94 | byday = part("BYDAY", non_empty_list(days)) 95 | 96 | months = any_of(1..12, &string(to_string(&1))) 97 | bymonth = part("BYMONTH", non_empty_list(months)) 98 | 99 | monthdays = 100 | choice( 101 | Enum.map( 102 | Enum.concat(-31..-1, 31..1//-1), 103 | &string(to_string(&1)) 104 | ) 105 | ) 106 | 107 | bymonthday = part("BYMONTHDAY", non_empty_list(monthdays)) 108 | 109 | part = 110 | choice([ 111 | freq, 112 | until, 113 | count, 114 | interval, 115 | bysecond, 116 | byminute, 117 | byhour, 118 | byday, 119 | byday, 120 | bymonth, 121 | bymonthday 122 | ]) 123 | 124 | defparsec( 125 | :parse, 126 | part 127 | |> repeat(with_separator(part, ";")) 128 | |> reduce(:to_map) 129 | ) 130 | 131 | # parsec:CalendarRecurrence.RRULE.Parser 132 | 133 | defp to_map(args) do 134 | args 135 | |> Enum.chunk_every(2) 136 | |> Enum.into(%{}, fn [k, v] -> {word_to_atom(k), cast_value(k, v)} end) 137 | end 138 | 139 | defp word_to_atom(string) do 140 | string |> String.downcase() |> String.to_atom() 141 | end 142 | 143 | defp cast_value("FREQ", value), do: word_to_atom(value) 144 | 145 | defp cast_value("UNTIL", [year, month, day]) do 146 | {:ok, date} = Date.new(year, month, day) 147 | date 148 | end 149 | 150 | defp cast_value("UNTIL", [year, month, day, hour, minute, second]) do 151 | NaiveDateTime.new!(year, month, day, hour, minute, second) 152 | end 153 | 154 | defp cast_value("UNTIL", [year, month, day, hour, minute, second, "Z"]) do 155 | NaiveDateTime.new!(year, month, day, hour, minute, second) 156 | |> DateTime.from_naive!("Etc/UTC") 157 | end 158 | 159 | defp cast_value("BYDAY", days), do: Enum.map(days, &cast_byday/1) 160 | defp cast_value("BYMONTHDAY", days) when is_list(days), do: Enum.map(days, &String.to_integer/1) 161 | defp cast_value("BYMONTHDAY", day), do: [String.to_integer(day)] 162 | 163 | defp cast_value("BYMONTH", months) when is_list(months), do: months 164 | defp cast_value("BYMONTH", month), do: [month] 165 | 166 | defp cast_value(_, value), do: value 167 | 168 | defp cast_byday("SU"), do: 7 169 | defp cast_byday("MO"), do: 1 170 | defp cast_byday("TU"), do: 2 171 | defp cast_byday("WE"), do: 3 172 | defp cast_byday("TH"), do: 4 173 | defp cast_byday("FR"), do: 5 174 | defp cast_byday("SA"), do: 6 175 | end 176 | -------------------------------------------------------------------------------- /lib/calendar_recurrence.ex: -------------------------------------------------------------------------------- 1 | defmodule CalendarRecurrence do 2 | @moduledoc """ 3 | Stream of recurring dates. 4 | 5 | Options: 6 | 7 | * `start` - The start of the recurrence 8 | * `stop` - When to stop the recurrence. Defaults to `:never` 9 | * `unit` - The interval for each recurrence, Defaults to `:day` 10 | * `step` - The count of how many units to apply for each recurrence. Defaults to `1` 11 | 12 | When the `:start` is an Elixir `DateTime` struct with a timezone other than "Etc/UTC" the recurrence will be calculated in that timezone, 13 | so that the wall clock time is stable even when switching between summer and winter time. That means the time will be the same even when 14 | the day has a duration of 23h or 25h. 15 | 16 | ## Examples 17 | 18 | iex> recurrence = CalendarRecurrence.new(start: ~D[2018-01-01]) 19 | iex> Enum.take(recurrence, 3) 20 | [~D[2018-01-01], ~D[2018-01-02], ~D[2018-01-03]] 21 | 22 | iex> recurrence = CalendarRecurrence.new(start: ~N[2018-01-01 12:00:00]) 23 | iex> Enum.take(recurrence, 3) 24 | [~N[2018-01-01 12:00:00], ~N[2018-01-02 12:00:00], ~N[2018-01-03 12:00:00]] 25 | 26 | iex> recurrence = CalendarRecurrence.new(start: ~U[2018-01-01 12:00:00Z]) 27 | iex> Enum.take(recurrence, 3) 28 | [~U[2018-01-01 12:00:00Z], ~U[2018-01-02 12:00:00Z], ~U[2018-01-03 12:00:00Z]] 29 | 30 | iex> recurrence = CalendarRecurrence.new(start: ~U[2018-01-01 12:00:00Z], unit: :hour) 31 | iex> Enum.take(recurrence, 3) 32 | [~U[2018-01-01 12:00:00Z], ~U[2018-01-01 13:00:00Z], ~U[2018-01-01 14:00:00Z]] 33 | 34 | iex> recurrence = CalendarRecurrence.new(start: ~U[2018-01-01 12:00:00Z], unit: :hour, step: 2) 35 | iex> Enum.take(recurrence, 3) 36 | [~U[2018-01-01 12:00:00Z], ~U[2018-01-01 14:00:00Z], ~U[2018-01-01 16:00:00Z]] 37 | 38 | iex> recurrence = CalendarRecurrence.new(start: ~D[2018-01-01], stop: {:count, 3}) 39 | iex> Enum.to_list(recurrence) 40 | [~D[2018-01-01], ~D[2018-01-02], ~D[2018-01-03]] 41 | 42 | iex> recurrence = CalendarRecurrence.new(start: ~D[2018-01-01], stop: {:until, ~D[2018-01-03]}) 43 | iex> Enum.to_list(recurrence) 44 | [~D[2018-01-01], ~D[2018-01-02], ~D[2018-01-03]] 45 | 46 | iex> recurrence = CalendarRecurrence.new(start: ~D[2018-01-01], step: fn _ -> 2 end) 47 | iex> Enum.take(recurrence, 3) 48 | [~D[2018-01-01], ~D[2018-01-03], ~D[2018-01-05]] 49 | """ 50 | 51 | @enforce_keys [:start] 52 | 53 | defstruct start: nil, 54 | step: 1, 55 | stop: :never, 56 | unit: :day 57 | 58 | @type date() :: Date.t() | NaiveDateTime.t() | DateTime.t() | CalendarRecurrence.T.t() 59 | 60 | @type stepper() :: (current :: date() -> pos_integer()) 61 | 62 | @type unit() :: :day | :hour | :minute | System.time_unit() 63 | 64 | @type t() :: %CalendarRecurrence{ 65 | start: date(), 66 | stop: :never | {:until, date()} | {:count, non_neg_integer()}, 67 | step: pos_integer() | stepper(), 68 | unit: unit() 69 | } 70 | 71 | @spec new(keyword()) :: t() 72 | def new(opts) when is_list(opts) do 73 | struct!(__MODULE__, opts) 74 | end 75 | 76 | defimpl Enumerable do 77 | def count(%CalendarRecurrence{stop: {:count, count}}), do: {:ok, count} 78 | 79 | def count(%CalendarRecurrence{start: start, stop: {:until, until}, step: step, unit: unit}) 80 | when is_integer(step), 81 | do: {:ok, round((CalendarRecurrence.T.diff(until, start, unit) + 1) / step)} 82 | 83 | def count(_), do: {:error, __MODULE__} 84 | 85 | def member?(_, _), do: {:error, __MODULE__} 86 | 87 | def reduce(recurrence, acc, fun) do 88 | do_reduce(recurrence.start, 1, recurrence, acc, fun) 89 | end 90 | 91 | def slice(_), do: {:error, __MODULE__} 92 | 93 | defp do_reduce(_start, _count, _recurrence, {:halt, acc}, _fun) do 94 | {:halted, acc} 95 | end 96 | 97 | defp do_reduce(_start, _count, _recurrence, {:suspend, acc}, _fun) do 98 | {:suspended, acc} 99 | end 100 | 101 | defp do_reduce(current, count, recurrence, {:cont, acc}, fun) do 102 | if continue?(current, count, recurrence) do 103 | next = CalendarRecurrence.T.add(current, step(recurrence, current), recurrence.unit) 104 | do_reduce(next, count + 1, recurrence, fun.(current, acc), fun) 105 | else 106 | {:halted, acc} 107 | end 108 | end 109 | 110 | defp step(%CalendarRecurrence{step: step}, _current) when is_integer(step), do: step 111 | 112 | defp step(%CalendarRecurrence{step: stepper}, current) when is_function(stepper, 1), 113 | do: stepper.(current) 114 | 115 | defp continue?(_current, _count, %CalendarRecurrence{stop: :never}), do: true 116 | 117 | defp continue?(_current, count, %CalendarRecurrence{stop: {:count, max}}) when max >= 0, 118 | do: count <= max 119 | 120 | defp continue?(current, _count, %CalendarRecurrence{stop: {:until, date}}), 121 | do: CalendarRecurrence.T.continue?(current, date) 122 | end 123 | end 124 | 125 | defprotocol CalendarRecurrence.T do 126 | def continue?(t1, t2) 127 | 128 | def add(t, count, unit) 129 | 130 | def diff(t1, t2, unit) 131 | end 132 | 133 | defimpl CalendarRecurrence.T, for: Date do 134 | def continue?(date1, date2) do 135 | Date.compare(date1, date2) in [:lt, :eq] 136 | end 137 | 138 | def add(date, step, _unit), do: Date.add(date, step) 139 | 140 | def diff(date, step, _unit), do: Date.diff(date, step) 141 | end 142 | 143 | defimpl CalendarRecurrence.T, for: NaiveDateTime do 144 | def continue?(date1, date2) do 145 | NaiveDateTime.compare(date1, date2) in [:lt, :eq] 146 | end 147 | 148 | defdelegate add(date, step, unit), to: NaiveDateTime 149 | 150 | defdelegate diff(date1, date2, unit), to: NaiveDateTime 151 | end 152 | 153 | defimpl CalendarRecurrence.T, for: DateTime do 154 | def continue?(date1, date2) do 155 | DateTime.compare(date1, date2) in [:lt, :eq] 156 | end 157 | 158 | def add(%DateTime{time_zone: "Etc/UTC"} = date, step, unit), do: DateTime.add(date, step, unit) 159 | 160 | def add(date, step, unit) do 161 | date 162 | |> DateTime.to_naive() 163 | |> NaiveDateTime.add(step, unit) 164 | |> dt_from_naive(step, unit, date.time_zone) 165 | end 166 | 167 | defdelegate diff(date1, date2, unit), to: DateTime 168 | 169 | defp dt_from_naive(%NaiveDateTime{} = ndt, step, unit, timezone) do 170 | case DateTime.from_naive(ndt, timezone) do 171 | {:ok, dt} -> 172 | dt 173 | 174 | {:ambiguous, first_dt, _second_dt} -> 175 | first_dt 176 | 177 | {:gap, _gap_start, _gap_end} -> 178 | ndt 179 | |> NaiveDateTime.add(step, unit) 180 | |> dt_from_naive(step, unit, timezone) 181 | 182 | {:error, reason} -> 183 | raise ArgumentError, 184 | "Could not convert date #{ndt} to DateTime with timezone #{timezone}, reason: #{reason}" 185 | end 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /test/calendar_recurrence_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CalendarRecurrenceTest do 2 | alias CalendarRecurrence.RRULE 3 | use ExUnit.Case, async: true 4 | doctest CalendarRecurrence 5 | 6 | test "enumerable" do 7 | recurrence = CalendarRecurrence.new(start: ~D[2018-01-01]) 8 | 9 | assert Enum.take(recurrence, 3) == [ 10 | ~D[2018-01-01], 11 | ~D[2018-01-02], 12 | ~D[2018-01-03] 13 | ] 14 | 15 | assert Stream.take(recurrence, 3) |> Enum.to_list() == [ 16 | ~D[2018-01-01], 17 | ~D[2018-01-02], 18 | ~D[2018-01-03] 19 | ] 20 | 21 | recurrence = 22 | CalendarRecurrence.new( 23 | start: ~U[2018-01-01 10:00:00Z], 24 | step: 1, 25 | unit: :day 26 | ) 27 | 28 | assert Enum.take(recurrence, 3) == [ 29 | ~U[2018-01-01 10:00:00Z], 30 | ~U[2018-01-02 10:00:00Z], 31 | ~U[2018-01-03 10:00:00Z] 32 | ] 33 | 34 | assert Stream.take(recurrence, 3) |> Enum.to_list() == [ 35 | ~U[2018-01-01 10:00:00Z], 36 | ~U[2018-01-02 10:00:00Z], 37 | ~U[2018-01-03 10:00:00Z] 38 | ] 39 | 40 | recurrence = RRULE.to_recurrence(%RRULE{freq: :daily, count: 3}, ~U[2019-01-01 10:50:00Z]) 41 | 42 | assert recurrence 43 | |> Stream.take_while(fn occurrence -> 44 | DateTime.compare(~U[2019-01-05 10:00:00Z], occurrence) in [:gt, :eq] 45 | end) 46 | |> Stream.filter(fn occurence -> 47 | DateTime.compare(~U[2019-01-01 10:00:00Z], occurence) in [:lt, :eq] 48 | end) 49 | |> Enum.to_list() == [ 50 | ~U[2019-01-01 10:50:00Z], 51 | ~U[2019-01-02 10:50:00Z], 52 | ~U[2019-01-03 10:50:00Z] 53 | ] 54 | end 55 | 56 | test "count" do 57 | assert Enum.count(CalendarRecurrence.new(start: ~D[2018-01-01], stop: {:count, 3})) == 3 58 | 59 | assert Enum.count(CalendarRecurrence.new(start: ~U[2018-01-01 10:00:00Z], stop: {:count, 3})) == 60 | 3 61 | 62 | assert Enum.count( 63 | CalendarRecurrence.new( 64 | start: ~U[2018-01-01 10:00:00Z], 65 | unit: :hour, 66 | stop: {:count, 3} 67 | ) 68 | ) == 69 | 3 70 | 71 | assert Enum.count( 72 | CalendarRecurrence.new(start: ~D[2018-01-01], stop: {:until, ~D[2018-01-03]}) 73 | ) == 3 74 | 75 | assert Enum.count( 76 | CalendarRecurrence.new( 77 | start: ~U[2018-01-01 10:00:00Z], 78 | stop: {:until, ~U[2018-01-03 10:00:00Z]} 79 | ) 80 | ) == 3 81 | 82 | assert Enum.count( 83 | CalendarRecurrence.new( 84 | start: ~U[2018-01-01 10:00:00Z], 85 | stop: {:until, ~U[2018-01-01 16:00:00Z]}, 86 | unit: :hour 87 | ) 88 | ) == 7 89 | 90 | assert Enum.count( 91 | CalendarRecurrence.new( 92 | start: ~D[2018-01-01], 93 | stop: {:until, ~D[2018-01-10]}, 94 | step: 2 95 | ) 96 | ) == 5 97 | 98 | assert Enum.count( 99 | CalendarRecurrence.new( 100 | start: ~U[2018-01-01 10:00:00Z], 101 | stop: {:until, ~U[2018-01-10 10:00:00Z]}, 102 | step: 2 103 | ) 104 | ) == 5 105 | end 106 | 107 | describe "timezones" do 108 | setup do 109 | Calendar.put_time_zone_database(Zoneinfo.TimeZoneDatabase) 110 | end 111 | 112 | test "wall clock time stays the same while switching to DST" do 113 | dt = DateTime.new!(~D[2024-03-30], ~T[12:00:00], "Europe/Berlin") 114 | 115 | [datetime1, datetime2] = 116 | CalendarRecurrence.new(start: dt, stop: {:count, 2}) 117 | |> Enum.to_list() 118 | 119 | assert DateTime.compare( 120 | datetime1, 121 | DateTime.new!(~D[2024-03-30], ~T[12:00:00], "Europe/Berlin") 122 | ) == :eq 123 | 124 | assert DateTime.compare( 125 | datetime2, 126 | DateTime.new!(~D[2024-03-31], ~T[12:00:00], "Europe/Berlin") 127 | ) == :eq 128 | end 129 | 130 | @doc """ 131 | Recurrence rules may generate recurrence instances with an invalid 132 | date (e.g., February 30) or nonexistent local time (e.g., 1:30 AM 133 | on a day where the local time is moved forward by an hour at 1:00 134 | AM). Such recurrence instances MUST be ignored and MUST NOT be 135 | counted as part of the recurrence set. 136 | 137 | https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10 138 | """ 139 | test "skip non exisiting time from DST change" do 140 | dt = DateTime.new!(~D[2024-03-30], ~T[02:00:00], "Europe/Berlin") 141 | 142 | [datetime1, datetime2] = 143 | CalendarRecurrence.new(start: dt, stop: {:count, 2}) 144 | |> Enum.to_list() 145 | 146 | assert DateTime.compare( 147 | datetime1, 148 | DateTime.new!(~D[2024-03-30], ~T[02:00:00], "Europe/Berlin") 149 | ) == :eq 150 | 151 | assert DateTime.compare( 152 | datetime2, 153 | DateTime.new!(~D[2024-04-01], ~T[02:00:00], "Europe/Berlin") 154 | ) == :eq 155 | end 156 | 157 | @doc """ 158 | If the computed local start time of a recurrence instance does not 159 | exist, or occurs more than once, for the specified time zone, the 160 | time of the recurrence instance is interpreted in the same manner 161 | as an explicit DATE-TIME value describing that date and time, as 162 | specified in Section 3.3.5. 163 | 164 | ### 3.3.5 165 | 166 | If, based on the definition of the referenced time zone, the local 167 | time described occurs more than once (when changing from daylight 168 | to standard time), the DATE-TIME value refers to the first 169 | occurrence of the referenced time. Thus, TZID=America/ 170 | New_York:20071104T013000 indicates November 4, 2007 at 1:30 A.M. 171 | EDT (UTC-04:00). If the local time described does not occur (when 172 | changing from standard to daylight time), the DATE-TIME value is 173 | interpreted using the UTC offset before the gap in local times. 174 | Thus, TZID=America/New_York:20070311T023000 indicates March 11, 175 | 2007 at 3:30 A.M. EDT (UTC-04:00), one hour after 1:30 A.M. EST 176 | (UTC-05:00). 177 | https://stackoverflow.com/questions/68617234/how-are-nonexistant-timestamps-due-to-dst-handled-in-icalendar-recurrence-rules 178 | """ 179 | 180 | test "on ambiguous date choose first datetime" do 181 | dt = DateTime.new!(~D[2024-10-26], ~T[02:00:00], "Europe/Berlin") 182 | 183 | [datetime1, datetime2] = 184 | CalendarRecurrence.new(start: dt, stop: {:count, 2}) 185 | |> Enum.to_list() 186 | 187 | assert DateTime.compare( 188 | datetime1, 189 | DateTime.new!(~D[2024-10-26], ~T[02:00:00], "Europe/Berlin") 190 | ) == :eq 191 | 192 | {:ambiguous, first_dt, second_dt} = 193 | DateTime.new(~D[2024-10-27], ~T[02:00:00], "Europe/Berlin") 194 | 195 | assert DateTime.compare(datetime2, first_dt) == :eq 196 | assert DateTime.compare(datetime2, second_dt) != :eq 197 | end 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /lib/calendar_recurrence/rrule.ex: -------------------------------------------------------------------------------- 1 | defmodule CalendarRecurrence.RRULE do 2 | @moduledoc """ 3 | RRULE parser. 4 | 5 | See https://tools.ietf.org/html/rfc5545#section-3.3.10 6 | """ 7 | 8 | defstruct freq: nil, 9 | interval: 1, 10 | until: nil, 11 | count: nil, 12 | bysecond: [], 13 | byminute: [], 14 | byhour: [], 15 | byday: [], 16 | bymonthday: [], 17 | byyearday: [], 18 | # byweekno: [], 19 | bymonth: [] 20 | 21 | # bysetpos: nil 22 | # wkst: nil 23 | 24 | @type t() :: %__MODULE__{ 25 | freq: :monthly | :weekly | :daily | :hourly | :minutely | :secondly | nil, 26 | interval: pos_integer(), 27 | until: CalendarRecurrence.date() | nil, 28 | count: non_neg_integer() | nil 29 | # bysecond: [0..59], 30 | # byminute: [0..59], 31 | # byhour: [0..59], 32 | # byday: [1..31], 33 | # bymonthday: , 34 | # byyearday: , 35 | # byweekno: 36 | # bymonth: [1..12] 37 | # bysetpos: , 38 | # wkst: 39 | } 40 | 41 | alias __MODULE__ 42 | 43 | @spec parse(String.t()) :: {:ok, t()} | {:error, term()} 44 | def parse(binary) do 45 | case CalendarRecurrence.RRULE.Parser.parse(binary) do 46 | {:ok, [map], "", _, _, _} -> 47 | if Map.has_key?(map, :freq) do 48 | if Map.has_key?(map, :until) && Map.has_key?(map, :count) do 49 | {:error, :until_or_count} 50 | else 51 | {:ok, struct!(__MODULE__, map)} 52 | end 53 | else 54 | {:error, :missing_freq} 55 | end 56 | 57 | {:ok, _, rest, _, _, _} -> 58 | {:error, {:leftover, rest}} 59 | 60 | {:error, message, _rest, _, _, _} -> 61 | {:error, message} 62 | end 63 | end 64 | 65 | @spec parse!(String.t()) :: t() | no_return() 66 | def parse!(binary) do 67 | case parse(binary) do 68 | {:ok, rrule} -> rrule 69 | {:error, reason} -> raise ArgumentError, "parse error: #{inspect(reason)}" 70 | end 71 | end 72 | 73 | @doc """ 74 | Converts `rrule` into a recurrence starting at given `start` date. 75 | 76 | ## Examples 77 | 78 | iex> RRULE.to_recurrence(%RRULE{freq: :daily}, ~D[2018-01-01]) |> Enum.take(3) 79 | [ 80 | ~D[2018-01-01], 81 | ~D[2018-01-02], 82 | ~D[2018-01-03] 83 | ] 84 | 85 | """ 86 | @spec to_recurrence(t() | String.t(), CalendarRecurrence.date()) :: CalendarRecurrence.t() 87 | def to_recurrence(%RRULE{} = rrule, %DateTime{} = start) do 88 | CalendarRecurrence.new( 89 | start: start, 90 | stop: stop(rrule, start), 91 | step: step(rrule), 92 | unit: :second 93 | ) 94 | end 95 | 96 | def to_recurrence(%RRULE{} = rrule, start) do 97 | CalendarRecurrence.new(start: start, stop: stop(rrule, start), step: step(rrule)) 98 | end 99 | 100 | def to_recurrence(string, start) when is_binary(string) do 101 | string |> parse!() |> to_recurrence(start) 102 | end 103 | 104 | defp stop(rrule, start) do 105 | cond do 106 | rrule.count -> {:count, rrule.count} 107 | rrule.until -> {:until, convert_date_type(rrule, start)} 108 | true -> :never 109 | end 110 | end 111 | 112 | defp convert_date_type(%RRULE{until: %Date{} = until}, %NaiveDateTime{}) do 113 | NaiveDateTime.new!(until, ~T[23:59:59]) 114 | end 115 | 116 | defp convert_date_type(%RRULE{until: %Date{} = until}, %DateTime{}) do 117 | DateTime.new!(until, ~T[23:59:59], "Etc/UTC") 118 | end 119 | 120 | defp convert_date_type(%RRULE{until: %NaiveDateTime{} = until}, %Date{}) do 121 | NaiveDateTime.to_date(until) 122 | end 123 | 124 | defp convert_date_type(%RRULE{until: %NaiveDateTime{} = until}, %DateTime{}) do 125 | DateTime.from_naive!(until, "Etc/UTC") 126 | end 127 | 128 | defp convert_date_type(%RRULE{until: %DateTime{} = until}, %Date{}) do 129 | DateTime.to_date(until) 130 | end 131 | 132 | defp convert_date_type(%RRULE{until: %DateTime{} = until}, %NaiveDateTime{}) do 133 | DateTime.to_naive(until) 134 | end 135 | 136 | defp convert_date_type(%RRULE{until: until}, _), do: until 137 | 138 | defp step(%RRULE{freq: :monthly, bymonthday: [], bymonth: months}) 139 | when is_list(months) and length(months) > 0 do 140 | months = Enum.sort(months) 141 | 142 | fn 143 | %DateTime{} = current -> 144 | next_month = Enum.find(months, &(&1 > current.month)) 145 | 146 | next = 147 | if next_month do 148 | %{current | month: next_month, day: 1} 149 | else 150 | next_month = List.first(months) 151 | %{current | year: current.year + 1, month: next_month, day: 1} 152 | end 153 | 154 | DateTime.diff(next, current, :second) 155 | 156 | current -> 157 | next_month = Enum.find(months, &(&1 > current.month)) 158 | 159 | if next_month do 160 | Date.diff(%{current | month: next_month, day: 1}, current) 161 | else 162 | next_month = List.first(months) 163 | Date.diff(%{current | year: current.year + 1, month: next_month, day: 1}, current) 164 | end 165 | end 166 | end 167 | 168 | defp step(%RRULE{freq: :monthly, bymonthday: days, bymonth: months}) 169 | when is_list(days) and is_list(months) and length(months) > 0 do 170 | months = Enum.sort(months) 171 | days = List.first(days) 172 | 173 | fn 174 | %DateTime{} = current -> 175 | next_month = Enum.find(months, &(&1 > current.month)) 176 | 177 | next = 178 | if next_month do 179 | %{current | month: next_month, day: days} 180 | else 181 | next_month = List.first(months) 182 | %{current | year: current.year + 1, month: next_month, day: days} 183 | end 184 | 185 | DateTime.diff(next, current, :second) 186 | 187 | current -> 188 | next_month = Enum.find(months, &(&1 > current.month)) 189 | 190 | if next_month do 191 | Date.diff(%{current | month: next_month, day: days}, current) 192 | else 193 | next_month = List.first(months) 194 | Date.diff(%{current | year: current.year + 1, month: next_month, day: days}, current) 195 | end 196 | end 197 | end 198 | 199 | defp step(%RRULE{freq: :monthly, interval: interval, bymonthday: []}) do 200 | fn 201 | %DateTime{} = current -> 202 | next = add_months(current, interval) 203 | DateTime.diff(next, current, :second) 204 | 205 | current -> 206 | # if Date or NaiveDateTime, return days until next occurrence 207 | next = add_months(current, interval) 208 | Date.diff(next, current) 209 | end 210 | end 211 | 212 | defp step(%RRULE{freq: :monthly, interval: interval, bymonthday: days}) when is_list(days) do 213 | days = Enum.sort(days) 214 | 215 | fn 216 | %DateTime{} = current -> 217 | next = next_monthday(current, days, interval) 218 | DateTime.diff(next, current, :second) 219 | 220 | current -> 221 | next = next_monthday(current, days, interval) 222 | Date.diff(next, current) 223 | end 224 | end 225 | 226 | defp step(%RRULE{freq: :weekly, byday: [], interval: interval}), 227 | do: fn 228 | %DateTime{} = date -> 229 | DateTime.add(date, interval * 7, :day) |> DateTime.diff(date, :second) 230 | 231 | _date -> 232 | 7 * interval 233 | end 234 | 235 | defp step(%RRULE{freq: :weekly, byday: days_of_week, interval: interval}) do 236 | days_of_week = Enum.sort(days_of_week) 237 | 238 | fn 239 | %DateTime{} = current -> 240 | current_day_of_week = Date.day_of_week(current) 241 | next_day_of_week = Enum.find(days_of_week, &(&1 > current_day_of_week)) 242 | 243 | if next_day_of_week do 244 | DateTime.add(current, next_day_of_week - current_day_of_week, :day) 245 | |> DateTime.diff(current, :second) 246 | else 247 | DateTime.add(current, interval * 7 - current_day_of_week + hd(days_of_week), :day) 248 | |> DateTime.diff(current, :second) 249 | end 250 | 251 | current -> 252 | current_day_of_week = Date.day_of_week(current) 253 | next_day_of_week = Enum.find(days_of_week, &(&1 > current_day_of_week)) 254 | 255 | if next_day_of_week do 256 | next_day_of_week - current_day_of_week 257 | else 258 | interval * 7 - current_day_of_week + hd(days_of_week) 259 | end 260 | end 261 | end 262 | 263 | defp step(%RRULE{freq: :daily, interval: interval}), 264 | do: fn 265 | %DateTime{} = date -> 266 | DateTime.add(date, interval, :day) |> DateTime.diff(date, :second) 267 | 268 | _date -> 269 | interval 270 | end 271 | 272 | defp step(%RRULE{freq: :hourly, interval: interval}), 273 | do: fn date -> DateTime.add(date, interval, :hour) |> DateTime.diff(date, :second) end 274 | 275 | defp step(%RRULE{freq: :minutely, interval: interval}), 276 | do: fn date -> 277 | DateTime.add(date, interval, :minute) |> DateTime.diff(date, :second) 278 | end 279 | 280 | defp step(%RRULE{freq: :secondly, interval: interval}), 281 | do: fn date -> 282 | DateTime.add(date, interval, :second) |> DateTime.diff(date, :second) 283 | end 284 | 285 | defp add_months(%DateTime{} = date, interval) do 286 | adjust_months(date, interval) 287 | end 288 | 289 | defp add_months(%Date{} = date, interval) do 290 | adjust_months(date, interval) 291 | end 292 | 293 | defp add_months(%NaiveDateTime{} = date, interval) do 294 | adjust_months(date, interval) 295 | end 296 | 297 | defp adjust_months(date, interval) do 298 | original_day = date.day 299 | new_month = date.month + interval 300 | years_to_add = div(new_month - 1, 12) 301 | final_month = rem(new_month - 1, 12) + 1 302 | 303 | days_in_new_month = :calendar.last_day_of_the_month(date.year + years_to_add, final_month) 304 | 305 | if original_day > days_in_new_month do 306 | # Skip to next month if this would create an invalid date 307 | adjust_months(date, interval + 1) 308 | else 309 | new_day = 310 | cond do 311 | # -1 in bymonthday 312 | original_day < 0 -> days_in_new_month 313 | true -> original_day 314 | end 315 | 316 | %{date | year: date.year + years_to_add, month: final_month, day: new_day} 317 | end 318 | end 319 | 320 | defp next_monthday(current, days, interval) do 321 | current_month_days = 322 | days 323 | |> Enum.map(fn day -> 324 | # -1, -2 etc 325 | if day < 0 do 326 | # get next occurrence 327 | :calendar.last_day_of_the_month(current.year, current.month) + day + 1 328 | else 329 | day 330 | end 331 | end) 332 | |> Enum.filter(fn day -> 333 | day <= :calendar.last_day_of_the_month(current.year, current.month) && 334 | %{current | day: day} |> Date.compare(current) == :gt 335 | end) 336 | 337 | case current_month_days do 338 | [] -> 339 | # no valid days in current month, move to next month 340 | new_month = current.month + interval 341 | 342 | next_month = 343 | cond do 344 | new_month > 12 -> 345 | years_to_add = div(new_month - 1, 12) 346 | remaining_month = rem(new_month - 1, 12) + 1 347 | %{current | year: current.year + years_to_add, month: remaining_month} 348 | 349 | true -> 350 | %{current | month: new_month} 351 | end 352 | 353 | # now take the bymonthday days from the next month 354 | next_day = 355 | days 356 | |> Enum.map(fn day -> 357 | if day < 0 do 358 | :calendar.last_day_of_the_month(next_month.year, next_month.month) + day + 1 359 | else 360 | day 361 | end 362 | end) 363 | |> Enum.filter(fn day -> 364 | day <= :calendar.last_day_of_the_month(next_month.year, next_month.month) 365 | end) 366 | |> Enum.sort() 367 | |> List.first() 368 | 369 | %{next_month | day: next_day} 370 | 371 | [next_day | _] -> 372 | %{current | day: next_day} 373 | end 374 | end 375 | 376 | defimpl String.Chars do 377 | @doc """ 378 | Converts `%RRULE{}` into a rrule string. 379 | 380 | ## Examples 381 | 382 | iex> to_string(%RRULE{freq: :daily, count: 2}) 383 | "FREQ=DAILY;COUNT=2" 384 | 385 | """ 386 | @spec to_string(CalendarRecurrence.RRULE.t()) :: binary() 387 | def to_string(rrule) do 388 | rrule = Map.from_struct(rrule) 389 | 390 | [ 391 | :freq, 392 | :interval, 393 | :until, 394 | :count, 395 | :bysecond, 396 | :byminute, 397 | :byhour, 398 | :byday, 399 | :bymonthday, 400 | :byyearday, 401 | :bymonth 402 | ] 403 | |> Enum.reduce([], fn key, acc -> [add_part(key, rrule[key]) | acc] end) 404 | |> Enum.reverse() 405 | |> Enum.reject(&is_nil/1) 406 | |> Enum.intersperse(";") 407 | |> IO.iodata_to_binary() 408 | end 409 | 410 | @weekdays %{ 411 | 1 => "MO", 412 | 2 => "TU", 413 | 3 => "WE", 414 | 4 => "TH", 415 | 5 => "FR", 416 | 6 => "SA", 417 | 7 => "SU" 418 | } 419 | 420 | defp add_part(_key, nil), do: nil 421 | defp add_part(_key, []), do: nil 422 | defp add_part(:interval, 1), do: nil 423 | 424 | defp add_part(:until = key, %DateTime{} = value) do 425 | key_value(key, DateTime.to_naive(value) |> NaiveDateTime.to_iso8601(:basic)) 426 | end 427 | 428 | defp add_part(:until = key, %NaiveDateTime{} = value) do 429 | key_value(key, NaiveDateTime.to_iso8601(value, :basic)) 430 | end 431 | 432 | defp add_part(:until = key, %Date{} = value) do 433 | key_value(key, Date.to_iso8601(value, :basic)) 434 | end 435 | 436 | defp add_part(:byday = key, value) do 437 | days = Enum.map_join(value, ",", fn day -> @weekdays[day] end) 438 | key_value(key, days) 439 | end 440 | 441 | defp add_part(key, value) when is_list(value) do 442 | values = value |> Enum.join(",") 443 | key_value(key, values) 444 | end 445 | 446 | defp add_part(key, value) when is_integer(value) or is_atom(value) do 447 | key_value(key, upcase(value)) 448 | end 449 | 450 | defp key_value(key, value) do 451 | [upcase(key), "=", value] 452 | end 453 | 454 | defp upcase(value) do 455 | String.Chars.to_string(value) |> String.upcase() 456 | end 457 | end 458 | end 459 | -------------------------------------------------------------------------------- /test/calendar_recurrence/rrule_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CalendarRecurrence.RRULETest do 2 | use ExUnit.Case, async: true 3 | alias CalendarRecurrence.RRULE 4 | doctest CalendarRecurrence.RRULE 5 | 6 | test "parse/1" do 7 | {:ok, %RRULE{freq: :daily}} = RRULE.parse("FREQ=DAILY") 8 | 9 | {:ok, %RRULE{freq: :daily, until: ~N[2018-01-02 10:20:30]}} = 10 | RRULE.parse("FREQ=DAILY;UNTIL=20180102T102030") 11 | 12 | {:ok, %RRULE{freq: :daily, until: ~U[2018-01-02 10:20:30Z]}} = 13 | RRULE.parse("FREQ=DAILY;UNTIL=20180102T102030Z") 14 | 15 | {:ok, %RRULE{freq: :daily, until: ~D[2018-01-02]}} = RRULE.parse("FREQ=DAILY;UNTIL=20180102") 16 | 17 | {:ok, %RRULE{freq: :daily, count: 10}} = RRULE.parse("FREQ=DAILY;COUNT=10") 18 | 19 | {:ok, %RRULE{freq: :daily, count: 10, interval: 2}} = 20 | RRULE.parse("FREQ=DAILY;COUNT=10;INTERVAL=2") 21 | 22 | {:ok, %RRULE{freq: :daily, bysecond: [5]}} = RRULE.parse("FREQ=DAILY;BYSECOND=5") 23 | 24 | {:ok, %RRULE{freq: :daily, bysecond: [5, 10]}} = RRULE.parse("FREQ=DAILY;BYSECOND=5,10") 25 | 26 | {:ok, %RRULE{freq: :daily, byminute: [5, 10]}} = RRULE.parse("FREQ=DAILY;BYMINUTE=5,10") 27 | 28 | {:ok, %RRULE{freq: :daily, byhour: [5, 10]}} = RRULE.parse("FREQ=DAILY;BYHOUR=5,10") 29 | 30 | {:ok, %RRULE{freq: :weekly, byday: [1, 2]}} = RRULE.parse("FREQ=WEEKLY;BYDAY=MO,TU") 31 | 32 | {:ok, %RRULE{freq: :monthly}} = RRULE.parse("FREQ=MONTHLY") 33 | 34 | {:ok, %RRULE{freq: :monthly, bymonthday: [-1]}} = RRULE.parse("FREQ=MONTHLY;BYMONTHDAY=-1") 35 | 36 | {:ok, %RRULE{freq: :monthly, bymonthday: [5]}} = RRULE.parse("FREQ=MONTHLY;BYMONTHDAY=5") 37 | 38 | {:ok, %RRULE{freq: :monthly, bymonthday: [-15]}} = RRULE.parse("FREQ=MONTHLY;BYMONTHDAY=-15") 39 | 40 | # test 15 evaluated before 1 41 | {:ok, %RRULE{freq: :monthly, bymonthday: [15]}} = RRULE.parse("FREQ=MONTHLY;BYMONTHDAY=15") 42 | 43 | {:ok, %RRULE{freq: :monthly, bymonthday: [5], bymonth: [1, 3, 4]}} = 44 | RRULE.parse("FREQ=MONTHLY;BYMONTHDAY=5;BYMONTH=1,3,4") 45 | 46 | {:ok, %RRULE{freq: :monthly, bymonthday: [15], bymonth: [1, 3, 4]}} = 47 | RRULE.parse("FREQ=MONTHLY;BYMONTHDAY=15;BYMONTH=1,3,4") 48 | 49 | {:error, :missing_freq} = RRULE.parse("COUNT=10") 50 | 51 | {:error, :until_or_count} = RRULE.parse("FREQ=DAILY;UNTIL=20180101;COUNT=10") 52 | 53 | {:error, "expected string \"FREQ\", followed by" <> _} = RRULE.parse("bad") 54 | 55 | {:error, {:leftover, "foobar"}} = RRULE.parse("FREQ=DAILYfoobar") 56 | end 57 | 58 | test "to_string/1" do 59 | assert "FREQ=DAILY" = to_string(%RRULE{freq: :daily}) 60 | 61 | assert "FREQ=DAILY;INTERVAL=2;COUNT=10" = 62 | to_string(%RRULE{freq: :daily, count: 10, interval: 2}) 63 | 64 | assert "FREQ=DAILY;UNTIL=20180102" = 65 | to_string(%RRULE{freq: :daily, until: ~D[2018-01-02]}) 66 | 67 | assert "FREQ=DAILY;UNTIL=20180102T102030" = 68 | to_string(%RRULE{freq: :daily, until: ~N[2018-01-02 10:20:30Z]}) 69 | 70 | assert "FREQ=DAILY;UNTIL=20180102T102030" = 71 | to_string(%RRULE{freq: :daily, until: ~U[2018-01-02 10:20:30Z]}) 72 | 73 | assert "FREQ=DAILY;BYSECOND=5,10" = to_string(%RRULE{freq: :daily, bysecond: [5, 10]}) 74 | assert "FREQ=DAILY;BYMINUTE=5,10" = to_string(%RRULE{freq: :daily, byminute: [5, 10]}) 75 | assert "FREQ=WEEKLY;BYDAY=MO,TU" = to_string(%RRULE{freq: :weekly, byday: [1, 2]}) 76 | 77 | assert "FREQ=MONTHLY" = to_string(%RRULE{freq: :monthly}) 78 | 79 | assert "FREQ=MONTHLY;BYMONTHDAY=5;BYMONTH=1,3,4" = 80 | to_string(%RRULE{freq: :monthly, bymonthday: [5], bymonth: [1, 3, 4]}) 81 | end 82 | 83 | test "to_recurrence/1" do 84 | assert Enum.take(RRULE.to_recurrence("FREQ=DAILY", ~D[2018-01-01]), 3) == [ 85 | ~D[2018-01-01], 86 | ~D[2018-01-02], 87 | ~D[2018-01-03] 88 | ] 89 | 90 | assert Enum.take(RRULE.to_recurrence(%RRULE{freq: :daily}, ~D[2018-01-01]), 3) == [ 91 | ~D[2018-01-01], 92 | ~D[2018-01-02], 93 | ~D[2018-01-03] 94 | ] 95 | 96 | assert Enum.to_list(RRULE.to_recurrence(%RRULE{freq: :daily, count: 3}, ~D[2018-01-01])) == [ 97 | ~D[2018-01-01], 98 | ~D[2018-01-02], 99 | ~D[2018-01-03] 100 | ] 101 | 102 | assert Enum.to_list( 103 | RRULE.to_recurrence(%RRULE{freq: :daily, until: ~D[2018-01-03]}, ~D[2018-01-01]) 104 | ) == [ 105 | ~D[2018-01-01], 106 | ~D[2018-01-02], 107 | ~D[2018-01-03] 108 | ] 109 | 110 | assert Enum.to_list( 111 | RRULE.to_recurrence( 112 | %RRULE{freq: :daily, until: ~D[2018-01-03]}, 113 | ~N[2018-01-01 10:00:00] 114 | ) 115 | ) == [ 116 | ~N[2018-01-01 10:00:00], 117 | ~N[2018-01-02 10:00:00], 118 | ~N[2018-01-03 10:00:00] 119 | ] 120 | 121 | assert Enum.to_list( 122 | RRULE.to_recurrence( 123 | %RRULE{freq: :daily, until: ~D[2018-01-03]}, 124 | ~U[2018-01-01 10:00:00Z] 125 | ) 126 | ) == [ 127 | ~U[2018-01-01 10:00:00Z], 128 | ~U[2018-01-02 10:00:00Z], 129 | ~U[2018-01-03 10:00:00Z] 130 | ] 131 | 132 | assert Enum.to_list( 133 | RRULE.to_recurrence( 134 | %RRULE{freq: :daily, until: ~N[2018-01-03 10:00:00]}, 135 | ~N[2018-01-01 10:00:00] 136 | ) 137 | ) == [ 138 | ~N[2018-01-01 10:00:00], 139 | ~N[2018-01-02 10:00:00], 140 | ~N[2018-01-03 10:00:00] 141 | ] 142 | 143 | assert Enum.to_list( 144 | RRULE.to_recurrence( 145 | %RRULE{freq: :daily, until: ~N[2018-01-03 10:00:00]}, 146 | ~D[2018-01-01] 147 | ) 148 | ) == [ 149 | ~D[2018-01-01], 150 | ~D[2018-01-02], 151 | ~D[2018-01-03] 152 | ] 153 | 154 | assert Enum.to_list( 155 | RRULE.to_recurrence( 156 | %RRULE{freq: :daily, until: ~N[2018-01-03 10:00:00]}, 157 | ~U[2018-01-01 10:00:00Z] 158 | ) 159 | ) == [ 160 | ~U[2018-01-01 10:00:00Z], 161 | ~U[2018-01-02 10:00:00Z], 162 | ~U[2018-01-03 10:00:00Z] 163 | ] 164 | 165 | assert Enum.to_list( 166 | RRULE.to_recurrence( 167 | %RRULE{freq: :daily, until: ~U[2018-01-03 10:00:00Z]}, 168 | ~U[2018-01-01 10:00:00Z] 169 | ) 170 | ) == [ 171 | ~U[2018-01-01 10:00:00Z], 172 | ~U[2018-01-02 10:00:00Z], 173 | ~U[2018-01-03 10:00:00Z] 174 | ] 175 | 176 | assert Enum.to_list( 177 | RRULE.to_recurrence( 178 | %RRULE{freq: :daily, until: ~U[2018-01-03 10:00:00Z]}, 179 | ~N[2018-01-01 10:00:00] 180 | ) 181 | ) == [ 182 | ~N[2018-01-01 10:00:00], 183 | ~N[2018-01-02 10:00:00], 184 | ~N[2018-01-03 10:00:00] 185 | ] 186 | 187 | assert Enum.to_list( 188 | RRULE.to_recurrence( 189 | %RRULE{freq: :daily, until: ~U[2018-01-03 10:00:00Z]}, 190 | ~D[2018-01-01] 191 | ) 192 | ) == [ 193 | ~D[2018-01-01], 194 | ~D[2018-01-02], 195 | ~D[2018-01-03] 196 | ] 197 | 198 | assert Enum.to_list( 199 | RRULE.to_recurrence(%RRULE{freq: :daily, count: 3, interval: 2}, ~D[2018-01-01]) 200 | ) == [ 201 | ~D[2018-01-01], 202 | ~D[2018-01-03], 203 | ~D[2018-01-05] 204 | ] 205 | 206 | assert Enum.to_list(RRULE.to_recurrence(%RRULE{freq: :weekly, count: 3}, ~D[2018-01-01])) == [ 207 | ~D[2018-01-01], 208 | ~D[2018-01-08], 209 | ~D[2018-01-15] 210 | ] 211 | 212 | assert Enum.to_list( 213 | RRULE.to_recurrence(%RRULE{freq: :weekly, byday: [1, 2], count: 3}, ~D[2018-01-01]) 214 | ) == [ 215 | ~D[2018-01-01], 216 | ~D[2018-01-02], 217 | ~D[2018-01-08] 218 | ] 219 | 220 | assert Enum.to_list( 221 | RRULE.to_recurrence(%RRULE{freq: :weekly, count: 3}, ~U"2018-01-01 10:00:00Z") 222 | ) == 223 | [ 224 | ~U"2018-01-01 10:00:00Z", 225 | ~U"2018-01-08 10:00:00Z", 226 | ~U"2018-01-15 10:00:00Z" 227 | ] 228 | 229 | assert Enum.to_list( 230 | RRULE.to_recurrence( 231 | %RRULE{freq: :weekly, byday: [1, 2], count: 3}, 232 | ~U"2018-01-01 10:00:00Z" 233 | ) 234 | ) == [ 235 | ~U[2018-01-01 10:00:00Z], 236 | ~U[2018-01-02 10:00:00Z], 237 | ~U[2018-01-08 10:00:00Z] 238 | ] 239 | 240 | assert Enum.to_list( 241 | RRULE.to_recurrence(%RRULE{freq: :daily, count: 3}, ~U"2018-01-01 10:00:00Z") 242 | ) == 243 | [ 244 | ~U"2018-01-01 10:00:00Z", 245 | ~U"2018-01-02 10:00:00Z", 246 | ~U"2018-01-03 10:00:00Z" 247 | ] 248 | 249 | assert Enum.to_list( 250 | RRULE.to_recurrence(%RRULE{freq: :hourly, count: 3}, ~U"2018-01-01 10:00:00Z") 251 | ) == [ 252 | ~U"2018-01-01 10:00:00Z", 253 | ~U"2018-01-01 11:00:00Z", 254 | ~U"2018-01-01 12:00:00Z" 255 | ] 256 | 257 | assert Enum.to_list( 258 | RRULE.to_recurrence(%RRULE{freq: :minutely, count: 3}, ~U"2018-01-01 10:00:00Z") 259 | ) == [ 260 | ~U"2018-01-01 10:00:00Z", 261 | ~U"2018-01-01 10:01:00Z", 262 | ~U"2018-01-01 10:02:00Z" 263 | ] 264 | 265 | assert Enum.to_list( 266 | RRULE.to_recurrence(%RRULE{freq: :secondly, count: 3}, ~U"2018-01-01 10:00:00Z") 267 | ) == [ 268 | ~U"2018-01-01 10:00:00Z", 269 | ~U"2018-01-01 10:00:01Z", 270 | ~U"2018-01-01 10:00:02Z" 271 | ] 272 | 273 | assert Enum.take(RRULE.to_recurrence("FREQ=MONTHLY", ~D[2018-01-15]), 3) == [ 274 | ~D[2018-01-15], 275 | ~D[2018-02-15], 276 | ~D[2018-03-15] 277 | ] 278 | 279 | # Per https://datatracker.ietf.org/doc/html/rfc5545#page-132, invalid dates like Feb 31 are ignored. 280 | assert Enum.take(RRULE.to_recurrence("FREQ=MONTHLY", ~D[2018-01-31]), 3) == [ 281 | ~D[2018-01-31], 282 | ~D[2018-03-31], 283 | ~D[2018-05-31] 284 | ] 285 | 286 | # with interval 287 | assert Enum.take(RRULE.to_recurrence(%RRULE{freq: :monthly, interval: 2}, ~D[2018-01-15]), 3) == 288 | [ 289 | ~D[2018-01-15], 290 | ~D[2018-03-15], 291 | ~D[2018-05-15] 292 | ] 293 | 294 | # with DateTime 295 | assert Enum.take(RRULE.to_recurrence(%RRULE{freq: :monthly}, ~U[2018-01-31 10:00:00Z]), 3) == 296 | [ 297 | ~U[2018-01-31 10:00:00Z], 298 | ~U[2018-03-31 10:00:00Z], 299 | ~U[2018-05-31 10:00:00Z] 300 | ] 301 | 302 | # with NaiveDateTime 303 | assert Enum.take(RRULE.to_recurrence(%RRULE{freq: :monthly}, ~N[2018-01-31 10:00:00]), 3) == [ 304 | ~N[2018-01-31 10:00:00], 305 | ~N[2018-03-31 10:00:00], 306 | ~N[2018-05-31 10:00:00] 307 | ] 308 | 309 | # bymonthday 310 | assert Enum.to_list( 311 | RRULE.to_recurrence( 312 | %RRULE{freq: :monthly, bymonthday: [-2], count: 4}, 313 | ~D[2024-12-31] 314 | ) 315 | ) == 316 | [ 317 | ~D[2024-12-31], 318 | ~D[2025-01-30], 319 | ~D[2025-02-27], 320 | ~D[2025-03-30] 321 | ] 322 | 323 | # bymonthday 324 | assert Enum.to_list( 325 | RRULE.to_recurrence( 326 | %RRULE{freq: :monthly, bymonthday: [-1], count: 4}, 327 | ~D[2024-12-31] 328 | ) 329 | ) == 330 | [ 331 | ~D[2024-12-31], 332 | ~D[2025-01-31], 333 | ~D[2025-02-28], 334 | ~D[2025-03-31] 335 | ] 336 | 337 | # bymonthday 338 | assert Enum.to_list( 339 | RRULE.to_recurrence( 340 | %RRULE{freq: :monthly, bymonthday: [15], count: 5}, 341 | ~D[2024-12-31] 342 | ) 343 | ) == 344 | [ 345 | ~D[2024-12-31], 346 | ~D[2025-01-15], 347 | ~D[2025-02-15], 348 | ~D[2025-03-15], 349 | ~D[2025-04-15] 350 | ] 351 | 352 | # multiple bymonthday 353 | assert Enum.to_list( 354 | RRULE.to_recurrence( 355 | %RRULE{freq: :monthly, bymonthday: [15, 20], count: 5}, 356 | ~D[2024-12-31] 357 | ) 358 | ) == 359 | [ 360 | ~D[2024-12-31], 361 | ~D[2025-01-15], 362 | ~D[2025-01-20], 363 | ~D[2025-02-15], 364 | ~D[2025-02-20] 365 | ] 366 | 367 | # bymonthday and bymonth 368 | assert Enum.to_list( 369 | RRULE.to_recurrence( 370 | %RRULE{freq: :monthly, bymonthday: [15], bymonth: [1, 3, 4], count: 5}, 371 | ~D[2024-12-31] 372 | ) 373 | ) == 374 | [ 375 | ~D[2024-12-31], 376 | ~D[2025-01-15], 377 | ~D[2025-03-15], 378 | ~D[2025-04-15], 379 | ~D[2026-01-15] 380 | ] 381 | 382 | # bymonth 383 | assert Enum.to_list( 384 | RRULE.to_recurrence( 385 | %RRULE{freq: :monthly, bymonth: [1, 3, 4], count: 5}, 386 | ~D[2024-12-31] 387 | ) 388 | ) == 389 | [ 390 | ~D[2024-12-31], 391 | ~D[2025-01-01], 392 | ~D[2025-03-01], 393 | ~D[2025-04-01], 394 | ~D[2026-01-01] 395 | ] 396 | 397 | # with count 398 | assert Enum.to_list(RRULE.to_recurrence(%RRULE{freq: :monthly, count: 3}, ~D[2018-12-31])) == 399 | [ 400 | ~D[2018-12-31], 401 | ~D[2019-01-31], 402 | ~D[2019-03-31] 403 | ] 404 | 405 | # with until date 406 | assert Enum.to_list( 407 | RRULE.to_recurrence(%RRULE{freq: :monthly, until: ~D[2019-02-28]}, ~D[2018-12-31]) 408 | ) == [ 409 | ~D[2018-12-31], 410 | ~D[2019-01-31] 411 | ] 412 | 413 | # with leap year 414 | assert Enum.take(RRULE.to_recurrence("FREQ=MONTHLY", ~D[2024-01-29]), 3) == [ 415 | ~D[2024-01-29], 416 | ~D[2024-02-29], 417 | ~D[2024-03-29] 418 | ] 419 | 420 | # crossing year boundary 421 | assert Enum.take(RRULE.to_recurrence(%RRULE{freq: :monthly, interval: 3}, ~D[2018-11-30]), 3) == 422 | [ 423 | ~D[2018-11-30], 424 | ~D[2019-03-30], 425 | ~D[2019-06-30] 426 | ] 427 | end 428 | end 429 | --------------------------------------------------------------------------------