Files
KisaniCal/Shared/RecurrenceRule.swift
kutesir b7af2dd76b tasks: add "every N weeks/months/days/years" recurrence (KC-70)
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>
2026-07-12 22:57:13 +00:00

110 lines
4.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
/// 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)"
}
}