From b7af2dd76b1c45e73878a69a9de62a0150762ded Mon Sep 17 00:00:00 2001 From: kutesir Date: Sun, 12 Jul 2026 00:15:45 +0300 Subject: [PATCH] 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 --- KisaniCal/ISSUES.md | 66 ++++++++++++ KisaniCal/Models/TaskItem.swift | 23 ++++- KisaniCal/Views/MatrixView.swift | 10 +- KisaniCal/Views/TodayView.swift | 108 ++++++++++++++++++-- KisaniCalWidgets/EventCountdownWidget.swift | 16 ++- KisaniCalWidgets/KisaniCalWidgets.swift | 2 +- KisaniCalWidgets/WidgetData.swift | 5 +- Shared/RecurrenceRule.swift | 17 +++ 8 files changed, 225 insertions(+), 22 deletions(-) diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index d09c985..8033d02 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -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). diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index b503115..a39bb85 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -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 diff --git a/KisaniCal/Views/MatrixView.swift b/KisaniCal/Views/MatrixView.swift index 8c4fbcc..e99700f 100644 --- a/KisaniCal/Views/MatrixView.swift +++ b/KisaniCal/Views/MatrixView.swift @@ -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() } } diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index 1622547..1e53b9a 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -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, isAllDay: Binding, reminderDate: Binding = .constant(nil), constantReminder: Binding = .constant(false), - recurrenceEnd: Binding = .constant(nil)) { + recurrenceEnd: Binding = .constant(nil), + recurrenceInterval: Binding = .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 Mon–Fri, 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) diff --git a/KisaniCalWidgets/EventCountdownWidget.swift b/KisaniCalWidgets/EventCountdownWidget.swift index 4821695..65191d6 100644 --- a/KisaniCalWidgets/EventCountdownWidget.swift +++ b/KisaniCalWidgets/EventCountdownWidget.swift @@ -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()) } } diff --git a/KisaniCalWidgets/KisaniCalWidgets.swift b/KisaniCalWidgets/KisaniCalWidgets.swift index f8ca462..98eb5cb 100644 --- a/KisaniCalWidgets/KisaniCalWidgets.swift +++ b/KisaniCalWidgets/KisaniCalWidgets.swift @@ -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 { diff --git a/KisaniCalWidgets/WidgetData.swift b/KisaniCalWidgets/WidgetData.swift index 75ee4de..bc0a130 100644 --- a/KisaniCalWidgets/WidgetData.swift +++ b/KisaniCalWidgets/WidgetData.swift @@ -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 } } diff --git a/Shared/RecurrenceRule.swift b/Shared/RecurrenceRule.swift index 82326b5..2b2a56e 100644 --- a/Shared/RecurrenceRule.swift +++ b/Shared/RecurrenceRule.swift @@ -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)" + } }