Skip to content

Commit 3a4da11

Browse files
committed
Extend fix for yearly recurrences (start date might be on Feb 29), and add a fix for week expansion
1 parent 13b7685 commit 3a4da11

File tree

2 files changed

+69
-7
lines changed

2 files changed

+69
-7
lines changed

Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,24 +232,40 @@ extension Calendar {
232232
}
233233
var componentsForEnumerating = recurrence.calendar._dateComponents(components, from: start)
234234

235-
let rangeForBaseRecurrence: Range<Date>? = nil
236-
var baseRecurrenceStartDate = start
237-
if componentsForEnumerating.day != nil, dayOfMonthAction == .expand || weekdayAction == .expand {
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 {
238239
// If we expand either the day of the month or weekday, then
239240
// the day of month is likely to not match that of the start
240241
// date. Reset it to 1 in the base recurrence as to not skip
241242
// "invalid" anchors, such as February 30
242243
componentsForEnumerating.day = 1
243244
}
244-
if componentsForEnumerating.month != nil, monthAction == .expand {
245+
if expansionChangesMonth, componentsForEnumerating.month != nil {
245246
// Likewise, if we will be changing the month, reset it to 1
246247
// in case the start date falls on a leap month
247248
componentsForEnumerating.month = 1
248249
componentsForEnumerating.isLeapMonth = nil
249250
}
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+
250266
baseRecurrence = Calendar.DatesByMatching(calendar: recurrence.calendar,
251267
start: start,
252-
range: rangeForBaseRecurrence,
268+
range: nil,
253269
matchingComponents: componentsForEnumerating,
254270
matchingPolicy: recurrence.matchingPolicy,
255271
repeatedTimePolicy: recurrence.repeatedTimePolicy,
@@ -349,6 +365,9 @@ extension Calendar {
349365
componentCombinations.weekdays = recurrence.weekdays
350366
componentCombinations.daysOfYear = nil
351367
componentCombinations.daysOfMonth = nil
368+
if recurrence.frequency == .yearly, monthAction != .expand {
369+
componentCombinations.months = nil
370+
}
352371
} else if recurrence.frequency == .weekly || weekAction == .expand {
353372
if let weekdayIdx = components.weekday, let weekday = Locale.Weekday(weekdayIdx) {
354373
// In a weekly recurrence (or one that expands weeks of year), we want results to fall on the same weekday as the initial date
@@ -360,6 +379,11 @@ extension Calendar {
360379
if weekAction == .expand {
361380
// In a yearly recurrence with weeks specified, results do not land on any specific month
362381
componentCombinations.weeksOfYear = recurrence.weeks
382+
if componentCombinations.weekdays == nil {
383+
if let weekdayIdx = components.weekday, let weekday = Locale.Weekday(weekdayIdx) {
384+
componentCombinations.weekdays = [.every(weekday)]
385+
}
386+
}
363387
componentCombinations.months = nil
364388
}
365389
if recurrence.frequency != .hourly, recurrence.frequency != .minutely {

Tests/FoundationEssentialsTests/GregorianCalendarRecurrenceRuleTests.swift

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -692,14 +692,52 @@ final class GregorianCalendarRecurrenceRuleTests: XCTestCase {
692692

693693
func testFifthFridaysStrictMatching() {
694694
let startDate = Date(timeIntervalSince1970: 1706659200.0) // 2024-01-31T00:00:00-0000
695-
695+
696696
var rule = Calendar.RecurrenceRule(calendar: gregorian, frequency: .monthly, matchingPolicy: .strict)
697697
rule.weekdays = [.nth(5, .friday)]
698-
698+
699699
var dates = rule.recurrences(of: startDate).makeIterator()
700+
700701
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1711670400.0)) // 2024-03-29T00:00:00-0000
701702
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1717113600.0)) // 2024-05-31T00:00:00-0000
702703
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1724976000.0)) // 2024-08-30T00:00:00-0000
703704
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1732838400.0)) // 2024-11-29T00:00:00-0000
704705
}
706+
707+
func testYearlyRecurrenceWeekdayExpansionStrictMatching() {
708+
let startDate = Date(timeIntervalSince1970: 1709164800.0) // 2024-02-29T00:00:00-0000
709+
710+
var rule = Calendar.RecurrenceRule(calendar: gregorian, frequency: .yearly, matchingPolicy: .strict)
711+
rule.weekdays = [.nth(5, .friday)]
712+
713+
var dates = rule.recurrences(of: startDate).makeIterator()
714+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1738281600.0)) // 2025-01-31T00:00:00-0000
715+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1769731200.0)) // 2026-01-30T00:00:00-0000
716+
}
717+
718+
func testYearlyRecurrenceDayOfYearExpansionStrictMatching() {
719+
let startDate = Date(timeIntervalSince1970: 1709164800.0) // 2024-02-29T00:00:00-0000
720+
721+
var rule = Calendar.RecurrenceRule(calendar: gregorian, frequency: .yearly, matchingPolicy: .strict)
722+
rule.daysOfTheYear = [61]
723+
724+
var dates = rule.recurrences(of: startDate).makeIterator()
725+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1709251200.0)) // 2024-03-01T00:00:00-0000
726+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1740873600.0)) // 2025-03-02T00:00:00-0000
727+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1772409600.0)) // 2026-03-02T00:00:00-0000
728+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1803945600.0)) // 2027-03-02T00:00:00-0000
729+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1835481600.0)) // 2028-03-01T00:00:00-0000
730+
}
731+
732+
func testYearlyRecurrenceWeekExpansionStrictMatching() {
733+
let startDate = Date(timeIntervalSince1970: 1709164800.0) // 2024-02-29T00:00:00-0000
734+
735+
var rule = Calendar.RecurrenceRule(calendar: gregorian, frequency: .yearly, matchingPolicy: .strict)
736+
rule.weeks = [2]
737+
738+
var dates = rule.recurrences(of: startDate).makeIterator()
739+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1736553600.0)) // 2025-01-11T00:00:00-0000
740+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1767484800.0)) // 2026-01-04T00:00:00-0000
741+
XCTAssertEqual(dates.next(), Date(timeIntervalSince1970: 1799020800.0)) // 2027-01-04T00:00:00-0000
742+
}
705743
}

0 commit comments

Comments
 (0)