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 <noreply@anthropic.com>
This commit is contained in:
@@ -1894,3 +1894,47 @@ compile correctness (balanced braces/parens checked programmatically; the
|
|||||||
manual `@AppStorage` backing-storage init in `HabitSlotRow` is the highest-risk
|
manual `@AppStorage` backing-storage init in `HabitSlotRow` is the highest-risk
|
||||||
spot syntactically — standard, documented pattern, but worth a close look on
|
spot syntactically — standard, documented pattern, but worth a close look on
|
||||||
first build).
|
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.<type>"]`, 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.
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
private static let logWorkoutActId = "LOG_WORKOUT"
|
private static let logWorkoutActId = "LOG_WORKOUT"
|
||||||
private static let missedWorkoutActId = "MISSED_WORKOUT"
|
private static let missedWorkoutActId = "MISSED_WORKOUT"
|
||||||
private static let snoozeWorkoutActId = "SNOOZE_WORKOUT"
|
private static let snoozeWorkoutActId = "SNOOZE_WORKOUT"
|
||||||
|
private static let habitCatId = "HABIT_LOG"
|
||||||
|
private static let habitDoneActId = "HABIT_DONE"
|
||||||
|
|
||||||
override private init() {
|
override private init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -49,7 +51,11 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
actions: [logAction, missedAction, snoozeAction],
|
actions: [logAction, missedAction, snoozeAction],
|
||||||
intentIdentifiers: [], options: .customDismissAction)
|
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)
|
// MARK: - Complete task directly in UserDefaults (called from background)
|
||||||
@@ -202,11 +208,17 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
let ud = UserDefaults.kisani
|
let ud = UserDefaults.kisani
|
||||||
var wanted = Set<String>()
|
var wanted = Set<String>()
|
||||||
|
|
||||||
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 }
|
guard enabled else { return }
|
||||||
wanted.insert(id)
|
wanted.insert(id)
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = title; content.body = body; content.sound = .default
|
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
|
var comps = DateComponents(); comps.hour = hour; comps.minute = minute
|
||||||
center.add(UNNotificationRequest(
|
center.add(UNNotificationRequest(
|
||||||
identifier: id, content: content,
|
identifier: id, content: content,
|
||||||
@@ -222,7 +234,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
hour: ud.object(forKey: "bedReminderHour") as? Int ?? 8,
|
hour: ud.object(forKey: "bedReminderHour") as? Int ?? 8,
|
||||||
minute: ud.object(forKey: "bedReminderMinute") as? Int ?? 0,
|
minute: ud.object(forKey: "bedReminderMinute") as? Int ?? 0,
|
||||||
title: "Rise and shine",
|
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.
|
// 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,
|
hour: ud.object(forKey: "prayer\(slot.key)Hour") as? Int ?? slot.hour,
|
||||||
minute: ud.object(forKey: "prayer\(slot.key)Minute") as? Int ?? slot.minute,
|
minute: ud.object(forKey: "prayer\(slot.key)Minute") as? Int ?? slot.minute,
|
||||||
title: "A moment to pause",
|
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,
|
hour: ud.object(forKey: "coffee\(slot.key)Hour") as? Int ?? slot.hour,
|
||||||
minute: ud.object(forKey: "coffee\(slot.key)Minute") as? Int ?? slot.minute,
|
minute: ud.object(forKey: "coffee\(slot.key)Minute") as? Int ?? slot.minute,
|
||||||
title: "\(slot.key) coffee",
|
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"
|
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,
|
hour: ud.object(forKey: "coffeeCustomHour") as? Int ?? 16,
|
||||||
minute: ud.object(forKey: "coffeeCustomMinute") as? Int ?? 30,
|
minute: ud.object(forKey: "coffeeCustomMinute") as? Int ?? 30,
|
||||||
title: customLabel,
|
title: customLabel,
|
||||||
body: "Time for \(customLabel.lowercased())?"
|
body: "Time for \(customLabel.lowercased())?",
|
||||||
|
habitType: "coffee"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cancel anything previously scheduled that isn't wanted this pass
|
// 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)))
|
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)
|
// MARK: - Analytics report notifications (deep-linked)
|
||||||
|
|
||||||
/// Fire one local notification for each newly-generated weekly / monthly
|
/// Fire one local notification for each newly-generated weekly / monthly
|
||||||
@@ -712,6 +753,9 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
|
|||||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
|
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
|
||||||
))
|
))
|
||||||
|
|
||||||
|
case Self.habitDoneActId:
|
||||||
|
if let habitType = info["habitType"] as? String { logHabitDone(type: habitType) }
|
||||||
|
|
||||||
default:
|
default:
|
||||||
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
|
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
|||||||
@@ -941,7 +941,10 @@ private struct HabitRemindersSheet: View {
|
|||||||
.font(.system(size: 13)).foregroundColor(AppColors.yellow)
|
.font(.system(size: 13)).foregroundColor(AppColors.yellow)
|
||||||
.frame(width: 28, height: 28).background(AppColors.yellowSoft).cornerRadius(8)
|
.frame(width: 28, height: 28).background(AppColors.yellowSoft).cornerRadius(8)
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
Text("Prayer reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
Text("Prayer reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||||||
|
habitBadge("prayer")
|
||||||
|
}
|
||||||
Text("\"Have you had a chance to pray and give thanks?\"")
|
Text("\"Have you had a chance to pray and give thanks?\"")
|
||||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||||
}
|
}
|
||||||
@@ -970,7 +973,10 @@ private struct HabitRemindersSheet: View {
|
|||||||
.font(.system(size: 13)).foregroundColor(AppColors.blue)
|
.font(.system(size: 13)).foregroundColor(AppColors.blue)
|
||||||
.frame(width: 28, height: 28).background(AppColors.blueSoft).cornerRadius(8)
|
.frame(width: 28, height: 28).background(AppColors.blueSoft).cornerRadius(8)
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
Text("Make your bed").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
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.")
|
Text("The simplest task that starts the day with a win.")
|
||||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||||
}
|
}
|
||||||
@@ -996,7 +1002,10 @@ private struct HabitRemindersSheet: View {
|
|||||||
.font(.system(size: 13)).foregroundColor(.white)
|
.font(.system(size: 13)).foregroundColor(.white)
|
||||||
.frame(width: 28, height: 28).background(Color(r: 122, g: 84, b: 52)).cornerRadius(8)
|
.frame(width: 28, height: 28).background(Color(r: 122, g: 84, b: 52)).cornerRadius(8)
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
Text("Coffee reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
Text("Coffee reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||||||
|
habitBadge("coffee")
|
||||||
|
}
|
||||||
Text("Track when you usually reach for a cup.")
|
Text("Track when you usually reach for a cup.")
|
||||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||||
}
|
}
|
||||||
@@ -1051,6 +1060,16 @@ private struct HabitRemindersSheet: View {
|
|||||||
: ["Morning": 8, "Midday": 12, "Afternoon": 15, "Evening": 18]
|
: ["Morning": 8, "Midday": 12, "Afternoon": 15, "Evening": 18]
|
||||||
return table[slot] ?? 9
|
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
|
/// One named, independently-toggleable daily time slot (e.g. "Morning") — used
|
||||||
|
|||||||
Reference in New Issue
Block a user