analytics(app): AnalyticsService + lifecycle reconcile + deep-linked notifications (KC-51 ph4a)
Some checks failed
CI / build-and-test (push) Has been cancelled

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:
kutesir
2026-07-07 23:05:02 +03:00
parent 065066c8f7
commit d25cf9ba80
5 changed files with 265 additions and 0 deletions

View File

@@ -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 */,

View 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 (oldestnewest) 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 (dateKeyvolume) 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)
}
}

View File

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

View File

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

View File

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