tasks: add "every N weeks/months/days/years" recurrence (KC-70)
Some checks failed
CI / build-and-test (push) Has been cancelled
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:
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Mon–Fri, 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)
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user