From a5f7bce09fee7fd11f71209c2a7d2c4e8505a7f6 Mon Sep 17 00:00:00 2001 From: kutesir Date: Thu, 9 Jul 2026 03:32:37 +0300 Subject: [PATCH] tasks: add 'Complete & Stop Series' for recurring tasks (KC-59) Recurring tasks only had Delete (erases the task + its entire completion history) as a way to stop a series -- no in-between option to wind one down while keeping its record. TaskViewModel.completeAndStopSeries(_:on:) marks the occurrence complete and sets recurrenceEnd to that day -- a field that already existed in the model and was already respected by isOccurrence, just had no UI. Task, title, and full completedOccurrences history stay intact; only future occurrences stop. New menu item in the shared TaskMenuItems (Today/Matrix/Calendar), shown only for recurring tasks. Wired through all 8 call sites, several via a new optional closure threaded through intermediate wrapper views (QuadrantCard, DayTimelineView, OverdueCard, UpcomingSection). Co-Authored-By: Claude Opus 4.8 --- KisaniCal/ISSUES.md | 42 +++++++++++++++++++++++++++ KisaniCal/Models/TaskItem.swift | 15 ++++++++++ KisaniCal/Views/CalendarView.swift | 8 +++-- KisaniCal/Views/MatrixView.swift | 17 ++++++++--- KisaniCal/Views/TaskContextMenu.swift | 9 ++++++ KisaniCal/Views/TodayView.swift | 13 +++++++-- 6 files changed, 96 insertions(+), 8 deletions(-) diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index 8f3950c..1109051 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -1987,3 +1987,45 @@ editable custom slot) custom reminder still reads sensibly if enabled. Files: `SettingsView.swift`, `NotificationManager.swift`. + +--- + +## KC-59 — "Complete & Stop Series" for recurring tasks (distinct from Delete) + +**Status:** Implemented (not build-verified) +**Reported by:** User: "for any reoccurring tasks in calendar and tasks view +can we put a complete and also stop series and not delete — for example if i +want to complete and stop a series from running" + +### Description +Recurring tasks previously had only two menu actions relevant here: toggle +today's occurrence (series continues forever), or **Delete** (erases the task +and its entire `completedOccurrences` history — no way to "wind down" a +series while keeping its record). + +### Change +- `TaskViewModel.completeAndStopSeries(_:on:)`: marks the given occurrence + (default: today) complete, then sets `recurrenceEnd` to that day — which + `isOccurrence` already respects (a field that existed in the model but had + no UI to set it). The task itself, its title, and its full + `completedOccurrences` history are all preserved — only future occurrences + stop generating. Genuinely different from `delete(_:)`. +- New "Complete & Stop Series" item in the shared `TaskMenuItems` (used by + Today, Matrix, and Calendar's day/week/3-day timelines) — shown only when + `task.isRecurring`. +- Wired through **all 8** `TaskMenuItems` call sites across `TodayView` + (`OverdueCard`, `UpcomingSection`), `MatrixView` (`QuadrantCard`, + `QuadrantDetailView` ×3), and `CalendarView` (main view, `DayTimelineView`, + the timeline-block context menu) — some needed a new optional closure + threaded through an intermediate wrapper view, others had `taskVM` directly. + +Files: `TaskItem.swift`, `TaskContextMenu.swift`, `TodayView.swift`, +`MatrixView.swift`, `CalendarView.swift`. + +### Note +Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/ +parens confirmed per-file via `git diff` (one file had a pre-existing +1 paren +offset from an unrelated comment, verified via stash — not introduced by this +change). `OverdueCard`'s wiring is technically unreachable today since +`overdueTasks` already excludes recurring tasks by design — wired anyway for +consistency in case that changes. diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index e117c6d..39b6a35 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -405,6 +405,21 @@ class TaskViewModel: ObservableObject { } } + /// Complete a recurring task's occurrence AND stop the series — distinct + /// from Delete, which erases the task (and every past completion) outright. + /// The task, its title, and its full completedOccurrences history stay + /// intact; only occurrences after `date` stop being generated (via + /// `recurrenceEnd`, which `isOccurrence` already respects). + func completeAndStopSeries(_ t: TaskItem, on date: Date = Date()) { + guard let idx = tasks.firstIndex(where: { $0.id == t.id }) else { return } + let key = occurrenceKey(date) + var set = Set(tasks[idx].completedOccurrences ?? []) + set.insert(key) // ensure done — never toggles off + tasks[idx].completedOccurrences = Array(set) + tasks[idx].recurrenceEnd = Calendar.current.startOfDay(for: date) + save() + } + /// A display copy of a recurring task pinned to one occurrence date, with that /// occurrence's completion baked into `isComplete` (so existing rows just work). func occurrenceCopy(_ t: TaskItem, on date: Date) -> TaskItem { diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index 3d90e8e..763a66d 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -615,6 +615,7 @@ struct CalendarView: View { onPin: { taskVM.pin($0) }, onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, + onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, @@ -1081,7 +1082,8 @@ struct CalendarView: View { onSetPriority: { taskVM.setPriority($0, priority: $1) }, onSetCategory: { taskVM.setCategory($0, category: $1) }, onEdit: { editingTask = $0 }, - onDelete: { taskVM.delete($0) } + onDelete: { taskVM.delete($0) }, + onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) } ) } } @@ -1807,6 +1809,7 @@ struct DayTimelineView: View { var onPostpone: ((TaskItem) -> Void)? = nil var onEdit: ((TaskItem) -> Void)? = nil var onDelete: ((TaskItem) -> Void)? = nil + var onCompleteAndStopSeries: ((TaskItem) -> Void)? = nil var onSetDate: ((TaskItem, Date?) -> Void)? = nil var onPostponeMinutes: ((TaskItem, Int) -> Void)? = nil var onMove: ((TaskItem, Quadrant) -> Void)? = nil @@ -1979,7 +1982,8 @@ struct DayTimelineView: View { onResetMatrixUrgency: onResetMatrixUrgency, onLiveActivity:{ LiveActivityManager.toggle(for: $0) }, onEdit: onEdit, - onDelete: { onDelete?($0) } + onDelete: { onDelete?($0) }, + onCompleteAndStopSeries: { onCompleteAndStopSeries?($0) } ) } diff --git a/KisaniCal/Views/MatrixView.swift b/KisaniCal/Views/MatrixView.swift index ddc900d..8c4fbcc 100644 --- a/KisaniCal/Views/MatrixView.swift +++ b/KisaniCal/Views/MatrixView.swift @@ -72,6 +72,7 @@ struct MatrixView: View { onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }, onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, + onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }, onTapHeader: { drillQuadrant = .urgent; showDrill = true } ) QuadrantCard( @@ -93,6 +94,7 @@ struct MatrixView: View { onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }, onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, + onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }, onTapHeader: { drillQuadrant = .schedule; showDrill = true } ) } @@ -118,6 +120,7 @@ struct MatrixView: View { onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }, onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, + onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }, onTapHeader: { drillQuadrant = .delegate_; showDrill = true } ) QuadrantCard( @@ -139,6 +142,7 @@ struct MatrixView: View { onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }, onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, + onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }, onTapHeader: { drillQuadrant = .eliminate; showDrill = true } ) } @@ -211,6 +215,7 @@ struct QuadrantCard: View { var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil var onEdit: ((TaskItem) -> Void)? = nil var onDelete: ((TaskItem) -> Void)? = nil + var onCompleteAndStopSeries: ((TaskItem) -> Void)? = nil var onTapHeader: (() -> Void)? = nil private let shortFmt: DateFormatter = { @@ -326,7 +331,8 @@ struct QuadrantCard: View { onResetMatrixUrgency: { t in withAnimation(KisaniSpring.snappy) { onResetMatrixUrgency?(t) } }, onLiveActivity:{ LiveActivityManager.toggle(for: $0) }, onEdit: onEdit.map { cb in { cb($0) } }, - onDelete: { t in withAnimation(KisaniSpring.snappy) { onDelete?(t) } } + onDelete: { t in withAnimation(KisaniSpring.snappy) { onDelete?(t) } }, + onCompleteAndStopSeries: { t in withAnimation(KisaniSpring.snappy) { onCompleteAndStopSeries?(t) } } ) } } @@ -487,7 +493,8 @@ struct QuadrantDetailView: View { onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }, onLiveActivity:{ LiveActivityManager.toggle(for: $0) }, onEdit: { editingTask = $0 }, - onDelete: { taskVM.delete($0) } + onDelete: { taskVM.delete($0) }, + onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) } ) } if task.id != overdueTasks.last?.id { Divider().padding(.leading, 46) } @@ -514,7 +521,8 @@ struct QuadrantDetailView: View { onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }, onLiveActivity:{ LiveActivityManager.toggle(for: $0) }, onEdit: { editingTask = $0 }, - onDelete: { taskVM.delete($0) } + onDelete: { taskVM.delete($0) }, + onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) } ) } if task.id != laterTasks.last?.id { Divider().padding(.leading, 46) } @@ -542,7 +550,8 @@ struct QuadrantDetailView: View { onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }, onLiveActivity:{ LiveActivityManager.toggle(for: $0) }, onEdit: { editingTask = $0 }, - onDelete: { taskVM.delete($0) } + onDelete: { taskVM.delete($0) }, + onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) } ) } if task.id != displayed.last?.id { Divider().padding(.leading, 46) } diff --git a/KisaniCal/Views/TaskContextMenu.swift b/KisaniCal/Views/TaskContextMenu.swift index ac47335..ca62f4f 100644 --- a/KisaniCal/Views/TaskContextMenu.swift +++ b/KisaniCal/Views/TaskContextMenu.swift @@ -40,6 +40,9 @@ struct TaskMenuItems: View { var onLiveActivity:(TaskItem) -> Void = { LiveActivityManager.toggle(for: $0) } var onEdit: ((TaskItem) -> Void)? = nil var onDelete: (TaskItem) -> Void = { _ in } + /// Complete today's occurrence AND stop the series — keeps the task and its + /// full completion history (unlike Delete, which erases both). + var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in } private var cal: Calendar { .current } @@ -132,6 +135,12 @@ struct TaskMenuItems: View { Button { onEdit(task) } label: { Label("Edit", systemImage: "pencil") } } + if task.isRecurring { + Button { onCompleteAndStopSeries(task) } label: { + Label("Complete & Stop Series", systemImage: "checkmark.circle.badge.xmark") + } + } + Divider() Button(role: .destructive) { onDelete(task) } label: { diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index d6a168c..491a2af 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -46,6 +46,7 @@ struct TodayView: View { onPin: { taskVM.pin($0) }, onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, + onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, @@ -159,6 +160,7 @@ struct TodayView: View { onPin: { taskVM.pin($0) }, onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, + onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, @@ -180,6 +182,7 @@ struct TodayView: View { onPin: { taskVM.pin($0) }, onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, + onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, @@ -199,6 +202,7 @@ struct TodayView: View { onPin: { taskVM.pin($0) }, onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, + onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, @@ -218,6 +222,7 @@ struct TodayView: View { onPin: { taskVM.pin($0) }, onEdit: { editingTask = $0 }, onDelete: { taskVM.delete($0) }, + onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }, onSetDate: { taskVM.setDate($0, date: $1) }, onPostponeMinutes: { taskVM.postpone($0, minutes: $1) }, onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) }, @@ -601,6 +606,7 @@ struct OverdueCard: View { var onPin: (TaskItem) -> Void = { _ in } var onEdit: (TaskItem) -> Void = { _ in } var onDelete: (TaskItem) -> Void = { _ in } + var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in } var onSetDate: (TaskItem, Date?) -> Void = { _, _ in } var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in } var onMove: (TaskItem, Quadrant) -> Void = { _, _ in } @@ -712,7 +718,8 @@ struct OverdueCard: View { onResetMatrixUrgency: onResetMatrixUrgency, onLiveActivity: { LiveActivityManager.toggle(for: $0) }, onEdit: onEdit, - onDelete: onDelete + onDelete: onDelete, + onCompleteAndStopSeries: onCompleteAndStopSeries ) } } @@ -1018,6 +1025,7 @@ struct UpcomingSection: View { var onPin: (TaskItem) -> Void = { _ in } var onEdit: (TaskItem) -> Void = { _ in } let onDelete: (TaskItem) -> Void + var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in } var onSetDate: (TaskItem, Date?) -> Void = { _, _ in } var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in } var onMove: (TaskItem, Quadrant) -> Void = { _, _ in } @@ -1091,7 +1099,8 @@ struct UpcomingSection: View { onResetMatrixUrgency: onResetMatrixUrgency, onLiveActivity: { LiveActivityManager.toggle(for: $0) }, onEdit: onEdit, - onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } } + onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } }, + onCompleteAndStopSeries: { task in withAnimation(KisaniSpring.snappy) { onCompleteAndStopSeries(task) } } ) } .padding(.horizontal, 18)