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>
This commit is contained in:
kutesir
2026-07-12 00:15:45 +03:00
committed by kutesir
parent f6a9358a86
commit b7af2dd76b
8 changed files with 225 additions and 22 deletions

View File

@@ -78,6 +78,7 @@ struct EventEntity: AppEntity {
let dueDate: Date?
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
var recurrenceInterval: Int? = nil
var createdAt: Date? = nil
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
@@ -107,6 +108,7 @@ struct EventQuery: EntityQuery {
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel,
recurrenceInterval: $0.recurrenceInterval,
createdAt: $0.createdAt) }
}
}
@@ -238,6 +240,7 @@ struct EventProvider: AppIntentTimelineProvider {
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel,
recurrenceInterval: $0.recurrenceInterval,
createdAt: $0.createdAt) }
}
@@ -272,10 +275,10 @@ struct EventProvider: AppIntentTimelineProvider {
/// so a birthday 5 days away reads ~98% full. One-off events (Mode A) count
/// from an explicit start, the task's real creation date, the tracking date,
/// or first-seen, up to the due date in that priority order.
private func resolveSpan(due: Date, isRecurring: Bool, label: String?,
private func resolveSpan(due: Date, isRecurring: Bool, label: String?, interval: Int? = nil,
anchorKey: String, explicitStart: Date?,
taskCreatedAt: Date? = nil) -> (start: Date, target: Date) {
if isRecurring, let label, let span = Self.recurrenceSpan(label: label, due: due) {
if isRecurring, let label, let span = Self.recurrenceSpan(label: label, interval: interval ?? 1, due: due) {
return (span.prev, span.next)
}
if let s = explicitStart, s < due { return (s, due) }
@@ -294,7 +297,8 @@ struct EventProvider: AppIntentTimelineProvider {
let trackedId = task.id.uuidString
let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date
let s = resolveSpan(due: task.dueDate ?? now, isRecurring: task.isRecurring,
label: task.recurrenceLabel, anchorKey: trackedId, explicitStart: since,
label: task.recurrenceLabel, interval: task.recurrenceInterval,
anchorKey: trackedId, explicitStart: since,
taskCreatedAt: task.createdAt)
return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode)
}
@@ -309,11 +313,13 @@ struct EventProvider: AppIntentTimelineProvider {
}
let ev = upcoming[idx]
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
interval: ev.recurrenceInterval,
anchorKey: ev.id, explicitStart: nil, taskCreatedAt: ev.createdAt)
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
}
if let ev = config.event, let due = ev.dueDate {
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
interval: ev.recurrenceInterval,
anchorKey: ev.id, explicitStart: config.startDate,
taskCreatedAt: ev.createdAt)
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
@@ -344,8 +350,8 @@ struct EventProvider: AppIntentTimelineProvider {
/// The actual math lives in RecurrenceRule (Shared/) shared with the main
/// app's occurrence matching and the other countdown widget's copy of this
/// same calculation, so none of the three can independently drift.
private static func recurrenceSpan(label: String, due: Date) -> (prev: Date, next: Date)? {
RecurrenceRule.span(label: label, due: due, now: Date())
private static func recurrenceSpan(label: String, interval: Int = 1, due: Date) -> (prev: Date, next: Date)? {
RecurrenceRule.span(label: label, interval: interval, due: due, now: Date())
}
}