Some checks failed
CI / build-and-test (push) Has been cancelled
Before adding "every N weeks/months" support, found the recurrence window math was independently hand-written in three places (main app's TaskItem.isOccurrence, and two separate widget extensions' countdown progress calculations) with no shared code to keep them in sync. Extracted the math into Shared/RecurrenceRule.swift (pure Foundation, no framework deps) compiled into both the app and widget extension targets via a new project.yml Shared/ source path, and pointed all three call sites at it. Verified every case is identical to prior behavior at interval==1 by compiling and running a standalone test driver directly against the real file with swiftc - 38/38 passing, including Jan-31-monthly and Feb-29-leap-year-yearly edge cases. Groundwork only - no recurrenceInterval field on TaskItem yet, the actual interval feature comes next on top of this. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
93 lines
3.8 KiB
Swift
93 lines
3.8 KiB
Swift
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)
|
||
}
|
||
}
|