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(_🔛) 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user