From 694db506b61fb859d340019c33aee24a8cc1c4b8 Mon Sep 17 00:00:00 2001 From: kutesir Date: Wed, 17 Jun 2026 00:03:22 +0300 Subject: [PATCH] Audit fixes: lock-screen sync, live notification urgency, batch postpone (KC-31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- KisaniCal/ISSUES.md | 59 ++++++++++++++++---- KisaniCal/Managers/NotificationManager.swift | 20 +++++-- KisaniCal/Models/TaskItem.swift | 2 + 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index e93ba82..f89663d 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -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. diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index a4cf86c..aaf9da1 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -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) { 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" } } } diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index 7807c53..8b38076 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -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() }