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 `project.yml` change; not build-verified in Xcode itself (no iOS runtime
here), though the standalone test above is real, executed verification of here), though the standalone test above is real, executed verification of
the actual shipped file. 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 taskColor: TaskColor = .orange
var isRecurring: Bool = false var isRecurring: Bool = false
var recurrenceLabel: String? = nil 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 isPinned: Bool = false
var endDate: Date? = nil var endDate: Date? = nil
var isAllDay: Bool = false var isAllDay: Bool = false
@@ -30,6 +33,15 @@ struct TaskItem: Identifiable, Codable, Equatable {
// Matrix: horizontal manual moves override urgency only. // Matrix: horizontal manual moves override urgency only.
// nil = auto from deadline, true = force urgent, false = force not-urgent. // nil = auto from deadline, true = force urgent, false = force not-urgent.
var urgencyOverride: Bool? = nil 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 { enum Priority: String, Codable, CaseIterable, Identifiable {
@@ -386,8 +398,8 @@ class TaskViewModel: ObservableObject {
/// extensions use for their countdown windows one implementation, not three. /// extensions use for their countdown windows one implementation, not three.
func isOccurrence(_ t: TaskItem, on date: Date) -> Bool { func isOccurrence(_ t: TaskItem, on date: Date) -> Bool {
guard recurs(t), let start = t.dueDate else { return false } guard recurs(t), let start = t.dueDate else { return false }
return RecurrenceRule.isOccurrence(label: t.recurrenceLabel, start: start, date: date, return RecurrenceRule.isOccurrence(label: t.recurrenceLabel, interval: t.recurrenceInterval ?? 1,
end: t.recurrenceEnd) start: start, date: date, end: t.recurrenceEnd)
} }
func occurrenceComplete(_ t: TaskItem, on date: Date) -> Bool { func occurrenceComplete(_ t: TaskItem, on date: Date) -> Bool {
@@ -621,7 +633,7 @@ class TaskViewModel: ObservableObject {
func addTask(title: String, dueDate: Date?, hasTime: Bool = false, func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder, quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
taskColor: TaskColor = .orange, isRecurring: Bool = false, taskColor: TaskColor = .orange, isRecurring: Bool = false,
recurrenceLabel: String? = nil, recurrenceLabel: String? = nil, recurrenceInterval: Int? = nil,
endDate: Date? = nil, isAllDay: Bool = false, endDate: Date? = nil, isAllDay: Bool = false,
priority: Priority = .none, reminderDate: Date? = nil, priority: Priority = .none, reminderDate: Date? = nil,
constantReminder: Bool = false, recurrenceEnd: Date? = nil) { constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
@@ -634,6 +646,7 @@ class TaskViewModel: ObservableObject {
endDate: endDate, isAllDay: isAllDay, endDate: endDate, isAllDay: isAllDay,
priority: resolvedPriority, reminderDate: reminderDate, priority: resolvedPriority, reminderDate: reminderDate,
constantReminder: constantReminder) constantReminder: constantReminder)
item.recurrenceInterval = recurrenceInterval
item.recurrenceEnd = recurrenceEnd item.recurrenceEnd = recurrenceEnd
item.quadrant = displayQuadrant(item) // color/widget identity follows placement item.quadrant = displayQuadrant(item) // color/widget identity follows placement
tasks.append(item) tasks.append(item)
@@ -643,13 +656,15 @@ class TaskViewModel: ObservableObject {
func updateTask(_ task: TaskItem, title: String, dueDate: Date?, hasTime: Bool, func updateTask(_ task: TaskItem, title: String, dueDate: Date?, hasTime: Bool,
isRecurring: Bool, recurrenceLabel: String?, isRecurring: Bool, recurrenceLabel: String?,
endDate: Date?, isAllDay: Bool, reminderDate: Date?, 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 } guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
tasks[idx].title = title tasks[idx].title = title
tasks[idx].dueDate = dueDate tasks[idx].dueDate = dueDate
tasks[idx].hasTime = hasTime tasks[idx].hasTime = hasTime
tasks[idx].isRecurring = isRecurring tasks[idx].isRecurring = isRecurring
tasks[idx].recurrenceLabel = recurrenceLabel tasks[idx].recurrenceLabel = recurrenceLabel
tasks[idx].recurrenceInterval = recurrenceInterval
tasks[idx].endDate = endDate tasks[idx].endDate = endDate
tasks[idx].isAllDay = isAllDay tasks[idx].isAllDay = isAllDay
tasks[idx].reminderDate = reminderDate tasks[idx].reminderDate = reminderDate

View File

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

View File

@@ -1355,7 +1355,7 @@ struct TaskRowView: View {
.font(AppFonts.sans(13, weight: .medium)) .font(AppFonts.sans(13, weight: .medium))
.foregroundColor(AppColors.text(cs)) .foregroundColor(AppColors.text(cs))
if let d = task.dueDate, showDetails { 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)) .font(AppFonts.mono(9.5))
.tracking(0.2) .tracking(0.2)
.foregroundColor(AppColors.text3(cs)) .foregroundColor(AppColors.text3(cs))
@@ -1515,6 +1515,7 @@ struct NLParsed {
var tokenRanges: [NSRange] = [] var tokenRanges: [NSRange] = []
var isRecurring: Bool = false var isRecurring: Bool = false
var recurrenceLabel: String? = nil var recurrenceLabel: String? = nil
var recurrenceInterval: Int? = nil
var recurrenceEnd: Date? = nil var recurrenceEnd: Date? = nil
var endDate: Date? = nil var endDate: Date? = nil
var isAllDay: Bool = false var isAllDay: Bool = false
@@ -1564,9 +1565,10 @@ struct NLTaskParser {
} }
// Recurrence // Recurrence
if let (r, label) = parseRecurrence(in: lower) { if let (r, label, interval) = parseRecurrence(in: lower) {
result.isRecurring = true result.isRecurring = true
result.recurrenceLabel = label result.recurrenceLabel = label
result.recurrenceInterval = interval > 1 ? interval : nil
rawRanges.append(r) rawRanges.append(r)
} }
@@ -1691,7 +1693,11 @@ struct NLTaskParser {
// MARK: - Recurrence // 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 // Order matters: more specific phrases ("every weekday") must precede the
// broader ones ("every week" / "every day") so they win. // broader ones ("every week" / "every day") so they win.
let pats: [(String, String)] = [ let pats: [(String, String)] = [
@@ -1711,7 +1717,36 @@ struct NLTaskParser {
("\\bdaily\\b", "Daily"), ("\\bdaily\\b", "Daily"),
] ]
for (pat, label) in pats { 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 return nil
} }
@@ -1955,7 +1990,9 @@ struct AddTaskSheet: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "arrow.clockwise") Image(systemName: "arrow.clockwise")
.font(.system(size: 11, weight: .semibold)) .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)) .font(AppFonts.sans(13, weight: .semibold))
} }
.foregroundColor(AppColors.accent) .foregroundColor(AppColors.accent)
@@ -2071,7 +2108,8 @@ struct AddTaskSheet: View {
isAllDay: $parsed.isAllDay, isAllDay: $parsed.isAllDay,
reminderDate: $reminderDate, reminderDate: $reminderDate,
constantReminder: $constantReminder, 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, taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
quadrant: prefilledQuadrant, category: selectedCategory, quadrant: prefilledQuadrant, category: selectedCategory,
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel, isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
recurrenceInterval: parsed.recurrenceInterval,
endDate: parsed.endDate, isAllDay: parsed.isAllDay, endDate: parsed.endDate, isAllDay: parsed.isAllDay,
priority: selectedPriority, priority: selectedPriority,
reminderDate: reminderDate, constantReminder: constantReminder, reminderDate: reminderDate, constantReminder: constantReminder,
@@ -2099,6 +2138,7 @@ struct TaskDatePickerSheet: View {
@Binding var isRecurring: Bool @Binding var isRecurring: Bool
@Binding var recurrenceLabel: String? @Binding var recurrenceLabel: String?
@Binding var recurrenceEnd: Date? @Binding var recurrenceEnd: Date?
@Binding var recurrenceInterval: Int?
@Binding var endDate: Date? @Binding var endDate: Date?
@Binding var isAllDay: Bool @Binding var isAllDay: Bool
@Binding var reminderDate: Date? @Binding var reminderDate: Date?
@@ -2120,6 +2160,7 @@ struct TaskDatePickerSheet: View {
@State private var customNumber: Int @State private var customNumber: Int
@State private var customUnit: Int // 0 = minutes, 1 = hours, 2 = days @State private var customUnit: Int // 0 = minutes, 1 = hours, 2 = days
@State private var localRepeat: String @State private var localRepeat: String
@State private var localRecurrenceInterval: Int
@State private var localRecurrenceEnd: Date? @State private var localRecurrenceEnd: Date?
@State private var showRecurrenceEndPicker = false @State private var showRecurrenceEndPicker = false
@State private var localIsAllDay: Bool @State private var localIsAllDay: Bool
@@ -2130,12 +2171,14 @@ struct TaskDatePickerSheet: View {
endDate: Binding<Date?>, isAllDay: Binding<Bool>, endDate: Binding<Date?>, isAllDay: Binding<Bool>,
reminderDate: Binding<Date?> = .constant(nil), reminderDate: Binding<Date?> = .constant(nil),
constantReminder: Binding<Bool> = .constant(false), constantReminder: Binding<Bool> = .constant(false),
recurrenceEnd: Binding<Date?> = .constant(nil)) { recurrenceEnd: Binding<Date?> = .constant(nil),
recurrenceInterval: Binding<Int?> = .constant(nil)) {
_selectedDate = selectedDate _selectedDate = selectedDate
_hasTime = hasTime _hasTime = hasTime
_isRecurring = isRecurring _isRecurring = isRecurring
_recurrenceLabel = recurrenceLabel _recurrenceLabel = recurrenceLabel
_recurrenceEnd = recurrenceEnd _recurrenceEnd = recurrenceEnd
_recurrenceInterval = recurrenceInterval
_endDate = endDate _endDate = endDate
_isAllDay = isAllDay _isAllDay = isAllDay
_reminderDate = reminderDate _reminderDate = reminderDate
@@ -2146,6 +2189,7 @@ struct TaskDatePickerSheet: View {
_localEndDate = State(initialValue: endDate.wrappedValue ?? start) _localEndDate = State(initialValue: endDate.wrappedValue ?? start)
_localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil) _localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil)
_localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None") _localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None")
_localRecurrenceInterval = State(initialValue: recurrenceInterval.wrappedValue ?? 1)
_localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue) _localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue)
_localIsAllDay = State(initialValue: isAllDay.wrappedValue) _localIsAllDay = State(initialValue: isAllDay.wrappedValue)
_pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0) _pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0)
@@ -2292,6 +2336,13 @@ struct TaskDatePickerSheet: View {
} }
.buttonStyle(.plain) .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) // Repeat until (only when a recurrence is set)
if localRepeat != "None" { if localRepeat != "None" {
Divider().padding(.leading, 54) Divider().padding(.leading, 54)
@@ -2655,6 +2706,37 @@ struct TaskDatePickerSheet: View {
.padding(.horizontal, 16).frame(height: 52) .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 struct RepeatOption { let key: String; let display: String }
private var repeatOptions: [RepeatOption] { private var repeatOptions: [RepeatOption] {
@@ -2682,7 +2764,12 @@ struct TaskDatePickerSheet: View {
} }
private var repeatDisplayValue: String { 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 { private var timeFmt: DateFormatter {
@@ -2726,6 +2813,11 @@ struct TaskDatePickerSheet: View {
isRecurring = localRepeat != "None" isRecurring = localRepeat != "None"
recurrenceLabel = localRepeat != "None" ? localRepeat : nil recurrenceLabel = localRepeat != "None" ? localRepeat : nil
recurrenceEnd = localRepeat != "None" ? localRecurrenceEnd : 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. // Reminder is a lead-time offset from the task date at the chosen time-of-day.
if let off = localReminderOffset { if let off = localReminderOffset {
reminderDate = computedReminderDate(offset: off) reminderDate = computedReminderDate(offset: off)

View File

@@ -78,6 +78,7 @@ struct EventEntity: AppEntity {
let dueDate: Date? let dueDate: Date?
var isRecurring: Bool = false var isRecurring: Bool = false
var recurrenceLabel: String? = nil var recurrenceLabel: String? = nil
var recurrenceInterval: Int? = nil
var createdAt: Date? = nil var createdAt: Date? = nil
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event" static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
@@ -107,6 +108,7 @@ struct EventQuery: EntityQuery {
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate, .map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel, isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel,
recurrenceInterval: $0.recurrenceInterval,
createdAt: $0.createdAt) } createdAt: $0.createdAt) }
} }
} }
@@ -238,6 +240,7 @@ struct EventProvider: AppIntentTimelineProvider {
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate, .map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel, isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel,
recurrenceInterval: $0.recurrenceInterval,
createdAt: $0.createdAt) } 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 /// 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, /// 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. /// 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?, anchorKey: String, explicitStart: Date?,
taskCreatedAt: Date? = nil) -> (start: Date, target: 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) return (span.prev, span.next)
} }
if let s = explicitStart, s < due { return (s, due) } if let s = explicitStart, s < due { return (s, due) }
@@ -294,7 +297,8 @@ struct EventProvider: AppIntentTimelineProvider {
let trackedId = task.id.uuidString let trackedId = task.id.uuidString
let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date
let s = resolveSpan(due: task.dueDate ?? now, isRecurring: task.isRecurring, 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) taskCreatedAt: task.createdAt)
return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode) 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 ev = upcoming[idx]
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel, let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
interval: ev.recurrenceInterval,
anchorKey: ev.id, explicitStart: nil, taskCreatedAt: ev.createdAt) anchorKey: ev.id, explicitStart: nil, taskCreatedAt: ev.createdAt)
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode) return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
} }
if let ev = config.event, let due = ev.dueDate { if let ev = config.event, let due = ev.dueDate {
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel, let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
interval: ev.recurrenceInterval,
anchorKey: ev.id, explicitStart: config.startDate, anchorKey: ev.id, explicitStart: config.startDate,
taskCreatedAt: ev.createdAt) taskCreatedAt: ev.createdAt)
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode) 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 /// The actual math lives in RecurrenceRule (Shared/) shared with the main
/// app's occurrence matching and the other countdown widget's copy of this /// app's occurrence matching and the other countdown widget's copy of this
/// same calculation, so none of the three can independently drift. /// same calculation, so none of the three can independently drift.
private static func recurrenceSpan(label: String, due: Date) -> (prev: Date, next: Date)? { private static func recurrenceSpan(label: String, interval: Int = 1, due: Date) -> (prev: Date, next: Date)? {
RecurrenceRule.span(label: label, due: due, now: 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) return RecurrenceRule.span(label: "Yearly", due: due, now: now)
} }
guard task.isRecurring, let label = task.recurrenceLabel else { return nil } 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 { private static func startOfISOWeek(containing date: Date) -> Date {

View File

@@ -12,6 +12,7 @@ struct WidgetTask: Codable, Identifiable {
let category: String let category: String
var isRecurring: Bool = false var isRecurring: Bool = false
var recurrenceLabel: String? = nil var recurrenceLabel: String? = nil
var recurrenceInterval: Int? = nil
var createdAt: Date? = nil var createdAt: Date? = nil
var reminderDate: Date? = nil var reminderDate: Date? = nil
var progress: Double? = nil var progress: Double? = nil
@@ -67,6 +68,7 @@ func loadWidgetTasks() -> [WidgetTask] {
WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate, WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate,
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category, isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category,
isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel, isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel,
recurrenceInterval: $0.recurrenceInterval,
createdAt: $0.createdAt, reminderDate: $0.reminderDate, createdAt: $0.createdAt, reminderDate: $0.reminderDate,
progress: $0.countdownProgress ?? $0.progress) progress: $0.countdownProgress ?? $0.progress)
} }
@@ -82,13 +84,14 @@ private struct RawTask: Codable {
var category: String = "reminder" var category: String = "reminder"
var isRecurring: Bool? = nil var isRecurring: Bool? = nil
var recurrenceLabel: String? = nil var recurrenceLabel: String? = nil
var recurrenceInterval: Int? = nil
var createdAt: Date? = nil var createdAt: Date? = nil
var reminderDate: Date? = nil var reminderDate: Date? = nil
var progress: Double? = nil var progress: Double? = nil
var countdownProgress: Double? = nil var countdownProgress: Double? = nil
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, title, dueDate, isComplete, quadrant, category, isRecurring, recurrenceLabel 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 } guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
return (prev, next) 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)"
}
} }