From 3f46369078ffd2bcb9a2b15b5a8e3a6270aac0e0 Mon Sep 17 00:00:00 2001 From: kutesir Date: Tue, 7 Jul 2026 23:05:02 +0300 Subject: [PATCH] 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 --- KisaniCal.xcodeproj/project.pbxproj | 4 + KisaniCal/Analytics/AnalyticsService.swift | 183 +++++++++++++++++++ KisaniCal/ContentView.swift | 5 + KisaniCal/ISSUES.md | 17 ++ KisaniCal/Managers/NotificationManager.swift | 56 ++++++ 5 files changed, 265 insertions(+) create mode 100644 KisaniCal/Analytics/AnalyticsService.swift diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index eb2674c..f665cf1 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; }; 55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.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 */; }; 6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.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 = ""; }; 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = ""; }; 35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = ""; }; + 3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; 3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStoreTests.swift; sourceTree = ""; }; 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalWidgets.swift; sourceTree = ""; }; 429806CE1021C8DE2EB770CE /* WidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViews.swift; sourceTree = ""; }; @@ -272,6 +274,7 @@ 7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */, 78EEE7568265196447E54D6B /* AnalyticsEngine.swift */, 0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */, + 3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */, 10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */, E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */, ); @@ -509,6 +512,7 @@ B39F302F10FA4899AA0A5BAC /* AnalyticsDeepLink.swift in Sources */, 152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */, 42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */, + 5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */, E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */, 2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */, 3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */, diff --git a/KisaniCal/Analytics/AnalyticsService.swift b/KisaniCal/Analytics/AnalyticsService.swift new file mode 100644 index 0000000..1c2cff8 --- /dev/null +++ b/KisaniCal/Analytics/AnalyticsService.swift @@ -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 = [] + var scheduledWeekdays: Set = [] + var missedDates: Set = [] + + 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 { + 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() + 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) + } +} diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift index 968b7f1..8ba9048 100644 --- a/KisaniCal/ContentView.swift +++ b/KisaniCal/ContentView.swift @@ -105,6 +105,9 @@ struct ContentView: View { HealthKitManager.shared.bootstrap() NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) 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. Task { await workoutVM.syncFromHealthKit() @@ -120,6 +123,8 @@ struct ContentView: View { workoutVM.checkPendingMissed() CloudSyncManager.shared.backupSettings() NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) + let generated = AnalyticsService.shared.reconcile(using: workoutVM) + NotificationManager.shared.scheduleAnalyticsReports(generated) Task { await workoutVM.syncFromHealthKit() NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index 319add2..d4577ad 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -1591,3 +1591,20 @@ last. Device/build QA tracked here. - 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, 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. diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index 0172d28..6958eb4 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -180,6 +180,57 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable 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 /// 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 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 { case Self.actionMarkId: