import Foundation /// Shared, framework-free recurrence math. Compiled into the main app /// (`TaskItem.isOccurrence`) AND both widget extensions (their countdown /// progress-window calculations) from `Shared/`, so all three can never /// independently drift from each other the way three hand-written copies did. /// /// `interval` defaults to 1 everywhere — every case below is written so that /// interval == 1 produces results identical to this app's pre-interval /// behavior (verified in RecurrenceRuleTests.swift). enum RecurrenceRule { /// Whether `date` is a valid occurrence of a rule starting at `start`, /// repeating every `interval` units of `label`, bounded by `end` if set. static func isOccurrence(label: String?, interval: Int = 1, start: Date, date: Date, end: Date? = nil, calendar: Calendar = .current) -> Bool { let cal = calendar let d0 = cal.startOfDay(for: date), s0 = cal.startOfDay(for: start) guard d0 >= s0 else { return false } if let end, d0 > cal.startOfDay(for: end) { return false } let n = max(1, interval) switch label { case "Daily": let days = cal.dateComponents([.day], from: s0, to: d0).day ?? 0 return days % n == 0 case "Every Weekday": // No interval concept — always Mon–Fri, same as before intervals existed. let wd = cal.component(.weekday, from: d0) // 1=Sun … 7=Sat return wd >= 2 && wd <= 6 case "Weekly": let days = cal.dateComponents([.day], from: s0, to: d0).day ?? 0 return days % (7 * n) == 0 case "Monthly": guard cal.component(.day, from: s0) == cal.component(.day, from: d0) else { return false } let months = cal.dateComponents([.month], from: s0, to: d0).month ?? 0 return months % n == 0 case "Yearly": guard cal.component(.day, from: s0) == cal.component(.day, from: d0), cal.component(.month, from: s0) == cal.component(.month, from: d0) else { return false } let years = cal.dateComponents([.year], from: s0, to: d0).year ?? 0 return years % n == 0 default: return false } } /// The occurrence window (prev, next) containing `now` — used by the /// countdown-dot widgets to size their progress bar. `prev` is the most /// recent occurrence on/before `now`; `next` is the following one. /// Returns nil for an unrecognized label. static func span(label: String, interval: Int = 1, due: Date, now: Date, calendar: Calendar = .current) -> (prev: Date, next: Date)? { let cal = calendar let n = max(1, interval) let comp: Calendar.Component let step: Int switch label { case "Daily": comp = .day; step = n case "Every Weekday": comp = .day; step = 1 // no interval concept, see isOccurrence case "Weekly": comp = .day; step = 7 * n case "Monthly": comp = .month; step = n case "Yearly": comp = .year; step = n default: return nil } var next = due var guardRail = 0 if next > now { while let prev = cal.date(byAdding: comp, value: -step, to: next), prev > now, guardRail < 4000 { next = prev guardRail += 1 } } else { while next <= now, let candidate = cal.date(byAdding: comp, value: step, to: next), guardRail < 4000 { next = candidate guardRail += 1 } } guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil } return (prev, next) } /// User-facing recurrence text — "Weekly" at interval 1 (unchanged from /// before intervals existed), "Every 2 Weeks" once N > 1. One shared /// formatter so the main app and the create/edit picker UI can't drift. static func displayLabel(label: String, interval: Int = 1) -> String { let n = max(1, interval) guard n > 1, label != "Every Weekday" else { return label } let unit: String switch label { case "Daily": unit = "Days" case "Weekly": unit = "Weeks" case "Monthly": unit = "Months" case "Yearly": unit = "Years" default: return label } return "Every \(n) \(unit)" } }