Some checks failed
CI / build-and-test (push) Has been cancelled
Recurrence previously only supported 5 fixed rules with no interval. Adds TaskItem.recurrenceInterval (optional, nil = 1, so every existing task is unaffected), a stepper in the shared create/edit sheet, quick-add NLP for phrases like "every 2 weeks"/"biweekly"/"fortnightly", and threads the interval through both countdown widgets so their progress bars size correctly for biweekly/etc tasks too. Built on KC-69's consolidated RecurrenceRule engine. Verified two things by actually compiling and running standalone swiftc drivers rather than just reading: the core interval math (44/44, unchanged from KC-69) and the new quick-add regex patterns against realistic phrases including "look into tru housing options every two weeks" (11/11 passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
110 lines
4.5 KiB
Swift
110 lines
4.5 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)
|
||
}
|
||
|
||
/// 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)"
|
||
}
|
||
}
|