From 084549f35b6ae60dea9879b7923fbc0d54fbbee1 Mon Sep 17 00:00:00 2001 From: Hristo Staykov Date: Fri, 11 Oct 2024 16:15:52 -0700 Subject: [PATCH] Performance improvements for `Calendar.RecurrenceRule` The original implementation of `Calendar.RecurrenceRule` expanded recurrences of dates using the Calendar APIs for matching date components. This would result in multiple sequences for matching date components even when just a single sequence would have sufficed, thus requiring more time and memory to complete enumeration E.g: finding the dates for Thanksgivings (fourth Thursday of each November) took ~4 times as much time using RecurrenceRule when compared to simply matching date components. This commit optimizes how we expand dates for recurrences. Instead of creating a sequence for each value of each component in the recurrence rule, we introduce a new type of sequence closely resembling Calendar.DatesByMatching, but which also allows multiple values per date component. --- .../BenchmarkCalendar.swift | 45 +- .../Calendar/Calendar_Enumerate.swift | 62 +- .../Calendar/Calendar_Recurrence.swift | 703 ++++++++++-------- ...GregorianCalendarRecurrenceRuleTests.swift | 42 +- 4 files changed, 509 insertions(+), 343 deletions(-) diff --git a/Benchmarks/Benchmarks/Internationalization/BenchmarkCalendar.swift b/Benchmarks/Benchmarks/Internationalization/BenchmarkCalendar.swift index 83eb424a1..d611487e4 100644 --- a/Benchmarks/Benchmarks/Internationalization/BenchmarkCalendar.swift +++ b/Benchmarks/Benchmarks/Internationalization/BenchmarkCalendar.swift @@ -29,7 +29,7 @@ let benchmarks = { let thanksgivingComponents = DateComponents(month: 11, weekday: 5, weekdayOrdinal: 4) let cal = Calendar(identifier: .gregorian) let currentCalendar = Calendar.current - let thanksgivingStart = Date(timeIntervalSinceReferenceDate: 496359355.795410) //2016-09-23T14:35:55-0700 + let thanksgivingStart = Date(timeIntervalSince1970: 1474666555.0) //2016-09-23T14:35:55-0700 Benchmark("nextThousandThursdaysInTheFourthWeekOfNovember") { benchmark in // This benchmark used to be nextThousandThanksgivings, but the name was deceiving since it does not compute the next thousand thanksgivings @@ -54,7 +54,7 @@ let benchmarks = { } // Only available in Swift 6 for non-Darwin platforms, macOS 15 for Darwin - #if swift(>=6.0) + #if compiler(>=6.0) if #available(macOS 15, *) { Benchmark("nextThousandThanksgivingsSequence") { benchmark in var count = 1000 @@ -66,7 +66,7 @@ let benchmarks = { } } - Benchmark("nextThousandThanksgivingsUsingRecurrenceRule") { benchmark in + Benchmark("RecurrenceRuleThanksgivings") { benchmark in var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .yearly, end: .afterOccurrences(1000)) rule.months = [11] rule.weekdays = [.nth(4, .thursday)] @@ -77,6 +77,43 @@ let benchmarks = { } assert(count == 1000) } + Benchmark("RecurrenceRuleThanksgivingMeals") { benchmark in + var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .yearly, end: .afterOccurrences(1000)) + rule.months = [11] + rule.weekdays = [.nth(4, .thursday)] + rule.hours = [14, 18] + rule.matchingPolicy = .nextTime + for date in rule.recurrences(of: thanksgivingStart) { + Benchmark.blackHole(date) + } + } + Benchmark("RecurrenceRuleLaborDay") { benchmark in + var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .yearly, end: .afterOccurrences(1000)) + rule.months = [9] + rule.weekdays = [.nth(1, .monday)] + rule.matchingPolicy = .nextTime + for date in rule.recurrences(of: thanksgivingStart) { + Benchmark.blackHole(date) + } + } + Benchmark("RecurrenceRuleBikeParties") { benchmark in + var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .monthly, end: .afterOccurrences(1000)) + rule.weekdays = [.nth(1, .friday), .nth(-1, .friday)] + rule.matchingPolicy = .nextTime + for date in rule.recurrences(of: thanksgivingStart) { + Benchmark.blackHole(date) + } + } + Benchmark("RecurrenceRuleDailyWithTimes") { benchmark in + var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .daily, end: .afterOccurrences(1000)) + rule.hours = [9, 10] + rule.minutes = [0, 30] + rule.weekdays = [.every(.monday), .every(.tuesday), .every(.wednesday)] + rule.matchingPolicy = .nextTime + for date in rule.recurrences(of: thanksgivingStart) { + Benchmark.blackHole(date) + } + } } // #available(macOS 15, *) #endif // swift(>=6.0) @@ -93,7 +130,7 @@ let benchmarks = { // MARK: - Allocations - let reference = Date(timeIntervalSinceReferenceDate: 496359355.795410) //2016-09-23T14:35:55-0700 + let reference = Date(timeIntervalSince1970: 1474666555.0) //2016-09-23T14:35:55-0700 let allocationsConfiguration = Benchmark.Configuration( metrics: [.cpuTotal, .mallocCountTotal, .peakMemoryResident, .throughput], diff --git a/Sources/FoundationEssentials/Calendar/Calendar_Enumerate.swift b/Sources/FoundationEssentials/Calendar/Calendar_Enumerate.swift index d244d0532..52da07e4c 100644 --- a/Sources/FoundationEssentials/Calendar/Calendar_Enumerate.swift +++ b/Sources/FoundationEssentials/Calendar/Calendar_Enumerate.swift @@ -454,6 +454,7 @@ extension Calendar { } internal func _enumerateDates(startingAfter start: Date, + previouslyReturnedMatchDate: Date? = nil, matching matchingComponents: DateComponents, matchingPolicy: MatchingPolicy, repeatedTimePolicy: RepeatedTimePolicy, @@ -470,7 +471,7 @@ extension Calendar { let STOP_EXHAUSTIVE_SEARCH_AFTER_MAX_ITERATIONS = 100 var searchingDate = start - var previouslyReturnedMatchDate: Date? = nil + var previouslyReturnedMatchDate = previouslyReturnedMatchDate var iterations = -1 repeat { @@ -511,14 +512,8 @@ extension Calendar { matchingPolicy: MatchingPolicy, repeatedTimePolicy: RepeatedTimePolicy, direction: SearchDirection, - inSearchingDate: Date, + inSearchingDate searchingDate: Date, previouslyReturnedMatchDate: Date?) throws -> SearchStepResult { - var exactMatch = true - var isLeapDay = false - var searchingDate = inSearchingDate - - // NOTE: Several comments reference "isForwardDST" as a way to relate areas in forward DST handling. - var isForwardDST = false // Step A: Call helper method that does the searching @@ -539,8 +534,25 @@ extension Calendar { // TODO: Check if returning the same searchingDate has any purpose return SearchStepResult(result: nil, newSearchDate: searchingDate) } + + return try _adjustedDate(unadjustedMatchDate, startingAfter: start, matching: matchingComponents, adjustedMatchingComponents: compsToMatch , matchingPolicy: matchingPolicy, repeatedTimePolicy: repeatedTimePolicy, direction: direction, inSearchingDate: searchingDate, previouslyReturnedMatchDate: previouslyReturnedMatchDate) + } + + internal func _adjustedDate(_ unadjustedMatchDate: Date, startingAfter start: Date, + allowStartDate: Bool = false, + matching matchingComponents: DateComponents, + adjustedMatchingComponents compsToMatch: DateComponents, + matchingPolicy: MatchingPolicy, + repeatedTimePolicy: RepeatedTimePolicy, + direction: SearchDirection, + inSearchingDate: Date, + previouslyReturnedMatchDate: Date?) throws -> SearchStepResult { + var exactMatch = true + var isLeapDay = false + var searchingDate = inSearchingDate - // Step B: Couldn't find matching date with a quick and dirty search in the current era, year, etc. Now try in the near future/past and make adjustments for leap situations and non-existent dates + // NOTE: Several comments reference "isForwardDST" as a way to relate areas in forward DST handling. + var isForwardDST = false // matchDate may be nil, which indicates a need to keep iterating // Step C: Validate what we found and then run block. Then prepare the search date for the next round of the loop @@ -624,7 +636,7 @@ extension Calendar { } // If we get a result that is exactly the same as the start date, skip. - if order == .orderedSame { + if !allowStartDate, order == .orderedSame { return SearchStepResult(result: nil, newSearchDate: searchingDate) } @@ -1393,7 +1405,7 @@ extension Calendar { } } - private func dateAfterMatchingEra(startingAt startDate: Date, components: DateComponents, direction: SearchDirection, matchedEra: inout Bool) -> Date? { + internal func dateAfterMatchingEra(startingAt startDate: Date, components: DateComponents, direction: SearchDirection, matchedEra: inout Bool) -> Date? { guard let era = components.era else { // Nothing to do return nil @@ -1431,7 +1443,7 @@ extension Calendar { } } - private func dateAfterMatchingYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { + internal func dateAfterMatchingYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { guard let year = components.year else { // Nothing to do return nil @@ -1466,7 +1478,7 @@ extension Calendar { } } - private func dateAfterMatchingYearForWeekOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { + internal func dateAfterMatchingYearForWeekOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { guard let yearForWeekOfYear = components.yearForWeekOfYear else { // Nothing to do return nil @@ -1494,7 +1506,7 @@ extension Calendar { } } - private func dateAfterMatchingQuarter(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { + internal func dateAfterMatchingQuarter(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { guard let quarter = components.quarter else { return nil } // Get the beginning of the year we need @@ -1530,7 +1542,7 @@ extension Calendar { } } - private func dateAfterMatchingWeekOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { + internal func dateAfterMatchingWeekOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { guard let weekOfYear = components.weekOfYear else { // Nothing to do return nil @@ -1569,7 +1581,7 @@ extension Calendar { } @available(FoundationPreview 0.4, *) - private func dateAfterMatchingDayOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { + internal func dateAfterMatchingDayOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { guard let dayOfYear = components.dayOfYear else { // Nothing to do return nil @@ -1606,7 +1618,7 @@ extension Calendar { return result } - private func dateAfterMatchingMonth(startingAt startDate: Date, components: DateComponents, direction: SearchDirection, strictMatching: Bool) throws -> Date? { + internal func dateAfterMatchingMonth(startingAt startDate: Date, components: DateComponents, direction: SearchDirection, strictMatching: Bool) throws -> Date? { guard let month = components.month else { // Nothing to do return nil @@ -1695,7 +1707,7 @@ extension Calendar { return result } - private func dateAfterMatchingWeekOfMonth(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { + internal func dateAfterMatchingWeekOfMonth(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { guard let weekOfMonth = components.weekOfMonth else { // Nothing to do return nil @@ -1784,7 +1796,7 @@ extension Calendar { return result } - private func dateAfterMatchingWeekdayOrdinal(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { + internal func dateAfterMatchingWeekdayOrdinal(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { guard let weekdayOrdinal = components.weekdayOrdinal else { // Nothing to do return nil @@ -1887,7 +1899,7 @@ extension Calendar { return result } - private func dateAfterMatchingWeekday(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { + internal func dateAfterMatchingWeekday(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { guard let weekday = components.weekday else { // Nothing to do return nil @@ -1944,7 +1956,7 @@ extension Calendar { return result } - private func dateAfterMatchingDay(startingAt startDate: Date, originalStartDate: Date, components comps: DateComponents, direction: SearchDirection) throws -> Date? { + internal func dateAfterMatchingDay(startingAt startDate: Date, originalStartDate: Date, components comps: DateComponents, direction: SearchDirection) throws -> Date? { guard let day = comps.day else { // Nothing to do return nil @@ -2045,7 +2057,7 @@ extension Calendar { return result } - private func dateAfterMatchingHour(startingAt startDate: Date, originalStartDate: Date, components: DateComponents, direction: SearchDirection, findLastMatch: Bool, isStrictMatching: Bool, matchingPolicy: MatchingPolicy) throws -> Date? { + internal func dateAfterMatchingHour(startingAt startDate: Date, originalStartDate: Date, components: DateComponents, direction: SearchDirection, findLastMatch: Bool, isStrictMatching: Bool, matchingPolicy: MatchingPolicy) throws -> Date? { guard let hour = components.hour else { // Nothing to do return nil @@ -2182,7 +2194,7 @@ extension Calendar { return result } - private func dateAfterMatchingMinute(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { + internal func dateAfterMatchingMinute(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { guard let minute = components.minute else { // Nothing to do return nil @@ -2211,7 +2223,7 @@ extension Calendar { return result } - private func dateAfterMatchingSecond(startingAt startDate: Date, originalStartDate: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { + internal func dateAfterMatchingSecond(startingAt startDate: Date, originalStartDate: Date, components: DateComponents, direction: SearchDirection) throws -> Date? { guard let second = components.second else { // Nothing to do return nil @@ -2277,7 +2289,7 @@ extension Calendar { return result } - private func dateAfterMatchingNanosecond(startingAt: Date, components: DateComponents, direction: SearchDirection) -> Date? { + internal func dateAfterMatchingNanosecond(startingAt: Date, components: DateComponents, direction: SearchDirection) -> Date? { guard let nanosecond = components.nanosecond else { // Nothing to do return nil diff --git a/Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift b/Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift index 2c925a728..2cd120153 100644 --- a/Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift +++ b/Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift @@ -28,6 +28,12 @@ extension Calendar.RecurrenceRule.Frequency { } } } +extension Calendar.RecurrenceRule.Month { + init?(from comps: DateComponents) { + guard let month = comps.month else { return nil } + self.init(month, isLeap: comps.isLeapMonth ?? false) + } +} /// The action of a component of the recurrence rule. /// @@ -301,36 +307,86 @@ extension Calendar { return } + let calendar = recurrence.calendar + var dates: [Date] = [anchor] - + + let components = calendar._dateComponents([.second, .minute, .hour, .day, .month, .isLeapMonth, .dayOfYear, .weekday], from: anchor) + + var componentCombinations = Calendar._DateComponentCombinations() + + if recurrence.frequency == .yearly || recurrence.frequency == .monthly { + if dayOfYearAction == .expand { + componentCombinations.months = nil + componentCombinations.daysOfMonth = nil + componentCombinations.daysOfYear = recurrence.daysOfTheYear + } else { + componentCombinations.months = if recurrence.months.isEmpty { [RecurrenceRule.Month(from: components)!] } else { recurrence.months } + componentCombinations.daysOfMonth = if recurrence.daysOfTheMonth.isEmpty { [components.day!] } else { recurrence.daysOfTheMonth} + componentCombinations.daysOfYear = nil + } + } else { + componentCombinations.months = nil + componentCombinations.daysOfMonth = nil + componentCombinations.daysOfYear = nil + } + + if weekdayAction == .expand { + componentCombinations.weekdays = recurrence.weekdays + componentCombinations.daysOfYear = nil + componentCombinations.daysOfMonth = nil + } else if recurrence.frequency == .weekly || weekAction == .expand { + if let weekdayIdx = components.weekday, let weekday = Locale.Weekday(weekdayIdx) { + // In a weekly recurrence (or one that expands weeks of year), we want results to fall on the same weekday as the initial date + componentCombinations.weekdays = [.every(weekday)] + componentCombinations.daysOfYear = nil + componentCombinations.daysOfMonth = nil + } + } + if weekAction == .expand { + // In a yearly recurrence with weeks specified, results do not land on any specific month + componentCombinations.weeksOfYear = recurrence.weeks + componentCombinations.months = nil + } + if recurrence.frequency != .hourly, recurrence.frequency != .minutely { + componentCombinations.hours = if hourAction == .expand { recurrence.hours } else { components.hour.map { [$0] } } + } + if recurrence.frequency != .minutely { + componentCombinations.minutes = if minuteAction == .expand { recurrence.minutes } else { components.minute.map { [$0] } } + } + componentCombinations.seconds = if secondAction == .expand { recurrence.seconds } else { components.second.map { [$0] } } + + + let searchInterval = calendar.dateInterval(of: recurrence.frequency.component, for: anchor)! + let searchRange = searchInterval.start..= self.start } @@ -408,282 +464,70 @@ extension Calendar { } extension Calendar.RecurrenceRule { - /// Move each date to the given weeks of the year - internal func _expandWeeks(dates: inout [Date], anchor: Date) { - guard - let yearInterval = calendar.dateInterval(of: .year, for: anchor), - let weekRange = calendar.range(of: .weekOfYear, in: .year, for: anchor) - else { - return - } - - /// The weekday on which the first day of the year falls - let firstWeekdayOfYear = calendar.component(.weekday, from: yearInterval.start) - /// The weekday on which the last day of the year falls. We remove a few - /// seconds from the end of the range, since it falls on January 1 00:00 - /// the following year. - let lastWeekayOfYear = calendar.component(.weekday, from: yearInterval.end.addingTimeInterval(-0.01)) - - let minimumDaysInFirstWeek = calendar.minimumDaysInFirstWeek - let firstWeekday = calendar.firstWeekday + internal func _limitMonths(dates: inout [Date], anchor: Date) { + let months = calendar._normalizedMonths(months, for: anchor) - /// How many days of the first week are within the year - let daysInFirstWeek = 7 - firstWeekdayOfYear + firstWeekday - /// How many days of the last week are within the year - let daysLeftInLastWeek = 7 - lastWeekayOfYear + firstWeekday - - - let firstWeekIdx = if daysInFirstWeek >= minimumDaysInFirstWeek { - weekRange.lowerBound - } else { - weekRange.lowerBound + 1 - } - - let lastWeekIdx = if daysLeftInLastWeek >= minimumDaysInFirstWeek { - weekRange.upperBound - 2 - } else { - weekRange.upperBound - 1 - } - - let weeks = weeks.map { weekIdx in - if weekIdx > 0 { - weekIdx - 1 + firstWeekIdx - } else { - lastWeekIdx + (weekIdx + 1) - } - } - - dates = dates.flatMap { date in - let week = calendar.component(.weekOfYear, from: date) - return weeks.compactMap { weekIdx in - let offset = weekIdx - week - return calendar.date(byAdding: .weekOfYear, value: offset, to: date) + dates = dates.filter { + let idx = calendar.component(.month, from: $0) + let isLeap = calendar._dateComponents([.month], from: $0).isLeapMonth + return months.contains { + $0.index == idx && $0.isLeap == isLeap } } } - - internal func _expandOrLimitMonths(dates: inout [Date], anchor: Date, action: ComponentAction) { - lazy var monthRange = calendar.range(of: .month, in: .year, for: anchor)! - let months = months.map { month in - if month.index > 0 { - return month - } else { - let newIndex = monthRange.upperBound + month.index - // The upper bound is the last month plus one. Subtracting 1 we get the last month - return Calendar.RecurrenceRule.Month(newIndex, isLeap: month.isLeap) - } - } - - if action == .limit { - dates = dates.filter { - let idx = calendar.component(.month, from: $0) - let isLeap = calendar._dateComponents([.month], from: $0).isLeapMonth - return months.contains { - $0.index == idx && $0.isLeap == isLeap - } - } - } else { - let componentSet: Calendar.ComponentSet = [ .month, .isLeapMonth, .day, .hour, .minute, .second ] - - let anchorComponents = calendar._dateComponents(componentSet, from: anchor) - let daysInYear = calendar.dateInterval(of: .year, for: anchor)! - // This is always the first expansion, so we can overwrite `dates` - dates = months.compactMap { month in - var components = anchorComponents - components.month = month.index - components.isLeapMonth = month.isLeap - return calendar.nextDate(after: daysInYear.start, matching: components, matchingPolicy: matchingPolicy) - } - } - } - internal func _expandOrLimitDaysOfTheMonth(dates: inout [Date], anchor: Date, action: ComponentAction) { - if action == .limit { - dates = dates.filter { date in - let day = calendar.component(.day, from: date) - var dayRange: Range? = nil - for dayOfMonth in daysOfTheMonth { - if dayOfMonth > 0 { - if dayOfMonth == day { return true } - } else { - if dayRange == nil { - dayRange = calendar.range(of: .day, in: .month, for: date) - } - if let dayRange, dayRange.upperBound + dayOfMonth == day { return true } - } - } - return false - } - } else { - let components: Calendar.ComponentSet = [.day, .hour, .minute, .second] - let anchorComponents = calendar._dateComponents(components, from: anchor) - - var componentsForEnumerating: [DateComponents] = [] - - if frequency == .yearly { - let monthRange = calendar.range(of: .month, in: .year, for: anchor)! - let enumerationDateInterval = calendar.dateInterval(of: frequency.component, for: anchor)! - let firstDayOfYear = enumerationDateInterval.start - lazy var monthsToDaysInMonth = monthRange.reduce(into: [Int: Int]()) { - dict, month in - let dayInMonth = calendar.date(bySetting: .month, value: month, of: firstDayOfYear)! - let numberOfDaysInMonth = calendar.range(of: .day, in: .month, for: dayInMonth)! - dict[month] = numberOfDaysInMonth.upperBound - 1 - } - for day in daysOfTheMonth { - if day > 0 { - var components = anchorComponents - components.day = day - componentsForEnumerating.append(components) - } else { - for (month, daysInMonth) in monthsToDaysInMonth { - var components = anchorComponents - components.day = daysInMonth + 1 + day - components.month = month - componentsForEnumerating.append(components) - } - } - } - } else { - for day in daysOfTheMonth { - let daysInMonth = calendar.range(of: .day, in: .month, for: anchor)!.upperBound - 1 - var components = anchorComponents - if day > 0 { - components.day = day - } else { - components.day = daysInMonth + 1 + day - } - componentsForEnumerating.append(components) - } - } - dates = dates.flatMap { date in - let enumerationDateInterval = calendar.dateInterval(of: .month, for: date)! - var expandedDates: [Date] = [] - for components in componentsForEnumerating { - if calendar.date(enumerationDateInterval.start, matchesComponents: components) { - expandedDates.append(enumerationDateInterval.start) - - } - for date in calendar.dates(byMatching: components, - startingAt: enumerationDateInterval.start, - in: enumerationDateInterval.start..? = nil + for dayOfMonth in daysOfTheMonth { + if dayOfMonth > 0 { + if dayOfMonth == day { return true } + } else { + if dayRange == nil { + dayRange = calendar.range(of: .day, in: .month, for: date) } + if let dayRange, dayRange.upperBound + dayOfMonth == day { return true } } - return expandedDates } + return false } } - internal func _expandOrLimitDaysOfTheYear(dates: inout [Date], anchor: Date, action: ComponentAction) { - if action == .limit { - dates = dates.filter { date in - let day = calendar.component(.dayOfYear, from: date) - var dayRange: Range? - for dayOfTheYear in daysOfTheYear { - if dayOfTheYear > 0 { - if dayOfTheYear == day { return true } - } else { - if dayRange == nil { - dayRange = calendar.range(of: .dayOfYear, in: .year, for: date) - } - if let dayRange, dayRange.upperBound + dayOfTheYear == day { return true } - } - } - return false - } - } else { - let components: Calendar.ComponentSet = [.hour, .minute, .second] - let anchorComponents = calendar._dateComponents(components, from: anchor) - - var componentsForEnumerating: [DateComponents] = [] - let enumerationDateInterval = calendar.dateInterval(of: frequency.component, for: anchor)! - - lazy var daysInYear = calendar.range(of: .dayOfYear, in: .year, for: anchor)!.upperBound - 1 - for day in daysOfTheYear { - if day > 0 { - var components = anchorComponents - components.dayOfYear = day - componentsForEnumerating.append(components) + internal func _limitDaysOfTheYear(dates: inout [Date], anchor: Date) { + dates = dates.filter { date in + let day = calendar.component(.dayOfYear, from: date) + var dayRange: Range? + for dayOfTheYear in daysOfTheYear { + if dayOfTheYear > 0 { + if dayOfTheYear == day { return true } } else { - var components = anchorComponents - components.dayOfYear = daysInYear + 1 + day - componentsForEnumerating.append(components) - } - } - dates = dates.flatMap { date in - var expandedDates: [Date] = [] - for components in componentsForEnumerating { - for date in calendar.dates(byMatching: components, - startingAt: enumerationDateInterval.start, - in: enumerationDateInterval.start.. [DateComponents]? { + anchor: Date, + anchorComponents: DateComponents? = nil) -> [DateComponents]? { /// Map of weekdays to which occurences of the weekday we are interested /// in. `1` is the first such weekday in the interval, `-1` is the last. /// An empty array indicates that any weekday is valid @@ -778,19 +617,19 @@ extension Calendar.RecurrenceRule { guard - let interval = calendar.dateInterval(of: parent, for: anchor) + let interval = dateInterval(of: parent, for: anchor) else { return nil } - lazy var weekRange = calendar.range(of: weekComponent, in: parent, for: anchor)! + lazy var weekRange = range(of: weekComponent, in: parent, for: anchor)! var result: [DateComponents] = [] - let anchorComponents = calendar._dateComponents(componentSet, from: anchor) + let anchorComponents = anchorComponents ?? _dateComponents(componentSet, from: anchor) - lazy var firstWeekday = calendar.component(.weekday, from: interval.start) + lazy var firstWeekday = component(.weekday, from: interval.start) // The end of the interval would always be midnight on the day after, so // it falls on the day after the last day in the interval. Subtracting a // few seconds can give us the last day in the interval - lazy var lastWeekday = calendar.component(.weekday, from: interval.end.addingTimeInterval(-0.1)) + lazy var lastWeekday = component(.weekday, from: interval.end.addingTimeInterval(-0.1)) for (weekday, occurences) in map { let weekdayIdx = weekday.icuIndex @@ -817,4 +656,242 @@ extension Calendar.RecurrenceRule { } return result } + + /// Normalized months so that all months are positive + func _normalizedMonths(_ months: [Calendar.RecurrenceRule.Month], for anchor: Date) -> [Calendar.RecurrenceRule.Month] { + lazy var monthRange = self.range(of: .month, in: .year, for: anchor) + return months.compactMap { month in + if month.index > 0 { + return month + } else if month.index > -monthRange!.upperBound { + let newIndex = monthRange!.upperBound + month.index + // The upper bound is the last month plus one. Subtracting 1 we get the last month + return Calendar.RecurrenceRule.Month(newIndex, isLeap: month.isLeap) + } else { + return nil + } + } + } + + /// Normalized days in a month so that all days are positive + internal func _normalizedDaysOfMonth(_ days: [Int], for anchor: Date) -> [Int] { + lazy var dayRange = self.range(of: .day, in: .month, for: anchor) + return days.compactMap { day in + if day > 0 { + day + } else if day > -dayRange!.upperBound { + dayRange!.upperBound + day + } else { + nil + } + } + } + + /// Normalized days in a year so that all days are positive + internal func _normalizedDaysOfYear(_ days: [Int], for anchor: Date) -> [Int] { + lazy var dayRange = self.range(of: .day, in: .year, for: anchor) + return days.compactMap { day in + if day > 0 { + day + } else if day > -dayRange!.upperBound { + dayRange!.upperBound + day + } else { + nil + } + } + } + + /// Normalized weeks of year so that all weeks are positive + fileprivate func _normalizedWeeksOfYear(_ weeksOfYear: [Int], anchor: Date) -> [Int] { + // Positive week indices can be treated as a date component the way they + // are. Negative indices mean that we count backwards from the last week + // of the year that contains the anchor weekday + lazy var weekRange = self.range(of: .weekOfYear, in: .year, for: anchor)! + lazy var lastDayOfYear = dateInterval(of: .year, for: anchor)!.end.addingTimeInterval(-0.01) + lazy var lastWeekayOfYear = component(.weekday, from: lastDayOfYear) + lazy var daysLeftInLastWeek = 7 - lastWeekayOfYear + firstWeekday + + lazy var lastWeekIdx = if daysLeftInLastWeek >= minimumDaysInFirstWeek { + weekRange.upperBound - 1 + } else { + weekRange.upperBound + } + + return weeksOfYear.compactMap { weekIdx in + if weekIdx > 0 { + weekIdx + } else if weekIdx > -lastWeekIdx { + lastWeekIdx + weekIdx + } else { + nil + } + } + } + + fileprivate func _unadjustedDates(after startDate: Date, + matching combinationComponents: _DateComponentCombinations, + matchingPolicy: MatchingPolicy, + repeatedTimePolicy: RepeatedTimePolicy) throws -> [(Date, DateComponents)]? { + + let isStrictMatching = matchingPolicy == .strict + + var dates = [(date: startDate, components: DateComponents())] + var lastMatchedComponent: Calendar.Component? = nil + + if let weeks = combinationComponents.weeksOfYear { + dates = try dates.flatMap { date, comps in + try _normalizedWeeksOfYear(weeks, anchor: date).map { week in + var comps = comps + comps.weekOfYear = week + var date = date + if let result = try dateAfterMatchingYearForWeekOfYear(startingAt: date, components: comps, direction: .forward) { + date = result + } + + if let result = try dateAfterMatchingWeekOfYear(startingAt: date, components: comps, direction: .forward) { + date = result + } + return (date, comps) + } + } + } + + if let daysOfYear = combinationComponents.daysOfYear { + dates = try dates.flatMap { date, comps in + try _normalizedDaysOfYear(daysOfYear, for: date).map { day in + var comps = comps + comps.dayOfYear = day + return try dateAfterMatchingDayOfYear(startingAt: date, components: comps, direction: .forward).map { ($0, comps) } ?? (date, comps) + } + } + lastMatchedComponent = .dayOfYear + } + + if let months = combinationComponents.months { + dates = try dates.flatMap { date, comps in + try _normalizedMonths(months, for: date).map { month in + var comps = comps + comps.month = month.index + comps.isLeapMonth = month.isLeap + return try dateAfterMatchingMonth(startingAt: date, components: comps, direction: .forward, strictMatching: isStrictMatching).map { ($0, comps) } ?? (date, comps) + } + } + lastMatchedComponent = .month + } + + if let weekdays = combinationComponents.weekdays { + dates = try dates.flatMap { date, comps in + let parentComponent: Calendar.Component = .month + let weekdayComponents = _weekdayComponents(for: weekdays, in: parentComponent, anchor: date, anchorComponents: comps) + let dates = try weekdayComponents!.map { comps in + var date = date + if let result = try dateAfterMatchingWeekOfYear(startingAt: date, components: comps, direction: .forward) { + date = result + } + if let result = try dateAfterMatchingWeekOfMonth(startingAt: date, components: comps, direction: .forward) { + date = result + } + if let result = try dateAfterMatchingWeekdayOrdinal(startingAt: date, components: comps, direction: .forward) { + date = result + } + if let result = try dateAfterMatchingWeekday(startingAt: date, components: comps, direction: .forward) { + date = result + } + return (date, comps) + } + return dates + } + } + + if let daysOfMonth = combinationComponents.daysOfMonth { + dates = try dates.flatMap { date, comps in + try _normalizedDaysOfMonth(daysOfMonth, for: date).map { day in + var comps = comps + comps.day = day + return try dateAfterMatchingDay(startingAt: date, originalStartDate: startDate, components: comps, direction: .forward).map { ($0, comps) } ?? (date, comps) + } + } + lastMatchedComponent = .day + } + + if let hours = combinationComponents.hours { + dates = try dates.flatMap { date, comps in + let searchStart: Date + if lastMatchedComponent == .day || lastMatchedComponent == .dayOfYear { + searchStart = date + } else { + searchStart = self.dateInterval(of: .day, for: date)!.start + } + return try hours.map { hour in + var comps = comps + comps.hour = hour + return try dateAfterMatchingHour(startingAt: searchStart, originalStartDate: startDate, components: comps, direction: .forward, findLastMatch: repeatedTimePolicy == .last, isStrictMatching: isStrictMatching, matchingPolicy: matchingPolicy).map { ($0, comps) } ?? (date, comps) + } + } + lastMatchedComponent = .hour + } + + if let minutes = combinationComponents.minutes { + dates = try dates.flatMap { date, comps in + let searchStart: Date + if lastMatchedComponent == .hour { + searchStart = date + } else { + searchStart = self.dateInterval(of: .hour, for: date)!.start + } + return try minutes.map { minute in + var comps = comps + comps.minute = minute + return try dateAfterMatchingMinute(startingAt: searchStart, components: comps, direction: .forward).map { ($0, comps) } ?? (date, comps) + } + } + lastMatchedComponent = .minute + } + + if let seconds = combinationComponents.seconds { + dates = try dates.flatMap { date, comps in + let searchStart: Date + if lastMatchedComponent == .minute { + searchStart = date + } else { + searchStart = self.dateInterval(of: .minute, for: date)!.start + } + return try seconds.map { second in + var comps = comps + comps.second = second + return try dateAfterMatchingSecond(startingAt: searchStart, originalStartDate: startDate, components: comps, direction: .forward).map { ($0, comps) } ?? (date, comps) + } + } + } + + return dates + } + + /// All dates that match a combination of date components + internal func _dates(startingAfter start: Date, + matching matchingComponents: _DateComponentCombinations, + in range: Range, + matchingPolicy: MatchingPolicy, + repeatedTimePolicy: RepeatedTimePolicy) throws -> [Date] { + + guard start.isValidForEnumeration else { return [] } + + guard let unadjustedMatchDates = try _unadjustedDates(after: start, matching: matchingComponents, matchingPolicy: matchingPolicy, repeatedTimePolicy: repeatedTimePolicy) else { + return [] + } + + let results = try unadjustedMatchDates.map { date, components in + (try _adjustedDate(date, startingAfter: start, allowStartDate: true, matching: components, adjustedMatchingComponents: components , matchingPolicy: matchingPolicy, repeatedTimePolicy: repeatedTimePolicy, direction: .forward, inSearchingDate: start, previouslyReturnedMatchDate: nil), components) + } + + var foundDates: [Date] = [] + for (result, _) in results { + if let (matchDate, _) = result.result { + if range.contains(matchDate) { + foundDates.append(matchDate) + } + } + } + return foundDates + } } diff --git a/Tests/FoundationEssentialsTests/GregorianCalendarRecurrenceRuleTests.swift b/Tests/FoundationEssentialsTests/GregorianCalendarRecurrenceRuleTests.swift index 540350091..0569ebb67 100644 --- a/Tests/FoundationEssentialsTests/GregorianCalendarRecurrenceRuleTests.swift +++ b/Tests/FoundationEssentialsTests/GregorianCalendarRecurrenceRuleTests.swift @@ -516,11 +516,38 @@ final class GregorianCalendarRecurrenceRuleTests: XCTestCase { func testYearlyRecurrenceWithWeekNumberExpansion() { var calendar = Calendar(identifier: .gregorian) calendar.firstWeekday = 2 // Week starts on Monday + calendar.minimumDaysInFirstWeek = 4 + + let start = Date(timeIntervalSince1970: 1357048800.0) // 2013-01-01T14:00:00-0000 + let end = Date(timeIntervalSince1970: 1483279200.0) // 2017-01-01T14:00:00-0000 + + var rule = Calendar.RecurrenceRule(calendar: calendar, frequency: .yearly) + rule.weeks = [1, 2] + + let results = Array(rule.recurrences(of: start, in: start..