tasks: add "every N weeks/months/days/years" recurrence (KC-70)
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>
This commit is contained in:
kutesir
2026-07-12 00:15:45 +03:00
parent 6915e64b67
commit c992554f99
8 changed files with 225 additions and 22 deletions

View File

@@ -2520,3 +2520,69 @@ now-tested engine. `xcodegen generate` re-run cleanly after the
`project.yml` change; not build-verified in Xcode itself (no iOS runtime
here), though the standalone test above is real, executed verification of
the actual shipped file.
## KC-70 — "Every N weeks/months/days/years" recurrence
Status: Implemented (not build-verified — no iOS runtime here, but the core
recurrence math and the quick-add regex are both real, executed-and-passing
standalone verification — see Note)
Reported by: User — "Look into TRU housing options every two weeks," and
generalized to "every 1 week or 2 or 3," "every 2 months," etc.
Area: Task model, task editor (Matrix + quick-add), quick-add NLP, both
countdown widgets
### Description
Recurrence previously only supported 5 fixed rules (Daily/Weekly/Monthly/
Yearly/Every Weekday) with no interval — "every 2 weeks" or "every 3
months" had no representation. Built on top of KC-69's consolidated
`RecurrenceRule` engine (which already accepted an `interval` parameter in
anticipation of this).
### Change
- `TaskItem.recurrenceInterval: Int? = nil` — optional, same safe-decode
pattern as every other recurrence field; nil means 1 everywhere, so
every existing task is unaffected.
- `TaskItem.recurrenceDisplayLabel` — "Weekly" stays "Weekly" at interval
1, becomes "Every 2 Weeks" etc. above that. Delegates to a new
`RecurrenceRule.displayLabel(label:interval:)` (Shared/) rather than a
4th hand-written copy of this formatting.
- `TaskDatePickerSheet` (the shared create/edit sheet used by both Matrix's
task editor and quick-add): new "Every N" `Stepper` row (1...30),
visible whenever a repeat unit other than "Every Weekday" is selected.
`repeatDisplayValue` switches to the "Every N X" format once N > 1.
- `addTask`/`updateTask` (`TaskItem.swift`) and both `TaskDatePickerSheet`
call sites (`MatrixView.swift`, `TodayView.swift`) thread the new field
through.
- Quick-add NLP (`NLTaskParser.parseRecurrence`): new
`parseIntervalRecurrence` recognizes "every 2 weeks," "every three
months," "biweekly," "fortnightly," "bimonthly" (checked before the
existing plain-unit patterns, which stay untouched). "every 1 X" falls
through unrecognized — matches how "every week" behaves today, not a
regression.
- Widgets: `WidgetTask`/`RawTask` (`WidgetData.swift`) and `EventEntity`
(`EventCountdownWidget.swift`) gained `recurrenceInterval`, threaded
through both countdown widgets' span calculations (`KisaniCalWidgets
.swift`'s `recurrenceSpan(for:)`, `EventCountdownWidget.swift`'s
`resolveSpan`/`recurrenceSpan`) so a biweekly task's countdown bar sizes
correctly in both widget families, not just the main app's Calendar/
Matrix/Today views.
Files: `KisaniCal/Models/TaskItem.swift`, `KisaniCal/Views/TodayView.swift`,
`KisaniCal/Views/MatrixView.swift`, `Shared/RecurrenceRule.swift`,
`KisaniCalWidgets/WidgetData.swift`, `KisaniCalWidgets/KisaniCalWidgets.swift`,
`KisaniCalWidgets/EventCountdownWidget.swift`.
### Note
Not build-verified in Xcode (no iOS runtime here). Two things WERE
actually verified by compiling and running standalone `swiftc` drivers
against the real logic (not copies, not just read):
1. `RecurrenceRule` itself — 44/44 cases (see KC-69), unchanged by this
commit.
2. The new quick-add regex patterns — extracted into a standalone driver
and run against realistic phrases including the user's own example
("look into tru housing options every two weeks" → Weekly/2) plus
biweekly/fortnightly/bimonthly and negative cases (plain "every week"
correctly does NOT trigger the interval path) — 11/11 passing.
The SwiftUI layer itself (the Stepper row, bindings, sheet wiring) is
self-reviewed only — balanced braces/parens confirmed per-file, one
pre-existing stray `(` in `TaskItem.swift` predates this (see KC-62).

View File

@@ -18,6 +18,9 @@ struct TaskItem: Identifiable, Codable, Equatable {
var taskColor: TaskColor = .orange
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
// "Every N [unit]" optional so existing saved tasks decode safely (missing
// key nil, treated as 1 everywhere). "Every Weekday" ignores this.
var recurrenceInterval: Int? = nil
var isPinned: Bool = false
var endDate: Date? = nil
var isAllDay: Bool = false
@@ -30,6 +33,15 @@ struct TaskItem: Identifiable, Codable, Equatable {
// Matrix: horizontal manual moves override urgency only.
// nil = auto from deadline, true = force urgent, false = force not-urgent.
var urgencyOverride: Bool? = nil
/// User-facing recurrence text "Weekly" stays "Weekly" at interval 1 (no
/// visual change for any existing task), but becomes "Every 2 Weeks" etc.
/// once an interval is set. Use this anywhere recurrence text is shown to
/// the user; `recurrenceLabel` itself stays a plain rule key for matching.
var recurrenceDisplayLabel: String? {
guard let label = recurrenceLabel else { return nil }
return RecurrenceRule.displayLabel(label: label, interval: recurrenceInterval ?? 1)
}
}
enum Priority: String, Codable, CaseIterable, Identifiable {
@@ -386,8 +398,8 @@ class TaskViewModel: ObservableObject {
/// extensions use for their countdown windows one implementation, not three.
func isOccurrence(_ t: TaskItem, on date: Date) -> Bool {
guard recurs(t), let start = t.dueDate else { return false }
return RecurrenceRule.isOccurrence(label: t.recurrenceLabel, start: start, date: date,
end: t.recurrenceEnd)
return RecurrenceRule.isOccurrence(label: t.recurrenceLabel, interval: t.recurrenceInterval ?? 1,
start: start, date: date, end: t.recurrenceEnd)
}
func occurrenceComplete(_ t: TaskItem, on date: Date) -> Bool {
@@ -621,7 +633,7 @@ class TaskViewModel: ObservableObject {
func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
taskColor: TaskColor = .orange, isRecurring: Bool = false,
recurrenceLabel: String? = nil,
recurrenceLabel: String? = nil, recurrenceInterval: Int? = nil,
endDate: Date? = nil, isAllDay: Bool = false,
priority: Priority = .none, reminderDate: Date? = nil,
constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
@@ -634,6 +646,7 @@ class TaskViewModel: ObservableObject {
endDate: endDate, isAllDay: isAllDay,
priority: resolvedPriority, reminderDate: reminderDate,
constantReminder: constantReminder)
item.recurrenceInterval = recurrenceInterval
item.recurrenceEnd = recurrenceEnd
item.quadrant = displayQuadrant(item) // color/widget identity follows placement
tasks.append(item)
@@ -643,13 +656,15 @@ class TaskViewModel: ObservableObject {
func updateTask(_ task: TaskItem, title: String, dueDate: Date?, hasTime: Bool,
isRecurring: Bool, recurrenceLabel: String?,
endDate: Date?, isAllDay: Bool, reminderDate: Date?,
constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
constantReminder: Bool = false, recurrenceEnd: Date? = nil,
recurrenceInterval: Int? = nil) {
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
tasks[idx].title = title
tasks[idx].dueDate = dueDate
tasks[idx].hasTime = hasTime
tasks[idx].isRecurring = isRecurring
tasks[idx].recurrenceLabel = recurrenceLabel
tasks[idx].recurrenceInterval = recurrenceInterval
tasks[idx].endDate = endDate
tasks[idx].isAllDay = isAllDay
tasks[idx].reminderDate = reminderDate

View File

@@ -738,6 +738,7 @@ struct TaskEditSheet: View {
@State private var isRecurring: Bool
@State private var recurrenceLabel: String?
@State private var recurrenceEnd: Date?
@State private var recurrenceInterval: Int?
@State private var endDate: Date?
@State private var isAllDay: Bool
@State private var reminderDate: Date?
@@ -752,6 +753,7 @@ struct TaskEditSheet: View {
_isRecurring = State(initialValue: task.isRecurring)
_recurrenceLabel = State(initialValue: task.recurrenceLabel)
_recurrenceEnd = State(initialValue: task.recurrenceEnd)
_recurrenceInterval = State(initialValue: task.recurrenceInterval)
_endDate = State(initialValue: task.endDate)
_isAllDay = State(initialValue: task.isAllDay)
_reminderDate = State(initialValue: task.reminderDate)
@@ -813,7 +815,7 @@ struct TaskEditSheet: View {
.font(.system(size: 12))
.foregroundColor(task.quadrant.color)
if let lbl = recurrenceLabel {
Text(lbl)
Text(RecurrenceRule.displayLabel(label: lbl, interval: recurrenceInterval ?? 1))
.font(AppFonts.sans(12))
.foregroundColor(AppColors.text3(cs))
}
@@ -903,7 +905,8 @@ struct TaskEditSheet: View {
isAllDay: $isAllDay,
reminderDate: $reminderDate,
constantReminder: $constantReminder,
recurrenceEnd: $recurrenceEnd
recurrenceEnd: $recurrenceEnd,
recurrenceInterval: $recurrenceInterval
)
}
}
@@ -914,7 +917,8 @@ struct TaskEditSheet: View {
taskVM.updateTask(task, title: t, dueDate: dueDate, hasTime: hasTime,
isRecurring: isRecurring, recurrenceLabel: recurrenceLabel,
endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate,
constantReminder: constantReminder, recurrenceEnd: recurrenceEnd)
constantReminder: constantReminder, recurrenceEnd: recurrenceEnd,
recurrenceInterval: recurrenceInterval)
dismiss()
}
}

View File

@@ -1355,7 +1355,7 @@ struct TaskRowView: View {
.font(AppFonts.sans(13, weight: .medium))
.foregroundColor(AppColors.text(cs))
if let d = task.dueDate, showDetails {
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceLabel ?? "Repeat")" : "")")
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceDisplayLabel ?? "Repeat")" : "")")
.font(AppFonts.mono(9.5))
.tracking(0.2)
.foregroundColor(AppColors.text3(cs))
@@ -1515,6 +1515,7 @@ struct NLParsed {
var tokenRanges: [NSRange] = []
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
var recurrenceInterval: Int? = nil
var recurrenceEnd: Date? = nil
var endDate: Date? = nil
var isAllDay: Bool = false
@@ -1564,9 +1565,10 @@ struct NLTaskParser {
}
// Recurrence
if let (r, label) = parseRecurrence(in: lower) {
if let (r, label, interval) = parseRecurrence(in: lower) {
result.isRecurring = true
result.recurrenceLabel = label
result.recurrenceInterval = interval > 1 ? interval : nil
rawRanges.append(r)
}
@@ -1691,7 +1693,11 @@ struct NLTaskParser {
// MARK: - Recurrence
private static func parseRecurrence(in text: String) -> (NSRange, String)? {
private static func parseRecurrence(in text: String) -> (NSRange, String, Int)? {
// Interval phrases ("every 2 weeks", "biweekly") are checked first since
// they're more specific than the plain unit phrases below.
if let (r, label, n) = parseIntervalRecurrence(in: text) { return (r, label, n) }
// Order matters: more specific phrases ("every weekday") must precede the
// broader ones ("every week" / "every day") so they win.
let pats: [(String, String)] = [
@@ -1711,7 +1717,36 @@ struct NLTaskParser {
("\\bdaily\\b", "Daily"),
]
for (pat, label) in pats {
if let r = match(pat, in: text) { return (r, label) }
if let r = match(pat, in: text) { return (r, label, 1) }
}
return nil
}
/// "every 2 weeks", "every three months", "biweekly", "fortnightly",
/// "bimonthly" anything with an actual N > 1 attached. "every 1 week"
/// falls through to the plain patterns above rather than being handled here.
private static func parseIntervalRecurrence(in text: String) -> (NSRange, String, Int)? {
if let r = match("\\b(biweekly|fortnightly)\\b", in: text) { return (r, "Weekly", 2) }
if let r = match("\\bbimonthly\\b", in: text) { return (r, "Monthly", 2) }
let wordNums = ["a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "four": 4,
"five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10]
let units: [(String, String)] = [
("days?", "Daily"),
("weeks?", "Weekly"),
("months?", "Monthly"),
("years?", "Yearly"),
]
for (unitPat, label) in units {
let pat = "\\bevery\\s+(\\d+|a|an|one|two|three|four|five|six|seven|eight|nine|ten)\\s+\(unitPat)\\b"
guard let rx = try? NSRegularExpression(pattern: pat),
let m = rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
m.numberOfRanges >= 2,
let nr = Range(m.range(at: 1), in: text) else { continue }
let ns = String(text[nr])
let n = Int(ns) ?? wordNums[ns] ?? 1
guard n > 1 else { continue }
return (m.range, label, n)
}
return nil
}
@@ -1955,7 +1990,9 @@ struct AddTaskSheet: View {
HStack(spacing: 4) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11, weight: .semibold))
Text(parsed.recurrenceLabel ?? "Recurring")
Text(parsed.recurrenceLabel.map {
RecurrenceRule.displayLabel(label: $0, interval: parsed.recurrenceInterval ?? 1)
} ?? "Recurring")
.font(AppFonts.sans(13, weight: .semibold))
}
.foregroundColor(AppColors.accent)
@@ -2071,7 +2108,8 @@ struct AddTaskSheet: View {
isAllDay: $parsed.isAllDay,
reminderDate: $reminderDate,
constantReminder: $constantReminder,
recurrenceEnd: $parsed.recurrenceEnd
recurrenceEnd: $parsed.recurrenceEnd,
recurrenceInterval: $parsed.recurrenceInterval
)
}
}
@@ -2082,6 +2120,7 @@ struct AddTaskSheet: View {
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
quadrant: prefilledQuadrant, category: selectedCategory,
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
recurrenceInterval: parsed.recurrenceInterval,
endDate: parsed.endDate, isAllDay: parsed.isAllDay,
priority: selectedPriority,
reminderDate: reminderDate, constantReminder: constantReminder,
@@ -2099,6 +2138,7 @@ struct TaskDatePickerSheet: View {
@Binding var isRecurring: Bool
@Binding var recurrenceLabel: String?
@Binding var recurrenceEnd: Date?
@Binding var recurrenceInterval: Int?
@Binding var endDate: Date?
@Binding var isAllDay: Bool
@Binding var reminderDate: Date?
@@ -2120,6 +2160,7 @@ struct TaskDatePickerSheet: View {
@State private var customNumber: Int
@State private var customUnit: Int // 0 = minutes, 1 = hours, 2 = days
@State private var localRepeat: String
@State private var localRecurrenceInterval: Int
@State private var localRecurrenceEnd: Date?
@State private var showRecurrenceEndPicker = false
@State private var localIsAllDay: Bool
@@ -2130,12 +2171,14 @@ struct TaskDatePickerSheet: View {
endDate: Binding<Date?>, isAllDay: Binding<Bool>,
reminderDate: Binding<Date?> = .constant(nil),
constantReminder: Binding<Bool> = .constant(false),
recurrenceEnd: Binding<Date?> = .constant(nil)) {
recurrenceEnd: Binding<Date?> = .constant(nil),
recurrenceInterval: Binding<Int?> = .constant(nil)) {
_selectedDate = selectedDate
_hasTime = hasTime
_isRecurring = isRecurring
_recurrenceLabel = recurrenceLabel
_recurrenceEnd = recurrenceEnd
_recurrenceInterval = recurrenceInterval
_endDate = endDate
_isAllDay = isAllDay
_reminderDate = reminderDate
@@ -2146,6 +2189,7 @@ struct TaskDatePickerSheet: View {
_localEndDate = State(initialValue: endDate.wrappedValue ?? start)
_localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil)
_localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None")
_localRecurrenceInterval = State(initialValue: recurrenceInterval.wrappedValue ?? 1)
_localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue)
_localIsAllDay = State(initialValue: isAllDay.wrappedValue)
_pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0)
@@ -2292,6 +2336,13 @@ struct TaskDatePickerSheet: View {
}
.buttonStyle(.plain)
// Every N (only for units where an interval means anything
// "Every Weekday" is always MonFri, no interval concept)
if localRepeat != "None" && localRepeat != "Every Weekday" {
Divider().padding(.leading, 54)
intervalRow
}
// Repeat until (only when a recurrence is set)
if localRepeat != "None" {
Divider().padding(.leading, 54)
@@ -2655,6 +2706,37 @@ struct TaskDatePickerSheet: View {
.padding(.horizontal, 16).frame(height: 52)
}
private var intervalUnitLabel: String {
let plural = localRecurrenceInterval != 1
switch localRepeat {
case "Daily": return plural ? "Days" : "Day"
case "Weekly": return plural ? "Weeks" : "Week"
case "Monthly": return plural ? "Months" : "Month"
case "Yearly": return plural ? "Years" : "Year"
default: return ""
}
}
@ViewBuilder
private var intervalRow: some View {
HStack(spacing: 14) {
Image(systemName: "number")
.font(.system(size: 18))
.foregroundColor(localRecurrenceInterval > 1 ? AppColors.accent : .secondary)
.frame(width: 24)
Text("Every")
.font(AppFonts.sans(16)).foregroundColor(.primary)
Spacer()
Stepper(value: $localRecurrenceInterval, in: 1...30) {
Text("\(localRecurrenceInterval) \(intervalUnitLabel)")
.font(AppFonts.sans(15))
.foregroundColor(localRecurrenceInterval > 1 ? AppColors.accent : .secondary)
}
.fixedSize()
}
.padding(.horizontal, 16).frame(height: 52)
}
private struct RepeatOption { let key: String; let display: String }
private var repeatOptions: [RepeatOption] {
@@ -2682,7 +2764,12 @@ struct TaskDatePickerSheet: View {
}
private var repeatDisplayValue: String {
repeatOptions.first(where: { $0.key == localRepeat })?.display ?? localRepeat
// At interval 1 keep the richer "Weekly (Tuesday)" style text; once an
// interval is set, "Every 2 Weeks" is clearer than trying to combine both.
if localRecurrenceInterval > 1 {
return RecurrenceRule.displayLabel(label: localRepeat, interval: localRecurrenceInterval)
}
return repeatOptions.first(where: { $0.key == localRepeat })?.display ?? localRepeat
}
private var timeFmt: DateFormatter {
@@ -2726,6 +2813,11 @@ struct TaskDatePickerSheet: View {
isRecurring = localRepeat != "None"
recurrenceLabel = localRepeat != "None" ? localRepeat : nil
recurrenceEnd = localRepeat != "None" ? localRecurrenceEnd : nil
// Store nil (not 1) at the default interval keeps "no interval set"
// and "explicitly every 1" indistinguishable, matching every other
// task's data shape and RecurrenceRule's own nil-means-1 default.
recurrenceInterval = (localRepeat != "None" && localRepeat != "Every Weekday"
&& localRecurrenceInterval > 1) ? localRecurrenceInterval : nil
// Reminder is a lead-time offset from the task date at the chosen time-of-day.
if let off = localReminderOffset {
reminderDate = computedReminderDate(offset: off)

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())
}
}

View File

@@ -206,7 +206,7 @@ struct ProgressDotProvider: AppIntentTimelineProvider {
return RecurrenceRule.span(label: "Yearly", due: due, now: now)
}
guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
return RecurrenceRule.span(label: label, due: due, now: now)
return RecurrenceRule.span(label: label, interval: task.recurrenceInterval ?? 1, due: due, now: now)
}
private static func startOfISOWeek(containing date: Date) -> Date {

View File

@@ -12,6 +12,7 @@ struct WidgetTask: Codable, Identifiable {
let category: String
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
var recurrenceInterval: Int? = nil
var createdAt: Date? = nil
var reminderDate: Date? = nil
var progress: Double? = nil
@@ -67,6 +68,7 @@ func loadWidgetTasks() -> [WidgetTask] {
WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate,
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category,
isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel,
recurrenceInterval: $0.recurrenceInterval,
createdAt: $0.createdAt, reminderDate: $0.reminderDate,
progress: $0.countdownProgress ?? $0.progress)
}
@@ -82,13 +84,14 @@ private struct RawTask: Codable {
var category: String = "reminder"
var isRecurring: Bool? = nil
var recurrenceLabel: String? = nil
var recurrenceInterval: Int? = nil
var createdAt: Date? = nil
var reminderDate: Date? = nil
var progress: Double? = nil
var countdownProgress: Double? = nil
enum CodingKeys: String, CodingKey {
case id, title, dueDate, isComplete, quadrant, category, isRecurring, recurrenceLabel
case createdAt, reminderDate, progress, countdownProgress
case recurrenceInterval, createdAt, reminderDate, progress, countdownProgress
}
}

View File

@@ -89,4 +89,21 @@ enum RecurrenceRule {
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)"
}
}