Skip to content

Commit 13b7685

Browse files
committed
Don't skip anchors with strict matching in Calendar.RecurrenceRule. Resolve #881
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.
1 parent 71eefee commit 13b7685

File tree

2 files changed

+41
-1
lines changed

2 files changed

+41
-1
lines changed

Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift

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

235235
let rangeForBaseRecurrence: Range<Date>? = nil
236+
var baseRecurrenceStartDate = start
237+
if componentsForEnumerating.day != nil, dayOfMonthAction == .expand || weekdayAction == .expand {
238+
// If we expand either the day of the month or weekday, then
239+
// the day of month is likely to not match that of the start
240+
// date. Reset it to 1 in the base recurrence as to not skip
241+
// "invalid" anchors, such as February 30
242+
componentsForEnumerating.day = 1
243+
}
244+
if componentsForEnumerating.month != nil, monthAction == .expand {
245+
// Likewise, if we will be changing the month, reset it to 1
246+
// in case the start date falls on a leap month
247+
componentsForEnumerating.month = 1
248+
componentsForEnumerating.isLeapMonth = nil
249+
}
236250
baseRecurrence = Calendar.DatesByMatching(calendar: recurrence.calendar,
237251
start: start,
238252
range: rangeForBaseRecurrence,

Tests/FoundationEssentialsTests/GregorianCalendarRecurrenceRuleTests.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,4 +676,30 @@ 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+
}
679705
}

0 commit comments

Comments
 (0)