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:
kutesir
2026-06-17 00:03:22 +03:00
parent cb0dc3e271
commit 694db506b6
3 changed files with 65 additions and 16 deletions

View File

@@ -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)
**Reported by:** User
**Area:** Notifications
### Problem
The user wanted recurring "one more period passed" nudges (referencing another
app): end of week, month, and year.
The user wanted recurring "one more period passed" nudges (Pretty Progress
style): day, week, month, and year, all at midnight.
### Fix
- `schedulePeriodMilestones()` schedules three recurring `UNCalendarNotificationTrigger`s:
- This Week — "One more week passed." — Sunday 20:00 (weekly)
- This Month — "One more month passed." — 1st at 09:00 (monthly)
- This Year — "One more year passed." — Jan 1 at 09:00 (yearly)
- `schedulePeriodMilestones()` schedules recurring `UNCalendarNotificationTrigger`s,
all firing at **midnight (00:00)**:
- Today — "One more day passed." — every day
- 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
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`.
### How to test
1. Authorize notifications; on the 1st of a month at 09:00 a "One more month
passed." notification fires (and equivalents for week/year).
1. Authorize notifications; at midnight a "One more day passed." notification
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.

View File

@@ -1,5 +1,6 @@
import SwiftUI
import UserNotifications
import WidgetKit
final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable {
static let shared = NotificationManager()
@@ -63,6 +64,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
tasks[idx].completedAt = Date()
if let encoded = try? JSONEncoder().encode(tasks) {
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
if let encoded = try? JSONEncoder().encode(tasks) {
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 recorded = workoutVM.isTodayRecorded
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 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.scheduleWorkoutConfirm(schedule: schedule, programs: programs)
self.scheduleCompensations(compensations, programs: programs)
self.scheduleTasks(snapshot: tasks)
self.scheduleTasks(snapshot: tasks, urgentIds: urgentIds)
self.scheduleCalendarEvents(snapshot: calEvents)
self.schedulePeriodMilestones()
self.updateBadge(snapshot: tasks)
@@ -424,7 +432,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
// MARK: - Per-task reminders + evening check-in
private func scheduleTasks(snapshot: [TaskItem]) {
private func scheduleTasks(snapshot: [TaskItem], urgentIds: Set<UUID>) {
let center = self.center
center.getPendingNotificationRequests { [weak self] pending in
@@ -461,7 +469,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
let content = UNMutableNotificationContent()
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.categoryIdentifier = Self.categoryId
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
// 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()
ec.title = "Evening check-in"
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 {
case .birthday: return "Birthday today"
case .domain: return "Domain renewal coming up"
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"
}
}
}

View File

@@ -599,6 +599,8 @@ class TaskViewModel: ObservableObject {
guard let idx = tasks.firstIndex(where: { $0.id == id }) else { continue }
let base = tasks[idx].dueDate ?? Date()
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()
}