Merge develop -> main: full session (KC-51 through KC-71) #28

Merged
kutesir merged 64 commits from develop into main 2026-07-12 22:57:14 +00:00
6 changed files with 96 additions and 8 deletions
Showing only changes of commit a5f7bce09f - Show all commits

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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) }
)
}

View File

@@ -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) }

View File

@@ -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: {

View File

@@ -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)