Skip to content

Commit 71d0424

Browse files
authored
Don't skip anchors with strict matching in Calendar.RecurrenceRule. Resolve #881 (#1000)
In recurrence rules that expand days or weekdays in a month, we first use a base recurrence to calculate "anchors" in the month, and then change the day of month or weekday to find results. Because the base recurrence used to match the day of month of the start date, we could miss anchors if matching was set to `.strict`. This change makes sure that if we know that the day of month is known to change, we reset it to 1 in the base recurrence. Likewise, we reset the month in case of leap month.
1 parent a92bc21 commit 71d0424

File tree

2 files changed

+99
-3
lines changed

2 files changed

+99
-3
lines changed

Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,12 +230,42 @@ extension Calendar {
230230
case .monthly: [.second, .minute, .hour, .day]
231231
case .yearly: [.second, .minute, .hour, .day, .month, .isLeapMonth]
232232
}
233-
let componentsForEnumerating = recurrence.calendar._dateComponents(components, from: start)
233+
var componentsForEnumerating = recurrence.calendar._dateComponents(components, from: start)
234234

235-
let rangeForBaseRecurrence: Range<Date>? = nil
235+
let expansionChangesDay = dayOfYearAction == .expand || dayOfMonthAction == .expand || weekAction == .expand || weekdayAction == .expand
236+
let expansionChangesMonth = dayOfYearAction == .expand || monthAction == .expand || weekAction == .expand
237+
238+
if expansionChangesDay, componentsForEnumerating.day != nil {
239+
// If we expand either the day of the month or weekday, then
240+
// the day of month is likely to not match that of the start
241+
// date. Reset it to 1 in the base recurrence as to not skip
242+
// "invalid" anchors, such as February 30
243+
componentsForEnumerating.day = 1
244+
}
245+
if expansionChangesMonth, componentsForEnumerating.month != nil {
246+
// Likewise, if we will be changing the month, reset it to 1
247+
// in case the start date falls on a leap month
248+
componentsForEnumerating.month = 1
249+
componentsForEnumerating.isLeapMonth = nil
250+
}
251+
if expansionChangesDay || expansionChangesMonth, weekAction == .expand, weekdayAction != .expand {
252+
// If we are expanding weeks, all expansions in a given year
253+
// will have the same weekday. Above we have reset the month
254+
// or the day of the month, so we also changed that weekday.
255+
256+
// To specify a yearly recurrence which starts from the same
257+
// weekday, and which doesn't start from a leap day / month,
258+
// simply use `dayOfYear` of the start date
259+
componentsForEnumerating.day = nil
260+
componentsForEnumerating.month = nil
261+
componentsForEnumerating.isLeapMonth = nil
262+
let daysInWeek = recurrence.calendar.maximumRange(of: .weekday)!.count
263+
componentsForEnumerating.dayOfYear = recurrence.calendar.component(.dayOfYear, from: start) % daysInWeek // mod 7 to get the same weekday in the beginning of the year, so it's guaranteed to always exist
264+
}
265+
236266
baseRecurrence = Calendar.DatesByMatching(calendar: recurrence.calendar,
237267
start: start,
238-
range: rangeForBaseRecurrence,
268+
range: nil,
239269
matchingComponents: componentsForEnumerating,
240270
matchingPolicy: recurrence.matchingPolicy,
241271
repeatedTimePolicy: recurrence.repeatedTimePolicy,
@@ -335,6 +365,9 @@ extension Calendar {
335365
componentCombinations.weekdays = recurrence.weekdays
336366
componentCombinations.daysOfYear = nil
337367
componentCombinations.daysOfMonth = nil
368+
if recurrence.frequency == .yearly, monthAction != .expand {
369+
componentCombinations.months = nil
370+
}
338371
} else if recurrence.frequency == .weekly || weekAction == .expand {
339372
if let weekdayIdx = components.weekday, let weekday = Locale.Weekday(weekdayIdx) {
340373
// In a weekly recurrence (or one that expands weeks of year), we want results to fall on the same weekday as the initial date

Tests/FoundationEssentialsTests/GregorianCalendarRecurrenceRuleTests.swift

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,4 +676,67 @@ final class GregorianCalendarRecurrenceRuleTests: XCTestCase {
676676

677677
XCTAssertEqual(results, [])
678678
}
679+
680+
func testFirstMondaysStrictMatching() {
681+
let startDate = Date(timeIntervalSince1970: 1706659200.0) // 2024-01-31T00:00:00-0000
682+
683+
var rule = Calendar.RecurrenceRule(calendar: gregorian, frequency: .monthly, matchingPolicy: .strict)
684+
rule.weekdays = [.nth(1, .monday)]
685+
686+
var dates = rule.recurrences(of: startDate).makeIterator()
687+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1707091200.0)) // 2024-02-05T00:00:00-0000
688+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1709510400.0)) // 2024-03-04T00:00:00-0000
689+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1711929600.0)) // 2024-04-01T00:00:00-0000
690+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1714953600.0)) // 2024-05-06T00:00:00-0000
691+
}
692+
693+
func testFifthFridaysStrictMatching() {
694+
let startDate = Date(timeIntervalSince1970: 1706659200.0) // 2024-01-31T00:00:00-0000
695+
696+
var rule = Calendar.RecurrenceRule(calendar: gregorian, frequency: .monthly, matchingPolicy: .strict)
697+
rule.weekdays = [.nth(5, .friday)]
698+
699+
var dates = rule.recurrences(of: startDate).makeIterator()
700+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1711670400.0)) // 2024-03-29T00:00:00-0000
701+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1717113600.0)) // 2024-05-31T00:00:00-0000
702+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1724976000.0)) // 2024-08-30T00:00:00-0000
703+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1732838400.0)) // 2024-11-29T00:00:00-0000
704+
}
705+
706+
func testYearlyRecurrenceWeekdayExpansionStrictMatching() {
707+
let startDate = Date(timeIntervalSince1970: 1709164800.0) // 2024-02-29T00:00:00-0000
708+
709+
var rule = Calendar.RecurrenceRule(calendar: gregorian, frequency: .yearly, matchingPolicy: .strict)
710+
rule.weekdays = [.nth(5, .friday)]
711+
712+
var dates = rule.recurrences(of: startDate).makeIterator()
713+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1738281600.0)) // 2025-01-31T00:00:00-0000
714+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1769731200.0)) // 2026-01-30T00:00:00-0000
715+
}
716+
717+
func testYearlyRecurrenceDayOfYearExpansionStrictMatching() {
718+
let startDate = Date(timeIntervalSince1970: 1709164800.0) // 2024-02-29T00:00:00-0000
719+
720+
var rule = Calendar.RecurrenceRule(calendar: gregorian, frequency: .yearly, matchingPolicy: .strict)
721+
rule.daysOfTheYear = [61]
722+
723+
var dates = rule.recurrences(of: startDate).makeIterator()
724+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1709251200.0)) // 2024-03-01T00:00:00-0000
725+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1740873600.0)) // 2025-03-02T00:00:00-0000
726+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1772409600.0)) // 2026-03-02T00:00:00-0000
727+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1803945600.0)) // 2027-03-02T00:00:00-0000
728+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1835481600.0)) // 2028-03-01T00:00:00-0000
729+
}
730+
731+
func testYearlyRecurrenceWeekExpansionStrictMatching() {
732+
let startDate = Date(timeIntervalSince1970: 1709164800.0) // 2024-02-29T00:00:00-0000
733+
734+
var rule = Calendar.RecurrenceRule(calendar: gregorian, frequency: .yearly, matchingPolicy: .strict)
735+
rule.weeks = [2]
736+
737+
var dates = rule.recurrences(of: startDate).makeIterator()
738+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1736553600.0)) // 2025-01-11T00:00:00-0000
739+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1767484800.0)) // 2026-01-04T00:00:00-0000
740+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1799020800.0)) // 2027-01-04T00:00:00-0000
741+
}
679742
}

0 commit comments

Comments
 (0)