Recurrence: add "repeat until" end date UI (KC-12)

Thread recurrenceEnd through NLParsed, TaskDatePickerSheet, the add/edit
sheets, and addTask/updateTask. The date picker now shows a "Repeat until"
row (graphical picker, default "Forever", with Clear) whenever a
recurrence is set; the engine already honored recurrenceEnd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-06-09 10:47:13 +03:00
parent 6817d3e24a
commit 0870359ae8
4 changed files with 68 additions and 18 deletions

View File

@@ -366,7 +366,7 @@ Files: `CalendarView.swift`, `TodayView.swift`. Committed in `7b2b61a`.
## KC-12 — Recurring tasks don't appear on future dates ## KC-12 — Recurring tasks don't appear on future dates
**Status:** In Progress (core engine done & build-verified; "repeat until" UI pending) **Status:** In Progress (implemented & build-verified, pending device build)
**Reported by:** User **Reported by:** User
**Area:** Tasks / Calendar **Area:** Tasks / Calendar
@@ -392,6 +392,10 @@ a single `dueDate`, and every surface matched by `isDate(dueDate, inSameDayAs:)`
Files: `TaskItem.swift`, `CalendarView.swift`. Files: `TaskItem.swift`, `CalendarView.swift`.
### Pending ### "Repeat until" UI (done)
- "Repeat until" date UI in the task date picker (thread `recurrenceEnd` through the - Added `recurrenceEnd` through `NLParsed`, `TaskDatePickerSheet`, both add/edit
add/edit sheets + `addTask`/`updateTask`). Engine already honors it. sheets, and `addTask`/`updateTask`.
- Date picker shows a "Repeat until" row (graphical date picker, default "Forever",
with Clear) whenever a recurrence is selected.
Files: `TaskItem.swift`, `CalendarView.swift`, `TodayView.swift`, `MatrixView.swift`.

View File

@@ -368,21 +368,23 @@ class TaskViewModel: ObservableObject {
recurrenceLabel: String? = nil, recurrenceLabel: String? = 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) { constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime, var item = TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
quadrant: quadrant, category: category, quadrant: quadrant, category: category,
taskColor: taskColor, isRecurring: isRecurring, taskColor: taskColor, isRecurring: isRecurring,
recurrenceLabel: recurrenceLabel, recurrenceLabel: recurrenceLabel,
endDate: endDate, isAllDay: isAllDay, endDate: endDate, isAllDay: isAllDay,
priority: priority, reminderDate: reminderDate, priority: priority, reminderDate: reminderDate,
constantReminder: constantReminder)) constantReminder: constantReminder)
item.recurrenceEnd = recurrenceEnd
tasks.append(item)
save() save()
} }
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) { constantReminder: Bool = false, recurrenceEnd: Date? = 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
@@ -393,6 +395,7 @@ class TaskViewModel: ObservableObject {
tasks[idx].isAllDay = isAllDay tasks[idx].isAllDay = isAllDay
tasks[idx].reminderDate = reminderDate tasks[idx].reminderDate = reminderDate
tasks[idx].constantReminder = constantReminder tasks[idx].constantReminder = constantReminder
tasks[idx].recurrenceEnd = recurrenceEnd
save() save()
} }

View File

@@ -675,6 +675,7 @@ struct TaskEditSheet: View {
@State private var hasTime: Bool @State private var hasTime: Bool
@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 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?
@@ -688,6 +689,7 @@ struct TaskEditSheet: View {
_hasTime = State(initialValue: task.hasTime) _hasTime = State(initialValue: task.hasTime)
_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)
_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)
@@ -838,7 +840,8 @@ struct TaskEditSheet: View {
endDate: $endDate, endDate: $endDate,
isAllDay: $isAllDay, isAllDay: $isAllDay,
reminderDate: $reminderDate, reminderDate: $reminderDate,
constantReminder: $constantReminder constantReminder: $constantReminder,
recurrenceEnd: $recurrenceEnd
) )
} }
} }
@@ -849,7 +852,7 @@ 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) constantReminder: constantReminder, recurrenceEnd: recurrenceEnd)
dismiss() dismiss()
} }
} }

View File

@@ -1304,6 +1304,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 recurrenceEnd: Date? = nil
var endDate: Date? = nil var endDate: Date? = nil
var isAllDay: Bool = false var isAllDay: Bool = false
var isBirthday: Bool = false var isBirthday: Bool = false
@@ -1807,7 +1808,8 @@ struct AddTaskSheet: View {
endDate: $parsed.endDate, endDate: $parsed.endDate,
isAllDay: $parsed.isAllDay, isAllDay: $parsed.isAllDay,
reminderDate: $reminderDate, reminderDate: $reminderDate,
constantReminder: $constantReminder constantReminder: $constantReminder,
recurrenceEnd: $parsed.recurrenceEnd
) )
} }
} }
@@ -1818,7 +1820,8 @@ struct AddTaskSheet: View {
quadrant: selectedQuadrant, category: selectedCategory, quadrant: selectedQuadrant, category: selectedCategory,
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel, isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
endDate: parsed.endDate, isAllDay: parsed.isAllDay, endDate: parsed.endDate, isAllDay: parsed.isAllDay,
reminderDate: reminderDate, constantReminder: constantReminder) reminderDate: reminderDate, constantReminder: constantReminder,
recurrenceEnd: parsed.recurrenceEnd)
dismiss() dismiss()
} }
@@ -1852,6 +1855,7 @@ struct TaskDatePickerSheet: View {
@Binding var hasTime: Bool @Binding var hasTime: Bool
@Binding var isRecurring: Bool @Binding var isRecurring: Bool
@Binding var recurrenceLabel: String? @Binding var recurrenceLabel: String?
@Binding var recurrenceEnd: Date?
@Binding var endDate: Date? @Binding var endDate: Date?
@Binding var isAllDay: Bool @Binding var isAllDay: Bool
@Binding var reminderDate: Date? @Binding var reminderDate: Date?
@@ -1876,6 +1880,8 @@ struct TaskDatePickerSheet: View {
@State private var customHour: Int @State private var customHour: Int
@State private var customMinute: Int @State private var customMinute: Int
@State private var localRepeat: String @State private var localRepeat: String
@State private var localRecurrenceEnd: Date?
@State private var showRecurrenceEndPicker = false
@State private var localIsAllDay: Bool @State private var localIsAllDay: Bool
@State private var editingEnd = false @State private var editingEnd = false
@@ -1883,11 +1889,13 @@ struct TaskDatePickerSheet: View {
isRecurring: Binding<Bool>, recurrenceLabel: Binding<String?>, isRecurring: Binding<Bool>, recurrenceLabel: Binding<String?>,
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)) {
_selectedDate = selectedDate _selectedDate = selectedDate
_hasTime = hasTime _hasTime = hasTime
_isRecurring = isRecurring _isRecurring = isRecurring
_recurrenceLabel = recurrenceLabel _recurrenceLabel = recurrenceLabel
_recurrenceEnd = recurrenceEnd
_endDate = endDate _endDate = endDate
_isAllDay = isAllDay _isAllDay = isAllDay
_reminderDate = reminderDate _reminderDate = reminderDate
@@ -1898,6 +1906,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")
_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)
_localConstant = State(initialValue: constantReminder.wrappedValue) _localConstant = State(initialValue: constantReminder.wrappedValue)
@@ -2048,6 +2057,32 @@ struct TaskDatePickerSheet: View {
value: repeatDisplayValue, active: localRepeat != "None") value: repeatDisplayValue, active: localRepeat != "None")
} }
.buttonStyle(.plain) .buttonStyle(.plain)
// Repeat until (only when a recurrence is set)
if localRepeat != "None" {
Divider().padding(.leading, 54)
Button { withAnimation(KisaniSpring.micro) { showRecurrenceEndPicker.toggle() } } label: {
pickerRow(icon: "calendar.badge.exclamationmark", label: "Repeat until",
value: localRecurrenceEnd.map { repeatEndFmt.string(from: $0) } ?? "Forever",
active: localRecurrenceEnd != nil)
}
.buttonStyle(.plain)
if showRecurrenceEndPicker {
DatePicker("", selection: Binding(
get: { localRecurrenceEnd ?? localDate },
set: { localRecurrenceEnd = $0 }
), in: localDate..., displayedComponents: .date)
.datePickerStyle(.graphical).labelsHidden()
.padding(.horizontal, 8)
if localRecurrenceEnd != nil {
Button { withAnimation(KisaniSpring.micro) { localRecurrenceEnd = nil; showRecurrenceEndPicker = false } } label: {
Text("Clear end date").font(AppFonts.sans(13)).foregroundColor(AppColors.accent)
}
.frame(maxWidth: .infinity).padding(.bottom, 8)
}
}
}
} }
.background(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground)) .background(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14)) .clipShape(RoundedRectangle(cornerRadius: 14))
@@ -2453,6 +2488,10 @@ struct TaskDatePickerSheet: View {
return "Duration: \(n) day\(n == 1 ? "" : "s")" return "Duration: \(n) day\(n == 1 ? "" : "s")"
} }
private let repeatEndFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMM d, yyyy"; return f
}()
private func commit() { private func commit() {
if pickerMode == 0 { if pickerMode == 0 {
var final = Calendar.current.startOfDay(for: localDate) var final = Calendar.current.startOfDay(for: localDate)
@@ -2469,6 +2508,7 @@ struct TaskDatePickerSheet: View {
isAllDay = false isAllDay = false
isRecurring = localRepeat != "None" isRecurring = localRepeat != "None"
recurrenceLabel = localRepeat != "None" ? localRepeat : nil recurrenceLabel = localRepeat != "None" ? localRepeat : nil
recurrenceEnd = localRepeat != "None" ? localRecurrenceEnd : 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)