Audit fixes: lock-screen sync, live notification urgency, batch postpone (KC-31)
- Lock-screen Mark Complete / Snooze now push to iCloud (CloudSyncManager) and
reload widget timelines, so background completions reach iCloud/Watch/widgets
immediately instead of waiting for the next app run.
- Notification copy ("Urgent — tap to mark complete" + evening check-in) now uses
the live computed Matrix quadrant: reschedule snapshots urgent task IDs from
displayQuadrant and threads them into scheduleTasks/notifBody, instead of the
stale stored task.quadrant.
- postponeAllOverdue now mirrors single postpone (clears urgencyOverride and
re-syncs the stored quadrant per task).
- ISSUES.md: added KC-31 (audit), corrected KC-30 (midnight day/week/month/year);
left #2 (period-notif Settings toggle) and #6 (configurable thresholds/snooze)
as pending; noted #1 (Move lists 3 quadrants) as intentional KC-25 behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -956,27 +956,66 @@ Files: `WidgetViews.swift`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## KC-30 — Period milestone notifications (week / month / year)
|
## KC-30 — Period milestone notifications (day / week / month / year)
|
||||||
|
|
||||||
**Status:** In Progress (implemented & build-verified, pending device QA)
|
**Status:** In Progress (implemented & build-verified, pending device QA)
|
||||||
**Reported by:** User
|
**Reported by:** User
|
||||||
**Area:** Notifications
|
**Area:** Notifications
|
||||||
|
|
||||||
### Problem
|
### Problem
|
||||||
The user wanted recurring "one more period passed" nudges (referencing another
|
The user wanted recurring "one more period passed" nudges (Pretty Progress
|
||||||
app): end of week, month, and year.
|
style): day, week, month, and year, all at midnight.
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
- `schedulePeriodMilestones()` schedules three recurring `UNCalendarNotificationTrigger`s:
|
- `schedulePeriodMilestones()` schedules recurring `UNCalendarNotificationTrigger`s,
|
||||||
- This Week — "One more week passed." — Sunday 20:00 (weekly)
|
all firing at **midnight (00:00)**:
|
||||||
- This Month — "One more month passed." — 1st at 09:00 (monthly)
|
- Today — "One more day passed." — every day
|
||||||
- This Year — "One more year passed." — Jan 1 at 09:00 (yearly)
|
- This Week — "One more week passed." — Monday 00:00 (weekly)
|
||||||
|
- This Month — "One more month passed." — 1st at 00:00 (monthly)
|
||||||
|
- This Year — "One more year passed." — Jan 1 at 00:00 (yearly)
|
||||||
- Fixed identifiers + remove-then-add keep them de-duplicated; runs via the
|
- Fixed identifiers + remove-then-add keep them de-duplicated; runs via the
|
||||||
existing `reschedule(...)` path when authorized; gated by the
|
existing `reschedule(...)` path when authorized; gated by the
|
||||||
`kisani.periodMilestones` flag (default on). No in-app toggle yet.
|
`kisani.periodMilestones` flag (default on).
|
||||||
|
- **Pending:** no in-app Settings toggle yet (see KC-31 #2).
|
||||||
|
|
||||||
Files: `NotificationManager.swift`.
|
Files: `NotificationManager.swift`.
|
||||||
|
|
||||||
### How to test
|
### How to test
|
||||||
1. Authorize notifications; on the 1st of a month at 09:00 a "One more month
|
1. Authorize notifications; at midnight a "One more day passed." notification
|
||||||
passed." notification fires (and equivalents for week/year).
|
fires (and the week/month/year equivalents on their boundaries).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KC-31 — Audit fixes: lock-screen sync, live urgency, batch postpone
|
||||||
|
|
||||||
|
**Status:** Fixed items build-verified; pending items remain open (see below)
|
||||||
|
**Reported by:** User (code review)
|
||||||
|
**Area:** Notifications / Tasks / Matrix
|
||||||
|
|
||||||
|
### Reviewed items & outcomes
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- **Lock-screen Mark Complete / Snooze now sync + reload.** `markTaskComplete`
|
||||||
|
and `snoozeTask` previously wrote only to `UserDefaults.kisani`. They now also
|
||||||
|
`CloudSyncManager.shared.push(...)` (→ iCloud / Apple Watch) and
|
||||||
|
`WidgetCenter.shared.reloadAllTimelines()`.
|
||||||
|
- **Notification copy uses live Matrix urgency, not stored quadrant.**
|
||||||
|
`reschedule(...)` snapshots urgent task IDs from the computed `displayQuadrant`
|
||||||
|
and threads them into `scheduleTasks` / `notifBody`, so "Urgent — tap to mark
|
||||||
|
complete" and the evening check-in reflect current urgency rather than a stale
|
||||||
|
stored fallback.
|
||||||
|
- **Batch `postponeAllOverdue` matches single postpone.** It now clears
|
||||||
|
`urgencyOverride` and re-syncs the stored quadrant per task.
|
||||||
|
|
||||||
|
Files: `NotificationManager.swift`, `TaskItem.swift`.
|
||||||
|
|
||||||
|
### Pending (not closed)
|
||||||
|
- **#2 — Period-milestone Settings toggle.** Currently only the
|
||||||
|
`kisani.periodMilestones` UserDefaults flag (default on); no in-app UI to turn
|
||||||
|
the day/week/month/year notifications on/off. **Pending.**
|
||||||
|
- **#6 — Configurable Matrix thresholds & snooze durations.** `urgentWindowDays`
|
||||||
|
(3), `categoryUrgentWindowDays` (14), and the 15/30/60/120-min snooze actions
|
||||||
|
are hardcoded constants — fine for now, but no settings for TickTick-style
|
||||||
|
customization. **Pending.**
|
||||||
|
- **Note (#1) — Matrix "Move" lists only the other 3 quadrants.** This is the
|
||||||
|
intended KC-25 behavior, not a defect; left as-is unless reversed.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable {
|
final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable {
|
||||||
static let shared = NotificationManager()
|
static let shared = NotificationManager()
|
||||||
@@ -63,6 +64,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
tasks[idx].completedAt = Date()
|
tasks[idx].completedAt = Date()
|
||||||
if let encoded = try? JSONEncoder().encode(tasks) {
|
if let encoded = try? JSONEncoder().encode(tasks) {
|
||||||
UserDefaults.kisani.set(encoded, forKey: key)
|
UserDefaults.kisani.set(encoded, forKey: key)
|
||||||
|
CloudSyncManager.shared.push(data: encoded, forKey: key) // sync to iCloud / Watch
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +79,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
tasks[idx].reminderDate = fireDate
|
tasks[idx].reminderDate = fireDate
|
||||||
if let encoded = try? JSONEncoder().encode(tasks) {
|
if let encoded = try? JSONEncoder().encode(tasks) {
|
||||||
UserDefaults.kisani.set(encoded, forKey: key)
|
UserDefaults.kisani.set(encoded, forKey: key)
|
||||||
|
CloudSyncManager.shared.push(data: encoded, forKey: key) // sync to iCloud / Watch
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +121,9 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
let progress = workoutVM.progress
|
let progress = workoutVM.progress
|
||||||
let recorded = workoutVM.isTodayRecorded
|
let recorded = workoutVM.isTodayRecorded
|
||||||
let tasks = taskVM.tasks
|
let tasks = taskVM.tasks
|
||||||
|
// Live urgency (computed Matrix quadrant), not the stored fallback, so notification
|
||||||
|
// copy reflects tasks that have become urgent as their dates approached.
|
||||||
|
let urgentIds = Set(tasks.filter { taskVM.displayQuadrant($0) == .urgent }.map { $0.id })
|
||||||
let calEvents = CalendarStore.shared.upcomingEventNotifs(days: 7)
|
let calEvents = CalendarStore.shared.upcomingEventNotifs(days: 7)
|
||||||
let compensations = workoutVM.compensations
|
let compensations = workoutVM.compensations
|
||||||
|
|
||||||
@@ -127,7 +135,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress, recordedToday: recorded)
|
self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress, recordedToday: recorded)
|
||||||
self.scheduleWorkoutConfirm(schedule: schedule, programs: programs)
|
self.scheduleWorkoutConfirm(schedule: schedule, programs: programs)
|
||||||
self.scheduleCompensations(compensations, programs: programs)
|
self.scheduleCompensations(compensations, programs: programs)
|
||||||
self.scheduleTasks(snapshot: tasks)
|
self.scheduleTasks(snapshot: tasks, urgentIds: urgentIds)
|
||||||
self.scheduleCalendarEvents(snapshot: calEvents)
|
self.scheduleCalendarEvents(snapshot: calEvents)
|
||||||
self.schedulePeriodMilestones()
|
self.schedulePeriodMilestones()
|
||||||
self.updateBadge(snapshot: tasks)
|
self.updateBadge(snapshot: tasks)
|
||||||
@@ -424,7 +432,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
|
|
||||||
// MARK: - Per-task reminders + evening check-in
|
// MARK: - Per-task reminders + evening check-in
|
||||||
|
|
||||||
private func scheduleTasks(snapshot: [TaskItem]) {
|
private func scheduleTasks(snapshot: [TaskItem], urgentIds: Set<UUID>) {
|
||||||
let center = self.center
|
let center = self.center
|
||||||
|
|
||||||
center.getPendingNotificationRequests { [weak self] pending in
|
center.getPendingNotificationRequests { [weak self] pending in
|
||||||
@@ -461,7 +469,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
|
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = task.title
|
content.title = task.title
|
||||||
content.body = self.notifBody(for: task)
|
content.body = self.notifBody(for: task, isUrgent: urgentIds.contains(task.id))
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
content.categoryIdentifier = Self.categoryId
|
content.categoryIdentifier = Self.categoryId
|
||||||
content.userInfo = ["taskId": task.id.uuidString, "deeplink": "tasks"]
|
content.userInfo = ["taskId": task.id.uuidString, "deeplink": "tasks"]
|
||||||
@@ -480,7 +488,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
|
|
||||||
// No count in body — repeating notifications bake in the value
|
// No count in body — repeating notifications bake in the value
|
||||||
// at schedule time and go stale until the app is next opened.
|
// at schedule time and go stale until the app is next opened.
|
||||||
guard snapshot.contains(where: { !$0.isComplete && $0.quadrant == .urgent }) else { return }
|
guard snapshot.contains(where: { !$0.isComplete && urgentIds.contains($0.id) }) else { return }
|
||||||
let ec = UNMutableNotificationContent()
|
let ec = UNMutableNotificationContent()
|
||||||
ec.title = "Evening check-in"
|
ec.title = "Evening check-in"
|
||||||
ec.body = "You still have urgent tasks open — tap to review."
|
ec.body = "You still have urgent tasks open — tap to review."
|
||||||
@@ -495,12 +503,12 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func notifBody(for task: TaskItem) -> String {
|
private func notifBody(for task: TaskItem, isUrgent: Bool) -> String {
|
||||||
switch task.category {
|
switch task.category {
|
||||||
case .birthday: return "Birthday today"
|
case .birthday: return "Birthday today"
|
||||||
case .domain: return "Domain renewal coming up"
|
case .domain: return "Domain renewal coming up"
|
||||||
case .annual: return "Annual reminder"
|
case .annual: return "Annual reminder"
|
||||||
default: return task.quadrant == .urgent ? "Urgent — tap to mark complete" : "Task reminder"
|
default: return isUrgent ? "Urgent — tap to mark complete" : "Task reminder"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -599,6 +599,8 @@ class TaskViewModel: ObservableObject {
|
|||||||
guard let idx = tasks.firstIndex(where: { $0.id == id }) else { continue }
|
guard let idx = tasks.firstIndex(where: { $0.id == id }) else { continue }
|
||||||
let base = tasks[idx].dueDate ?? Date()
|
let base = tasks[idx].dueDate ?? Date()
|
||||||
tasks[idx].dueDate = Calendar.current.date(byAdding: .day, value: days, to: base)
|
tasks[idx].dueDate = Calendar.current.date(byAdding: .day, value: days, to: base)
|
||||||
|
tasks[idx].urgencyOverride = nil // re-place from the new deadline
|
||||||
|
tasks[idx].quadrant = displayQuadrant(tasks[idx]) // keep color/fallback in sync
|
||||||
}
|
}
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user