From e192e0c5ec144550161050b5bc6a73ed6c1bc84e Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 15 Apr 2026 09:59:30 +0200 Subject: [PATCH 01/96] sort recurrence tests into serialization, deserialization, generation --- test/ical/recurrence_test.exs | 610 +++++++++++++++++----------------- 1 file changed, 312 insertions(+), 298 deletions(-) diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index b74c331..7320d2b 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -4,307 +4,321 @@ defmodule ICal.RecurrenceTest do alias ICal.Test.Fixtures alias ICal.Test.Helper - test "ICalender.to_ics/1 with rrule" do - ics = - Fixtures.recurrence_event() - |> ICal.to_ics() - |> to_string() - - # Extract RRULE line for comparison (parameter order doesn't matter per RFC 5545) - [rrule_line] = Regex.run(~r/RRULE:(.+)/, ics, capture: :all_but_first) - - rrule_params = - rrule_line - |> String.split(";") - |> MapSet.new() - - expected_params = - MapSet.new([ - "BYDAY=WE1FR-2SA", - "BYHOUR=3", - "BYMINUTE=2", - "BYMONTH=10", - "BYMONTHDAY=6", - "BYSECOND=1", - "BYSETPOS=20", - "BYWEEKNO=-1", - "BYYEARDAY=7,8,9", - "COUNT=3", - "FREQ=DAILY", - "INTERVAL=1", - "UNTIL=20191124T084500Z", - "WKST=MONDAY" - ]) - - assert rrule_params == expected_params + describe "RRULE: serialization" do + test "Serializes correctly" do + ics = + Fixtures.recurrence_event() + |> ICal.to_ics() + |> to_string() + + # Extract RRULE line for comparison (parameter order doesn't matter per RFC 5545) + [rrule_line] = Regex.run(~r/RRULE:(.+)/, ics, capture: :all_but_first) + + rrule_params = + rrule_line + |> String.split(";") + |> MapSet.new() + + expected_params = + MapSet.new([ + "BYDAY=WE1FR-2SA", + "BYHOUR=3", + "BYMINUTE=2", + "BYMONTH=10", + "BYMONTHDAY=6", + "BYSECOND=1", + "BYSETPOS=20", + "BYWEEKNO=-1", + "BYYEARDAY=7,8,9", + "COUNT=3", + "FREQ=DAILY", + "INTERVAL=1", + "UNTIL=20191124T084500Z", + "WKST=MONDAY" + ]) + + assert rrule_params == expected_params + end + + test "weekday abbreviations handled corrrectly" do + rrule = %{"FREQ" => "DAILY", "BYDAY" => "-1SU,SU,1MO,-1TU,+2WE,TH,FR,SA,GA,GARBAGE,,0,-1"} + + recurrence = %ICal.Recurrence{ + frequency: :daily, + by_day: [ + {-1, :sunday}, + {nil, :sunday}, + {1, :monday}, + {-1, :tuesday}, + {2, :wednesday}, + {nil, :thursday}, + {nil, :friday}, + {nil, :saturday} + ] + } + + assert recurrence === ICal.Deserialize.Recurrence.from_params(rrule) + + serialized = ICal.Serialize.Recurrence.property(recurrence) |> to_string() + + assert String.starts_with?(serialized, "RRULE:FREQ=DAILY") + assert String.contains?(serialized, ";INTERVAL=1") + assert String.contains?(serialized, ";BYDAY=-1SUSU1MO-1TU2WETHFRSA") + assert String.ends_with?(serialized, "\n") + end end - test "event with no recurrences" do - assert [] == - Fixtures.one_event() - |> ICal.Recurrence.stream() - |> Enum.to_list() + describe "RRULE: deserialization" do + test "ignores bad WKST values" do + rrule = %{"FREQ" => "DAILY", "WKST" => "NO"} + + assert %ICal.Recurrence{frequency: :daily, weekday: nil} === + ICal.Deserialize.Recurrence.from_params(rrule) + end + + test "clamps time values" do + rrule = %{ + "FREQ" => "DAILY", + "BYSECOND" => "-1,-,0,1,10,50,59,60,70", + "BYMINUTE" => "-1,-,0,1,10,50,59,60,70", + "BYHOUR" => "-1,-,0,1,6,12,23,24" + } + + assert %ICal.Recurrence{ + frequency: :daily, + by_second: [0, 1, 10, 50, 59], + by_minute: [0, 1, 10, 50, 59], + by_hour: [0, 1, 6, 12, 23] + } === ICal.Deserialize.Recurrence.from_params(rrule) + end + + test "clamps day/week/month values" do + rrule = %{ + "FREQ" => "DAILY", + "BYWEEKNO" => "-54,-53,-1,0,a,1,25,2,53,54", + "BYMONTHDAY" => "-32,-31,a,-1,1,31,32", + "BYMONTH" => "0,1,12,a,13", + "BYYEARDAY" => "-367,-366,-1,0,a,,1,366,367,garbage", + "BYSETPOS" => "-367,-366,-1,0,a,,1,366,367" + } + + assert %ICal.Recurrence{ + frequency: :daily, + by_week_number: [-53, -1, 1, 25, 2, 53], + by_month_day: [-31, -1, 1, 31], + by_month: [1, 12], + by_year_day: [-366, -1, 1, 366], + by_set_position: [-366, -1, 1, 366] + } === ICal.Deserialize.Recurrence.from_params(rrule) + end + + test "ignores garbage in count and interval" do + rrule = %{ + "FREQ" => "DAILY", + "COUNT" => "GARBAGE", + "INTERVAL" => "" + } + + assert %ICal.Recurrence{ + frequency: :daily, + count: nil, + interval: 1 + } === ICal.Deserialize.Recurrence.from_params(rrule) + end + + test "parses values of frequency corrrectly" do + rrule = %{"FREQ" => "DAILY"} + + assert %ICal.Recurrence{frequency: :daily} === + ICal.Deserialize.Recurrence.from_params(rrule) + + rrule = %{"FREQ" => "WEEKLY"} + + assert %ICal.Recurrence{frequency: :weekly} === + ICal.Deserialize.Recurrence.from_params(rrule) + + rrule = %{"FREQ" => "MONTHLY"} + + assert %ICal.Recurrence{frequency: :monthly} === + ICal.Deserialize.Recurrence.from_params(rrule) + + rrule = %{"FREQ" => "YEARLY"} + + assert %ICal.Recurrence{frequency: :yearly} === + ICal.Deserialize.Recurrence.from_params(rrule) + + rrule = %{"FREQ" => "HOURLY"} + + assert %ICal.Recurrence{frequency: :hourly} === + ICal.Deserialize.Recurrence.from_params(rrule) + + rrule = %{"FREQ" => "MINUTELY"} + + assert %ICal.Recurrence{frequency: :minutely} === + ICal.Deserialize.Recurrence.from_params(rrule) + + rrule = %{"FREQ" => "SECONDLY"} + + assert %ICal.Recurrence{frequency: :secondly} === + ICal.Deserialize.Recurrence.from_params(rrule) + + rrule = %{"FREQ" => "GARBAGE"} + assert nil === ICal.Deserialize.Recurrence.from_params(rrule) + end end - test "daily reccuring event with until" do - events = - Helper.test_data("recurrance_daily_until") - |> ICal.from_ics() - |> Map.get(:events) - |> Enum.map(fn event -> - recurrences = - ICal.Recurrence.stream(event) - |> Enum.to_list() - - [event | recurrences] - end) - |> List.flatten() - - assert events |> Enum.count() == 8 - - [event | events] = events - assert event.dtstart == ~U[2015-12-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-25 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-26 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-27 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-28 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-29 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-30 08:30:00Z] - [event] = events - assert event.dtstart == ~U[2015-12-31 08:30:00Z] - end - - test "daily reccuring event with count" do - events = - Helper.test_data("recurrance_with_count") - |> ICal.from_ics() - |> Map.get(:events) - |> Enum.map(fn event -> - recurrences = - ICal.Recurrence.stream(event) - |> Enum.to_list() - - [event | recurrences] - end) - |> List.flatten() - - assert events |> Enum.count() == 3 - - [event | events] = events - assert event.dtstart == ~U[2015-12-24 08:30:00Z] - [event | _events] = events - assert event.dtstart == ~U[2015-12-25 08:30:00Z] - end - - test "monthly reccuring event with until" do - events = - Helper.test_data("recurrance_with_until_monthly") - |> ICal.from_ics() - |> Map.get(:events) - |> Enum.map(fn event -> - recurrences = - ICal.Recurrence.stream(event) - |> Enum.to_list() - - [event | recurrences] - end) - |> List.flatten() - - assert events |> Enum.count() == 7 - - [event | events] = events - assert event.dtstart == ~U[2015-12-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-01-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-02-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-03-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-04-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-05-24 08:30:00Z] - [event] = events - assert event.dtstart == ~U[2016-06-24 08:30:00Z] - end - - test "weekly reccuring event with until" do - events = - Helper.test_data("recurrance_with_until_weekly") - |> ICal.from_ics() - |> Map.get(:events) - |> Enum.map(fn event -> - recurrences = - ICal.Recurrence.stream(event) - |> Enum.to_list() - - [event | recurrences] - end) - |> List.flatten() - - assert events |> Enum.count() == 6 - - [event | events] = events - assert event.dtstart == ~U[2015-12-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-31 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-01-07 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-01-14 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-01-21 08:30:00Z] - [event] = events - assert event.dtstart == ~U[2016-01-28 08:30:00Z] - end - - test "exdates not included in reccuring event with until and byday, ignoring invalid byday value" do - events = - Helper.test_data("recurrence_until_byday") - |> ICal.from_ics() - |> Map.get(:events) - |> Enum.map(fn event -> - recurrences = - ICal.Recurrence.stream(event) - |> Enum.to_list() - - [event | recurrences] - end) - |> List.flatten() - - assert events |> Enum.count() == 5 - - [event | events] = events - assert event.dtstart == ~U[2020-09-03 14:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2020-09-30 14:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2020-10-01 14:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2020-10-14 14:30:00Z] - [event] = events - assert event.dtstart == ~U[2020-10-15 14:30:00Z] - end - - test "Recurrence deserialization ignores bad WKST values" do - rrule = %{"FREQ" => "DAILY", "WKST" => "NO"} - - assert %ICal.Recurrence{frequency: :daily, weekday: nil} === - ICal.Deserialize.Recurrence.from_params(rrule) - end - - test "Recurrence deserialization clamps time values" do - rrule = %{ - "FREQ" => "DAILY", - "BYSECOND" => "-1,-,0,1,10,50,59,60,70", - "BYMINUTE" => "-1,-,0,1,10,50,59,60,70", - "BYHOUR" => "-1,-,0,1,6,12,23,24" - } - - assert %ICal.Recurrence{ - frequency: :daily, - by_second: [0, 1, 10, 50, 59], - by_minute: [0, 1, 10, 50, 59], - by_hour: [0, 1, 6, 12, 23] - } === ICal.Deserialize.Recurrence.from_params(rrule) - end - - test "Recurrence deserialization clamps day/week/month values" do - rrule = %{ - "FREQ" => "DAILY", - "BYWEEKNO" => "-54,-53,-1,0,a,1,25,2,53,54", - "BYMONTHDAY" => "-32,-31,a,-1,1,31,32", - "BYMONTH" => "0,1,12,a,13", - "BYYEARDAY" => "-367,-366,-1,0,a,,1,366,367,garbage", - "BYSETPOS" => "-367,-366,-1,0,a,,1,366,367" - } - - assert %ICal.Recurrence{ - frequency: :daily, - by_week_number: [-53, -1, 1, 25, 2, 53], - by_month_day: [-31, -1, 1, 31], - by_month: [1, 12], - by_year_day: [-366, -1, 1, 366], - by_set_position: [-366, -1, 1, 366] - } === ICal.Deserialize.Recurrence.from_params(rrule) - end - - test "Recurrence deserialization ignores garbage in count and interval" do - rrule = %{ - "FREQ" => "DAILY", - "COUNT" => "GARBAGE", - "INTERVAL" => "" - } - - assert %ICal.Recurrence{ - frequency: :daily, - count: nil, - interval: 1 - } === ICal.Deserialize.Recurrence.from_params(rrule) - end - - test "Recurrence de/serializes weekday abbreviations corrrectly" do - rrule = %{"FREQ" => "DAILY", "BYDAY" => "-1SU,SU,1MO,-1TU,+2WE,TH,FR,SA,GA,GARBAGE,,0,-1"} - - recurrence = %ICal.Recurrence{ - frequency: :daily, - by_day: [ - {-1, :sunday}, - {nil, :sunday}, - {1, :monday}, - {-1, :tuesday}, - {2, :wednesday}, - {nil, :thursday}, - {nil, :friday}, - {nil, :saturday} - ] - } - - assert recurrence === ICal.Deserialize.Recurrence.from_params(rrule) - - serialized = ICal.Serialize.Recurrence.property(recurrence) |> to_string() - - assert String.starts_with?(serialized, "RRULE:FREQ=DAILY") - assert String.contains?(serialized, ";INTERVAL=1") - assert String.contains?(serialized, ";BYDAY=-1SUSU1MO-1TU2WETHFRSA") - assert String.ends_with?(serialized, "\n") - end - - test "Recurrence deserialization parses values of frequency corrrectly" do - rrule = %{"FREQ" => "DAILY"} - assert %ICal.Recurrence{frequency: :daily} === ICal.Deserialize.Recurrence.from_params(rrule) - - rrule = %{"FREQ" => "WEEKLY"} - assert %ICal.Recurrence{frequency: :weekly} === ICal.Deserialize.Recurrence.from_params(rrule) - - rrule = %{"FREQ" => "MONTHLY"} - - assert %ICal.Recurrence{frequency: :monthly} === - ICal.Deserialize.Recurrence.from_params(rrule) - - rrule = %{"FREQ" => "YEARLY"} - assert %ICal.Recurrence{frequency: :yearly} === ICal.Deserialize.Recurrence.from_params(rrule) - - rrule = %{"FREQ" => "HOURLY"} - assert %ICal.Recurrence{frequency: :hourly} === ICal.Deserialize.Recurrence.from_params(rrule) - - rrule = %{"FREQ" => "MINUTELY"} - - assert %ICal.Recurrence{frequency: :minutely} === - ICal.Deserialize.Recurrence.from_params(rrule) - - rrule = %{"FREQ" => "SECONDLY"} - - assert %ICal.Recurrence{frequency: :secondly} === - ICal.Deserialize.Recurrence.from_params(rrule) - - rrule = %{"FREQ" => "GARBAGE"} - assert nil === ICal.Deserialize.Recurrence.from_params(rrule) + describe "RRULE: generating recurrences" do + test "event with no recurrences" do + assert [] == + Fixtures.one_event() + |> ICal.Recurrence.stream() + |> Enum.to_list() + end + + test "daily reccuring event with until" do + events = + Helper.test_data("recurrance_daily_until") + |> ICal.from_ics() + |> Map.get(:events) + |> Enum.map(fn event -> + recurrences = + ICal.Recurrence.stream(event) + |> Enum.to_list() + + [event | recurrences] + end) + |> List.flatten() + + assert events |> Enum.count() == 8 + + [event | events] = events + assert event.dtstart == ~U[2015-12-24 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2015-12-25 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2015-12-26 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2015-12-27 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2015-12-28 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2015-12-29 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2015-12-30 08:30:00Z] + [event] = events + assert event.dtstart == ~U[2015-12-31 08:30:00Z] + end + + test "daily reccuring event with count" do + events = + Helper.test_data("recurrance_with_count") + |> ICal.from_ics() + |> Map.get(:events) + |> Enum.map(fn event -> + recurrences = + ICal.Recurrence.stream(event) + |> Enum.to_list() + + [event | recurrences] + end) + |> List.flatten() + + assert events |> Enum.count() == 3 + + [event | events] = events + assert event.dtstart == ~U[2015-12-24 08:30:00Z] + [event | _events] = events + assert event.dtstart == ~U[2015-12-25 08:30:00Z] + end + + test "monthly reccuring event with until" do + events = + Helper.test_data("recurrance_with_until_monthly") + |> ICal.from_ics() + |> Map.get(:events) + |> Enum.map(fn event -> + recurrences = + ICal.Recurrence.stream(event) + |> Enum.to_list() + + [event | recurrences] + end) + |> List.flatten() + + assert events |> Enum.count() == 7 + + [event | events] = events + assert event.dtstart == ~U[2015-12-24 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2016-01-24 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2016-02-24 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2016-03-24 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2016-04-24 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2016-05-24 08:30:00Z] + [event] = events + assert event.dtstart == ~U[2016-06-24 08:30:00Z] + end + + test "weekly reccuring event with until" do + events = + Helper.test_data("recurrance_with_until_weekly") + |> ICal.from_ics() + |> Map.get(:events) + |> Enum.map(fn event -> + recurrences = + ICal.Recurrence.stream(event) + |> Enum.to_list() + + [event | recurrences] + end) + |> List.flatten() + + assert events |> Enum.count() == 6 + + [event | events] = events + assert event.dtstart == ~U[2015-12-24 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2015-12-31 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2016-01-07 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2016-01-14 08:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2016-01-21 08:30:00Z] + [event] = events + assert event.dtstart == ~U[2016-01-28 08:30:00Z] + end + + test "exdates not included in reccuring event with until and byday, ignoring invalid byday value" do + events = + Helper.test_data("recurrence_until_byday") + |> ICal.from_ics() + |> Map.get(:events) + |> Enum.map(fn event -> + recurrences = + ICal.Recurrence.stream(event) + |> Enum.to_list() + + [event | recurrences] + end) + |> List.flatten() + + assert events |> Enum.count() == 5 + + [event | events] = events + assert event.dtstart == ~U[2020-09-03 14:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2020-09-30 14:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2020-10-01 14:30:00Z] + [event | events] = events + assert event.dtstart == ~U[2020-10-14 14:30:00Z] + [event] = events + assert event.dtstart == ~U[2020-10-15 14:30:00Z] + end end end From 0687f5707e2ea1559290ff61324f3983f92f11cf Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 15 Apr 2026 10:09:14 +0200 Subject: [PATCH 02/96] remove duplicates, sort for easier processing --- lib/ical/deserialize/recurrence.ex | 5 ++++- test/ical/recurrence_test.exs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/ical/deserialize/recurrence.ex b/lib/ical/deserialize/recurrence.ex index 01963a7..faaae0e 100644 --- a/lib/ical/deserialize/recurrence.ex +++ b/lib/ical/deserialize/recurrence.ex @@ -113,7 +113,10 @@ defmodule ICal.Deserialize.Recurrence do end defp to_clamped_numbers(string, min, max) do - to_clamped_numbers(string, min, max, "", []) + string + |> to_clamped_numbers(min, max, "", []) + |> Enum.sort() + |> Enum.uniq() end defp to_clamped_numbers(<<>>, min, max, value, acc) do diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 7320d2b..ae945c5 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -104,7 +104,7 @@ defmodule ICal.RecurrenceTest do assert %ICal.Recurrence{ frequency: :daily, - by_week_number: [-53, -1, 1, 25, 2, 53], + by_week_number: [-53, -1, 1, 2, 25, 53], by_month_day: [-31, -1, 1, 31], by_month: [1, 12], by_year_day: [-366, -1, 1, 366], From cae8764dc217b466f8150d4d8b30f3a19cffb0a5 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 15 Apr 2026 11:00:04 +0200 Subject: [PATCH 03/96] use 0 offsets, not nil --- lib/ical/deserialize/recurrence.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ical/deserialize/recurrence.ex b/lib/ical/deserialize/recurrence.ex index faaae0e..9a83c84 100644 --- a/lib/ical/deserialize/recurrence.ex +++ b/lib/ical/deserialize/recurrence.ex @@ -171,7 +171,7 @@ defmodule ICal.Deserialize.Recurrence do # no offset, so just send it in with a 0 for the offset, which the user will ignore # in a {0, someweekday} tuple defp to_weekdays(string, acc) do - to_weekday_with_offset(string, nil, acc) + to_weekday_with_offset(string, 0, acc) end # doing an entry with an offset, and we have more numbers, so keep going From a3873606ffe523ad7b0a00a46acf308c6dc3ffa7 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 15 Apr 2026 11:00:23 +0200 Subject: [PATCH 04/96] normalize recurrece rule values after parsing this enforces uniqueness, and sorts for later ease of use in recurrence calculations some entries, such as the order of the weekday entries (if any), can depend on OTHER entries such as the weekday start entry --- lib/ical/deserialize/recurrence.ex | 52 +++++++--------- lib/ical/recurrence.ex | 95 ++++++++++++++++++++++++++---- test/ical/recurrence_test.exs | 14 ++--- 3 files changed, 113 insertions(+), 48 deletions(-) diff --git a/lib/ical/deserialize/recurrence.ex b/lib/ical/deserialize/recurrence.ex index 9a83c84..ebe6f07 100644 --- a/lib/ical/deserialize/recurrence.ex +++ b/lib/ical/deserialize/recurrence.ex @@ -9,14 +9,6 @@ defmodule ICal.Deserialize.Recurrence do add_frequency(%ICal.Recurrence{}, params) end - @spec from_event(map | ICal.Event.t()) :: ICal.Recurrence.t() | nil - def from_event(%ICal.Event{rrule: %ICal.Recurrence{} = rule}), do: rule - def from_event(%ICal.Event{rrule: nil}), do: nil - - def from_event(%ICal.Event{} = event) do - add_frequency(%ICal.Recurrence{}, event.rrule) - end - defp add_frequency(recurrence, params) do with freq when not is_nil(freq) <- Map.get(params, "FREQ"), {:ok, frequency} <- to_frequency_atom(freq) do @@ -25,6 +17,7 @@ defmodule ICal.Deserialize.Recurrence do %{recurrence | frequency: frequency}, &add_to_recurrence/2 ) + |> ICal.Recurrence.normalize() else _ -> nil end @@ -61,17 +54,17 @@ defmodule ICal.Deserialize.Recurrence do # sets of numbres, except BYDAY which is extra complicated. # see to_weekdays for more on that. defp add_by_list_to_recurrence("SECOND", value, recurrence) do - number_list = to_clamped_numbers(value, 0, 59) + number_list = parse_number_list(value) %{recurrence | by_second: number_list} end defp add_by_list_to_recurrence("MINUTE", value, recurrence) do - number_list = to_clamped_numbers(value, 0, 59) + number_list = parse_number_list(value) %{recurrence | by_minute: number_list} end defp add_by_list_to_recurrence("HOUR", value, recurrence) do - number_list = to_clamped_numbers(value, 0, 23) + number_list = parse_number_list(value) %{recurrence | by_hour: number_list} end @@ -81,27 +74,27 @@ defmodule ICal.Deserialize.Recurrence do end defp add_by_list_to_recurrence("MONTH", value, recurrence) do - number_list = to_clamped_numbers(value, 1, 12) + number_list = parse_number_list(value) %{recurrence | by_month: number_list} end defp add_by_list_to_recurrence("MONTHDAY", value, recurrence) do - number_list = to_clamped_numbers(value, -31, 31) + number_list = parse_number_list(value) %{recurrence | by_month_day: number_list} end defp add_by_list_to_recurrence("YEARDAY", value, recurrence) do - number_list = to_clamped_numbers(value, -366, 366) + number_list = parse_number_list(value) %{recurrence | by_year_day: number_list} end defp add_by_list_to_recurrence("WEEKNO", value, recurrence) do - number_list = to_clamped_numbers(value, -53, 53) + number_list = parse_number_list(value) %{recurrence | by_week_number: number_list} end defp add_by_list_to_recurrence("SETPOS", value, recurrence) do - number_list = to_clamped_numbers(value, -366, 366) + number_list = parse_number_list(value) %{recurrence | by_set_position: number_list} end @@ -112,36 +105,33 @@ defmodule ICal.Deserialize.Recurrence do end end - defp to_clamped_numbers(string, min, max) do + def parse_number_list(string) do string - |> to_clamped_numbers(min, max, "", []) + |> accumulate_numbers("", []) |> Enum.sort() |> Enum.uniq() end - defp to_clamped_numbers(<<>>, min, max, value, acc) do - clamp_number(value, min, max, acc) + defp accumulate_numbers(<<>>, value, acc) do + accumulate_if_number(value, acc) end - defp to_clamped_numbers(<>, min, max, value, acc) do - acc = clamp_number(value, min, max, acc) - to_clamped_numbers(string, min, max, "", acc) + defp accumulate_numbers(<>, value, acc) do + acc = accumulate_if_number(value, acc) + accumulate_numbers(string, "", acc) end - defp to_clamped_numbers(<>, min, max, value, acc) do - to_clamped_numbers(string, min, max, <>, acc) + defp accumulate_numbers(<>, value, acc) do + accumulate_numbers(string, <>, acc) end - defp clamp_number(value, min, max, acc) do + defp accumulate_if_number(value, acc) do # zeros are only allowed when the min value is also zero: # a negative min means no zeros, and if the min is above zero, obviously # zero is not ok case Deserialize.to_integer(value) do - 0 when min == 0 -> - acc ++ [0] - - number when is_number(number) and number != 0 and number <= max and number >= min -> - acc ++ [number] + number when is_number(number) -> + [number | acc] _ -> acc diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 9c064cc..8bb197c 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -34,18 +34,93 @@ defmodule ICal.Recurrence do until: DateTime.t() | nil, count: integer, interval: integer, - by_second: [non_neg_integer], - by_minute: [non_neg_integer], - by_hour: [non_neg_integer], - by_day: [{offset :: integer, byday :: weekday}], - by_month_day: [non_neg_integer], - by_year_day: [non_neg_integer], - by_month: [non_neg_integer], - by_week_number: [non_neg_integer], - by_set_position: [non_neg_integer], - weekday: weekday + by_second: [non_neg_integer] | nil, + by_minute: [non_neg_integer] | nil, + by_hour: [non_neg_integer] | nil, + by_day: [{offset :: integer, byday :: weekday}] | nil, + by_month_day: [non_neg_integer] | nil, + by_year_day: [non_neg_integer] | nil, + by_month: [non_neg_integer] | nil, + by_week_number: [non_neg_integer] | nil, + by_set_position: [non_neg_integer] | nil, + weekday: weekday | nil } + def normalize(%__MODULE__{} = recurrence) do + %{ + recurrence + | by_second: clamped_numbers(recurrence.by_second, 0, 59), + by_minute: clamped_numbers(recurrence.by_minute, 0, 59), + by_hour: clamped_numbers(recurrence.by_hour, 0, 23), + by_day: normalize_weekdays(recurrence.by_day, recurrence.weekday), + by_month_day: clamped_numbers(recurrence.by_month_day, -31, 31), + by_year_day: clamped_numbers(recurrence.by_year_day, -366, 366), + by_month: clamped_numbers(recurrence.by_month, 1, 12), + by_set_position: clamped_numbers(recurrence.by_set_position, -366, 366), + by_week_number: clamped_numbers(recurrence.by_week_number, -53, 53) + } + end + + defp clamped_numbers(nil, _min, __max), do: nil + + defp clamped_numbers(numbers, min, max) do + numbers + |> Enum.sort() + |> Enum.uniq() + |> Enum.reduce( + [], + fn number, acc -> + case number do + 0 when min == 0 -> + acc ++ [0] + + number when is_number(number) and number != 0 and number <= max and number >= min -> + acc ++ [number] + + _ -> + acc + end + end + ) + end + + def normalize_weekdays(nil, _week_start) do + nil + end + + def normalize_weekdays(weekdays, week_start) do + valid_weekdays = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday] + + weekday_order = + if week_start == nil do + valid_weekdays + else + index = Enum.find_index(valid_weekdays, fn wk -> wk == week_start end) || 0 + {l, r} = Enum.split(valid_weekdays, max(0, index)) + r ++ l + end + |> Enum.with_index() + |> Enum.into(%{}) + + weekdays + |> Enum.filter(fn {_, weekday} -> Enum.member?(valid_weekdays, weekday) end) + |> Enum.uniq() + |> Enum.sort(fn {loffset, l}, {roffset, r} -> + if loffset == roffset do + Map.get(weekday_order, l) < Map.get(weekday_order, r) + else + l_is_neg = loffset < 0 + r_is_neg = roffset < 0 + + if l_is_neg == r_is_neg do + loffset < roffset + else + r_is_neg + end + end + end) + end + # ignore :byhour, :monthday, :byyearday, :byweekno, :bymonth for now @supported_by_x_rrules [:by_day] diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index ae945c5..234ad72 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -46,14 +46,14 @@ defmodule ICal.RecurrenceTest do recurrence = %ICal.Recurrence{ frequency: :daily, by_day: [ - {-1, :sunday}, - {nil, :sunday}, + {0, :thursday}, + {0, :friday}, + {0, :saturday}, + {0, :sunday}, {1, :monday}, - {-1, :tuesday}, {2, :wednesday}, - {nil, :thursday}, - {nil, :friday}, - {nil, :saturday} + {-1, :tuesday}, + {-1, :sunday} ] } @@ -63,7 +63,7 @@ defmodule ICal.RecurrenceTest do assert String.starts_with?(serialized, "RRULE:FREQ=DAILY") assert String.contains?(serialized, ";INTERVAL=1") - assert String.contains?(serialized, ";BYDAY=-1SUSU1MO-1TU2WETHFRSA") + assert String.contains?(serialized, ";BYDAY=THFRSASU1MO2WE-1TU-1SU") assert String.ends_with?(serialized, "\n") end end From 040e6e6de044df20333971b9796003659d5d3101 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 15 Apr 2026 19:38:16 +0200 Subject: [PATCH 05/96] improve typing accuracy --- lib/ical/recurrence.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 8bb197c..f953f48 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -32,8 +32,8 @@ defmodule ICal.Recurrence do @type t :: %__MODULE__{ frequency: frequency, until: DateTime.t() | nil, - count: integer, - interval: integer, + count: non_neg_integer, + interval: non_neg_integer, by_second: [non_neg_integer] | nil, by_minute: [non_neg_integer] | nil, by_hour: [non_neg_integer] | nil, From 5cc5c7c9b06bcf7dadb55f1248344f8a9bf0c271 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 15 Apr 2026 19:39:20 +0200 Subject: [PATCH 06/96] small improvements in recurrence normalization --- lib/ical/recurrence.ex | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index f953f48..bfd130c 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -57,10 +57,18 @@ defmodule ICal.Recurrence do by_year_day: clamped_numbers(recurrence.by_year_day, -366, 366), by_month: clamped_numbers(recurrence.by_month, 1, 12), by_set_position: clamped_numbers(recurrence.by_set_position, -366, 366), - by_week_number: clamped_numbers(recurrence.by_week_number, -53, 53) + by_week_number: clamped_numbers(recurrence.by_week_number, -53, 53), + count: nil_or_positive(recurrence.count), + interval: positive(recurrence.interval, 1) } end + defp nil_or_positive(value) when is_integer(value) and value > 0, do: value + defp nil_or_positive(_), do: nil + + defp positive(value, _default) when is_integer(value) and value > 0, do: value + defp positive(_, default), do: default + defp clamped_numbers(nil, _min, __max), do: nil defp clamped_numbers(numbers, min, max) do @@ -103,7 +111,6 @@ defmodule ICal.Recurrence do |> Enum.into(%{}) weekdays - |> Enum.filter(fn {_, weekday} -> Enum.member?(valid_weekdays, weekday) end) |> Enum.uniq() |> Enum.sort(fn {loffset, l}, {roffset, r} -> if loffset == roffset do From a7d715b0eba9c717784055810281890d6c6f2bfa Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 15 Apr 2026 19:39:43 +0200 Subject: [PATCH 07/96] `Reccurence.from_ics` convenience to parse recurrence from a string --- lib/ical/recurrence.ex | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index bfd130c..8bff4da 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -63,6 +63,12 @@ defmodule ICal.Recurrence do } end + def from_ics(<<"RRULE", data::binary>>) do + data = ICal.Deserialize.skip_params(data) + {_data, values} = ICal.Deserialize.param_list(data) + ICal.Deserialize.Recurrence.from_params(values) + end + defp nil_or_positive(value) when is_integer(value) and value > 0, do: value defp nil_or_positive(_), do: nil From fb2afc46db8213b3b5a13bf4f0928ef961f85eda Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 15 Apr 2026 19:40:44 +0200 Subject: [PATCH 08/96] add a TODO for a future API-breaking release --- lib/ical/recurrence.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 8bff4da..5e9f667 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -10,6 +10,7 @@ defmodule ICal.Recurrence do require Logger + # TODO: weekday should be renamed to week_start_day defstruct [ :until, :count, From 67c08561c1867a181153a901b4a0f0af7605c6da Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 15 Apr 2026 21:38:08 +0200 Subject: [PATCH 09/96] a handy little timer function; benchee_lite --- test/test_helper.exs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/test_helper.exs b/test/test_helper.exs index 110fcbf..9a8031e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -19,6 +19,14 @@ defmodule ICal.Test.Helper do "-//Elixir ICal//v#{version}//#{custom_vendor}//EN" end + @doc "Times a function" + @spec time(fun, label :: String.t()) :: term + def time(function, label \\ "") do + {time, value} = :timer.tc(function, :microsecond) + IO.puts("TIME #{label} => #{time} microseconds / #{time / 1000} ms") + value + end + defmacro __using__(_) do quote do alias ICal.Test.Helper From a6db42aa925f1fded2539b20394bc7bc4cb1a8c7 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 15 Apr 2026 21:38:47 +0200 Subject: [PATCH 10/96] make the default weekday :default; works nicely with Date fns --- lib/ical/recurrence.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 5e9f667..267dd93 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -23,7 +23,7 @@ defmodule ICal.Recurrence do :by_month, :by_set_position, :by_week_number, - :weekday, + weekday: :default, frequency: :daily, interval: 1 ] @@ -44,7 +44,7 @@ defmodule ICal.Recurrence do by_month: [non_neg_integer] | nil, by_week_number: [non_neg_integer] | nil, by_set_position: [non_neg_integer] | nil, - weekday: weekday | nil + weekday: weekday | :default } def normalize(%__MODULE__{} = recurrence) do From 0b03e4ebcd19ec8af8301f2b939bc1d46da87ae9 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 15 Apr 2026 21:39:09 +0200 Subject: [PATCH 11/96] beginning of a full-featured recurrence calculator --- lib/ical/recurrence/generate.ex | 341 ++++++++++++++++++++++++++++++++ test/ical/recurrence_test.exs | 187 ++++++++++++++++++ 2 files changed, 528 insertions(+) create mode 100644 lib/ical/recurrence/generate.ex diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex new file mode 100644 index 0000000..0416511 --- /dev/null +++ b/lib/ical/recurrence/generate.ex @@ -0,0 +1,341 @@ +defmodule ICal.Recurrence.Generate do + @moduledoc false + + defguard has_some(x) when is_list(x) and x != [] + defguard has_none(x) when not has_some(x) + + def all(%ICal.Recurrence{frequency: :yearly, interval: interval} = rule, dtstart) do + generate( + limiter(rule), + dtstart, + [year: interval], + [ + :by_month, + :by_week_number, + :by_year_day, + :by_month_day, + :by_day, + :by_hour, + :by_minute, + :by_second + ], + [:by_set_position], + rule + ) + end + + def all(%ICal.Recurrence{frequency: :monthly, interval: interval} = rule, dtstart) do + generate( + limiter(rule), + dtstart, + [month: interval], + [:by_month_day, :by_day, :by_hour, :by_minute, :by_second], + [:by_month, :by_set_position], + rule + ) + end + + def all(%ICal.Recurrence{frequency: :weekly, interval: interval} = rule, dtstart) do + generate( + limiter(rule), + dtstart, + [week: interval], + [:by_day, :by_hour, :by_minute, :by_second], + [:by_month, :by_set_position], + rule + ) + end + + def all(%ICal.Recurrence{frequency: :daily, interval: interval} = rule, dtstart) do + generate( + limiter(rule), + dtstart, + [day: interval], + [:by_hour, :by_minute, :by_second], + [:by_month, :by_day, :by_set_position], + rule + ) + end + + def all(%ICal.Recurrence{frequency: :hourly, interval: interval} = rule, dtstart) do + generate( + limiter(rule), + dtstart, + [hour: interval], + [:by_minute, :by_second], + [:by_month, :by_year_day, :by_month_day, :by_day, :by_hour, :by_set_position], + rule + ) + end + + def all(%ICal.Recurrence{frequency: :minutely, interval: interval} = rule, dtstart) do + generate( + limiter(rule), + dtstart, + [minute: interval], + [:by_second], + [:by_month, :by_year_day, :by_month_day, :by_day, :by_hour, :by_minute, :by_set_position], + rule + ) + end + + def all(%ICal.Recurrence{frequency: :secondly, interval: interval} = rule, dtstart) do + generate( + limiter(rule), + dtstart, + [second: interval], + [], + [ + :by_month, + :by_year_day, + :by_month_day, + :by_day, + :by_hour, + :by_minute, + :by_second, + :by_set_position + ], + rule + ) + end + + defp generate(limit, dtstart, offset, expanders, limiters, rule) do + recurrences = + [dtstart] + |> expand(expanders, rule) + |> limit(limiters, rule) + |> Enum.filter(fn date -> is_not_before(date, dtstart) end) + + {limit, recurrences} = update_limit(limit, recurrences) + + generate( + limit, + dtstart, + offset, + expanders, + limiters, + rule, + recurrences + ) + end + + defp generate(limit, _dtstart, _offset, _expanders, _limiters, _rule, acc) + when is_integer(limit) and limit < 1, do: acc + + defp generate(limit, dtstart, offset, expanders, limiters, rule, acc) do + dtnext = shift(dtstart, offset) + + recurrences = + [dtnext] + |> expand(expanders, rule) + |> limit(limiters, rule) + + {limit, recurrences} = update_limit(limit, recurrences) + + if limit == nil do + acc ++ recurrences + else + generate( + limit, + dtnext, + offset, + expanders, + limiters, + rule, + acc ++ recurrences + ) + end + end + + defp expand(recurrences, expanders, rule) do + Enum.reduce(expanders, recurrences, fn expand_by, acc -> expand_by(expand_by, rule, acc) end) + end + + defp limit(recurrences, limiters, rule) do + Enum.reduce(limiters, recurrences, fn limit_by, acc -> limit_by(limit_by, rule, acc) end) + end + + defp expand_by(:by_month, %{by_month: months}, acc) when has_some(months) do + Enum.reduce(acc, [], fn dtstart, acc -> + acc ++ Enum.map(months, fn month -> %{dtstart | month: month} end) + end) + end + + defp expand_by(:by_week_number, %{by_week_number: weeks}, acc) when has_none(weeks), do: acc + + defp expand_by(:by_week_number, %{by_month: months} = rule, acc) when has_some(months) do + # it was expanded by months, so limit the occurances by week number + limit_by(:by_week_number, rule, acc) + end + + defp expand_by(:by_week_number, %{by_week_number: weeks}, acc) do + Enum.reduce(acc, [], fn recurrence, acc -> + acc ++ + Enum.flat_map(weeks, fn week -> + {first, last} = + week_number_bookends(recurrence, week) + + range(first, last, recurrence) + end) + end) + end + + defp expand_by(:by_year_day, %{by_year_day: days}, acc) when has_none(days) do + acc + end + + defp expand_by(:by_year_day, %{by_month: months, by_week_number: weeks} = rule, acc) + when has_some(months) or has_some(weeks) do + # we are limiting rather than expanding + limit_by(:by_year_day, rule, acc) + end + + defp expand_by(:by_year_day, %{by_year_day: year_days}, acc) do + Enum.uniq_by(acc, fn recurrence -> recurrence.year end) + |> Enum.flat_map(fn recurrence -> + first_of_jan = %{recurrence | month: 1, day: 1} + + Enum.map(year_days, fn day_of_year -> + shift(first_of_jan, day: day_of_year) + end) + end) + end + + defp expand_by(_by, _rule, acc), do: acc + + defp limit_by(:by_set_position, %{by_set_position: index}, recurrences) + when is_integer(index) and index != 0 do + index = if index > 0, do: index - 1, else: index + + case Enum.at(recurrences, index) do + nil -> [] + recurrence -> [recurrence] + end + end + + defp limit_by(:by_year_day, %{by_year_day: year_days}, acc) when has_some(year_days) do + Enum.filter(acc, fn recurrence -> + Enum.member?(year_days, Date.day_of_year(recurrence)) + end) + end + + defp limit_by(:by_week_number, %{by_week_number: weeks}, acc) when has_some(weeks) do + Enum.filter(acc, fn recurrence -> + Enum.find(weeks, fn week -> + {week_start, week_end} = week_number_bookends(recurrence, week) + is_between(week_start, recurrence, week_end) + end) != nil + end) + end + + defp limit_by(:by_month, %{by_month: months}, acc) when has_some(months) do + Enum.filter(acc, fn recurrence -> + Enum.member?(months, recurrence.month) + end) + end + + defp limit_by(:by_month_day, %{by_month_day: days}, acc) when has_some(days) do + Enum.filter(acc, fn recurrence -> + Enum.member?(days, recurrence.day) + end) + end + + defp limit_by(:by_day, %{by_day: days}, acc) when has_some(days) do + Enum.filter(acc, fn recurrence -> + target = weekday(recurrence) + Enum.find(days, fn {_, allowed_day} -> allowed_day == target end) != nil + end) + end + + defp limit_by(_limiter, _rule, recurrences), do: recurrences + + defp limiter(%{count: count}) when is_integer(count), do: count + defp limiter(%{until: until}), do: until + + # TODO: is the start of the week needed here? + def weekday(%Date{} = date) do + index_date = Date.day_of_week(date) + days = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday] + Enum.at(days, index_date - 1) + end + + def weekday(%DateTime{} = dt), do: weekday(DateTime.to_date(dt)) + + defp update_limit(limit, recurrences) when is_integer(limit) do + updated_limit = limit - Enum.count(recurrences) + + if updated_limit < 1 do + {nil, Enum.slice(recurrences, 0, limit)} + else + {updated_limit, recurrences} + end + end + + defp update_limit(limit, recurrences) do + index = Enum.find_index(recurrences, fn recurrence -> is_not_after(limit, recurrence) end) + + if index != nil do + {nil, Enum.slice(recurrences, 0, index + 1)} + else + {limit, recurrences} + end + end + + def range(first, last, %Date{}) do + Date.range(first, last) |> Enum.to_list() + end + + def range(first, last, %DateTime{} = dt) do + time = DateTime.to_time(dt) + Date.range(first, last) |> Enum.map(fn date -> DateTime.new!(date, time) end) + end + + defp shift(%DateTime{} = dtstart, offset), do: DateTime.shift(dtstart, offset) + defp shift(%Date{} = dtstart, offset), do: Date.shift(dtstart, offset) + + def week_number_bookends(dtstart, week) do + # shift the week + if week > 0 do + # positive week number, start from first day of the year + start_date = + Date.new!(dtstart.year, 1, 1) + |> Date.shift(week: week - 1) + |> Date.beginning_of_week() + + end_date = + start_date + |> Date.end_of_week() + + {start_date, end_date} + else + # negative week number, start from the last week of the year + # and since it is already on the last week, move one less week than requested + # e.g. the -1 week is 0 weeks from the last week of the year + start_date = + Date.new!(dtstart.year, 1, 1) + |> Date.shift(year: 1) + |> Date.end_of_week() + |> Date.shift(day: 1) + |> IO.inspect() + |> Date.shift(week: week) + + end_date = start_date |> Date.end_of_week() + + {start_date, end_date} + end + end + + defp is_between(earliest, middle, latest) do + is_not_after(earliest, middle) and is_not_after(middle, latest) + end + + defp is_not_before(%Date{} = d, %DateTime{} = dt), do: is_not_before(d, DateTime.to_date(dt)) + defp is_not_before(%DateTime{} = dt, %Date{} = d), do: is_not_before(DateTime.to_date(dt), d) + defp is_not_before(%Date{} = l, r), do: Date.compare(l, r) != :lt + defp is_not_before(%DateTime{} = l, r), do: DateTime.compare(l, r) != :lt + + defp is_not_after(%Date{} = d, %DateTime{} = dt), do: is_not_after(d, DateTime.to_date(dt)) + defp is_not_after(%DateTime{} = dt, %Date{} = d), do: is_not_after(DateTime.to_date(dt), d) + defp is_not_after(%Date{} = l, r), do: Date.compare(l, r) != :gt + defp is_not_after(%DateTime{} = l, r), do: DateTime.compare(l, r) != :gt +end diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 234ad72..912fe01 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -321,4 +321,191 @@ defmodule ICal.RecurrenceTest do assert event.dtstart == ~U[2020-10-15 14:30:00Z] end end + + describe "RRULE: generate with yearly frequence" do + test "simple" do + count = 5 + rule = %ICal.Recurrence{frequency: :yearly, count: count} + dtstart = ~U[2026-04-15 13:00:00Z] + + recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + + assert Enum.count(recurrences) == count + end + + test "by month" do + count = 5 + rule = %ICal.Recurrence{frequency: :yearly, count: count, by_month: [1, 4, 6]} + dtstart = ~U[2026-04-15 13:00:00Z] + + recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + + assert Enum.count(recurrences) == count + [recurrence | _] = recurrences + assert recurrence.month == 4 + end + + test "positive set position" do + count = 5 + + rule = %ICal.Recurrence{ + frequency: :yearly, + count: count, + by_month: [1, 4, 6], + by_set_position: 1 + } + + dtstart = ~U[2026-04-15 13:00:00Z] + + recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + + assert Enum.count(recurrences) == count + end + + test "negative set position" do + count = 5 + + rule = %ICal.Recurrence{ + frequency: :yearly, + count: count, + by_month: [1, 4, 6], + by_set_position: -1 + } + + dtstart = ~U[2026-04-15 13:00:00Z] + + recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + + assert Enum.count(recurrences) == count + [recurrence | _] = recurrences + assert recurrence.month == 6 + end + + test "by week number" do + count = 22 + rule = %ICal.Recurrence{frequency: :yearly, count: count, by_week_number: [3, 17]} + dtstart = ~U[2026-04-15 13:00:00Z] + + recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + + assert Enum.count(recurrences) == count + + assert [ + ~U[2026-04-20 13:00:00Z], + ~U[2026-04-21 13:00:00Z], + ~U[2026-04-22 13:00:00Z], + ~U[2026-04-23 13:00:00Z], + ~U[2026-04-24 13:00:00Z], + ~U[2026-04-25 13:00:00Z], + ~U[2026-04-26 13:00:00Z], + ~U[2027-01-11 13:00:00Z], + ~U[2027-01-12 13:00:00Z], + ~U[2027-01-13 13:00:00Z], + ~U[2027-01-14 13:00:00Z], + ~U[2027-01-15 13:00:00Z], + ~U[2027-01-16 13:00:00Z], + ~U[2027-01-17 13:00:00Z], + ~U[2027-04-19 13:00:00Z], + ~U[2027-04-20 13:00:00Z], + ~U[2027-04-21 13:00:00Z], + ~U[2027-04-22 13:00:00Z], + ~U[2027-04-23 13:00:00Z], + ~U[2027-04-24 13:00:00Z], + ~U[2027-04-25 13:00:00Z], + ~U[2028-01-10 13:00:00Z] + ] == recurrences + end + + test "by week number applied to by month " do + count = 5 + + rule = %ICal.Recurrence{ + frequency: :yearly, + count: count, + by_month: [1, 4, 6], + by_week_number: [3, 17] + } + + dtstart = ~U[2026-04-15 13:00:00Z] + + recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + + assert Enum.count(recurrences) == count + + assert [ + ~U[2027-01-15 13:00:00Z], + ~U[2028-01-15 13:00:00Z], + ~U[2029-01-15 13:00:00Z], + ~U[2030-01-15 13:00:00Z], + ~U[2031-01-15 13:00:00Z] + ] == recurrences + end + + test "by year day" do + count = 5 + rule = %ICal.Recurrence{frequency: :yearly, count: count, by_year_day: [15, 50]} + dtstart = ~U[2026-04-15 13:00:00Z] + + recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + + assert Enum.count(recurrences) == count + + assert [ + ~U[2027-01-16 13:00:00Z], + ~U[2027-02-20 13:00:00Z], + ~U[2028-01-16 13:00:00Z], + ~U[2028-02-20 13:00:00Z], + ~U[2029-01-16 13:00:00Z] + ] == recurrences + end + + test "by year day applied to by month" do + count = 5 + + rule = %ICal.Recurrence{ + frequency: :yearly, + count: count, + by_month: [1, 4, 6], + by_year_day: [15, 50] + } + + dtstart = ~U[2026-04-15 13:00:00Z] + + recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + + assert Enum.count(recurrences) == count + + assert [ + ~U[2027-01-15 13:00:00Z], + ~U[2028-01-15 13:00:00Z], + ~U[2029-01-15 13:00:00Z], + ~U[2030-01-15 13:00:00Z], + ~U[2031-01-15 13:00:00Z] + ] == recurrences + end + end + + describe("RRULE: generate with daily frequence") do + test "every day in january for 3 years" do + dtstart = DateTime.new!(~D[1998-01-31], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1") + + Helper.time(fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "every day in january for 3 years") + end + + test "every january 10th and 31st for 3 years" do + dtstart = DateTime.new!(~D[1998-01-31], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1;BYMONTHDAY=10,31") + + Helper.time(fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "every 10th and 31st in january for 3 years") + end + + test "every Tuesday and Thursday in january for 3 years" do + dtstart = DateTime.new!(~D[2026-01-31], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20280131T140000Z;BYMONTH=1;BYDAY=TH,TU") + + Helper.time(fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "every 10th and 31st in january for 3 years") +# |> IO.inspect() + end + end end From 18d93f8119120e6f1bba68cb905003f337f658d4 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Fri, 17 Apr 2026 08:21:38 +0200 Subject: [PATCH 12/96] more tests --- test/ical/recurrence_test.exs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 912fe01..92aa81a 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -485,7 +485,7 @@ defmodule ICal.RecurrenceTest do end end - describe("RRULE: generate with daily frequence") do + describe "RRULE: generate with daily frequence" do test "every day in january for 3 years" do dtstart = DateTime.new!(~D[1998-01-31], ~T[09:00:00], "America/New_York") rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1") @@ -508,4 +508,15 @@ defmodule ICal.RecurrenceTest do # |> IO.inspect() end end + + describe "RRULE: generate with weekly frequency" do + test "weekly for 10 weeks" do + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;COUNT=10") + + Helper.time(fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "weekly for 10 weeks") +# ==> (1997 9:00 AM EDT) September 2,9,16,23,30;October 7,14,21 +# (1997 9:00 AM EST) October 28;November 4 + end + end end From aadb1facd1ec6fbf544b014fa9ef066914789a2b Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Fri, 17 Apr 2026 08:35:35 +0200 Subject: [PATCH 13/96] fix test --- test/ical/recurrence_test.exs | 40 ++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 92aa81a..c114512 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -72,7 +72,7 @@ defmodule ICal.RecurrenceTest do test "ignores bad WKST values" do rrule = %{"FREQ" => "DAILY", "WKST" => "NO"} - assert %ICal.Recurrence{frequency: :daily, weekday: nil} === + assert %ICal.Recurrence{frequency: :daily, weekday: :default} === ICal.Deserialize.Recurrence.from_params(rrule) end @@ -485,38 +485,54 @@ defmodule ICal.RecurrenceTest do end end - describe "RRULE: generate with daily frequence" do + describe "RRULE: generate with daily frequence" do test "every day in january for 3 years" do dtstart = DateTime.new!(~D[1998-01-31], ~T[09:00:00], "America/New_York") rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1") - Helper.time(fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "every day in january for 3 years") + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "every day in january for 3 years" + ) end test "every january 10th and 31st for 3 years" do dtstart = DateTime.new!(~D[1998-01-31], ~T[09:00:00], "America/New_York") - rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1;BYMONTHDAY=10,31") - Helper.time(fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "every 10th and 31st in january for 3 years") + rule = + ICal.Recurrence.from_ics( + "RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1;BYMONTHDAY=10,31" + ) + + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "every 10th and 31st in january for 3 years" + ) end test "every Tuesday and Thursday in january for 3 years" do dtstart = DateTime.new!(~D[2026-01-31], ~T[09:00:00], "America/New_York") - rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20280131T140000Z;BYMONTH=1;BYDAY=TH,TU") - Helper.time(fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "every 10th and 31st in january for 3 years") -# |> IO.inspect() + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20280131T140000Z;BYMONTH=1;BYDAY=TH,TU") + + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "every 10th and 31st in january for 3 years" + ) + + # |> IO.inspect() end end describe "RRULE: generate with weekly frequency" do - test "weekly for 10 weeks" do + test "weekly for 10 weeks" do dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") - rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;COUNT=10") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;COUNT=10") Helper.time(fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "weekly for 10 weeks") -# ==> (1997 9:00 AM EDT) September 2,9,16,23,30;October 7,14,21 -# (1997 9:00 AM EST) October 28;November 4 + # ==> (1997 9:00 AM EDT) September 2,9,16,23,30;October 7,14,21 + # (1997 9:00 AM EST) October 28;November 4 end end end From ca1b60f35eb0f01715472eac0f4ac2e05d839b1c Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Fri, 17 Apr 2026 08:44:08 +0200 Subject: [PATCH 14/96] rename Recurrence.weekday to Recurrence.week_start_day --- lib/ical/deserialize/recurrence.ex | 2 +- lib/ical/recurrence.ex | 7 +++---- lib/ical/serialize/recurrence.ex | 3 ++- test/ical/recurrence_test.exs | 2 +- test/support/fixtures.ex | 26 +++++++++++++------------- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/lib/ical/deserialize/recurrence.ex b/lib/ical/deserialize/recurrence.ex index ebe6f07..350cc33 100644 --- a/lib/ical/deserialize/recurrence.ex +++ b/lib/ical/deserialize/recurrence.ex @@ -43,7 +43,7 @@ defmodule ICal.Deserialize.Recurrence do defp add_to_recurrence({"WKST", value}, recurrence) do case to_weekday(value) do :error -> recurrence - {weekday_atom, _} -> %{recurrence | weekday: weekday_atom} + {weekday_atom, _} -> %{recurrence | week_start_day: weekday_atom} end end diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 267dd93..9f1d4d1 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -10,7 +10,6 @@ defmodule ICal.Recurrence do require Logger - # TODO: weekday should be renamed to week_start_day defstruct [ :until, :count, @@ -23,7 +22,7 @@ defmodule ICal.Recurrence do :by_month, :by_set_position, :by_week_number, - weekday: :default, + week_start_day: :default, frequency: :daily, interval: 1 ] @@ -44,7 +43,7 @@ defmodule ICal.Recurrence do by_month: [non_neg_integer] | nil, by_week_number: [non_neg_integer] | nil, by_set_position: [non_neg_integer] | nil, - weekday: weekday | :default + week_start_day: weekday | :default } def normalize(%__MODULE__{} = recurrence) do @@ -53,7 +52,7 @@ defmodule ICal.Recurrence do | by_second: clamped_numbers(recurrence.by_second, 0, 59), by_minute: clamped_numbers(recurrence.by_minute, 0, 59), by_hour: clamped_numbers(recurrence.by_hour, 0, 23), - by_day: normalize_weekdays(recurrence.by_day, recurrence.weekday), + by_day: normalize_weekdays(recurrence.by_day, recurrence.week_start_day), by_month_day: clamped_numbers(recurrence.by_month_day, -31, 31), by_year_day: clamped_numbers(recurrence.by_year_day, -366, 366), by_month: clamped_numbers(recurrence.by_month, 1, 12), diff --git a/lib/ical/serialize/recurrence.ex b/lib/ical/serialize/recurrence.ex index c541f6a..2d53026 100644 --- a/lib/ical/serialize/recurrence.ex +++ b/lib/ical/serialize/recurrence.ex @@ -18,6 +18,7 @@ defmodule ICal.Serialize.Recurrence do # skip empty entries defp to_rrule_entry({_, nil}, acc), do: acc + defp to_rrule_entry({:week_start_day, :default}, acc), do: acc # everything else! defp to_rrule_entry({key, _} = rrule, acc) do @@ -65,7 +66,7 @@ defmodule ICal.Serialize.Recurrence do defp key_to_string(:by_month), do: "BYMONTH" defp key_to_string(:by_set_position), do: "BYSETPOS" defp key_to_string(:by_week_number), do: "BYWEEKNO" - defp key_to_string(:weekday), do: "WKST" + defp key_to_string(:week_start_day), do: "WKST" # :frequency is handled manually in `to_ics` # defp key_to_string(:frequency), do: "FREQ" defp key_to_string(:interval), do: "INTERVAL" diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index c114512..116b056 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -72,7 +72,7 @@ defmodule ICal.RecurrenceTest do test "ignores bad WKST values" do rrule = %{"FREQ" => "DAILY", "WKST" => "NO"} - assert %ICal.Recurrence{frequency: :daily, weekday: :default} === + assert %ICal.Recurrence{frequency: :daily, week_start_day: :default} === ICal.Deserialize.Recurrence.from_params(rrule) end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index be20c1e..b11613a 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -481,7 +481,7 @@ defmodule ICal.Test.Fixtures do by_month: [10], by_set_position: [20], by_week_number: [-1], - weekday: :monday, + week_start_day: :monday, frequency: :daily, interval: 1 } @@ -798,7 +798,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: ~U[1973-04-29 07:00:00Z], - weekday: nil + week_start_day: :default } }, %ICal.Timezone.Properties{ @@ -831,7 +831,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: ~U[1986-04-27 07:00:00Z], - weekday: nil + week_start_day: :default } }, %ICal.Timezone.Properties{ @@ -855,7 +855,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: ~U[2006-04-02 07:00:00Z], - weekday: nil + week_start_day: :default } }, %ICal.Timezone.Properties{ @@ -879,7 +879,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: nil, - weekday: nil + week_start_day: :default } } ], @@ -906,7 +906,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: ~U[2006-10-29 06:00:00Z], - weekday: nil + week_start_day: :default } }, %ICal.Timezone.Properties{ @@ -930,7 +930,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: nil, - weekday: nil + week_start_day: :default } } ], @@ -995,7 +995,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: nil, - weekday: nil + week_start_day: :default } } ], @@ -1022,7 +1022,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: nil, - weekday: nil + week_start_day: :default } } ], @@ -1056,7 +1056,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: ~U[1998-04-04 07:00:00Z], - weekday: nil + week_start_day: :default } } ], @@ -1083,7 +1083,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: nil, - weekday: nil + week_start_day: :default } } ], @@ -1117,7 +1117,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: ~U[1998-04-04 07:00:00Z], - weekday: nil + week_start_day: :default } }, %ICal.Timezone.Properties{ @@ -1157,7 +1157,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: nil, - weekday: nil + week_start_day: :default } } ], From f5ef87527a8bd2e8badf94eb9053b0dc65eea07c Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Sun, 19 Apr 2026 21:02:44 +0200 Subject: [PATCH 15/96] move as_valid_datetime to ICal for reuse --- lib/ical.ex | 35 +++++++++++++++++++++++++++++++++++ lib/ical/deserialize.ex | 31 +------------------------------ 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/lib/ical.ex b/lib/ical.ex index 923c376..baa2e21 100644 --- a/lib/ical.ex +++ b/lib/ical.ex @@ -119,4 +119,39 @@ defmodule ICal do def encode_to_iodata!(calendar, _options \\ []) do to_ics(calendar) end + + @doc false + @spec as_valid_datetime(Date.t(), Time.t(), timezone :: String.t()) :: DateTime.t() | nil + def as_valid_datetime(date, time, timezone) do + case DateTime.new(date, time, timezone) do + {:ok, dt} -> dt + {:ambiguous, first, _second} -> first + {:gap, just_before, just_after} -> adjust_to_gap(time, just_before, just_after) + _ -> nil + end + end + + defp adjust_to_gap(original_time, before_gap, after_gap) do + before_gap = + before_gap + |> DateTime.to_time() + |> round_off_micros() + + diff = Time.diff(original_time, before_gap) + + time = Time.shift(DateTime.to_time(after_gap), second: diff) + + DateTime.new!(DateTime.to_date(after_gap), time, after_gap.time_zone) + end + + defp round_off_micros(time) do + # gap times are often 59.9999 seconds, the moment RIGHT before. + # snug those times up to the minute + {ms, precision} = time.microsecond + offset = round(ms / Integer.pow(10, precision)) + + time + |> Time.truncate(:second) + |> Time.shift(second: offset) + end end diff --git a/lib/ical/deserialize.ex b/lib/ical/deserialize.ex index d7089f9..8909010 100644 --- a/lib/ical/deserialize.ex +++ b/lib/ical/deserialize.ex @@ -489,12 +489,7 @@ defmodule ICal.Deserialize do # DATE-TIME value is interpreted using the UTC offset before the gap." # e.g. 2:30 AM in a spring-forward gap → apply pre-gap offset (EST) to # get UTC, then express in the post-gap offset (EDT) → 3:30 AM EDT. - case DateTime.new(date, time, timezone) do - {:ok, dt} -> dt - {:ambiguous, first, _second} -> first - {:gap, just_before, just_after} -> adjust_to_gap(time, just_before, just_after) - _ -> nil - end + ICal.as_valid_datetime(date, time, timezone) else _ -> nil end @@ -542,28 +537,4 @@ defmodule ICal.Deserialize do def status(%ICal.Journal{}, "FINAL"), do: :final def status(_, "CANCELLED"), do: :cancelled def status(_, _), do: nil - - defp adjust_to_gap(original_time, before_gap, after_gap) do - before_gap = - before_gap - |> DateTime.to_time() - |> round_off_micros() - - diff = Time.diff(original_time, before_gap) - - time = Time.shift(DateTime.to_time(after_gap), second: diff) - - DateTime.new!(DateTime.to_date(after_gap), time, after_gap.time_zone) - end - - defp round_off_micros(time) do - # gap times are often 59.9999 seconds, the moment RIGHT before. - # snug those times up to the minute - {ms, precision} = time.microsecond - offset = round(ms / Integer.pow(10, precision)) - - time - |> Time.truncate(:second) - |> Time.shift(second: offset) - end end From 88b899f954ade0d3b0c0b5b2bfbdf5115d1528e6 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Sun, 19 Apr 2026 21:03:16 +0200 Subject: [PATCH 16/96] recurrences calc week numbers correctly, and correct application order of by* --- lib/ical/recurrence/generate.ex | 359 +++++++++++++++++++++----------- test/ical/recurrence_test.exs | 54 +++-- 2 files changed, 264 insertions(+), 149 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 0416511..ab8a45b 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -5,252 +5,335 @@ defmodule ICal.Recurrence.Generate do defguard has_none(x) when not has_some(x) def all(%ICal.Recurrence{frequency: :yearly, interval: interval} = rule, dtstart) do + modifiers = by_year_modifiers(rule) + generate( - limiter(rule), + ends_by(rule), dtstart, [year: interval], - [ - :by_month, - :by_week_number, - :by_year_day, - :by_month_day, - :by_day, - :by_hour, - :by_minute, - :by_second - ], - [:by_set_position], + modifiers, rule ) end def all(%ICal.Recurrence{frequency: :monthly, interval: interval} = rule, dtstart) do generate( - limiter(rule), + ends_by(rule), dtstart, [month: interval], - [:by_month_day, :by_day, :by_hour, :by_minute, :by_second], - [:by_month, :by_set_position], + [ + {:by_month, :limit}, + {:by_month_day, :expand}, + {:by_day, :expand}, + {:by_hour, :expand}, + {:by_minute, :expand}, + {:by_second, :expand}, + {:by_set_position, :limit} + ], rule ) end def all(%ICal.Recurrence{frequency: :weekly, interval: interval} = rule, dtstart) do generate( - limiter(rule), + ends_by(rule), dtstart, [week: interval], - [:by_day, :by_hour, :by_minute, :by_second], - [:by_month, :by_set_position], + [ + {:by_month, :limit}, + {:by_day, :expand}, + {:by_hour, :expand}, + {:by_minute, :expand}, + {:by_second, :expand}, + {:by_set_position, :limit} + ], rule ) end def all(%ICal.Recurrence{frequency: :daily, interval: interval} = rule, dtstart) do generate( - limiter(rule), + ends_by(rule), dtstart, [day: interval], - [:by_hour, :by_minute, :by_second], - [:by_month, :by_day, :by_set_position], + [ + {:by_month, :limit}, + {:by_month_day, :limit}, + {:by_day, :limit}, + {:by_hour, :expand}, + {:by_minute, :expand}, + {:by_second, :expand}, + {:by_set_position, :limit} + ], rule ) end def all(%ICal.Recurrence{frequency: :hourly, interval: interval} = rule, dtstart) do generate( - limiter(rule), + ends_by(rule), dtstart, [hour: interval], - [:by_minute, :by_second], - [:by_month, :by_year_day, :by_month_day, :by_day, :by_hour, :by_set_position], + [ + {:by_month, :limit}, + {:by_year_day, :limit}, + {:by_month_day, :limit}, + {:by_day, :limit}, + {:by_hour, :limit}, + {:by_minute, :expand}, + {:by_set_position, :limit} + ], rule ) end def all(%ICal.Recurrence{frequency: :minutely, interval: interval} = rule, dtstart) do generate( - limiter(rule), + ends_by(rule), dtstart, [minute: interval], - [:by_second], - [:by_month, :by_year_day, :by_month_day, :by_day, :by_hour, :by_minute, :by_set_position], + [ + {:by_month, :limit}, + {:by_year_day, :limit}, + {:by_month_day, :limit}, + {:by_day, :limit}, + {:by_hour, :limit}, + {:by_minute, :limit}, + {:by_second, :expand}, + {:by_set_position, :limit} + ], rule ) end def all(%ICal.Recurrence{frequency: :secondly, interval: interval} = rule, dtstart) do generate( - limiter(rule), + ends_by(rule), dtstart, [second: interval], - [], [ - :by_month, - :by_year_day, - :by_month_day, - :by_day, - :by_hour, - :by_minute, - :by_second, - :by_set_position + {:by_month, :limit}, + {:by_year_day, :limit}, + {:by_month_day, :limit}, + {:by_day, :limit}, + {:by_hour, :limit}, + {:by_minute, :limit}, + {:by_set_position, :limit} ], rule ) end - defp generate(limit, dtstart, offset, expanders, limiters, rule) do - recurrences = - [dtstart] - |> expand(expanders, rule) - |> limit(limiters, rule) - |> Enum.filter(fn date -> is_not_before(date, dtstart) end) + defp by_year_modifiers(%{by_month: months}) + when has_some(months) do + [{:by_month, :expand}, {:by_week_number, :limit}, {:by_year_day, :limit}] ++ + yearly_always_modifiers() + end - {limit, recurrences} = update_limit(limit, recurrences) + defp by_year_modifiers(%{by_week_number: weeks}) + when has_some(weeks) do + [{:by_month, :expand}, {:by_week_number, :expand}, {:by_year_day, :limit}] ++ + yearly_always_modifiers() + end + + defp by_year_modifiers(_rule) do + [{:by_month, :expand}, {:by_week_number, :expand}, {:by_year_day, :expand}] ++ + yearly_always_modifiers() + end + defp yearly_always_modifiers do + [ + {:by_month_day, :expand}, + {:by_day, :expand}, + {:by_hour, :expand}, + {:by_minute, :expand}, + {:by_second, :expand}, + {:by_set_position, :limit} + ] + end + + defp generate(limit, dtstart, offset, by, rule) do generate( limit, dtstart, offset, - expanders, - limiters, + by, rule, - recurrences + [] ) end - defp generate(limit, _dtstart, _offset, _expanders, _limiters, _rule, acc) + defp generate(limit, _dtstart, _offset, _by, _rule, acc) when is_integer(limit) and limit < 1, do: acc - defp generate(limit, dtstart, offset, expanders, limiters, rule, acc) do - dtnext = shift(dtstart, offset) - + defp generate(limit, dtstart, offset, by, rule, acc) do recurrences = - [dtnext] - |> expand(expanders, rule) - |> limit(limiters, rule) + [dtstart] + |> apply_all_by(by, rule) + |> exclude(dtstart) {limit, recurrences} = update_limit(limit, recurrences) if limit == nil do acc ++ recurrences else + dtnext = shift(dtstart, offset) + generate( limit, dtnext, offset, - expanders, - limiters, + by, rule, acc ++ recurrences ) end end - defp expand(recurrences, expanders, rule) do - Enum.reduce(expanders, recurrences, fn expand_by, acc -> expand_by(expand_by, rule, acc) end) + defp apply_all_by(recurrences, by, rule) do + Enum.reduce(by, recurrences, fn by, acc -> + apply_by(by, rule, acc) + |> Enum.reduce([], &only_valid_dates/2) + |> Enum.sort(&compare_recurrences/2) + end) end - defp limit(recurrences, limiters, rule) do - Enum.reduce(limiters, recurrences, fn limit_by, acc -> limit_by(limit_by, rule, acc) end) + defp only_valid_dates(%NaiveDateTime{} = date, acc) do + case NaiveDateTime.new(NaiveDateTime.to_date(date), NaiveDateTime.to_time(date)) do + {:ok, date} -> acc ++ [date] + _ -> acc + end + end + + defp only_valid_dates(%Date{} = date, acc) do + case Date.new(date.year, date.month, date.day) do + {:ok, date} -> acc ++ [date] + _ -> acc + end + end + + defp only_valid_dates(%DateTime{} = datetime, acc) do + case ICal.as_valid_datetime( + DateTime.to_date(datetime), + DateTime.to_time(datetime), + datetime.time_zone + ) do + nil -> acc + datetime -> acc ++ [datetime] + end end - defp expand_by(:by_month, %{by_month: months}, acc) when has_some(months) do + defp compare_recurrences(%DateTime{} = l, r), do: DateTime.compare(l, r) == :lt + defp compare_recurrences(%NaiveDateTime{} = l, r), do: NaiveDateTime.compare(l, r) == :lt + defp compare_recurrences(%Date{} = l, r), do: Date.compare(l, r) == :lt + + defp apply_by({:by_month, :expand}, %{by_month: months}, acc) when has_some(months) do Enum.reduce(acc, [], fn dtstart, acc -> - acc ++ Enum.map(months, fn month -> %{dtstart | month: month} end) + acc ++ + Enum.map(months, fn month -> + if month > dtstart.month do + %{dtstart | month: month} + else + %{dtstart | year: dtstart.year + 1, month: month} + end + end) end) end - defp expand_by(:by_week_number, %{by_week_number: weeks}, acc) when has_none(weeks), do: acc - - defp expand_by(:by_week_number, %{by_month: months} = rule, acc) when has_some(months) do - # it was expanded by months, so limit the occurances by week number - limit_by(:by_week_number, rule, acc) + defp apply_by({:by_month, :limit}, %{by_month: months}, acc) when has_some(months) do + Enum.filter(acc, fn recurrence -> + Enum.member?(months, recurrence.month) + end) end - defp expand_by(:by_week_number, %{by_week_number: weeks}, acc) do + defp apply_by({:by_week_number, :expand}, %{by_week_number: weeks}, acc) when has_some(weeks) do Enum.reduce(acc, [], fn recurrence, acc -> + recurrence_week = week_of_year(recurrence) + acc ++ Enum.flat_map(weeks, fn week -> + reference_date = + if week > recurrence_week do + recurrence + else + %{recurrence | year: recurrence.year + 1} + end + {first, last} = - week_number_bookends(recurrence, week) + week_number_bookends(reference_date, week) range(first, last, recurrence) end) end) end - defp expand_by(:by_year_day, %{by_year_day: days}, acc) when has_none(days) do - acc - end - - defp expand_by(:by_year_day, %{by_month: months, by_week_number: weeks} = rule, acc) - when has_some(months) or has_some(weeks) do - # we are limiting rather than expanding - limit_by(:by_year_day, rule, acc) + defp apply_by({:by_week_number, :limit}, %{by_week_number: weeks}, acc) when has_some(weeks) do + Enum.filter(acc, fn recurrence -> + Enum.find(weeks, fn week -> + {week_start, week_end} = week_number_bookends(recurrence, week) + is_between(week_start, recurrence, week_end) + end) != nil + end) end - defp expand_by(:by_year_day, %{by_year_day: year_days}, acc) do + defp apply_by({:by_year_day, :expand}, %{by_year_day: year_days}, acc) + when has_some(year_days) do Enum.uniq_by(acc, fn recurrence -> recurrence.year end) |> Enum.flat_map(fn recurrence -> + orig_day_of_year = day_of_year(recurrence) first_of_jan = %{recurrence | month: 1, day: 1} Enum.map(year_days, fn day_of_year -> - shift(first_of_jan, day: day_of_year) + if day_of_year > orig_day_of_year do + shift(first_of_jan, day: day_of_year - 1) + else + shift(first_of_jan, year: 1, day: day_of_year - 1) + end end) end) end - defp expand_by(_by, _rule, acc), do: acc - - defp limit_by(:by_set_position, %{by_set_position: index}, recurrences) - when is_integer(index) and index != 0 do - index = if index > 0, do: index - 1, else: index - - case Enum.at(recurrences, index) do - nil -> [] - recurrence -> [recurrence] - end - end - - defp limit_by(:by_year_day, %{by_year_day: year_days}, acc) when has_some(year_days) do + defp apply_by({:by_year_day, :limit}, %{by_year_day: year_days}, acc) + when has_some(year_days) do Enum.filter(acc, fn recurrence -> Enum.member?(year_days, Date.day_of_year(recurrence)) end) end - defp limit_by(:by_week_number, %{by_week_number: weeks}, acc) when has_some(weeks) do - Enum.filter(acc, fn recurrence -> - Enum.find(weeks, fn week -> - {week_start, week_end} = week_number_bookends(recurrence, week) - is_between(week_start, recurrence, week_end) - end) != nil - end) - end - - defp limit_by(:by_month, %{by_month: months}, acc) when has_some(months) do - Enum.filter(acc, fn recurrence -> - Enum.member?(months, recurrence.month) - end) - end - - defp limit_by(:by_month_day, %{by_month_day: days}, acc) when has_some(days) do + defp apply_by({:by_month_day, :limit}, %{by_month_day: days}, acc) when has_some(days) do Enum.filter(acc, fn recurrence -> Enum.member?(days, recurrence.day) end) end - defp limit_by(:by_day, %{by_day: days}, acc) when has_some(days) do + defp apply_by({:by_day, :limit}, %{by_day: days}, acc) when has_some(days) do Enum.filter(acc, fn recurrence -> target = weekday(recurrence) Enum.find(days, fn {_, allowed_day} -> allowed_day == target end) != nil end) end - defp limit_by(_limiter, _rule, recurrences), do: recurrences + defp apply_by({:by_set_position, :limit}, %{by_set_position: index}, recurrences) + when is_integer(index) and index != 0 do + index = if index > 0, do: index - 1, else: index + + case Enum.at(recurrences, index) do + nil -> [] + recurrence -> [recurrence] + end + + recurrences + end + + defp apply_by({_, :expand}, _rule, acc), do: acc + defp apply_by({_, :limit}, _rule, acc), do: acc - defp limiter(%{count: count}) when is_integer(count), do: count - defp limiter(%{until: until}), do: until + defp exclude(recurrences, dtstart) do + Enum.filter(recurrences, fn recurrence -> is_not_before(recurrence, dtstart) end) + end + + defp ends_by(%{count: count}) when is_integer(count), do: count + defp ends_by(%{until: until}), do: until # TODO: is the start of the week needed here? def weekday(%Date{} = date) do @@ -261,6 +344,10 @@ defmodule ICal.Recurrence.Generate do def weekday(%DateTime{} = dt), do: weekday(DateTime.to_date(dt)) + # when no more recurrences are generated, then stop even if it could in theory + # go further? hmmm... there should be a search limit + # defp update_limit(_limit, []), do: {nil, []} + defp update_limit(limit, recurrences) when is_integer(limit) do updated_limit = limit - Enum.count(recurrences) @@ -296,15 +383,14 @@ defmodule ICal.Recurrence.Generate do def week_number_bookends(dtstart, week) do # shift the week if week > 0 do - # positive week number, start from first day of the year - start_date = + # positive week number, start from first w of the year + end_date = Date.new!(dtstart.year, 1, 1) + |> Date.end_of_week() + |> ensure_end_of_first_week() |> Date.shift(week: week - 1) - |> Date.beginning_of_week() - end_date = - start_date - |> Date.end_of_week() + start_date = Date.beginning_of_week(end_date) {start_date, end_date} else @@ -312,11 +398,9 @@ defmodule ICal.Recurrence.Generate do # and since it is already on the last week, move one less week than requested # e.g. the -1 week is 0 weeks from the last week of the year start_date = - Date.new!(dtstart.year, 1, 1) - |> Date.shift(year: 1) + Date.new!(dtstart.year + 1, 1, 1) |> Date.end_of_week() |> Date.shift(day: 1) - |> IO.inspect() |> Date.shift(week: week) end_date = start_date |> Date.end_of_week() @@ -325,6 +409,39 @@ defmodule ICal.Recurrence.Generate do end end + defp week_of_year(%DateTime{} = datetime), do: week_of_year(DateTime.to_date(datetime)) + + defp week_of_year(%NaiveDateTime{} = datetime), + do: week_of_year(NaiveDateTime.to_date(datetime)) + + defp week_of_year(%Date{} = date) do + end_of_first_week = + Date.new!(date.year, 1, 1) + |> Date.end_of_week() + |> ensure_end_of_first_week() + |> Date.day_of_year() + + end_of_this_week = + date + |> Date.end_of_week() + |> Date.day_of_year() + + week = + (end_of_this_week - end_of_first_week) + |> Integer.floor_div(7) + + week + 1 + end + + # the first week is considered the one with at least 4 days + # so if the end of the first week is 3 or less, then bump it by a week + defp ensure_end_of_first_week(%{day: day} = date) when day < 4, do: Date.shift(date, week: 1) + defp ensure_end_of_first_week(day), do: day + + defp day_of_year(%DateTime{} = datetime), do: day_of_year(DateTime.to_date(datetime)) + defp day_of_year(%NaiveDateTime{} = datetime), do: day_of_year(NaiveDateTime.to_date(datetime)) + defp day_of_year(%Date{} = date), do: Date.day_of_year(date) + defp is_between(earliest, middle, latest) do is_not_after(earliest, middle) and is_not_after(middle, latest) end diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 116b056..157a95b 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -342,7 +342,7 @@ defmodule ICal.RecurrenceTest do assert Enum.count(recurrences) == count [recurrence | _] = recurrences - assert recurrence.month == 4 + assert recurrence.month == 6 end test "positive set position" do @@ -398,32 +398,32 @@ defmodule ICal.RecurrenceTest do ~U[2026-04-24 13:00:00Z], ~U[2026-04-25 13:00:00Z], ~U[2026-04-26 13:00:00Z], - ~U[2027-01-11 13:00:00Z], - ~U[2027-01-12 13:00:00Z], - ~U[2027-01-13 13:00:00Z], - ~U[2027-01-14 13:00:00Z], - ~U[2027-01-15 13:00:00Z], - ~U[2027-01-16 13:00:00Z], - ~U[2027-01-17 13:00:00Z], - ~U[2027-04-19 13:00:00Z], - ~U[2027-04-20 13:00:00Z], - ~U[2027-04-21 13:00:00Z], - ~U[2027-04-22 13:00:00Z], - ~U[2027-04-23 13:00:00Z], - ~U[2027-04-24 13:00:00Z], - ~U[2027-04-25 13:00:00Z], - ~U[2028-01-10 13:00:00Z] + ~U[2027-01-18 13:00:00Z], + ~U[2027-01-19 13:00:00Z], + ~U[2027-01-20 13:00:00Z], + ~U[2027-01-21 13:00:00Z], + ~U[2027-01-22 13:00:00Z], + ~U[2027-01-23 13:00:00Z], + ~U[2027-01-24 13:00:00Z], + ~U[2027-04-26 13:00:00Z], + ~U[2027-04-27 13:00:00Z], + ~U[2027-04-28 13:00:00Z], + ~U[2027-04-29 13:00:00Z], + ~U[2027-04-30 13:00:00Z], + ~U[2027-05-01 13:00:00Z], + ~U[2027-05-02 13:00:00Z], + ~U[2028-01-17 13:00:00Z] ] == recurrences end - test "by week number applied to by month " do + test "by week number applied to by month" do count = 5 rule = %ICal.Recurrence{ frequency: :yearly, count: count, by_month: [1, 4, 6], - by_week_number: [3, 17] + by_week_number: [2, 17] } dtstart = ~U[2026-04-15 13:00:00Z] @@ -435,9 +435,9 @@ defmodule ICal.RecurrenceTest do assert [ ~U[2027-01-15 13:00:00Z], ~U[2028-01-15 13:00:00Z], - ~U[2029-01-15 13:00:00Z], - ~U[2030-01-15 13:00:00Z], - ~U[2031-01-15 13:00:00Z] + ~U[2033-01-15 13:00:00Z], + ~U[2034-01-15 13:00:00Z], + ~U[2038-01-15 13:00:00Z] ] == recurrences end @@ -451,11 +451,11 @@ defmodule ICal.RecurrenceTest do assert Enum.count(recurrences) == count assert [ - ~U[2027-01-16 13:00:00Z], - ~U[2027-02-20 13:00:00Z], - ~U[2028-01-16 13:00:00Z], - ~U[2028-02-20 13:00:00Z], - ~U[2029-01-16 13:00:00Z] + ~U[2027-01-15 13:00:00Z], + ~U[2027-02-19 13:00:00Z], + ~U[2028-01-15 13:00:00Z], + ~U[2028-02-19 13:00:00Z], + ~U[2029-01-15 13:00:00Z] ] == recurrences end @@ -520,8 +520,6 @@ defmodule ICal.RecurrenceTest do fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "every 10th and 31st in january for 3 years" ) - - # |> IO.inspect() end end From f683fa095070827b247d4a929a365125e2b8ed92 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Sun, 19 Apr 2026 21:14:13 +0200 Subject: [PATCH 17/96] implement a very crude max searches to prevent non-halting --- lib/ical/recurrence/generate.ex | 39 +++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index ab8a45b..f29981d 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -1,6 +1,11 @@ defmodule ICal.Recurrence.Generate do @moduledoc false + require Logger + + @fruitless_search_start_count 0 + @max_fruitless_search_depth 1000 + defguard has_some(x) when is_list(x) and x != [] defguard has_none(x) when not has_some(x) @@ -159,20 +164,28 @@ defmodule ICal.Recurrence.Generate do offset, by, rule, + 0, [] ) end - defp generate(limit, _dtstart, _offset, _by, _rule, acc) + defp generate(limit, _dtstart, _offset, _by, _rule, _fruitless_searches, acc) when is_integer(limit) and limit < 1, do: acc - defp generate(limit, dtstart, offset, by, rule, acc) do + defp generate(_limit, _dtstart, _offset, _by, rule, fruitless_searches, acc) + when fruitless_searches > @max_fruitless_search_depth do + Logger.warning("Could not find all recurrences of #{inspect(rule)} due to search exhaustion") + acc + end + + defp generate(limit, dtstart, offset, by, rule, fruitless_searches, acc) do recurrences = [dtstart] |> apply_all_by(by, rule) |> exclude(dtstart) - {limit, recurrences} = update_limit(limit, recurrences) + {limit, recurrences, fruitless_searches} = + update_limit(limit, recurrences, fruitless_searches) if limit == nil do acc ++ recurrences @@ -185,6 +198,7 @@ defmodule ICal.Recurrence.Generate do offset, by, rule, + fruitless_searches, acc ++ recurrences ) end @@ -344,27 +358,28 @@ defmodule ICal.Recurrence.Generate do def weekday(%DateTime{} = dt), do: weekday(DateTime.to_date(dt)) - # when no more recurrences are generated, then stop even if it could in theory - # go further? hmmm... there should be a search limit - # defp update_limit(_limit, []), do: {nil, []} + # when no more recurrences are generated for too long, then stop even if it could in theory + # go further. + defp update_limit(limit, [], fruitless_searches), do: {limit, [], fruitless_searches + 1} - defp update_limit(limit, recurrences) when is_integer(limit) do + # TODO: recurrence search depth limit + defp update_limit(limit, recurrences, _fruitless_searches) when is_integer(limit) do updated_limit = limit - Enum.count(recurrences) if updated_limit < 1 do - {nil, Enum.slice(recurrences, 0, limit)} + {nil, Enum.slice(recurrences, 0, limit), @fruitless_search_start_count} else - {updated_limit, recurrences} + {updated_limit, recurrences, @fruitless_search_start_count} end end - defp update_limit(limit, recurrences) do + defp update_limit(limit, recurrences, _fruitless_searches) do index = Enum.find_index(recurrences, fn recurrence -> is_not_after(limit, recurrence) end) if index != nil do - {nil, Enum.slice(recurrences, 0, index + 1)} + {nil, Enum.slice(recurrences, 0, index + 1), @fruitless_search_start_count} else - {limit, recurrences} + {limit, recurrences, @fruitless_search_start_count} end end From 7d5781496db7f5a891457c4892b63b1d2e118b37 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Sun, 19 Apr 2026 21:41:21 +0200 Subject: [PATCH 18/96] another recurrence test, correct slicing to until date --- lib/ical/recurrence/generate.ex | 13 +++++++++---- test/ical/recurrence_test.exs | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index f29981d..9e170cd 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -285,7 +285,7 @@ defmodule ICal.Recurrence.Generate do Enum.filter(acc, fn recurrence -> Enum.find(weeks, fn week -> {week_start, week_end} = week_number_bookends(recurrence, week) - is_between(week_start, recurrence, week_end) + is_between_inclusive(week_start, recurrence, week_end) end) != nil end) end @@ -374,10 +374,10 @@ defmodule ICal.Recurrence.Generate do end defp update_limit(limit, recurrences, _fruitless_searches) do - index = Enum.find_index(recurrences, fn recurrence -> is_not_after(limit, recurrence) end) + index = Enum.find_index(recurrences, fn recurrence -> is_after(recurrence, limit) end) if index != nil do - {nil, Enum.slice(recurrences, 0, index + 1), @fruitless_search_start_count} + {nil, Enum.slice(recurrences, 0, index), @fruitless_search_start_count} else {limit, recurrences, @fruitless_search_start_count} end @@ -457,7 +457,7 @@ defmodule ICal.Recurrence.Generate do defp day_of_year(%NaiveDateTime{} = datetime), do: day_of_year(NaiveDateTime.to_date(datetime)) defp day_of_year(%Date{} = date), do: Date.day_of_year(date) - defp is_between(earliest, middle, latest) do + defp is_between_inclusive(earliest, middle, latest) do is_not_after(earliest, middle) and is_not_after(middle, latest) end @@ -466,6 +466,11 @@ defmodule ICal.Recurrence.Generate do defp is_not_before(%Date{} = l, r), do: Date.compare(l, r) != :lt defp is_not_before(%DateTime{} = l, r), do: DateTime.compare(l, r) != :lt + defp is_after(%Date{} = d, %DateTime{} = dt), do: is_after(d, DateTime.to_date(dt)) + defp is_after(%DateTime{} = dt, %Date{} = d), do: is_after(DateTime.to_date(dt), d) + defp is_after(%Date{} = l, r), do: Date.compare(l, r) == :gt + defp is_after(%DateTime{} = l, r), do: DateTime.compare(l, r) == :gt + defp is_not_after(%Date{} = d, %DateTime{} = dt), do: is_not_after(d, DateTime.to_date(dt)) defp is_not_after(%DateTime{} = dt, %Date{} = d), do: is_not_after(DateTime.to_date(dt), d) defp is_not_after(%Date{} = l, r), do: Date.compare(l, r) != :gt diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 157a95b..7c261d0 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -521,6 +521,26 @@ defmodule ICal.RecurrenceTest do "every 10th and 31st in january for 3 years" ) end + + test "daily until December 24, 1997" do + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=19971224T000000Z") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "daily until December 24, 1997" + ) + + assert Enum.at(recurrences, 0) == + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + # DST hits, and it is one hour earlier! + assert Enum.at(recurrences, -1) == + DateTime.new!(~D[1997-12-23], ~T[08:00:00], "America/New_York") + + assert Enum.count(recurrences) == 113 + end end describe "RRULE: generate with weekly frequency" do From 1fcea754aec0a0b1ff31e452280266c7ec1070ee Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Sun, 19 Apr 2026 21:49:28 +0200 Subject: [PATCH 19/96] organize apply_by/3 by by_*, and add stubs for all missing impls --- lib/ical/recurrence/generate.ex | 50 +++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 9e170cd..f7d016a 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -314,12 +314,28 @@ defmodule ICal.Recurrence.Generate do end) end - defp apply_by({:by_month_day, :limit}, %{by_month_day: days}, acc) when has_some(days) do + defp apply_by({:by_month_day, :expand}, %{by_month_day: month_days}, acc) + when has_some(month_days) do + acc + |> Enum.flat_map(fn recurrence -> + Enum.map(month_days, fn month_day -> + %{recurrence | day: month_day} + end) + end) + end + + defp apply_by({:by_month_day, :limit}, %{by_month_day: month_days}, acc) + when has_some(month_days) do Enum.filter(acc, fn recurrence -> - Enum.member?(days, recurrence.day) + Enum.member?(month_days, recurrence.day) end) end + # TODO + defp apply_by({:by_day, :expand}, %{by_day: days}, acc) when has_some(days) do + acc + end + defp apply_by({:by_day, :limit}, %{by_day: days}, acc) when has_some(days) do Enum.filter(acc, fn recurrence -> target = weekday(recurrence) @@ -327,6 +343,36 @@ defmodule ICal.Recurrence.Generate do end) end + # TODO + defp apply_by({:by_hour, :expand}, %{by_hour: hours}, acc) when has_some(hours) do + acc + end + + # TODO + defp apply_by({:by_hour, :limit}, %{by_hour: hours}, acc) when has_some(hours) do + acc + end + + # TODO + defp apply_by({:by_minute, :expand}, %{by_minute: minutes}, acc) when has_some(minutes) do + acc + end + + # TODO + defp apply_by({:by_minute, :limit}, %{by_minute: minutes}, acc) when has_some(minutes) do + acc + end + + # TODO + defp apply_by({:by_second, :expand}, %{by_second: seconds}, acc) when has_some(seconds) do + acc + end + + # TODO + defp apply_by({:by_second, :limit}, %{by_second: seconds}, acc) when has_some(seconds) do + acc + end + defp apply_by({:by_set_position, :limit}, %{by_set_position: index}, recurrences) when is_integer(index) and index != 0 do index = if index > 0, do: index - 1, else: index From 45e779256d8ee2fc28af53107a7b256287076362 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Sun, 19 Apr 2026 22:05:07 +0200 Subject: [PATCH 20/96] spiff up by_* conditionals, add missing ones --- lib/ical/recurrence/generate.ex | 73 ++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index f7d016a..d8e0558 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -10,7 +10,33 @@ defmodule ICal.Recurrence.Generate do defguard has_none(x) when not has_some(x) def all(%ICal.Recurrence{frequency: :yearly, interval: interval} = rule, dtstart) do - modifiers = by_year_modifiers(rule) + week_number_application = + if has_some(rule.by_month), do: :limit, else: :expand + + year_day_appliaction = + if has_some(rule.by_month) or has_some(rule.by_week_number), do: :limit, else: :expand + + by_day_application = + cond do + has_some(rule.by_year_day) -> :limit + has_some(rule.by_month_day) -> :limit + has_some(rule.by_week_number) -> :expand_week + has_some(rule.by_month) -> :expand_month + true -> :expand_year + end + + modifiers = + [ + {:by_month, :expand}, + {:by_week_number, week_number_application}, + {:by_year_day, year_day_appliaction}, + {:by_month_day, :expand}, + {:by_day, by_day_application}, + {:by_hour, :expand}, + {:by_minute, :expand}, + {:by_second, :expand}, + {:by_set_position, :limit} + ] generate( ends_by(rule), @@ -22,6 +48,8 @@ defmodule ICal.Recurrence.Generate do end def all(%ICal.Recurrence{frequency: :monthly, interval: interval} = rule, dtstart) do + by_day_application = if has_some(rule.by_month_day), do: :limit, else: :expand + generate( ends_by(rule), dtstart, @@ -29,7 +57,7 @@ defmodule ICal.Recurrence.Generate do [ {:by_month, :limit}, {:by_month_day, :expand}, - {:by_day, :expand}, + {:by_day, by_day_application}, {:by_hour, :expand}, {:by_minute, :expand}, {:by_second, :expand}, @@ -129,34 +157,6 @@ defmodule ICal.Recurrence.Generate do ) end - defp by_year_modifiers(%{by_month: months}) - when has_some(months) do - [{:by_month, :expand}, {:by_week_number, :limit}, {:by_year_day, :limit}] ++ - yearly_always_modifiers() - end - - defp by_year_modifiers(%{by_week_number: weeks}) - when has_some(weeks) do - [{:by_month, :expand}, {:by_week_number, :expand}, {:by_year_day, :limit}] ++ - yearly_always_modifiers() - end - - defp by_year_modifiers(_rule) do - [{:by_month, :expand}, {:by_week_number, :expand}, {:by_year_day, :expand}] ++ - yearly_always_modifiers() - end - - defp yearly_always_modifiers do - [ - {:by_month_day, :expand}, - {:by_day, :expand}, - {:by_hour, :expand}, - {:by_minute, :expand}, - {:by_second, :expand}, - {:by_set_position, :limit} - ] - end - defp generate(limit, dtstart, offset, by, rule) do generate( limit, @@ -332,7 +332,15 @@ defmodule ICal.Recurrence.Generate do end # TODO - defp apply_by({:by_day, :expand}, %{by_day: days}, acc) when has_some(days) do + defp apply_by({:by_day, :expand_year}, %{by_day: days}, acc) when has_some(days) do + acc + end + + defp apply_by({:by_day, :expand_month}, %{by_day: days}, acc) when has_some(days) do + acc + end + + defp apply_by({:by_day, :expand_week}, %{by_day: days}, acc) when has_some(days) do acc end @@ -385,8 +393,7 @@ defmodule ICal.Recurrence.Generate do recurrences end - defp apply_by({_, :expand}, _rule, acc), do: acc - defp apply_by({_, :limit}, _rule, acc), do: acc + defp apply_by(_, _rule, acc), do: acc defp exclude(recurrences, dtstart) do Enum.filter(recurrences, fn recurrence -> is_not_before(recurrence, dtstart) end) From 498614f1c47ad6e3f42bc298d05fa978872bcce6 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Sun, 19 Apr 2026 22:06:48 +0200 Subject: [PATCH 21/96] fixes to by_* conditionals --- lib/ical/recurrence/generate.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index d8e0558..1a5d35a 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -48,7 +48,7 @@ defmodule ICal.Recurrence.Generate do end def all(%ICal.Recurrence{frequency: :monthly, interval: interval} = rule, dtstart) do - by_day_application = if has_some(rule.by_month_day), do: :limit, else: :expand + by_day_application = if has_some(rule.by_month_day), do: :limit, else: :expand_month generate( ends_by(rule), @@ -74,7 +74,7 @@ defmodule ICal.Recurrence.Generate do [week: interval], [ {:by_month, :limit}, - {:by_day, :expand}, + {:by_day, :expand_week}, {:by_hour, :expand}, {:by_minute, :expand}, {:by_second, :expand}, From db81f427e7a0b2786f61a2bf60bb0a41e8c588a2 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 12:39:46 +0200 Subject: [PATCH 22/96] refactor Recurrence.Generate for clarity and re-use of code behind all/2 --- lib/ical/recurrence/generate.ex | 311 ++++++++++++++++---------------- 1 file changed, 158 insertions(+), 153 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 1a5d35a..0094197 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -9,159 +9,160 @@ defmodule ICal.Recurrence.Generate do defguard has_some(x) when is_list(x) and x != [] defguard has_none(x) when not has_some(x) - def all(%ICal.Recurrence{frequency: :yearly, interval: interval} = rule, dtstart) do - week_number_application = - if has_some(rule.by_month), do: :limit, else: :expand + @type recurrence :: Date.t() | DateTime.t() | NaiveDateTime.t() + @type error_reasons :: :search_exhaustion | :no_defined_limit - year_day_appliaction = - if has_some(rule.by_month) or has_some(rule.by_week_number), do: :limit, else: :expand - - by_day_application = - cond do - has_some(rule.by_year_day) -> :limit - has_some(rule.by_month_day) -> :limit - has_some(rule.by_week_number) -> :expand_week - has_some(rule.by_month) -> :expand_month - true -> :expand_year - end + @spec all(ICal.Recurrence.t(), starting_from :: recurrence) :: + {:ok, [recurrence]} | {:error, error_reasons, [recurrence]} + def all(rule, starting_date) do + interval = rule_interval(rule) + modifiers = rule_modifiers(rule) - modifiers = - [ - {:by_month, :expand}, - {:by_week_number, week_number_application}, - {:by_year_day, year_day_appliaction}, - {:by_month_day, :expand}, - {:by_day, by_day_application}, - {:by_hour, :expand}, - {:by_minute, :expand}, - {:by_second, :expand}, - {:by_set_position, :limit} - ] - - generate( + generate_all( ends_by(rule), - dtstart, - [year: interval], + starting_date, + interval, modifiers, rule ) end - def all(%ICal.Recurrence{frequency: :monthly, interval: interval} = rule, dtstart) do - by_day_application = if has_some(rule.by_month_day), do: :limit, else: :expand_month + defp rule_interval(%ICal.Recurrence{frequency: :yearly, interval: interval}) do + [year: interval] + end - generate( - ends_by(rule), - dtstart, - [month: interval], - [ - {:by_month, :limit}, - {:by_month_day, :expand}, - {:by_day, by_day_application}, - {:by_hour, :expand}, - {:by_minute, :expand}, - {:by_second, :expand}, - {:by_set_position, :limit} - ], - rule - ) + defp rule_interval(%ICal.Recurrence{frequency: :monthly, interval: interval}) do + [month: interval] end - def all(%ICal.Recurrence{frequency: :weekly, interval: interval} = rule, dtstart) do - generate( - ends_by(rule), - dtstart, - [week: interval], - [ - {:by_month, :limit}, - {:by_day, :expand_week}, - {:by_hour, :expand}, - {:by_minute, :expand}, - {:by_second, :expand}, - {:by_set_position, :limit} - ], - rule - ) + defp rule_interval(%ICal.Recurrence{frequency: :weekly, interval: interval}) do + [week: interval] end - def all(%ICal.Recurrence{frequency: :daily, interval: interval} = rule, dtstart) do - generate( - ends_by(rule), - dtstart, - [day: interval], - [ - {:by_month, :limit}, - {:by_month_day, :limit}, - {:by_day, :limit}, - {:by_hour, :expand}, - {:by_minute, :expand}, - {:by_second, :expand}, - {:by_set_position, :limit} - ], - rule - ) + defp rule_interval(%ICal.Recurrence{frequency: :daily, interval: interval}) do + [day: interval] end - def all(%ICal.Recurrence{frequency: :hourly, interval: interval} = rule, dtstart) do - generate( - ends_by(rule), - dtstart, - [hour: interval], - [ - {:by_month, :limit}, - {:by_year_day, :limit}, - {:by_month_day, :limit}, - {:by_day, :limit}, - {:by_hour, :limit}, - {:by_minute, :expand}, - {:by_set_position, :limit} - ], - rule - ) + defp rule_interval(%ICal.Recurrence{frequency: :hourly, interval: interval}) do + [hour: interval] end - def all(%ICal.Recurrence{frequency: :minutely, interval: interval} = rule, dtstart) do - generate( - ends_by(rule), - dtstart, - [minute: interval], - [ - {:by_month, :limit}, - {:by_year_day, :limit}, - {:by_month_day, :limit}, - {:by_day, :limit}, - {:by_hour, :limit}, - {:by_minute, :limit}, - {:by_second, :expand}, - {:by_set_position, :limit} - ], - rule - ) + defp rule_interval(%ICal.Recurrence{frequency: :minutely, interval: interval}) do + [minute: interval] end - def all(%ICal.Recurrence{frequency: :secondly, interval: interval} = rule, dtstart) do - generate( - ends_by(rule), - dtstart, - [second: interval], - [ - {:by_month, :limit}, - {:by_year_day, :limit}, - {:by_month_day, :limit}, - {:by_day, :limit}, - {:by_hour, :limit}, - {:by_minute, :limit}, - {:by_set_position, :limit} - ], - rule - ) + defp rule_interval(%ICal.Recurrence{frequency: :secondly, interval: interval}) do + [second: interval] + end + + defp rule_modifiers(%ICal.Recurrence{frequency: :yearly} = rule) do + week_number_application = + if has_some(rule.by_month), do: :limit, else: :expand + + year_day_appliaction = + if has_some(rule.by_month) or has_some(rule.by_week_number), do: :limit, else: :expand + + by_day_application = + cond do + has_some(rule.by_year_day) -> :limit + has_some(rule.by_month_day) -> :limit + has_some(rule.by_week_number) -> :expand_week + has_some(rule.by_month) -> :expand_month + true -> :expand_year + end + + [ + {:by_month, :expand}, + {:by_week_number, week_number_application}, + {:by_year_day, year_day_appliaction}, + {:by_month_day, :expand}, + {:by_day, by_day_application}, + {:by_hour, :expand}, + {:by_minute, :expand}, + {:by_second, :expand}, + {:by_set_position, :limit} + ] end - defp generate(limit, dtstart, offset, by, rule) do - generate( + defp rule_modifiers(%ICal.Recurrence{frequency: :monthly} = rule) do + by_day_application = if has_some(rule.by_month_day), do: :limit, else: :expand_month + + [ + {:by_month, :limit}, + {:by_month_day, :expand}, + {:by_day, by_day_application}, + {:by_hour, :expand}, + {:by_minute, :expand}, + {:by_second, :expand}, + {:by_set_position, :limit} + ] + end + + defp rule_modifiers(%ICal.Recurrence{frequency: :weekly}) do + [ + {:by_month, :limit}, + {:by_day, :expand_week}, + {:by_hour, :expand}, + {:by_minute, :expand}, + {:by_second, :expand}, + {:by_set_position, :limit} + ] + end + + defp rule_modifiers(%ICal.Recurrence{frequency: :daily}) do + [ + {:by_month, :limit}, + {:by_month_day, :limit}, + {:by_day, :limit}, + {:by_hour, :expand}, + {:by_minute, :expand}, + {:by_second, :expand}, + {:by_set_position, :limit} + ] + end + + defp rule_modifiers(%ICal.Recurrence{frequency: :hourly}) do + [ + {:by_month, :limit}, + {:by_year_day, :limit}, + {:by_month_day, :limit}, + {:by_day, :limit}, + {:by_hour, :limit}, + {:by_minute, :expand}, + {:by_set_position, :limit} + ] + end + + defp rule_modifiers(%ICal.Recurrence{frequency: :minutely}) do + [ + {:by_month, :limit}, + {:by_year_day, :limit}, + {:by_month_day, :limit}, + {:by_day, :limit}, + {:by_hour, :limit}, + {:by_minute, :limit}, + {:by_second, :expand}, + {:by_set_position, :limit} + ] + end + + defp rule_modifiers(%ICal.Recurrence{frequency: :secondly}) do + [ + {:by_month, :limit}, + {:by_year_day, :limit}, + {:by_month_day, :limit}, + {:by_day, :limit}, + {:by_hour, :limit}, + {:by_minute, :limit}, + {:by_set_position, :limit} + ] + end + + defp generate_all(limit, starting_date, interval, by, rule) do + generate_all( limit, - dtstart, - offset, + starting_date, + interval, by, rule, 0, @@ -169,20 +170,24 @@ defmodule ICal.Recurrence.Generate do ) end - defp generate(limit, _dtstart, _offset, _by, _rule, _fruitless_searches, acc) - when is_integer(limit) and limit < 1, do: acc + defp generate_all(nil, _starting_date, _interval, _by, _rule, _fruitless_searches, _acc) do + {:error, :no_defined_limit, []} + end - defp generate(_limit, _dtstart, _offset, _by, rule, fruitless_searches, acc) + defp generate_all(limit, _starting_date, _interval, _by, _rule, _fruitless_searches, acc) + when is_integer(limit) and limit < 1, do: {:ok, acc} + + defp generate_all(_limit, _starting_date, _interval, _by, rule, fruitless_searches, acc) when fruitless_searches > @max_fruitless_search_depth do Logger.warning("Could not find all recurrences of #{inspect(rule)} due to search exhaustion") - acc + {:error, :search_exhaustion, acc} end - defp generate(limit, dtstart, offset, by, rule, fruitless_searches, acc) do + defp generate_all(limit, starting_date, interval, by, rule, fruitless_searches, acc) do recurrences = - [dtstart] + [starting_date] |> apply_all_by(by, rule) - |> exclude(dtstart) + |> exclude(starting_date) {limit, recurrences, fruitless_searches} = update_limit(limit, recurrences, fruitless_searches) @@ -190,12 +195,12 @@ defmodule ICal.Recurrence.Generate do if limit == nil do acc ++ recurrences else - dtnext = shift(dtstart, offset) + next_starting_date = shift(starting_date, interval) - generate( + generate_all( limit, - dtnext, - offset, + next_starting_date, + interval, by, rule, fruitless_searches, @@ -242,13 +247,13 @@ defmodule ICal.Recurrence.Generate do defp compare_recurrences(%Date{} = l, r), do: Date.compare(l, r) == :lt defp apply_by({:by_month, :expand}, %{by_month: months}, acc) when has_some(months) do - Enum.reduce(acc, [], fn dtstart, acc -> + Enum.reduce(acc, [], fn recurrence, acc -> acc ++ Enum.map(months, fn month -> - if month > dtstart.month do - %{dtstart | month: month} + if month > recurrence.month do + %{recurrence | month: month} else - %{dtstart | year: dtstart.year + 1, month: month} + %{recurrence | year: recurrence.year + 1, month: month} end end) end) @@ -395,8 +400,8 @@ defmodule ICal.Recurrence.Generate do defp apply_by(_, _rule, acc), do: acc - defp exclude(recurrences, dtstart) do - Enum.filter(recurrences, fn recurrence -> is_not_before(recurrence, dtstart) end) + defp exclude(recurrences, starting_date) do + Enum.filter(recurrences, fn recurrence -> is_not_before(recurrence, starting_date) end) end defp ends_by(%{count: count}) when is_integer(count), do: count @@ -445,15 +450,15 @@ defmodule ICal.Recurrence.Generate do Date.range(first, last) |> Enum.map(fn date -> DateTime.new!(date, time) end) end - defp shift(%DateTime{} = dtstart, offset), do: DateTime.shift(dtstart, offset) - defp shift(%Date{} = dtstart, offset), do: Date.shift(dtstart, offset) + defp shift(%DateTime{} = starting_date, interval), do: DateTime.shift(starting_date, interval) + defp shift(%Date{} = starting_date, interval), do: Date.shift(starting_date, interval) - def week_number_bookends(dtstart, week) do + def week_number_bookends(starting_date, week) do # shift the week if week > 0 do # positive week number, start from first w of the year end_date = - Date.new!(dtstart.year, 1, 1) + Date.new!(starting_date.year, 1, 1) |> Date.end_of_week() |> ensure_end_of_first_week() |> Date.shift(week: week - 1) @@ -466,7 +471,7 @@ defmodule ICal.Recurrence.Generate do # and since it is already on the last week, move one less week than requested # e.g. the -1 week is 0 weeks from the last week of the year start_date = - Date.new!(dtstart.year + 1, 1, 1) + Date.new!(starting_date.year + 1, 1, 1) |> Date.end_of_week() |> Date.shift(day: 1) |> Date.shift(week: week) From 0902d0812f2335674cbd0b25a7b4567839337a3b Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 12:50:39 +0200 Subject: [PATCH 23/96] more refactoring: by -> modifier[s], generate_set/6 --- lib/ical/recurrence/generate.ex | 102 ++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 39 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 0094197..37805f7 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -27,6 +27,21 @@ defmodule ICal.Recurrence.Generate do ) end + @spec one_set(ICal.Recurrence.t(), starting_from :: recurrence) :: + {:ok, [recurrence]} | {:error, error_reasons, [recurrence]} + def one_set(rule, starting_date) do + interval = rule_interval(rule) + modifiers = rule_modifiers(rule) + + generate_all( + ends_by(rule), + starting_date, + interval, + modifiers, + rule + ) + end + defp rule_interval(%ICal.Recurrence{frequency: :yearly, interval: interval}) do [year: interval] end @@ -158,50 +173,43 @@ defmodule ICal.Recurrence.Generate do ] end - defp generate_all(limit, starting_date, interval, by, rule) do + defp generate_all(limit, starting_date, interval, modifiers, rule) do generate_all( limit, starting_date, interval, - by, + modifiers, rule, 0, [] ) end - defp generate_all(nil, _starting_date, _interval, _by, _rule, _fruitless_searches, _acc) do + defp generate_all(nil, _starting_date, _interval, _modifiers, _rule, _fruitless_searches, _acc) do {:error, :no_defined_limit, []} end - defp generate_all(limit, _starting_date, _interval, _by, _rule, _fruitless_searches, acc) + defp generate_all(limit, _starting_date, _interval, _modifiers, _rule, _fruitless_searches, acc) when is_integer(limit) and limit < 1, do: {:ok, acc} - defp generate_all(_limit, _starting_date, _interval, _by, rule, fruitless_searches, acc) + defp generate_all(_limit, _starting_date, _interval, _modifiers, rule, fruitless_searches, acc) when fruitless_searches > @max_fruitless_search_depth do Logger.warning("Could not find all recurrences of #{inspect(rule)} due to search exhaustion") {:error, :search_exhaustion, acc} end - defp generate_all(limit, starting_date, interval, by, rule, fruitless_searches, acc) do - recurrences = - [starting_date] - |> apply_all_by(by, rule) - |> exclude(starting_date) - - {limit, recurrences, fruitless_searches} = - update_limit(limit, recurrences, fruitless_searches) + defp generate_all(limit, starting_date, interval, modifiers, rule, fruitless_searches, acc) do + {recurrences, next_starting_date, limit, fruitless_searches} = + generate_set(limit, starting_date, interval, modifiers, rule, fruitless_searches) if limit == nil do acc ++ recurrences else - next_starting_date = shift(starting_date, interval) - generate_all( limit, next_starting_date, interval, - by, + modifiers, rule, fruitless_searches, acc ++ recurrences @@ -209,9 +217,23 @@ defmodule ICal.Recurrence.Generate do end end - defp apply_all_by(recurrences, by, rule) do - Enum.reduce(by, recurrences, fn by, acc -> - apply_by(by, rule, acc) + defp generate_set(limit, starting_date, interval, modifiers, rule, fruitless_searches) do + recurrences = + [starting_date] + |> apply_all_modifiers(modifiers, rule) + |> exclude(starting_date) + + {limit, recurrences, fruitless_searches} = + update_limit(limit, recurrences, fruitless_searches) + + next_starting_date = shift(starting_date, interval) + + {recurrences, next_starting_date, limit, fruitless_searches} + end + + defp apply_all_modifiers(recurrences, modifiers, rule) do + Enum.reduce(modifiers, recurrences, fn modifier, acc -> + apply_modifier(modifier, rule, acc) |> Enum.reduce([], &only_valid_dates/2) |> Enum.sort(&compare_recurrences/2) end) @@ -246,7 +268,7 @@ defmodule ICal.Recurrence.Generate do defp compare_recurrences(%NaiveDateTime{} = l, r), do: NaiveDateTime.compare(l, r) == :lt defp compare_recurrences(%Date{} = l, r), do: Date.compare(l, r) == :lt - defp apply_by({:by_month, :expand}, %{by_month: months}, acc) when has_some(months) do + defp apply_modifier({:by_month, :expand}, %{by_month: months}, acc) when has_some(months) do Enum.reduce(acc, [], fn recurrence, acc -> acc ++ Enum.map(months, fn month -> @@ -259,13 +281,14 @@ defmodule ICal.Recurrence.Generate do end) end - defp apply_by({:by_month, :limit}, %{by_month: months}, acc) when has_some(months) do + defp apply_modifier({:by_month, :limit}, %{by_month: months}, acc) when has_some(months) do Enum.filter(acc, fn recurrence -> Enum.member?(months, recurrence.month) end) end - defp apply_by({:by_week_number, :expand}, %{by_week_number: weeks}, acc) when has_some(weeks) do + defp apply_modifier({:by_week_number, :expand}, %{by_week_number: weeks}, acc) + when has_some(weeks) do Enum.reduce(acc, [], fn recurrence, acc -> recurrence_week = week_of_year(recurrence) @@ -286,7 +309,8 @@ defmodule ICal.Recurrence.Generate do end) end - defp apply_by({:by_week_number, :limit}, %{by_week_number: weeks}, acc) when has_some(weeks) do + defp apply_modifier({:by_week_number, :limit}, %{by_week_number: weeks}, acc) + when has_some(weeks) do Enum.filter(acc, fn recurrence -> Enum.find(weeks, fn week -> {week_start, week_end} = week_number_bookends(recurrence, week) @@ -295,7 +319,7 @@ defmodule ICal.Recurrence.Generate do end) end - defp apply_by({:by_year_day, :expand}, %{by_year_day: year_days}, acc) + defp apply_modifier({:by_year_day, :expand}, %{by_year_day: year_days}, acc) when has_some(year_days) do Enum.uniq_by(acc, fn recurrence -> recurrence.year end) |> Enum.flat_map(fn recurrence -> @@ -312,14 +336,14 @@ defmodule ICal.Recurrence.Generate do end) end - defp apply_by({:by_year_day, :limit}, %{by_year_day: year_days}, acc) + defp apply_modifier({:by_year_day, :limit}, %{by_year_day: year_days}, acc) when has_some(year_days) do Enum.filter(acc, fn recurrence -> Enum.member?(year_days, Date.day_of_year(recurrence)) end) end - defp apply_by({:by_month_day, :expand}, %{by_month_day: month_days}, acc) + defp apply_modifier({:by_month_day, :expand}, %{by_month_day: month_days}, acc) when has_some(month_days) do acc |> Enum.flat_map(fn recurrence -> @@ -329,7 +353,7 @@ defmodule ICal.Recurrence.Generate do end) end - defp apply_by({:by_month_day, :limit}, %{by_month_day: month_days}, acc) + defp apply_modifier({:by_month_day, :limit}, %{by_month_day: month_days}, acc) when has_some(month_days) do Enum.filter(acc, fn recurrence -> Enum.member?(month_days, recurrence.day) @@ -337,19 +361,19 @@ defmodule ICal.Recurrence.Generate do end # TODO - defp apply_by({:by_day, :expand_year}, %{by_day: days}, acc) when has_some(days) do + defp apply_modifier({:by_day, :expand_year}, %{by_day: days}, acc) when has_some(days) do acc end - defp apply_by({:by_day, :expand_month}, %{by_day: days}, acc) when has_some(days) do + defp apply_modifier({:by_day, :expand_month}, %{by_day: days}, acc) when has_some(days) do acc end - defp apply_by({:by_day, :expand_week}, %{by_day: days}, acc) when has_some(days) do + defp apply_modifier({:by_day, :expand_week}, %{by_day: days}, acc) when has_some(days) do acc end - defp apply_by({:by_day, :limit}, %{by_day: days}, acc) when has_some(days) do + defp apply_modifier({:by_day, :limit}, %{by_day: days}, acc) when has_some(days) do Enum.filter(acc, fn recurrence -> target = weekday(recurrence) Enum.find(days, fn {_, allowed_day} -> allowed_day == target end) != nil @@ -357,36 +381,36 @@ defmodule ICal.Recurrence.Generate do end # TODO - defp apply_by({:by_hour, :expand}, %{by_hour: hours}, acc) when has_some(hours) do + defp apply_modifier({:by_hour, :expand}, %{by_hour: hours}, acc) when has_some(hours) do acc end # TODO - defp apply_by({:by_hour, :limit}, %{by_hour: hours}, acc) when has_some(hours) do + defp apply_modifier({:by_hour, :limit}, %{by_hour: hours}, acc) when has_some(hours) do acc end # TODO - defp apply_by({:by_minute, :expand}, %{by_minute: minutes}, acc) when has_some(minutes) do + defp apply_modifier({:by_minute, :expand}, %{by_minute: minutes}, acc) when has_some(minutes) do acc end # TODO - defp apply_by({:by_minute, :limit}, %{by_minute: minutes}, acc) when has_some(minutes) do + defp apply_modifier({:by_minute, :limit}, %{by_minute: minutes}, acc) when has_some(minutes) do acc end # TODO - defp apply_by({:by_second, :expand}, %{by_second: seconds}, acc) when has_some(seconds) do + defp apply_modifier({:by_second, :expand}, %{by_second: seconds}, acc) when has_some(seconds) do acc end # TODO - defp apply_by({:by_second, :limit}, %{by_second: seconds}, acc) when has_some(seconds) do + defp apply_modifier({:by_second, :limit}, %{by_second: seconds}, acc) when has_some(seconds) do acc end - defp apply_by({:by_set_position, :limit}, %{by_set_position: index}, recurrences) + defp apply_modifier({:by_set_position, :limit}, %{by_set_position: index}, recurrences) when is_integer(index) and index != 0 do index = if index > 0, do: index - 1, else: index @@ -398,7 +422,7 @@ defmodule ICal.Recurrence.Generate do recurrences end - defp apply_by(_, _rule, acc), do: acc + defp apply_modifier(_, _rule, acc), do: acc defp exclude(recurrences, starting_date) do Enum.filter(recurrences, fn recurrence -> is_not_before(recurrence, starting_date) end) From d9a550ea6f92e967b5a5549a581d720a9bf89e44 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 13:22:39 +0200 Subject: [PATCH 24/96] wrap responses in :ok/:error tuples, introduce ICal.Recurrence.State this simplifies a number of function heads and allows generate_set/2 to easily be called repeatedly by catching the state for later use --- lib/ical/recurrence/generate.ex | 135 +++++++++++++------------------- lib/ical/recurrence/state.ex | 25 ++++++ test/ical/recurrence_test.exs | 18 ++--- 3 files changed, 90 insertions(+), 88 deletions(-) create mode 100644 lib/ical/recurrence/state.ex diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 37805f7..cbcb898 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -2,6 +2,7 @@ defmodule ICal.Recurrence.Generate do @moduledoc false require Logger + alias ICal.Recurrence.State @fruitless_search_start_count 0 @max_fruitless_search_depth 1000 @@ -14,32 +15,28 @@ defmodule ICal.Recurrence.Generate do @spec all(ICal.Recurrence.t(), starting_from :: recurrence) :: {:ok, [recurrence]} | {:error, error_reasons, [recurrence]} - def all(rule, starting_date) do - interval = rule_interval(rule) - modifiers = rule_modifiers(rule) - - generate_all( - ends_by(rule), - starting_date, - interval, - modifiers, - rule - ) + def all(rule, start_date) do + %State{ + limit: rule_limit(rule), + start_date: start_date, + interval: rule_interval(rule), + modifiers: rule_modifiers(rule), + rule: rule + } + |> generate_all() end @spec one_set(ICal.Recurrence.t(), starting_from :: recurrence) :: {:ok, [recurrence]} | {:error, error_reasons, [recurrence]} - def one_set(rule, starting_date) do - interval = rule_interval(rule) - modifiers = rule_modifiers(rule) - - generate_all( - ends_by(rule), - starting_date, - interval, - modifiers, - rule - ) + def one_set(rule, start_date) do + %State{ + limit: rule_limit(rule), + start_date: start_date, + interval: rule_interval(rule), + modifiers: rule_modifiers(rule), + rule: rule + } + |> generate_set() end defp rule_interval(%ICal.Recurrence{frequency: :yearly, interval: interval}) do @@ -173,65 +170,44 @@ defmodule ICal.Recurrence.Generate do ] end - defp generate_all(limit, starting_date, interval, modifiers, rule) do - generate_all( - limit, - starting_date, - interval, - modifiers, - rule, - 0, - [] - ) + defp generate_all(state) do + generate_all(state, []) end - defp generate_all(nil, _starting_date, _interval, _modifiers, _rule, _fruitless_searches, _acc) do - {:error, :no_defined_limit, []} + defp generate_all(%{limit: nil}, acc) do + {:error, :no_defined_limit, acc} end - defp generate_all(limit, _starting_date, _interval, _modifiers, _rule, _fruitless_searches, acc) + defp generate_all(%{limit: limit}, acc) when is_integer(limit) and limit < 1, do: {:ok, acc} - defp generate_all(_limit, _starting_date, _interval, _modifiers, rule, fruitless_searches, acc) + defp generate_all(%{fruitless_searches: fruitless_searches, rule: rule}, acc) when fruitless_searches > @max_fruitless_search_depth do Logger.warning("Could not find all recurrences of #{inspect(rule)} due to search exhaustion") {:error, :search_exhaustion, acc} end - defp generate_all(limit, starting_date, interval, modifiers, rule, fruitless_searches, acc) do - {recurrences, next_starting_date, limit, fruitless_searches} = - generate_set(limit, starting_date, interval, modifiers, rule, fruitless_searches) + defp generate_all(state, acc) do + {recurrences, new_state} = generate_set(state) - if limit == nil do - acc ++ recurrences + if new_state.limit == :reached do + {:ok, acc ++ recurrences} else - generate_all( - limit, - next_starting_date, - interval, - modifiers, - rule, - fruitless_searches, - acc ++ recurrences - ) + generate_all(new_state, acc ++ recurrences) end end - defp generate_set(limit, starting_date, interval, modifiers, rule, fruitless_searches) do + defp generate_set(%State{} = state) do recurrences = - [starting_date] - |> apply_all_modifiers(modifiers, rule) - |> exclude(starting_date) - - {limit, recurrences, fruitless_searches} = - update_limit(limit, recurrences, fruitless_searches) - - next_starting_date = shift(starting_date, interval) + [state.start_date] + |> apply_all_modifiers(state) + |> exclude(state.start_date) - {recurrences, next_starting_date, limit, fruitless_searches} + new_state = %{state | start_date: shift(state.start_date, state.interval)} + update_limit(recurrences, new_state) end - defp apply_all_modifiers(recurrences, modifiers, rule) do + defp apply_all_modifiers(recurrences, %{modifiers: modifiers, rule: rule}) do Enum.reduce(modifiers, recurrences, fn modifier, acc -> apply_modifier(modifier, rule, acc) |> Enum.reduce([], &only_valid_dates/2) @@ -424,12 +400,12 @@ defmodule ICal.Recurrence.Generate do defp apply_modifier(_, _rule, acc), do: acc - defp exclude(recurrences, starting_date) do - Enum.filter(recurrences, fn recurrence -> is_not_before(recurrence, starting_date) end) + defp exclude(recurrences, start_date) do + Enum.filter(recurrences, fn recurrence -> is_not_before(recurrence, start_date) end) end - defp ends_by(%{count: count}) when is_integer(count), do: count - defp ends_by(%{until: until}), do: until + defp rule_limit(%{count: count}) when is_integer(count), do: count + defp rule_limit(%{until: until}), do: until # TODO: is the start of the week needed here? def weekday(%Date{} = date) do @@ -442,26 +418,27 @@ defmodule ICal.Recurrence.Generate do # when no more recurrences are generated for too long, then stop even if it could in theory # go further. - defp update_limit(limit, [], fruitless_searches), do: {limit, [], fruitless_searches + 1} + defp update_limit([], state), + do: {[], %{state | fruitless_searches: state.fruitless_searches + 1}} - # TODO: recurrence search depth limit - defp update_limit(limit, recurrences, _fruitless_searches) when is_integer(limit) do + defp update_limit(recurrences, %{limit: limit} = state) when is_integer(limit) do updated_limit = limit - Enum.count(recurrences) if updated_limit < 1 do - {nil, Enum.slice(recurrences, 0, limit), @fruitless_search_start_count} + {Enum.slice(recurrences, 0, limit), %{state | limit: :reached}} else - {updated_limit, recurrences, @fruitless_search_start_count} + {recurrences, + %{state | limit: updated_limit, fruitless_searches: @fruitless_search_start_count}} end end - defp update_limit(limit, recurrences, _fruitless_searches) do - index = Enum.find_index(recurrences, fn recurrence -> is_after(recurrence, limit) end) + defp update_limit(recurrences, %{limit: limit_date} = state) do + index = Enum.find_index(recurrences, fn recurrence -> is_after(recurrence, limit_date) end) if index != nil do - {nil, Enum.slice(recurrences, 0, index), @fruitless_search_start_count} + {Enum.slice(recurrences, 0, index), %{state | limit: :reached}} else - {limit, recurrences, @fruitless_search_start_count} + {recurrences, %{state | fruitless_searches: @fruitless_search_start_count}} end end @@ -474,15 +451,15 @@ defmodule ICal.Recurrence.Generate do Date.range(first, last) |> Enum.map(fn date -> DateTime.new!(date, time) end) end - defp shift(%DateTime{} = starting_date, interval), do: DateTime.shift(starting_date, interval) - defp shift(%Date{} = starting_date, interval), do: Date.shift(starting_date, interval) + defp shift(%DateTime{} = start_date, interval), do: DateTime.shift(start_date, interval) + defp shift(%Date{} = start_date, interval), do: Date.shift(start_date, interval) - def week_number_bookends(starting_date, week) do + def week_number_bookends(start_date, week) do # shift the week if week > 0 do # positive week number, start from first w of the year end_date = - Date.new!(starting_date.year, 1, 1) + Date.new!(start_date.year, 1, 1) |> Date.end_of_week() |> ensure_end_of_first_week() |> Date.shift(week: week - 1) @@ -495,7 +472,7 @@ defmodule ICal.Recurrence.Generate do # and since it is already on the last week, move one less week than requested # e.g. the -1 week is 0 weeks from the last week of the year start_date = - Date.new!(starting_date.year + 1, 1, 1) + Date.new!(start_date.year + 1, 1, 1) |> Date.end_of_week() |> Date.shift(day: 1) |> Date.shift(week: week) diff --git a/lib/ical/recurrence/state.ex b/lib/ical/recurrence/state.ex new file mode 100644 index 0000000..28a2683 --- /dev/null +++ b/lib/ical/recurrence/state.ex @@ -0,0 +1,25 @@ +defmodule ICal.Recurrence.State do + defstruct [:limit, :start_date, :interval, :modifiers, :rule, fruitless_searches: 0] + + @type recurrence :: Date.t() | DateTime.t() | NaiveDateTime.t() + @type modifier_scope :: + :by_month + | :by_week_number + | :by_year_day + | :by_month_day + | :by_day + | :by_hour + | :by_minute + | :by_second + | :by_set_position + @type modifier_mode :: :limit | :expand | :expand_week | :expand_month | :expand_year + + @type t :: %__MODULE__{ + limit: :reached | non_neg_integer() | recurrence, + start_date: recurrence, + interval: Duration.duration(), + modifiers: [{modifier_scope, modifier_mode}], + rule: ICal.Recurrence.t(), + fruitless_searches: non_neg_integer() + } +end diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 7c261d0..58654df 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -328,7 +328,7 @@ defmodule ICal.RecurrenceTest do rule = %ICal.Recurrence{frequency: :yearly, count: count} dtstart = ~U[2026-04-15 13:00:00Z] - recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) assert Enum.count(recurrences) == count end @@ -338,7 +338,7 @@ defmodule ICal.RecurrenceTest do rule = %ICal.Recurrence{frequency: :yearly, count: count, by_month: [1, 4, 6]} dtstart = ~U[2026-04-15 13:00:00Z] - recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) assert Enum.count(recurrences) == count [recurrence | _] = recurrences @@ -357,7 +357,7 @@ defmodule ICal.RecurrenceTest do dtstart = ~U[2026-04-15 13:00:00Z] - recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) assert Enum.count(recurrences) == count end @@ -374,7 +374,7 @@ defmodule ICal.RecurrenceTest do dtstart = ~U[2026-04-15 13:00:00Z] - recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) assert Enum.count(recurrences) == count [recurrence | _] = recurrences @@ -386,7 +386,7 @@ defmodule ICal.RecurrenceTest do rule = %ICal.Recurrence{frequency: :yearly, count: count, by_week_number: [3, 17]} dtstart = ~U[2026-04-15 13:00:00Z] - recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) assert Enum.count(recurrences) == count @@ -428,7 +428,7 @@ defmodule ICal.RecurrenceTest do dtstart = ~U[2026-04-15 13:00:00Z] - recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) assert Enum.count(recurrences) == count @@ -446,7 +446,7 @@ defmodule ICal.RecurrenceTest do rule = %ICal.Recurrence{frequency: :yearly, count: count, by_year_day: [15, 50]} dtstart = ~U[2026-04-15 13:00:00Z] - recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) assert Enum.count(recurrences) == count @@ -471,7 +471,7 @@ defmodule ICal.RecurrenceTest do dtstart = ~U[2026-04-15 13:00:00Z] - recurrences = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) assert Enum.count(recurrences) == count @@ -526,7 +526,7 @@ defmodule ICal.RecurrenceTest do dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=19971224T000000Z") - recurrences = + {:ok, recurrences} = Helper.time( fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "daily until December 24, 1997" From 3ddd768601f4eec0bdb4d9b8a13c29f5e08a9472 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 14:41:31 +0200 Subject: [PATCH 25/96] add optional end and exclusionary dates --- lib/ical/recurrence/generate.ex | 91 +++++++++++++++++++++++---------- lib/ical/recurrence/state.ex | 13 ++++- 2 files changed, 76 insertions(+), 28 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index cbcb898..fe00c0a 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -13,30 +13,29 @@ defmodule ICal.Recurrence.Generate do @type recurrence :: Date.t() | DateTime.t() | NaiveDateTime.t() @type error_reasons :: :search_exhaustion | :no_defined_limit - @spec all(ICal.Recurrence.t(), starting_from :: recurrence) :: - {:ok, [recurrence]} | {:error, error_reasons, [recurrence]} - def all(rule, start_date) do + @spec init(ICal.Recurrence.t(), start_date :: recurrence, end_date :: nil | recurrence) :: + State.t() + def init(rule, start_date, end_date \\ nil, exclude_dates \\ []) do %State{ - limit: rule_limit(rule), start_date: start_date, interval: rule_interval(rule), modifiers: rule_modifiers(rule), - rule: rule + rule: rule, + exclude_dates: exclude_dates } - |> generate_all() + |> add_rule_limits(rule, end_date) end - @spec one_set(ICal.Recurrence.t(), starting_from :: recurrence) :: + @spec all(ICal.Recurrence.t(), starting_from :: recurrence) :: {:ok, [recurrence]} | {:error, error_reasons, [recurrence]} - def one_set(rule, start_date) do - %State{ - limit: rule_limit(rule), - start_date: start_date, - interval: rule_interval(rule), - modifiers: rule_modifiers(rule), - rule: rule - } - |> generate_set() + def all(rule, start_date) do + init(rule, start_date) + |> generate_all() + end + + @spec one_set(State.t()) :: {[recurrence], State.t()} + def one_set(%State{} = state) do + generate_set(state) end defp rule_interval(%ICal.Recurrence{frequency: :yearly, interval: interval}) do @@ -191,7 +190,7 @@ defmodule ICal.Recurrence.Generate do {recurrences, new_state} = generate_set(state) if new_state.limit == :reached do - {:ok, acc ++ recurrences} + {:ok, acc ++ recurrences} else generate_all(new_state, acc ++ recurrences) end @@ -201,7 +200,7 @@ defmodule ICal.Recurrence.Generate do recurrences = [state.start_date] |> apply_all_modifiers(state) - |> exclude(state.start_date) + |> exclude(state) new_state = %{state | start_date: shift(state.start_date, state.interval)} update_limit(recurrences, new_state) @@ -400,12 +399,27 @@ defmodule ICal.Recurrence.Generate do defp apply_modifier(_, _rule, acc), do: acc - defp exclude(recurrences, start_date) do - Enum.filter(recurrences, fn recurrence -> is_not_before(recurrence, start_date) end) + defp exclude(recurrences, %{start_date: start_date, exclude_dates: exclude_dates}) do + Enum.filter(recurrences, fn recurrence -> + is_not_before(recurrence, start_date) and not in_dates?(exclude_dates, recurrence) + end) + end + + defp in_dates?(all_dates, recurrence) do + Enum.reduce(all_dates, false, fn date, acc -> acc or equal?(date, recurrence) end) + end + + defp add_rule_limits(state, %{count: count}, end_date) when is_integer(count) do + %{state | limit: count, end_date: end_date} end - defp rule_limit(%{count: count}) when is_integer(count), do: count - defp rule_limit(%{until: until}), do: until + defp add_rule_limits(state, %{until: until}, end_date) do + if end_date != nil and is_after(until, end_date) do + %{state | limit: end_date, end_date: end_date} + else + %{state | limit: until, end_date: end_date} + end + end # TODO: is the start of the week needed here? def weekday(%Date{} = date) do @@ -418,8 +432,9 @@ defmodule ICal.Recurrence.Generate do # when no more recurrences are generated for too long, then stop even if it could in theory # go further. - defp update_limit([], state), - do: {[], %{state | fruitless_searches: state.fruitless_searches + 1}} + defp update_limit([], state) do + {[], %{state | fruitless_searches: state.fruitless_searches + 1}} + end defp update_limit(recurrences, %{limit: limit} = state) when is_integer(limit) do updated_limit = limit - Enum.count(recurrences) @@ -427,18 +442,33 @@ defmodule ICal.Recurrence.Generate do if updated_limit < 1 do {Enum.slice(recurrences, 0, limit), %{state | limit: :reached}} else - {recurrences, - %{state | limit: updated_limit, fruitless_searches: @fruitless_search_start_count}} + new_state = %{ + state + | limit: updated_limit, + fruitless_searches: @fruitless_search_start_count + } + + update_limit_by_date(recurrences, state.end_date, new_state) end end defp update_limit(recurrences, %{limit: limit_date} = state) do + new_state = %{state | fruitless_searches: @fruitless_search_start_count} + + update_limit_by_date(recurrences, limit_date, new_state) + end + + defp update_limit_by_date(recurrences, nil, state) do + {recurrences, state} + end + + defp update_limit_by_date(recurrences, limit_date, state) do index = Enum.find_index(recurrences, fn recurrence -> is_after(recurrence, limit_date) end) if index != nil do {Enum.slice(recurrences, 0, index), %{state | limit: :reached}} else - {recurrences, %{state | fruitless_searches: @fruitless_search_start_count}} + {recurrences, state} end end @@ -520,6 +550,13 @@ defmodule ICal.Recurrence.Generate do is_not_after(earliest, middle) and is_not_after(middle, latest) end + defp equal?(%Date{} = d, %DateTime{} = dt), do: equal?(d, DateTime.to_date(dt)) + + defp equal?(%DateTime{} = dt, %Date{} = d), + do: equal?(dt, DateTime.new!(d, Time.new(0, 0, 0), dt.time_zone)) + + defp equal?(l, r), do: l == r + defp is_not_before(%Date{} = d, %DateTime{} = dt), do: is_not_before(d, DateTime.to_date(dt)) defp is_not_before(%DateTime{} = dt, %Date{} = d), do: is_not_before(DateTime.to_date(dt), d) defp is_not_before(%Date{} = l, r), do: Date.compare(l, r) != :lt diff --git a/lib/ical/recurrence/state.ex b/lib/ical/recurrence/state.ex index 28a2683..b5fb42b 100644 --- a/lib/ical/recurrence/state.ex +++ b/lib/ical/recurrence/state.ex @@ -1,5 +1,14 @@ defmodule ICal.Recurrence.State do - defstruct [:limit, :start_date, :interval, :modifiers, :rule, fruitless_searches: 0] + defstruct [ + :limit, + :start_date, + :end_date, + :interval, + :modifiers, + :rule, + exclude_dates: [], + fruitless_searches: 0 + ] @type recurrence :: Date.t() | DateTime.t() | NaiveDateTime.t() @type modifier_scope :: @@ -17,9 +26,11 @@ defmodule ICal.Recurrence.State do @type t :: %__MODULE__{ limit: :reached | non_neg_integer() | recurrence, start_date: recurrence, + end_date: recurrence | nil, interval: Duration.duration(), modifiers: [{modifier_scope, modifier_mode}], rule: ICal.Recurrence.t(), + exclude_dates: [recurrence], fruitless_searches: non_neg_integer() } end From d3b3455f779db856dd5722b5e659668cf9cb5e77 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 14:42:07 +0200 Subject: [PATCH 26/96] port ICal.Recurrence.stream/2 to use ICal.Recurrence.Generate --- lib/ical/recurrence.ex | 269 +++------------------------------- test/ical/recurrence_test.exs | 176 +++++++++------------- 2 files changed, 92 insertions(+), 353 deletions(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 9f1d4d1..23253e2 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -9,6 +9,7 @@ defmodule ICal.Recurrence do """ require Logger + alias ICal.Recurrence.{Generate, State} defstruct [ :until, @@ -98,11 +99,11 @@ defmodule ICal.Recurrence do ) end - def normalize_weekdays(nil, _week_start) do + defp normalize_weekdays(nil, _week_start) do nil end - def normalize_weekdays(weekdays, week_start) do + defp normalize_weekdays(weekdays, week_start) do valid_weekdays = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday] weekday_order = @@ -134,9 +135,6 @@ defmodule ICal.Recurrence do end) end - # ignore :byhour, :monthday, :byyearday, :byweekno, :bymonth for now - @supported_by_x_rrules [:by_day] - @doc """ Given a component with a recurrence rule, return a stream of recurrences for it. @@ -152,40 +150,6 @@ defmodule ICal.Recurrence do `until` is set). If no end_date is set, it will default to `DateTime.utc_now()`. - ## Event rrule options - - Event recurrance details are specified in the `rrule`. The following options - are considered: - - - `freq`: Represents how frequently it recurs. Allowed frequencies - are `DAILY`, `WEEKLY`, and `MONTHLY`. These can be further modified by - the `interval` option. - - - `count` *(optional)*: Represents the number of times that it will - recur. This takes precedence over the `end_date` parameter and the - `until` option. - - - `interval` *(optional)*: Represents the interval at which it occurs. - This option works in concert with `freq` above; by using the `interval` - option, it could recur every 5 days or every 3 weeks. - - - `until` *(optional)*: Represents the end date for the recurrances. - This takes precedence over the `end_date` parameter. - - - `by_day` *(optional)*: Represents the days of the week at which recurrences occur. - - The `freq` option is required for a valid rrule, but the others are - optional. They may be used either individually (ex. just `freq`) or in - concert (ex. `freq` + `interval` + `until`). - - ## Future rrule options (not yet supported) - - - `byhour` *(optional)*: Represents the hours of the day at which it occurs. - - `byweekno` *(optional)*: Represents the week number at which it occurs. - - `bymonthday` *(optional)*: Represents the days of the month at which it occurs. - - `bymonth` *(optional)*: Represents the months at which it occurs. - - `byyearday` *(optional)*: Represents the days of the year at which it occurs. - ## Examples iex> dt = ~D[2016-08-13] @@ -196,229 +160,38 @@ defmodule ICal.Recurrence do |> Enum.to_list() """ - @type recurrable_component :: %{ - required(:rrule) => t() | nil, - required(:dtstart) => Date.t() | DateTime.t() | nil, - optional(:dtend) => Date.t() | DateTime.t() | nil - } - - @spec stream(recurrable_component) :: Enumerable.t() - def stream(component) do - create_recurrence_stream(component, nil, component.rrule) - end + @type recurrable_component :: %{} - @spec stream(recurrable_component, %Date{} | %DateTime{}) :: Enumerable.t() - def stream(component, end_date) do - create_recurrence_stream(component, end_date, component.rrule) + @spec stream(recurrable_component, nil | %Date{} | %DateTime{}) :: Enumerable.t() + def stream(component, end_date \\ nil) do + create_recurrence_stream(component, end_date) end # no occurences, so simply drop out, and return the component itself as the only recurrence - defp create_recurrence_stream(_component, _end_date, nil) do + defp create_recurrence_stream(%{rrule: rule, dtstart: start_date}, _end_date) + when is_nil(rule) or is_nil(start_date) do Stream.transform([], [], fn _, acc -> {:halt, acc} end) end - defp create_recurrence_stream(component, end_date, rule) do - references = - Map.from_struct(rule) - |> Map.take(@supported_by_x_rrules) - |> build_references_by_x_rules(component) - - # Two types of recurrence are supported: by count or until, with until being the default - # If not until date is specifically provided, then the end_date is used - # An interval may be given, which alters the amount the date is shifted by - case rule do - %__MODULE__{frequency: frequency, count: count, interval: interval} when count != nil -> - # the main component counts as 1 occurance, so look for `count - 1` more - add_recurrences_for_count( - component, - references, - count - 1, - shift_opts(frequency, interval) - ) - - %__MODULE__{frequency: frequency, until: until, interval: interval} -> - add_recurrences_until( - component, - references, - until || resolve_end_date(end_date, component), - shift_opts(frequency, interval) - ) - end - end - - # The end date and the original's dtstart must be the same sort of date - # The user *should* take care of this, but let's not expect to much of ourselves - # and instead ensure that they match! - defp resolve_end_date(end_date, %{dtstart: match_to}), do: resolve_end_date(end_date, match_to) - defp resolve_end_date(%x{} = end_date, %x{}), do: end_date - defp resolve_end_date(nil, %Date{}), do: DateTime.to_date(DateTime.utc_now()) - defp resolve_end_date(nil, %DateTime{}), do: DateTime.utc_now() - - defp resolve_end_date(%Date{} = end_date, %DateTime{} = match_to) do - DateTime.new(end_date, ~T[00:00:00], match_to.time_zone) - end - - defp resolve_end_date(%DateTime{} = end_date, %Date{}), do: DateTime.to_date(end_date) - - defp shift_opts(:daily, interval), do: [day: interval] - defp shift_opts(:weekly, interval), do: [week: interval] - defp shift_opts(:monthly, interval), do: [month: interval] - defp shift_opts(:yearly, interval), do: [year: interval] - - defp add_recurrences_until(original_event, references, until, shift_opts) do + defp create_recurrence_stream(%{rrule: rule, dtstart: start_date, exdates: exclude_dates}, end_date) do Stream.resource( - fn -> references end, - fn references -> - next_recurring_event_until( - references, - original_event, - until, - shift_opts - ) - end, - fn recurrences -> recurrences end + fn -> {[], Generate.init(rule, start_date, end_date, exclude_dates)} end, + fn state -> next_recurring_event(state) end, + fn state -> state end ) end - defp next_recurring_event_until([], _original_event, _until, _shift_opts) do - {:halt, []} + defp next_recurring_event({[], %State{limit: :reached}} = state) do + {:halt, state} end - defp next_recurring_event_until( - [reference_event | remaining_references], - original_event, - until, - shift_opts - ) do - new_event = shift(reference_event, shift_opts) - - case compare(new_event.dtstart, until) do - :gt -> - {:halt, {[], []}} - - _ -> - references = remaining_references ++ [new_event] - - if exclude?(new_event, original_event) do - next_recurring_event_until( - references, - original_event, - until, - shift_opts - ) - else - {[new_event], references} - end - end + defp next_recurring_event({[], %State{} = generate_state}) do + generate_state + |> Generate.one_set() + |> next_recurring_event() end - defp add_recurrences_for_count(original_event, references, count, shift_opts) do - Stream.resource( - fn -> {references, count} end, - fn {references, count} -> - next_recurring_event(references, count, original_event, shift_opts) - end, - fn recurrences -> recurrences end - ) + defp next_recurring_event({recurrences, %State{} = generate_state}) do + {recurrences, {[], generate_state}} end - - defp next_recurring_event(_references, count, _original_event, _shift_opts) - when count < 1 do - {:halt, {[], 0}} - end - - defp next_recurring_event([], _count, _original_event, _shift_opts) do - {:halt, {[], 0}} - end - - defp next_recurring_event( - [reference_event | remaining_references], - count, - original_event, - shift_opts - ) do - new_event = shift(reference_event, shift_opts) - references = remaining_references ++ [new_event] - - if exclude?(new_event, original_event) do - next_recurring_event( - references, - count, - original_event, - shift_opts - ) - else - {[new_event], {references, count - 1}} - end - end - - defp shift(%{dtstart: starts, dtend: ends} = component, shift_opts) do - Map.merge(component, %{ - dtstart: shift_date(starts, shift_opts), - dtend: shift_date(ends, shift_opts) - }) - end - - defp shift(%{dtstart: starts} = component, shift_opts) do - Map.merge(component, %{ - dtstart: shift_date(starts, shift_opts) - }) - end - - defp shift_date(%Date{} = date, shift_opts), do: Date.shift(date, shift_opts) - defp shift_date(%DateTime{} = date, shift_opts), do: DateTime.shift(date, shift_opts) - - defp build_references_by_x_rules(by_x_rrules, component) when by_x_rrules == %{} do - [component] - end - - defp build_references_by_x_rules(by_x_rrules, component) do - by_x_rrules - |> Enum.map(fn {by_x, entries} -> - build_references_by_x_rule(component, by_x, entries) - end) - |> List.flatten() - end - - defp build_references_by_x_rule(component, _by_x, nil), do: [component] - - defp build_references_by_x_rule(component, :by_day, entries) do - day_values = %{ - monday: 1, - tuesday: 2, - wednesday: 3, - thursday: 4, - friday: 5, - saturday: 6, - sunday: 7 - } - - entries - |> Enum.sort(fn {loffset, lday}, {roffset, rday} -> - if loffset == roffset do - Map.get(day_values, lday) <= Map.get(day_values, rday) - else - loffset <= roffset - end - end) - |> Enum.map(fn {_offset, by_day} -> - # TODO: support offsets other than the trivial case of 0 - # determine the difference between the by_day and dtstart - day_offset_for_reference = Map.get(day_values, by_day) - Date.day_of_week(component.dtstart) - shift(component, day: day_offset_for_reference) - end) - end - - defp compare(%Date{} = l, r), do: Date.compare(l, r) - defp compare(%DateTime{} = l, r), do: DateTime.compare(l, r) - - defp exclude?(recurrence, original) do - # 1. The component doesn't fall on an EXDATE - # 2. The recurrence is not before the original component (created as a reference) - recurrence.dtstart in original.exdates or - compare_dates(recurrence.dtstart, original.dtstart) == :lt - end - - defp compare_dates(%Date{} = l, r), do: Date.compare(l, r) - defp compare_dates(%DateTime{} = l, r), do: Date.compare(l, r) end diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 58654df..edb6be1 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -167,162 +167,128 @@ defmodule ICal.RecurrenceTest do end end - describe "RRULE: generating recurrences" do - test "event with no recurrences" do + describe "Recurrence stream" do + test "correctly handles event with no recurrences" do assert [] == Fixtures.one_event() |> ICal.Recurrence.stream() |> Enum.to_list() end - test "daily reccuring event with until" do - events = + test "generates daily reccuring event with until" do + recurrences = Helper.test_data("recurrance_daily_until") |> ICal.from_ics() |> Map.get(:events) |> Enum.map(fn event -> - recurrences = - ICal.Recurrence.stream(event) - |> Enum.to_list() - - [event | recurrences] + ICal.Recurrence.stream(event) + |> Enum.to_list() end) |> List.flatten() - assert events |> Enum.count() == 8 - - [event | events] = events - assert event.dtstart == ~U[2015-12-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-25 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-26 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-27 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-28 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-29 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-30 08:30:00Z] - [event] = events - assert event.dtstart == ~U[2015-12-31 08:30:00Z] + assert Enum.count(recurrences) == 8 + + assert recurrences == [ + ~U[2015-12-24 08:30:00Z], + ~U[2015-12-25 08:30:00Z], + ~U[2015-12-26 08:30:00Z], + ~U[2015-12-27 08:30:00Z], + ~U[2015-12-28 08:30:00Z], + ~U[2015-12-29 08:30:00Z], + ~U[2015-12-30 08:30:00Z], + ~U[2015-12-31 08:30:00Z] + ] end - test "daily reccuring event with count" do - events = + test "generates daily reccuring event with count" do + recurrences = Helper.test_data("recurrance_with_count") |> ICal.from_ics() |> Map.get(:events) |> Enum.map(fn event -> - recurrences = - ICal.Recurrence.stream(event) - |> Enum.to_list() - - [event | recurrences] + ICal.Recurrence.stream(event) + |> Enum.to_list() end) |> List.flatten() - assert events |> Enum.count() == 3 + assert Enum.count(recurrences) == 3 - [event | events] = events - assert event.dtstart == ~U[2015-12-24 08:30:00Z] - [event | _events] = events - assert event.dtstart == ~U[2015-12-25 08:30:00Z] + assert recurrences == [ + ~U[2015-12-24 08:30:00Z], + ~U[2015-12-25 08:30:00Z], + ~U[2015-12-26 08:30:00Z] + ] end - test "monthly reccuring event with until" do - events = + test "generates monthly reccuring event with until" do + recurrences = Helper.test_data("recurrance_with_until_monthly") |> ICal.from_ics() |> Map.get(:events) |> Enum.map(fn event -> - recurrences = - ICal.Recurrence.stream(event) - |> Enum.to_list() - - [event | recurrences] + ICal.Recurrence.stream(event) + |> Enum.to_list() end) |> List.flatten() - assert events |> Enum.count() == 7 - - [event | events] = events - assert event.dtstart == ~U[2015-12-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-01-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-02-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-03-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-04-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-05-24 08:30:00Z] - [event] = events - assert event.dtstart == ~U[2016-06-24 08:30:00Z] + assert Enum.count(recurrences) == 7 + + assert recurrences == [ + ~U[2015-12-24 08:30:00Z], + ~U[2016-01-24 08:30:00Z], + ~U[2016-02-24 08:30:00Z], + ~U[2016-03-24 08:30:00Z], + ~U[2016-04-24 08:30:00Z], + ~U[2016-05-24 08:30:00Z], + ~U[2016-06-24 08:30:00Z] + ] end - test "weekly reccuring event with until" do - events = + test "generates weekly reccuring event with until" do + recurrences = Helper.test_data("recurrance_with_until_weekly") |> ICal.from_ics() |> Map.get(:events) |> Enum.map(fn event -> - recurrences = - ICal.Recurrence.stream(event) - |> Enum.to_list() - - [event | recurrences] + ICal.Recurrence.stream(event) + |> Enum.to_list() end) |> List.flatten() - assert events |> Enum.count() == 6 - - [event | events] = events - assert event.dtstart == ~U[2015-12-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-31 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-01-07 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-01-14 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-01-21 08:30:00Z] - [event] = events - assert event.dtstart == ~U[2016-01-28 08:30:00Z] + assert Enum.count(recurrences) == 6 + + assert recurrences == [ + ~U[2015-12-24 08:30:00Z], + ~U[2015-12-31 08:30:00Z], + ~U[2016-01-07 08:30:00Z], + ~U[2016-01-14 08:30:00Z], + ~U[2016-01-21 08:30:00Z], + ~U[2016-01-28 08:30:00Z] + ] end - test "exdates not included in reccuring event with until and byday, ignoring invalid byday value" do - events = + test "ensures exdates not included in reccuring event with until and byday, ignoring invalid byday value" do + recurrences = Helper.test_data("recurrence_until_byday") |> ICal.from_ics() |> Map.get(:events) |> Enum.map(fn event -> - recurrences = - ICal.Recurrence.stream(event) - |> Enum.to_list() - - [event | recurrences] + ICal.Recurrence.stream(event) + |> Enum.to_list() end) |> List.flatten() - assert events |> Enum.count() == 5 - - [event | events] = events - assert event.dtstart == ~U[2020-09-03 14:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2020-09-30 14:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2020-10-01 14:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2020-10-14 14:30:00Z] - [event] = events - assert event.dtstart == ~U[2020-10-15 14:30:00Z] + assert Enum.count(recurrences) == 3 + + assert recurrences == [ + ~U[2020-09-03 14:30:00Z], + ~U[2020-10-01 14:30:00Z], + ~U[2020-10-15 14:30:00Z] + ] end end - describe "RRULE: generate with yearly frequence" do + describe "Recurrence generation with yearly frequence" do test "simple" do count = 5 rule = %ICal.Recurrence{frequency: :yearly, count: count} @@ -485,7 +451,7 @@ defmodule ICal.RecurrenceTest do end end - describe "RRULE: generate with daily frequence" do + describe "Recurrence generation with daily frequence" do test "every day in january for 3 years" do dtstart = DateTime.new!(~D[1998-01-31], ~T[09:00:00], "America/New_York") rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1") @@ -543,7 +509,7 @@ defmodule ICal.RecurrenceTest do end end - describe "RRULE: generate with weekly frequency" do + describe "Recurrence generation with weekly frequency" do test "weekly for 10 weeks" do dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;COUNT=10") From 6086c96dcbd78a869141759e9fab543b9722bc0e Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 14:50:42 +0200 Subject: [PATCH 27/96] re-type recurrable component, formatting --- lib/ical/recurrence.ex | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 23253e2..7805082 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -160,7 +160,12 @@ defmodule ICal.Recurrence do |> Enum.to_list() """ - @type recurrable_component :: %{} + @type recurrable_component :: %{ + required(:rrule) => t() | nil, + required(:dtstart) => Date.t() | DateTime.t() | nil, + optional(:dtend) => Date.t() | DateTime.t() | nil, + optional(:rdates) => [Date.t() | DateTime.t() | ICal.period()] + } @spec stream(recurrable_component, nil | %Date{} | %DateTime{}) :: Enumerable.t() def stream(component, end_date \\ nil) do @@ -173,7 +178,10 @@ defmodule ICal.Recurrence do Stream.transform([], [], fn _, acc -> {:halt, acc} end) end - defp create_recurrence_stream(%{rrule: rule, dtstart: start_date, exdates: exclude_dates}, end_date) do + defp create_recurrence_stream( + %{rrule: rule, dtstart: start_date, exdates: exclude_dates}, + end_date + ) do Stream.resource( fn -> {[], Generate.init(rule, start_date, end_date, exclude_dates)} end, fn state -> next_recurring_event(state) end, From 9d96f59202ca87953fb4a3d3d37e0441b1e17107 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 15:08:54 +0200 Subject: [PATCH 28/96] start handling rdates (TODO left in one case) --- lib/ical/recurrence.ex | 12 ++++++++++-- test/ical/recurrence_test.exs | 9 +++++++++ test/support/fixtures.ex | 12 ++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 7805082..3af85e8 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -173,15 +173,23 @@ defmodule ICal.Recurrence do end # no occurences, so simply drop out, and return the component itself as the only recurrence - defp create_recurrence_stream(%{rrule: rule, dtstart: start_date}, _end_date) + defp create_recurrence_stream(%{rrule: rule, dtstart: start_date} = component, _end_date) when is_nil(rule) or is_nil(start_date) do - Stream.transform([], [], fn _, acc -> {:halt, acc} end) + Stream.resource( + fn -> Map.get(component, :rdates, []) end, + fn + nil -> {:halt, nil} + rdates -> {rdates, nil} + end, + fn state -> state end + ) end defp create_recurrence_stream( %{rrule: rule, dtstart: start_date, exdates: exclude_dates}, end_date ) do + # TODO add rdates into the stream Stream.resource( fn -> {[], Generate.init(rule, start_date, end_date, exclude_dates)} end, fn state -> next_recurring_event(state) end, diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index edb6be1..01f1626 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -175,6 +175,15 @@ defmodule ICal.RecurrenceTest do |> Enum.to_list() end + test "correctly handles event with no recurrence rule, but recurrence dates" do + event = Fixtures.one_event(:with_rdates) + + assert event.rdates == + event + |> ICal.Recurrence.stream() + |> Enum.to_list() + end + test "generates daily reccuring event with until" do recurrences = Helper.test_data("recurrance_daily_until") diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index b11613a..d361634 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -114,6 +114,18 @@ defmodule ICal.Test.Fixtures do } end + def one_event(:with_rdates) do + %{ + one_event(:deserialize) + | rdates: [ + ~D[1997-01-01], + ~D[1997-01-20], + ~D[1997-02-17], + ~D[1997-04-21] + ] + } + end + def one_event(:one_alarm) do %{ one_event(:deserialize) From d6b237aff81295868605661b9eb3c903640d585e Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 15:14:09 +0200 Subject: [PATCH 29/96] allow processing recurrence rules without a component --- lib/ical/recurrence.ex | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 3af85e8..d64a7cb 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -172,6 +172,20 @@ defmodule ICal.Recurrence do create_recurrence_stream(component, end_date) end + @doc """ + Creates a stream of recurrences based on an `%ICal.Recurrence{}`, a starting date, + and an optional ending date + """ + @spec stream(t(), start_date :: Date.t() | DateTime.t() | NaiveDateTime.t()) :: + Enumerable.t() + def stream(%__MODULE__{} = rule, start_date, end_date) do + Stream.resource( + fn -> {[], Generate.init(rule, start_date, end_date)} end, + fn state -> next_recurring_event(state) end, + fn state -> state end + ) + end + # no occurences, so simply drop out, and return the component itself as the only recurrence defp create_recurrence_stream(%{rrule: rule, dtstart: start_date} = component, _end_date) when is_nil(rule) or is_nil(start_date) do From 838b5c69bc688e1cdffcfb934e1e6aa06f150d6a Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 16:51:33 +0200 Subject: [PATCH 30/96] respect an optional list of other recurrences, update todos --- lib/ical/recurrence.ex | 69 +++++++++++++-------- lib/ical/recurrence/generate.ex | 105 ++++++++++++++++++++++++++++---- lib/ical/recurrence/state.ex | 3 +- test/ical/recurrence_test.exs | 31 ++++++++++ test/test_helper.exs | 8 +++ 5 files changed, 176 insertions(+), 40 deletions(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index d64a7cb..b55c2b2 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -163,34 +163,20 @@ defmodule ICal.Recurrence do @type recurrable_component :: %{ required(:rrule) => t() | nil, required(:dtstart) => Date.t() | DateTime.t() | nil, + optional(:exdates) => [Date.t() | DateTime.t()], optional(:dtend) => Date.t() | DateTime.t() | nil, optional(:rdates) => [Date.t() | DateTime.t() | ICal.period()] } @spec stream(recurrable_component, nil | %Date{} | %DateTime{}) :: Enumerable.t() - def stream(component, end_date \\ nil) do - create_recurrence_stream(component, end_date) - end + def stream(component, end_date \\ nil) - @doc """ - Creates a stream of recurrences based on an `%ICal.Recurrence{}`, a starting date, - and an optional ending date - """ - @spec stream(t(), start_date :: Date.t() | DateTime.t() | NaiveDateTime.t()) :: - Enumerable.t() - def stream(%__MODULE__{} = rule, start_date, end_date) do + def stream(%{rrule: rule, dtstart: start_date} = component, _end_date) + when is_nil(rule) or is_nil(start_date) do + # this creates a stream with only the rdates of the component, if any, + # when the component lacks a rule or a start date Stream.resource( - fn -> {[], Generate.init(rule, start_date, end_date)} end, - fn state -> next_recurring_event(state) end, - fn state -> state end - ) - end - - # no occurences, so simply drop out, and return the component itself as the only recurrence - defp create_recurrence_stream(%{rrule: rule, dtstart: start_date} = component, _end_date) - when is_nil(rule) or is_nil(start_date) do - Stream.resource( - fn -> Map.get(component, :rdates, []) end, + fn -> Map.get(component, :rdates) || [] end, fn nil -> {:halt, nil} rdates -> {rdates, nil} @@ -199,13 +185,44 @@ defmodule ICal.Recurrence do ) end - defp create_recurrence_stream( - %{rrule: rule, dtstart: start_date, exdates: exclude_dates}, - end_date - ) do + def stream(%{rrule: rule, dtstart: start_date} = component, end_date) do # TODO add rdates into the stream + other_recurrences = + case Map.get(component, :rdates) do + [] -> nil + nil -> nil + rdates -> rdates + end + + exclude_dates = + case Map.get(component, :exdates) do + [] -> nil + nil -> nil + exdates -> exdates + end + + Generate.init(rule, start_date, + end_date: end_date, + exclude_dates: exclude_dates, + other_recurrences: other_recurrences + ) + |> create_stream() + end + + @doc """ + Creates a stream of recurrences based on an `%ICal.Recurrence{}`, a starting date, + and an optional ending date + """ + @spec stream(t(), start_date :: Date.t() | DateTime.t() | NaiveDateTime.t()) :: + Enumerable.t() + def stream(%__MODULE__{} = rule, start_date, end_date) do + Generate.init(rule, start_date, end_date) + |> create_stream() + end + + defp create_stream(state) do Stream.resource( - fn -> {[], Generate.init(rule, start_date, end_date, exclude_dates)} end, + fn -> {[], state} end, fn state -> next_recurring_event(state) end, fn state -> state end ) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index fe00c0a..b8da01c 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -10,20 +10,30 @@ defmodule ICal.Recurrence.Generate do defguard has_some(x) when is_list(x) and x != [] defguard has_none(x) when not has_some(x) - @type recurrence :: Date.t() | DateTime.t() | NaiveDateTime.t() + @type recurrence :: Date.t() | DateTime.t() @type error_reasons :: :search_exhaustion | :no_defined_limit + @type init_option :: + {:end_date, recurrence} + | {:exclude_dates, [recurrence]} + | {:other_recurrences, [recurrence]} - @spec init(ICal.Recurrence.t(), start_date :: recurrence, end_date :: nil | recurrence) :: + @spec init(ICal.Recurrence.t(), start_date :: recurrence, options :: [init_option]) :: State.t() - def init(rule, start_date, end_date \\ nil, exclude_dates \\ []) do + def init(rule, start_date, options \\ []) do + other_recurrences = + options + |> resolve_option(:other_recurrences, []) + |> Enum.sort(&compare_recurrences/2) + %State{ start_date: start_date, interval: rule_interval(rule), modifiers: rule_modifiers(rule), rule: rule, - exclude_dates: exclude_dates + exclude_dates: resolve_option(options, :exclude_dates, []), + other_recurrences: other_recurrences } - |> add_rule_limits(rule, end_date) + |> add_rule_limits(rule, Keyword.get(options, :end_date)) end @spec all(ICal.Recurrence.t(), starting_from :: recurrence) :: @@ -38,6 +48,13 @@ defmodule ICal.Recurrence.Generate do generate_set(state) end + defp resolve_option(options, key, default) do + case Keyword.get(options, key) do + nil -> default + value -> value + end + end + defp rule_interval(%ICal.Recurrence{frequency: :yearly, interval: interval}) do [year: interval] end @@ -177,8 +194,9 @@ defmodule ICal.Recurrence.Generate do {:error, :no_defined_limit, acc} end - defp generate_all(%{limit: limit}, acc) - when is_integer(limit) and limit < 1, do: {:ok, acc} + defp generate_all(%{limit: limit}, acc) when is_integer(limit) and limit < 1 do + {:ok, acc} + end defp generate_all(%{fruitless_searches: fruitless_searches, rule: rule}, acc) when fruitless_searches > @max_fruitless_search_depth do @@ -340,10 +358,12 @@ defmodule ICal.Recurrence.Generate do acc end + # TODO defp apply_modifier({:by_day, :expand_month}, %{by_day: days}, acc) when has_some(days) do acc end + # TODO defp apply_modifier({:by_day, :expand_week}, %{by_day: days}, acc) when has_some(days) do acc end @@ -409,6 +429,62 @@ defmodule ICal.Recurrence.Generate do Enum.reduce(all_dates, false, fn date, acc -> acc or equal?(date, recurrence) end) end + defp include_all_other(recurrences, %{other_recurrences: []} = state) do + {recurrences, state} + end + + defp include_all_other(recurrences, %{other_recurrences: other_recurrences} = state) do + { + Enum.sort(recurrences ++ other_recurrences, &compare_recurrences/2), + %{state | other_recurrences: []} + } + end + + defp include_other(recurrences, %{limit: :reached} = state) do + {recurrences, remaining_other} = merge_other(recurrences, state.other_recurrences) + include_all_other(recurrences, %{state | other_recurrences: remaining_other}) + end + + defp include_other(recurrences, %{other_recurrences: [_ | _] = other_recurrences} = state) do + {recurrences, remaining_other} = merge_other(recurrences, other_recurrences) + {recurrences, %{state | other_recurrences: remaining_other}} + end + + defp include_other(recurrences, state) do + {recurrences, state} + end + + @spec merge_other(recurrences :: [recurrence], other :: [recurrence]) :: + {merged_recurrences :: [recurrence], remaining_other :: [recurrence]} + defp merge_other(recurrences, []) do + {recurrences, []} + end + + defp merge_other(recurrences, other_recurrences) do + Enum.reduce(recurrences, {[], other_recurrences}, fn + recurrence, {acc, []} -> + {acc ++ [recurrence], []} + + recurrence, {acc, other_recurrences} -> + # other_recurrences is sorted, so only compare until a failure + index = + Enum.reduce_while(other_recurrences, -1, fn other_recurrence, index -> + if is_after(recurrence, other_recurrence) do + {:cont, index + 1} + else + {:halt, index} + end + end) + + if index > -1 do + {inclusions, other_recurrences} = Enum.split(other_recurrences, index + 1) + {acc ++ inclusions ++ [recurrence], other_recurrences} + else + {acc ++ [recurrence], other_recurrences} + end + end) + end + defp add_rule_limits(state, %{count: count}, end_date) when is_integer(count) do %{state | limit: count, end_date: end_date} end @@ -421,7 +497,6 @@ defmodule ICal.Recurrence.Generate do end end - # TODO: is the start of the week needed here? def weekday(%Date{} = date) do index_date = Date.day_of_week(date) days = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday] @@ -440,7 +515,9 @@ defmodule ICal.Recurrence.Generate do updated_limit = limit - Enum.count(recurrences) if updated_limit < 1 do - {Enum.slice(recurrences, 0, limit), %{state | limit: :reached}} + recurrences + |> Enum.slice(0, limit) + |> include_other(%{state | limit: :reached}) else new_state = %{ state @@ -448,7 +525,7 @@ defmodule ICal.Recurrence.Generate do fruitless_searches: @fruitless_search_start_count } - update_limit_by_date(recurrences, state.end_date, new_state) + update_limit_by_date(recurrences, new_state.end_date, new_state) end end @@ -459,16 +536,18 @@ defmodule ICal.Recurrence.Generate do end defp update_limit_by_date(recurrences, nil, state) do - {recurrences, state} + include_other(recurrences, state) end defp update_limit_by_date(recurrences, limit_date, state) do index = Enum.find_index(recurrences, fn recurrence -> is_after(recurrence, limit_date) end) if index != nil do - {Enum.slice(recurrences, 0, index), %{state | limit: :reached}} + recurrences + |> Enum.slice(0, index) + |> include_other(%{state | limit: :reached}) else - {recurrences, state} + include_other(recurrences, state) end end diff --git a/lib/ical/recurrence/state.ex b/lib/ical/recurrence/state.ex index b5fb42b..58de1f4 100644 --- a/lib/ical/recurrence/state.ex +++ b/lib/ical/recurrence/state.ex @@ -6,7 +6,8 @@ defmodule ICal.Recurrence.State do :interval, :modifiers, :rule, - exclude_dates: [], + exclude_dates: nil, + other_recurrences: nil, fruitless_searches: 0 ] diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 01f1626..6cbe3fd 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -184,6 +184,37 @@ defmodule ICal.RecurrenceTest do |> Enum.to_list() end + test "correctly handles event with a recurrence rule and recurrence dates" do + [event] = + Helper.test_data("recurrance_with_count") + |> ICal.from_ics() + |> Map.get(:events) + + rdates = [ + ~U[2015-10-23 07:30:00Z], + ~U[2015-10-23 09:30:00Z], + ~U[2015-12-24 12:30:00Z], + ~U[2015-12-25 12:30:00Z], + ~U[2015-12-26 10:30:00Z], + ~U[2015-12-27 09:30:00Z] + ] + + event = %{event | rdates: rdates} + + generated = [ + ~U[2015-12-24 08:30:00Z], + ~U[2015-12-25 08:30:00Z], + ~U[2015-12-26 08:30:00Z] + ] + + expected = Helper.sort_dates(rdates ++ generated) + + assert expected == + event + |> ICal.Recurrence.stream() + |> Enum.to_list() + end + test "generates daily reccuring event with until" do recurrences = Helper.test_data("recurrance_daily_until") diff --git a/test/test_helper.exs b/test/test_helper.exs index 9a8031e..be7729f 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -27,6 +27,14 @@ defmodule ICal.Test.Helper do value end + def sort_dates(dates) do + Enum.sort(dates, &compare_dates/2) + end + + defp compare_dates(%DateTime{} = l, r), do: DateTime.compare(l, r) == :lt + defp compare_dates(%NaiveDateTime{} = l, r), do: NaiveDateTime.compare(l, r) == :lt + defp compare_dates(%Date{} = l, r), do: Date.compare(l, r) == :lt + defmacro __using__(_) do quote do alias ICal.Test.Helper From 2b8e9f81d7fb6c8d43769b1f35d35cb586e5a984 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 17:02:57 +0200 Subject: [PATCH 31/96] update docs for ICal.Recurrence.Stream/2 --- lib/ical/recurrence.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index b55c2b2..6ac45e7 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -136,7 +136,10 @@ defmodule ICal.Recurrence do end @doc """ - Given a component with a recurrence rule, return a stream of recurrences for it. + Given a component that supports recurrence, returns a stream of recurrences for it. + + The stream takes into consideration any recurrence rules (RRULE), recurrence dates (RDATE), + and excluded dates (EXDATE). It starts at the start date (DTSTART) defined in the component. Warning: this may create a very large sequence of recurrences. From 52ca803e35f3d2ca2f00a80b2e8c959d60cb0941 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 17:04:12 +0200 Subject: [PATCH 32/96] strike this TODO as it is done --- lib/ical/recurrence.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 6ac45e7..3c01da0 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -189,7 +189,6 @@ defmodule ICal.Recurrence do end def stream(%{rrule: rule, dtstart: start_date} = component, end_date) do - # TODO add rdates into the stream other_recurrences = case Map.get(component, :rdates) do [] -> nil From f411a7657956799dd176253dd6ef93e684a60013 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 17:11:34 +0200 Subject: [PATCH 33/96] more docs updates --- README.md | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6514242..1362e26 100644 --- a/README.md +++ b/README.md @@ -75,41 +75,31 @@ Inline attachments can be decoded via `ICal.Attachment.decoded_data/1`. ## Installation -- Add `:ical` to your list of dependencies in `mix.exs`: +- Add `:ical` to your list of dependencies in `mix.exs`, along with a timezone database: ```elixir def deps do [{:ical, "~> 1.0"}] - end - ``` - -- ICal needs a timezone database for timezone-aware operations (parsing events - with `TZID`, recurrence calculations, etc.). Add a package that implements the - `Calendar.TimeZoneDatabase` behaviour to your dependencies: - - ```elixir - def deps do - [ {:ical, "~> 1.0"}, - {:tzdata, "~> 1.1"} + {:tz, "~> 1.1"} ] end ``` - Then configure it in your `config/config.exs`: + Then configure the timezone database in e.g. `config/config.exs`: ```elixir - config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase + config :elixir, :time_zone_database, Tz.TimeZoneDatabase ``` + For auto-updating the timezone database at runtime, check the documentation for the timezone + library you are using. - The following timezone database packages are known to be compatible: + The following timezone database packages are known to work well with ICal: | Package | Module | |---|---| - | [`tzdata`](https://hex.pm/packages/tzdata) | `Tzdata.TimeZoneDatabase` | | [`tz`](https://hex.pm/packages/tz) | `Tz.TimeZoneDatabase` | | [`time_zone_info`](https://hex.pm/packages/time_zone_info) | `TimeZoneInfo.TimeZoneDatabase` | - | [`zoneinfo`](https://hex.pm/packages/zoneinfo) | `Zoneinfo.TimeZoneDatabase` | See [tzdb_test](https://github.com/mathieuprog/tzdb_test) for more information on the available timezone database libraries. From 378ceb99fbd0f1d5a60b0e94c572ad5fc3b200e0 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 17:27:49 +0200 Subject: [PATCH 34/96] use ICal.Recurrence.apply/2 --- lib/ical/alarm.ex | 9 +++++++-- lib/ical/recurrence.ex | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/ical/alarm.ex b/lib/ical/alarm.ex index 85d3a95..400e306 100644 --- a/lib/ical/alarm.ex +++ b/lib/ical/alarm.ex @@ -35,8 +35,13 @@ defmodule ICal.Alarm do |> Enum.take(1) case recurrences do - [recurrence] -> calculate_alarms(recurrence) - _ -> [] + [recurrence] -> + recurrence + |> ICal.Recurrence.apply(component) + |> calculate_alarms() + + _ -> + [] end end diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 3c01da0..6b6ad02 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -70,6 +70,28 @@ defmodule ICal.Recurrence do ICal.Deserialize.Recurrence.from_params(values) end + def apply(%x{} = recurrence, component) when x == Date or x == DateTime do + %{ + component + | dtstart: recurrence, + dtend: offset(recurrence, diff(component.dtend, component.dstart)) + } + end + + defp diff(%Date{} = l, r), do: [day: Date.diff(l, r)] + defp diff(%DateTime{} = l, r), do: [second: DateTime.diff(l, r)] + + defp offset(%DateTime{} = l, offset), do: DateTime.shift(l, offset) + + defp offset(%Date{} = l, second: seconds) do + days = Integer.floor_div(seconds, 60 * 60 * 24) + Date.shift(l, day: days) + end + + defp offset(%Date{} = l, offset) do + Date.shift(l, offset) + end + defp nil_or_positive(value) when is_integer(value) and value > 0, do: value defp nil_or_positive(_), do: nil From bd47eb8a2a8e0f6a0469ff0cb9575363c2e6ac8e Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 17:28:06 +0200 Subject: [PATCH 35/96] update features in README --- README.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1362e26..ac37719 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ A library for reading and writing iCalendar data. * Journals * Timezones * Alarms -* Recurrence calculations (currrently only `BYDAY` is supported) +* Recurrence calculations +* Alarm calculations * Compatibility * RFC 5545 compliant * Support for common non-standard properties, including: @@ -28,11 +29,6 @@ Components that will eventually be supported (in rough order): * Free/busy (VFREEBUSY) -Planned features: - -* Alarm calculation -* Expanded recurrency calculation - ## Usage Full documentation can be found on [Hexdocs](https://hexdocs.pm/ical). From 44282e5613b633e255a302e215c6dfc42ce69bcb Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 20 Apr 2026 19:10:31 +0200 Subject: [PATCH 36/96] dstart => dtstart --- lib/ical/recurrence.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 6b6ad02..bb513f6 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -74,7 +74,7 @@ defmodule ICal.Recurrence do %{ component | dtstart: recurrence, - dtend: offset(recurrence, diff(component.dtend, component.dstart)) + dtend: offset(recurrence, diff(component.dtend, component.dtstart)) } end From a3142206d3280e35330cf9a14bfde6c8f81ca043 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 00:32:13 +0200 Subject: [PATCH 37/96] do not jump the year if the months are equal --- lib/ical/recurrence/generate.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index b8da01c..f83f389 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -265,7 +265,7 @@ defmodule ICal.Recurrence.Generate do Enum.reduce(acc, [], fn recurrence, acc -> acc ++ Enum.map(months, fn month -> - if month > recurrence.month do + if month >= recurrence.month do %{recurrence | month: month} else %{recurrence | year: recurrence.year + 1, month: month} From 8c0b147b588b4d1e6c4a87ad6f4122d70dedacf3 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 00:32:37 +0200 Subject: [PATCH 38/96] move some types to Recurrence, start implementing by_day, more tests --- lib/ical/recurrence.ex | 174 +++++++++++++++++--------------- lib/ical/recurrence/generate.ex | 82 +++++++++++---- lib/ical/recurrence/state.ex | 9 +- test/ical/recurrence_test.exs | 79 ++++++++++++++- 4 files changed, 239 insertions(+), 105 deletions(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index bb513f6..a1d6ab3 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -28,6 +28,12 @@ defmodule ICal.Recurrence do interval: 1 ] + @type recurrence_date :: Date.t() | DateTime.t() + @type stream_option :: + {:end_date, recurrence_date} + | {:exclude_dates, [recurrence_date]} + | {:other_recurrences, [recurrence_date]} + @type frequency :: :secondly | :minutely | :hourly | :daily | :weekly | :monthly | :yearly @type weekday :: :monday | :tuesday | :wednesday | :thursday | :friday | :saturday | :sunday @type t :: %__MODULE__{ @@ -78,85 +84,6 @@ defmodule ICal.Recurrence do } end - defp diff(%Date{} = l, r), do: [day: Date.diff(l, r)] - defp diff(%DateTime{} = l, r), do: [second: DateTime.diff(l, r)] - - defp offset(%DateTime{} = l, offset), do: DateTime.shift(l, offset) - - defp offset(%Date{} = l, second: seconds) do - days = Integer.floor_div(seconds, 60 * 60 * 24) - Date.shift(l, day: days) - end - - defp offset(%Date{} = l, offset) do - Date.shift(l, offset) - end - - defp nil_or_positive(value) when is_integer(value) and value > 0, do: value - defp nil_or_positive(_), do: nil - - defp positive(value, _default) when is_integer(value) and value > 0, do: value - defp positive(_, default), do: default - - defp clamped_numbers(nil, _min, __max), do: nil - - defp clamped_numbers(numbers, min, max) do - numbers - |> Enum.sort() - |> Enum.uniq() - |> Enum.reduce( - [], - fn number, acc -> - case number do - 0 when min == 0 -> - acc ++ [0] - - number when is_number(number) and number != 0 and number <= max and number >= min -> - acc ++ [number] - - _ -> - acc - end - end - ) - end - - defp normalize_weekdays(nil, _week_start) do - nil - end - - defp normalize_weekdays(weekdays, week_start) do - valid_weekdays = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday] - - weekday_order = - if week_start == nil do - valid_weekdays - else - index = Enum.find_index(valid_weekdays, fn wk -> wk == week_start end) || 0 - {l, r} = Enum.split(valid_weekdays, max(0, index)) - r ++ l - end - |> Enum.with_index() - |> Enum.into(%{}) - - weekdays - |> Enum.uniq() - |> Enum.sort(fn {loffset, l}, {roffset, r} -> - if loffset == roffset do - Map.get(weekday_order, l) < Map.get(weekday_order, r) - else - l_is_neg = loffset < 0 - r_is_neg = roffset < 0 - - if l_is_neg == r_is_neg do - loffset < roffset - else - r_is_neg - end - end - end) - end - @doc """ Given a component that supports recurrence, returns a stream of recurrences for it. @@ -237,13 +164,96 @@ defmodule ICal.Recurrence do Creates a stream of recurrences based on an `%ICal.Recurrence{}`, a starting date, and an optional ending date """ - @spec stream(t(), start_date :: Date.t() | DateTime.t() | NaiveDateTime.t()) :: + @spec stream( + t(), + start_date :: Date.t() | DateTime.t(), + options :: [stream_option()] + ) :: Enumerable.t() - def stream(%__MODULE__{} = rule, start_date, end_date) do - Generate.init(rule, start_date, end_date) + def stream(%__MODULE__{} = rule, start_date, options) do + Generate.init(rule, start_date, options) |> create_stream() end + defp diff(%Date{} = l, r), do: [day: Date.diff(l, r)] + defp diff(%DateTime{} = l, r), do: [second: DateTime.diff(l, r)] + + defp offset(%DateTime{} = l, offset), do: DateTime.shift(l, offset) + + defp offset(%Date{} = l, second: seconds) do + days = Integer.floor_div(seconds, 60 * 60 * 24) + Date.shift(l, day: days) + end + + defp offset(%Date{} = l, offset) do + Date.shift(l, offset) + end + + defp nil_or_positive(value) when is_integer(value) and value > 0, do: value + defp nil_or_positive(_), do: nil + + defp positive(value, _default) when is_integer(value) and value > 0, do: value + defp positive(_, default), do: default + + defp clamped_numbers(nil, _min, __max), do: nil + + defp clamped_numbers(numbers, min, max) do + numbers + |> Enum.sort() + |> Enum.uniq() + |> Enum.reduce( + [], + fn number, acc -> + case number do + 0 when min == 0 -> + acc ++ [0] + + number when is_number(number) and number != 0 and number <= max and number >= min -> + acc ++ [number] + + _ -> + acc + end + end + ) + end + + defp normalize_weekdays(nil, _week_start) do + nil + end + + defp normalize_weekdays(weekdays, week_start) do + valid_weekdays = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday] + + weekday_order = + if week_start == nil do + valid_weekdays + else + index = Enum.find_index(valid_weekdays, fn wk -> wk == week_start end) || 0 + {l, r} = Enum.split(valid_weekdays, max(0, index)) + r ++ l + end + |> Enum.with_index() + |> Enum.into(%{}) + + weekdays + |> Enum.uniq() + |> Enum.sort(fn {loffset, l}, {roffset, r} -> + if loffset == roffset do + Map.get(weekday_order, l) < Map.get(weekday_order, r) + else + l_is_neg = loffset < 0 + r_is_neg = roffset < 0 + + if l_is_neg == r_is_neg do + loffset < roffset + else + r_is_neg + end + end + end) + end + defp create_stream(state) do Stream.resource( fn -> {[], state} end, diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index f83f389..5116537 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -10,14 +10,13 @@ defmodule ICal.Recurrence.Generate do defguard has_some(x) when is_list(x) and x != [] defguard has_none(x) when not has_some(x) - @type recurrence :: Date.t() | DateTime.t() @type error_reasons :: :search_exhaustion | :no_defined_limit - @type init_option :: - {:end_date, recurrence} - | {:exclude_dates, [recurrence]} - | {:other_recurrences, [recurrence]} - @spec init(ICal.Recurrence.t(), start_date :: recurrence, options :: [init_option]) :: + @spec init( + ICal.Recurrence.t(), + start_date :: ICal.Recurrence.recurrence_date(), + options :: [ICal.Recurrence.stream_option()] + ) :: State.t() def init(rule, start_date, options \\ []) do other_recurrences = @@ -36,14 +35,15 @@ defmodule ICal.Recurrence.Generate do |> add_rule_limits(rule, Keyword.get(options, :end_date)) end - @spec all(ICal.Recurrence.t(), starting_from :: recurrence) :: - {:ok, [recurrence]} | {:error, error_reasons, [recurrence]} + @spec all(ICal.Recurrence.t(), starting_from :: ICal.Recurrence.recurrence_date()) :: + {:ok, [ICal.Recurrence.recurrence_date()]} + | {:error, error_reasons, [ICal.Recurrence.recurrence_date()]} def all(rule, start_date) do init(rule, start_date) |> generate_all() end - @spec one_set(State.t()) :: {[recurrence], State.t()} + @spec one_set(State.t()) :: {[ICal.Recurrence.recurrence_date()], State.t()} def one_set(%State{} = state) do generate_set(state) end @@ -354,24 +354,53 @@ defmodule ICal.Recurrence.Generate do end # TODO - defp apply_modifier({:by_day, :expand_year}, %{by_day: days}, acc) when has_some(days) do + defp apply_modifier({:by_day, :expand_year}, %{by_day: weekdays}, acc) + when has_some(weekdays) do acc end # TODO - defp apply_modifier({:by_day, :expand_month}, %{by_day: days}, acc) when has_some(days) do - acc + defp apply_modifier({:by_day, :expand_month}, %{by_day: weekdays}, acc) + when has_some(weekdays) do + Enum.flat_map(acc, fn recurrence -> + order = weekday_order() + first_week_day = order[weekday(recurrence)] + + Enum.flat_map( + weekdays, + fn + {0, weekday} -> + weekday_order = order[weekday] + + # calculate when the first of this day occurs in the month + first = + case first_week_day - weekday_order do + diff when diff < 0 -> diff + 7 + diff -> diff + 1 + end + + generate_by_day_in_month([%{recurrence | day: first}]) + + {offset, _weekday} when offset >= 0 -> + recurrence + + {from_end, _weekday} -> + recurrence + end + ) + end) end # TODO - defp apply_modifier({:by_day, :expand_week}, %{by_day: days}, acc) when has_some(days) do + defp apply_modifier({:by_day, :expand_week}, %{by_day: weekdays}, acc) + when has_some(weekdays) do acc end - defp apply_modifier({:by_day, :limit}, %{by_day: days}, acc) when has_some(days) do + defp apply_modifier({:by_day, :limit}, %{by_day: weekdays}, acc) when has_some(weekdays) do Enum.filter(acc, fn recurrence -> target = weekday(recurrence) - Enum.find(days, fn {_, allowed_day} -> allowed_day == target end) != nil + Enum.find(weekdays, fn {_, allowed_day} -> allowed_day == target end) != nil end) end @@ -454,8 +483,12 @@ defmodule ICal.Recurrence.Generate do {recurrences, state} end - @spec merge_other(recurrences :: [recurrence], other :: [recurrence]) :: - {merged_recurrences :: [recurrence], remaining_other :: [recurrence]} + @spec merge_other( + recurrences :: [ICal.Recurrence.recurrence_date()], + other :: [ICal.Recurrence.recurrence_date()] + ) :: + {merged_recurrences :: [ICal.Recurrence.recurrence_date()], + remaining_other :: [ICal.Recurrence.recurrence_date()]} defp merge_other(recurrences, []) do {recurrences, []} end @@ -497,6 +530,10 @@ defmodule ICal.Recurrence.Generate do end end + def weekday_order do + %{monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 7} + end + def weekday(%Date{} = date) do index_date = Date.day_of_week(date) days = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday] @@ -505,6 +542,16 @@ defmodule ICal.Recurrence.Generate do def weekday(%DateTime{} = dt), do: weekday(DateTime.to_date(dt)) + def generate_by_day_in_month([last | _] = acc) do + next = shift(last, week: 1) + + if next.month == last.month do + generate_by_day_in_month([next | acc]) + else + acc + end + end + # when no more recurrences are generated for too long, then stop even if it could in theory # go further. defp update_limit([], state) do @@ -540,6 +587,7 @@ defmodule ICal.Recurrence.Generate do end defp update_limit_by_date(recurrences, limit_date, state) do + # TODO: comparing from the end be more efficient in most cases? index = Enum.find_index(recurrences, fn recurrence -> is_after(recurrence, limit_date) end) if index != nil do diff --git a/lib/ical/recurrence/state.ex b/lib/ical/recurrence/state.ex index 58de1f4..e6e7677 100644 --- a/lib/ical/recurrence/state.ex +++ b/lib/ical/recurrence/state.ex @@ -11,7 +11,6 @@ defmodule ICal.Recurrence.State do fruitless_searches: 0 ] - @type recurrence :: Date.t() | DateTime.t() | NaiveDateTime.t() @type modifier_scope :: :by_month | :by_week_number @@ -25,13 +24,13 @@ defmodule ICal.Recurrence.State do @type modifier_mode :: :limit | :expand | :expand_week | :expand_month | :expand_year @type t :: %__MODULE__{ - limit: :reached | non_neg_integer() | recurrence, - start_date: recurrence, - end_date: recurrence | nil, + limit: :reached | non_neg_integer() | ICal.Recurrence.recurrence_date, + start_date: ICal.Recurrence.recurrence_date, + end_date: ICal.Recurrence.recurrence_date | nil, interval: Duration.duration(), modifiers: [{modifier_scope, modifier_mode}], rule: ICal.Recurrence.t(), - exclude_dates: [recurrence], + exclude_dates: [ICal.Recurrence.recurrence_date], fruitless_searches: non_neg_integer() } end diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 6cbe3fd..f3e29bf 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -351,6 +351,29 @@ defmodule ICal.RecurrenceTest do assert recurrence.month == 6 end + test "every day in january for 3 years using BYMONTH and BYDAY" do + dtstart = DateTime.new!(~D[1998-01-01], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics( + "RRULE:FREQ=YEARLY;UNTIL=20000131T140000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA" + ) + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "every day in january for 3 years with yearly freq" + ) + + assert Enum.count(recurrences) == 93 + + assert Enum.at(recurrences, 0) == + DateTime.new!(~D[1998-01-01], ~T[09:00:00], "America/New_York") + + assert Enum.at(recurrences, -1) == + DateTime.new!(~D[2000-01-31], ~T[09:00:00], "America/New_York") + end + test "positive set position" do count = 5 @@ -492,7 +515,7 @@ defmodule ICal.RecurrenceTest do end describe "Recurrence generation with daily frequence" do - test "every day in january for 3 years" do + test "every day in january for 3 years using BYMONTH" do dtstart = DateTime.new!(~D[1998-01-31], ~T[09:00:00], "America/New_York") rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1") @@ -547,6 +570,60 @@ defmodule ICal.RecurrenceTest do assert Enum.count(recurrences) == 113 end + + test "every other day forever is rejected by all/2" do + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=2") + + assert {:error, :no_defined_limit, []} == ICal.Recurrence.Generate.all(rule, dtstart) + end + + test "every other day forever works with a stream" do + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=2") + count = 10 + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-04], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-06], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-08], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-14], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-16], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-18], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-20], ~T[09:00:00], "America/New_York") + ] + + recurrences = + ICal.Recurrence.stream(rule, dtstart, []) + |> Enum.take(count) + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every 10 days, five times" do + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=10;COUNT=5") + count = 10 + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-22], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-12], ~T[09:00:00], "America/New_York") + ] + + recurrences = + ICal.Recurrence.stream(rule, dtstart, []) + |> Enum.take(count) + + assert Enum.count(recurrences) == 5 + assert recurrences == expected + end end describe "Recurrence generation with weekly frequency" do From 123364f1de93269dc5dee86f967c72178fdd16ce Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 00:47:44 +0200 Subject: [PATCH 39/96] fix tests --- test/ical/recurrence_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index f3e29bf..550cece 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -348,7 +348,7 @@ defmodule ICal.RecurrenceTest do assert Enum.count(recurrences) == count [recurrence | _] = recurrences - assert recurrence.month == 6 + assert %{month: 4} = recurrence end test "every day in january for 3 years using BYMONTH and BYDAY" do @@ -407,7 +407,7 @@ defmodule ICal.RecurrenceTest do assert Enum.count(recurrences) == count [recurrence | _] = recurrences - assert recurrence.month == 6 + assert %{month: 4} = recurrence end test "by week number" do From e9cb46b90b8a4794c1268e1896e7c409640f7430 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 00:56:22 +0200 Subject: [PATCH 40/96] more tests from RFC5545 --- test/ical/recurrence_test.exs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 550cece..762b4bc 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -631,9 +631,26 @@ defmodule ICal.RecurrenceTest do dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;COUNT=10") - Helper.time(fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "weekly for 10 weeks") + {:ok, recurrences} = + Helper.time(fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "weekly for 10 weeks") + + assert Enum.count(recurrences) == 10 # ==> (1997 9:00 AM EDT) September 2,9,16,23,30;October 7,14,21 # (1997 9:00 AM EST) October 28;November 4 end + + test "weekly until a date" do + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;UNTIL=19971224T000000Z") + + {:ok, recurrences} = + Helper.time(fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "weekly for 10 weeks") + + assert Enum.count(recurrences) == 17 + assert Enum.at(recurrences, 0) == dtstart + + assert Enum.at(recurrences, -1) == + DateTime.new!(~D[1997-12-23], ~T[08:00:00], "America/New_York") + end end end From 49191f6b0e1bf3dd4ca1f1574c4772e0ea873b22 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 09:47:30 +0200 Subject: [PATCH 41/96] def -> defp --- lib/ical/recurrence/generate.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 5116537..5203dfc 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -534,15 +534,15 @@ defmodule ICal.Recurrence.Generate do %{monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 7} end - def weekday(%Date{} = date) do + defp weekday(%Date{} = date) do index_date = Date.day_of_week(date) days = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday] Enum.at(days, index_date - 1) end - def weekday(%DateTime{} = dt), do: weekday(DateTime.to_date(dt)) + defp weekday(%DateTime{} = dt), do: weekday(DateTime.to_date(dt)) - def generate_by_day_in_month([last | _] = acc) do + defp generate_by_day_in_month([last | _] = acc) do next = shift(last, week: 1) if next.month == last.month do From 77c58d2a1a0308d88a8443b230be78f3d4d57da1 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 09:48:38 +0200 Subject: [PATCH 42/96] some TODO markers --- lib/ical/recurrence/generate.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 5203dfc..f019d14 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -382,9 +382,11 @@ defmodule ICal.Recurrence.Generate do generate_by_day_in_month([%{recurrence | day: first}]) {offset, _weekday} when offset >= 0 -> + # TODO recurrence {from_end, _weekday} -> + # TODO recurrence end ) From 72a7d9e886d1b822427cf00e4475ef59f1be29cd Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 09:49:15 +0200 Subject: [PATCH 43/96] implement {:by_day, :expand_week} --- lib/ical/recurrence/generate.ex | 34 ++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index f019d14..a9aaf70 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -394,9 +394,27 @@ defmodule ICal.Recurrence.Generate do end # TODO - defp apply_modifier({:by_day, :expand_week}, %{by_day: weekdays}, acc) + defp apply_modifier( + {:by_day, :expand_week}, + %{by_day: weekdays, week_start_day: week_start_day}, + acc + ) when has_some(weekdays) do - acc + Enum.flat_map(acc, fn recurrence -> + order = weekday_order() + first_week_day = beginning_of_week(recurrence, week_start_day) + + week_start_ordinal = order[weekday(first_week_day)] + + Enum.map( + weekdays, + fn {_, weekday} -> + # mod by 7 in case of negative difference + shift_days = Integer.mod(order[weekday] - week_start_ordinal, 7) + shift(first_week_day, day: shift_days) + end + ) + end) end defp apply_modifier({:by_day, :limit}, %{by_day: weekdays}, acc) when has_some(weekdays) do @@ -532,7 +550,17 @@ defmodule ICal.Recurrence.Generate do end end - def weekday_order do + defp beginning_of_week(%DateTime{} = date, start) do + DateTime.new!( + Date.beginning_of_week(DateTime.to_date(date), start), + DateTime.to_time(date), + date.time_zone + ) + end + + defp beginning_of_week(%Date{} = date, start), do: Date.beginning_of_week(date, start) + + defp weekday_order do %{monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 7} end From 637dfece7ed375ce618aa263a1c280daa018b750 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 09:49:29 +0200 Subject: [PATCH 44/96] tidies --- lib/ical/recurrence/generate.ex | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index a9aaf70..a5a3faf 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -672,8 +672,9 @@ defmodule ICal.Recurrence.Generate do defp week_of_year(%DateTime{} = datetime), do: week_of_year(DateTime.to_date(datetime)) - defp week_of_year(%NaiveDateTime{} = datetime), - do: week_of_year(NaiveDateTime.to_date(datetime)) + defp week_of_year(%NaiveDateTime{} = datetime) do + week_of_year(NaiveDateTime.to_date(datetime)) + end defp week_of_year(%Date{} = date) do end_of_first_week = @@ -708,10 +709,7 @@ defmodule ICal.Recurrence.Generate do end defp equal?(%Date{} = d, %DateTime{} = dt), do: equal?(d, DateTime.to_date(dt)) - - defp equal?(%DateTime{} = dt, %Date{} = d), - do: equal?(dt, DateTime.new!(d, Time.new(0, 0, 0), dt.time_zone)) - + defp equal?(%DateTime{} = dt, %Date{} = d), do: equal?(DateTime.to_date(dt), d) defp equal?(l, r), do: l == r defp is_not_before(%Date{} = d, %DateTime{} = dt), do: is_not_before(d, DateTime.to_date(dt)) From 9b61c459fed75057cfcf01de0dc066117f9e2bb1 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 09:49:41 +0200 Subject: [PATCH 45/96] use Logger --- test/test_helper.exs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/test_helper.exs b/test/test_helper.exs index be7729f..ccfc5c2 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,4 +1,6 @@ defmodule ICal.Test.Helper do + require Logger + def test_data_path(name) do Path.join([File.cwd!(), "/test/data"], name <> ".ics") end @@ -23,7 +25,7 @@ defmodule ICal.Test.Helper do @spec time(fun, label :: String.t()) :: term def time(function, label \\ "") do {time, value} = :timer.tc(function, :microsecond) - IO.puts("TIME #{label} => #{time} microseconds / #{time / 1000} ms") + Logger.info("TIME #{label} => #{time} microseconds / #{time / 1000} ms") value end From a50ff0dfdac031ab1018f7bc684aa6b66471e44d Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 09:49:48 +0200 Subject: [PATCH 46/96] more tests --- test/ical/recurrence_test.exs | 52 ++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 762b4bc..ea968b1 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -626,7 +626,7 @@ defmodule ICal.RecurrenceTest do end end - describe "Recurrence generation with weekly frequency" do + describe "Recurrence generation with weekly frequency," do test "weekly for 10 weeks" do dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;COUNT=10") @@ -652,5 +652,55 @@ defmodule ICal.RecurrenceTest do assert Enum.at(recurrences, -1) == DateTime.new!(~D[1997-12-23], ~T[08:00:00], "America/New_York") end + + test "every other week, forever" do + count = 5 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + "every other week, forever" + ) + + assert Enum.count(recurrences) == count + assert Enum.at(recurrences, 0) == dtstart + + assert Enum.at(recurrences, -1) == + DateTime.new!(~D[1997-10-28], ~T[08:00:00], "America/New_York") + end + + test "five weeks of tuesdays and thursdays" do + count = 10 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;UNTIL=19971007T000000Z;WKST=SU;BYDAY=TU,TH") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "every other week, forever" + ) + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-04], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-09], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-11], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-16], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-18], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-23], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-25], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-02], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + # end end From 159657b02c08ee4bc1ed11d44d9f9864f9182699 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 11:23:54 +0200 Subject: [PATCH 47/96] update the changelog --- CHANGELOG.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17668d6..f79ea56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,10 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## vNEXT +## v2.0.0 -This release drops support for Elixir 1.15 and 1.16 in order to gain -access to the improved date and calendaring APIs introduced in 1.17. - -Timex was also removed as a dependency, along with its transitive dependencies -such as gettext. +The minimum version of Elixir required is 1.17. Support for Elixir 1.15 and 1.16 was +dropped so `ICal` may use the improved date and calendaring APIs introduced in 1.17. It is recommended to add a timezone database such as `tz` to applications that use ICal in order to benefit fully from these changes. @@ -20,13 +17,18 @@ ICal in order to benefit fully from these changes. - `next_activation/2`: calculates when an alarm should next activate (if ever) - `next_alarms/1`: returns all next alarms with activation times for a compoonent with alarms (`ICal.Event`, `ICal.Todo`) - - Recurrence now supports `ICal.Todo` + - Recurrence generation was re-written: + - The entirety of the RFC5545 RRULE specification is supported + - Works with `ICal.Event`, `ICal.Todo` and `ICal.Journal` + - Recurrence dates (`RDATE`) are included + - Excluded dates (`EXDATE`) are respected - Fixes - Gap and ambiguous times are properly handled when a datetime lands in a timezone shift period - Janitorial + - The dependency on `Timex` was removed - Documentation improvements -Contributors to this release included: +Contributors to this release include: - [Matthew Lehner](https://github.com/matthewlehner) - [Patrick Wendo](https://github.com/W3NDO) From b120839989efe450a70bebeca3fd16f3301169f0 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 16:44:39 +0200 Subject: [PATCH 48/96] track the initial starting date in Recurrence.State, exclude by it --- lib/ical/recurrence/generate.ex | 21 +++++++++++++++++---- lib/ical/recurrence/state.ex | 10 ++++++---- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index a5a3faf..290e616 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -25,6 +25,7 @@ defmodule ICal.Recurrence.Generate do |> Enum.sort(&compare_recurrences/2) %State{ + earliest_date: start_date, start_date: start_date, interval: rule_interval(rule), modifiers: rule_modifiers(rule), @@ -468,10 +469,22 @@ defmodule ICal.Recurrence.Generate do defp apply_modifier(_, _rule, acc), do: acc - defp exclude(recurrences, %{start_date: start_date, exclude_dates: exclude_dates}) do - Enum.filter(recurrences, fn recurrence -> - is_not_before(recurrence, start_date) and not in_dates?(exclude_dates, recurrence) - end) + defp exclude(recurrences, %{earliest_date: earliest, exclude_dates: exclude_dates}) do + in_set = + Enum.filter(recurrences, fn recurrence -> + not in_dates?(exclude_dates, recurrence) + end) + + index = + Enum.find_index(in_set, fn recurrence -> + is_not_before(recurrence, earliest) + end) + + if index != nil and index > 0 do + Enum.slice(in_set, index..-1//1) + else + in_set + end end defp in_dates?(all_dates, recurrence) do diff --git a/lib/ical/recurrence/state.ex b/lib/ical/recurrence/state.ex index e6e7677..362f191 100644 --- a/lib/ical/recurrence/state.ex +++ b/lib/ical/recurrence/state.ex @@ -1,6 +1,7 @@ defmodule ICal.Recurrence.State do defstruct [ :limit, + :earliest_date, :start_date, :end_date, :interval, @@ -24,13 +25,14 @@ defmodule ICal.Recurrence.State do @type modifier_mode :: :limit | :expand | :expand_week | :expand_month | :expand_year @type t :: %__MODULE__{ - limit: :reached | non_neg_integer() | ICal.Recurrence.recurrence_date, - start_date: ICal.Recurrence.recurrence_date, - end_date: ICal.Recurrence.recurrence_date | nil, + limit: :reached | non_neg_integer() | ICal.Recurrence.recurrence_date(), + earliest_date: ICal.Recurrence.recurrence_date(), + start_date: ICal.Recurrence.recurrence_date(), + end_date: ICal.Recurrence.recurrence_date() | nil, interval: Duration.duration(), modifiers: [{modifier_scope, modifier_mode}], rule: ICal.Recurrence.t(), - exclude_dates: [ICal.Recurrence.recurrence_date], + exclude_dates: [ICal.Recurrence.recurrence_date()], fruitless_searches: non_neg_integer() } end From 6f083474924486e337803bb5f74d26e7d15b18d1 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 16:45:48 +0200 Subject: [PATCH 49/96] implement positive monthly-byday day offsets --- lib/ical/recurrence/generate.ex | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 290e616..8cfa9a4 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -382,9 +382,11 @@ defmodule ICal.Recurrence.Generate do generate_by_day_in_month([%{recurrence | day: first}]) - {offset, _weekday} when offset >= 0 -> - # TODO - recurrence + {offset, weekday} when offset >= 0 -> + first_day = %{recurrence | day: 1} + month_starts = order[weekday(first_day)] + shift_days = Integer.mod(order[weekday] - month_starts, 7) * offset + [shift(first_day, day: shift_days)] {from_end, _weekday} -> # TODO @@ -404,7 +406,6 @@ defmodule ICal.Recurrence.Generate do Enum.flat_map(acc, fn recurrence -> order = weekday_order() first_week_day = beginning_of_week(recurrence, week_start_day) - week_start_ordinal = order[weekday(first_week_day)] Enum.map( From e821078de07e849d0864c2cce67a0fc255492175 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 16:45:52 +0200 Subject: [PATCH 50/96] more tests --- test/ical/recurrence_test.exs | 114 +++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index ea968b1..e471b73 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -318,11 +318,13 @@ defmodule ICal.RecurrenceTest do end) |> List.flatten() - assert Enum.count(recurrences) == 3 + assert Enum.count(recurrences) == 5 assert recurrences == [ ~U[2020-09-03 14:30:00Z], + ~U[2020-09-30 14:30:00Z], ~U[2020-10-01 14:30:00Z], + ~U[2020-10-14 14:30:00Z], ~U[2020-10-15 14:30:00Z] ] end @@ -701,6 +703,114 @@ defmodule ICal.RecurrenceTest do assert recurrences == expected end - # + test "every other week on Monday, Wednesday, and Friday until December + 24, 1997, starting on Monday, September 1, 1997" do + count = 25 + dtstart = DateTime.new!(~D[1997-09-01], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics( + "RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=19971224T000000Z;WKST=SU;BYDAY=MO,WE,FR" + ) + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "every other week, Mo/We/Fr until Dec 24" + ) + + expected = [ + DateTime.new!(~D[1997-09-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-03], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-17], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-03], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-17], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-27], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-29], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-31], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-10], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-12], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-14], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-24], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-26], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-28], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-08], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-10], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-12], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-22], ~T[08:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every other week on Tuesday and Thursday, for 8 occurrences" do + count = 8 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=8;WKST=SU;BYDAY=TU,TH") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "every other week, Mo/We/Fr until Dec 24" + ) + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-04], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-16], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-18], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-14], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-16], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + # ==> (1997 9:00 AM EDT) September 5;October 3 + # (1997 9:00 AM EST) November 7;December 5 + # (1998 9:00 AM EST) January 2;February 6;March 6;April 3 + # (1998 9:00 AM EDT) May 1;June 5 + test "monthly on the first Friday for 10 occurrences" do + count = 10 + dtstart = DateTime.new!(~D[1997-09-05], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;COUNT=10;BYDAY=1FR") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "every other week, Mo/We/Fr until Dec 24" + ) + + expected = [ + DateTime.new!(~D[1997-09-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-03], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-07], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-05], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-02], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1998-02-06], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-06], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1998-04-03], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1998-05-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-06-05], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end end end From f658439fc677c7ef6809e5b0de06a0f991df6d0e Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 17:14:44 +0200 Subject: [PATCH 51/96] add a days_in_month that is date type agnostic --- lib/ical/recurrence/generate.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 8cfa9a4..16a9f43 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -360,7 +360,6 @@ defmodule ICal.Recurrence.Generate do acc end - # TODO defp apply_modifier({:by_day, :expand_month}, %{by_day: weekdays}, acc) when has_some(weekdays) do Enum.flat_map(acc, fn recurrence -> @@ -690,6 +689,9 @@ defmodule ICal.Recurrence.Generate do week_of_year(NaiveDateTime.to_date(datetime)) end + defp days_in_month(%Date{} = date), do: Date.days_in_month(date) + defp days_in_month(date), do: Date.days_in_month(DateTime.to_date(date)) + defp week_of_year(%Date{} = date) do end_of_first_week = Date.new!(date.year, 1, 1) From 8c986d37685e3eea8b94cfeb9033bb26861b3cd3 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 17:15:29 +0200 Subject: [PATCH 52/96] implement netagive monthly-byday day offsets --- lib/ical/recurrence/generate.ex | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 16a9f43..bbe3740 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -384,12 +384,25 @@ defmodule ICal.Recurrence.Generate do {offset, weekday} when offset >= 0 -> first_day = %{recurrence | day: 1} month_starts = order[weekday(first_day)] + days_in_month = days_in_month(recurrence) shift_days = Integer.mod(order[weekday] - month_starts, 7) * offset - [shift(first_day, day: shift_days)] - {from_end, _weekday} -> - # TODO - recurrence + if shift_days >= days_in_month do + [] + else + [shift(first_day, day: shift_days)] + end + + {offset, weekday} -> + last_day = %{recurrence | day: days_in_month(recurrence)} + month_ends = order[weekday(last_day)] + shift_days = Integer.mod(order[weekday] - month_ends, 7) * offset + + if shift_days >= month_ends do + [] + else + [shift(last_day, day: -shift_days)] + end end ) end) From 46179d31bfb9b840f37bf3f0528ecad7054ce99b Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 18:04:38 +0200 Subject: [PATCH 53/96] remove some fluff --- test/ical/recurrence_test.exs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index e471b73..4b2069d 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -779,10 +779,6 @@ defmodule ICal.RecurrenceTest do assert recurrences == expected end - # ==> (1997 9:00 AM EDT) September 5;October 3 - # (1997 9:00 AM EST) November 7;December 5 - # (1998 9:00 AM EST) January 2;February 6;March 6;April 3 - # (1998 9:00 AM EDT) May 1;June 5 test "monthly on the first Friday for 10 occurrences" do count = 10 dtstart = DateTime.new!(~D[1997-09-05], ~T[09:00:00], "America/New_York") From 458d6ef80ef62621ac818e746ab3826d31605cd8 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 18:58:49 +0200 Subject: [PATCH 54/96] implement expand/limit for time components --- lib/ical/recurrence/generate.ex | 65 ++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index bbe3740..ed1ac5b 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -408,7 +408,6 @@ defmodule ICal.Recurrence.Generate do end) end - # TODO defp apply_modifier( {:by_day, :expand_week}, %{by_day: weekdays, week_start_day: week_start_day}, @@ -438,34 +437,73 @@ defmodule ICal.Recurrence.Generate do end) end - # TODO defp apply_modifier({:by_hour, :expand}, %{by_hour: hours}, acc) when has_some(hours) do - acc + Enum.flat_map(acc, fn recurrence -> + Enum.map( + hours, + fn hour -> + case recurrence do + %Date{} = date -> DateTime.new!(date, Time.new!(hour, 0, 0)) + %DateTime{} = date -> %{date | hour: hour} + end + end + ) + end) end - # TODO defp apply_modifier({:by_hour, :limit}, %{by_hour: hours}, acc) when has_some(hours) do - acc + Enum.filter(acc, fn recurrence -> + case recurrence do + %DateTime{hour: hour} -> Enum.member?(hours, hour) + _ -> false + end + end) end - # TODO defp apply_modifier({:by_minute, :expand}, %{by_minute: minutes}, acc) when has_some(minutes) do - acc + Enum.flat_map(acc, fn recurrence -> + Enum.map( + minutes, + fn minute -> + case recurrence do + %Date{} = date -> DateTime.new!(date, Time.new!(0, minute, 0)) + %DateTime{} = date -> %{date | minute: minute} + end + end + ) + end) end - # TODO defp apply_modifier({:by_minute, :limit}, %{by_minute: minutes}, acc) when has_some(minutes) do - acc + Enum.filter(acc, fn recurrence -> + case recurrence do + %DateTime{minute: minute} -> Enum.member?(minutes, minute) + _ -> false + end + end) end - # TODO defp apply_modifier({:by_second, :expand}, %{by_second: seconds}, acc) when has_some(seconds) do - acc + Enum.flat_map(acc, fn recurrence -> + Enum.map( + seconds, + fn second -> + case recurrence do + %Date{} = date -> DateTime.new!(date, Time.new!(0, 0, second)) + %DateTime{} = date -> %{date | second: second} + end + end + ) + end) end - # TODO defp apply_modifier({:by_second, :limit}, %{by_second: seconds}, acc) when has_some(seconds) do - acc + Enum.filter(acc, fn recurrence -> + case recurrence do + %DateTime{second: second} -> Enum.member?(seconds, second) + _ -> false + end + end) end defp apply_modifier({:by_set_position, :limit}, %{by_set_position: index}, recurrences) @@ -643,7 +681,6 @@ defmodule ICal.Recurrence.Generate do end defp update_limit_by_date(recurrences, limit_date, state) do - # TODO: comparing from the end be more efficient in most cases? index = Enum.find_index(recurrences, fn recurrence -> is_after(recurrence, limit_date) end) if index != nil do From 23b38f79ffae6f787c1e614ef789d37cfc36204d Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 19:47:24 +0200 Subject: [PATCH 55/96] move functions of same arity together --- lib/ical/recurrence/generate.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index ed1ac5b..354d7dd 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -733,15 +733,15 @@ defmodule ICal.Recurrence.Generate do end end + defp days_in_month(%Date{} = date), do: Date.days_in_month(date) + defp days_in_month(date), do: Date.days_in_month(DateTime.to_date(date)) + defp week_of_year(%DateTime{} = datetime), do: week_of_year(DateTime.to_date(datetime)) defp week_of_year(%NaiveDateTime{} = datetime) do week_of_year(NaiveDateTime.to_date(datetime)) end - defp days_in_month(%Date{} = date), do: Date.days_in_month(date) - defp days_in_month(date), do: Date.days_in_month(DateTime.to_date(date)) - defp week_of_year(%Date{} = date) do end_of_first_week = Date.new!(date.year, 1, 1) From f24c8fb910bae6bbb2c7e5322a603b24b9a341d1 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 19:47:38 +0200 Subject: [PATCH 56/96] fixes for offset weekdays in months --- lib/ical/recurrence/generate.ex | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 354d7dd..e892530 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -381,11 +381,11 @@ defmodule ICal.Recurrence.Generate do generate_by_day_in_month([%{recurrence | day: first}]) - {offset, weekday} when offset >= 0 -> + {offset, weekday} when offset > 0 -> first_day = %{recurrence | day: 1} month_starts = order[weekday(first_day)] days_in_month = days_in_month(recurrence) - shift_days = Integer.mod(order[weekday] - month_starts, 7) * offset + shift_days = Integer.mod(order[weekday] - month_starts, 7) + 7 * (offset - 1) if shift_days >= days_in_month do [] @@ -396,9 +396,13 @@ defmodule ICal.Recurrence.Generate do {offset, weekday} -> last_day = %{recurrence | day: days_in_month(recurrence)} month_ends = order[weekday(last_day)] - shift_days = Integer.mod(order[weekday] - month_ends, 7) * offset - if shift_days >= month_ends do + # shift by the different between the month end weekday and the target weekday, + # plus one week per offset + shift_days = + Integer.mod(month_ends - order[weekday], 7) + 7 * (abs(offset) - 1) + + if shift_days >= last_day.day do [] else [shift(last_day, day: -shift_days)] From a8f882bc135c952e287092ffa5c4ce1c5088b909 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 19:47:53 +0200 Subject: [PATCH 57/96] update credo --- mix.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.lock b/mix.lock index 9bb72c8..8144219 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "benchee": {:hex, :benchee, "1.5.0", "4d812c31d54b0ec0167e91278e7de3f596324a78a096fd3d0bea68bb0c513b10", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "5b075393aea81b8ae74eadd1c28b1d87e8a63696c649d8293db7c4df3eb67535"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, + "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, From dfca6f86b0ede28e9e6ef44f75f2d2d6b476481c Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 20:19:06 +0200 Subject: [PATCH 58/96] shift UNTIL dates into the tz of the starting date --- lib/ical/recurrence/generate.ex | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index e892530..51b85f6 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -611,6 +611,21 @@ defmodule ICal.Recurrence.Generate do end defp add_rule_limits(state, %{until: until}, end_date) do + # when until is a date time, set the until into the same timezone as the start date + # it can not just be shifted, as the literal values should remain as they are + until = + case until do + %DateTime{} -> + DateTime.new!( + DateTime.to_date(until), + DateTime.to_time(until), + state.earliest_date.time_zone + ) + + _ -> + until + end + if end_date != nil and is_after(until, end_date) do %{state | limit: end_date, end_date: end_date} else From 1ec8176c38e35576f27f1e9a5f5342992d07864c Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 20:49:44 +0200 Subject: [PATCH 59/96] make timing configurable, default to false --- config/config.exs | 4 ++++ test/test_helper.exs | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index 0655d56..cae55e3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -4,3 +4,7 @@ if Mix.env() == :dev do config :mix_test_watch, extra_extensions: [".ics"] end + +if Mix.env() == :dev or Mix.env() == :test do + config :ical, show_test_timings: false +end diff --git a/test/test_helper.exs b/test/test_helper.exs index ccfc5c2..01b7976 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -25,7 +25,11 @@ defmodule ICal.Test.Helper do @spec time(fun, label :: String.t()) :: term def time(function, label \\ "") do {time, value} = :timer.tc(function, :microsecond) - Logger.info("TIME #{label} => #{time} microseconds / #{time / 1000} ms") + + if Application.get_env(:ical, :show_test_timings, false) do + Logger.info("TIME #{label} => #{time} microseconds / #{time / 1000} ms") + end + value end From 6a072f21e79b2c6088500854e41ebfda2fd0e5f4 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 21:35:33 +0200 Subject: [PATCH 60/96] remove unused fn --- lib/ical/recurrence/generate.ex | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 51b85f6..2e97831 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -785,10 +785,6 @@ defmodule ICal.Recurrence.Generate do defp ensure_end_of_first_week(%{day: day} = date) when day < 4, do: Date.shift(date, week: 1) defp ensure_end_of_first_week(day), do: day - defp day_of_year(%DateTime{} = datetime), do: day_of_year(DateTime.to_date(datetime)) - defp day_of_year(%NaiveDateTime{} = datetime), do: day_of_year(NaiveDateTime.to_date(datetime)) - defp day_of_year(%Date{} = date), do: Date.day_of_year(date) - defp is_between_inclusive(earliest, middle, latest) do is_not_after(earliest, middle) and is_not_after(middle, latest) end From a3ee974a95c53842b27c8794dc0431216cbeda65 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 21:35:54 +0200 Subject: [PATCH 61/96] when shifting dates, do not touch the times, according to spec --- lib/ical/recurrence/generate.ex | 82 ++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 36 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 2e97831..63b027e 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -57,31 +57,31 @@ defmodule ICal.Recurrence.Generate do end defp rule_interval(%ICal.Recurrence{frequency: :yearly, interval: interval}) do - [year: interval] + {:date, [year: interval]} end defp rule_interval(%ICal.Recurrence{frequency: :monthly, interval: interval}) do - [month: interval] + {:date, [month: interval]} end defp rule_interval(%ICal.Recurrence{frequency: :weekly, interval: interval}) do - [week: interval] + {:date, [week: interval]} end defp rule_interval(%ICal.Recurrence{frequency: :daily, interval: interval}) do - [day: interval] + {:date, [day: interval]} end defp rule_interval(%ICal.Recurrence{frequency: :hourly, interval: interval}) do - [hour: interval] + {:time, [hour: interval]} end defp rule_interval(%ICal.Recurrence{frequency: :minutely, interval: interval}) do - [minute: interval] + {:time, [minute: interval]} end defp rule_interval(%ICal.Recurrence{frequency: :secondly, interval: interval}) do - [second: interval] + {:time, [second: interval]} end defp rule_modifiers(%ICal.Recurrence{frequency: :yearly} = rule) do @@ -221,7 +221,7 @@ defmodule ICal.Recurrence.Generate do |> apply_all_modifiers(state) |> exclude(state) - new_state = %{state | start_date: shift(state.start_date, state.interval)} + new_state = %{state | start_date: shift_interval(state.start_date, state.interval)} update_limit(recurrences, new_state) end @@ -264,14 +264,9 @@ defmodule ICal.Recurrence.Generate do defp apply_modifier({:by_month, :expand}, %{by_month: months}, acc) when has_some(months) do Enum.reduce(acc, [], fn recurrence, acc -> - acc ++ - Enum.map(months, fn month -> - if month >= recurrence.month do - %{recurrence | month: month} - else - %{recurrence | year: recurrence.year + 1, month: month} - end - end) + Enum.reduce(months, acc, fn month, acc -> + acc ++ [%{recurrence | month: month}] + end) end) end @@ -317,15 +312,12 @@ defmodule ICal.Recurrence.Generate do when has_some(year_days) do Enum.uniq_by(acc, fn recurrence -> recurrence.year end) |> Enum.flat_map(fn recurrence -> - orig_day_of_year = day_of_year(recurrence) + # orig_day_of_year = day_of_year(recurrence) first_of_jan = %{recurrence | month: 1, day: 1} Enum.map(year_days, fn day_of_year -> - if day_of_year > orig_day_of_year do - shift(first_of_jan, day: day_of_year - 1) - else - shift(first_of_jan, year: 1, day: day_of_year - 1) - end + shifted = shift_date(first_of_jan, day: day_of_year - 1) + %{recurrence | month: shifted.month, day: shifted.day} end) end) end @@ -390,7 +382,7 @@ defmodule ICal.Recurrence.Generate do if shift_days >= days_in_month do [] else - [shift(first_day, day: shift_days)] + [shift_date(first_day, day: shift_days)] end {offset, weekday} -> @@ -405,7 +397,7 @@ defmodule ICal.Recurrence.Generate do if shift_days >= last_day.day do [] else - [shift(last_day, day: -shift_days)] + [shift_date(last_day, day: -shift_days)] end end ) @@ -428,7 +420,7 @@ defmodule ICal.Recurrence.Generate do fn {_, weekday} -> # mod by 7 in case of negative difference shift_days = Integer.mod(order[weekday] - week_start_ordinal, 7) - shift(first_week_day, day: shift_days) + shift_date(first_week_day, day: shift_days) end ) end) @@ -526,19 +518,23 @@ defmodule ICal.Recurrence.Generate do defp exclude(recurrences, %{earliest_date: earliest, exclude_dates: exclude_dates}) do in_set = - Enum.filter(recurrences, fn recurrence -> - not in_dates?(exclude_dates, recurrence) - end) + if has_some(exclude_dates) do + Enum.filter(recurrences, fn recurrence -> + not in_dates?(exclude_dates, recurrence) + end) + else + recurrences + end index = Enum.find_index(in_set, fn recurrence -> is_not_before(recurrence, earliest) end) - if index != nil and index > 0 do - Enum.slice(in_set, index..-1//1) - else - in_set + case index do + nil -> [] + 0 -> in_set + index -> Enum.slice(in_set, index..-1//1) end end @@ -656,7 +652,7 @@ defmodule ICal.Recurrence.Generate do defp weekday(%DateTime{} = dt), do: weekday(DateTime.to_date(dt)) defp generate_by_day_in_month([last | _] = acc) do - next = shift(last, week: 1) + next = shift_date(last, week: 1) if next.month == last.month do generate_by_day_in_month([next | acc]) @@ -700,7 +696,8 @@ defmodule ICal.Recurrence.Generate do end defp update_limit_by_date(recurrences, limit_date, state) do - index = Enum.find_index(recurrences, fn recurrence -> is_after(recurrence, limit_date) end) + index = + Enum.find_index(recurrences, fn recurrence -> is_after(recurrence, limit_date) end) if index != nil do recurrences @@ -720,8 +717,21 @@ defmodule ICal.Recurrence.Generate do Date.range(first, last) |> Enum.map(fn date -> DateTime.new!(date, time) end) end - defp shift(%DateTime{} = start_date, interval), do: DateTime.shift(start_date, interval) - defp shift(%Date{} = start_date, interval), do: Date.shift(start_date, interval) + defp shift_interval(date, {:date, interval}), do: shift_date(date, interval) + + defp shift_interval(date, {:time, interval}) do + case date do + %Date{} -> DateTime.new!(date, Time.new!(0, 0, 0), "Etc/UTC") |> DateTime.shift(interval) + %DateTime{} -> DateTime.shift(date, interval) + end + end + + defp shift_date(%DateTime{} = date, interval) do + shifted = DateTime.shift(date, interval) + %{date | year: shifted.year, month: shifted.month, day: shifted.day} + end + + defp shift_date(%Date{} = date, interval), do: Date.shift(date, interval) def week_number_bookends(start_date, week) do # shift the week From 5b6cfd4aa0b84c985a5e5f0d3f46050e3c5e67c0 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 22:24:50 +0200 Subject: [PATCH 62/96] implement pos/neg by day year expansion --- lib/ical/recurrence/generate.ex | 57 +++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 63b027e..ecb1adb 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -312,7 +312,6 @@ defmodule ICal.Recurrence.Generate do when has_some(year_days) do Enum.uniq_by(acc, fn recurrence -> recurrence.year end) |> Enum.flat_map(fn recurrence -> - # orig_day_of_year = day_of_year(recurrence) first_of_jan = %{recurrence | month: 1, day: 1} Enum.map(year_days, fn day_of_year -> @@ -346,10 +345,62 @@ defmodule ICal.Recurrence.Generate do end) end - # TODO defp apply_modifier({:by_day, :expand_year}, %{by_day: weekdays}, acc) when has_some(weekdays) do - acc + Enum.reduce(acc, [], fn recurrence, acc -> + Enum.reduce(weekdays, acc, fn + {offset, weekday}, acc when offset >= 0 -> + order = weekday_order() + first_of_jan = %{recurrence | month: 1, day: 1} + first_week_day = order[weekday(first_of_jan)] + weekday_order = order[weekday] + # calculate when the first of this day occurs in the month + first = + case first_week_day - weekday_order do + diff when diff < 0 -> diff + 7 + diff -> 8 - diff + end + + # first day of the year this weekday appears + first_occurance = %{first_of_jan | day: first} + + # having found the first day in the year, move forward offset less one + # more weeks + offset = max(0, offset - 1) + + next = shift_date(first_occurance, week: offset) + + if next.year == recurrence.year do + acc ++ [next] + else + acc + end + + {offset, weekday}, acc -> + order = weekday_order() + dec_31 = %{recurrence | month: 12, day: 31} + last_week_day = order[weekday(dec_31)] + weekday_order = order[weekday] + + # the last day in the month for this weekday + last = + case weekday_order - last_week_day do + diff when diff < 1 -> 31 + diff + diff -> 24 + diff + end + + # last day in the year this weekday appears + last_occurance = %{dec_31 | day: last} + + next = shift_date(last_occurance, week: offset + 1) + + if next.year == recurrence.year do + acc ++ [next] + else + acc + end + end) + end) end defp apply_modifier({:by_day, :expand_month}, %{by_day: weekdays}, acc) From a7056d7c9fc7701100a591b109a04dcf2ce06678 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 22:26:19 +0200 Subject: [PATCH 63/96] reduce instead of flat_map, improve function name --- lib/ical/recurrence/generate.ex | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index ecb1adb..ea4cd08 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -405,14 +405,15 @@ defmodule ICal.Recurrence.Generate do defp apply_modifier({:by_day, :expand_month}, %{by_day: weekdays}, acc) when has_some(weekdays) do - Enum.flat_map(acc, fn recurrence -> + Enum.reduce(acc, [], fn recurrence, acc -> order = weekday_order() first_week_day = order[weekday(recurrence)] - Enum.flat_map( + Enum.reduce( weekdays, + acc, fn - {0, weekday} -> + {0, weekday}, acc -> weekday_order = order[weekday] # calculate when the first of this day occurs in the month @@ -422,21 +423,21 @@ defmodule ICal.Recurrence.Generate do diff -> diff + 1 end - generate_by_day_in_month([%{recurrence | day: first}]) + acc ++ generate_all_weekdays_in_month([%{recurrence | day: first}]) - {offset, weekday} when offset > 0 -> + {offset, weekday}, acc when offset > 0 -> first_day = %{recurrence | day: 1} month_starts = order[weekday(first_day)] days_in_month = days_in_month(recurrence) shift_days = Integer.mod(order[weekday] - month_starts, 7) + 7 * (offset - 1) if shift_days >= days_in_month do - [] + acc else - [shift_date(first_day, day: shift_days)] + acc ++ [shift_date(first_day, day: shift_days)] end - {offset, weekday} -> + {offset, weekday}, acc -> last_day = %{recurrence | day: days_in_month(recurrence)} month_ends = order[weekday(last_day)] @@ -446,9 +447,9 @@ defmodule ICal.Recurrence.Generate do Integer.mod(month_ends - order[weekday], 7) + 7 * (abs(offset) - 1) if shift_days >= last_day.day do - [] + acc else - [shift_date(last_day, day: -shift_days)] + acc ++ [shift_date(last_day, day: -shift_days)] end end ) @@ -702,11 +703,11 @@ defmodule ICal.Recurrence.Generate do defp weekday(%DateTime{} = dt), do: weekday(DateTime.to_date(dt)) - defp generate_by_day_in_month([last | _] = acc) do + defp generate_all_weekdays_in_month([last | _] = acc) do next = shift_date(last, week: 1) if next.month == last.month do - generate_by_day_in_month([next | acc]) + generate_all_weekdays_in_month([next | acc]) else acc end From 8c5d590c8ca7203b2b15bbff7b4705c47ae46d6d Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 22:52:50 +0200 Subject: [PATCH 64/96] by_day limits on yearly recurrences when weekno is set --- lib/ical/recurrence/generate.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index ea4cd08..24fa67e 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -95,7 +95,7 @@ defmodule ICal.Recurrence.Generate do cond do has_some(rule.by_year_day) -> :limit has_some(rule.by_month_day) -> :limit - has_some(rule.by_week_number) -> :expand_week + has_some(rule.by_week_number) -> :limit has_some(rule.by_month) -> :expand_month true -> :expand_year end From d94492e85a7f042b7a35efe397592737e4214e90 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 22:53:34 +0200 Subject: [PATCH 65/96] simplify {:by_week_number, :expand} --- lib/ical/recurrence/generate.ex | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 24fa67e..2a04ba6 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -279,22 +279,11 @@ defmodule ICal.Recurrence.Generate do defp apply_modifier({:by_week_number, :expand}, %{by_week_number: weeks}, acc) when has_some(weeks) do Enum.reduce(acc, [], fn recurrence, acc -> - recurrence_week = week_of_year(recurrence) + Enum.reduce(weeks, acc, fn week, acc -> + {first, last} = week_number_bookends(recurrence, week) - acc ++ - Enum.flat_map(weeks, fn week -> - reference_date = - if week > recurrence_week do - recurrence - else - %{recurrence | year: recurrence.year + 1} - end - - {first, last} = - week_number_bookends(reference_date, week) - - range(first, last, recurrence) - end) + acc ++ range(first, last, recurrence) + end) end) end From 3bcb2911b658a01aa2791db1d956bc783e7710b7 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 22:54:38 +0200 Subject: [PATCH 66/96] do not drop timezones when creating a range of datetimes --- lib/ical/recurrence/generate.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 2a04ba6..0186e2d 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -755,7 +755,7 @@ defmodule ICal.Recurrence.Generate do def range(first, last, %DateTime{} = dt) do time = DateTime.to_time(dt) - Date.range(first, last) |> Enum.map(fn date -> DateTime.new!(date, time) end) + Date.range(first, last) |> Enum.map(fn date -> DateTime.new!(date, time, dt.time_zone) end) end defp shift_interval(date, {:date, interval}), do: shift_date(date, interval) From a6cdea31de132d47ca13bed37d58cea1d08d8ed3 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 22:55:16 +0200 Subject: [PATCH 67/96] keep tz in week_number_bookends, remove week_of_year as now unused --- lib/ical/recurrence/generate.ex | 42 ++++++++++----------------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 0186e2d..01c067e 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -774,62 +774,46 @@ defmodule ICal.Recurrence.Generate do defp shift_date(%Date{} = date, interval), do: Date.shift(date, interval) - def week_number_bookends(start_date, week) do + def week_number_bookends(date, week) do # shift the week if week > 0 do # positive week number, start from first w of the year end_date = - Date.new!(start_date.year, 1, 1) + Date.new!(date.year, 1, 1) |> Date.end_of_week() |> ensure_end_of_first_week() |> Date.shift(week: week - 1) start_date = Date.beginning_of_week(end_date) - {start_date, end_date} + add_time_if_exists(date, start_date, end_date) else # negative week number, start from the last week of the year # and since it is already on the last week, move one less week than requested # e.g. the -1 week is 0 weeks from the last week of the year start_date = - Date.new!(start_date.year + 1, 1, 1) + Date.new!(date.year + 1, 1, 1) |> Date.end_of_week() |> Date.shift(day: 1) |> Date.shift(week: week) end_date = start_date |> Date.end_of_week() - {start_date, end_date} + add_time_if_exists(date, start_date, end_date) end end - defp days_in_month(%Date{} = date), do: Date.days_in_month(date) - defp days_in_month(date), do: Date.days_in_month(DateTime.to_date(date)) - - defp week_of_year(%DateTime{} = datetime), do: week_of_year(DateTime.to_date(datetime)) - - defp week_of_year(%NaiveDateTime{} = datetime) do - week_of_year(NaiveDateTime.to_date(datetime)) + defp add_time_if_exists(%DateTime{} = date, start_date, end_date) do + { + DateTime.new!(start_date, DateTime.to_time(date), date.time_zone), + DateTime.new!(end_date, DateTime.to_time(date), date.time_zone) + } end - defp week_of_year(%Date{} = date) do - end_of_first_week = - Date.new!(date.year, 1, 1) - |> Date.end_of_week() - |> ensure_end_of_first_week() - |> Date.day_of_year() + defp add_time_if_exists(_, start_date, end_date), do: {start_date, end_date} - end_of_this_week = - date - |> Date.end_of_week() - |> Date.day_of_year() - - week = - (end_of_this_week - end_of_first_week) - |> Integer.floor_div(7) - - week + 1 - end + defp days_in_month(%Date{} = date), do: Date.days_in_month(date) + defp days_in_month(date), do: Date.days_in_month(DateTime.to_date(date)) # the first week is considered the one with at least 4 days # so if the end of the first week is 3 or less, then bump it by a week From 621b047c31812f5d8ec1a09937b23ab7d0c2572f Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 23:19:30 +0200 Subject: [PATCH 68/96] fix {:by_day, :expand_month} for recurrences not first of the month --- lib/ical/recurrence/generate.ex | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 01c067e..c333098 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -396,7 +396,8 @@ defmodule ICal.Recurrence.Generate do when has_some(weekdays) do Enum.reduce(acc, [], fn recurrence, acc -> order = weekday_order() - first_week_day = order[weekday(recurrence)] + + first_week_day = order[weekday(%{recurrence | day: 1})] Enum.reduce( weekdays, @@ -406,10 +407,13 @@ defmodule ICal.Recurrence.Generate do weekday_order = order[weekday] # calculate when the first of this day occurs in the month + # if the weekday order is before the first weekday + # of the month, then it appears one week later minus the difference + # if the weekday order is after, then it's just the diff + 1 days in first = case first_week_day - weekday_order do - diff when diff < 0 -> diff + 7 - diff -> diff + 1 + diff when diff <= 0 -> abs(diff) + 1 + diff -> 8 - diff end acc ++ generate_all_weekdays_in_month([%{recurrence | day: first}]) @@ -430,8 +434,8 @@ defmodule ICal.Recurrence.Generate do last_day = %{recurrence | day: days_in_month(recurrence)} month_ends = order[weekday(last_day)] - # shift by the different between the month end weekday and the target weekday, - # plus one week per offset + # shift by the difference between the month end weekday and the target weekday, + # plus one week per additional offset shift_days = Integer.mod(month_ends - order[weekday], 7) + 7 * (abs(offset) - 1) From e3cb4f74d13cf8c2d805d3997f83bc49165bd1d3 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 21 Apr 2026 23:28:44 +0200 Subject: [PATCH 69/96] finish out the yearly and weekly test suites --- test/ical/recurrence_test.exs | 636 ++++++++++++++++++++++++++++++++-- 1 file changed, 608 insertions(+), 28 deletions(-) diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 4b2069d..c783e75 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -330,7 +330,7 @@ defmodule ICal.RecurrenceTest do end end - describe "Recurrence generation with yearly frequence" do + describe "Recurrence generation with yearly frequence," do test "simple" do count = 5 rule = %ICal.Recurrence{frequency: :yearly, count: count} @@ -514,9 +514,256 @@ defmodule ICal.RecurrenceTest do ~U[2031-01-15 13:00:00Z] ] == recurrences end + + test "in June and July for 10 occurrences" do + count = 10 + dtstart = DateTime.new!(~D[1997-06-10], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;COUNT=10;BYMONTH=6,7") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "in June and July for 10 occurrences" + ) + + expected = [ + DateTime.new!(~D[1997-06-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-07-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-06-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-07-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-06-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-07-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2000-06-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2000-07-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2001-06-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2001-07-10], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every other year on January, February, and March for 10 occurences" do + count = 10 + dtstart = DateTime.new!(~D[1997-03-10], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;INTERVAL=2;COUNT=10;BYMONTH=1,2,3") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "every other year on January, February, and March for 10 occurences" + ) + + expected = [ + DateTime.new!(~D[1997-03-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-01-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-02-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-03-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2001-01-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2001-02-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2001-03-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2003-01-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2003-02-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2003-03-10], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every third year on the 1st, 100th, and 200th day for 10 occurences" do + count = 10 + dtstart = DateTime.new!(~D[1997-01-01], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;INTERVAL=3;COUNT=10;BYYEARDAY=1,100,200") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "every third year on the 1st, 100th, and 200th day for 10 occurences" + ) + + expected = [ + DateTime.new!(~D[1997-01-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-04-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-07-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2000-01-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2000-04-09], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2000-07-18], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2003-01-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2003-04-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2003-07-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2006-01-01], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every 20th Monday of the year" do + count = 3 + dtstart = DateTime.new!(~D[1997-05-19], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;BYDAY=20MO") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + "every 20th Monday of the year" + ) + + expected = [ + DateTime.new!(~D[1997-05-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-05-18], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-05-17], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every 2nd-to-last Monday of the year" do + count = 3 + dtstart = DateTime.new!(~D[1997-12-22], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;BYDAY=-2MO") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + "every 2nd-to-last Monday of the year" + ) + + expected = [ + DateTime.new!(~D[1997-12-22], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-12-21], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-12-20], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "Monday of week number 20 (where the default start of the week is Monday" do + count = 3 + dtstart = DateTime.new!(~D[1997-05-12], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + "Monday of week number 20 (where the default start of the week is Monday" + ) + + expected = [ + DateTime.new!(~D[1997-05-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-05-11], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-05-17], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every Thursday in March" do + count = 7 + dtstart = DateTime.new!(~D[1997-03-13], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=TH") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + "every Thursday in March" + ) + + expected = [ + DateTime.new!(~D[1997-03-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-03-20], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-03-27], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-26], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every Thursday, but only during June, July, and August" do + count = 14 + dtstart = DateTime.new!(~D[1997-06-05], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;BYDAY=TH;BYMONTH=6,7,8") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + "every Thursday, but only during June, July, and August" + ) + + expected = [ + DateTime.new!(~D[1997-06-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-06-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-06-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-06-26], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-07-03], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-07-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-07-17], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-07-24], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-07-31], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-07], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-14], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-21], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-28], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-06-04], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every 4 years, the first Tuesday after a Monday in November" do + count = 3 + dtstart = DateTime.new!(~D[1996-11-05], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics( + "RRULE:FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8" + ) + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + "every 4 years, the first Tuesday after a Monday in November" + ) + + expected = [ + DateTime.new!(~D[1996-11-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2000-11-07], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2004-11-02], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end end - describe "Recurrence generation with daily frequence" do + describe "Recurrence generation with daily frequence," do test "every day in january for 3 years using BYMONTH" do dtstart = DateTime.new!(~D[1998-01-31], ~T[09:00:00], "America/New_York") rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1") @@ -568,7 +815,7 @@ defmodule ICal.RecurrenceTest do # DST hits, and it is one hour earlier! assert Enum.at(recurrences, -1) == - DateTime.new!(~D[1997-12-23], ~T[08:00:00], "America/New_York") + DateTime.new!(~D[1997-12-23], ~T[09:00:00], "America/New_York") assert Enum.count(recurrences) == 113 end @@ -652,7 +899,7 @@ defmodule ICal.RecurrenceTest do assert Enum.at(recurrences, 0) == dtstart assert Enum.at(recurrences, -1) == - DateTime.new!(~D[1997-12-23], ~T[08:00:00], "America/New_York") + DateTime.new!(~D[1997-12-23], ~T[09:00:00], "America/New_York") end test "every other week, forever" do @@ -670,7 +917,7 @@ defmodule ICal.RecurrenceTest do assert Enum.at(recurrences, 0) == dtstart assert Enum.at(recurrences, -1) == - DateTime.new!(~D[1997-10-28], ~T[08:00:00], "America/New_York") + DateTime.new!(~D[1997-10-28], ~T[09:00:00], "America/New_York") end test "five weeks of tuesdays and thursdays" do @@ -703,8 +950,7 @@ defmodule ICal.RecurrenceTest do assert recurrences == expected end - test "every other week on Monday, Wednesday, and Friday until December - 24, 1997, starting on Monday, September 1, 1997" do + test "every other week on Monday, Wednesday, and Friday until December 24, 1997" do count = 25 dtstart = DateTime.new!(~D[1997-09-01], ~T[09:00:00], "America/New_York") @@ -732,19 +978,19 @@ defmodule ICal.RecurrenceTest do DateTime.new!(~D[1997-10-13], ~T[09:00:00], "America/New_York"), DateTime.new!(~D[1997-10-15], ~T[09:00:00], "America/New_York"), DateTime.new!(~D[1997-10-17], ~T[09:00:00], "America/New_York"), - DateTime.new!(~D[1997-10-27], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1997-10-29], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1997-10-31], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1997-11-10], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1997-11-12], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1997-11-14], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1997-11-24], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1997-11-26], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1997-11-28], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1997-12-08], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1997-12-10], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1997-12-12], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1997-12-22], ~T[08:00:00], "America/New_York") + DateTime.new!(~D[1997-10-27], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-31], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-14], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-24], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-26], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-28], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-08], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-22], ~T[09:00:00], "America/New_York") ] assert Enum.count(recurrences) == count @@ -779,7 +1025,57 @@ defmodule ICal.RecurrenceTest do assert recurrences == expected end - test "monthly on the first Friday for 10 occurrences" do + test "WKST variance -> days generated with MO WKST" do + count = 4 + dtstart = DateTime.new!(~D[1997-08-05], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "WKST variance -> days generated with MO WKST" + ) + + expected = [ + DateTime.new!(~D[1997-08-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-24], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "WKST variance -> days generated with SU WKST" do + count = 4 + dtstart = DateTime.new!(~D[1997-08-05], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=SU") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "example where the days generated makes a difference because of WKST" + ) + + expected = [ + DateTime.new!(~D[1997-08-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-17], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-31], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + end + + describe "Recurrence generation with monthly frequency," do + test "on the first Friday for 10 occurrences" do count = 10 dtstart = DateTime.new!(~D[1997-09-05], ~T[09:00:00], "America/New_York") @@ -789,18 +1085,18 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, - "every other week, Mo/We/Fr until Dec 24" + "monthly on the first Friday for 10 occurrences" ) expected = [ DateTime.new!(~D[1997-09-05], ~T[09:00:00], "America/New_York"), DateTime.new!(~D[1997-10-03], ~T[09:00:00], "America/New_York"), - DateTime.new!(~D[1997-11-07], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1997-12-05], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1998-01-02], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1998-02-06], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1998-03-06], ~T[08:00:00], "America/New_York"), - DateTime.new!(~D[1998-04-03], ~T[08:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-07], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-02-06], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-06], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-04-03], ~T[09:00:00], "America/New_York"), DateTime.new!(~D[1998-05-01], ~T[09:00:00], "America/New_York"), DateTime.new!(~D[1998-06-05], ~T[09:00:00], "America/New_York") ] @@ -808,5 +1104,289 @@ defmodule ICal.RecurrenceTest do assert Enum.count(recurrences) == count assert recurrences == expected end + + test "on the first Friday until December 24, 1997" do + count = 4 + dtstart = DateTime.new!(~D[1997-09-05], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;UNTIL=19971224T000000Z;BYDAY=1FR") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "monthly on the first Friday until December 24, 1997" + ) + + expected = [ + DateTime.new!(~D[1997-09-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-03], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-07], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-05], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every other month on the first and last Sunday of the month for 10 occurences" do + count = 10 + dtstart = DateTime.new!(~D[1997-09-07], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=10;BYDAY=1SU,-1SU") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "every other month on the first and last Sunday of the month for 10 occurences" + ) + + expected = [ + DateTime.new!(~D[1997-09-07], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-28], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-04], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-25], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-05-03], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-05-31], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "on the second-to-last Monday of the month for 6 months" do + count = 6 + dtstart = DateTime.new!(~D[1997-09-22], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=-2MO") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "monthly on the second-to-last Monday of the month for 6 months" + ) + + expected = [ + DateTime.new!(~D[1997-09-22], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-20], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-17], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-22], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-02-16], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + # Monthly on the third-to-the-last day of the month, forever: + # + # DTSTART;TZID=America/New_York:19970928T090000 + # RRULE:FREQ=MONTHLY;BYMONTHDAY=-3 + # + # ==> (1997 9:00 AM EDT) September 28 + # (1997 9:00 AM EST) October 29;November 28;December 29 + # (1998 9:00 AM EST) January 29;February 26 + # ... + # + # Monthly on the 2nd and 15th of the month for 10 occurrences: + # + # DTSTART;TZID=America/New_York:19970902T090000 + # RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15 + # + # ==> (1997 9:00 AM EDT) September 2,15;October 2,15 + # (1997 9:00 AM EST) November 2,15;December 2,15 + # (1998 9:00 AM EST) January 2,15 + # + # Monthly on the first and last day of the month for 10 occurrences: + # + # DTSTART;TZID=America/New_York:19970930T090000 + # RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1 + # + # ==> (1997 9:00 AM EDT) September 30;October 1 + # (1997 9:00 AM EST) October 31;November 1,30;December 1,31 + # (1998 9:00 AM EST) January 1,31;February 1 + # + # Every 18 months on the 10th thru 15th of the month for 10 + # occurrences: + # + # DTSTART;TZID=America/New_York:19970910T090000 + # RRULE:FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12, + # 13,14,15 + # + # ==> (1997 9:00 AM EDT) September 10,11,12,13,14,15 + # (1999 9:00 AM EST) March 10,11,12,13 + # + # Every Tuesday, every other month: + # + # DTSTART;TZID=America/New_York:19970902T090000 + # RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=TU + # + # ==> (1997 9:00 AM EDT) September 2,9,16,23,30 + # (1997 9:00 AM EST) November 4,11,18,25 + # (1998 9:00 AM EST) January 6,13,20,27;March 3,10,17,24,31 + # ... + # Every Friday the 13th, forever: + # + # DTSTART;TZID=America/New_York:19970902T090000 + # EXDATE;TZID=America/New_York:19970902T090000 + # RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13 + # + # ==> (1998 9:00 AM EST) February 13;March 13;November 13 + # (1999 9:00 AM EDT) August 13 + # (2000 9:00 AM EDT) October 13 + # ... + # The first Saturday that follows the first Sunday of the month, + # forever: + # + # DTSTART;TZID=America/New_York:19970913T090000 + # RRULE:FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13 + # + # ==> (1997 9:00 AM EDT) September 13;October 11 + # (1997 9:00 AM EST) November 8;December 13 + # (1998 9:00 AM EST) January 10;February 7;March 7 + # (1998 9:00 AM EDT) April 11;May 9;June 13... + # ... + # The third instance into the month of one of Tuesday, Wednesday, or + # Thursday, for the next 3 months: + # + # DTSTART;TZID=America/New_York:19970904T090000 + # RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3 + # + # ==> (1997 9:00 AM EDT) September 4;October 7 + # (1997 9:00 AM EST) November 6 + # + # The second-to-last weekday of the month: + # + # DTSTART;TZID=America/New_York:19970929T090000 + # RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2 + # + # ==> (1997 9:00 AM EDT) September 29 + # (1997 9:00 AM EST) October 30;November 27;December 30 + # (1998 9:00 AM EST) January 29;February 26;March 30 + # ... + # An example where an invalid date (i.e., February 30) is ignored. + # + # DTSTART;TZID=America/New_York:20070115T090000 + # RRULE:FREQ=MONTHLY;BYMONTHDAY=15,30;COUNT=5 + # + # ==> (2007 EST) January 15,30 + # (2007 EST) February 15 + # (2007 EDT) March 15,30 + end + + describe "Recurrence generation with time components," do + test "every 3 hours from 9:00 AM to 5:00 PM on a specific day" do + count = 3 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=HOURLY;INTERVAL=3;UNTIL=19970902T170000Z") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "every 3 hours from 9:00 AM to 5:00 PM on a specific day" + ) + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[12:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[15:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every 15 minutes for 6 occurrences" do + count = 6 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MINUTELY;INTERVAL=15;COUNT=6") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "every 15 minutes for 6 occurrences" + ) + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:15:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:30:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:45:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[10:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[10:15:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every hour and a half for 4 occurrences" do + count = 4 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MINUTELY;INTERVAL=90;COUNT=4") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + "every hour and a half for 4 occurrences" + ) + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[10:30:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[12:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[13:30:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every 20 minutes from 9:00 AM to 4:40 PM every day" do + count = 40 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics( + "RRULE:FREQ=DAILY;BYHOUR=9,10,11,12,13,14,15,16;BYMINUTE=0,20,40" + ) + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + "every 20 minutes from 9:00 AM to 4:40 PM every day" + ) + + assert Enum.count(recurrences) == count + + assert [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:20:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:40:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[10:00:00], "America/New_York") + ] == Enum.slice(recurrences, 0, 4) + + assert [ + DateTime.new!(~D[1997-09-02], ~T[16:40:00], "America/New_York"), + DateTime.new!(~D[1997-09-03], ~T[09:00:00], "America/New_York") + ] == Enum.slice(recurrences, 23, 2) + + assert DateTime.new!(~D[1997-09-03], ~T[14:00:00], "America/New_York") == + Enum.at(recurrences, -1) + end end end From 8b0b6a382122b8c20c1b8ee93c93c66095eaf342 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 08:56:27 +0200 Subject: [PATCH 70/96] catch search exhaustion in generate_set this prevents infinite looping in streams --- lib/ical/recurrence/generate.ex | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index c333098..a297606 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -199,12 +199,6 @@ defmodule ICal.Recurrence.Generate do {:ok, acc} end - defp generate_all(%{fruitless_searches: fruitless_searches, rule: rule}, acc) - when fruitless_searches > @max_fruitless_search_depth do - Logger.warning("Could not find all recurrences of #{inspect(rule)} due to search exhaustion") - {:error, :search_exhaustion, acc} - end - defp generate_all(state, acc) do {recurrences, new_state} = generate_set(state) @@ -215,6 +209,12 @@ defmodule ICal.Recurrence.Generate do end end + defp generate_set(%{fruitless_searches: fruitless_searches, rule: rule} = state) + when fruitless_searches > @max_fruitless_search_depth do + Logger.warning("Could not find all recurrences of #{inspect(rule)} due to search exhaustion") + {[], %{state | limit: :reached}} + end + defp generate_set(%State{} = state) do recurrences = [state.start_date] From 15696bd4d59c647c1063e49ae4623d3e7f302f19 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 08:57:46 +0200 Subject: [PATCH 71/96] catch exception and return nil fixes generation of invalid dates --- lib/ical.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/ical.ex b/lib/ical.ex index baa2e21..5dc7cdd 100644 --- a/lib/ical.ex +++ b/lib/ical.ex @@ -129,6 +129,8 @@ defmodule ICal do {:gap, just_before, just_after} -> adjust_to_gap(time, just_before, just_after) _ -> nil end + rescue + _ -> nil end defp adjust_to_gap(original_time, before_gap, after_gap) do From 2ee0f6ed697248d50d6c7619891e1297b1d9ef90 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 09:14:18 +0200 Subject: [PATCH 72/96] rely on shift_date entirely --- lib/ical/recurrence/generate.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index a297606..d587712 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -304,8 +304,7 @@ defmodule ICal.Recurrence.Generate do first_of_jan = %{recurrence | month: 1, day: 1} Enum.map(year_days, fn day_of_year -> - shifted = shift_date(first_of_jan, day: day_of_year - 1) - %{recurrence | month: shifted.month, day: shifted.day} + shift_date(first_of_jan, day: day_of_year - 1) end) end) end From fe0130dc5e0a136a1e215530289b2958cc919810 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 09:14:47 +0200 Subject: [PATCH 73/96] support negative year day offsets --- lib/ical/recurrence/generate.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index d587712..b238744 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -321,7 +321,12 @@ defmodule ICal.Recurrence.Generate do acc |> Enum.flat_map(fn recurrence -> Enum.map(month_days, fn month_day -> - %{recurrence | day: month_day} + if month_day > 0 do + %{recurrence | day: month_day} + else + end_day = days_in_month(recurrence) + %{recurrence | day: end_day + month_day + 1} + end end) end) end From 2b0f3fea2fd47fccab2040eee2dd4c166c3908bf Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 09:15:07 +0200 Subject: [PATCH 74/96] preserve timezone shifts when shifting dates instead of setting the date components on the original datetime, set the time components on the new (shifted) datetime. this way when the timezone changes (e.g. EDT -> EST) due to shifting the day, that change is retained. --- lib/ical/recurrence/generate.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index b238744..530c179 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -777,7 +777,7 @@ defmodule ICal.Recurrence.Generate do defp shift_date(%DateTime{} = date, interval) do shifted = DateTime.shift(date, interval) - %{date | year: shifted.year, month: shifted.month, day: shifted.day} + %{shifted | hour: date.hour, minute: date.minute, second: date.second} end defp shift_date(%Date{} = date, interval), do: Date.shift(date, interval) From bb8a1a71b59512cb1fcea2a9ce1aa3b01bbcd19e Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 09:16:56 +0200 Subject: [PATCH 75/96] filter rather than copy all the dates --- lib/ical/recurrence/generate.ex | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 530c179..a2f68d8 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -228,34 +228,31 @@ defmodule ICal.Recurrence.Generate do defp apply_all_modifiers(recurrences, %{modifiers: modifiers, rule: rule}) do Enum.reduce(modifiers, recurrences, fn modifier, acc -> apply_modifier(modifier, rule, acc) - |> Enum.reduce([], &only_valid_dates/2) + |> Enum.filter(&date_valid?/1) |> Enum.sort(&compare_recurrences/2) end) end - defp only_valid_dates(%NaiveDateTime{} = date, acc) do + defp date_valid?(%NaiveDateTime{} = date) do case NaiveDateTime.new(NaiveDateTime.to_date(date), NaiveDateTime.to_time(date)) do - {:ok, date} -> acc ++ [date] - _ -> acc + {:ok, _date} -> true + _ -> false end end - defp only_valid_dates(%Date{} = date, acc) do + defp date_valid?(%Date{} = date) do case Date.new(date.year, date.month, date.day) do - {:ok, date} -> acc ++ [date] - _ -> acc + {:ok, _} -> true + _ -> false end end - defp only_valid_dates(%DateTime{} = datetime, acc) do - case ICal.as_valid_datetime( - DateTime.to_date(datetime), - DateTime.to_time(datetime), - datetime.time_zone - ) do - nil -> acc - datetime -> acc ++ [datetime] - end + defp date_valid?(%DateTime{} = datetime) do + ICal.as_valid_datetime( + DateTime.to_date(datetime), + DateTime.to_time(datetime), + datetime.time_zone + ) != nil end defp compare_recurrences(%DateTime{} = l, r), do: DateTime.compare(l, r) == :lt From 4cad5354fd423fce02aefe7988273c08700fd585 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 09:22:26 +0200 Subject: [PATCH 76/96] replace date_valid? impl with Calendar.ISO functions --- lib/ical/recurrence/generate.ex | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index a2f68d8..dda784b 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -233,26 +233,13 @@ defmodule ICal.Recurrence.Generate do end) end - defp date_valid?(%NaiveDateTime{} = date) do - case NaiveDateTime.new(NaiveDateTime.to_date(date), NaiveDateTime.to_time(date)) do - {:ok, _date} -> true - _ -> false - end - end - defp date_valid?(%Date{} = date) do - case Date.new(date.year, date.month, date.day) do - {:ok, _} -> true - _ -> false - end + Calendar.ISO.valid_date?(date.year, date.month, date.day) end defp date_valid?(%DateTime{} = datetime) do - ICal.as_valid_datetime( - DateTime.to_date(datetime), - DateTime.to_time(datetime), - datetime.time_zone - ) != nil + Calendar.ISO.valid_date?(datetime.year, datetime.month, datetime.day) and + Calendar.ISO.valid_time?(datetime.hour, datetime.minute, datetime.second, {0, 0}) end defp compare_recurrences(%DateTime{} = l, r), do: DateTime.compare(l, r) == :lt From 786c28920019da98780e46726083569d5dae65ac Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 09:23:08 +0200 Subject: [PATCH 77/96] set the tz database via config in test env --- config/config.exs | 1 + test/test_helper.exs | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index cae55e3..3b2d2b5 100644 --- a/config/config.exs +++ b/config/config.exs @@ -7,4 +7,5 @@ end if Mix.env() == :dev or Mix.env() == :test do config :ical, show_test_timings: false + config :elixir, :time_zone_database, Tz.TimeZoneDatabase end diff --git a/test/test_helper.exs b/test/test_helper.exs index 01b7976..f211f85 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -74,5 +74,4 @@ defmodule ICal.Test.Helper do end end -Calendar.put_time_zone_database(Tz.TimeZoneDatabase) ExUnit.start() From ccb76fc7d0dbd2622bc203851b83436945032b24 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 09:35:58 +0200 Subject: [PATCH 78/96] add Recurrence.terminates? and a document functions --- lib/ical/recurrence.ex | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index a1d6ab3..6e9fddd 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -53,6 +53,21 @@ defmodule ICal.Recurrence do week_start_day: weekday | :default } + @doc """ + Takes a string starting with "RRULE:" and returns a recurrence struct. + """ + def from_ics(<<"RRULE", data::binary>>) do + data = ICal.Deserialize.skip_params(data) + {_data, values} = ICal.Deserialize.param_list(data) + ICal.Deserialize.Recurrence.from_params(values) + end + + @doc """ + Normalizes a recurrence, to ensure it is within the boundaries defined by RFC5545. + + Call this before using the recurrence if creating recurrences manually. Recurrences + parsed from ics data are automatically normalized. + """ def normalize(%__MODULE__{} = recurrence) do %{ recurrence @@ -70,12 +85,10 @@ defmodule ICal.Recurrence do } end - def from_ics(<<"RRULE", data::binary>>) do - data = ICal.Deserialize.skip_params(data) - {_data, values} = ICal.Deserialize.param_list(data) - ICal.Deserialize.Recurrence.from_params(values) - end - + @doc """ + Applies a generated date or datetime recurrence to an `ICal` component such as + an event, todo, or journal entry. + """ def apply(%x{} = recurrence, component) when x == Date or x == DateTime do %{ component @@ -84,13 +97,24 @@ defmodule ICal.Recurrence do } end + @doc """ + Returns true if the recurrence terminates eventually, false if it has no + defined end and instead continues indefinitely. + """ + def terminates?(%__MODULE__{count: count, until: until}) do + is_integer(count) or until != nil + end + @doc """ Given a component that supports recurrence, returns a stream of recurrences for it. The stream takes into consideration any recurrence rules (RRULE), recurrence dates (RDATE), and excluded dates (EXDATE). It starts at the start date (DTSTART) defined in the component. - Warning: this may create a very large sequence of recurrences. + As recurrences may not have an end, continuing on forever, the resulting stream can be also + be infinite. For this reason, unless absolutely sure the stream terminates do not request the + entire contents of the stream by calling `Enum.to_list()` on it, for isntance. Instead, always + consume the stream in chunks until it is exausted. ## Parameters From 28ddad17f9dd6d1c967474543a4e253aaa6ce601 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 09:44:42 +0200 Subject: [PATCH 79/96] use shift_date to preserve timezone correctness --- lib/ical/recurrence/generate.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index dda784b..887b73d 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -305,11 +305,13 @@ defmodule ICal.Recurrence.Generate do acc |> Enum.flat_map(fn recurrence -> Enum.map(month_days, fn month_day -> + first = %{recurrence | day: 1} + if month_day > 0 do - %{recurrence | day: month_day} + shift_date(first, day: month_day - 1) else end_day = days_in_month(recurrence) - %{recurrence | day: end_day + month_day + 1} + shift_date(first, day: end_day + month_day) end end) end) From 4446da56fcc56101d95ee49c6abd5aa0d863e09f Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 10:26:37 +0200 Subject: [PATCH 80/96] there may be multiple set positions --- lib/ical/recurrence/generate.ex | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 887b73d..d994246 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -537,16 +537,16 @@ defmodule ICal.Recurrence.Generate do end) end - defp apply_modifier({:by_set_position, :limit}, %{by_set_position: index}, recurrences) - when is_integer(index) and index != 0 do - index = if index > 0, do: index - 1, else: index - - case Enum.at(recurrences, index) do - nil -> [] - recurrence -> [recurrence] - end - - recurrences + defp apply_modifier({:by_set_position, :limit}, %{by_set_position: positions}, recurrences) + when has_some(positions) do + Enum.reduce(positions, [], fn index, acc -> + index = if index > 0, do: index - 1, else: index + + case Enum.at(recurrences, index) do + nil -> acc + recurrence -> acc ++ [recurrence] + end + end) end defp apply_modifier(_, _rule, acc), do: acc From 56a032ed57607559524d42cc0f2b657e692affb7 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 10:33:14 +0200 Subject: [PATCH 81/96] ensure month day expansion stays in the month --- lib/ical/recurrence/generate.ex | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index d994246..d6fdb53 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -302,16 +302,22 @@ defmodule ICal.Recurrence.Generate do defp apply_modifier({:by_month_day, :expand}, %{by_month_day: month_days}, acc) when has_some(month_days) do - acc - |> Enum.flat_map(fn recurrence -> - Enum.map(month_days, fn month_day -> + Enum.reduce(acc, [], fn recurrence, acc -> + Enum.reduce(month_days, acc, fn month_day, acc -> first = %{recurrence | day: 1} - if month_day > 0 do - shift_date(first, day: month_day - 1) + date = + if month_day > 0 do + shift_date(first, day: month_day - 1) + else + end_day = days_in_month(recurrence) + shift_date(first, day: end_day + month_day) + end + + if date.month == recurrence.month do + acc ++ [date] else - end_day = days_in_month(recurrence) - shift_date(first, day: end_day + month_day) + acc end end) end) From 2698d3cdc3a286bc3c6a755ff09c5ea10812f3ef Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 10:40:39 +0200 Subject: [PATCH 82/96] prefer reduce of flat_map, prepend new dates when applying by_* using [new_date | acc] is fine as the list always gets sorted later anyways --- lib/ical/recurrence/generate.ex | 83 +++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 35 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index d6fdb53..134a0a2 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -249,7 +249,7 @@ defmodule ICal.Recurrence.Generate do defp apply_modifier({:by_month, :expand}, %{by_month: months}, acc) when has_some(months) do Enum.reduce(acc, [], fn recurrence, acc -> Enum.reduce(months, acc, fn month, acc -> - acc ++ [%{recurrence | month: month}] + [%{recurrence | month: month} | acc] end) end) end @@ -284,11 +284,11 @@ defmodule ICal.Recurrence.Generate do defp apply_modifier({:by_year_day, :expand}, %{by_year_day: year_days}, acc) when has_some(year_days) do Enum.uniq_by(acc, fn recurrence -> recurrence.year end) - |> Enum.flat_map(fn recurrence -> + |> Enum.reduce([], fn recurrence, acc -> first_of_jan = %{recurrence | month: 1, day: 1} - Enum.map(year_days, fn day_of_year -> - shift_date(first_of_jan, day: day_of_year - 1) + Enum.reduce(year_days, acc, fn day_of_year, acc -> + [shift_date(first_of_jan, day: day_of_year - 1) | acc] end) end) end @@ -315,7 +315,7 @@ defmodule ICal.Recurrence.Generate do end if date.month == recurrence.month do - acc ++ [date] + [date | acc] else acc end @@ -356,7 +356,7 @@ defmodule ICal.Recurrence.Generate do next = shift_date(first_occurance, week: offset) if next.year == recurrence.year do - acc ++ [next] + [next | acc] else acc end @@ -380,7 +380,7 @@ defmodule ICal.Recurrence.Generate do next = shift_date(last_occurance, week: offset + 1) if next.year == recurrence.year do - acc ++ [next] + [next | acc] else acc end @@ -423,7 +423,7 @@ defmodule ICal.Recurrence.Generate do if shift_days >= days_in_month do acc else - acc ++ [shift_date(first_day, day: shift_days)] + [shift_date(first_day, day: shift_days) | acc] end {offset, weekday}, acc -> @@ -438,7 +438,7 @@ defmodule ICal.Recurrence.Generate do if shift_days >= last_day.day do acc else - acc ++ [shift_date(last_day, day: -shift_days)] + [shift_date(last_day, day: -shift_days) | acc] end end ) @@ -451,17 +451,18 @@ defmodule ICal.Recurrence.Generate do acc ) when has_some(weekdays) do - Enum.flat_map(acc, fn recurrence -> + Enum.reduce(acc, [], fn recurrence, acc -> order = weekday_order() first_week_day = beginning_of_week(recurrence, week_start_day) week_start_ordinal = order[weekday(first_week_day)] - Enum.map( + Enum.reduce( weekdays, - fn {_, weekday} -> + acc, + fn {_, weekday}, acc -> # mod by 7 in case of negative difference shift_days = Integer.mod(order[weekday] - week_start_ordinal, 7) - shift_date(first_week_day, day: shift_days) + [shift_date(first_week_day, day: shift_days) | acc] end ) end) @@ -475,14 +476,18 @@ defmodule ICal.Recurrence.Generate do end defp apply_modifier({:by_hour, :expand}, %{by_hour: hours}, acc) when has_some(hours) do - Enum.flat_map(acc, fn recurrence -> - Enum.map( + Enum.reduce(acc, [], fn recurrence, acc -> + Enum.reduce( hours, - fn hour -> - case recurrence do - %Date{} = date -> DateTime.new!(date, Time.new!(hour, 0, 0)) - %DateTime{} = date -> %{date | hour: hour} - end + acc, + fn hour, acc -> + date = + case recurrence do + %Date{} = date -> DateTime.new!(date, Time.new!(hour, 0, 0)) + %DateTime{} = date -> %{date | hour: hour} + end + + [date | acc] end ) end) @@ -498,14 +503,18 @@ defmodule ICal.Recurrence.Generate do end defp apply_modifier({:by_minute, :expand}, %{by_minute: minutes}, acc) when has_some(minutes) do - Enum.flat_map(acc, fn recurrence -> - Enum.map( + Enum.reduce(acc, [], fn recurrence, acc -> + Enum.reduce( minutes, - fn minute -> - case recurrence do - %Date{} = date -> DateTime.new!(date, Time.new!(0, minute, 0)) - %DateTime{} = date -> %{date | minute: minute} - end + acc, + fn minute, acc -> + date = + case recurrence do + %Date{} = date -> DateTime.new!(date, Time.new!(0, minute, 0)) + %DateTime{} = date -> %{date | minute: minute} + end + + [date | acc] end ) end) @@ -521,14 +530,18 @@ defmodule ICal.Recurrence.Generate do end defp apply_modifier({:by_second, :expand}, %{by_second: seconds}, acc) when has_some(seconds) do - Enum.flat_map(acc, fn recurrence -> - Enum.map( + Enum.reduce(acc, [], fn recurrence, acc -> + Enum.reduce( seconds, - fn second -> - case recurrence do - %Date{} = date -> DateTime.new!(date, Time.new!(0, 0, second)) - %DateTime{} = date -> %{date | second: second} - end + acc, + fn second, acc -> + date = + case recurrence do + %Date{} = date -> DateTime.new!(date, Time.new!(0, 0, second)) + %DateTime{} = date -> %{date | second: second} + end + + [date | acc] end ) end) @@ -550,7 +563,7 @@ defmodule ICal.Recurrence.Generate do case Enum.at(recurrences, index) do nil -> acc - recurrence -> acc ++ [recurrence] + recurrence -> [recurrence | acc] end end) end From 063e46b69ad545d18b14c44d154ccfdf9ff0e92e Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 10:41:48 +0200 Subject: [PATCH 83/96] the last of the examples from rfc5545 as tests --- test/ical/recurrence_test.exs | 377 +++++++++++++++++++++++++--------- 1 file changed, 283 insertions(+), 94 deletions(-) diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index c783e75..e47e79a 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -827,6 +827,16 @@ defmodule ICal.RecurrenceTest do assert {:error, :no_defined_limit, []} == ICal.Recurrence.Generate.all(rule, dtstart) end + test "recurrenct termination is correctly noted" do + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=2") + refute ICal.Recurrence.terminates?(rule) + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=2;COUNT=10") + assert ICal.Recurrence.terminates?(rule) + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=2;UNTIL=19971224T000000Z") + assert ICal.Recurrence.terminates?(rule) + end + test "every other day forever works with a stream" do dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=2") @@ -1185,100 +1195,279 @@ defmodule ICal.RecurrenceTest do assert recurrences == expected end - # Monthly on the third-to-the-last day of the month, forever: - # - # DTSTART;TZID=America/New_York:19970928T090000 - # RRULE:FREQ=MONTHLY;BYMONTHDAY=-3 - # - # ==> (1997 9:00 AM EDT) September 28 - # (1997 9:00 AM EST) October 29;November 28;December 29 - # (1998 9:00 AM EST) January 29;February 26 - # ... - # - # Monthly on the 2nd and 15th of the month for 10 occurrences: - # - # DTSTART;TZID=America/New_York:19970902T090000 - # RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15 - # - # ==> (1997 9:00 AM EDT) September 2,15;October 2,15 - # (1997 9:00 AM EST) November 2,15;December 2,15 - # (1998 9:00 AM EST) January 2,15 - # - # Monthly on the first and last day of the month for 10 occurrences: - # - # DTSTART;TZID=America/New_York:19970930T090000 - # RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1 - # - # ==> (1997 9:00 AM EDT) September 30;October 1 - # (1997 9:00 AM EST) October 31;November 1,30;December 1,31 - # (1998 9:00 AM EST) January 1,31;February 1 - # - # Every 18 months on the 10th thru 15th of the month for 10 - # occurrences: - # - # DTSTART;TZID=America/New_York:19970910T090000 - # RRULE:FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12, - # 13,14,15 - # - # ==> (1997 9:00 AM EDT) September 10,11,12,13,14,15 - # (1999 9:00 AM EST) March 10,11,12,13 - # - # Every Tuesday, every other month: - # - # DTSTART;TZID=America/New_York:19970902T090000 - # RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=TU - # - # ==> (1997 9:00 AM EDT) September 2,9,16,23,30 - # (1997 9:00 AM EST) November 4,11,18,25 - # (1998 9:00 AM EST) January 6,13,20,27;March 3,10,17,24,31 - # ... - # Every Friday the 13th, forever: - # - # DTSTART;TZID=America/New_York:19970902T090000 - # EXDATE;TZID=America/New_York:19970902T090000 - # RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13 - # - # ==> (1998 9:00 AM EST) February 13;March 13;November 13 - # (1999 9:00 AM EDT) August 13 - # (2000 9:00 AM EDT) October 13 - # ... - # The first Saturday that follows the first Sunday of the month, - # forever: - # - # DTSTART;TZID=America/New_York:19970913T090000 - # RRULE:FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13 - # - # ==> (1997 9:00 AM EDT) September 13;October 11 - # (1997 9:00 AM EST) November 8;December 13 - # (1998 9:00 AM EST) January 10;February 7;March 7 - # (1998 9:00 AM EDT) April 11;May 9;June 13... - # ... - # The third instance into the month of one of Tuesday, Wednesday, or - # Thursday, for the next 3 months: - # - # DTSTART;TZID=America/New_York:19970904T090000 - # RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3 - # - # ==> (1997 9:00 AM EDT) September 4;October 7 - # (1997 9:00 AM EST) November 6 - # - # The second-to-last weekday of the month: - # - # DTSTART;TZID=America/New_York:19970929T090000 - # RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2 - # - # ==> (1997 9:00 AM EDT) September 29 - # (1997 9:00 AM EST) October 30;November 27;December 30 - # (1998 9:00 AM EST) January 29;February 26;March 30 - # ... - # An example where an invalid date (i.e., February 30) is ignored. - # - # DTSTART;TZID=America/New_York:20070115T090000 - # RRULE:FREQ=MONTHLY;BYMONTHDAY=15,30;COUNT=5 - # - # ==> (2007 EST) January 15,30 - # (2007 EST) February 15 - # (2007 EDT) March 15,30 + test "on the third-to-the-last day of the month" do + count = 6 + dtstart = DateTime.new!(~D[1997-09-28], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;BYMONTHDAY=-3") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + "on the third-to-the-last day of the month" + ) + + expected = [ + DateTime.new!(~D[1997-09-28], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-28], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-02-26], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "2nd and 15th of the month for 10 occurrences" do + count = 10 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.to_list() end, + "2nd and 15th of the month for 10 occurrences" + ) + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-15], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "first and last day of the month for 10 occurrencess" do + count = 10 + dtstart = DateTime.new!(~D[1997-09-30], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.to_list() end, + "first and last day of the month for 10 occurrencess" + ) + + expected = [ + DateTime.new!(~D[1997-09-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-31], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-31], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-31], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-02-01], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every 18 months on the 10th thru 15th of the month for 10 occurrencess" do + count = 10 + dtstart = DateTime.new!(~D[1997-09-10], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics( + "RRULE:FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12,13,14,15" + ) + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.to_list() end, + "every 18 months on the 10th thru 15th of the month for 10 occurrencess" + ) + + expected = [ + DateTime.new!(~D[1997-09-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-11], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-14], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-03-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-03-11], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-03-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-03-13], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every Tuesday, every other month" do + count = 13 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=TU") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + "every Tuesday, every other month" + ) + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-09], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-16], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-23], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-04], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-11], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-18], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-25], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-06], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-20], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-27], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every Friday the 13th" do + count = 5 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13") + + recurrences = + Helper.time( + fn -> + ICal.Recurrence.stream(rule, dtstart, exclude_dates: [dtstart]) |> Enum.take(count) + end, + "every Friday the 13th" + ) + + expected = [ + DateTime.new!(~D[1998-02-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-11-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-08-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2000-10-13], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "first Saturday that follows the first Sunday of the month" do + count = 7 + dtstart = DateTime.new!(~D[1997-09-13], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + "first Saturday that follows the first Sunday of the month" + ) + + expected = [ + DateTime.new!(~D[1997-09-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-11], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-08], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-02-07], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-07], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "third instance into the month of one of Tuesday, Wednesday, or Thursday for the next 3 months" do + count = 3 + dtstart = DateTime.new!(~D[1997-09-04], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.to_list() end, + "third instance into the month of one of Tuesday, Wednesday, or Thursday for the next 3 months" + ) + + expected = [ + DateTime.new!(~D[1997-09-04], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-07], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-06], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "second-to-last weekday of the month" do + count = 7 + dtstart = DateTime.new!(~D[1997-09-29], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + "second-to-last weekday of the month" + ) + + expected = [ + DateTime.new!(~D[1997-09-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-27], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-02-26], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-30], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "an invalid date (February 30) is ignored" do + count = 5 + dtstart = DateTime.new!(~D[2007-01-15], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;BYMONTHDAY=15,30;COUNT=5") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + "second-to-last weekday of the month" + ) + + expected = [ + DateTime.new!(~D[2007-01-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2007-01-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2007-02-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2007-03-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2007-03-30], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end end describe "Recurrence generation with time components," do From f39eed0541633a612e9145ebe747c527af06b706 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 10:58:06 +0200 Subject: [PATCH 84/96] add a test for secondly freq --- test/ical/recurrence_test.exs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index e47e79a..0804bfb 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -1577,5 +1577,27 @@ defmodule ICal.RecurrenceTest do assert DateTime.new!(~D[1997-09-03], ~T[14:00:00], "America/New_York") == Enum.at(recurrences, -1) end + + test "every 25 seconds from 09:00" do + count = 4 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics( "RRULE:FREQ=SECONDLY;INTERVAL=25" ) + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + "every 25 seconds from 09:00" + ) + + assert Enum.count(recurrences) == count + + assert [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:00:25], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:00:50], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:01:15], "America/New_York") + ] == recurrences + end end end From 621933e594a52f66e0439f9ccb26840e043107aa Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 10:58:24 +0200 Subject: [PATCH 85/96] do not document Recurrence.State --- lib/ical/recurrence/state.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/ical/recurrence/state.ex b/lib/ical/recurrence/state.ex index 362f191..4ba7896 100644 --- a/lib/ical/recurrence/state.ex +++ b/lib/ical/recurrence/state.ex @@ -1,4 +1,6 @@ defmodule ICal.Recurrence.State do + @moduledoc false + defstruct [ :limit, :earliest_date, From b861337d4ee6d5cbacd3cfdf05175abb008695e3 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 10:58:42 +0200 Subject: [PATCH 86/96] simplify docs --- lib/ical/recurrence.ex | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 6e9fddd..51ba01d 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -2,10 +2,7 @@ # credo:disable-for-this-file defmodule ICal.Recurrence do @moduledoc """ - Adds support for ICal recurring. - - Events can recur by frequency, count, interval, and/or start/end date. To - see the specific rules and examples, see `add_recurring_events/2` below. + Support for recurring events, todos, and journals. """ require Logger From 8cdf2d684ad46390d7cfebce26135342d61e01ee Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 10:58:49 +0200 Subject: [PATCH 87/96] use before?/after? funs --- lib/ical/recurrence/generate.ex | 16 +++++----------- mix.exs | 10 +++++----- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 134a0a2..812ee12 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -243,7 +243,6 @@ defmodule ICal.Recurrence.Generate do end defp compare_recurrences(%DateTime{} = l, r), do: DateTime.compare(l, r) == :lt - defp compare_recurrences(%NaiveDateTime{} = l, r), do: NaiveDateTime.compare(l, r) == :lt defp compare_recurrences(%Date{} = l, r), do: Date.compare(l, r) == :lt defp apply_modifier({:by_month, :expand}, %{by_month: months}, acc) when has_some(months) do @@ -834,7 +833,7 @@ defmodule ICal.Recurrence.Generate do defp ensure_end_of_first_week(day), do: day defp is_between_inclusive(earliest, middle, latest) do - is_not_after(earliest, middle) and is_not_after(middle, latest) + not is_after(earliest, middle) and not is_after(middle, latest) end defp equal?(%Date{} = d, %DateTime{} = dt), do: equal?(d, DateTime.to_date(dt)) @@ -843,16 +842,11 @@ defmodule ICal.Recurrence.Generate do defp is_not_before(%Date{} = d, %DateTime{} = dt), do: is_not_before(d, DateTime.to_date(dt)) defp is_not_before(%DateTime{} = dt, %Date{} = d), do: is_not_before(DateTime.to_date(dt), d) - defp is_not_before(%Date{} = l, r), do: Date.compare(l, r) != :lt - defp is_not_before(%DateTime{} = l, r), do: DateTime.compare(l, r) != :lt + defp is_not_before(%Date{} = l, r), do: not Date.before?(l, r) + defp is_not_before(%DateTime{} = l, r), do: not DateTime.before?(l, r) defp is_after(%Date{} = d, %DateTime{} = dt), do: is_after(d, DateTime.to_date(dt)) defp is_after(%DateTime{} = dt, %Date{} = d), do: is_after(DateTime.to_date(dt), d) - defp is_after(%Date{} = l, r), do: Date.compare(l, r) == :gt - defp is_after(%DateTime{} = l, r), do: DateTime.compare(l, r) == :gt - - defp is_not_after(%Date{} = d, %DateTime{} = dt), do: is_not_after(d, DateTime.to_date(dt)) - defp is_not_after(%DateTime{} = dt, %Date{} = d), do: is_not_after(DateTime.to_date(dt), d) - defp is_not_after(%Date{} = l, r), do: Date.compare(l, r) != :gt - defp is_not_after(%DateTime{} = l, r), do: DateTime.compare(l, r) != :gt + defp is_after(%Date{} = l, r), do: Date.after?(l, r) + defp is_after(%DateTime{} = l, r), do: DateTime.after?(l, r) end diff --git a/mix.exs b/mix.exs index 5f7e0cc..29c1cb8 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule ICal.Mixfile do use Mix.Project @source_url "https://github.com/expothecary/ical" - @version "1.1.2" + @version "2.0.0" def project do [ @@ -76,7 +76,9 @@ defmodule ICal.Mixfile do source_ref: "v#{@version}", formatters: ["html"], groups_for_modules: [ - "Calendar Entries": [ICal.Alarm, ICal.Event, ICal.Journal, ICal.Timezone, ICal.Todo], + Components: [ICal.Alarm, ICal.Event, ICal.Journal, ICal.Timezone, ICal.Todo], + Recurrences: [ICal.Recurrence], + Alarms: [~r/ICal.Alarm.*/], Properties: [ ICal.Attachment, ICal.Attendee, @@ -84,9 +86,7 @@ defmodule ICal.Mixfile do ICal.Duration, ICal.RequestStatus, ICal.Timezone.Properties - ], - Alarms: [~r/ICal.Alarm.*/], - Utilities: [ICal.Recurrence] + ] ] ] end From 4cd9c3439d500033e11c6c9efc85ee667db10790 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 11:02:03 +0200 Subject: [PATCH 88/96] tidy --- test/ical/recurrence_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 0804bfb..0c9cfaf 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -1582,7 +1582,7 @@ defmodule ICal.RecurrenceTest do count = 4 dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") - rule = ICal.Recurrence.from_ics( "RRULE:FREQ=SECONDLY;INTERVAL=25" ) + rule = ICal.Recurrence.from_ics("RRULE:FREQ=SECONDLY;INTERVAL=25") recurrences = Helper.time( From eda8881b00cb9b311e9514c0171b02f2b2982910 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 11:08:28 +0200 Subject: [PATCH 89/96] allow nesting up to 3 levels deep; simply needed for this lib --- .credo.exs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.credo.exs b/.credo.exs index 888e7ea..1a35529 100644 --- a/.credo.exs +++ b/.credo.exs @@ -4,6 +4,9 @@ %{ name: "default", checks: %{ + extra: [ + {Credo.Check.Refactor.Nesting, [max_nesting: 3]} + ], disabled: [ {Credo.Check.Design.AliasUsage, []} ] From 93178b7f0abbb6bbe86840086985fd59cc76603d Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 11:08:40 +0200 Subject: [PATCH 90/96] clean up credo issues --- lib/ical/recurrence/generate.ex | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 812ee12..0ed085f 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -275,7 +275,7 @@ defmodule ICal.Recurrence.Generate do Enum.filter(acc, fn recurrence -> Enum.find(weeks, fn week -> {week_start, week_end} = week_number_bookends(recurrence, week) - is_between_inclusive(week_start, recurrence, week_end) + inclusively_between?(week_start, recurrence, week_end) end) != nil end) end @@ -581,7 +581,7 @@ defmodule ICal.Recurrence.Generate do index = Enum.find_index(in_set, fn recurrence -> - is_not_before(recurrence, earliest) + not before?(recurrence, earliest) end) case index do @@ -639,7 +639,7 @@ defmodule ICal.Recurrence.Generate do # other_recurrences is sorted, so only compare until a failure index = Enum.reduce_while(other_recurrences, -1, fn other_recurrence, index -> - if is_after(recurrence, other_recurrence) do + if after?(recurrence, other_recurrence) do {:cont, index + 1} else {:halt, index} @@ -675,7 +675,7 @@ defmodule ICal.Recurrence.Generate do until end - if end_date != nil and is_after(until, end_date) do + if end_date != nil and after?(until, end_date) do %{state | limit: end_date, end_date: end_date} else %{state | limit: until, end_date: end_date} @@ -750,7 +750,7 @@ defmodule ICal.Recurrence.Generate do defp update_limit_by_date(recurrences, limit_date, state) do index = - Enum.find_index(recurrences, fn recurrence -> is_after(recurrence, limit_date) end) + Enum.find_index(recurrences, fn recurrence -> after?(recurrence, limit_date) end) if index != nil do recurrences @@ -832,21 +832,21 @@ defmodule ICal.Recurrence.Generate do defp ensure_end_of_first_week(%{day: day} = date) when day < 4, do: Date.shift(date, week: 1) defp ensure_end_of_first_week(day), do: day - defp is_between_inclusive(earliest, middle, latest) do - not is_after(earliest, middle) and not is_after(middle, latest) + defp inclusively_between?(earliest, middle, latest) do + not after?(earliest, middle) and not after?(middle, latest) end defp equal?(%Date{} = d, %DateTime{} = dt), do: equal?(d, DateTime.to_date(dt)) defp equal?(%DateTime{} = dt, %Date{} = d), do: equal?(DateTime.to_date(dt), d) defp equal?(l, r), do: l == r - defp is_not_before(%Date{} = d, %DateTime{} = dt), do: is_not_before(d, DateTime.to_date(dt)) - defp is_not_before(%DateTime{} = dt, %Date{} = d), do: is_not_before(DateTime.to_date(dt), d) - defp is_not_before(%Date{} = l, r), do: not Date.before?(l, r) - defp is_not_before(%DateTime{} = l, r), do: not DateTime.before?(l, r) + defp before?(%Date{} = d, %DateTime{} = dt), do: before?(d, DateTime.to_date(dt)) + defp before?(%DateTime{} = dt, %Date{} = d), do: before?(DateTime.to_date(dt), d) + defp before?(%Date{} = l, r), do: Date.before?(l, r) + defp before?(%DateTime{} = l, r), do: DateTime.before?(l, r) - defp is_after(%Date{} = d, %DateTime{} = dt), do: is_after(d, DateTime.to_date(dt)) - defp is_after(%DateTime{} = dt, %Date{} = d), do: is_after(DateTime.to_date(dt), d) - defp is_after(%Date{} = l, r), do: Date.after?(l, r) - defp is_after(%DateTime{} = l, r), do: DateTime.after?(l, r) + defp after?(%Date{} = d, %DateTime{} = dt), do: after?(d, DateTime.to_date(dt)) + defp after?(%DateTime{} = dt, %Date{} = d), do: after?(DateTime.to_date(dt), d) + defp after?(%Date{} = l, r), do: Date.after?(l, r) + defp after?(%DateTime{} = l, r), do: DateTime.after?(l, r) end From 4cbd4edcbe181b7a78276a9e523a1bd1caca77b6 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 11:41:24 +0200 Subject: [PATCH 91/96] if an end date is provided, but not until, use end_date --- lib/ical/recurrence/generate.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index 0ed085f..fd0b773 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -675,7 +675,7 @@ defmodule ICal.Recurrence.Generate do until end - if end_date != nil and after?(until, end_date) do + if end_date != nil and (until == nil or after?(until, end_date)) do %{state | limit: end_date, end_date: end_date} else %{state | limit: until, end_date: end_date} From 146ed9280ab680557cdc584c621b96045a2c4445 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 11:46:59 +0200 Subject: [PATCH 92/96] tidy, add more change information --- CHANGELOG.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f79ea56..3c400b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,21 +9,23 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm The minimum version of Elixir required is 1.17. Support for Elixir 1.15 and 1.16 was dropped so `ICal` may use the improved date and calendaring APIs introduced in 1.17. -It is recommended to add a timezone database such as `tz` to applications that use +It is recommended to add a timezone database such as `tz` to applications that use ICal in order to benefit fully from these changes. - Improvements - - New functions in `ICal.Alarm` - - `next_activation/2`: calculates when an alarm should next activate (if ever) - - `next_alarms/1`: returns all next alarms with activation times for a compoonent with - alarms (`ICal.Event`, `ICal.Todo`) - - Recurrence generation was re-written: + - Recurrence generation was re-written and is now feature complete - The entirety of the RFC5545 RRULE specification is supported - Works with `ICal.Event`, `ICal.Todo` and `ICal.Journal` - Recurrence dates (`RDATE`) are included - Excluded dates (`EXDATE`) are respected + - New functions in `ICal.Alarm` + - `next_activation/2`: calculates when an alarm should next activate (if ever) + - `next_alarms/1`: returns all next alarms with activation times for a compoonent with + alarms (`ICal.Event`, `ICal.Todo`) - Fixes - Gap and ambiguous times are properly handled when a datetime lands in a timezone shift period + - Properly parse lists of excluded dates + - Fix serializing of components with status of draft - Janitorial - The dependency on `Timex` was removed - Documentation improvements From 1dc99f3742fcfaf3ea3313be9b1fb878937045a3 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 12:07:25 +0200 Subject: [PATCH 93/96] streamline stream options, combine stream/2 and stream/3 into stream/2 --- lib/ical/recurrence.ex | 111 ++++++++++++++------------------ lib/ical/recurrence/generate.ex | 11 ++-- test/ical/recurrence_test.exs | 45 +++++++------ 3 files changed, 80 insertions(+), 87 deletions(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 51ba01d..57fa7bf 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -27,9 +27,10 @@ defmodule ICal.Recurrence do @type recurrence_date :: Date.t() | DateTime.t() @type stream_option :: - {:end_date, recurrence_date} - | {:exclude_dates, [recurrence_date]} - | {:other_recurrences, [recurrence_date]} + {:start_date, recurrence_date} + | {:end_date, recurrence_date} + | {:include, [recurrence_date]} + | {:exclude, [recurrence_date]} @type frequency :: :secondly | :minutely | :hourly | :daily | :weekly | :monthly | :yearly @type weekday :: :monday | :tuesday | :wednesday | :thursday | :friday | :saturday | :sunday @@ -103,7 +104,7 @@ defmodule ICal.Recurrence do end @doc """ - Given a component that supports recurrence, returns a stream of recurrences for it. + Creates a stream of recurrences for a recurrence rule or a component with a recurrence rule. The stream takes into consideration any recurrence rules (RRULE), recurrence dates (RDATE), and excluded dates (EXDATE). It starts at the start date (DTSTART) defined in the component. @@ -114,37 +115,44 @@ defmodule ICal.Recurrence do consume the stream in chunks until it is exausted. ## Parameters - - - `component`: The ICal component (e.g. event or todo) that may contain an rrule. See `ICal.Event`. - - - `end_date` *(optional)*: A date time that represents the fallback end date - for recurrence. This value is only used when the options specified - in the rrule result in an infinite recurrance (ie. when neither `count` nor - `until` is set). If no end_date is set, it will default to - `DateTime.utc_now()`. + - `component`: An `ICal.Event`, `ICal.Todo`, `ICal.Journal`, or a bare `ICal.Recurrence` struct + - `options`: An optional list of `t:stream_option/0` values. Can be used to set explicit start and end + dates as well as recurrences to include in or exclude from the stream. ## Examples - iex> dt = ~D[2016-08-13] - iex> dt_end = ~D[2016-08-23] - iex> event = %ICal.Event{rrule: %ICal.Recurrence{frequency: :daily}, dtstart: dt, dtend: dt} - iex> recurrences = - ICal.Recurrence.stream(event) - |> Enum.to_list() + ``` + iex> dt = ~D[2016-08-13] + iex> dt_end = ~D[2016-08-23] + iex> recurrence = %ICal.Recurrence{frequency: :daily} + iex> event = %ICal.Event{rrule: recurrence, dtstart: dt, dtend: dt_end} + iex> ICal.Recurrence.stream(event) |> Enum.take(5) + [~D[2016-08-13], ~D[2016-08-14], ~D[2016-08-15], ~D[2016-08-16], ~D[2016-08-17]] + iex> rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY") + iex> ICal.Recurrence.stream(rule, start_date: dt, end_date: dt_end) |> Enum.take(5) + [~D[2016-08-13], ~D[2016-08-14], ~D[2016-08-15], ~D[2016-08-16], ~D[2016-08-17]] + ``` """ - @type recurrable_component :: %{ - required(:rrule) => t() | nil, - required(:dtstart) => Date.t() | DateTime.t() | nil, - optional(:exdates) => [Date.t() | DateTime.t()], - optional(:dtend) => Date.t() | DateTime.t() | nil, - optional(:rdates) => [Date.t() | DateTime.t() | ICal.period()] - } - - @spec stream(recurrable_component, nil | %Date{} | %DateTime{}) :: Enumerable.t() - def stream(component, end_date \\ nil) + @type recurrable_component :: + t() + | %{ + required(:rrule) => t() | nil, + required(:dtstart) => Date.t() | DateTime.t() | nil, + optional(:exdates) => [Date.t() | DateTime.t()], + optional(:dtend) => Date.t() | DateTime.t() | nil, + optional(:rdates) => [Date.t() | DateTime.t() | ICal.period()] + } + + @spec stream(recurrable_component, options :: [stream_option()]) :: Enumerable.t() + def stream(component, options \\ []) + + def stream(%__MODULE__{} = rule, options) do + Generate.init(rule, options) + |> create_stream() + end - def stream(%{rrule: rule, dtstart: start_date} = component, _end_date) + def stream(%{rrule: rule, dtstart: start_date} = component, _options) when is_nil(rule) or is_nil(start_date) do # this creates a stream with only the rdates of the component, if any, # when the component lacks a rule or a start date @@ -158,42 +166,23 @@ defmodule ICal.Recurrence do ) end - def stream(%{rrule: rule, dtstart: start_date} = component, end_date) do - other_recurrences = - case Map.get(component, :rdates) do - [] -> nil - nil -> nil - rdates -> rdates - end + def stream(%{rrule: rule, dtstart: start_date} = component, options) do + all_options = + options + |> Keyword.put_new_lazy(:include, fn -> default_option(component, :rdates) end) + |> Keyword.put_new_lazy(:exclude, fn -> default_option(component, :exdates) end) + |> Keyword.put_new(:start_date, start_date) - exclude_dates = - case Map.get(component, :exdates) do - [] -> nil - nil -> nil - exdates -> exdates - end - - Generate.init(rule, start_date, - end_date: end_date, - exclude_dates: exclude_dates, - other_recurrences: other_recurrences - ) + Generate.init(rule, all_options) |> create_stream() end - @doc """ - Creates a stream of recurrences based on an `%ICal.Recurrence{}`, a starting date, - and an optional ending date - """ - @spec stream( - t(), - start_date :: Date.t() | DateTime.t(), - options :: [stream_option()] - ) :: - Enumerable.t() - def stream(%__MODULE__{} = rule, start_date, options) do - Generate.init(rule, start_date, options) - |> create_stream() + defp default_option(component, key) do + case Map.get(component, key) do + [] -> nil + nil -> nil + rdates -> rdates + end end defp diff(%Date{} = l, r), do: [day: Date.diff(l, r)] diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index fd0b773..ed87f02 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -14,23 +14,24 @@ defmodule ICal.Recurrence.Generate do @spec init( ICal.Recurrence.t(), - start_date :: ICal.Recurrence.recurrence_date(), options :: [ICal.Recurrence.stream_option()] ) :: State.t() - def init(rule, start_date, options \\ []) do + def init(rule, options) do other_recurrences = options - |> resolve_option(:other_recurrences, []) + |> resolve_option(:include, []) |> Enum.sort(&compare_recurrences/2) + start_date = resolve_option(options, :start_date, DateTime.utc_now()) + %State{ earliest_date: start_date, start_date: start_date, interval: rule_interval(rule), modifiers: rule_modifiers(rule), rule: rule, - exclude_dates: resolve_option(options, :exclude_dates, []), + exclude_dates: resolve_option(options, :exclude, []), other_recurrences: other_recurrences } |> add_rule_limits(rule, Keyword.get(options, :end_date)) @@ -40,7 +41,7 @@ defmodule ICal.Recurrence.Generate do {:ok, [ICal.Recurrence.recurrence_date()]} | {:error, error_reasons, [ICal.Recurrence.recurrence_date()]} def all(rule, start_date) do - init(rule, start_date) + init(rule, start_date: start_date) |> generate_all() end diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index 0c9cfaf..a54cca3 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -4,6 +4,8 @@ defmodule ICal.RecurrenceTest do alias ICal.Test.Fixtures alias ICal.Test.Helper + doctest ICal.Recurrence + describe "RRULE: serialization" do test "Serializes correctly" do ics = @@ -613,7 +615,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, "every 20th Monday of the year" ) @@ -636,7 +638,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, "every 2nd-to-last Monday of the year" ) @@ -659,7 +661,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, "Monday of week number 20 (where the default start of the week is Monday" ) @@ -682,7 +684,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, "every Thursday in March" ) @@ -710,7 +712,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, "every Thursday, but only during June, July, and August" ) @@ -747,7 +749,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, "every 4 years, the first Tuesday after a Monday in November" ) @@ -856,7 +858,7 @@ defmodule ICal.RecurrenceTest do ] recurrences = - ICal.Recurrence.stream(rule, dtstart, []) + ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) assert Enum.count(recurrences) == count @@ -877,7 +879,7 @@ defmodule ICal.RecurrenceTest do ] recurrences = - ICal.Recurrence.stream(rule, dtstart, []) + ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) assert Enum.count(recurrences) == 5 @@ -919,7 +921,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, "every other week, forever" ) @@ -1204,7 +1206,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, "on the third-to-the-last day of the month" ) @@ -1230,7 +1232,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.to_list() end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.to_list() end, "2nd and 15th of the month for 10 occurrences" ) @@ -1260,7 +1262,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.to_list() end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.to_list() end, "first and last day of the month for 10 occurrencess" ) @@ -1292,7 +1294,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.to_list() end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.to_list() end, "every 18 months on the 10th thru 15th of the month for 10 occurrencess" ) @@ -1321,7 +1323,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, "every Tuesday, every other month" ) @@ -1354,7 +1356,8 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( fn -> - ICal.Recurrence.stream(rule, dtstart, exclude_dates: [dtstart]) |> Enum.take(count) + ICal.Recurrence.stream(rule, start_date: dtstart, exclude_recurrences: [dtstart]) + |> Enum.take(count) end, "every Friday the 13th" ) @@ -1379,7 +1382,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, "first Saturday that follows the first Sunday of the month" ) @@ -1405,7 +1408,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.to_list() end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.to_list() end, "third instance into the month of one of Tuesday, Wednesday, or Thursday for the next 3 months" ) @@ -1427,7 +1430,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, "second-to-last weekday of the month" ) @@ -1453,7 +1456,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, "second-to-last weekday of the month" ) @@ -1556,7 +1559,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, "every 20 minutes from 9:00 AM to 4:40 PM every day" ) @@ -1586,7 +1589,7 @@ defmodule ICal.RecurrenceTest do recurrences = Helper.time( - fn -> ICal.Recurrence.stream(rule, dtstart, []) |> Enum.take(count) end, + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, "every 25 seconds from 09:00" ) From 786481df158519725d7dc2119413d8a7d48b629a Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 12:14:09 +0200 Subject: [PATCH 94/96] apparently explicit time units was only added in OTP26.0 --- test/test_helper.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_helper.exs b/test/test_helper.exs index f211f85..3c74e55 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -24,7 +24,7 @@ defmodule ICal.Test.Helper do @doc "Times a function" @spec time(fun, label :: String.t()) :: term def time(function, label \\ "") do - {time, value} = :timer.tc(function, :microsecond) + {time, value} = :timer.tc(function) if Application.get_env(:ical, :show_test_timings, false) do Logger.info("TIME #{label} => #{time} microseconds / #{time / 1000} ms") From a75bf2ee1674947c8f91baf3bb49db73e8ecced4 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 13:02:44 +0200 Subject: [PATCH 95/96] improve typing, Generate.all now takes options, include error in State --- lib/ical/recurrence.ex | 20 +++++------ lib/ical/recurrence/generate.ex | 36 ++++++++++--------- lib/ical/recurrence/state.ex | 20 ++++++----- test/ical/recurrence_test.exs | 62 ++++++++++++++++----------------- 4 files changed, 71 insertions(+), 67 deletions(-) diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 57fa7bf..11d731e 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -134,17 +134,15 @@ defmodule ICal.Recurrence do ``` """ - @type recurrable_component :: - t() - | %{ - required(:rrule) => t() | nil, - required(:dtstart) => Date.t() | DateTime.t() | nil, - optional(:exdates) => [Date.t() | DateTime.t()], - optional(:dtend) => Date.t() | DateTime.t() | nil, - optional(:rdates) => [Date.t() | DateTime.t() | ICal.period()] - } - - @spec stream(recurrable_component, options :: [stream_option()]) :: Enumerable.t() + @type recurrable_component :: %{ + required(:rrule) => t() | nil, + required(:dtstart) => Date.t() | DateTime.t() | nil, + optional(:exdates) => [Date.t() | DateTime.t()], + optional(:dtend) => Date.t() | DateTime.t() | nil, + optional(:rdates) => [Date.t() | DateTime.t() | ICal.period()] + } + + @spec stream(t() | recurrable_component, options :: [stream_option()]) :: Enumerable.t() def stream(component, options \\ []) def stream(%__MODULE__{} = rule, options) do diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex index ed87f02..971b77b 100644 --- a/lib/ical/recurrence/generate.ex +++ b/lib/ical/recurrence/generate.ex @@ -10,13 +10,10 @@ defmodule ICal.Recurrence.Generate do defguard has_some(x) when is_list(x) and x != [] defguard has_none(x) when not has_some(x) - @type error_reasons :: :search_exhaustion | :no_defined_limit - @spec init( ICal.Recurrence.t(), options :: [ICal.Recurrence.stream_option()] - ) :: - State.t() + ) :: State.t() def init(rule, options) do other_recurrences = options @@ -32,16 +29,18 @@ defmodule ICal.Recurrence.Generate do modifiers: rule_modifiers(rule), rule: rule, exclude_dates: resolve_option(options, :exclude, []), - other_recurrences: other_recurrences + other_recurrences: other_recurrences, + error: :none } |> add_rule_limits(rule, Keyword.get(options, :end_date)) end - @spec all(ICal.Recurrence.t(), starting_from :: ICal.Recurrence.recurrence_date()) :: + @spec all(ICal.Recurrence.t(), options :: [ICal.Recurrence.stream_option()]) :: {:ok, [ICal.Recurrence.recurrence_date()]} - | {:error, error_reasons, [ICal.Recurrence.recurrence_date()]} - def all(rule, start_date) do - init(rule, start_date: start_date) + | {:error, State.error_reason(), [ICal.Recurrence.recurrence_date()]} + def all(rule, options) do + rule + |> init(options) |> generate_all() end @@ -188,32 +187,35 @@ defmodule ICal.Recurrence.Generate do ] end + @spec generate_all(State.t()) :: + {:ok, [ICal.Recurrence.recurrence_date()]} + | {:error, State.error_reason(), [ICal.Recurrence.recurrence_date()]} defp generate_all(state) do generate_all(state, []) end - defp generate_all(%{limit: nil}, acc) do + defp generate_all(%State{limit: nil}, acc) do {:error, :no_defined_limit, acc} end - defp generate_all(%{limit: limit}, acc) when is_integer(limit) and limit < 1 do + defp generate_all(%State{limit: limit}, acc) when is_integer(limit) and limit < 1 do {:ok, acc} end - defp generate_all(state, acc) do + defp generate_all(%State{} = state, acc) do {recurrences, new_state} = generate_set(state) - if new_state.limit == :reached do - {:ok, acc ++ recurrences} - else - generate_all(new_state, acc ++ recurrences) + case new_state do + %{limit: :reached, error: :none} -> {:ok, acc ++ recurrences} + %{limit: :reached, error: error} -> {:error, error, acc ++ recurrences} + new_state -> generate_all(new_state, acc ++ recurrences) end end defp generate_set(%{fruitless_searches: fruitless_searches, rule: rule} = state) when fruitless_searches > @max_fruitless_search_depth do Logger.warning("Could not find all recurrences of #{inspect(rule)} due to search exhaustion") - {[], %{state | limit: :reached}} + {[], %{state | limit: :reached, error: :search_exhaustion}} end defp generate_set(%State{} = state) do diff --git a/lib/ical/recurrence/state.ex b/lib/ical/recurrence/state.ex index 4ba7896..9e35844 100644 --- a/lib/ical/recurrence/state.ex +++ b/lib/ical/recurrence/state.ex @@ -11,9 +11,12 @@ defmodule ICal.Recurrence.State do :rule, exclude_dates: nil, other_recurrences: nil, - fruitless_searches: 0 + fruitless_searches: 0, + error: :none ] + @type recurrence_date :: Date.t() | DateTime.t() + @type error_reason :: :none | :search_exhaustion | :no_defined_limit @type modifier_scope :: :by_month | :by_week_number @@ -27,14 +30,15 @@ defmodule ICal.Recurrence.State do @type modifier_mode :: :limit | :expand | :expand_week | :expand_month | :expand_year @type t :: %__MODULE__{ - limit: :reached | non_neg_integer() | ICal.Recurrence.recurrence_date(), - earliest_date: ICal.Recurrence.recurrence_date(), - start_date: ICal.Recurrence.recurrence_date(), - end_date: ICal.Recurrence.recurrence_date() | nil, - interval: Duration.duration(), + earliest_date: recurrence_date(), + end_date: recurrence_date() | nil, + error: error_reason(), + exclude_dates: [recurrence_date()], + fruitless_searches: non_neg_integer(), + interval: {:date | :time, Duration.duration()}, + limit: :reached | non_neg_integer() | recurrence_date(), modifiers: [{modifier_scope, modifier_mode}], rule: ICal.Recurrence.t(), - exclude_dates: [ICal.Recurrence.recurrence_date()], - fruitless_searches: non_neg_integer() + start_date: recurrence_date() } end diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index a54cca3..b9eae40 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -338,7 +338,7 @@ defmodule ICal.RecurrenceTest do rule = %ICal.Recurrence{frequency: :yearly, count: count} dtstart = ~U[2026-04-15 13:00:00Z] - {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) assert Enum.count(recurrences) == count end @@ -348,7 +348,7 @@ defmodule ICal.RecurrenceTest do rule = %ICal.Recurrence{frequency: :yearly, count: count, by_month: [1, 4, 6]} dtstart = ~U[2026-04-15 13:00:00Z] - {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) assert Enum.count(recurrences) == count [recurrence | _] = recurrences @@ -365,7 +365,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "every day in january for 3 years with yearly freq" ) @@ -390,7 +390,7 @@ defmodule ICal.RecurrenceTest do dtstart = ~U[2026-04-15 13:00:00Z] - {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) assert Enum.count(recurrences) == count end @@ -407,7 +407,7 @@ defmodule ICal.RecurrenceTest do dtstart = ~U[2026-04-15 13:00:00Z] - {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) assert Enum.count(recurrences) == count [recurrence | _] = recurrences @@ -419,7 +419,7 @@ defmodule ICal.RecurrenceTest do rule = %ICal.Recurrence{frequency: :yearly, count: count, by_week_number: [3, 17]} dtstart = ~U[2026-04-15 13:00:00Z] - {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) assert Enum.count(recurrences) == count @@ -461,7 +461,7 @@ defmodule ICal.RecurrenceTest do dtstart = ~U[2026-04-15 13:00:00Z] - {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) assert Enum.count(recurrences) == count @@ -479,7 +479,7 @@ defmodule ICal.RecurrenceTest do rule = %ICal.Recurrence{frequency: :yearly, count: count, by_year_day: [15, 50]} dtstart = ~U[2026-04-15 13:00:00Z] - {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) assert Enum.count(recurrences) == count @@ -504,7 +504,7 @@ defmodule ICal.RecurrenceTest do dtstart = ~U[2026-04-15 13:00:00Z] - {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, dtstart) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) assert Enum.count(recurrences) == count @@ -525,7 +525,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "in June and July for 10 occurrences" ) @@ -555,7 +555,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "every other year on January, February, and March for 10 occurences" ) @@ -585,7 +585,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "every third year on the 1st, 100th, and 200th day for 10 occurences" ) @@ -771,7 +771,7 @@ defmodule ICal.RecurrenceTest do rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1") Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "every day in january for 3 years" ) end @@ -785,7 +785,7 @@ defmodule ICal.RecurrenceTest do ) Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "every 10th and 31st in january for 3 years" ) end @@ -797,7 +797,7 @@ defmodule ICal.RecurrenceTest do ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20280131T140000Z;BYMONTH=1;BYDAY=TH,TU") Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "every 10th and 31st in january for 3 years" ) end @@ -808,7 +808,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "daily until December 24, 1997" ) @@ -826,7 +826,7 @@ defmodule ICal.RecurrenceTest do dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=2") - assert {:error, :no_defined_limit, []} == ICal.Recurrence.Generate.all(rule, dtstart) + assert {:error, :no_defined_limit, []} == ICal.Recurrence.Generate.all(rule, start_date: dtstart) end test "recurrenct termination is correctly noted" do @@ -893,7 +893,7 @@ defmodule ICal.RecurrenceTest do rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;COUNT=10") {:ok, recurrences} = - Helper.time(fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "weekly for 10 weeks") + Helper.time(fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "weekly for 10 weeks") assert Enum.count(recurrences) == 10 # ==> (1997 9:00 AM EDT) September 2,9,16,23,30;October 7,14,21 @@ -905,7 +905,7 @@ defmodule ICal.RecurrenceTest do rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;UNTIL=19971224T000000Z") {:ok, recurrences} = - Helper.time(fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, "weekly for 10 weeks") + Helper.time(fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "weekly for 10 weeks") assert Enum.count(recurrences) == 17 assert Enum.at(recurrences, 0) == dtstart @@ -941,7 +941,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "every other week, forever" ) @@ -973,7 +973,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "every other week, Mo/We/Fr until Dec 24" ) @@ -1018,7 +1018,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "every other week, Mo/We/Fr until Dec 24" ) @@ -1046,7 +1046,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "WKST variance -> days generated with MO WKST" ) @@ -1070,7 +1070,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "example where the days generated makes a difference because of WKST" ) @@ -1096,7 +1096,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "monthly on the first Friday for 10 occurrences" ) @@ -1126,7 +1126,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "monthly on the first Friday until December 24, 1997" ) @@ -1150,7 +1150,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "every other month on the first and last Sunday of the month for 10 occurences" ) @@ -1180,7 +1180,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "monthly on the second-to-last Monday of the month for 6 months" ) @@ -1483,7 +1483,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "every 3 hours from 9:00 AM to 5:00 PM on a specific day" ) @@ -1506,7 +1506,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "every 15 minutes for 6 occurrences" ) @@ -1532,7 +1532,7 @@ defmodule ICal.RecurrenceTest do {:ok, recurrences} = Helper.time( - fn -> ICal.Recurrence.Generate.all(rule, dtstart) end, + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "every hour and a half for 4 occurrences" ) From bc21039bc4a312cbbcac1d39e4a3bc4bc0e35d44 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 22 Apr 2026 13:12:46 +0200 Subject: [PATCH 96/96] formatting :/ --- test/ical/recurrence_test.exs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index b9eae40..1efcb8a 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -826,7 +826,8 @@ defmodule ICal.RecurrenceTest do dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=2") - assert {:error, :no_defined_limit, []} == ICal.Recurrence.Generate.all(rule, start_date: dtstart) + assert {:error, :no_defined_limit, []} == + ICal.Recurrence.Generate.all(rule, start_date: dtstart) end test "recurrenct termination is correctly noted" do @@ -893,7 +894,10 @@ defmodule ICal.RecurrenceTest do rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;COUNT=10") {:ok, recurrences} = - Helper.time(fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "weekly for 10 weeks") + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "weekly for 10 weeks" + ) assert Enum.count(recurrences) == 10 # ==> (1997 9:00 AM EDT) September 2,9,16,23,30;October 7,14,21 @@ -905,7 +909,10 @@ defmodule ICal.RecurrenceTest do rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;UNTIL=19971224T000000Z") {:ok, recurrences} = - Helper.time(fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, "weekly for 10 weeks") + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "weekly for 10 weeks" + ) assert Enum.count(recurrences) == 17 assert Enum.at(recurrences, 0) == dtstart