2026-07-11 23:56:58 +03:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-07-12 00:15:45 +03:00
|
|
|
|
|
|
|
|
|
|
/// 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)"
|
|
|
|
|
|
}
|
2026-07-11 23:56:58 +03:00
|
|
|
|
}
|