From 97782a82bc4b0acbc744a9229003087f9adb2fd4 Mon Sep 17 00:00:00 2001 From: kutesir Date: Thu, 9 Jul 2026 02:12:16 +0300 Subject: [PATCH] habits: add a 'Done' notification action + private day-count badge (KC-56) Follow-up to KC-55's design question: habit reminders (Prayer/Bed/Coffee) stay out of Today/Matrix/Activity History (would add 3-9 daily checkboxes forever, against 'nothing shouts'), but now get a 'Done' action on the notification itself -- like the workout confirm flow's 'Yes, log it' -- that logs a private timestamp. New HABIT_LOG category/HABIT_DONE action; every habit notification carries userInfo['habitType'] (bed/prayer/coffee -- slot detail collapses to the type). logHabitDone/habitDoneCount store a deduped date array in UserDefaults.kisani, mirroring the lifetime-tally streak philosophy from KC-40 rather than a breakable streak. Settings shows a quiet 'Xd' badge per habit -- the only place the count appears; nothing touches the task list. Co-Authored-By: Claude Opus 4.8 --- KisaniCal/ISSUES.md | 44 +++++++++++++++ KisaniCal/Managers/NotificationManager.swift | 56 +++++++++++++++++--- KisaniCal/Views/SettingsView.swift | 25 +++++++-- 3 files changed, 116 insertions(+), 9 deletions(-) diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index 9d8f68d..857ab21 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -1894,3 +1894,47 @@ compile correctness (balanced braces/parens checked programmatically; the manual `@AppStorage` backing-storage init in `HabitSlotRow` is the highest-risk spot syntactically — standard, documented pattern, but worth a close look on first build). + +--- + +## KC-56 — Habit reminders: "Done" notification action + private day count + +**Status:** Implemented (not build-verified) +**Reported by:** User (follow-up to KC-55: "should they appear in tasks, or +stay reminders only?") + +### Decision +Discussed with the user: keep habit reminders (Prayer/Bed/Coffee, KC-55) OUT +of Today/Matrix/Activity History — turning them into recurring TaskItems would +add 3–9 extra daily checkboxes forever, against PRODUCT.md's "nothing shouts / +data is primary" principle, and a prayer nudge isn't really a "deliverable." +Middle ground: add a **"Done" action directly on the notification** (like the +workout confirm flow's "Yes, log it") that logs a private timestamp for a +lightweight day count — no task, no Activity History entry, nothing shown +outside Settings. + +### Implementation +- New `HABIT_LOG` notification category + `HABIT_DONE` action ("Done"), + registered alongside the existing task/workout categories. +- Every habit notification (bed, all 4 prayer slots, all 4 coffee slots + the + custom one) carries `userInfo["habitType"]` = "bed"/"prayer"/"coffee" (slot- + level detail collapses to the 3 habit TYPES — tapping Done on any prayer + slot counts as "prayed today," not per-slot). +- `logHabitDone(type:)`: appends today's date to `UserDefaults.kisani["kisani. + habit.doneDates."]`, deduped (idempotent — tapping Done twice same day + is a no-op). Mirrors the "lifetime tally, never breaks" philosophy already + established for the workout streak (KC-40), not a fragile consecutive + streak. +- `habitDoneCount(type:)`: reads the count back. +- Settings → Habit Reminders now shows a quiet "Xd" badge next to each + habit's title (Prayer/Bed/Coffee) once count > 0 — the only place this + number appears. + +Files: `NotificationManager.swift`, `SettingsView.swift`. + +### Note +Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/ +parens checked programmatically; `logHabitDone`/`habitDoneCount` intentionally +left without `@MainActor` to match the existing nonisolated handler functions +(`markTaskComplete`, `snoozeTask`) they run alongside in the notification +delegate callback. diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index 1bda8fa..17345a1 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -20,6 +20,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable private static let logWorkoutActId = "LOG_WORKOUT" private static let missedWorkoutActId = "MISSED_WORKOUT" private static let snoozeWorkoutActId = "SNOOZE_WORKOUT" + private static let habitCatId = "HABIT_LOG" + private static let habitDoneActId = "HABIT_DONE" override private init() { super.init() @@ -49,7 +51,11 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable actions: [logAction, missedAction, snoozeAction], intentIdentifiers: [], options: .customDismissAction) - center.setNotificationCategories([taskCat, confirmCat]) + let habitDoneAction = UNNotificationAction(identifier: Self.habitDoneActId, title: "Done", options: []) + let habitCat = UNNotificationCategory(identifier: Self.habitCatId, actions: [habitDoneAction], + intentIdentifiers: [], options: .customDismissAction) + + center.setNotificationCategories([taskCat, confirmCat, habitCat]) } // MARK: - Complete task directly in UserDefaults (called from background) @@ -202,11 +208,17 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable let ud = UserDefaults.kisani var wanted = Set() - func addDaily(id: String, enabled: Bool, hour: Int, minute: Int, title: String, body: String) { + // `habitType` drives the "Done" action's log bucket ("bed"/"prayer"/ + // "coffee") — tapping Done on any slot of a type logs one entry for + // that type today (deduped), feeding a simple lifetime "days done" + // count. Never touches the task list or Activity History. + func addDaily(id: String, enabled: Bool, hour: Int, minute: Int, title: String, body: String, habitType: String) { guard enabled else { return } wanted.insert(id) let content = UNMutableNotificationContent() content.title = title; content.body = body; content.sound = .default + content.categoryIdentifier = Self.habitCatId + content.userInfo = ["habitType": habitType] var comps = DateComponents(); comps.hour = hour; comps.minute = minute center.add(UNNotificationRequest( identifier: id, content: content, @@ -222,7 +234,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable hour: ud.object(forKey: "bedReminderHour") as? Int ?? 8, minute: ud.object(forKey: "bedReminderMinute") as? Int ?? 0, title: "Rise and shine", - body: "Have you made your bed? Small win, great start to the day." + body: "Have you made your bed? Small win, great start to the day.", + habitType: "bed" ) // Prayer — up to 4 independently-toggleable times a day. @@ -234,7 +247,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable hour: ud.object(forKey: "prayer\(slot.key)Hour") as? Int ?? slot.hour, minute: ud.object(forKey: "prayer\(slot.key)Minute") as? Int ?? slot.minute, title: "A moment to pause", - body: "Have you had a chance to pray and give thanks?" + body: "Have you had a chance to pray and give thanks?", + habitType: "prayer" ) } @@ -247,7 +261,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable hour: ud.object(forKey: "coffee\(slot.key)Hour") as? Int ?? slot.hour, minute: ud.object(forKey: "coffee\(slot.key)Minute") as? Int ?? slot.minute, title: "\(slot.key) coffee", - body: "Time for your \(slot.key.lowercased()) coffee?" + body: "Time for your \(slot.key.lowercased()) coffee?", + habitType: "coffee" ) } let customLabel = ud.string(forKey: "coffeeCustomLabel") ?? "Coffee" @@ -257,7 +272,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable hour: ud.object(forKey: "coffeeCustomHour") as? Int ?? 16, minute: ud.object(forKey: "coffeeCustomMinute") as? Int ?? 30, title: customLabel, - body: "Time for \(customLabel.lowercased())?" + body: "Time for \(customLabel.lowercased())?", + habitType: "coffee" ) // Cancel anything previously scheduled that isn't wanted this pass @@ -271,6 +287,31 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable center.removePendingNotificationRequests(withIdentifiers: Array(allPossibleIds.subtracting(wanted))) } + // MARK: - Habit "Done" logging (private, lightweight — no task-list footprint) + + private static let habitLogKeyPrefix = "kisani.habit.doneDates." + + /// Log today as done for a habit type ("bed"/"prayer"/"coffee"), once per + /// day (idempotent — tapping Done twice doesn't double-count). Mirrors the + /// app's "lifetime tally, never breaks" streak philosophy (see workout + /// streakDays) rather than a fragile consecutive-day streak. + private func logHabitDone(type: String) { + let ud = UserDefaults.kisani + let key = Self.habitLogKeyPrefix + type + let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd" + let today = fmt.string(from: Date()) + var dates = Set(ud.stringArray(forKey: key) ?? []) + guard !dates.contains(today) else { return } + dates.insert(today) + ud.set(Array(dates), forKey: key) + } + + /// Total distinct days logged done for a habit type — for a small "X days" + /// badge in Settings. Never surfaced in Today/Matrix/Activity History. + func habitDoneCount(type: String) -> Int { + (UserDefaults.kisani.stringArray(forKey: Self.habitLogKeyPrefix + type) ?? []).count + } + // MARK: - Analytics report notifications (deep-linked) /// Fire one local notification for each newly-generated weekly / monthly @@ -712,6 +753,9 @@ extension NotificationManager: UNUserNotificationCenterDelegate { trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false) )) + case Self.habitDoneActId: + if let habitType = info["habitType"] as? String { logHabitDone(type: habitType) } + default: if let taskId { markTaskComplete(taskId: taskId, userId: userId) } Task { @MainActor in diff --git a/KisaniCal/Views/SettingsView.swift b/KisaniCal/Views/SettingsView.swift index c23add8..c87581e 100644 --- a/KisaniCal/Views/SettingsView.swift +++ b/KisaniCal/Views/SettingsView.swift @@ -941,7 +941,10 @@ private struct HabitRemindersSheet: View { .font(.system(size: 13)).foregroundColor(AppColors.yellow) .frame(width: 28, height: 28).background(AppColors.yellowSoft).cornerRadius(8) VStack(alignment: .leading, spacing: 1) { - Text("Prayer reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + HStack(spacing: 6) { + Text("Prayer reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + habitBadge("prayer") + } Text("\"Have you had a chance to pray and give thanks?\"") .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) } @@ -970,7 +973,10 @@ private struct HabitRemindersSheet: View { .font(.system(size: 13)).foregroundColor(AppColors.blue) .frame(width: 28, height: 28).background(AppColors.blueSoft).cornerRadius(8) VStack(alignment: .leading, spacing: 1) { - Text("Make your bed").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + HStack(spacing: 6) { + Text("Make your bed").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + habitBadge("bed") + } Text("The simplest task that starts the day with a win.") .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) } @@ -996,7 +1002,10 @@ private struct HabitRemindersSheet: View { .font(.system(size: 13)).foregroundColor(.white) .frame(width: 28, height: 28).background(Color(r: 122, g: 84, b: 52)).cornerRadius(8) VStack(alignment: .leading, spacing: 1) { - Text("Coffee reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + HStack(spacing: 6) { + Text("Coffee reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + habitBadge("coffee") + } Text("Track when you usually reach for a cup.") .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) } @@ -1051,6 +1060,16 @@ private struct HabitRemindersSheet: View { : ["Morning": 8, "Midday": 12, "Afternoon": 15, "Evening": 18] return table[slot] ?? 9 } + + /// A quiet "X days" badge from tapping "Done" on a habit's notifications — + /// a private lifetime tally, never shown in Today/Matrix/Activity History. + @ViewBuilder + private func habitBadge(_ type: String) -> some View { + let count = NotificationManager.shared.habitDoneCount(type: type) + if count > 0 { + AppBadge(text: "\(count)d", bg: AppColors.greenSoft, fg: AppColors.green) + } + } } /// One named, independently-toggleable daily time slot (e.g. "Morning") — used