From 0870359ae8d155ceeab690253a3de31e0bf3900e Mon Sep 17 00:00:00 2001 From: kutesir Date: Tue, 9 Jun 2026 10:47:13 +0300 Subject: [PATCH] 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 --- KisaniCal/ISSUES.md | 12 ++++++--- KisaniCal/Models/TaskItem.swift | 21 ++++++++------- KisaniCal/Views/MatrixView.swift | 7 +++-- KisaniCal/Views/TodayView.swift | 46 +++++++++++++++++++++++++++++--- 4 files changed, 68 insertions(+), 18 deletions(-) diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index ebba150..ecbc1bc 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -366,7 +366,7 @@ Files: `CalendarView.swift`, `TodayView.swift`. Committed in `7b2b61a`. ## 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 **Area:** Tasks / Calendar @@ -392,6 +392,10 @@ a single `dueDate`, and every surface matched by `isDate(dueDate, inSameDayAs:)` Files: `TaskItem.swift`, `CalendarView.swift`. -### Pending -- "Repeat until" date UI in the task date picker (thread `recurrenceEnd` through the - add/edit sheets + `addTask`/`updateTask`). Engine already honors it. +### "Repeat until" UI (done) +- Added `recurrenceEnd` through `NLParsed`, `TaskDatePickerSheet`, both add/edit + 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`. diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index 5934ee7..1b21dcd 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -368,21 +368,23 @@ class TaskViewModel: ObservableObject { recurrenceLabel: String? = nil, endDate: Date? = nil, isAllDay: Bool = false, priority: Priority = .none, reminderDate: Date? = nil, - constantReminder: Bool = false) { - tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime, - quadrant: quadrant, category: category, - taskColor: taskColor, isRecurring: isRecurring, - recurrenceLabel: recurrenceLabel, - endDate: endDate, isAllDay: isAllDay, - priority: priority, reminderDate: reminderDate, - constantReminder: constantReminder)) + constantReminder: Bool = false, recurrenceEnd: Date? = nil) { + var item = TaskItem(title: title, dueDate: dueDate, hasTime: hasTime, + quadrant: quadrant, category: category, + taskColor: taskColor, isRecurring: isRecurring, + recurrenceLabel: recurrenceLabel, + endDate: endDate, isAllDay: isAllDay, + priority: priority, reminderDate: reminderDate, + constantReminder: constantReminder) + item.recurrenceEnd = recurrenceEnd + tasks.append(item) save() } func updateTask(_ task: TaskItem, title: String, dueDate: Date?, hasTime: Bool, isRecurring: Bool, recurrenceLabel: String?, 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 } tasks[idx].title = title tasks[idx].dueDate = dueDate @@ -393,6 +395,7 @@ class TaskViewModel: ObservableObject { tasks[idx].isAllDay = isAllDay tasks[idx].reminderDate = reminderDate tasks[idx].constantReminder = constantReminder + tasks[idx].recurrenceEnd = recurrenceEnd save() } diff --git a/KisaniCal/Views/MatrixView.swift b/KisaniCal/Views/MatrixView.swift index 32d7361..28eadcc 100644 --- a/KisaniCal/Views/MatrixView.swift +++ b/KisaniCal/Views/MatrixView.swift @@ -675,6 +675,7 @@ struct TaskEditSheet: View { @State private var hasTime: Bool @State private var isRecurring: Bool @State private var recurrenceLabel: String? + @State private var recurrenceEnd: Date? @State private var endDate: Date? @State private var isAllDay: Bool @State private var reminderDate: Date? @@ -688,6 +689,7 @@ struct TaskEditSheet: View { _hasTime = State(initialValue: task.hasTime) _isRecurring = State(initialValue: task.isRecurring) _recurrenceLabel = State(initialValue: task.recurrenceLabel) + _recurrenceEnd = State(initialValue: task.recurrenceEnd) _endDate = State(initialValue: task.endDate) _isAllDay = State(initialValue: task.isAllDay) _reminderDate = State(initialValue: task.reminderDate) @@ -838,7 +840,8 @@ struct TaskEditSheet: View { endDate: $endDate, isAllDay: $isAllDay, reminderDate: $reminderDate, - constantReminder: $constantReminder + constantReminder: $constantReminder, + recurrenceEnd: $recurrenceEnd ) } } @@ -849,7 +852,7 @@ struct TaskEditSheet: View { taskVM.updateTask(task, title: t, dueDate: dueDate, hasTime: hasTime, isRecurring: isRecurring, recurrenceLabel: recurrenceLabel, endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate, - constantReminder: constantReminder) + constantReminder: constantReminder, recurrenceEnd: recurrenceEnd) dismiss() } } diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index 951882d..f01f31f 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -1304,6 +1304,7 @@ struct NLParsed { var tokenRanges: [NSRange] = [] var isRecurring: Bool = false var recurrenceLabel: String? = nil + var recurrenceEnd: Date? = nil var endDate: Date? = nil var isAllDay: Bool = false var isBirthday: Bool = false @@ -1807,7 +1808,8 @@ struct AddTaskSheet: View { endDate: $parsed.endDate, isAllDay: $parsed.isAllDay, reminderDate: $reminderDate, - constantReminder: $constantReminder + constantReminder: $constantReminder, + recurrenceEnd: $parsed.recurrenceEnd ) } } @@ -1818,7 +1820,8 @@ struct AddTaskSheet: View { quadrant: selectedQuadrant, category: selectedCategory, isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel, endDate: parsed.endDate, isAllDay: parsed.isAllDay, - reminderDate: reminderDate, constantReminder: constantReminder) + reminderDate: reminderDate, constantReminder: constantReminder, + recurrenceEnd: parsed.recurrenceEnd) dismiss() } @@ -1852,6 +1855,7 @@ struct TaskDatePickerSheet: View { @Binding var hasTime: Bool @Binding var isRecurring: Bool @Binding var recurrenceLabel: String? + @Binding var recurrenceEnd: Date? @Binding var endDate: Date? @Binding var isAllDay: Bool @Binding var reminderDate: Date? @@ -1876,6 +1880,8 @@ struct TaskDatePickerSheet: View { @State private var customHour: Int @State private var customMinute: Int @State private var localRepeat: String + @State private var localRecurrenceEnd: Date? + @State private var showRecurrenceEndPicker = false @State private var localIsAllDay: Bool @State private var editingEnd = false @@ -1883,11 +1889,13 @@ struct TaskDatePickerSheet: View { isRecurring: Binding, recurrenceLabel: Binding, endDate: Binding, isAllDay: Binding, reminderDate: Binding = .constant(nil), - constantReminder: Binding = .constant(false)) { + constantReminder: Binding = .constant(false), + recurrenceEnd: Binding = .constant(nil)) { _selectedDate = selectedDate _hasTime = hasTime _isRecurring = isRecurring _recurrenceLabel = recurrenceLabel + _recurrenceEnd = recurrenceEnd _endDate = endDate _isAllDay = isAllDay _reminderDate = reminderDate @@ -1898,6 +1906,7 @@ struct TaskDatePickerSheet: View { _localEndDate = State(initialValue: endDate.wrappedValue ?? start) _localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil) _localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None") + _localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue) _localIsAllDay = State(initialValue: isAllDay.wrappedValue) _pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0) _localConstant = State(initialValue: constantReminder.wrappedValue) @@ -2048,6 +2057,32 @@ struct TaskDatePickerSheet: View { value: repeatDisplayValue, active: localRepeat != "None") } .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)) .clipShape(RoundedRectangle(cornerRadius: 14)) @@ -2453,6 +2488,10 @@ struct TaskDatePickerSheet: View { return "Duration: \(n) day\(n == 1 ? "" : "s")" } + private let repeatEndFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "MMM d, yyyy"; return f + }() + private func commit() { if pickerMode == 0 { var final = Calendar.current.startOfDay(for: localDate) @@ -2469,6 +2508,7 @@ struct TaskDatePickerSheet: View { isAllDay = false isRecurring = localRepeat != "None" 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. if let off = localReminderOffset { reminderDate = computedReminderDate(offset: off)