-
Notifications
You must be signed in to change notification settings - Fork 248
Fixes for RRULE:FREQ=YEARLY combined with BYMONTH
#884
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -162,10 +162,20 @@ private IEnumerable<CalDateTime> EnumerateDates(CalDateTime originalDate, CalDat | |
| var dateCount = 0; | ||
| while (true) | ||
| { | ||
| if (searchEndDate < GetIntervalLowerLimit(intervalRefTime, pattern)) | ||
| var lowerLimit = GetIntervalLowerLimit(intervalRefTime, pattern, originalDate); | ||
|
|
||
| if (searchEndDate < lowerLimit) | ||
| break; | ||
|
|
||
| var candidates = GetCandidates(intervalRefTime, pattern, expandBehavior); | ||
| // Use the original DTSTART month as the anchor for YEARLY rules when BYMONTH is not specified. | ||
| // This ensures BYMONTHDAY expansion/limiting is performed relative to the DTSTART month | ||
| // (the expected behavior for yearly rules without an explicit BYMONTH), instead of the | ||
| // current interval reference month. | ||
| var anchorMonth = pattern is { Frequency: FrequencyType.Yearly, ByMonth.Count: 0 } | ||
| ? originalDate.Month | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When changing this to return
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The anchorMonth was one of the ideas introduced in a very first draft. It was only partly helpful. When investigating further, anchorMonth became fully redundant - which I oversaw. Excellent finding, Please keep up these reviews. |
||
| : intervalRefTime.Month; | ||
|
|
||
| var candidates = GetCandidates(intervalRefTime, pattern, expandBehavior, anchorMonth); | ||
|
|
||
| foreach (var t in candidates.Where(t => t >= originalDate)) | ||
| { | ||
|
|
@@ -210,16 +220,77 @@ private IEnumerable<CalDateTime> EnumerateDates(CalDateTime originalDate, CalDat | |
| /// Find the lowest possible date/time for a recurrence in the given interval. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Usually intervalRefTime is either at DTSTART or later at the start of the interval. | ||
| /// However, for YEARLY recurrences with BYWEEKNO=1 there could be recurrences before | ||
| /// Jan 1st, so we need to adjust the intervalRefTime to the start of the week. | ||
| /// For most frequencies the interval's lower limit is simply the provided | ||
| /// <paramref name="intervalRefTime"/>. YEARLY rules require special handling: | ||
| /// - If BYMONTH is present and BYWEEKNO is not, an occurrence for the interval | ||
| /// might fall earlier in the year than the intervalRefTime's month/day. In | ||
| /// that case we compute the earliest possible date/time that could be | ||
| /// generated for the interval (earliest month/day/hour/minute/second). | ||
| /// - If BYWEEKNO is present, the interval may contain days from the previous | ||
| /// or next year (ISO week boundaries). In that case we adjust the interval | ||
| /// start to the first day of the configured week so we don't miss candidates | ||
| /// that belong to the week containing Jan 1st. | ||
| /// </remarks> | ||
| private static CalDateTime GetIntervalLowerLimit(CalDateTime intervalRefTime, RecurrencePattern pattern) | ||
| private static CalDateTime GetIntervalLowerLimit(CalDateTime intervalRefTime, RecurrencePattern pattern, CalDateTime originalDate) | ||
| { | ||
| var intervalLowerLimit = intervalRefTime; | ||
| if ((pattern.Frequency == FrequencyType.Yearly) && (pattern.ByWeekNo.Count != 0)) | ||
| intervalLowerLimit = GetFirstDayOfWeekDate(intervalRefTime, pattern.FirstDayOfWeek); | ||
| return intervalLowerLimit; | ||
| switch (pattern) | ||
| { | ||
| case { Frequency: FrequencyType.Yearly, ByMonth.Count: > 0, ByWeekNo.Count: 0 }: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel this whole method is not perfectly intuitive, maybe we should improve the naming or the description. I.e. if I remember correctly, it is there for a minor performance improvement, i.e. to avoid iterating an extra year at the end of the sequence where this can be avoided, maybe also to avoid running into a
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yes, agree, that's not clean. Covering special cases here is a risk. Not sure, which further special cases we can expect besides those just implemented. For the moment, they do their job. |
||
| { | ||
| // When evaluating a YEARLY rule that restricts months (BYMONTH) but not | ||
| // week numbers, it's possible that the earliest candidate inside the | ||
| // interval is in an earlier month/day than `intervalRefTime` (for example | ||
| // BYMONTH=1 while intervalRefTime is anchored on a later month). To avoid | ||
| // terminating enumeration prematurely (with a coarse UNTIL cutoff), compute | ||
| // the earliest plausible date/time for this interval and use that as the | ||
| // lower limit. | ||
| // | ||
| // We pick: | ||
| // - year = intervalRefTime.Year | ||
| // - month = smallest BYMONTH or the original DTSTART month if BYMONTH absent | ||
| // - day = smallest BYMONTHDAY (clamped to daysInMonth) or original DTSTART day | ||
| // - time components = smallest BYHOUR/BYMINUTE/BYSECOND or original DTSTART time | ||
| // | ||
| // This is a conservative earliest-possible candidate; it must not exclude | ||
| // any valid occurrence for the interval. | ||
| var year = intervalRefTime.Year; | ||
|
|
||
| // Determine the earliest month we could possibly generate for this interval. | ||
| var month = pattern.ByMonth.Min(); | ||
|
|
||
| // Determine an appropriate day in the month. | ||
| var daysInMonth = Calendar.GetDaysInMonth(year, month); | ||
| int day; | ||
| if (pattern.ByMonthDay.Count > 0) | ||
| { | ||
| var md = pattern.ByMonthDay.Min(); | ||
| day = md > 0 ? Math.Min(md, daysInMonth) : Math.Max(1, daysInMonth + md + 1); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably not correct if positive/negative values are mixed, e.g. |
||
| } | ||
| else | ||
| { | ||
| // default to original date's day, constrained to the target month length | ||
| day = Math.Min(originalDate.Day, daysInMonth); | ||
| } | ||
|
|
||
| // Determine earliest time components | ||
| var hour = pattern.ByHour.Count > 0 ? pattern.ByHour.Min() : originalDate.Hour; | ||
| var minute = pattern.ByMinute.Count > 0 ? pattern.ByMinute.Min() : originalDate.Minute; | ||
| var second = pattern.BySecond.Count > 0 ? pattern.BySecond.Min() : originalDate.Second; | ||
|
|
||
| return new CalDateTime(year, month, day, hour, minute, second, intervalRefTime.TzId); | ||
| } | ||
| case { Frequency: FrequencyType.Yearly, ByWeekNo.Count: not 0 }: | ||
| { | ||
| // YEARLY with BYWEEKNO: weeks may span year boundaries. Move the | ||
| // interval lower limit to the first day of the week so expansion over | ||
| // the week (including days before Jan 1st) is handled correctly. | ||
| return GetFirstDayOfWeekDate(intervalRefTime, pattern.FirstDayOfWeek); | ||
| } | ||
| default: | ||
| { | ||
| return intervalRefTime; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private struct ExpandContext | ||
|
|
@@ -241,15 +312,17 @@ private struct ExpandContext | |
| /// <param name="pattern"></param> | ||
| /// <param name="expandBehaviors"></param> | ||
| /// <returns>A list of possible dates.</returns> | ||
| private IEnumerable<CalDateTime> GetCandidates(CalDateTime date, RecurrencePattern pattern, bool?[] expandBehaviors) | ||
| private IEnumerable<CalDateTime> GetCandidates(CalDateTime date, RecurrencePattern pattern, bool?[] expandBehaviors, int anchorMonth) | ||
| { | ||
| var expandContext = new ExpandContext() { DatesFullyExpanded = false }; | ||
|
|
||
| IEnumerable<CalDateTime> dates = [ date ]; | ||
| IEnumerable<CalDateTime> dates = [date]; | ||
| dates = GetMonthVariants(dates, pattern, expandBehaviors[0]); | ||
| dates = GetWeekNoVariants(dates, pattern, expandBehaviors[1], ref expandContext); | ||
| dates = GetYearDayVariants(dates, pattern, expandBehaviors[2], ref expandContext); | ||
| dates = GetMonthDayVariants(dates, pattern, expandBehaviors[3], ref expandContext); | ||
| // Use the provided anchorMonth (typically the original DTSTART month) so BYMONTHDAY expansion | ||
| // is performed relative to the intended month when BYMONTH is not specified. | ||
| dates = GetMonthDayVariants(dates, pattern, expandBehaviors[3], ref expandContext, anchorMonth: anchorMonth); | ||
| dates = GetDayVariants(dates, pattern, expandBehaviors[4], ref expandContext); | ||
| dates = GetHourVariants(dates, pattern, expandBehaviors[5]); | ||
| dates = GetMinuteVariants(dates, pattern, expandBehaviors[6]); | ||
|
|
@@ -316,10 +389,8 @@ private static IEnumerable<CalDateTime> GetMonthVariants(IEnumerable<CalDateTime | |
| return dates.Where(date => pattern.ByMonth.Contains(date.Month) | ||
| || pattern.ByMonth.Contains(date.AddDays(6).Month)); | ||
| } | ||
| else | ||
| { | ||
| return dates.Where(date => pattern.ByMonth.Contains(date.Month)); | ||
| } | ||
|
|
||
| return dates.Where(date => pattern.ByMonth.Contains(date.Month)); | ||
| } | ||
|
|
||
| /// <summary> | ||
|
|
@@ -348,12 +419,12 @@ private static IEnumerable<CalDateTime> GetWeekNoVariants(IEnumerable<CalDateTim | |
|
|
||
| private static IEnumerable<CalDateTime> GetWeekNoVariantsExpanded(IEnumerable<CalDateTime> dates, RecurrencePattern pattern) | ||
| { | ||
| foreach ((var t, var weekNo) in dates.SelectMany(t => GetByWeekNoForYearNormalized(pattern, t.Year), (t, weekNo) => (t, weekNo))) | ||
| foreach (var (t, weekNo) in dates.SelectMany(t => GetByWeekNoForYearNormalized(pattern, t.Year), (t, weekNo) => (t, weekNo))) | ||
| { | ||
| var date = t; | ||
|
|
||
| // Make sure we start from a reference date that is in a week that belongs to the current year. | ||
| // Its not important that the date lies in a certain week, but that the week belongs to the | ||
| // It's not important that the date lies in a certain week, but that the week belongs to the | ||
| // current year and that the week day is preserved. | ||
| if (date.Month == 1) | ||
| date = date.AddDays(7); | ||
|
|
@@ -394,11 +465,30 @@ private static List<int> GetByWeekNoForYearNormalized(RecurrencePattern pattern, | |
| } | ||
|
|
||
| /// <summary> | ||
| /// Applies BYYEARDAY rules specified in this Recur instance to the specified date list. | ||
| /// If no BYYEARDAY rules are specified, the date list is returned unmodified. | ||
| /// Applies <c>BYYEARDAY</c> rules from <paramref name="pattern"/> to a sequence of candidate dates. | ||
| /// </summary> | ||
| /// <param name="dates">The list of dates to which the BYYEARDAY rules will be applied.</param> | ||
| /// <returns>The modified list of dates after applying the BYYEARDAY rules.</returns> | ||
| /// <param name="dates">Sequence of candidate dates (typically interval reference dates or previously-expanded dates).</param> | ||
| /// <param name="pattern">Recurrence pattern containing the <c>BYYEARDAY</c> values.</param> | ||
| /// <param name="expand"> | ||
| /// Controls the operation mode: | ||
| /// - <c>true</c> : perform an expand operation — each input date is expanded into the concrete dates | ||
| /// represented by the <c>BYYEARDAY</c> values for that date's year. | ||
| /// - <c>false</c> : perform a limit/filter operation — only return input dates that match any of the | ||
| /// <c>BYYEARDAY</c> values for the input date's year. | ||
| /// - <c>null</c> : no operation, return <paramref name="dates"/> unchanged. | ||
| /// </param> | ||
| /// <param name="expandContext"> | ||
| /// Context that indicates whether earlier parts have already fully expanded the candidate set. | ||
| /// If <see cref="ExpandContext.DatesFullyExpanded"/> is <c>true</c> then expansion must not be | ||
| /// performed again and the method should behave in limit mode. | ||
| /// When this method performs an expansion it will set <see cref="ExpandContext.DatesFullyExpanded"/> | ||
| /// to <c>true</c> to prevent later parts from expanding again. | ||
| /// </param> | ||
| /// <returns> | ||
| /// A sequence of dates after applying the <c>BYYEARDAY</c> rules. | ||
| /// Expanded dates produced for a given input are constrained to the same calendar year as the input date; | ||
| /// out-of-range <c>BYYEARDAY</c> values (e.g. +-366 in non-leap years) are ignored. | ||
| /// </returns> | ||
| private static IEnumerable<CalDateTime> GetYearDayVariants(IEnumerable<CalDateTime> dates, RecurrencePattern pattern, bool? expand, ref ExpandContext expandContext) | ||
| { | ||
| if (expand is null || pattern.ByYearDay.Count == 0) | ||
|
|
@@ -448,12 +538,11 @@ from yearDay in pattern.ByYearDay | |
| } | ||
|
|
||
| /// <summary> | ||
| /// Applies BYMONTHDAY rules specified in this Recur instance to the specified date list. | ||
| /// Applies BYMONTHDAY rules specified in this RecurrencePattern instance to the specified date list. | ||
| /// If no BYMONTHDAY rules are specified, the date list is returned unmodified. | ||
| /// </summary> | ||
| /// <param name="dates">The list of dates to which the BYMONTHDAY rules will be applied.</param> | ||
| /// <returns>The modified list of dates after applying the BYMONTHDAY rules.</returns> | ||
| private static IEnumerable<CalDateTime> GetMonthDayVariants(IEnumerable<CalDateTime> dates, RecurrencePattern pattern, bool? expand, ref ExpandContext expandContext) | ||
| private static IEnumerable<CalDateTime> GetMonthDayVariants(IEnumerable<CalDateTime> dates, RecurrencePattern pattern, bool? expand, ref ExpandContext expandContext, int anchorMonth) | ||
| { | ||
| if (expand == null || pattern.ByMonthDay.Count == 0) | ||
| return dates; | ||
|
|
@@ -465,22 +554,45 @@ private static IEnumerable<CalDateTime> GetMonthDayVariants(IEnumerable<CalDateT | |
| } | ||
|
|
||
| // limit behavior | ||
| return GetMonthDayVariantsLimited(dates, pattern); | ||
| return GetMonthDayVariantsLimited(dates, pattern, anchorMonth); | ||
| } | ||
|
|
||
| private static IEnumerable<CalDateTime> GetMonthDayVariantsLimited(IEnumerable<CalDateTime> dates, RecurrencePattern pattern) | ||
| private static IEnumerable<CalDateTime> GetMonthDayVariantsLimited(IEnumerable<CalDateTime> dates, RecurrencePattern pattern, int anchorMonth) | ||
| { | ||
| // Helper that checks whether the given candidate matches any BYMONTHDAY entry | ||
| // taking negative values into account (relative to the month's length). | ||
| static bool MatchesAnyMonthDay(CalDateTime candidate, IEnumerable<int> monthDays) | ||
| { | ||
| var daysInMonth = Calendar.GetDaysInMonth(candidate.Year, candidate.Month); | ||
| foreach (var monthDay in monthDays) | ||
| { | ||
| var byMonthDay = monthDay > 0 ? monthDay : (daysInMonth + monthDay + 1); | ||
| if (candidate.Day == byMonthDay) | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| foreach (var date in dates) | ||
| { | ||
| var daysInMonth = Calendar.GetDaysInMonth(date.Year, date.Month); | ||
| foreach (var monthDay in pattern.ByMonthDay) | ||
| if (pattern.ByMonth.Count > 0) | ||
| { | ||
| var byMonthDay = (monthDay > 0) ? monthDay : (daysInMonth + monthDay + 1); | ||
| if (date.Day == byMonthDay) | ||
| { | ||
| // If BYMONTH is specified, the date must be in one of those months | ||
| // and match a BYMONTHDAY value. | ||
| if (!pattern.ByMonth.Contains(date.Month)) | ||
| continue; | ||
|
|
||
| if (MatchesAnyMonthDay(date, pattern.ByMonthDay)) | ||
| yield return date; | ||
| } | ||
| else | ||
| { | ||
| // When BYMONTH is not specified, only consider dates in the anchor month. | ||
| if (date.Month != anchorMonth) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would be good to add some more context on why this was added. Not sure it is fully correct. Consider the following test case, which fails now (could be added to RRULE:FREQ=YEARLY;BYYEARDAY=-1,1;BYMONTHDAY=-1,1;COUNT=3
DTSTART:20250101
INSTANCES:20250101,20251231,20260101
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I consider the test correct. It worked before this commit. Sadly we didn't have the test before - so thanks a lot. |
||
| continue; | ||
|
|
||
| if (MatchesAnyMonthDay(date, pattern.ByMonthDay)) | ||
| yield return date; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe more precisely
So we preserve the weekday when using BYWEEKNO and preserve month/day otherwise?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this is more precise.