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
|
||||
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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1355,7 +1355,7 @@ struct TaskRowView: View {
|
||||
.font(AppFonts.sans(13, weight: .medium))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
if let d = task.dueDate, showDetails {
|
||||
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceLabel ?? "Repeat")" : "")")
|
||||
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceDisplayLabel ?? "Repeat")" : "")")
|
||||
.font(AppFonts.mono(9.5))
|
||||
.tracking(0.2)
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
@@ -1515,6 +1515,7 @@ struct NLParsed {
|
||||
var tokenRanges: [NSRange] = []
|
||||
var isRecurring: Bool = false
|
||||
var recurrenceLabel: String? = nil
|
||||
var recurrenceInterval: Int? = nil
|
||||
var recurrenceEnd: Date? = nil
|
||||
var endDate: Date? = nil
|
||||
var isAllDay: Bool = false
|
||||
@@ -1564,9 +1565,10 @@ struct NLTaskParser {
|
||||
}
|
||||
|
||||
// ── Recurrence ──────────────────────────────────────────────────────
|
||||
if let (r, label) = parseRecurrence(in: lower) {
|
||||
if let (r, label, interval) = parseRecurrence(in: lower) {
|
||||
result.isRecurring = true
|
||||
result.recurrenceLabel = label
|
||||
result.recurrenceInterval = interval > 1 ? interval : nil
|
||||
rawRanges.append(r)
|
||||
}
|
||||
|
||||
@@ -1691,7 +1693,11 @@ struct NLTaskParser {
|
||||
|
||||
// MARK: - Recurrence
|
||||
|
||||
private static func parseRecurrence(in text: String) -> (NSRange, String)? {
|
||||
private static func parseRecurrence(in text: String) -> (NSRange, String, Int)? {
|
||||
// Interval phrases ("every 2 weeks", "biweekly") are checked first since
|
||||
// they're more specific than the plain unit phrases below.
|
||||
if let (r, label, n) = parseIntervalRecurrence(in: text) { return (r, label, n) }
|
||||
|
||||
// Order matters: more specific phrases ("every weekday") must precede the
|
||||
// broader ones ("every week" / "every day") so they win.
|
||||
let pats: [(String, String)] = [
|
||||
@@ -1711,7 +1717,36 @@ struct NLTaskParser {
|
||||
("\\bdaily\\b", "Daily"),
|
||||
]
|
||||
for (pat, label) in pats {
|
||||
if let r = match(pat, in: text) { return (r, label) }
|
||||
if let r = match(pat, in: text) { return (r, label, 1) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// "every 2 weeks", "every three months", "biweekly", "fortnightly",
|
||||
/// "bimonthly" — anything with an actual N > 1 attached. "every 1 week"
|
||||
/// falls through to the plain patterns above rather than being handled here.
|
||||
private static func parseIntervalRecurrence(in text: String) -> (NSRange, String, Int)? {
|
||||
if let r = match("\\b(biweekly|fortnightly)\\b", in: text) { return (r, "Weekly", 2) }
|
||||
if let r = match("\\bbimonthly\\b", in: text) { return (r, "Monthly", 2) }
|
||||
|
||||
let wordNums = ["a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "four": 4,
|
||||
"five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10]
|
||||
let units: [(String, String)] = [
|
||||
("days?", "Daily"),
|
||||
("weeks?", "Weekly"),
|
||||
("months?", "Monthly"),
|
||||
("years?", "Yearly"),
|
||||
]
|
||||
for (unitPat, label) in units {
|
||||
let pat = "\\bevery\\s+(\\d+|a|an|one|two|three|four|five|six|seven|eight|nine|ten)\\s+\(unitPat)\\b"
|
||||
guard let rx = try? NSRegularExpression(pattern: pat),
|
||||
let m = rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
|
||||
m.numberOfRanges >= 2,
|
||||
let nr = Range(m.range(at: 1), in: text) else { continue }
|
||||
let ns = String(text[nr])
|
||||
let n = Int(ns) ?? wordNums[ns] ?? 1
|
||||
guard n > 1 else { continue }
|
||||
return (m.range, label, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1955,7 +1990,9 @@ struct AddTaskSheet: View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
Text(parsed.recurrenceLabel ?? "Recurring")
|
||||
Text(parsed.recurrenceLabel.map {
|
||||
RecurrenceRule.displayLabel(label: $0, interval: parsed.recurrenceInterval ?? 1)
|
||||
} ?? "Recurring")
|
||||
.font(AppFonts.sans(13, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(AppColors.accent)
|
||||
@@ -2071,7 +2108,8 @@ struct AddTaskSheet: View {
|
||||
isAllDay: $parsed.isAllDay,
|
||||
reminderDate: $reminderDate,
|
||||
constantReminder: $constantReminder,
|
||||
recurrenceEnd: $parsed.recurrenceEnd
|
||||
recurrenceEnd: $parsed.recurrenceEnd,
|
||||
recurrenceInterval: $parsed.recurrenceInterval
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2082,6 +2120,7 @@ struct AddTaskSheet: View {
|
||||
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
|
||||
quadrant: prefilledQuadrant, category: selectedCategory,
|
||||
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
|
||||
recurrenceInterval: parsed.recurrenceInterval,
|
||||
endDate: parsed.endDate, isAllDay: parsed.isAllDay,
|
||||
priority: selectedPriority,
|
||||
reminderDate: reminderDate, constantReminder: constantReminder,
|
||||
@@ -2099,6 +2138,7 @@ struct TaskDatePickerSheet: View {
|
||||
@Binding var isRecurring: Bool
|
||||
@Binding var recurrenceLabel: String?
|
||||
@Binding var recurrenceEnd: Date?
|
||||
@Binding var recurrenceInterval: Int?
|
||||
@Binding var endDate: Date?
|
||||
@Binding var isAllDay: Bool
|
||||
@Binding var reminderDate: Date?
|
||||
@@ -2120,6 +2160,7 @@ struct TaskDatePickerSheet: View {
|
||||
@State private var customNumber: Int
|
||||
@State private var customUnit: Int // 0 = minutes, 1 = hours, 2 = days
|
||||
@State private var localRepeat: String
|
||||
@State private var localRecurrenceInterval: Int
|
||||
@State private var localRecurrenceEnd: Date?
|
||||
@State private var showRecurrenceEndPicker = false
|
||||
@State private var localIsAllDay: Bool
|
||||
@@ -2130,12 +2171,14 @@ struct TaskDatePickerSheet: View {
|
||||
endDate: Binding<Date?>, isAllDay: Binding<Bool>,
|
||||
reminderDate: Binding<Date?> = .constant(nil),
|
||||
constantReminder: Binding<Bool> = .constant(false),
|
||||
recurrenceEnd: Binding<Date?> = .constant(nil)) {
|
||||
recurrenceEnd: Binding<Date?> = .constant(nil),
|
||||
recurrenceInterval: Binding<Int?> = .constant(nil)) {
|
||||
_selectedDate = selectedDate
|
||||
_hasTime = hasTime
|
||||
_isRecurring = isRecurring
|
||||
_recurrenceLabel = recurrenceLabel
|
||||
_recurrenceEnd = recurrenceEnd
|
||||
_recurrenceInterval = recurrenceInterval
|
||||
_endDate = endDate
|
||||
_isAllDay = isAllDay
|
||||
_reminderDate = reminderDate
|
||||
@@ -2146,6 +2189,7 @@ struct TaskDatePickerSheet: View {
|
||||
_localEndDate = State(initialValue: endDate.wrappedValue ?? start)
|
||||
_localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil)
|
||||
_localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None")
|
||||
_localRecurrenceInterval = State(initialValue: recurrenceInterval.wrappedValue ?? 1)
|
||||
_localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue)
|
||||
_localIsAllDay = State(initialValue: isAllDay.wrappedValue)
|
||||
_pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0)
|
||||
@@ -2292,6 +2336,13 @@ struct TaskDatePickerSheet: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// ── Every N (only for units where an interval means anything —
|
||||
// "Every Weekday" is always 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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user