From f3213c080ea21d911c5fb94141916dfd618a823c Mon Sep 17 00:00:00 2001 From: kutesir Date: Tue, 7 Jul 2026 22:53:20 +0300 Subject: [PATCH] analytics(integration): adapter + reconcile coordinator + deep links (KC-51 ph3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 core (verifiable): WorkoutAnalyticsAdapter (app data → DaySample), AnalyticsCoordinator (idempotent backfill of missing weekly/monthly reports + retention prune, injectable providers), AnalyticsDeepLink (wenza://analytics report routing). Fixed a reconcile bug caught by tests: a retention-boundary week was generated then immediately pruned, regenerating every run — aligned completedWeekStartKeys to the prune cutoff → idempotent. Tests: AnalyticsAdapterTests + AnalyticsCoordinatorTests. Verified standalone via swiftc (~90 analytics assertions total across engine/store/adapter/coordinator). Remaining (app wiring, notifications, UI) requires an Xcode build — logged in ISSUES.md as device-only. Co-Authored-By: Claude Opus 4.8 --- KisaniCal.xcodeproj/project.pbxproj | 20 ++++ .../Analytics/AnalyticsCoordinator.swift | 76 +++++++++++++++ KisaniCal/Analytics/AnalyticsDeepLink.swift | 56 +++++++++++ KisaniCal/Analytics/AnalyticsEngine.swift | 7 +- .../Analytics/WorkoutAnalyticsAdapter.swift | 93 +++++++++++++++++++ KisaniCal/ISSUES.md | 30 ++++++ KisaniCalTests/AnalyticsAdapterTests.swift | 61 ++++++++++++ .../AnalyticsCoordinatorTests.swift | 53 +++++++++++ 8 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 KisaniCal/Analytics/AnalyticsCoordinator.swift create mode 100644 KisaniCal/Analytics/AnalyticsDeepLink.swift create mode 100644 KisaniCal/Analytics/WorkoutAnalyticsAdapter.swift create mode 100644 KisaniCalTests/AnalyticsAdapterTests.swift create mode 100644 KisaniCalTests/AnalyticsCoordinatorTests.swift diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index d9e89ef..eb2674c 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 023A636FF533304DA5578D1C /* AnalyticsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */; }; 06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */; }; 096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; }; 12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; }; @@ -50,8 +51,11 @@ A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; }; A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; }; AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */; }; + B0B83389E72D6DF4563D3DD4 /* AnalyticsAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */; }; + B39F302F10FA4899AA0A5BAC /* AnalyticsDeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */; }; BD0DB4B0AA8A63D124EDFF2C /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */; }; BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44530A77DF12A17E52AAF34 /* MatrixView.swift */; }; + C69A21EC244513185A1F59BD /* WorkoutAnalyticsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */; }; C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */; }; C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.swift */; }; CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */; }; @@ -62,6 +66,7 @@ ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD014B7E3E30A34E18696A0 /* AuthView.swift */; }; EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */; }; EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */; }; + FC3D7406706ADC96500AB764 /* AnalyticsCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -123,11 +128,13 @@ 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = ""; }; 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskContextMenu.swift; sourceTree = ""; }; 69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityHistoryView.swift; sourceTree = ""; }; + 713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsAdapterTests.swift; sourceTree = ""; }; 72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGridTests.swift; sourceTree = ""; }; 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = ""; }; 78EEE7568265196447E54D6B /* AnalyticsEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEngine.swift; sourceTree = ""; }; + 7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsDeepLink.swift; sourceTree = ""; }; 7A9D47B284FD6A217AEF813B /* WenzaWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WenzaWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechRecognizer.swift; sourceTree = ""; }; 89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; @@ -139,6 +146,7 @@ 9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLRecurrenceParseTests.swift; sourceTree = ""; }; 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEngineTests.swift; sourceTree = ""; }; + A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCoordinatorTests.swift; sourceTree = ""; }; ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = ""; }; AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = ""; }; B4CFFDFE4653A9E901CEF28D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -149,8 +157,10 @@ C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = ""; }; D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakLogicTests.swift; sourceTree = ""; }; D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = ""; }; + D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCoordinator.swift; sourceTree = ""; }; D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurrenceTests.swift; sourceTree = ""; }; DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = ""; }; + E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutAnalyticsAdapter.swift; sourceTree = ""; }; ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayMenuFeatures.swift; sourceTree = ""; }; FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = ""; }; FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = ""; }; @@ -163,6 +173,8 @@ 068B77B2F01C399C7A430292 /* KisaniCalTests */ = { isa = PBXGroup; children = ( + 713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */, + A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */, 9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */, 3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */, 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */, @@ -256,9 +268,12 @@ C7409CC354029293D424BEDA /* Analytics */ = { isa = PBXGroup; children = ( + D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */, + 7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */, 78EEE7568265196447E54D6B /* AnalyticsEngine.swift */, 0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */, 10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */, + E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */, ); path = Analytics; sourceTree = ""; @@ -465,6 +480,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B0B83389E72D6DF4563D3DD4 /* AnalyticsAdapterTests.swift in Sources */, + FC3D7406706ADC96500AB764 /* AnalyticsCoordinatorTests.swift in Sources */, 550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */, 87564C27F644ED9CFF1E517B /* AnalyticsStoreTests.swift in Sources */, 6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */, @@ -488,6 +505,8 @@ files = ( D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */, A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */, + 023A636FF533304DA5578D1C /* AnalyticsCoordinator.swift in Sources */, + B39F302F10FA4899AA0A5BAC /* AnalyticsDeepLink.swift in Sources */, 152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */, 42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */, E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */, @@ -520,6 +539,7 @@ C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */, 3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */, 8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */, + C69A21EC244513185A1F59BD /* WorkoutAnalyticsAdapter.swift in Sources */, 9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/KisaniCal/Analytics/AnalyticsCoordinator.swift b/KisaniCal/Analytics/AnalyticsCoordinator.swift new file mode 100644 index 0000000..2e79cd0 --- /dev/null +++ b/KisaniCal/Analytics/AnalyticsCoordinator.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Generates any missing immutable weekly/monthly reports for completed periods, +/// then prunes beyond the retention window. Idempotent: already-generated periods +/// are skipped (dedup by store key), so it is safe to run on every launch / +/// foreground without depending on exact background execution times. +/// +/// Sample providers are injected so the reconcile logic stays independent of app +/// models / HealthKit and is unit-testable. The app wires the providers to +/// `WorkoutAnalyticsAdapter` + `WorkoutViewModel` + `HealthKitManager`. +final class AnalyticsCoordinator { + + struct ReconcileResult: Equatable { + var weekliesGenerated: [String] = [] + var monthliesGenerated: [String] = [] + var isEmpty: Bool { weekliesGenerated.isEmpty && monthliesGenerated.isEmpty } + } + + /// Samples for a completed period plus its comparison baseline. + typealias PeriodSamples = (days: [DaySample], previous: [DaySample]) + + private let store: AnalyticsStore + private let calendar: Calendar + private let now: () -> Date + private let retentionMonths: Int + private let weekSamples: (_ weekStartKey: String) -> PeriodSamples + private let monthSamples: (_ monthKey: String) -> PeriodSamples + + init(store: AnalyticsStore, + calendar: Calendar, + retentionMonths: Int = 12, + now: @escaping () -> Date = Date.init, + weekSamples: @escaping (String) -> PeriodSamples, + monthSamples: @escaping (String) -> PeriodSamples) { + self.store = store + self.calendar = calendar + self.retentionMonths = retentionMonths + self.now = now + self.weekSamples = weekSamples + self.monthSamples = monthSamples + } + + /// Backfill every completed week/month within the retention window that has + /// no report yet, then prune expired data. Returns which periods were newly + /// generated (empty on a no-op run → drives "should I notify?" decisions). + @discardableResult + func reconcile() -> ReconcileResult { + let ref = now() + var result = ReconcileResult() + + let allWeeks = AnalyticsEngine.completedWeekStartKeys(now: ref, months: retentionMonths, calendar: calendar) + for key in AnalyticsEngine.missingKeys(allWeeks, existing: store.existingWeeklyKeys()) { + let s = weekSamples(key) + let report = AnalyticsEngine.weeklyReport(weekStartKey: key, days: s.days, + previousWeekDays: s.previous, generatedAt: ref) + if store.saveWeeklyReport(report) { result.weekliesGenerated.append(key) } + } + + let allMonths = AnalyticsEngine.completedMonthKeys(now: ref, months: retentionMonths, calendar: calendar) + for key in AnalyticsEngine.missingKeys(allMonths, existing: store.existingMonthlyKeys()) { + let s = monthSamples(key) + let report = AnalyticsEngine.monthlyReport(monthKey: key, days: s.days, + previousMonthDays: s.previous, generatedAt: ref) + if store.saveMonthlyReport(report) { result.monthliesGenerated.append(key) } + } + + store.prune(now: ref, months: retentionMonths, calendar: calendar) + return result + } + + /// The most recent completed weekly / monthly report keys (for "notify about + /// the latest report" without duplicating). Deep links for these come from + /// `AnalyticsDeepLink`. + func latestWeeklyKey() -> String? { store.existingWeeklyKeys().max() } + func latestMonthlyKey() -> String? { store.existingMonthlyKeys().max() } +} diff --git a/KisaniCal/Analytics/AnalyticsDeepLink.swift b/KisaniCal/Analytics/AnalyticsDeepLink.swift new file mode 100644 index 0000000..a47e0f5 --- /dev/null +++ b/KisaniCal/Analytics/AnalyticsDeepLink.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Deep links into the Analytics area — used by weekly/monthly report +/// notifications to route straight to the relevant report. Pure value type with +/// symmetric URL encode/decode so routing is testable without any UI. +/// +/// Format: wenza://analytics → overview +/// wenza://analytics/weekly/ → a weekly report +/// wenza://analytics/monthly/ → a monthly report +enum AnalyticsDeepLink: Equatable { + case overview + case weekly(weekStartKey: String) + case monthly(monthKey: String) + + static let scheme = "wenza" + static let host = "analytics" + + /// Stable userInfo key carried on notifications. + static let userInfoKey = "kisani.analytics.deeplink" + + var url: URL { + var c = URLComponents() + c.scheme = Self.scheme + c.host = Self.host + switch self { + case .overview: c.path = "" + case .weekly(let key): c.path = "/weekly/\(key)" + case .monthly(let key): c.path = "/monthly/\(key)" + } + return c.url! + } + + init?(url: URL) { + guard url.scheme == Self.scheme, url.host == Self.host else { return nil } + let parts = url.path.split(separator: "/").map(String.init) + switch parts.first { + case nil: + self = .overview + case "weekly": + guard parts.count >= 2 else { return nil } + self = .weekly(weekStartKey: parts[1]) + case "monthly": + guard parts.count >= 2 else { return nil } + self = .monthly(monthKey: parts[1]) + default: + return nil + } + } + + /// Build/parse via a plain string (for notification userInfo payloads). + var stringValue: String { url.absoluteString } + init?(string: String) { + guard let url = URL(string: string) else { return nil } + self.init(url: url) + } +} diff --git a/KisaniCal/Analytics/AnalyticsEngine.swift b/KisaniCal/Analytics/AnalyticsEngine.swift index 372b29a..e24c338 100644 --- a/KisaniCal/Analytics/AnalyticsEngine.swift +++ b/KisaniCal/Analytics/AnalyticsEngine.swift @@ -288,14 +288,19 @@ enum AnalyticsEngine { // MARK: - Reconciliation selection (which completed periods need a report) /// Week-start keys of every completed week within the last `months`, oldest→newest. + /// Only weeks whose start is on/after the retention cutoff are included, so + /// generation and `prune` agree — otherwise a boundary week would be + /// generated then immediately pruned, regenerating on every run. static func completedWeekStartKeys(now: Date, months: Int = 12, calendar cal: Calendar) -> [String] { guard let windowStart = cal.date(byAdding: .month, value: -months, to: now), let thisWeek = weekInterval(containing: now, calendar: cal), var cursor = weekInterval(containing: windowStart, calendar: cal)?.start else { return [] } + let cutoffKey = dateKey(for: cal.startOfDay(for: windowStart), calendar: cal) var keys: [String] = [] var guardCount = 0 while cursor < thisWeek.start, guardCount < 70 { - keys.append(dateKey(for: cursor, calendar: cal)) + let key = dateKey(for: cursor, calendar: cal) + if key >= cutoffKey { keys.append(key) } guard let next = cal.date(byAdding: .weekOfYear, value: 1, to: cursor) else { break } cursor = next; guardCount += 1 } diff --git a/KisaniCal/Analytics/WorkoutAnalyticsAdapter.swift b/KisaniCal/Analytics/WorkoutAnalyticsAdapter.swift new file mode 100644 index 0000000..4ffda58 --- /dev/null +++ b/KisaniCal/Analytics/WorkoutAnalyticsAdapter.swift @@ -0,0 +1,93 @@ +import Foundation + +// Pure bridge from app workout data (+ HealthKit reference) into the engine's +// `DaySample` inputs. Kept free of app-model / HealthKit types so it stays +// deterministic and unit-testable; the app maps `WorkoutDayLog` → `RawWorkoutDay` +// and supplies HealthKit reference values at the call site. + +/// One day's app-generated workout, already reduced to analysis primitives. +/// The app builds this from `WorkoutDayLog` (volume = Σ weight×reps of done sets). +struct RawWorkoutDay: Equatable { + let dateKey: String + let didWorkout: Bool + let sessions: Int + let durationMinutes: Double + let exercises: [ExerciseSample] + + var sets: Int { exercises.reduce(0) { $0 + $1.sets } } + var volume: Double { exercises.reduce(0) { $0 + $1.volume } } +} + +/// HealthKit reference values for a day (source of truth for health measurements). +/// All optional — authorization or data may be missing. +struct RawHealthDay: Equatable { + let steps: Int? + let activeCalories: Int? + let restingHeartRate: Int? +} + +enum WorkoutAnalyticsAdapter { + + /// Build ordered `DaySample`s for `dayKeys`. A day is: + /// - `didWorkout` if it has a workout record with `didWorkout`, + /// - `scheduled` if its key is in `scheduledKeys`, + /// - enriched with HealthKit reference values where present. + /// Missing days become rest/empty samples (honest — no fabricated data). + static func daySamples(dayKeys: [String], + workouts: [String: RawWorkoutDay], + scheduledKeys: Set, + health: [String: RawHealthDay] = [:]) -> [DaySample] { + dayKeys.map { key in + let w = workouts[key] + let h = health[key] + return DaySample( + dateKey: key, + didWorkout: w?.didWorkout ?? false, + scheduled: scheduledKeys.contains(key), + sessions: w?.sessions ?? 0, + sets: w?.sets ?? 0, + volume: w?.volume ?? 0, + durationMinutes: w?.durationMinutes ?? 0, + exercises: w?.exercises ?? [], + steps: h?.steps, + activeCalories: h?.activeCalories, + restingHeartRate: h?.restingHeartRate + ) + } + } + + /// Inclusive list of day keys from `start` to `end`, in the given calendar. + static func dayKeys(from start: Date, to end: Date, calendar: Calendar) -> [String] { + var keys: [String] = [] + var cursor = calendar.startOfDay(for: start) + let last = calendar.startOfDay(for: end) + var guardCount = 0 + while cursor <= last, guardCount < 800 { // ~2yr ceiling + keys.append(AnalyticsEngine.dateKey(for: cursor, calendar: calendar)) + guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + cursor = next; guardCount += 1 + } + return keys + } + + /// Day keys for the calendar week/month containing `date`. + static func weekDayKeys(containing date: Date, calendar: Calendar) -> [String] { + guard let w = AnalyticsEngine.weekInterval(containing: date, calendar: calendar) else { return [] } + return dayKeys(from: w.start, to: calendar.date(byAdding: .day, value: -1, to: w.end) ?? w.start, calendar: calendar) + } + + static func monthDayKeys(containing date: Date, calendar: Calendar) -> [String] { + guard let m = AnalyticsEngine.monthInterval(containing: date, calendar: calendar) else { return [] } + return dayKeys(from: m.start, to: calendar.date(byAdding: .day, value: -1, to: m.end) ?? m.start, calendar: calendar) + } + + /// Reduce an exercise's completed sets to an `ExerciseSample` (app helper — + /// mirrors `WorkoutDayLog.volume`). Kept here so the identity + volume rule is + /// defined once and testable. + static func exerciseSample(name: String, completedSets: [(weight: Double, reps: Int)]) -> ExerciseSample { + let reps = completedSets.reduce(0) { $0 + $1.reps } + let volume = completedSets.reduce(0.0) { $0 + AnalyticsEngine.volume(weight: $1.weight, reps: $1.reps) } + return ExerciseSample(identity: AnalyticsEngine.exerciseIdentity(name), + sets: completedSets.count, reps: reps, volume: volume) + } +} diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index 3856e41..319add2 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -1561,3 +1561,33 @@ last. Device/build QA tracked here. DaySample), reconcile coordinator on launch/active, notifications + deep links. Phase 4: Analytics UI (6 areas + states + accessibility). Both require an Xcode build / device to verify (no sim runtime here). + +### KC-51 progress — Phase 3: adapter + reconcile coordinator + deep links (verified) +- `WorkoutAnalyticsAdapter` (pure): `WorkoutDayLog`/HealthKit primitives → + `DaySample`; exercise reduction (identity + volume), inclusive day-key ranges, + week/month key sets. Rest/missing days become honest empty samples. +- `AnalyticsCoordinator` (injectable sample providers → testable): backfills + every missing completed week/month, saves immutably (dedup), prunes retention. + **Idempotent** — a second run generates nothing. +- `AnalyticsDeepLink`: `wenza://analytics[/weekly/|/monthly/]` with + symmetric URL/string encode-decode for report notifications. +- **Bug caught + fixed by tests:** `completedWeekStartKeys` included a boundary + week that `prune` then deleted → the report regenerated (and would re-notify) + on every launch. Aligned generation to the retention cutoff → idempotent. +- Tests: `AnalyticsAdapterTests`, `AnalyticsCoordinatorTests`. Verified + standalone via swiftc (adapter 17 + coordinator/deeplink 10 assertions, all + pass). Total analytics assertions verified standalone: ~90. + +### KC-51 — remaining (device/Xcode-build required; NOT verified here) +- App wiring: inject real providers (adapter + WorkoutViewModel + HealthKit) into + the coordinator; call `reconcile()` on launch + `scenePhase == .active` + (reuse existing checkPending* pattern). App-coupled — needs Xcode build. +- Notifications: extend `NotificationManager.schedulePeriodMilestones()` to fire + one permission-aware weekly + monthly local notification carrying an + `AnalyticsDeepLink` in `userInfo`; route it on tap. +- Analytics UI (6 areas: Overview / Daily / Weekly / Monthly / 12-month trends / + Per-exercise) + loading/empty/denied/partial/error states + Dynamic Type, + VoiceOver, light/dark, size classes. SwiftUI — not build-verifiable 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. diff --git a/KisaniCalTests/AnalyticsAdapterTests.swift b/KisaniCalTests/AnalyticsAdapterTests.swift new file mode 100644 index 0000000..167dc2c --- /dev/null +++ b/KisaniCalTests/AnalyticsAdapterTests.swift @@ -0,0 +1,61 @@ +import XCTest +@testable import KisaniCal + +/// Adapter tests: exercise reduction (identity + volume), inclusive day-key +/// ranges, and app-data → DaySample mapping (rest/missing/scheduled handling). +final class AnalyticsAdapterTests: XCTestCase { + + private func cal() -> Calendar { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "UTC")! + c.locale = Locale(identifier: "en_US_POSIX") + return c + } + + func testExerciseSampleReduction() { + let es = WorkoutAnalyticsAdapter.exerciseSample(name: " Bench Press ", + completedSets: [(60, 10), (60, 8), (70, 6)]) + XCTAssertEqual(es.identity, "bench press") + XCTAssertEqual(es.sets, 3) + XCTAssertEqual(es.reps, 24) + XCTAssertEqual(es.volume, 1500) // 600 + 480 + 420 + } + + func testInclusiveDayKeys() { + let c = cal() + let start = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08 + let keys = WorkoutAnalyticsAdapter.dayKeys(from: start, to: c.date(byAdding: .day, value: 6, to: start)!, calendar: c) + XCTAssertEqual(keys.count, 7) + XCTAssertEqual(keys, keys.sorted()) + } + + func testDaySampleMapping() { + let c = cal() + let start = Date(timeIntervalSince1970: 1_783_500_000) + let keys = WorkoutAnalyticsAdapter.dayKeys(from: start, to: c.date(byAdding: .day, value: 6, to: start)!, calendar: c) + let es = WorkoutAnalyticsAdapter.exerciseSample(name: "Squat", completedSets: [(100, 5), (100, 5)]) + let raw = RawWorkoutDay(dateKey: keys[0], didWorkout: true, sessions: 1, durationMinutes: 45, exercises: [es]) + let samples = WorkoutAnalyticsAdapter.daySamples( + dayKeys: keys, + workouts: [keys[0]: raw], + scheduledKeys: [keys[0], keys[2]], + health: [keys[0]: RawHealthDay(steps: 8000, activeCalories: 500, restingHeartRate: 55)]) + + XCTAssertEqual(samples.count, 7) + XCTAssertTrue(samples[0].didWorkout) + XCTAssertEqual(samples[0].volume, 1000) + XCTAssertTrue(samples[0].scheduled) + XCTAssertEqual(samples[0].steps, 8000) + XCTAssertFalse(samples[1].didWorkout) // rest / missing + XCTAssertEqual(samples[1].volume, 0) + XCTAssertNil(samples[1].steps) // honest: no fabricated data + XCTAssertTrue(samples[2].scheduled && !samples[2].didWorkout) // scheduled but missed + } + + func testWeekAndMonthDayKeys() { + let c = cal() + let d = Date(timeIntervalSince1970: 1_783_500_000) + XCTAssertEqual(WorkoutAnalyticsAdapter.weekDayKeys(containing: d, calendar: c).count, 7) + XCTAssertEqual(WorkoutAnalyticsAdapter.monthDayKeys(containing: d, calendar: c).count, 31) // July + } +} diff --git a/KisaniCalTests/AnalyticsCoordinatorTests.swift b/KisaniCalTests/AnalyticsCoordinatorTests.swift new file mode 100644 index 0000000..e5b6afe --- /dev/null +++ b/KisaniCalTests/AnalyticsCoordinatorTests.swift @@ -0,0 +1,53 @@ +import XCTest +@testable import KisaniCal + +/// Deep-link routing + reconcile idempotency (no duplicate reports across runs). +final class AnalyticsCoordinatorTests: XCTestCase { + + private func cal() -> Calendar { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "UTC")! + c.locale = Locale(identifier: "en_US_POSIX") + return c + } + private let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08 + + // MARK: - Deep links + + func testDeepLinkRoundTrips() { + XCTAssertEqual(AnalyticsDeepLink(url: AnalyticsDeepLink.overview.url), .overview) + XCTAssertEqual(AnalyticsDeepLink(url: AnalyticsDeepLink.weekly(weekStartKey: "2026-06-29").url), + .weekly(weekStartKey: "2026-06-29")) + XCTAssertEqual(AnalyticsDeepLink(string: "wenza://analytics/monthly/2026-05"), + .monthly(monthKey: "2026-05")) + XCTAssertEqual(AnalyticsDeepLink.weekly(weekStartKey: "2026-06-29").url.absoluteString, + "wenza://analytics/weekly/2026-06-29") + XCTAssertNil(AnalyticsDeepLink(string: "http://x/weekly/2026-06-29")) + } + + // MARK: - Reconcile idempotency + + func testReconcileGeneratesThenIsIdempotent() { + let tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("coord_\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: tmp) } + let store = AnalyticsStore(root: tmp) + func sample(_ v: Double) -> [DaySample] { + [DaySample(dateKey: "x", didWorkout: true, scheduled: true, sessions: 1, sets: 10, volume: v)] + } + let coord = AnalyticsCoordinator( + store: store, calendar: cal(), retentionMonths: 12, now: { self.now }, + weekSamples: { _ in (sample(1000), sample(800)) }, + monthSamples: { _ in (sample(4000), sample(3500)) }) + + let first = coord.reconcile() + XCTAssertGreaterThanOrEqual(first.weekliesGenerated.count, 50) + XCTAssertEqual(first.monthliesGenerated.count, 12) + + let second = coord.reconcile() + XCTAssertTrue(second.isEmpty, "second run must generate nothing (no duplicate reports)") + + XCTAssertEqual(store.allWeeklyReports().count, first.weekliesGenerated.count) + XCTAssertEqual(store.allMonthlyReports().count, 12) + XCTAssertEqual(store.allWeeklyReports().first?.progression, .improving) // 1000 vs 800 + } +}