Merge develop -> main: full session (KC-51 through KC-71) #28

Merged
kutesir merged 64 commits from develop into main 2026-07-12 22:57:14 +00:00
3 changed files with 116 additions and 9 deletions
Showing only changes of commit 97782a82bc - Show all commits

View File

@@ -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 39 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.

View File

@@ -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

View File

@@ -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) {
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?\"") 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) {
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.") 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) {
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.") 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