From f54a5ffb3dadd4908ddbec766abbffeb2f93319e Mon Sep 17 00:00:00 2001 From: NRG-Drink Date: Sun, 27 Jul 2025 19:18:52 +0200 Subject: [PATCH 1/9] test: add wiki recurrence example tests #846 --- .../WikiSamples/RecurrenceWikiTests.cs | 492 ++++++++++++++++++ 1 file changed, 492 insertions(+) create mode 100644 Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs diff --git a/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs new file mode 100644 index 00000000..642193b8 --- /dev/null +++ b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs @@ -0,0 +1,492 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Ical.Net.CalendarComponents; +using Ical.Net.DataTypes; +using Ical.Net.Serialization; +using NUnit.Framework; + +namespace Ical.Net.Tests.WikiSamples; + +public static class RecurrenceWikiTestsUtilsExtensions +{ + public static Calendar ToCalendar(this CalendarEvent eve, Action? func = null) + { + var calendar = new Calendar(); + calendar.Events.Add(eve); + if (func is not null) + func(calendar); + + return calendar; + } + + public static Calendar ToCalendar(this IEnumerable eves, Action? func = null) + { + var calendar = new Calendar(); + calendar.Events.AddRange(eves); + if (func is not null) + func(calendar); + + return calendar; + } + + public static CalendarEvent With(this CalendarEvent eve, Action func) + { + func(eve); + return eve; + } + + public static CalendarEvent WithRecurrenceRule(this CalendarEvent eve, params RecurrencePattern[] rules) + { + eve.RecurrenceRules = rules; + return eve; + } + + public static RecurrencePattern With(this RecurrencePattern pattern, Action func) + { + func(pattern); + return pattern; + } +} + +[TestFixture, Category("Recurrence")] +public class RecurrenceWikiTests +{ + [Test] + public void Introduction() + { + var recurrence = new RecurrencePattern() + { + Frequency = FrequencyType.Daily, + Interval = 2, + Count = 30 + // Add other parameters like ByDay, ByMonth, etc. + }; + + var calendarEvent = new CalendarEvent() + { + DtStart = new CalDateTime(2025, 07, 10), + DtEnd = new CalDateTime(2025, 07, 11), + // Add the rule to the event. + RecurrenceRules = [recurrence] + }; + + // Get all occurrences of the series. + IEnumerable allOccurrences = calendarEvent.GetOccurrences(); + Assert.That(allOccurrences.Count(), Is.EqualTo(30)); + + // Get the occurrences in July. + IEnumerable julyOccurrences = calendarEvent + .GetOccurrences(new CalDateTime(2025, 07, 01)) + .TakeWhileBefore(new CalDateTime(2025, 08, 01)); + Assert.That(julyOccurrences.Count(), Is.EqualTo(11)); + } + + [Test] + public void DailyIntervalCount() + { + // Pattern: Daily every second day, tow times. + + // Create the CalendarEvent + var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); + var recurrence = new RecurrencePattern() + { + Frequency = FrequencyType.Daily, + Interval = 2, + Count = 2 + }; + + var calendarEvent = new CalendarEvent() + { + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [recurrence] + }; + + // Add CalendarEvent to Calendar + var calendar = new Calendar(); + calendar.Events.Add(calendarEvent); + + // Serialize Calendar to string + var calendarSerializer = new CalendarSerializer(); + var calendarAsIcs = calendarSerializer.SerializeToString(calendar); + Assert.That(calendarAsIcs, Is.Not.Null); + + // Calculate all occurrences + IEnumerable occurrences = calendar.GetOccurrences(); + Assert.That(occurrences.Count(), Is.EqualTo(2)); + + // Calendar output + var expectedIcsCalendar = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTEND;TZID=Europe/Zurich:20250710T100000 + DTSTART;TZID=Europe/Zurich:20250710T090000 + RRULE:FREQ=DAILY;INTERVAL=2;COUNT=2 + END:VEVENT + END:VCALENDAR + """; + // Occurring dates + var expectedOccurrenceDates = """ + DTEND;TZID=Europe/Zurich:20250710T100000 + DTSTART;TZID=Europe/Zurich:20250710T090000 + + DTEND;TZID=Europe/Zurich:20250712T100000 + DTSTART;TZID=Europe/Zurich:20250712T090000 + """; + + // Asserts + Assert.That(calendarAsIcs, Is.Not.Null); + var calendarTestString = ToTestableCalendarString(calendarAsIcs); + Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + + var occurrenceTestString = ToTestablePeriodString(occurrences); + Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + } + + [Test] + public void YearlyByMonthDayUntil() + { + // Pattern: Yearly at 10 and 12th of July, until +2 years. + // Note: TimeZone of the until-date. + // Note: Inclusive manner of the until-date. + + // Create the CalendarEvent + var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); + var recurrence = new RecurrencePattern() + { + Frequency = FrequencyType.Yearly, + ByMonthDay = [10, 12], + // 2027-07-10 09:00:00 Europe/Zurich (07:00:00 UTC) + Until = start.AddYears(2).ToTimeZone("UTC") + }; + + var calendarEvent = new CalendarEvent() + { + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [recurrence] + }; + + // Add CalendarEvent to Calendar + var calendar = new Calendar(); + calendar.Events.Add(calendarEvent); + + // Serialize Calendar to string + var calendarSerializer = new CalendarSerializer(); + var calendarAsIcs = calendarSerializer.SerializeToString(calendar); + + // Calculate all occurrences + IEnumerable occurrences = calendar.GetOccurrences(); + + // Calendar output + var expectedIcsCalendar = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTEND;TZID=Europe/Zurich:20250710T100000 + DTSTART;TZID=Europe/Zurich:20250710T090000 + RRULE:FREQ=YEARLY;UNTIL=20270710T070000Z;BYMONTHDAY=10,12 + END:VEVENT + END:VCALENDAR + """; + // Occurring dates + var expectedOccurrenceDates = """ + DTEND;TZID=Europe/Zurich:20250710T100000 + DTSTART;TZID=Europe/Zurich:20250710T090000 + + DTEND;TZID=Europe/Zurich:20250712T100000 + DTSTART;TZID=Europe/Zurich:20250712T090000 + + DTEND;TZID=Europe/Zurich:20260710T100000 + DTSTART;TZID=Europe/Zurich:20260710T090000 + + DTEND;TZID=Europe/Zurich:20260712T100000 + DTSTART;TZID=Europe/Zurich:20260712T090000 + + DTEND;TZID=Europe/Zurich:20270710T100000 + DTSTART;TZID=Europe/Zurich:20270710T090000 + """; + + // Asserts + Assert.That(calendarAsIcs, Is.Not.Null); + var calendarTestString = ToTestableCalendarString(calendarAsIcs); + Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + + var occurrenceTestString = ToTestablePeriodString(occurrences); + Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + } + + [Test] + public void MonthlyByDayCountRDate() + { + // Pattern: Monthly every last Sunday, for 3 times - plus July 10th. + // Note: RDATE takes a List of PeriodList. + + // Create the CalendarEvent + var start = new CalDateTime(2025, 06, 29, 16, 00, 00, "Europe/Zurich"); + var recurrence = new RecurrencePattern() + { + Frequency = FrequencyType.Monthly, + ByDay = [new(DayOfWeek.Sunday, FrequencyOccurrence.Last)], + Count = 3, + }; + + // Create additional occurrence. + PeriodList periodList = new PeriodList(); + periodList.Add(new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich")); + + var calendarEvent = new CalendarEvent() + { + DtStart = start, + DtEnd = start.AddHours(4), + RecurrenceRules = [recurrence], + // Add the additional occurrence to the series. + RecurrenceDatesPeriodLists = [periodList] + }; + + // Add CalendarEvent to Calendar + var calendar = new Calendar(); + calendar.Events.Add(calendarEvent); + + // Serialize Calendar to string + var calendarSerializer = new CalendarSerializer(); + var calendarAsIcs = calendarSerializer.SerializeToString(calendar); + + // Calculate all occurrences + IEnumerable occurrences = calendar.GetOccurrences(); + + // Calendar output + var expectedIcsCalendar = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTEND;TZID=Europe/Zurich:20250629T200000 + DTSTART;TZID=Europe/Zurich:20250629T160000 + RDATE;TZID=Europe/Zurich:20250710T090000 + RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=-1SU + END:VEVENT + END:VCALENDAR + """; + // Occurring dates + var expectedOccurrenceDates = """ + DTEND;TZID=Europe/Zurich:20250629T200000 + DTSTART;TZID=Europe/Zurich:20250629T160000 + + DTEND;TZID=Europe/Zurich:20250710T130000 + DTSTART;TZID=Europe/Zurich:20250710T090000 + + DTEND;TZID=Europe/Zurich:20250727T200000 + DTSTART;TZID=Europe/Zurich:20250727T160000 + + DTEND;TZID=Europe/Zurich:20250831T200000 + DTSTART;TZID=Europe/Zurich:20250831T160000 + """; + + // Asserts + Assert.That(calendarAsIcs, Is.Not.Null); + var calendarTestString = ToTestableCalendarString(calendarAsIcs); + Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + + var occurrenceTestString = ToTestablePeriodString(occurrences); + Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + } + + [Test] + public void HourlyUntilExDate() + { + // Pattern: Hourly every hour, until midnight (inclusive) - except 22:00. + // Note: EXDATE takes a List of PeriodList. + + // Create the CalendarEvent + var start = new CalDateTime(2025, 07, 10, 20, 00, 00, "UTC"); + var recurrence = new RecurrencePattern() + { + Frequency = FrequencyType.Hourly, + Until = start.AddHours(4) + }; + + // Create exception for an occurrence. + PeriodList periodList = new PeriodList(); + periodList.Add(new CalDateTime(2025, 07, 10, 22, 00, 00, "UTC")); + + var calendarEvent = new CalendarEvent() + { + DtStart = start, + DtEnd = start.AddMinutes(15), + RecurrenceRules = [recurrence], + // Add the exception date(s) to the series. + ExceptionDatesPeriodLists = [periodList] + }; + + // Add CalendarEvent to Calendar + var calendar = new Calendar(); + calendar.Events.Add(calendarEvent); + + // Serialize Calendar to string + var calendarSerializer = new CalendarSerializer(); + var calendarAsIcs = calendarSerializer.SerializeToString(calendar); + + // Calculate all occurrences + IEnumerable occurrences = calendar.GetOccurrences(); + + // Calendar output + var expectedIcsCalendar = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTEND:20250710T201500Z + DTSTART:20250710T200000Z + EXDATE:20250710T220000Z + RRULE:FREQ=HOURLY;UNTIL=20250711T000000Z + END:VEVENT + END:VCALENDAR + """; + // Occurring dates + var expectedOccurrenceDates = """ + DTEND:20250710T201500Z + DTSTART:20250710T200000Z + + DTEND:20250710T211500Z + DTSTART:20250710T210000Z + + DTEND:20250710T231500Z + DTSTART:20250710T230000Z + + DTEND:20250711T001500Z + DTSTART:20250711T000000Z + """; + + // Asserts + Assert.That(calendarAsIcs, Is.Not.Null); + var calendarTestString = ToTestableCalendarString(calendarAsIcs); + Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + + var occurrenceTestString = ToTestablePeriodString(occurrences); + Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + } + + [Test] + public void DailyIntervalCountMoved() + { + // Pattern: Daily every second day, four times - third is moved. + // Note: Link moved events with series-master by same UID. + + // Create the CalendarEvent + var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); + var recurrence = new RecurrencePattern() + { + Frequency = FrequencyType.Daily, + Interval = 2, + Count = 4 + }; + + var calendarEvent = new CalendarEvent() + { + // UID links master with child. + Uid = "my-custom-id", + Summary = "Walking", + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [recurrence], + }; + + var startMoved = new CalDateTime(2025, 07, 13, 13, 00, 00, "Europe/Zurich"); + var movedEvent = new CalendarEvent() + { + // UID links master with child. + Uid = "my-custom-id", + // Overwrite properties of the original occurrence. + Summary = "Short after lunch walk", + // Set new start and end time. + DtStart = startMoved, + DtEnd = startMoved.AddMinutes(13), + // Set the original date of the occurrence (2025-07-14 09:00:00). + RecurrenceId = start.AddDays(4) + }; + + // Add CalendarEvent to Calendar + var calendar = new Calendar(); + calendar.Events.Add(calendarEvent); + calendar.Events.Add(movedEvent); + + // Serialize Calendar to string + var calendarSerializer = new CalendarSerializer(); + var calendarAsIcs = calendarSerializer.SerializeToString(calendar); + + // Calculate all occurrences + IEnumerable occurrences = calendar.GetOccurrences(); + + // Calendar output + var expectedIcsCalendar = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTEND;TZID=Europe/Zurich:20250710T100000 + DTSTART;TZID=Europe/Zurich:20250710T090000 + RRULE:FREQ=DAILY;INTERVAL=2;COUNT=4 + SUMMARY:Walking + UID:my-custom-id + END:VEVENT + BEGIN:VEVENT + DTEND;TZID=Europe/Zurich:20250713T131300 + DTSTART;TZID=Europe/Zurich:20250713T130000 + RECURRENCE-ID;TZID=Europe/Zurich:20250714T090000 + SUMMARY:Short after lunch walk + UID:my-custom-id + END:VEVENT + END:VCALENDAR + """; + // Occurring dates + var expectedOccurrenceDates = """ + DTEND;TZID=Europe/Zurich:20250710T100000 + DTSTART;TZID=Europe/Zurich:20250710T090000 + + DTEND;TZID=Europe/Zurich:20250712T100000 + DTSTART;TZID=Europe/Zurich:20250712T090000 + + DTEND;TZID=Europe/Zurich:20250713T131300 + DTSTART;TZID=Europe/Zurich:20250713T130000 + + DTEND;TZID=Europe/Zurich:20250716T100000 + DTSTART;TZID=Europe/Zurich:20250716T090000 + """; + + // Asserts + Assert.That(calendarAsIcs, Is.Not.Null); + var calendarTestString = ToTestableCalendarString(calendarAsIcs, allowUid: true); + Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + + var occurrenceTestString = ToTestablePeriodString(occurrences); + Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + } + + private static string ToTestableCalendarString(string calendarAsIcs, bool allowUid = false) + => calendarAsIcs + .Split('\n') + .Select(e => e.Replace("\r", "")) + .Where(e => !e.StartsWith("PRODID")) + .Where(e => !e.StartsWith("DTSTAMP")) + .Where(e => allowUid || !e.StartsWith("UID")) + .Where(e => !e.StartsWith("SEQUENCE")) + .Where(e => !e.StartsWith("VERSION")) + .Aggregate(new StringBuilder(), (acc, e) => acc.AppendLine(e), e => e.ToString().TrimEnd()); + + private static string ToTestablePeriodString(IEnumerable occurrences) + => occurrences + .Select(e => GetPeriodString(e.Period)) + .OfType() + .Aggregate(new StringBuilder(), (acc, e) => acc.AppendLine(e), e => e.ToString().TrimEnd()); + + private static string GetPeriodString(Period p) + { + var start = new CalendarProperty("DTSTART", p.StartTime); + var end = new CalendarProperty("DTEND", p.EffectiveEndTime ?? p.StartTime.Add(p.Duration!.Value)); + var serializer = new PropertySerializer(); + + return serializer.SerializeToString(end) + serializer.SerializeToString(start); + } +} From a8d14519d4ac364a2b02cf5ab6b6674d9ddf58bd Mon Sep 17 00:00:00 2001 From: NRG-Drink Date: Sun, 27 Jul 2025 19:19:16 +0200 Subject: [PATCH 2/9] docs: add wiki recurrence markdown #846 --- Ical.Net.Tests/WikiSamples/RecurrenceWiki.md | 414 +++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 Ical.Net.Tests/WikiSamples/RecurrenceWiki.md diff --git a/Ical.Net.Tests/WikiSamples/RecurrenceWiki.md b/Ical.Net.Tests/WikiSamples/RecurrenceWiki.md new file mode 100644 index 00000000..fda55260 --- /dev/null +++ b/Ical.Net.Tests/WikiSamples/RecurrenceWiki.md @@ -0,0 +1,414 @@ + +It's impossible to provide an example of every type of recurrence scenario, so here are a few that represent some common use cases. If you'd like to see a specific example laid out, please [create an issue](https://github.com/rianjs/ical.net/issues). + +## How to think about occurrences +If you create an event or an alarm that happens more than once, it typically has a start time, an end time, and some rules about how and when it repeats: +- "Daily, forever" +- "Every other Tuesday until the end of the year" +- "The fourth Thursday of every November" +You then want to *search* for occurrences of that event during a given time period. This kicks off the machinery which generates the set of occurrences of that event that match your search criteria. + +### How to create a recurring event +It's like creating a normal event but we add one or more `RecurrencePattern` to the event to make it recurring. + +> [!NOTE] +> Make sure that the event's start date is the first occurrence of the series. + +```cs +var recurrence = new RecurrencePattern() +{ + Frequency = FrequencyType.Daily, + Interval = 2, + Count = 30 + // Add other parameters like ByDay, ByMonth, etc. +}; + +var calendarEvent = new CalendarEvent() +{ + DtStart = new CalDateTime(2025, 07, 10), + DtEnd = new CalDateTime(2025, 07, 11), + // Add the rule to the event. + RecurrenceRules = [recurrence] +}; +``` +### How to get occurrences of a recurring event +After creating a recurring event we can call `GetOccurrences()` to get all occurrences of a series or `GetOccurrences(CalDateTime startTime)` to get all occurrences after a given date. We can limit the evaluation by adding a `TakeWhileBefore(CalDateTime periodEnd)` to get occurrences till a specific point in time. + +> [!NOTE] +> The provided end-date in the `TakeWhileBefore` method calculate against the event start is meant in an exclusive manner. In our example, the occurrence on 2025-08-01 is not provided despite the event-start (2025-08-01) and the calculation-end (2025-08-01) is the same date (exclusive). To include it we have to set the calculation-end to 2025-08-01T00:00:01. + +> [!WARNING] +> Calculating a series with no end may lead in a `EvaluationOutOfRangeException`. Make sure you use `TakeWhileBefore` to avoid this. + +```cs +// Get all occurrences of the series. +IEnumerable allOccurrences = calendarEvent.GetOccurrences(); +Assert.That(allOccurrences.Count(), Is.EqualTo(30)); + +// Get the occurrences in July. +IEnumerable julyOccurrences = calendarEvent + .GetOccurrences(new CalDateTime(2025, 07, 01)) + .TakeWhileBefore(new CalDateTime(2025, 08, 01)); +Assert.That(julyOccurrences.Count(), Is.EqualTo(11)); +``` + +> [!TIP] +> We can call the `GetOccurrences` method on `CalendarEvent` and on `Calendar`. + + +## Recurrence Examples +### Daily, Interval, Count +Suppose we want to create an event for July 10, between 09:00 and 10:00 that recurs every other day for 2 occurrences. + +```cs +// Create the CalendarEvent +var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); +var recurrence = new RecurrencePattern() +{ + Frequency = FrequencyType.Daily, + Interval = 2, + Count = 2 +}; + +var calendarEvent = new CalendarEvent() +{ + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [recurrence] +}; + +// Add CalendarEvent to Calendar +var calendar = new Calendar(); +calendar.Events.Add(calendarEvent); + +// Serialize Calendar to string +var calendarSerializer = new CalendarSerializer(); +var calendarAsIcs = calendarSerializer.SerializeToString(calendar); +``` +This results in the following Ics string. +```ics +BEGIN:VCALENDAR +BEGIN:VEVENT +DTEND;TZID=Europe/Zurich:20250710T100000 +DTSTART;TZID=Europe/Zurich:20250710T090000 +RRULE:FREQ=DAILY;INTERVAL=2;COUNT=2 +END:VEVENT +END:VCALENDAR +``` +Now let's get all occurrences of this series. +```cs +var occurrences = calendar.GetOccurrences(); +``` +This will give us two occurrences with the following start and end dates. +```ics +DTEND;TZID=Europe/Zurich:20250710T100000 +DTSTART;TZID=Europe/Zurich:20250710T090000 + +DTEND;TZID=Europe/Zurich:20250712T100000 +DTSTART;TZID=Europe/Zurich:20250712T090000 +``` + +### Yearly, ByMonthDay, Until +Suppose we want to create an series of events for July 10 and 12, between 09:00 and 10:00 that recurs every year until in two years. + +> [!WARNING] +> The `UNTIL` date must have UTC or none time zone. + +> [!NOTE] +> The `UNTIL` date is meant in an inclusive manner. + +```cs +// Create the CalendarEvent +var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); +var recurrence = new RecurrencePattern() +{ + Frequency = FrequencyType.Yearly, + ByMonthDay = [10, 12], + // 2027-07-10 09:00:00 Europe/Zurich (07:00:00 UTC) + Until = start.AddYears(2).ToTimeZone("UTC") +}; + +var calendarEvent = new CalendarEvent() +{ + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [recurrence] +}; + +// Add CalendarEvent to Calendar +var calendar = new Calendar(); +calendar.Events.Add(calendarEvent); + +// Serialize Calendar to string +var calendarSerializer = new CalendarSerializer(); +var calendarAsIcs = calendarSerializer.SerializeToString(calendar); +``` +This results in the following Ics string. +```ics +BEGIN:VCALENDAR +BEGIN:VEVENT +DTEND;TZID=Europe/Zurich:20250710T100000 +DTSTART;TZID=Europe/Zurich:20250710T090000 +RRULE:FREQ=YEARLY;UNTIL=20270710T070000Z;BYMONTHDAY=10,12 +END:VEVENT +END:VCALENDAR +``` +Now let's get all occurrences of this series. +```cs +var occurrences = calendar.GetOccurrences(); +``` +This will give us two occurrences with the following start and end dates. +```ics +DTEND;TZID=Europe/Zurich:20250710T100000 +DTSTART;TZID=Europe/Zurich:20250710T090000 + +DTEND;TZID=Europe/Zurich:20250712T100000 +DTSTART;TZID=Europe/Zurich:20250712T090000 + +DTEND;TZID=Europe/Zurich:20260710T100000 +DTSTART;TZID=Europe/Zurich:20260710T090000 + +DTEND;TZID=Europe/Zurich:20260712T100000 +DTSTART;TZID=Europe/Zurich:20260712T090000 + +DTEND;TZID=Europe/Zurich:20270710T100000 +DTSTART;TZID=Europe/Zurich:20270710T090000 +``` +> [!TIP] +> Have a look at the last occurrence, it has the same start as the `UNTIL` of the recurrence, this is the meaning of 'inclusive' (valid as long as `DTSTART` <= `UNTIL`). + +### Montly, ByDay, Count, RDate (add occurrence) +Suppose we decided to play poker with our friends every last sunday per month for the next three months but also on July 10th, just to stay sharp. + +```cs +// Create the CalendarEvent +var start = new CalDateTime(2025, 06, 29, 16, 00, 00, "Europe/Zurich"); +var recurrence = new RecurrencePattern() +{ + Frequency = FrequencyType.Monthly, + ByDay = [new(DayOfWeek.Sunday, FrequencyOccurrence.Last)], + Count = 3, +}; + +// Create additional occurrence. +PeriodList periodList = new PeriodList(); +periodList.Add(new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich")); + +var calendarEvent = new CalendarEvent() +{ + DtStart = start, + DtEnd = start.AddHours(4), + RecurrenceRules = [recurrence], + // Add the additional occurrence to the series. + RecurrenceDatesPeriodLists = [periodList] +}; + +// Add CalendarEvent to Calendar +var calendar = new Calendar(); +calendar.Events.Add(calendarEvent); + +// Serialize Calendar to string +var calendarSerializer = new CalendarSerializer(); +var calendarAsIcs = calendarSerializer.SerializeToString(calendar); +``` +This results in the following Ics string. +> [!NOTE] +> Have a look at the `RDATE` property which defines our additional occurrence. +```ics +BEGIN:VCALENDAR +BEGIN:VEVENT +DTEND;TZID=Europe/Zurich:20250629T200000 +DTSTART;TZID=Europe/Zurich:20250629T160000 +RDATE;TZID=Europe/Zurich:20250710T090000 +RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=-1SU +END:VEVENT +END:VCALENDAR +``` +Now let's get all occurrences of this series. +```cs +var occurrences = calendar.GetOccurrences(); +``` +This will give us two occurrences with the following start and end dates. +> [!NOTE] +> We only specified the start of our additional occurrence, the duration is inherit of the base-series. +```ics +DTEND;TZID=Europe/Zurich:20250629T200000 +DTSTART;TZID=Europe/Zurich:20250629T160000 + +DTEND;TZID=Europe/Zurich:20250710T130000 +DTSTART;TZID=Europe/Zurich:20250710T090000 + +DTEND;TZID=Europe/Zurich:20250727T200000 +DTSTART;TZID=Europe/Zurich:20250727T160000 + +DTEND;TZID=Europe/Zurich:20250831T200000 +DTSTART;TZID=Europe/Zurich:20250831T160000 +``` + +### Hourly, Until, ExDate (remove occurrence) +Suppose we decided to read 15 minutes every full hour until midnight - except a 22:00 where we eat dinner (what a strange life we have 😅). + +```cs +// Create the CalendarEvent +var start = new CalDateTime(2025, 07, 10, 20, 00, 00, "UTC"); +var recurrence = new RecurrencePattern() +{ + Frequency = FrequencyType.Hourly, + Until = start.AddHours(4) +}; + +// Create exception for an occurrence. +PeriodList periodList = new PeriodList(); +periodList.Add(new CalDateTime(2025, 07, 10, 22, 00, 00, "UTC")); + +var calendarEvent = new CalendarEvent() +{ + DtStart = start, + DtEnd = start.AddMinutes(15), + RecurrenceRules = [recurrence], + // Add the exception date(s) to the series. + ExceptionDatesPeriodLists = [periodList] +}; + +// Add CalendarEvent to Calendar +var calendar = new Calendar(); +calendar.Events.Add(calendarEvent); + +// Serialize Calendar to string +var calendarSerializer = new CalendarSerializer(); +var calendarAsIcs = calendarSerializer.SerializeToString(calendar); +``` +This results in the following Ics string. +> [!NOTE] +> Have a look at the `EXDATE` property which defines our removed occurrences. +```ics +BEGIN:VCALENDAR +BEGIN:VEVENT +DTEND:20250710T201500Z +DTSTART:20250710T200000Z +EXDATE:20250710T220000Z +RRULE:FREQ=HOURLY;UNTIL=20250711T000000Z +END:VEVENT +END:VCALENDAR +``` +Now let's get all occurrences of this series. +```cs +var occurrences = calendar.GetOccurrences(); +``` +This will give us two occurrences with the following start and end dates. +```ics +DTEND:20250710T201500Z +DTSTART:20250710T200000Z + +DTEND:20250710T211500Z +DTSTART:20250710T210000Z + +DTEND:20250710T231500Z +DTSTART:20250710T230000Z + +DTEND:20250711T001500Z +DTSTART:20250711T000000Z +``` + +### Daily, Interval, Count, Exception (moved occurrence) +Suppose we decided to go for a walk every other day at 09:00 (4 times) - but not the third occurrence, we have a packed schedule then and can only go for 13 minutes at 13:00. + +Since start, end, duration and title of the event are changing, we have to create that new event and tell the series to replace an occurrence of the series with this 'special' event (child event). We do this by linking the child event to the series by using the same `UID`. We also have to link the child event to the original occurrence of the series by adding the original occurrence start-date in the `RCURRENCE-ID` property. + +> [!NOTE] Link moved events +> 1. Create a `CalendarEvent` with the changes (child event). +> 2. Link the child event to the series by using the same `Uid` (`UID`). +> 3. Link the child event to the original occurrence date by using the `RecurrenceId` (`RECURRENCE-ID`). + +```cs +// Create the CalendarEvent +var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); +var recurrence = new RecurrencePattern() +{ + Frequency = FrequencyType.Daily, + Interval = 2, + Count = 4 +}; + +var calendarEvent = new CalendarEvent() +{ + // UID links master with child. + Uid = "my-custom-id", + Summary = "Walking", + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [recurrence], +}; + +var startMoved = new CalDateTime(2025, 07, 13, 13, 00, 00, "Europe/Zurich"); +var movedEvent = new CalendarEvent() +{ + // UID links master with child. + Uid = "my-custom-id", + // Overwrite properties of the original occurrence. + Summary = "Short after lunch walk", + // Set new start and end time. + DtStart = startMoved, + DtEnd = startMoved.AddMinutes(13), + // Set the original date of the occurrence (2025-07-14 09:00:00). + RecurrenceId = start.AddDays(4) +}; + +// Add CalendarEvent to Calendar +var calendar = new Calendar(); +calendar.Events.Add(calendarEvent); +calendar.Events.Add(movedEvent); + +// Serialize Calendar to string +var calendarSerializer = new CalendarSerializer(); +var calendarAsIcs = calendarSerializer.SerializeToString(calendar); +``` +This results in the following Ics string. +> [!NOTE] +> Have a look at the `SUMMARY` property which we have overridden. +```ics +BEGIN:VCALENDAR +BEGIN:VEVENT +DTEND;TZID=Europe/Zurich:20250710T100000 +DTSTART;TZID=Europe/Zurich:20250710T090000 +RRULE:FREQ=DAILY;INTERVAL=2;COUNT=4 +SUMMARY:Walking +UID:my-custom-id +END:VEVENT +BEGIN:VEVENT +DTEND;TZID=Europe/Zurich:20250713T131300 +DTSTART;TZID=Europe/Zurich:20250713T130000 +RECURRENCE-ID;TZID=Europe/Zurich:20250714T090000 +SUMMARY:Short after lunch walk +UID:my-custom-id +END:VEVENT +END:VCALENDAR +``` +Now let's get all occurrences of this series. +```cs +var occurrences = calendar.GetOccurrences(); +``` +This will give us two occurrences with the following start and end dates. +```ics +DTEND;TZID=Europe/Zurich:20250710T100000 +DTSTART;TZID=Europe/Zurich:20250710T090000 + +DTEND;TZID=Europe/Zurich:20250712T100000 +DTSTART;TZID=Europe/Zurich:20250712T090000 + +DTEND;TZID=Europe/Zurich:20250713T131300 +DTSTART;TZID=Europe/Zurich:20250713T130000 + +DTEND;TZID=Europe/Zurich:20250716T100000 +DTSTART;TZID=Europe/Zurich:20250716T090000 +``` + +## FAQ (Recurrence) + +### Can I add multiple recurrence rules? +Yes, you can, even it's not shown in the examples above. + +### What about negative recurrrence rules? +You can use them with the `ExceptionRules` property on a e.g. `CalendarEvent`. There are two facts to consider on using: +1. It is marked as `deprecated` in [RFC 5545 (iCalendar)](https://www.rfc-editor.org/rfc/rfc5545#section-8.3.2) and maybe other software may not read this rule(s). +2. They are hard to understand by humans (as multiple rules are in general). From 8ac1fb722b8e877bb7f4a3f99de7680d79f65c59 Mon Sep 17 00:00:00 2001 From: NRG-Drink Date: Sun, 27 Jul 2025 19:29:16 +0200 Subject: [PATCH 3/9] test: removed unused helper extensions --- .../WikiSamples/RecurrenceWikiTests.cs | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs index 642193b8..68650cfa 100644 --- a/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs +++ b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs @@ -15,47 +15,6 @@ namespace Ical.Net.Tests.WikiSamples; -public static class RecurrenceWikiTestsUtilsExtensions -{ - public static Calendar ToCalendar(this CalendarEvent eve, Action? func = null) - { - var calendar = new Calendar(); - calendar.Events.Add(eve); - if (func is not null) - func(calendar); - - return calendar; - } - - public static Calendar ToCalendar(this IEnumerable eves, Action? func = null) - { - var calendar = new Calendar(); - calendar.Events.AddRange(eves); - if (func is not null) - func(calendar); - - return calendar; - } - - public static CalendarEvent With(this CalendarEvent eve, Action func) - { - func(eve); - return eve; - } - - public static CalendarEvent WithRecurrenceRule(this CalendarEvent eve, params RecurrencePattern[] rules) - { - eve.RecurrenceRules = rules; - return eve; - } - - public static RecurrencePattern With(this RecurrencePattern pattern, Action func) - { - func(pattern); - return pattern; - } -} - [TestFixture, Category("Recurrence")] public class RecurrenceWikiTests { From 7190bb7bbd6bd438ec1ba11bbd7fbd4b0ba7397c Mon Sep 17 00:00:00 2001 From: NRG-Drink Date: Sun, 27 Jul 2025 19:18:52 +0200 Subject: [PATCH 4/9] test: add wiki recurrence example tests #846 --- .../WikiSamples/RecurrenceWikiTests.cs | 492 ++++++++++++++++++ 1 file changed, 492 insertions(+) create mode 100644 Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs diff --git a/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs new file mode 100644 index 00000000..642193b8 --- /dev/null +++ b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs @@ -0,0 +1,492 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Ical.Net.CalendarComponents; +using Ical.Net.DataTypes; +using Ical.Net.Serialization; +using NUnit.Framework; + +namespace Ical.Net.Tests.WikiSamples; + +public static class RecurrenceWikiTestsUtilsExtensions +{ + public static Calendar ToCalendar(this CalendarEvent eve, Action? func = null) + { + var calendar = new Calendar(); + calendar.Events.Add(eve); + if (func is not null) + func(calendar); + + return calendar; + } + + public static Calendar ToCalendar(this IEnumerable eves, Action? func = null) + { + var calendar = new Calendar(); + calendar.Events.AddRange(eves); + if (func is not null) + func(calendar); + + return calendar; + } + + public static CalendarEvent With(this CalendarEvent eve, Action func) + { + func(eve); + return eve; + } + + public static CalendarEvent WithRecurrenceRule(this CalendarEvent eve, params RecurrencePattern[] rules) + { + eve.RecurrenceRules = rules; + return eve; + } + + public static RecurrencePattern With(this RecurrencePattern pattern, Action func) + { + func(pattern); + return pattern; + } +} + +[TestFixture, Category("Recurrence")] +public class RecurrenceWikiTests +{ + [Test] + public void Introduction() + { + var recurrence = new RecurrencePattern() + { + Frequency = FrequencyType.Daily, + Interval = 2, + Count = 30 + // Add other parameters like ByDay, ByMonth, etc. + }; + + var calendarEvent = new CalendarEvent() + { + DtStart = new CalDateTime(2025, 07, 10), + DtEnd = new CalDateTime(2025, 07, 11), + // Add the rule to the event. + RecurrenceRules = [recurrence] + }; + + // Get all occurrences of the series. + IEnumerable allOccurrences = calendarEvent.GetOccurrences(); + Assert.That(allOccurrences.Count(), Is.EqualTo(30)); + + // Get the occurrences in July. + IEnumerable julyOccurrences = calendarEvent + .GetOccurrences(new CalDateTime(2025, 07, 01)) + .TakeWhileBefore(new CalDateTime(2025, 08, 01)); + Assert.That(julyOccurrences.Count(), Is.EqualTo(11)); + } + + [Test] + public void DailyIntervalCount() + { + // Pattern: Daily every second day, tow times. + + // Create the CalendarEvent + var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); + var recurrence = new RecurrencePattern() + { + Frequency = FrequencyType.Daily, + Interval = 2, + Count = 2 + }; + + var calendarEvent = new CalendarEvent() + { + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [recurrence] + }; + + // Add CalendarEvent to Calendar + var calendar = new Calendar(); + calendar.Events.Add(calendarEvent); + + // Serialize Calendar to string + var calendarSerializer = new CalendarSerializer(); + var calendarAsIcs = calendarSerializer.SerializeToString(calendar); + Assert.That(calendarAsIcs, Is.Not.Null); + + // Calculate all occurrences + IEnumerable occurrences = calendar.GetOccurrences(); + Assert.That(occurrences.Count(), Is.EqualTo(2)); + + // Calendar output + var expectedIcsCalendar = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTEND;TZID=Europe/Zurich:20250710T100000 + DTSTART;TZID=Europe/Zurich:20250710T090000 + RRULE:FREQ=DAILY;INTERVAL=2;COUNT=2 + END:VEVENT + END:VCALENDAR + """; + // Occurring dates + var expectedOccurrenceDates = """ + DTEND;TZID=Europe/Zurich:20250710T100000 + DTSTART;TZID=Europe/Zurich:20250710T090000 + + DTEND;TZID=Europe/Zurich:20250712T100000 + DTSTART;TZID=Europe/Zurich:20250712T090000 + """; + + // Asserts + Assert.That(calendarAsIcs, Is.Not.Null); + var calendarTestString = ToTestableCalendarString(calendarAsIcs); + Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + + var occurrenceTestString = ToTestablePeriodString(occurrences); + Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + } + + [Test] + public void YearlyByMonthDayUntil() + { + // Pattern: Yearly at 10 and 12th of July, until +2 years. + // Note: TimeZone of the until-date. + // Note: Inclusive manner of the until-date. + + // Create the CalendarEvent + var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); + var recurrence = new RecurrencePattern() + { + Frequency = FrequencyType.Yearly, + ByMonthDay = [10, 12], + // 2027-07-10 09:00:00 Europe/Zurich (07:00:00 UTC) + Until = start.AddYears(2).ToTimeZone("UTC") + }; + + var calendarEvent = new CalendarEvent() + { + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [recurrence] + }; + + // Add CalendarEvent to Calendar + var calendar = new Calendar(); + calendar.Events.Add(calendarEvent); + + // Serialize Calendar to string + var calendarSerializer = new CalendarSerializer(); + var calendarAsIcs = calendarSerializer.SerializeToString(calendar); + + // Calculate all occurrences + IEnumerable occurrences = calendar.GetOccurrences(); + + // Calendar output + var expectedIcsCalendar = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTEND;TZID=Europe/Zurich:20250710T100000 + DTSTART;TZID=Europe/Zurich:20250710T090000 + RRULE:FREQ=YEARLY;UNTIL=20270710T070000Z;BYMONTHDAY=10,12 + END:VEVENT + END:VCALENDAR + """; + // Occurring dates + var expectedOccurrenceDates = """ + DTEND;TZID=Europe/Zurich:20250710T100000 + DTSTART;TZID=Europe/Zurich:20250710T090000 + + DTEND;TZID=Europe/Zurich:20250712T100000 + DTSTART;TZID=Europe/Zurich:20250712T090000 + + DTEND;TZID=Europe/Zurich:20260710T100000 + DTSTART;TZID=Europe/Zurich:20260710T090000 + + DTEND;TZID=Europe/Zurich:20260712T100000 + DTSTART;TZID=Europe/Zurich:20260712T090000 + + DTEND;TZID=Europe/Zurich:20270710T100000 + DTSTART;TZID=Europe/Zurich:20270710T090000 + """; + + // Asserts + Assert.That(calendarAsIcs, Is.Not.Null); + var calendarTestString = ToTestableCalendarString(calendarAsIcs); + Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + + var occurrenceTestString = ToTestablePeriodString(occurrences); + Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + } + + [Test] + public void MonthlyByDayCountRDate() + { + // Pattern: Monthly every last Sunday, for 3 times - plus July 10th. + // Note: RDATE takes a List of PeriodList. + + // Create the CalendarEvent + var start = new CalDateTime(2025, 06, 29, 16, 00, 00, "Europe/Zurich"); + var recurrence = new RecurrencePattern() + { + Frequency = FrequencyType.Monthly, + ByDay = [new(DayOfWeek.Sunday, FrequencyOccurrence.Last)], + Count = 3, + }; + + // Create additional occurrence. + PeriodList periodList = new PeriodList(); + periodList.Add(new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich")); + + var calendarEvent = new CalendarEvent() + { + DtStart = start, + DtEnd = start.AddHours(4), + RecurrenceRules = [recurrence], + // Add the additional occurrence to the series. + RecurrenceDatesPeriodLists = [periodList] + }; + + // Add CalendarEvent to Calendar + var calendar = new Calendar(); + calendar.Events.Add(calendarEvent); + + // Serialize Calendar to string + var calendarSerializer = new CalendarSerializer(); + var calendarAsIcs = calendarSerializer.SerializeToString(calendar); + + // Calculate all occurrences + IEnumerable occurrences = calendar.GetOccurrences(); + + // Calendar output + var expectedIcsCalendar = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTEND;TZID=Europe/Zurich:20250629T200000 + DTSTART;TZID=Europe/Zurich:20250629T160000 + RDATE;TZID=Europe/Zurich:20250710T090000 + RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=-1SU + END:VEVENT + END:VCALENDAR + """; + // Occurring dates + var expectedOccurrenceDates = """ + DTEND;TZID=Europe/Zurich:20250629T200000 + DTSTART;TZID=Europe/Zurich:20250629T160000 + + DTEND;TZID=Europe/Zurich:20250710T130000 + DTSTART;TZID=Europe/Zurich:20250710T090000 + + DTEND;TZID=Europe/Zurich:20250727T200000 + DTSTART;TZID=Europe/Zurich:20250727T160000 + + DTEND;TZID=Europe/Zurich:20250831T200000 + DTSTART;TZID=Europe/Zurich:20250831T160000 + """; + + // Asserts + Assert.That(calendarAsIcs, Is.Not.Null); + var calendarTestString = ToTestableCalendarString(calendarAsIcs); + Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + + var occurrenceTestString = ToTestablePeriodString(occurrences); + Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + } + + [Test] + public void HourlyUntilExDate() + { + // Pattern: Hourly every hour, until midnight (inclusive) - except 22:00. + // Note: EXDATE takes a List of PeriodList. + + // Create the CalendarEvent + var start = new CalDateTime(2025, 07, 10, 20, 00, 00, "UTC"); + var recurrence = new RecurrencePattern() + { + Frequency = FrequencyType.Hourly, + Until = start.AddHours(4) + }; + + // Create exception for an occurrence. + PeriodList periodList = new PeriodList(); + periodList.Add(new CalDateTime(2025, 07, 10, 22, 00, 00, "UTC")); + + var calendarEvent = new CalendarEvent() + { + DtStart = start, + DtEnd = start.AddMinutes(15), + RecurrenceRules = [recurrence], + // Add the exception date(s) to the series. + ExceptionDatesPeriodLists = [periodList] + }; + + // Add CalendarEvent to Calendar + var calendar = new Calendar(); + calendar.Events.Add(calendarEvent); + + // Serialize Calendar to string + var calendarSerializer = new CalendarSerializer(); + var calendarAsIcs = calendarSerializer.SerializeToString(calendar); + + // Calculate all occurrences + IEnumerable occurrences = calendar.GetOccurrences(); + + // Calendar output + var expectedIcsCalendar = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTEND:20250710T201500Z + DTSTART:20250710T200000Z + EXDATE:20250710T220000Z + RRULE:FREQ=HOURLY;UNTIL=20250711T000000Z + END:VEVENT + END:VCALENDAR + """; + // Occurring dates + var expectedOccurrenceDates = """ + DTEND:20250710T201500Z + DTSTART:20250710T200000Z + + DTEND:20250710T211500Z + DTSTART:20250710T210000Z + + DTEND:20250710T231500Z + DTSTART:20250710T230000Z + + DTEND:20250711T001500Z + DTSTART:20250711T000000Z + """; + + // Asserts + Assert.That(calendarAsIcs, Is.Not.Null); + var calendarTestString = ToTestableCalendarString(calendarAsIcs); + Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + + var occurrenceTestString = ToTestablePeriodString(occurrences); + Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + } + + [Test] + public void DailyIntervalCountMoved() + { + // Pattern: Daily every second day, four times - third is moved. + // Note: Link moved events with series-master by same UID. + + // Create the CalendarEvent + var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); + var recurrence = new RecurrencePattern() + { + Frequency = FrequencyType.Daily, + Interval = 2, + Count = 4 + }; + + var calendarEvent = new CalendarEvent() + { + // UID links master with child. + Uid = "my-custom-id", + Summary = "Walking", + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [recurrence], + }; + + var startMoved = new CalDateTime(2025, 07, 13, 13, 00, 00, "Europe/Zurich"); + var movedEvent = new CalendarEvent() + { + // UID links master with child. + Uid = "my-custom-id", + // Overwrite properties of the original occurrence. + Summary = "Short after lunch walk", + // Set new start and end time. + DtStart = startMoved, + DtEnd = startMoved.AddMinutes(13), + // Set the original date of the occurrence (2025-07-14 09:00:00). + RecurrenceId = start.AddDays(4) + }; + + // Add CalendarEvent to Calendar + var calendar = new Calendar(); + calendar.Events.Add(calendarEvent); + calendar.Events.Add(movedEvent); + + // Serialize Calendar to string + var calendarSerializer = new CalendarSerializer(); + var calendarAsIcs = calendarSerializer.SerializeToString(calendar); + + // Calculate all occurrences + IEnumerable occurrences = calendar.GetOccurrences(); + + // Calendar output + var expectedIcsCalendar = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTEND;TZID=Europe/Zurich:20250710T100000 + DTSTART;TZID=Europe/Zurich:20250710T090000 + RRULE:FREQ=DAILY;INTERVAL=2;COUNT=4 + SUMMARY:Walking + UID:my-custom-id + END:VEVENT + BEGIN:VEVENT + DTEND;TZID=Europe/Zurich:20250713T131300 + DTSTART;TZID=Europe/Zurich:20250713T130000 + RECURRENCE-ID;TZID=Europe/Zurich:20250714T090000 + SUMMARY:Short after lunch walk + UID:my-custom-id + END:VEVENT + END:VCALENDAR + """; + // Occurring dates + var expectedOccurrenceDates = """ + DTEND;TZID=Europe/Zurich:20250710T100000 + DTSTART;TZID=Europe/Zurich:20250710T090000 + + DTEND;TZID=Europe/Zurich:20250712T100000 + DTSTART;TZID=Europe/Zurich:20250712T090000 + + DTEND;TZID=Europe/Zurich:20250713T131300 + DTSTART;TZID=Europe/Zurich:20250713T130000 + + DTEND;TZID=Europe/Zurich:20250716T100000 + DTSTART;TZID=Europe/Zurich:20250716T090000 + """; + + // Asserts + Assert.That(calendarAsIcs, Is.Not.Null); + var calendarTestString = ToTestableCalendarString(calendarAsIcs, allowUid: true); + Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + + var occurrenceTestString = ToTestablePeriodString(occurrences); + Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + } + + private static string ToTestableCalendarString(string calendarAsIcs, bool allowUid = false) + => calendarAsIcs + .Split('\n') + .Select(e => e.Replace("\r", "")) + .Where(e => !e.StartsWith("PRODID")) + .Where(e => !e.StartsWith("DTSTAMP")) + .Where(e => allowUid || !e.StartsWith("UID")) + .Where(e => !e.StartsWith("SEQUENCE")) + .Where(e => !e.StartsWith("VERSION")) + .Aggregate(new StringBuilder(), (acc, e) => acc.AppendLine(e), e => e.ToString().TrimEnd()); + + private static string ToTestablePeriodString(IEnumerable occurrences) + => occurrences + .Select(e => GetPeriodString(e.Period)) + .OfType() + .Aggregate(new StringBuilder(), (acc, e) => acc.AppendLine(e), e => e.ToString().TrimEnd()); + + private static string GetPeriodString(Period p) + { + var start = new CalendarProperty("DTSTART", p.StartTime); + var end = new CalendarProperty("DTEND", p.EffectiveEndTime ?? p.StartTime.Add(p.Duration!.Value)); + var serializer = new PropertySerializer(); + + return serializer.SerializeToString(end) + serializer.SerializeToString(start); + } +} From e9a3a1b50f96202408717a19fc0d8b1454773d13 Mon Sep 17 00:00:00 2001 From: NRG-Drink Date: Sun, 27 Jul 2025 19:19:16 +0200 Subject: [PATCH 5/9] docs: add wiki recurrence markdown #846 --- Ical.Net.Tests/WikiSamples/RecurrenceWiki.md | 414 +++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 Ical.Net.Tests/WikiSamples/RecurrenceWiki.md diff --git a/Ical.Net.Tests/WikiSamples/RecurrenceWiki.md b/Ical.Net.Tests/WikiSamples/RecurrenceWiki.md new file mode 100644 index 00000000..fda55260 --- /dev/null +++ b/Ical.Net.Tests/WikiSamples/RecurrenceWiki.md @@ -0,0 +1,414 @@ + +It's impossible to provide an example of every type of recurrence scenario, so here are a few that represent some common use cases. If you'd like to see a specific example laid out, please [create an issue](https://github.com/rianjs/ical.net/issues). + +## How to think about occurrences +If you create an event or an alarm that happens more than once, it typically has a start time, an end time, and some rules about how and when it repeats: +- "Daily, forever" +- "Every other Tuesday until the end of the year" +- "The fourth Thursday of every November" +You then want to *search* for occurrences of that event during a given time period. This kicks off the machinery which generates the set of occurrences of that event that match your search criteria. + +### How to create a recurring event +It's like creating a normal event but we add one or more `RecurrencePattern` to the event to make it recurring. + +> [!NOTE] +> Make sure that the event's start date is the first occurrence of the series. + +```cs +var recurrence = new RecurrencePattern() +{ + Frequency = FrequencyType.Daily, + Interval = 2, + Count = 30 + // Add other parameters like ByDay, ByMonth, etc. +}; + +var calendarEvent = new CalendarEvent() +{ + DtStart = new CalDateTime(2025, 07, 10), + DtEnd = new CalDateTime(2025, 07, 11), + // Add the rule to the event. + RecurrenceRules = [recurrence] +}; +``` +### How to get occurrences of a recurring event +After creating a recurring event we can call `GetOccurrences()` to get all occurrences of a series or `GetOccurrences(CalDateTime startTime)` to get all occurrences after a given date. We can limit the evaluation by adding a `TakeWhileBefore(CalDateTime periodEnd)` to get occurrences till a specific point in time. + +> [!NOTE] +> The provided end-date in the `TakeWhileBefore` method calculate against the event start is meant in an exclusive manner. In our example, the occurrence on 2025-08-01 is not provided despite the event-start (2025-08-01) and the calculation-end (2025-08-01) is the same date (exclusive). To include it we have to set the calculation-end to 2025-08-01T00:00:01. + +> [!WARNING] +> Calculating a series with no end may lead in a `EvaluationOutOfRangeException`. Make sure you use `TakeWhileBefore` to avoid this. + +```cs +// Get all occurrences of the series. +IEnumerable allOccurrences = calendarEvent.GetOccurrences(); +Assert.That(allOccurrences.Count(), Is.EqualTo(30)); + +// Get the occurrences in July. +IEnumerable julyOccurrences = calendarEvent + .GetOccurrences(new CalDateTime(2025, 07, 01)) + .TakeWhileBefore(new CalDateTime(2025, 08, 01)); +Assert.That(julyOccurrences.Count(), Is.EqualTo(11)); +``` + +> [!TIP] +> We can call the `GetOccurrences` method on `CalendarEvent` and on `Calendar`. + + +## Recurrence Examples +### Daily, Interval, Count +Suppose we want to create an event for July 10, between 09:00 and 10:00 that recurs every other day for 2 occurrences. + +```cs +// Create the CalendarEvent +var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); +var recurrence = new RecurrencePattern() +{ + Frequency = FrequencyType.Daily, + Interval = 2, + Count = 2 +}; + +var calendarEvent = new CalendarEvent() +{ + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [recurrence] +}; + +// Add CalendarEvent to Calendar +var calendar = new Calendar(); +calendar.Events.Add(calendarEvent); + +// Serialize Calendar to string +var calendarSerializer = new CalendarSerializer(); +var calendarAsIcs = calendarSerializer.SerializeToString(calendar); +``` +This results in the following Ics string. +```ics +BEGIN:VCALENDAR +BEGIN:VEVENT +DTEND;TZID=Europe/Zurich:20250710T100000 +DTSTART;TZID=Europe/Zurich:20250710T090000 +RRULE:FREQ=DAILY;INTERVAL=2;COUNT=2 +END:VEVENT +END:VCALENDAR +``` +Now let's get all occurrences of this series. +```cs +var occurrences = calendar.GetOccurrences(); +``` +This will give us two occurrences with the following start and end dates. +```ics +DTEND;TZID=Europe/Zurich:20250710T100000 +DTSTART;TZID=Europe/Zurich:20250710T090000 + +DTEND;TZID=Europe/Zurich:20250712T100000 +DTSTART;TZID=Europe/Zurich:20250712T090000 +``` + +### Yearly, ByMonthDay, Until +Suppose we want to create an series of events for July 10 and 12, between 09:00 and 10:00 that recurs every year until in two years. + +> [!WARNING] +> The `UNTIL` date must have UTC or none time zone. + +> [!NOTE] +> The `UNTIL` date is meant in an inclusive manner. + +```cs +// Create the CalendarEvent +var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); +var recurrence = new RecurrencePattern() +{ + Frequency = FrequencyType.Yearly, + ByMonthDay = [10, 12], + // 2027-07-10 09:00:00 Europe/Zurich (07:00:00 UTC) + Until = start.AddYears(2).ToTimeZone("UTC") +}; + +var calendarEvent = new CalendarEvent() +{ + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [recurrence] +}; + +// Add CalendarEvent to Calendar +var calendar = new Calendar(); +calendar.Events.Add(calendarEvent); + +// Serialize Calendar to string +var calendarSerializer = new CalendarSerializer(); +var calendarAsIcs = calendarSerializer.SerializeToString(calendar); +``` +This results in the following Ics string. +```ics +BEGIN:VCALENDAR +BEGIN:VEVENT +DTEND;TZID=Europe/Zurich:20250710T100000 +DTSTART;TZID=Europe/Zurich:20250710T090000 +RRULE:FREQ=YEARLY;UNTIL=20270710T070000Z;BYMONTHDAY=10,12 +END:VEVENT +END:VCALENDAR +``` +Now let's get all occurrences of this series. +```cs +var occurrences = calendar.GetOccurrences(); +``` +This will give us two occurrences with the following start and end dates. +```ics +DTEND;TZID=Europe/Zurich:20250710T100000 +DTSTART;TZID=Europe/Zurich:20250710T090000 + +DTEND;TZID=Europe/Zurich:20250712T100000 +DTSTART;TZID=Europe/Zurich:20250712T090000 + +DTEND;TZID=Europe/Zurich:20260710T100000 +DTSTART;TZID=Europe/Zurich:20260710T090000 + +DTEND;TZID=Europe/Zurich:20260712T100000 +DTSTART;TZID=Europe/Zurich:20260712T090000 + +DTEND;TZID=Europe/Zurich:20270710T100000 +DTSTART;TZID=Europe/Zurich:20270710T090000 +``` +> [!TIP] +> Have a look at the last occurrence, it has the same start as the `UNTIL` of the recurrence, this is the meaning of 'inclusive' (valid as long as `DTSTART` <= `UNTIL`). + +### Montly, ByDay, Count, RDate (add occurrence) +Suppose we decided to play poker with our friends every last sunday per month for the next three months but also on July 10th, just to stay sharp. + +```cs +// Create the CalendarEvent +var start = new CalDateTime(2025, 06, 29, 16, 00, 00, "Europe/Zurich"); +var recurrence = new RecurrencePattern() +{ + Frequency = FrequencyType.Monthly, + ByDay = [new(DayOfWeek.Sunday, FrequencyOccurrence.Last)], + Count = 3, +}; + +// Create additional occurrence. +PeriodList periodList = new PeriodList(); +periodList.Add(new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich")); + +var calendarEvent = new CalendarEvent() +{ + DtStart = start, + DtEnd = start.AddHours(4), + RecurrenceRules = [recurrence], + // Add the additional occurrence to the series. + RecurrenceDatesPeriodLists = [periodList] +}; + +// Add CalendarEvent to Calendar +var calendar = new Calendar(); +calendar.Events.Add(calendarEvent); + +// Serialize Calendar to string +var calendarSerializer = new CalendarSerializer(); +var calendarAsIcs = calendarSerializer.SerializeToString(calendar); +``` +This results in the following Ics string. +> [!NOTE] +> Have a look at the `RDATE` property which defines our additional occurrence. +```ics +BEGIN:VCALENDAR +BEGIN:VEVENT +DTEND;TZID=Europe/Zurich:20250629T200000 +DTSTART;TZID=Europe/Zurich:20250629T160000 +RDATE;TZID=Europe/Zurich:20250710T090000 +RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=-1SU +END:VEVENT +END:VCALENDAR +``` +Now let's get all occurrences of this series. +```cs +var occurrences = calendar.GetOccurrences(); +``` +This will give us two occurrences with the following start and end dates. +> [!NOTE] +> We only specified the start of our additional occurrence, the duration is inherit of the base-series. +```ics +DTEND;TZID=Europe/Zurich:20250629T200000 +DTSTART;TZID=Europe/Zurich:20250629T160000 + +DTEND;TZID=Europe/Zurich:20250710T130000 +DTSTART;TZID=Europe/Zurich:20250710T090000 + +DTEND;TZID=Europe/Zurich:20250727T200000 +DTSTART;TZID=Europe/Zurich:20250727T160000 + +DTEND;TZID=Europe/Zurich:20250831T200000 +DTSTART;TZID=Europe/Zurich:20250831T160000 +``` + +### Hourly, Until, ExDate (remove occurrence) +Suppose we decided to read 15 minutes every full hour until midnight - except a 22:00 where we eat dinner (what a strange life we have 😅). + +```cs +// Create the CalendarEvent +var start = new CalDateTime(2025, 07, 10, 20, 00, 00, "UTC"); +var recurrence = new RecurrencePattern() +{ + Frequency = FrequencyType.Hourly, + Until = start.AddHours(4) +}; + +// Create exception for an occurrence. +PeriodList periodList = new PeriodList(); +periodList.Add(new CalDateTime(2025, 07, 10, 22, 00, 00, "UTC")); + +var calendarEvent = new CalendarEvent() +{ + DtStart = start, + DtEnd = start.AddMinutes(15), + RecurrenceRules = [recurrence], + // Add the exception date(s) to the series. + ExceptionDatesPeriodLists = [periodList] +}; + +// Add CalendarEvent to Calendar +var calendar = new Calendar(); +calendar.Events.Add(calendarEvent); + +// Serialize Calendar to string +var calendarSerializer = new CalendarSerializer(); +var calendarAsIcs = calendarSerializer.SerializeToString(calendar); +``` +This results in the following Ics string. +> [!NOTE] +> Have a look at the `EXDATE` property which defines our removed occurrences. +```ics +BEGIN:VCALENDAR +BEGIN:VEVENT +DTEND:20250710T201500Z +DTSTART:20250710T200000Z +EXDATE:20250710T220000Z +RRULE:FREQ=HOURLY;UNTIL=20250711T000000Z +END:VEVENT +END:VCALENDAR +``` +Now let's get all occurrences of this series. +```cs +var occurrences = calendar.GetOccurrences(); +``` +This will give us two occurrences with the following start and end dates. +```ics +DTEND:20250710T201500Z +DTSTART:20250710T200000Z + +DTEND:20250710T211500Z +DTSTART:20250710T210000Z + +DTEND:20250710T231500Z +DTSTART:20250710T230000Z + +DTEND:20250711T001500Z +DTSTART:20250711T000000Z +``` + +### Daily, Interval, Count, Exception (moved occurrence) +Suppose we decided to go for a walk every other day at 09:00 (4 times) - but not the third occurrence, we have a packed schedule then and can only go for 13 minutes at 13:00. + +Since start, end, duration and title of the event are changing, we have to create that new event and tell the series to replace an occurrence of the series with this 'special' event (child event). We do this by linking the child event to the series by using the same `UID`. We also have to link the child event to the original occurrence of the series by adding the original occurrence start-date in the `RCURRENCE-ID` property. + +> [!NOTE] Link moved events +> 1. Create a `CalendarEvent` with the changes (child event). +> 2. Link the child event to the series by using the same `Uid` (`UID`). +> 3. Link the child event to the original occurrence date by using the `RecurrenceId` (`RECURRENCE-ID`). + +```cs +// Create the CalendarEvent +var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); +var recurrence = new RecurrencePattern() +{ + Frequency = FrequencyType.Daily, + Interval = 2, + Count = 4 +}; + +var calendarEvent = new CalendarEvent() +{ + // UID links master with child. + Uid = "my-custom-id", + Summary = "Walking", + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [recurrence], +}; + +var startMoved = new CalDateTime(2025, 07, 13, 13, 00, 00, "Europe/Zurich"); +var movedEvent = new CalendarEvent() +{ + // UID links master with child. + Uid = "my-custom-id", + // Overwrite properties of the original occurrence. + Summary = "Short after lunch walk", + // Set new start and end time. + DtStart = startMoved, + DtEnd = startMoved.AddMinutes(13), + // Set the original date of the occurrence (2025-07-14 09:00:00). + RecurrenceId = start.AddDays(4) +}; + +// Add CalendarEvent to Calendar +var calendar = new Calendar(); +calendar.Events.Add(calendarEvent); +calendar.Events.Add(movedEvent); + +// Serialize Calendar to string +var calendarSerializer = new CalendarSerializer(); +var calendarAsIcs = calendarSerializer.SerializeToString(calendar); +``` +This results in the following Ics string. +> [!NOTE] +> Have a look at the `SUMMARY` property which we have overridden. +```ics +BEGIN:VCALENDAR +BEGIN:VEVENT +DTEND;TZID=Europe/Zurich:20250710T100000 +DTSTART;TZID=Europe/Zurich:20250710T090000 +RRULE:FREQ=DAILY;INTERVAL=2;COUNT=4 +SUMMARY:Walking +UID:my-custom-id +END:VEVENT +BEGIN:VEVENT +DTEND;TZID=Europe/Zurich:20250713T131300 +DTSTART;TZID=Europe/Zurich:20250713T130000 +RECURRENCE-ID;TZID=Europe/Zurich:20250714T090000 +SUMMARY:Short after lunch walk +UID:my-custom-id +END:VEVENT +END:VCALENDAR +``` +Now let's get all occurrences of this series. +```cs +var occurrences = calendar.GetOccurrences(); +``` +This will give us two occurrences with the following start and end dates. +```ics +DTEND;TZID=Europe/Zurich:20250710T100000 +DTSTART;TZID=Europe/Zurich:20250710T090000 + +DTEND;TZID=Europe/Zurich:20250712T100000 +DTSTART;TZID=Europe/Zurich:20250712T090000 + +DTEND;TZID=Europe/Zurich:20250713T131300 +DTSTART;TZID=Europe/Zurich:20250713T130000 + +DTEND;TZID=Europe/Zurich:20250716T100000 +DTSTART;TZID=Europe/Zurich:20250716T090000 +``` + +## FAQ (Recurrence) + +### Can I add multiple recurrence rules? +Yes, you can, even it's not shown in the examples above. + +### What about negative recurrrence rules? +You can use them with the `ExceptionRules` property on a e.g. `CalendarEvent`. There are two facts to consider on using: +1. It is marked as `deprecated` in [RFC 5545 (iCalendar)](https://www.rfc-editor.org/rfc/rfc5545#section-8.3.2) and maybe other software may not read this rule(s). +2. They are hard to understand by humans (as multiple rules are in general). From cdc97635778de0a7d99ff9efe9697995b49cbc86 Mon Sep 17 00:00:00 2001 From: NRG-Drink Date: Sun, 27 Jul 2025 19:29:16 +0200 Subject: [PATCH 6/9] test: removed unused helper extensions --- .../WikiSamples/RecurrenceWikiTests.cs | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs index 642193b8..68650cfa 100644 --- a/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs +++ b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs @@ -15,47 +15,6 @@ namespace Ical.Net.Tests.WikiSamples; -public static class RecurrenceWikiTestsUtilsExtensions -{ - public static Calendar ToCalendar(this CalendarEvent eve, Action? func = null) - { - var calendar = new Calendar(); - calendar.Events.Add(eve); - if (func is not null) - func(calendar); - - return calendar; - } - - public static Calendar ToCalendar(this IEnumerable eves, Action? func = null) - { - var calendar = new Calendar(); - calendar.Events.AddRange(eves); - if (func is not null) - func(calendar); - - return calendar; - } - - public static CalendarEvent With(this CalendarEvent eve, Action func) - { - func(eve); - return eve; - } - - public static CalendarEvent WithRecurrenceRule(this CalendarEvent eve, params RecurrencePattern[] rules) - { - eve.RecurrenceRules = rules; - return eve; - } - - public static RecurrencePattern With(this RecurrencePattern pattern, Action func) - { - func(pattern); - return pattern; - } -} - [TestFixture, Category("Recurrence")] public class RecurrenceWikiTests { From 25b8351f093bb5996c94258c834b38b2ada0867c Mon Sep 17 00:00:00 2001 From: NRG-Drink Date: Sun, 31 Aug 2025 18:27:35 +0200 Subject: [PATCH 7/9] test: use system new-line --- Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs index 68650cfa..e7514050 100644 --- a/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs +++ b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs @@ -437,7 +437,6 @@ private static string ToTestableCalendarString(string calendarAsIcs, bool allowU private static string ToTestablePeriodString(IEnumerable occurrences) => occurrences .Select(e => GetPeriodString(e.Period)) - .OfType() .Aggregate(new StringBuilder(), (acc, e) => acc.AppendLine(e), e => e.ToString().TrimEnd()); private static string GetPeriodString(Period p) @@ -446,6 +445,9 @@ private static string GetPeriodString(Period p) var end = new CalendarProperty("DTEND", p.EffectiveEndTime ?? p.StartTime.Add(p.Duration!.Value)); var serializer = new PropertySerializer(); - return serializer.SerializeToString(end) + serializer.SerializeToString(start); + var endSerialized = serializer.SerializeToString(end)?.TrimEnd('\n').TrimEnd('\r'); + var startSerialized = serializer.SerializeToString(start)?.TrimEnd('\n').TrimEnd('\r'); + + return $"{endSerialized}{Environment.NewLine}{startSerialized}{Environment.NewLine}"; } } From 2916aaef10810fc2b4d61e923d726048a55ce3eb Mon Sep 17 00:00:00 2001 From: NRG-Drink Date: Sun, 31 Aug 2025 19:23:09 +0200 Subject: [PATCH 8/9] test: use log as occurrence output --- .../WikiSamples/RecurrenceWikiTests.cs | 91 ++++++------------- 1 file changed, 27 insertions(+), 64 deletions(-) diff --git a/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs index 88208e46..743f1391 100644 --- a/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs +++ b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs @@ -11,6 +11,7 @@ using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; using Ical.Net.Serialization; +using Ical.Net.Tests.Logging; using NUnit.Framework; namespace Ical.Net.Tests.WikiSamples; @@ -94,11 +95,10 @@ public void DailyIntervalCount() """; // Occurring dates var expectedOccurrenceDates = """ - DTEND;TZID=Europe/Zurich:20250710T100000 - DTSTART;TZID=Europe/Zurich:20250710T090000 - - DTEND;TZID=Europe/Zurich:20250712T100000 - DTSTART;TZID=Europe/Zurich:20250712T090000 + Occurrences: + Start: 07/10/2025 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/10/2025 10:00:00 +02:00 Europe/Zurich + Start: 07/12/2025 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/12/2025 10:00:00 +02:00 Europe/Zurich + """; // Asserts @@ -157,20 +157,13 @@ public void YearlyByMonthDayUntil() """; // Occurring dates var expectedOccurrenceDates = """ - DTEND;TZID=Europe/Zurich:20250710T100000 - DTSTART;TZID=Europe/Zurich:20250710T090000 - - DTEND;TZID=Europe/Zurich:20250712T100000 - DTSTART;TZID=Europe/Zurich:20250712T090000 - - DTEND;TZID=Europe/Zurich:20260710T100000 - DTSTART;TZID=Europe/Zurich:20260710T090000 + Occurrences: + Start: 07/10/2025 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/10/2025 10:00:00 +02:00 Europe/Zurich + Start: 07/12/2025 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/12/2025 10:00:00 +02:00 Europe/Zurich + Start: 07/10/2026 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/10/2026 10:00:00 +02:00 Europe/Zurich + Start: 07/12/2026 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/12/2026 10:00:00 +02:00 Europe/Zurich + Start: 07/10/2027 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/10/2027 10:00:00 +02:00 Europe/Zurich - DTEND;TZID=Europe/Zurich:20260712T100000 - DTSTART;TZID=Europe/Zurich:20260712T090000 - - DTEND;TZID=Europe/Zurich:20270710T100000 - DTSTART;TZID=Europe/Zurich:20270710T090000 """; // Asserts @@ -234,17 +227,12 @@ public void MonthlyByDayCountRDate() """; // Occurring dates var expectedOccurrenceDates = """ - DTEND;TZID=Europe/Zurich:20250629T200000 - DTSTART;TZID=Europe/Zurich:20250629T160000 + Occurrences: + Start: 06/29/2025 16:00:00 +02:00 Europe/Zurich Period: PT4H End: 06/29/2025 20:00:00 +02:00 Europe/Zurich + Start: 07/10/2025 09:00:00 +02:00 Europe/Zurich Period: PT4H End: 07/10/2025 13:00:00 +02:00 Europe/Zurich + Start: 07/27/2025 16:00:00 +02:00 Europe/Zurich Period: PT4H End: 07/27/2025 20:00:00 +02:00 Europe/Zurich + Start: 08/31/2025 16:00:00 +02:00 Europe/Zurich Period: PT4H End: 08/31/2025 20:00:00 +02:00 Europe/Zurich - DTEND;TZID=Europe/Zurich:20250710T130000 - DTSTART;TZID=Europe/Zurich:20250710T090000 - - DTEND;TZID=Europe/Zurich:20250727T200000 - DTSTART;TZID=Europe/Zurich:20250727T160000 - - DTEND;TZID=Europe/Zurich:20250831T200000 - DTSTART;TZID=Europe/Zurich:20250831T160000 """; // Asserts @@ -307,17 +295,12 @@ public void HourlyUntilExDate() """; // Occurring dates var expectedOccurrenceDates = """ - DTEND:20250710T201500Z - DTSTART:20250710T200000Z - - DTEND:20250710T211500Z - DTSTART:20250710T210000Z - - DTEND:20250710T231500Z - DTSTART:20250710T230000Z + Occurrences: + Start: 07/10/2025 20:00:00 +00:00 UTC Period: PT15M End: 07/10/2025 20:15:00 +00:00 UTC + Start: 07/10/2025 21:00:00 +00:00 UTC Period: PT15M End: 07/10/2025 21:15:00 +00:00 UTC + Start: 07/10/2025 23:00:00 +00:00 UTC Period: PT15M End: 07/10/2025 23:15:00 +00:00 UTC + Start: 07/11/2025 00:00:00 +00:00 UTC Period: PT15M End: 07/11/2025 00:15:00 +00:00 UTC - DTEND:20250711T001500Z - DTSTART:20250711T000000Z """; // Asserts @@ -401,17 +384,12 @@ public void DailyIntervalCountMoved() """; // Occurring dates var expectedOccurrenceDates = """ - DTEND;TZID=Europe/Zurich:20250710T100000 - DTSTART;TZID=Europe/Zurich:20250710T090000 + Occurrences: + Start: 07/10/2025 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/10/2025 10:00:00 +02:00 Europe/Zurich + Start: 07/12/2025 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/12/2025 10:00:00 +02:00 Europe/Zurich + Start: 07/13/2025 13:00:00 +02:00 Europe/Zurich Period: PT13M End: 07/13/2025 13:13:00 +02:00 Europe/Zurich + Start: 07/16/2025 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/16/2025 10:00:00 +02:00 Europe/Zurich - DTEND;TZID=Europe/Zurich:20250712T100000 - DTSTART;TZID=Europe/Zurich:20250712T090000 - - DTEND;TZID=Europe/Zurich:20250713T131300 - DTSTART;TZID=Europe/Zurich:20250713T130000 - - DTEND;TZID=Europe/Zurich:20250716T100000 - DTSTART;TZID=Europe/Zurich:20250716T090000 """; // Asserts @@ -435,20 +413,5 @@ private static string ToTestableCalendarString(string calendarAsIcs, bool allowU .Aggregate(new StringBuilder(), (acc, e) => acc.AppendLine(e), e => e.ToString().TrimEnd()); private static string ToTestablePeriodString(IEnumerable occurrences) - => occurrences - .Select(e => GetPeriodString(e.Period)) - .OfType() - .Aggregate(new StringBuilder(), (acc, e) => acc.AppendLine(e), e => e.ToString().TrimEnd()); - - private static string GetPeriodString(Period p) - { - var start = new CalendarProperty("DTSTART", p.StartTime); - var end = new CalendarProperty("DTEND", p.EffectiveEndTime ?? p.StartTime.Add(p.Duration!.Value)); - var serializer = new PropertySerializer(); - - var endSerialized = serializer.SerializeToString(end)?.TrimEnd('\n').TrimEnd('\r'); - var startSerialized = serializer.SerializeToString(start)?.TrimEnd('\n').TrimEnd('\r'); - - return $"{endSerialized}{Environment.NewLine}{startSerialized}{Environment.NewLine}"; - } + => occurrences.ToLog(); } From b7ad4f578937377f292cac7eb8c971c7cf34890c Mon Sep 17 00:00:00 2001 From: axunonb Date: Tue, 23 Sep 2025 11:50:56 +0200 Subject: [PATCH 9/9] chore: Refactor code samples in `RecurrenceWikiTests` * Implement remarks from last PR review * Updated class `ToLogExtensions` for better formatting of occurrences. * Introduce logging to create strings for ICS and occurrences and simplify copying to the Wiki * Added new tests for time zone changes and complex recurrence rules * Update `RecurrenceWiki.md` * Movef `RecurrenceWiki.md` to https://github.com/ical-org/ical.net/wiki/Working-with-recurring-elements --- .../Logging.Tests/TestLoggingManagerTests.cs | 9 +- Ical.Net.Tests/Logging/ToLogExtensions.cs | 16 +- Ical.Net.Tests/WikiSamples/RecurrenceWiki.md | 414 -------------- .../WikiSamples/RecurrenceWikiTests.cs | 506 +++++++++++++----- 4 files changed, 385 insertions(+), 560 deletions(-) delete mode 100644 Ical.Net.Tests/WikiSamples/RecurrenceWiki.md diff --git a/Ical.Net.Tests/Logging.Tests/TestLoggingManagerTests.cs b/Ical.Net.Tests/Logging.Tests/TestLoggingManagerTests.cs index 634ef158..d8d9fcc6 100644 --- a/Ical.Net.Tests/Logging.Tests/TestLoggingManagerTests.cs +++ b/Ical.Net.Tests/Logging.Tests/TestLoggingManagerTests.cs @@ -85,14 +85,7 @@ public void DemoForOccurrences() var logs = mgr.Logs.ToList(); /* 2025-07-31 11:00:53.7022|TRACE|Occurrences|Occurrences: - Start: 01/05/2025 08:30:00 -05:00 US-Eastern Period: PT1H End: 01/05/2025 09:30:00 -05:00 US-Eastern - Start: 01/05/2025 09:30:00 -05:00 US-Eastern Period: PT1H End: 01/05/2025 10:30:00 -05:00 US-Eastern - Start: 01/12/2025 08:30:00 -05:00 US-Eastern Period: PT1H End: 01/12/2025 09:30:00 -05:00 US-Eastern - Start: 01/12/2025 09:30:00 -05:00 US-Eastern Period: PT1H End: 01/12/2025 10:30:00 -05:00 US-Eastern - Start: 01/19/2025 08:30:00 -05:00 US-Eastern Period: PT1H End: 01/19/2025 09:30:00 -05:00 US-Eastern - Start: 01/19/2025 09:30:00 -05:00 US-Eastern Period: PT1H End: 01/19/2025 10:30:00 -05:00 US-Eastern - Start: 01/26/2025 08:30:00 -05:00 US-Eastern Period: PT1H End: 01/26/2025 09:30:00 -05:00 US-Eastern - Start: 01/26/2025 09:30:00 -05:00 US-Eastern Period: PT1H End: 01/26/2025 10:30:00 -05:00 US-Eastern + ... */ Assert.That(logs, Has.Count.EqualTo(1)); Assert.That(logs[0], Does.Contain("Occurrences")); diff --git a/Ical.Net.Tests/Logging/ToLogExtensions.cs b/Ical.Net.Tests/Logging/ToLogExtensions.cs index 564134c0..80284766 100644 --- a/Ical.Net.Tests/Logging/ToLogExtensions.cs +++ b/Ical.Net.Tests/Logging/ToLogExtensions.cs @@ -31,24 +31,28 @@ public static string ToLog(this Exception? exception) public static string ToLog(this IEnumerable? occurrences) { var sb = new StringBuilder(); - if (occurrences == null || !occurrences.Any()) + var occurrenceList = occurrences?.ToList(); + if (occurrenceList == null || occurrenceList.Count == 0) { return "No occurrences found."; } - sb.AppendLine("Occurrences:"); + sb.Append(occurrenceList.Count).AppendLine(" occurrences:"); - foreach (var occurrence in occurrences) + foreach (var occurrence in occurrenceList) { sb.AppendLine(occurrence.ToLog()); } - return sb.ToString(); + return sb.ToString().TrimEnd(Environment.NewLine.ToCharArray()); } public static string ToLog(this Occurrence occurrence) { - var o = occurrence; - return $"Start: {o.Period.StartTime} Period: {o.Period.Duration?.ToString() ?? "null"} End: {o.Period.EffectiveEndTime ?? o.Period.StartTime.Add(o.Period.Duration!.Value)}"; + return $""" + Start: {occurrence.Period.StartTime} + Period: {occurrence.Period.Duration?.ToString() ?? "null"} + End: {occurrence.Period.EffectiveEndTime ?? occurrence.Period.StartTime.Add(occurrence.Period.Duration!.Value)} + """; } } diff --git a/Ical.Net.Tests/WikiSamples/RecurrenceWiki.md b/Ical.Net.Tests/WikiSamples/RecurrenceWiki.md deleted file mode 100644 index fda55260..00000000 --- a/Ical.Net.Tests/WikiSamples/RecurrenceWiki.md +++ /dev/null @@ -1,414 +0,0 @@ - -It's impossible to provide an example of every type of recurrence scenario, so here are a few that represent some common use cases. If you'd like to see a specific example laid out, please [create an issue](https://github.com/rianjs/ical.net/issues). - -## How to think about occurrences -If you create an event or an alarm that happens more than once, it typically has a start time, an end time, and some rules about how and when it repeats: -- "Daily, forever" -- "Every other Tuesday until the end of the year" -- "The fourth Thursday of every November" -You then want to *search* for occurrences of that event during a given time period. This kicks off the machinery which generates the set of occurrences of that event that match your search criteria. - -### How to create a recurring event -It's like creating a normal event but we add one or more `RecurrencePattern` to the event to make it recurring. - -> [!NOTE] -> Make sure that the event's start date is the first occurrence of the series. - -```cs -var recurrence = new RecurrencePattern() -{ - Frequency = FrequencyType.Daily, - Interval = 2, - Count = 30 - // Add other parameters like ByDay, ByMonth, etc. -}; - -var calendarEvent = new CalendarEvent() -{ - DtStart = new CalDateTime(2025, 07, 10), - DtEnd = new CalDateTime(2025, 07, 11), - // Add the rule to the event. - RecurrenceRules = [recurrence] -}; -``` -### How to get occurrences of a recurring event -After creating a recurring event we can call `GetOccurrences()` to get all occurrences of a series or `GetOccurrences(CalDateTime startTime)` to get all occurrences after a given date. We can limit the evaluation by adding a `TakeWhileBefore(CalDateTime periodEnd)` to get occurrences till a specific point in time. - -> [!NOTE] -> The provided end-date in the `TakeWhileBefore` method calculate against the event start is meant in an exclusive manner. In our example, the occurrence on 2025-08-01 is not provided despite the event-start (2025-08-01) and the calculation-end (2025-08-01) is the same date (exclusive). To include it we have to set the calculation-end to 2025-08-01T00:00:01. - -> [!WARNING] -> Calculating a series with no end may lead in a `EvaluationOutOfRangeException`. Make sure you use `TakeWhileBefore` to avoid this. - -```cs -// Get all occurrences of the series. -IEnumerable allOccurrences = calendarEvent.GetOccurrences(); -Assert.That(allOccurrences.Count(), Is.EqualTo(30)); - -// Get the occurrences in July. -IEnumerable julyOccurrences = calendarEvent - .GetOccurrences(new CalDateTime(2025, 07, 01)) - .TakeWhileBefore(new CalDateTime(2025, 08, 01)); -Assert.That(julyOccurrences.Count(), Is.EqualTo(11)); -``` - -> [!TIP] -> We can call the `GetOccurrences` method on `CalendarEvent` and on `Calendar`. - - -## Recurrence Examples -### Daily, Interval, Count -Suppose we want to create an event for July 10, between 09:00 and 10:00 that recurs every other day for 2 occurrences. - -```cs -// Create the CalendarEvent -var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); -var recurrence = new RecurrencePattern() -{ - Frequency = FrequencyType.Daily, - Interval = 2, - Count = 2 -}; - -var calendarEvent = new CalendarEvent() -{ - DtStart = start, - DtEnd = start.AddHours(1), - RecurrenceRules = [recurrence] -}; - -// Add CalendarEvent to Calendar -var calendar = new Calendar(); -calendar.Events.Add(calendarEvent); - -// Serialize Calendar to string -var calendarSerializer = new CalendarSerializer(); -var calendarAsIcs = calendarSerializer.SerializeToString(calendar); -``` -This results in the following Ics string. -```ics -BEGIN:VCALENDAR -BEGIN:VEVENT -DTEND;TZID=Europe/Zurich:20250710T100000 -DTSTART;TZID=Europe/Zurich:20250710T090000 -RRULE:FREQ=DAILY;INTERVAL=2;COUNT=2 -END:VEVENT -END:VCALENDAR -``` -Now let's get all occurrences of this series. -```cs -var occurrences = calendar.GetOccurrences(); -``` -This will give us two occurrences with the following start and end dates. -```ics -DTEND;TZID=Europe/Zurich:20250710T100000 -DTSTART;TZID=Europe/Zurich:20250710T090000 - -DTEND;TZID=Europe/Zurich:20250712T100000 -DTSTART;TZID=Europe/Zurich:20250712T090000 -``` - -### Yearly, ByMonthDay, Until -Suppose we want to create an series of events for July 10 and 12, between 09:00 and 10:00 that recurs every year until in two years. - -> [!WARNING] -> The `UNTIL` date must have UTC or none time zone. - -> [!NOTE] -> The `UNTIL` date is meant in an inclusive manner. - -```cs -// Create the CalendarEvent -var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); -var recurrence = new RecurrencePattern() -{ - Frequency = FrequencyType.Yearly, - ByMonthDay = [10, 12], - // 2027-07-10 09:00:00 Europe/Zurich (07:00:00 UTC) - Until = start.AddYears(2).ToTimeZone("UTC") -}; - -var calendarEvent = new CalendarEvent() -{ - DtStart = start, - DtEnd = start.AddHours(1), - RecurrenceRules = [recurrence] -}; - -// Add CalendarEvent to Calendar -var calendar = new Calendar(); -calendar.Events.Add(calendarEvent); - -// Serialize Calendar to string -var calendarSerializer = new CalendarSerializer(); -var calendarAsIcs = calendarSerializer.SerializeToString(calendar); -``` -This results in the following Ics string. -```ics -BEGIN:VCALENDAR -BEGIN:VEVENT -DTEND;TZID=Europe/Zurich:20250710T100000 -DTSTART;TZID=Europe/Zurich:20250710T090000 -RRULE:FREQ=YEARLY;UNTIL=20270710T070000Z;BYMONTHDAY=10,12 -END:VEVENT -END:VCALENDAR -``` -Now let's get all occurrences of this series. -```cs -var occurrences = calendar.GetOccurrences(); -``` -This will give us two occurrences with the following start and end dates. -```ics -DTEND;TZID=Europe/Zurich:20250710T100000 -DTSTART;TZID=Europe/Zurich:20250710T090000 - -DTEND;TZID=Europe/Zurich:20250712T100000 -DTSTART;TZID=Europe/Zurich:20250712T090000 - -DTEND;TZID=Europe/Zurich:20260710T100000 -DTSTART;TZID=Europe/Zurich:20260710T090000 - -DTEND;TZID=Europe/Zurich:20260712T100000 -DTSTART;TZID=Europe/Zurich:20260712T090000 - -DTEND;TZID=Europe/Zurich:20270710T100000 -DTSTART;TZID=Europe/Zurich:20270710T090000 -``` -> [!TIP] -> Have a look at the last occurrence, it has the same start as the `UNTIL` of the recurrence, this is the meaning of 'inclusive' (valid as long as `DTSTART` <= `UNTIL`). - -### Montly, ByDay, Count, RDate (add occurrence) -Suppose we decided to play poker with our friends every last sunday per month for the next three months but also on July 10th, just to stay sharp. - -```cs -// Create the CalendarEvent -var start = new CalDateTime(2025, 06, 29, 16, 00, 00, "Europe/Zurich"); -var recurrence = new RecurrencePattern() -{ - Frequency = FrequencyType.Monthly, - ByDay = [new(DayOfWeek.Sunday, FrequencyOccurrence.Last)], - Count = 3, -}; - -// Create additional occurrence. -PeriodList periodList = new PeriodList(); -periodList.Add(new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich")); - -var calendarEvent = new CalendarEvent() -{ - DtStart = start, - DtEnd = start.AddHours(4), - RecurrenceRules = [recurrence], - // Add the additional occurrence to the series. - RecurrenceDatesPeriodLists = [periodList] -}; - -// Add CalendarEvent to Calendar -var calendar = new Calendar(); -calendar.Events.Add(calendarEvent); - -// Serialize Calendar to string -var calendarSerializer = new CalendarSerializer(); -var calendarAsIcs = calendarSerializer.SerializeToString(calendar); -``` -This results in the following Ics string. -> [!NOTE] -> Have a look at the `RDATE` property which defines our additional occurrence. -```ics -BEGIN:VCALENDAR -BEGIN:VEVENT -DTEND;TZID=Europe/Zurich:20250629T200000 -DTSTART;TZID=Europe/Zurich:20250629T160000 -RDATE;TZID=Europe/Zurich:20250710T090000 -RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=-1SU -END:VEVENT -END:VCALENDAR -``` -Now let's get all occurrences of this series. -```cs -var occurrences = calendar.GetOccurrences(); -``` -This will give us two occurrences with the following start and end dates. -> [!NOTE] -> We only specified the start of our additional occurrence, the duration is inherit of the base-series. -```ics -DTEND;TZID=Europe/Zurich:20250629T200000 -DTSTART;TZID=Europe/Zurich:20250629T160000 - -DTEND;TZID=Europe/Zurich:20250710T130000 -DTSTART;TZID=Europe/Zurich:20250710T090000 - -DTEND;TZID=Europe/Zurich:20250727T200000 -DTSTART;TZID=Europe/Zurich:20250727T160000 - -DTEND;TZID=Europe/Zurich:20250831T200000 -DTSTART;TZID=Europe/Zurich:20250831T160000 -``` - -### Hourly, Until, ExDate (remove occurrence) -Suppose we decided to read 15 minutes every full hour until midnight - except a 22:00 where we eat dinner (what a strange life we have 😅). - -```cs -// Create the CalendarEvent -var start = new CalDateTime(2025, 07, 10, 20, 00, 00, "UTC"); -var recurrence = new RecurrencePattern() -{ - Frequency = FrequencyType.Hourly, - Until = start.AddHours(4) -}; - -// Create exception for an occurrence. -PeriodList periodList = new PeriodList(); -periodList.Add(new CalDateTime(2025, 07, 10, 22, 00, 00, "UTC")); - -var calendarEvent = new CalendarEvent() -{ - DtStart = start, - DtEnd = start.AddMinutes(15), - RecurrenceRules = [recurrence], - // Add the exception date(s) to the series. - ExceptionDatesPeriodLists = [periodList] -}; - -// Add CalendarEvent to Calendar -var calendar = new Calendar(); -calendar.Events.Add(calendarEvent); - -// Serialize Calendar to string -var calendarSerializer = new CalendarSerializer(); -var calendarAsIcs = calendarSerializer.SerializeToString(calendar); -``` -This results in the following Ics string. -> [!NOTE] -> Have a look at the `EXDATE` property which defines our removed occurrences. -```ics -BEGIN:VCALENDAR -BEGIN:VEVENT -DTEND:20250710T201500Z -DTSTART:20250710T200000Z -EXDATE:20250710T220000Z -RRULE:FREQ=HOURLY;UNTIL=20250711T000000Z -END:VEVENT -END:VCALENDAR -``` -Now let's get all occurrences of this series. -```cs -var occurrences = calendar.GetOccurrences(); -``` -This will give us two occurrences with the following start and end dates. -```ics -DTEND:20250710T201500Z -DTSTART:20250710T200000Z - -DTEND:20250710T211500Z -DTSTART:20250710T210000Z - -DTEND:20250710T231500Z -DTSTART:20250710T230000Z - -DTEND:20250711T001500Z -DTSTART:20250711T000000Z -``` - -### Daily, Interval, Count, Exception (moved occurrence) -Suppose we decided to go for a walk every other day at 09:00 (4 times) - but not the third occurrence, we have a packed schedule then and can only go for 13 minutes at 13:00. - -Since start, end, duration and title of the event are changing, we have to create that new event and tell the series to replace an occurrence of the series with this 'special' event (child event). We do this by linking the child event to the series by using the same `UID`. We also have to link the child event to the original occurrence of the series by adding the original occurrence start-date in the `RCURRENCE-ID` property. - -> [!NOTE] Link moved events -> 1. Create a `CalendarEvent` with the changes (child event). -> 2. Link the child event to the series by using the same `Uid` (`UID`). -> 3. Link the child event to the original occurrence date by using the `RecurrenceId` (`RECURRENCE-ID`). - -```cs -// Create the CalendarEvent -var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); -var recurrence = new RecurrencePattern() -{ - Frequency = FrequencyType.Daily, - Interval = 2, - Count = 4 -}; - -var calendarEvent = new CalendarEvent() -{ - // UID links master with child. - Uid = "my-custom-id", - Summary = "Walking", - DtStart = start, - DtEnd = start.AddHours(1), - RecurrenceRules = [recurrence], -}; - -var startMoved = new CalDateTime(2025, 07, 13, 13, 00, 00, "Europe/Zurich"); -var movedEvent = new CalendarEvent() -{ - // UID links master with child. - Uid = "my-custom-id", - // Overwrite properties of the original occurrence. - Summary = "Short after lunch walk", - // Set new start and end time. - DtStart = startMoved, - DtEnd = startMoved.AddMinutes(13), - // Set the original date of the occurrence (2025-07-14 09:00:00). - RecurrenceId = start.AddDays(4) -}; - -// Add CalendarEvent to Calendar -var calendar = new Calendar(); -calendar.Events.Add(calendarEvent); -calendar.Events.Add(movedEvent); - -// Serialize Calendar to string -var calendarSerializer = new CalendarSerializer(); -var calendarAsIcs = calendarSerializer.SerializeToString(calendar); -``` -This results in the following Ics string. -> [!NOTE] -> Have a look at the `SUMMARY` property which we have overridden. -```ics -BEGIN:VCALENDAR -BEGIN:VEVENT -DTEND;TZID=Europe/Zurich:20250710T100000 -DTSTART;TZID=Europe/Zurich:20250710T090000 -RRULE:FREQ=DAILY;INTERVAL=2;COUNT=4 -SUMMARY:Walking -UID:my-custom-id -END:VEVENT -BEGIN:VEVENT -DTEND;TZID=Europe/Zurich:20250713T131300 -DTSTART;TZID=Europe/Zurich:20250713T130000 -RECURRENCE-ID;TZID=Europe/Zurich:20250714T090000 -SUMMARY:Short after lunch walk -UID:my-custom-id -END:VEVENT -END:VCALENDAR -``` -Now let's get all occurrences of this series. -```cs -var occurrences = calendar.GetOccurrences(); -``` -This will give us two occurrences with the following start and end dates. -```ics -DTEND;TZID=Europe/Zurich:20250710T100000 -DTSTART;TZID=Europe/Zurich:20250710T090000 - -DTEND;TZID=Europe/Zurich:20250712T100000 -DTSTART;TZID=Europe/Zurich:20250712T090000 - -DTEND;TZID=Europe/Zurich:20250713T131300 -DTSTART;TZID=Europe/Zurich:20250713T130000 - -DTEND;TZID=Europe/Zurich:20250716T100000 -DTSTART;TZID=Europe/Zurich:20250716T090000 -``` - -## FAQ (Recurrence) - -### Can I add multiple recurrence rules? -Yes, you can, even it's not shown in the examples above. - -### What about negative recurrrence rules? -You can use them with the `ExceptionRules` property on a e.g. `CalendarEvent`. There are two facts to consider on using: -1. It is marked as `deprecated` in [RFC 5545 (iCalendar)](https://www.rfc-editor.org/rfc/rfc5545#section-8.3.2) and maybe other software may not read this rule(s). -2. They are hard to understand by humans (as multiple rules are in general). diff --git a/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs index 743f1391..3db24126 100644 --- a/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs +++ b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs @@ -12,58 +12,114 @@ using Ical.Net.DataTypes; using Ical.Net.Serialization; using Ical.Net.Tests.Logging; +using Microsoft.Extensions.Logging; using NUnit.Framework; namespace Ical.Net.Tests.WikiSamples; +#pragma warning disable IDE0007 -[TestFixture, Category("Recurrence")] -public class RecurrenceWikiTests +[TestFixture, Category("Wiki")] +internal class RecurrenceWikiTests { + private TestLoggingManager _loggingManager = null!; + private ILoggerFactory _loggerFactory = null!; + private ILogger _logger; + + [OneTimeSetUp] + public void Setup() + { + // Enable logging of occurrences for test output comparison. + _loggingManager = new TestLoggingManager( + new Options + { + DebugModeOnly = false, + MinLogLevel = LogLevel.Debug, + Filters = [new Filter(LogLevel.Debug, GetType().FullName!)], + OutputTemplate = "{Message:lj}{NewLine}---{NewLine}", + // Output for easy copy-paste to wiki. + LogToConsole = true + }); + + _loggerFactory = _loggingManager.TestFactory; + _logger = _loggerFactory.CreateLogger(); + } + + [OneTimeTearDown] + public void TearDown() + { + _loggingManager.Dispose(); + _loggerFactory.Dispose(); + } + [Test] public void Introduction() { - var recurrence = new RecurrencePattern() + // Wiki code start + + var recurrence = new RecurrencePattern { Frequency = FrequencyType.Daily, Interval = 2, - Count = 30 - // Add other parameters like ByDay, ByMonth, etc. + Count = 5 + // Add other properties like ByDay, ByMonth, etc. }; - var calendarEvent = new CalendarEvent() + var calendarEvent = new CalendarEvent { DtStart = new CalDateTime(2025, 07, 10), - DtEnd = new CalDateTime(2025, 07, 11), // Add the rule to the event. RecurrenceRules = [recurrence] }; // Get all occurrences of the series. IEnumerable allOccurrences = calendarEvent.GetOccurrences(); - Assert.That(allOccurrences.Count(), Is.EqualTo(30)); + Assert.That(allOccurrences.Count(), Is.EqualTo(5)); + + // Wiki code end + + const string expectedOccurrences = + """ + 5 occurrences: + Start: 07/10/2025 + Period: P1D + End: 07/11/2025 + Start: 07/12/2025 + Period: P1D + End: 07/13/2025 + Start: 07/14/2025 + Period: P1D + End: 07/15/2025 + Start: 07/16/2025 + Period: P1D + End: 07/17/2025 + Start: 07/18/2025 + Period: P1D + End: 07/19/2025 + """; - // Get the occurrences in July. - IEnumerable julyOccurrences = calendarEvent - .GetOccurrences(new CalDateTime(2025, 07, 01)) - .TakeWhileBefore(new CalDateTime(2025, 08, 01)); - Assert.That(julyOccurrences.Count(), Is.EqualTo(11)); + var generatedOccurrences = ToWikiPeriodString(allOccurrences); + Assert.That(generatedOccurrences, Is.EqualTo(expectedOccurrences)); + + _logger.LogDebug(expectedOccurrences); } [Test] public void DailyIntervalCount() { - // Pattern: Daily every second day, tow times. + // Wiki code start + + // Pattern: Daily every second day, two times. // Create the CalendarEvent var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); - var recurrence = new RecurrencePattern() + var recurrence = new RecurrencePattern { Frequency = FrequencyType.Daily, Interval = 2, Count = 2 }; - var calendarEvent = new CalendarEvent() + var calendarEvent = new CalendarEvent { DtStart = start, DtEnd = start.AddHours(1), @@ -76,15 +132,17 @@ public void DailyIntervalCount() // Serialize Calendar to string var calendarSerializer = new CalendarSerializer(); - var calendarAsIcs = calendarSerializer.SerializeToString(calendar); - Assert.That(calendarAsIcs, Is.Not.Null); + var generatedIcs = calendarSerializer.SerializeToString(calendar); // Calculate all occurrences IEnumerable occurrences = calendar.GetOccurrences(); Assert.That(occurrences.Count(), Is.EqualTo(2)); - // Calendar output - var expectedIcsCalendar = """ + // Wiki code end + + // Calendar output (irrelevant properties are excluded) + const string expectedIcs = + """ BEGIN:VCALENDAR BEGIN:VEVENT DTEND;TZID=Europe/Zurich:20250710T100000 @@ -93,21 +151,26 @@ public void DailyIntervalCount() END:VEVENT END:VCALENDAR """; - // Occurring dates - var expectedOccurrenceDates = """ - Occurrences: - Start: 07/10/2025 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/10/2025 10:00:00 +02:00 Europe/Zurich - Start: 07/12/2025 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/12/2025 10:00:00 +02:00 Europe/Zurich - + // Occurrences + const string expectedOccurrences = + """ + 2 occurrences: + Start: 07/10/2025 09:00:00 +02:00 Europe/Zurich + Period: PT1H + End: 07/10/2025 10:00:00 +02:00 Europe/Zurich + Start: 07/12/2025 09:00:00 +02:00 Europe/Zurich + Period: PT1H + End: 07/12/2025 10:00:00 +02:00 Europe/Zurich """; + + // Non-Wiki Asserts + Assert.That(RemoveIrrelevantProperties(generatedIcs), Is.EqualTo(expectedIcs)); - // Asserts - Assert.That(calendarAsIcs, Is.Not.Null); - var calendarTestString = ToTestableCalendarString(calendarAsIcs); - Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + var generatedOccurrences = ToWikiPeriodString(occurrences); + Assert.That(generatedOccurrences, Is.EqualTo(expectedOccurrences)); - var occurrenceTestString = ToTestablePeriodString(occurrences); - Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + _logger.LogDebug(expectedIcs); + _logger.LogDebug(expectedOccurrences); } [Test] @@ -117,9 +180,11 @@ public void YearlyByMonthDayUntil() // Note: TimeZone of the until-date. // Note: Inclusive manner of the until-date. + // Wiki code start + // Create the CalendarEvent var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); - var recurrence = new RecurrencePattern() + var recurrence = new RecurrencePattern { Frequency = FrequencyType.Yearly, ByMonthDay = [10, 12], @@ -127,7 +192,7 @@ public void YearlyByMonthDayUntil() Until = start.AddYears(2).ToTimeZone("UTC") }; - var calendarEvent = new CalendarEvent() + var calendarEvent = new CalendarEvent { DtStart = start, DtEnd = start.AddHours(1), @@ -140,13 +205,16 @@ public void YearlyByMonthDayUntil() // Serialize Calendar to string var calendarSerializer = new CalendarSerializer(); - var calendarAsIcs = calendarSerializer.SerializeToString(calendar); + var generatedIcs = calendarSerializer.SerializeToString(calendar); // Calculate all occurrences IEnumerable occurrences = calendar.GetOccurrences(); - // Calendar output - var expectedIcsCalendar = """ + // Wiki code end + + // Calendar output (irrelevant properties are excluded) + const string expectedIcs = + """ BEGIN:VCALENDAR BEGIN:VEVENT DTEND;TZID=Europe/Zurich:20250710T100000 @@ -155,53 +223,64 @@ public void YearlyByMonthDayUntil() END:VEVENT END:VCALENDAR """; - // Occurring dates - var expectedOccurrenceDates = """ - Occurrences: - Start: 07/10/2025 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/10/2025 10:00:00 +02:00 Europe/Zurich - Start: 07/12/2025 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/12/2025 10:00:00 +02:00 Europe/Zurich - Start: 07/10/2026 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/10/2026 10:00:00 +02:00 Europe/Zurich - Start: 07/12/2026 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/12/2026 10:00:00 +02:00 Europe/Zurich - Start: 07/10/2027 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/10/2027 10:00:00 +02:00 Europe/Zurich - + // Occurrences + const string expectedOccurrences = + """ + 5 occurrences: + Start: 07/10/2025 09:00:00 +02:00 Europe/Zurich + Period: PT1H + End: 07/10/2025 10:00:00 +02:00 Europe/Zurich + Start: 07/12/2025 09:00:00 +02:00 Europe/Zurich + Period: PT1H + End: 07/12/2025 10:00:00 +02:00 Europe/Zurich + Start: 07/10/2026 09:00:00 +02:00 Europe/Zurich + Period: PT1H + End: 07/10/2026 10:00:00 +02:00 Europe/Zurich + Start: 07/12/2026 09:00:00 +02:00 Europe/Zurich + Period: PT1H + End: 07/12/2026 10:00:00 +02:00 Europe/Zurich + Start: 07/10/2027 09:00:00 +02:00 Europe/Zurich + Period: PT1H + End: 07/10/2027 10:00:00 +02:00 Europe/Zurich """; - // Asserts - Assert.That(calendarAsIcs, Is.Not.Null); - var calendarTestString = ToTestableCalendarString(calendarAsIcs); - Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + // Non-Wiki Asserts + + var calendarTestString = RemoveIrrelevantProperties(generatedIcs!); + Assert.That(RemoveIrrelevantProperties(calendarTestString), Is.EqualTo(expectedIcs)); - var occurrenceTestString = ToTestablePeriodString(occurrences); - Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + var generatedOccurrences = ToWikiPeriodString(occurrences); + Assert.That(generatedOccurrences, Is.EqualTo(expectedOccurrences)); + + _logger.LogDebug(expectedIcs); + _logger.LogDebug(expectedOccurrences); } [Test] public void MonthlyByDayCountRDate() { // Pattern: Monthly every last Sunday, for 3 times - plus July 10th. - // Note: RDATE takes a List of PeriodList. + + // Wiki code start // Create the CalendarEvent var start = new CalDateTime(2025, 06, 29, 16, 00, 00, "Europe/Zurich"); - var recurrence = new RecurrencePattern() + var recurrence = new RecurrencePattern { Frequency = FrequencyType.Monthly, ByDay = [new(DayOfWeek.Sunday, FrequencyOccurrence.Last)], - Count = 3, + Count = 3 }; - // Create additional occurrence. - PeriodList periodList = new PeriodList(); - periodList.Add(new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich")); - - var calendarEvent = new CalendarEvent() + var calendarEvent = new CalendarEvent { DtStart = start, DtEnd = start.AddHours(4), RecurrenceRules = [recurrence], - // Add the additional occurrence to the series. - RecurrenceDatesPeriodLists = [periodList] }; + // Add additional an occurrence to the series. + calendarEvent.RecurrenceDates + .Add(new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich")); // Add CalendarEvent to Calendar var calendar = new Calendar(); @@ -209,13 +288,16 @@ public void MonthlyByDayCountRDate() // Serialize Calendar to string var calendarSerializer = new CalendarSerializer(); - var calendarAsIcs = calendarSerializer.SerializeToString(calendar); + var generatedIcs = calendarSerializer.SerializeToString(calendar); // Calculate all occurrences IEnumerable occurrences = calendar.GetOccurrences(); - // Calendar output - var expectedIcsCalendar = """ + // Wiki code end + + // Calendar output (irrelevant properties are excluded) + const string expectedIcs = + """ BEGIN:VCALENDAR BEGIN:VEVENT DTEND;TZID=Europe/Zurich:20250629T200000 @@ -225,51 +307,58 @@ public void MonthlyByDayCountRDate() END:VEVENT END:VCALENDAR """; - // Occurring dates - var expectedOccurrenceDates = """ - Occurrences: - Start: 06/29/2025 16:00:00 +02:00 Europe/Zurich Period: PT4H End: 06/29/2025 20:00:00 +02:00 Europe/Zurich - Start: 07/10/2025 09:00:00 +02:00 Europe/Zurich Period: PT4H End: 07/10/2025 13:00:00 +02:00 Europe/Zurich - Start: 07/27/2025 16:00:00 +02:00 Europe/Zurich Period: PT4H End: 07/27/2025 20:00:00 +02:00 Europe/Zurich - Start: 08/31/2025 16:00:00 +02:00 Europe/Zurich Period: PT4H End: 08/31/2025 20:00:00 +02:00 Europe/Zurich - + // Occurrences + const string expectedOccurrences = + """ + 4 occurrences: + Start: 06/29/2025 16:00:00 +02:00 Europe/Zurich + Period: PT4H + End: 06/29/2025 20:00:00 +02:00 Europe/Zurich + Start: 07/10/2025 09:00:00 +02:00 Europe/Zurich + Period: PT4H + End: 07/10/2025 13:00:00 +02:00 Europe/Zurich + Start: 07/27/2025 16:00:00 +02:00 Europe/Zurich + Period: PT4H + End: 07/27/2025 20:00:00 +02:00 Europe/Zurich + Start: 08/31/2025 16:00:00 +02:00 Europe/Zurich + Period: PT4H + End: 08/31/2025 20:00:00 +02:00 Europe/Zurich """; - // Asserts - Assert.That(calendarAsIcs, Is.Not.Null); - var calendarTestString = ToTestableCalendarString(calendarAsIcs); - Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + // Non-Wiki Asserts + Assert.That(RemoveIrrelevantProperties(generatedIcs!), Is.EqualTo(expectedIcs)); + + var generatedOccurrences = ToWikiPeriodString(occurrences); + Assert.That(generatedOccurrences, Is.EqualTo(expectedOccurrences)); - var occurrenceTestString = ToTestablePeriodString(occurrences); - Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + _logger.LogDebug(expectedIcs); + _logger.LogDebug(expectedOccurrences); } [Test] public void HourlyUntilExDate() { // Pattern: Hourly every hour, until midnight (inclusive) - except 22:00. - // Note: EXDATE takes a List of PeriodList. + + // Wiki code start // Create the CalendarEvent var start = new CalDateTime(2025, 07, 10, 20, 00, 00, "UTC"); - var recurrence = new RecurrencePattern() + var recurrence = new RecurrencePattern { Frequency = FrequencyType.Hourly, Until = start.AddHours(4) }; - // Create exception for an occurrence. - PeriodList periodList = new PeriodList(); - periodList.Add(new CalDateTime(2025, 07, 10, 22, 00, 00, "UTC")); - - var calendarEvent = new CalendarEvent() + var calendarEvent = new CalendarEvent { DtStart = start, DtEnd = start.AddMinutes(15), RecurrenceRules = [recurrence], - // Add the exception date(s) to the series. - ExceptionDatesPeriodLists = [periodList] }; + // Add the exception date to the series. + calendarEvent.ExceptionDates + .Add(new CalDateTime(2025, 07, 10, 22, 00, 00, "UTC")); // Add CalendarEvent to Calendar var calendar = new Calendar(); @@ -277,13 +366,16 @@ public void HourlyUntilExDate() // Serialize Calendar to string var calendarSerializer = new CalendarSerializer(); - var calendarAsIcs = calendarSerializer.SerializeToString(calendar); + var generatedIcs = calendarSerializer.SerializeToString(calendar); // Calculate all occurrences IEnumerable occurrences = calendar.GetOccurrences(); - // Calendar output - var expectedIcsCalendar = """ + // Wiki code end + + // Calendar output (irrelevant properties are excluded) + const string expectedIcs = + """ BEGIN:VCALENDAR BEGIN:VEVENT DTEND:20250710T201500Z @@ -293,23 +385,32 @@ public void HourlyUntilExDate() END:VEVENT END:VCALENDAR """; - // Occurring dates - var expectedOccurrenceDates = """ - Occurrences: - Start: 07/10/2025 20:00:00 +00:00 UTC Period: PT15M End: 07/10/2025 20:15:00 +00:00 UTC - Start: 07/10/2025 21:00:00 +00:00 UTC Period: PT15M End: 07/10/2025 21:15:00 +00:00 UTC - Start: 07/10/2025 23:00:00 +00:00 UTC Period: PT15M End: 07/10/2025 23:15:00 +00:00 UTC - Start: 07/11/2025 00:00:00 +00:00 UTC Period: PT15M End: 07/11/2025 00:15:00 +00:00 UTC - + // Occurrences + const string expectedOccurrences = + """ + 4 occurrences: + Start: 07/10/2025 20:00:00 +00:00 UTC + Period: PT15M + End: 07/10/2025 20:15:00 +00:00 UTC + Start: 07/10/2025 21:00:00 +00:00 UTC + Period: PT15M + End: 07/10/2025 21:15:00 +00:00 UTC + Start: 07/10/2025 23:00:00 +00:00 UTC + Period: PT15M + End: 07/10/2025 23:15:00 +00:00 UTC + Start: 07/11/2025 00:00:00 +00:00 UTC + Period: PT15M + End: 07/11/2025 00:15:00 +00:00 UTC """; - // Asserts - Assert.That(calendarAsIcs, Is.Not.Null); - var calendarTestString = ToTestableCalendarString(calendarAsIcs); - Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + // Non-Wiki Asserts + Assert.That(RemoveIrrelevantProperties(generatedIcs!), Is.EqualTo(expectedIcs)); - var occurrenceTestString = ToTestablePeriodString(occurrences); - Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + var generatedOccurrences = ToWikiPeriodString(occurrences); + Assert.That(generatedOccurrences, Is.EqualTo(expectedOccurrences)); + + _logger.LogDebug(expectedOccurrences); + _logger.LogDebug(expectedOccurrences); } [Test] @@ -317,17 +418,20 @@ public void DailyIntervalCountMoved() { // Pattern: Daily every second day, four times - third is moved. // Note: Link moved events with series-master by same UID. + // Note: For chained events with RECURRENCE-ID, SEQUENCE should be set. + + // Wiki code start // Create the CalendarEvent var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); - var recurrence = new RecurrencePattern() + var recurrence = new RecurrencePattern { Frequency = FrequencyType.Daily, Interval = 2, Count = 4 }; - var calendarEvent = new CalendarEvent() + var calendarEvent = new CalendarEvent { // UID links master with child. Uid = "my-custom-id", @@ -335,10 +439,11 @@ public void DailyIntervalCountMoved() DtStart = start, DtEnd = start.AddHours(1), RecurrenceRules = [recurrence], + Sequence = 0 // default value }; var startMoved = new CalDateTime(2025, 07, 13, 13, 00, 00, "Europe/Zurich"); - var movedEvent = new CalendarEvent() + var movedEvent = new CalendarEvent { // UID links master with child. Uid = "my-custom-id", @@ -348,7 +453,9 @@ public void DailyIntervalCountMoved() DtStart = startMoved, DtEnd = startMoved.AddMinutes(13), // Set the original date of the occurrence (2025-07-14 09:00:00). - RecurrenceId = start.AddDays(4) + RecurrenceId = start.AddDays(4), + // The first change for this RecurrenceId + Sequence = 1 }; // Add CalendarEvent to Calendar @@ -358,18 +465,22 @@ public void DailyIntervalCountMoved() // Serialize Calendar to string var calendarSerializer = new CalendarSerializer(); - var calendarAsIcs = calendarSerializer.SerializeToString(calendar); + var generatedIcs = calendarSerializer.SerializeToString(calendar); // Calculate all occurrences IEnumerable occurrences = calendar.GetOccurrences(); - // Calendar output - var expectedIcsCalendar = """ + // Wiki code end + + // Calendar output (irrelevant properties are excluded) + const string expectedIcs = + """ BEGIN:VCALENDAR BEGIN:VEVENT DTEND;TZID=Europe/Zurich:20250710T100000 DTSTART;TZID=Europe/Zurich:20250710T090000 RRULE:FREQ=DAILY;INTERVAL=2;COUNT=4 + SEQUENCE:0 SUMMARY:Walking UID:my-custom-id END:VEVENT @@ -377,41 +488,172 @@ public void DailyIntervalCountMoved() DTEND;TZID=Europe/Zurich:20250713T131300 DTSTART;TZID=Europe/Zurich:20250713T130000 RECURRENCE-ID;TZID=Europe/Zurich:20250714T090000 + SEQUENCE:1 SUMMARY:Short after lunch walk UID:my-custom-id END:VEVENT END:VCALENDAR """; - // Occurring dates - var expectedOccurrenceDates = """ - Occurrences: - Start: 07/10/2025 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/10/2025 10:00:00 +02:00 Europe/Zurich - Start: 07/12/2025 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/12/2025 10:00:00 +02:00 Europe/Zurich - Start: 07/13/2025 13:00:00 +02:00 Europe/Zurich Period: PT13M End: 07/13/2025 13:13:00 +02:00 Europe/Zurich - Start: 07/16/2025 09:00:00 +02:00 Europe/Zurich Period: PT1H End: 07/16/2025 10:00:00 +02:00 Europe/Zurich - + // Occurrences + const string expectedOccurrences = + """ + 4 occurrences: + Start: 07/10/2025 09:00:00 +02:00 Europe/Zurich + Period: PT1H + End: 07/10/2025 10:00:00 +02:00 Europe/Zurich + Start: 07/12/2025 09:00:00 +02:00 Europe/Zurich + Period: PT1H + End: 07/12/2025 10:00:00 +02:00 Europe/Zurich + Start: 07/13/2025 13:00:00 +02:00 Europe/Zurich + Period: PT13M + End: 07/13/2025 13:13:00 +02:00 Europe/Zurich + Start: 07/16/2025 09:00:00 +02:00 Europe/Zurich + Period: PT1H + End: 07/16/2025 10:00:00 +02:00 Europe/Zurich """; + + // Non-Wiki Asserts + Assert.That(RemoveIrrelevantProperties(generatedIcs!, ["UID", "SEQUENCE"]), Is.EqualTo(expectedIcs)); - // Asserts - Assert.That(calendarAsIcs, Is.Not.Null); - var calendarTestString = ToTestableCalendarString(calendarAsIcs, allowUid: true); - Assert.That(calendarTestString, Is.EqualTo(expectedIcsCalendar)); + var generatedOccurrences = ToWikiPeriodString(occurrences); + Assert.That(generatedOccurrences, Is.EqualTo(expectedOccurrences)); - var occurrenceTestString = ToTestablePeriodString(occurrences); - Assert.That(occurrenceTestString, Is.EqualTo(expectedOccurrenceDates)); + _logger.LogDebug(expectedIcs); + _logger.LogDebug(expectedOccurrences); } - private static string ToTestableCalendarString(string calendarAsIcs, bool allowUid = false) - => calendarAsIcs + [Test] + public void RecurrenceWithTimeZoneChanges() + { + // Pattern: Recurrence weekly on Mondays, three times - before, on, and after DST change. + + // Wiki code start + + // Create the CalendarEvent + var start = new CalDateTime(2025, 03, 24, 09, 00, 00, "Europe/Zurich"); // Before DST starts + var recurrence = new RecurrencePattern + { + Frequency = FrequencyType.Weekly, + Count = 3 // Three Mondays: before, on, and after DST change + }; + + var calendarEvent = new CalendarEvent + { + DtStart = start, + DtEnd = start.AddHours(1), + RecurrenceRules = [recurrence] + }; + + // Add CalendarEvent to Calendar + var calendar = new Calendar(); + calendar.Events.Add(calendarEvent); + + // Serialize Calendar to string + var calendarSerializer = new CalendarSerializer(); + var generatedIcs = calendarSerializer.SerializeToString(calendar); + + // Calculate all occurrences + IEnumerable occurrences = calendar.GetOccurrences(); + + // Wiki code end + + // Calendar output (irrelevant properties are excluded) + const string expectedIcs = + """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTEND;TZID=Europe/Zurich:20250324T100000 + DTSTART;TZID=Europe/Zurich:20250324T090000 + RRULE:FREQ=WEEKLY;COUNT=3 + END:VEVENT + END:VCALENDAR + """; + // Occurrences + const string expectedOccurrences = + """ + 3 occurrences: + Start: 03/24/2025 09:00:00 +01:00 Europe/Zurich + Period: PT1H + End: 03/24/2025 10:00:00 +01:00 Europe/Zurich + Start: 03/31/2025 09:00:00 +02:00 Europe/Zurich + Period: PT1H + End: 03/31/2025 10:00:00 +02:00 Europe/Zurich + Start: 04/07/2025 09:00:00 +02:00 Europe/Zurich + Period: PT1H + End: 04/07/2025 10:00:00 +02:00 Europe/Zurich + """; + + // Non-Wiki Asserts + Assert.That(RemoveIrrelevantProperties(generatedIcs!), Is.EqualTo(expectedIcs)); + + var generatedOccurrences = ToWikiPeriodString(occurrences); + Assert.That(generatedOccurrences, Is.EqualTo(expectedOccurrences)); + + _logger.LogDebug(expectedIcs); + _logger.LogDebug(expectedOccurrences); + } + + [Test] + public void MoreRecurrenceRuleExamples() + { + // Every other Tuesday until the end of the year + var rrule1 = new RecurrencePattern(FrequencyType.Weekly, 2) + { + Until = new CalDateTime(2026, 1, 1) + }; + + // The 2nd day of every month for 5 occurrences + var rrule2 = new RecurrencePattern(FrequencyType.Monthly) + { + ByMonthDay = [2], // Your day of the month goes here + Count = 5 + }; + + // The 4th Thursday of November every year + var rrule3 = new RecurrencePattern(FrequencyType.Yearly, 1) + { + Frequency = FrequencyType.Yearly, + Interval = 1, + ByMonth = [11], + ByDay = [new WeekDay { DayOfWeek = DayOfWeek.Thursday, Offset = 4 }], + }; + + // Every day in 2025, except Sundays + var rrule4 = new RecurrencePattern(FrequencyType.Daily) + { + // Start: 2025-01-01, End: 2025-12-31 + Until = new CalDateTime(2025, 12, 31), + // Exclude Sundays + ByDay = [ + new WeekDay(DayOfWeek.Monday), + new WeekDay(DayOfWeek.Tuesday), + new WeekDay(DayOfWeek.Wednesday), + new WeekDay(DayOfWeek.Thursday), + new WeekDay(DayOfWeek.Friday), + new WeekDay(DayOfWeek.Saturday) + ] + }; + + Assert.That(() => + { + _ = new CalendarEvent + { + ExceptionRules = [rrule1, rrule2, rrule3, rrule4] + }; + }, Throws.Nothing); + } + + private static string RemoveIrrelevantProperties(string generatedIcs, string[]? keep = null) + => generatedIcs .Split('\n') .Select(e => e.Replace("\r", "")) .Where(e => !e.StartsWith("PRODID")) - .Where(e => !e.StartsWith("DTSTAMP")) - .Where(e => allowUid || !e.StartsWith("UID")) - .Where(e => !e.StartsWith("SEQUENCE")) .Where(e => !e.StartsWith("VERSION")) + .Where(e => !e.StartsWith("DTSTAMP")) + .Where(e => !(e.StartsWith("UID") && keep?.Contains("UID") != true)) + .Where(e => !(e.StartsWith("SEQUENCE") && keep?.Contains("SEQUENCE") != true)) .Aggregate(new StringBuilder(), (acc, e) => acc.AppendLine(e), e => e.ToString().TrimEnd()); - private static string ToTestablePeriodString(IEnumerable occurrences) + private static string ToWikiPeriodString(IEnumerable occurrences) => occurrences.ToLog(); }