recurrence: consolidate 3 duplicate implementations into one shared, tested engine (KC-69)
Some checks failed
CI / build-and-test (push) Has been cancelled
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>
This commit is contained in:
92
Shared/RecurrenceRule.swift
Normal file
92
Shared/RecurrenceRule.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user