recurrence: consolidate 3 duplicate implementations into one shared, tested engine (KC-69)
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:
kutesir
2026-07-11 23:56:58 +03:00
parent eff5cc9330
commit 6915e64b67
7 changed files with 187 additions and 73 deletions

View 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 MonFri, 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)
}
}