analytics(app): AnalyticsService + lifecycle reconcile + deep-linked notifications (KC-51 ph4a)
Wires the verified analytics core into the app: AnalyticsService feeds live WorkoutViewModel data into the reconcile coordinator and exposes UI reads; ContentView reconciles on launch + foreground; NotificationManager fires one permission-aware, deduped weekly/monthly report notification carrying an AnalyticsDeepLink, captured on tap for routing. NOT build-verified (no iOS runtime; app-module/SwiftUI deps). Logged in ISSUES.md; historical HealthKit reference + user-scheduled notification time are follow-ups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@
|
|||||||
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; };
|
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; };
|
||||||
55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; };
|
55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; };
|
||||||
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; };
|
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; };
|
||||||
|
5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */; };
|
||||||
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; };
|
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; };
|
||||||
6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; };
|
6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; };
|
||||||
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */; };
|
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */; };
|
||||||
@@ -118,6 +119,7 @@
|
|||||||
23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; };
|
23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; };
|
||||||
326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = "<group>"; };
|
326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = "<group>"; };
|
||||||
35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = "<group>"; };
|
35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = "<group>"; };
|
||||||
|
3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
|
||||||
3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStoreTests.swift; sourceTree = "<group>"; };
|
3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStoreTests.swift; sourceTree = "<group>"; };
|
||||||
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalWidgets.swift; sourceTree = "<group>"; };
|
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalWidgets.swift; sourceTree = "<group>"; };
|
||||||
429806CE1021C8DE2EB770CE /* WidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViews.swift; sourceTree = "<group>"; };
|
429806CE1021C8DE2EB770CE /* WidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViews.swift; sourceTree = "<group>"; };
|
||||||
@@ -272,6 +274,7 @@
|
|||||||
7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */,
|
7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */,
|
||||||
78EEE7568265196447E54D6B /* AnalyticsEngine.swift */,
|
78EEE7568265196447E54D6B /* AnalyticsEngine.swift */,
|
||||||
0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */,
|
0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */,
|
||||||
|
3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */,
|
||||||
10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */,
|
10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */,
|
||||||
E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */,
|
E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */,
|
||||||
);
|
);
|
||||||
@@ -509,6 +512,7 @@
|
|||||||
B39F302F10FA4899AA0A5BAC /* AnalyticsDeepLink.swift in Sources */,
|
B39F302F10FA4899AA0A5BAC /* AnalyticsDeepLink.swift in Sources */,
|
||||||
152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */,
|
152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */,
|
||||||
42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */,
|
42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */,
|
||||||
|
5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */,
|
||||||
E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */,
|
E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */,
|
||||||
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
|
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
|
||||||
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
|
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
|
||||||
|
|||||||
183
KisaniCal/Analytics/AnalyticsService.swift
Normal file
183
KisaniCal/Analytics/AnalyticsService.swift
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// App-side glue for the analytics system. Owns the file store, builds the
|
||||||
|
// reconcile coordinator from a snapshot of live workout data, and exposes read
|
||||||
|
// APIs for the Analytics UI. Pure engine/store/coordinator/adapter are unit-
|
||||||
|
// tested; this layer is thin wiring (compiles in-target; not standalone-tested).
|
||||||
|
//
|
||||||
|
// HealthKit per-day historical reference is optional and currently left nil
|
||||||
|
// (honest — never fabricated). Wiring historical HK queries is a documented
|
||||||
|
// follow-up; DaySample health fields already degrade gracefully.
|
||||||
|
@MainActor
|
||||||
|
final class AnalyticsService: ObservableObject {
|
||||||
|
static let shared = AnalyticsService()
|
||||||
|
|
||||||
|
let store: AnalyticsStore
|
||||||
|
private let calendar: Calendar
|
||||||
|
|
||||||
|
@Published private(set) var lastReconcile: Date?
|
||||||
|
/// Cached snapshot from the most recent reconcile, so UI reads (daily
|
||||||
|
/// comparison, per-exercise history) don't need the view model passed around.
|
||||||
|
private var snapshot = WorkoutSnapshot()
|
||||||
|
|
||||||
|
init(store: AnalyticsStore = AnalyticsStore(), calendar: Calendar = .current) {
|
||||||
|
self.store = store
|
||||||
|
self.calendar = calendar
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Snapshot of live workout data (decouples from the view model)
|
||||||
|
|
||||||
|
struct WorkoutSnapshot {
|
||||||
|
var logByDate: [String: WorkoutDayLog] = [:]
|
||||||
|
var workoutDates: Set<String> = []
|
||||||
|
var scheduledWeekdays: Set<Int> = []
|
||||||
|
var missedDates: Set<String> = []
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
init(vm: WorkoutViewModel) {
|
||||||
|
self.logByDate = Dictionary(vm.history.map { ($0.date, $0) }, uniquingKeysWith: { a, _ in a })
|
||||||
|
self.workoutDates = Set(vm.workoutDates)
|
||||||
|
self.scheduledWeekdays = Set(vm.schedule.keys)
|
||||||
|
self.missedDates = Set(vm.missedDates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reconcile (call on launch + scenePhase active)
|
||||||
|
|
||||||
|
/// Snapshot recent days, backfill missing completed weekly/monthly reports,
|
||||||
|
/// prune. Idempotent — safe to call every launch/foreground.
|
||||||
|
@discardableResult
|
||||||
|
func reconcile(using vm: WorkoutViewModel, now: Date = Date()) -> AnalyticsCoordinator.ReconcileResult {
|
||||||
|
snapshot = WorkoutSnapshot(vm: vm)
|
||||||
|
freezeRecentSnapshots(now: now)
|
||||||
|
let result = makeCoordinator(now: now).reconcile()
|
||||||
|
lastReconcile = now
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCoordinator(now: Date) -> AnalyticsCoordinator {
|
||||||
|
AnalyticsCoordinator(
|
||||||
|
store: store, calendar: calendar, retentionMonths: 12, now: { now },
|
||||||
|
weekSamples: { [weak self] key in
|
||||||
|
guard let self else { return ([], []) }
|
||||||
|
let start = self.date(fromKey: key) ?? now
|
||||||
|
let prevStart = self.calendar.date(byAdding: .weekOfYear, value: -1, to: start) ?? start
|
||||||
|
return (self.samples(WorkoutAnalyticsAdapter.weekDayKeys(containing: start, calendar: self.calendar)),
|
||||||
|
self.samples(WorkoutAnalyticsAdapter.weekDayKeys(containing: prevStart, calendar: self.calendar)))
|
||||||
|
},
|
||||||
|
monthSamples: { [weak self] key in
|
||||||
|
guard let self, let start = self.date(fromMonthKey: key) else { return ([], []) }
|
||||||
|
let prevStart = self.calendar.date(byAdding: .month, value: -1, to: start) ?? start
|
||||||
|
return (self.samples(WorkoutAnalyticsAdapter.monthDayKeys(containing: start, calendar: self.calendar)),
|
||||||
|
self.samples(WorkoutAnalyticsAdapter.monthDayKeys(containing: prevStart, calendar: self.calendar)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Freeze immutable snapshots for completed recent days (yesterday and back a
|
||||||
|
/// week) so later edits can't rewrite them; refresh today's live.
|
||||||
|
private func freezeRecentSnapshots(now: Date) {
|
||||||
|
let today = calendar.startOfDay(for: now)
|
||||||
|
for back in 0...8 {
|
||||||
|
guard let d = calendar.date(byAdding: .day, value: -back, to: today) else { continue }
|
||||||
|
let key = AnalyticsEngine.dateKey(for: d, calendar: calendar)
|
||||||
|
let sample = daySample(forKey: key)
|
||||||
|
store.saveSnapshot(sample, isPast: back > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sample building
|
||||||
|
|
||||||
|
private func samples(_ keys: [String]) -> [DaySample] {
|
||||||
|
WorkoutAnalyticsAdapter.daySamples(dayKeys: keys,
|
||||||
|
workouts: workoutsMap(for: keys),
|
||||||
|
scheduledKeys: scheduledKeys(for: keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func workoutsMap(for keys: [String]) -> [String: RawWorkoutDay] {
|
||||||
|
var map: [String: RawWorkoutDay] = [:]
|
||||||
|
for key in keys {
|
||||||
|
if let log = snapshot.logByDate[key] {
|
||||||
|
map[key] = raw(from: log, done: snapshot.workoutDates.contains(key))
|
||||||
|
} else if snapshot.workoutDates.contains(key) {
|
||||||
|
map[key] = RawWorkoutDay(dateKey: key, didWorkout: true, sessions: 1, durationMinutes: 0, exercises: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduledKeys(for keys: [String]) -> Set<String> {
|
||||||
|
Set(keys.filter { key in
|
||||||
|
guard let d = date(fromKey: key) else { return false }
|
||||||
|
return snapshot.scheduledWeekdays.contains(calendar.component(.weekday, from: d))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func raw(from log: WorkoutDayLog, done: Bool) -> RawWorkoutDay {
|
||||||
|
let samples = log.exercises.map { ex -> ExerciseSample in
|
||||||
|
let completed = ex.sets.filter { $0.done }.map { (weight: $0.weight, reps: $0.reps) }
|
||||||
|
return WorkoutAnalyticsAdapter.exerciseSample(name: ex.name, completedSets: completed)
|
||||||
|
}
|
||||||
|
return RawWorkoutDay(dateKey: log.date, didWorkout: done || !samples.isEmpty,
|
||||||
|
sessions: 1, durationMinutes: 0, exercises: samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func daySample(forKey key: String) -> DaySample {
|
||||||
|
samples([key]).first ?? DaySample(dateKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Read APIs for the UI
|
||||||
|
|
||||||
|
func weeklyReports() -> [WeeklyReport] { store.allWeeklyReports().sorted { $0.weekStartKey > $1.weekStartKey } }
|
||||||
|
func monthlyReports() -> [MonthlyReport] { store.allMonthlyReports().sorted { $0.monthKey > $1.monthKey } }
|
||||||
|
|
||||||
|
/// 12-month volume series (oldest→newest) for the trends screen.
|
||||||
|
func monthlyVolumeTrend() -> [(monthKey: String, volume: Double)] {
|
||||||
|
store.allMonthlyReports().sorted { $0.monthKey < $1.monthKey }.map { ($0.monthKey, $0.summary.volume) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func monthlyTrendClassification() -> TrendResult {
|
||||||
|
AnalyticsEngine.classifyTrend(monthlyVolumeTrend().map(\.volume))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Daily comparison for `date`: vs previous day and vs same weekday last week.
|
||||||
|
func dailyComparison(on date: Date) -> DailyComparison {
|
||||||
|
let dayKey = AnalyticsEngine.dateKey(for: date, calendar: calendar)
|
||||||
|
let prev = calendar.date(byAdding: .day, value: -1, to: date)
|
||||||
|
let lastWeek = AnalyticsEngine.sameWeekdayLastWeek(date, calendar: calendar)
|
||||||
|
return AnalyticsEngine.dailyComparison(
|
||||||
|
day: daySample(forKey: dayKey),
|
||||||
|
previousDay: prev.map { daySample(forKey: AnalyticsEngine.dateKey(for: $0, calendar: calendar)) },
|
||||||
|
sameWeekdayLastWeek: lastWeek.map { daySample(forKey: AnalyticsEngine.dateKey(for: $0, calendar: calendar)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-exercise volume history (dateKey→volume) across stored snapshots.
|
||||||
|
func exerciseHistory(identity: String) -> [(dateKey: String, volume: Double)] {
|
||||||
|
store.allSnapshotKeys().sorted().compactMap { key in
|
||||||
|
guard let sample = store.snapshot(dateKey: key),
|
||||||
|
let ex = sample.exercises.first(where: { $0.identity == identity }) else { return nil }
|
||||||
|
return (key, ex.volume)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distinct exercise identities seen across stored snapshots (for pickers).
|
||||||
|
func knownExerciseIdentities() -> [String] {
|
||||||
|
var set = Set<String>()
|
||||||
|
for key in store.allSnapshotKeys() {
|
||||||
|
store.snapshot(dateKey: key)?.exercises.forEach { set.insert($0.identity) }
|
||||||
|
}
|
||||||
|
return set.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Key helpers
|
||||||
|
|
||||||
|
private func date(fromKey key: String) -> Date? {
|
||||||
|
let f = DateFormatter(); f.calendar = calendar; f.timeZone = calendar.timeZone
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX"); f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f.date(from: key)
|
||||||
|
}
|
||||||
|
private func date(fromMonthKey key: String) -> Date? {
|
||||||
|
let f = DateFormatter(); f.calendar = calendar; f.timeZone = calendar.timeZone
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX"); f.dateFormat = "yyyy-MM"
|
||||||
|
return f.date(from: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,6 +105,9 @@ struct ContentView: View {
|
|||||||
HealthKitManager.shared.bootstrap()
|
HealthKitManager.shared.bootstrap()
|
||||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||||
consumeWidgetAddTask()
|
consumeWidgetAddTask()
|
||||||
|
// Backfill any missing analytics reports (idempotent), then notify.
|
||||||
|
let generated = AnalyticsService.shared.reconcile(using: workoutVM)
|
||||||
|
NotificationManager.shared.scheduleAnalyticsReports(generated)
|
||||||
// Pull workouts from Health, then reschedule so a detected workout updates reminders.
|
// Pull workouts from Health, then reschedule so a detected workout updates reminders.
|
||||||
Task {
|
Task {
|
||||||
await workoutVM.syncFromHealthKit()
|
await workoutVM.syncFromHealthKit()
|
||||||
@@ -120,6 +123,8 @@ struct ContentView: View {
|
|||||||
workoutVM.checkPendingMissed()
|
workoutVM.checkPendingMissed()
|
||||||
CloudSyncManager.shared.backupSettings()
|
CloudSyncManager.shared.backupSettings()
|
||||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||||
|
let generated = AnalyticsService.shared.reconcile(using: workoutVM)
|
||||||
|
NotificationManager.shared.scheduleAnalyticsReports(generated)
|
||||||
Task {
|
Task {
|
||||||
await workoutVM.syncFromHealthKit()
|
await workoutVM.syncFromHealthKit()
|
||||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||||
|
|||||||
@@ -1591,3 +1591,20 @@ last. Device/build QA tracked here.
|
|||||||
- BLOCKER unchanged: no iOS runtime in this env → cannot run the Xcode test
|
- BLOCKER unchanged: no iOS runtime in this env → cannot run the Xcode test
|
||||||
target or build (goal conditions #10/#11, and thus UI #8/#9). Restore a runtime,
|
target or build (goal conditions #10/#11, and thus UI #8/#9). Restore a runtime,
|
||||||
then wire + build + run the suite incrementally.
|
then wire + build + run the suite incrementally.
|
||||||
|
|
||||||
|
### KC-51 progress — Phase 4a: app integration wiring (NOT build-verified)
|
||||||
|
- `AnalyticsService` (@MainActor): owns the store, builds the coordinator from a
|
||||||
|
`WorkoutViewModel` snapshot (history/workoutDates/schedule/missedDates →
|
||||||
|
RawWorkoutDay → DaySample), freezes recent daily snapshots, exposes UI reads
|
||||||
|
(weekly/monthly reports, 12-month volume trend, daily comparison, per-exercise
|
||||||
|
history). HealthKit per-day historical reference left nil (honest) — follow-up.
|
||||||
|
- Lifecycle: `ContentView` calls `AnalyticsService.shared.reconcile(using:)` +
|
||||||
|
`NotificationManager.scheduleAnalyticsReports(...)` on launch and scenePhase
|
||||||
|
`.active` (reuses existing pending-action pattern).
|
||||||
|
- Notifications: `scheduleAnalyticsReports` fires one permission-aware,
|
||||||
|
setting-gated weekly + monthly notification with an `AnalyticsDeepLink` in
|
||||||
|
userInfo, deduped by fixed per-period id (remove-then-add). Tap captured in the
|
||||||
|
UNUserNotificationCenter delegate → `consumePendingAnalyticsDeepLink()`.
|
||||||
|
- ⚠️ NOT compiled/build-verified (no iOS runtime + app-module/SwiftUI deps).
|
||||||
|
Time-of-day "user-scheduled" report notification (vs deliver-on-detect) and
|
||||||
|
historical HealthKit reference are documented follow-ups.
|
||||||
|
|||||||
@@ -180,6 +180,57 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
add(Self.periodIds[3], "This Year", "One more year passed.", year)
|
add(Self.periodIds[3], "This Year", "One more year passed.", year)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Analytics report notifications (deep-linked)
|
||||||
|
|
||||||
|
/// Fire one local notification for each newly-generated weekly / monthly
|
||||||
|
/// report, deep-linking to it. Permission-aware and gated on the same
|
||||||
|
/// "Period milestones" setting. `result` only lists reports created this run
|
||||||
|
/// (reconcile is idempotent) and each uses a fixed per-period identifier with
|
||||||
|
/// remove-then-add, so notifications are never duplicated.
|
||||||
|
func scheduleAnalyticsReports(_ result: AnalyticsCoordinator.ReconcileResult) {
|
||||||
|
guard !result.isEmpty,
|
||||||
|
UserDefaults.kisani.object(forKey: "kisani.periodMilestones") as? Bool ?? true else { return }
|
||||||
|
center.getNotificationSettings { [weak self] settings in
|
||||||
|
guard let self,
|
||||||
|
settings.authorizationStatus == .authorized
|
||||||
|
|| settings.authorizationStatus == .provisional else { return }
|
||||||
|
if let week = result.weekliesGenerated.max() {
|
||||||
|
self.postAnalyticsReport(id: "kisani.analytics.weekly.\(week)",
|
||||||
|
title: "Your week in review",
|
||||||
|
body: "Your weekly training summary is ready.",
|
||||||
|
link: .weekly(weekStartKey: week))
|
||||||
|
}
|
||||||
|
if let month = result.monthliesGenerated.max() {
|
||||||
|
self.postAnalyticsReport(id: "kisani.analytics.monthly.\(month)",
|
||||||
|
title: "Your month in review",
|
||||||
|
body: "Your monthly training summary is ready.",
|
||||||
|
link: .monthly(monthKey: month))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func postAnalyticsReport(id: String, title: String, body: String, link: AnalyticsDeepLink) {
|
||||||
|
center.removePendingNotificationRequests(withIdentifiers: [id])
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = title
|
||||||
|
content.body = body
|
||||||
|
content.sound = .default
|
||||||
|
content.userInfo = [AnalyticsDeepLink.userInfoKey: link.stringValue]
|
||||||
|
// Reconcile already runs at an appropriate moment (app opened after the
|
||||||
|
// period closed) — not tied to exact background execution. Deliver shortly.
|
||||||
|
center.add(UNNotificationRequest(
|
||||||
|
identifier: id, content: content,
|
||||||
|
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app reads this on active to route to a tapped report (mirrors the
|
||||||
|
/// existing pending-action pattern used for workout confirm/missed).
|
||||||
|
func consumePendingAnalyticsDeepLink() -> AnalyticsDeepLink? {
|
||||||
|
guard let s = UserDefaults.kisani.string(forKey: "kisani.analytics.pendingDeepLink") else { return nil }
|
||||||
|
UserDefaults.kisani.removeObject(forKey: "kisani.analytics.pendingDeepLink")
|
||||||
|
return AnalyticsDeepLink(string: s)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Recurring task completion confirmation
|
// MARK: - Recurring task completion confirmation
|
||||||
|
|
||||||
/// Posts a quiet "✓ Done. Repeats …" confirmation when a recurring task's
|
/// Posts a quiet "✓ Done. Repeats …" confirmation when a recurring task's
|
||||||
@@ -534,6 +585,11 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
|
|||||||
let deeplink = info["deeplink"] as? String
|
let deeplink = info["deeplink"] as? String
|
||||||
let taskId = info["taskId"] as? String
|
let taskId = info["taskId"] as? String
|
||||||
|
|
||||||
|
// Analytics report tap → store the deep link for the app to route on active.
|
||||||
|
if let s = info[AnalyticsDeepLink.userInfoKey] as? String, AnalyticsDeepLink(string: s) != nil {
|
||||||
|
UserDefaults.kisani.set(s, forKey: "kisani.analytics.pendingDeepLink")
|
||||||
|
}
|
||||||
|
|
||||||
switch response.actionIdentifier {
|
switch response.actionIdentifier {
|
||||||
|
|
||||||
case Self.actionMarkId:
|
case Self.actionMarkId:
|
||||||
|
|||||||
Reference in New Issue
Block a user