// ── Recurring Timer Engine ──────────────────────────────────── // Recurrence rules, next-occurrence calculation, skip/pause logic // Ported from web/src/lib/recurrence.ts import Foundation // MARK: - Recurrence Frequency enum RecurrenceFrequency: String, Codable, CaseIterable, Identifiable { case daily case weekday case weekend case weekly case biweekly case monthly case custom var id: String { rawValue } var label: String { switch self { case .daily: return "Every day" case .weekday: return "Weekdays (Mon–Fri)" case .weekend: return "Weekends (Sat–Sun)" case .weekly: return "Every week" case .biweekly: return "Every 2 weeks" case .monthly: return "Every month" case .custom: return "Custom days" } } } // MARK: - Recurrence Rule struct RecurrenceRule: Codable, Equatable { let frequency: RecurrenceFrequency var daysOfWeek: [Int]? // 1=Sun, 2=Mon, ..., 7=Sat (Calendar weekday) var interval: Int // Every N periods (default 1) var endDate: Date? // Stop recurring after this var timeOfDay: Int // Minutes since midnight (e.g., 540 = 9:00 AM) init( frequency: RecurrenceFrequency, daysOfWeek: [Int]? = nil, interval: Int = 1, endDate: Date? = nil, timeOfDay: Int ) { self.frequency = frequency self.daysOfWeek = daysOfWeek self.interval = interval self.endDate = endDate self.timeOfDay = timeOfDay } } // MARK: - Recurring Timer struct RecurringTimer: Codable, Identifiable, Equatable { let id: String var recurrence: RecurrenceRule var paused: Bool var skipNext: Bool var lastOccurrence: Date? } // MARK: - Next Occurrence Calculation private let daySeconds: TimeInterval = 24 * 60 * 60 /// Calculate the next occurrence of a recurring timer after `afterDate`. /// Returns the date of next occurrence, or nil if no more occurrences. func getNextOccurrence( rule: RecurrenceRule, afterDate: Date, maxLookaheadDays: Int = 366 ) -> Date? { let calendar = Calendar.current let interval = max(rule.interval, 1) // Start from the beginning of the afterDate day let startOfDay = calendar.startOfDay(for: afterDate) // Helper: set time-of-day on a given date func setTimeOnDate(_ date: Date) -> Date { let hours = rule.timeOfDay / 60 let minutes = rule.timeOfDay % 60 return calendar.date(bySettingHour: hours, minute: minutes, second: 0, of: date) ?? date } // Check same day first let sameDayCandidate = setTimeOnDate(startOfDay) var candidates: [Date] = [] if sameDayCandidate > afterDate { candidates.append(sameDayCandidate) } // Generate candidates going forward for dayOffset in 1...maxLookaheadDays { guard let futureDay = calendar.date(byAdding: .day, value: dayOffset, to: startOfDay) else { continue } candidates.append(setTimeOnDate(futureDay)) } for candidate in candidates { // Check end date if let endDate = rule.endDate, candidate > endDate { return nil } if matchesFrequency(date: candidate, rule: rule, afterDate: afterDate, interval: interval) { return candidate } } return nil } /// Check if a given date matches the recurrence frequency rule. private func matchesFrequency( date: Date, rule: RecurrenceRule, afterDate: Date, interval: Int ) -> Bool { let calendar = Calendar.current let weekday = calendar.component(.weekday, from: date) // 1=Sun, 2=Mon, ..., 7=Sat switch rule.frequency { case .daily: return true case .weekday: return weekday >= 2 && weekday <= 6 // Mon-Fri case .weekend: return weekday == 1 || weekday == 7 // Sun or Sat case .weekly: let refWeekday = calendar.component(.weekday, from: afterDate) if weekday != refWeekday { return false } if interval <= 1 { return true } let refStart = calendar.startOfDay(for: afterDate) let diffDays = calendar.dateComponents([.day], from: refStart, to: date).day ?? 0 let mod = diffDays % (interval * 7) return mod == 0 || mod == 7 case .biweekly: let refWeekday = calendar.component(.weekday, from: afterDate) if weekday != refWeekday { return false } let refStart = calendar.startOfDay(for: afterDate) let diffDays = calendar.dateComponents([.day], from: refStart, to: date).day ?? 0 return diffDays >= 0 && diffDays % 14 < 7 case .monthly: let refDayOfMonth = calendar.component(.day, from: afterDate) let daysInMonth = calendar.range(of: .day, in: .month, for: date)?.count ?? 28 let targetDay = min(refDayOfMonth, daysInMonth) return calendar.component(.day, from: date) == targetDay case .custom: guard let days = rule.daysOfWeek else { return false } return days.contains(weekday) } } // MARK: - Bulk Helpers /// Get the next N occurrences of a recurring timer. func getNextNOccurrences( rule: RecurrenceRule, afterDate: Date, count: Int ) -> [Date] { var occurrences: [Date] = [] var cursor = afterDate for _ in 0.. Date? { guard let next = getNextOccurrence(rule: rule, afterDate: afterDate) else { return nil } return getNextOccurrence(rule: rule, afterDate: next) } // MARK: - Rule Builders func createDailyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule { RecurrenceRule(frequency: .daily, endDate: endDate, timeOfDay: timeOfDayMinutes) } func createWeekdayRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule { RecurrenceRule(frequency: .weekday, endDate: endDate, timeOfDay: timeOfDayMinutes) } func createWeekendRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule { RecurrenceRule(frequency: .weekend, endDate: endDate, timeOfDay: timeOfDayMinutes) } func createWeeklyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule { RecurrenceRule(frequency: .weekly, interval: 1, endDate: endDate, timeOfDay: timeOfDayMinutes) } func createBiweeklyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule { RecurrenceRule(frequency: .biweekly, endDate: endDate, timeOfDay: timeOfDayMinutes) } func createMonthlyRule(timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule { RecurrenceRule(frequency: .monthly, endDate: endDate, timeOfDay: timeOfDayMinutes) } func createCustomRule(daysOfWeek: [Int], timeOfDayMinutes: Int, endDate: Date? = nil) -> RecurrenceRule { RecurrenceRule(frequency: .custom, daysOfWeek: daysOfWeek, endDate: endDate, timeOfDay: timeOfDayMinutes) } // MARK: - Display Helpers /// Format time-of-day minutes as "HH:MM AM/PM" func formatTimeOfDay(minutes: Int) -> String { let h = minutes / 60 let m = minutes % 60 let period = h >= 12 ? "PM" : "AM" let h12 = h == 0 ? 12 : (h > 12 ? h - 12 : h) return "\(h12):\(String(format: "%02d", m)) \(period)" } /// Get a human-readable description of a recurrence rule. func describeRecurrence(rule: RecurrenceRule) -> String { let time = formatTimeOfDay(minutes: rule.timeOfDay) let dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] switch rule.frequency { case .daily: return "Every day at \(time)" case .weekday: return "Weekdays at \(time)" case .weekend: return "Weekends at \(time)" case .weekly: return "Every week at \(time)" case .biweekly: return "Every 2 weeks at \(time)" case .monthly: return "Monthly at \(time)" case .custom: guard let dows = rule.daysOfWeek, !dows.isEmpty else { return "Custom at \(time)" } // Convert Calendar weekday (1=Sun) to dayNames index (0=Sun) let days = dows.sorted().compactMap { d -> String? in let idx = d - 1 guard idx >= 0 && idx < dayNames.count else { return nil } return dayNames[idx] }.joined(separator: ", ") return "\(days) at \(time)" } }