From b4813272f61f0af5e577a38ea5d54cb4719867d9 Mon Sep 17 00:00:00 2001 From: kutesir Date: Wed, 8 Jul 2026 10:39:00 +0300 Subject: [PATCH] analytics(healthkit): wire historical HealthKit reference into reports (KC-51 ph4e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes 'available HealthKit trends' for weekly/monthly reports. Adds HealthKitManager.dailyReference (3 parallel bucketed HKStatisticsCollectionQuery for steps/active-calories/resting-HR over ~13 months), AnalyticsService. refreshHealthReference to cache and merge it into DaySamples, and avgSteps/ avgActiveCalories/avgRestingHR on PeriodSummary (nil when absent — honest). Surfaced in AnalyticsView's weekly/monthly cards. Verified standalone (swiftc, 8/8): aggregation correct when present, nil when absent, workout metrics unaffected, reports stay deterministic. Tests added to AnalyticsEngineTests. This completes all analytics logic — remaining work is the first Xcode build (device, user-run) to catch any SwiftUI compile issues. Co-Authored-By: Claude Opus 4.8 --- KisaniCal/Analytics/AnalyticsEngine.swift | 6 ++- KisaniCal/Analytics/AnalyticsModels.swift | 4 ++ KisaniCal/Analytics/AnalyticsService.swift | 18 ++++++++- KisaniCal/ContentView.swift | 2 + KisaniCal/ISSUES.md | 29 ++++++++++++++ KisaniCal/Managers/HealthKitManager.swift | 45 ++++++++++++++++++++++ KisaniCal/Views/AnalyticsView.swift | 13 +++++++ KisaniCalTests/AnalyticsEngineTests.swift | 24 ++++++++++++ 8 files changed, 138 insertions(+), 3 deletions(-) diff --git a/KisaniCal/Analytics/AnalyticsEngine.swift b/KisaniCal/Analytics/AnalyticsEngine.swift index e24c338..2ee775e 100644 --- a/KisaniCal/Analytics/AnalyticsEngine.swift +++ b/KisaniCal/Analytics/AnalyticsEngine.swift @@ -182,9 +182,13 @@ enum AnalyticsEngine { let sets = days.reduce(0) { $0 + $1.sets } let volume = days.reduce(0) { $0 + $1.volume } let duration = days.reduce(0) { $0 + $1.durationMinutes } + func avg(_ vals: [Int]) -> Int? { vals.isEmpty ? nil : Int((Double(vals.reduce(0, +)) / Double(vals.count)).rounded()) } return PeriodSummary(sessions: sessions, sets: sets, volume: volume, durationMinutes: duration, scheduled: scheduled, - missed: missed, consistency: consistency(sessions: sessions, scheduled: scheduled)) + missed: missed, consistency: consistency(sessions: sessions, scheduled: scheduled), + avgSteps: avg(days.compactMap { $0.steps }), + avgActiveCalories: avg(days.compactMap { $0.activeCalories }), + avgRestingHR: avg(days.compactMap { $0.restingHeartRate })) } private static func periodDeltas(current cur: PeriodSummary, previous prev: PeriodSummary) -> [String: MetricDelta] { diff --git a/KisaniCal/Analytics/AnalyticsModels.swift b/KisaniCal/Analytics/AnalyticsModels.swift index 09f9dde..32c41fe 100644 --- a/KisaniCal/Analytics/AnalyticsModels.swift +++ b/KisaniCal/Analytics/AnalyticsModels.swift @@ -129,6 +129,10 @@ struct PeriodSummary: Codable, Equatable { let scheduled: Int let missed: Int let consistency: Double // 0...1 + // Available HealthKit trends — nil when no reference data in the period. + var avgSteps: Int? = nil + var avgActiveCalories: Int? = nil + var avgRestingHR: Int? = nil } /// Immutable weekly report. Once generated with `generatedAt`, later edits to diff --git a/KisaniCal/Analytics/AnalyticsService.swift b/KisaniCal/Analytics/AnalyticsService.swift index 76fdc74..5cc39f4 100644 --- a/KisaniCal/Analytics/AnalyticsService.swift +++ b/KisaniCal/Analytics/AnalyticsService.swift @@ -33,6 +33,7 @@ final class AnalyticsService: ObservableObject { var workoutDates: Set = [] var scheduledWeekdays: Set = [] var missedDates: Set = [] + var healthByDate: [String: RawHealthDay] = [:] // HealthKit reference init() {} init(vm: WorkoutViewModel) { @@ -43,13 +44,17 @@ final class AnalyticsService: ObservableObject { } } + /// Cached HealthKit per-day reference (refreshed async). Merged into snapshots. + private var healthByDate: [String: RawHealthDay] = [:] + // 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 { - let snap = WorkoutSnapshot(vm: vm) + var snap = WorkoutSnapshot(vm: vm) + snap.healthByDate = healthByDate snapshot = snap freezeRecentSnapshots(snap, now: now) let result = makeCoordinator(snap, now: now).reconcile() @@ -57,6 +62,14 @@ final class AnalyticsService: ObservableObject { return result } + /// Refresh the HealthKit per-day reference cache (~13 months). Call from the + /// app's async Health sync path, then reconcile so new snapshots capture it. + func refreshHealthReference() async { + guard let start = calendar.date(byAdding: .month, value: -13, to: Date()) else { return } + let raw = await HealthKitManager.shared.dailyReference(from: start, to: Date()) + healthByDate = raw.mapValues { RawHealthDay(steps: $0.steps, activeCalories: $0.calories, restingHeartRate: $0.restingHR) } + } + private func makeCoordinator(_ snap: WorkoutSnapshot, now: Date) -> AnalyticsCoordinator { let cal = calendar return AnalyticsCoordinator( @@ -100,7 +113,8 @@ final class AnalyticsService: ObservableObject { workouts[key] = RawWorkoutDay(dateKey: key, didWorkout: true, sessions: 1, durationMinutes: 0, exercises: []) } } - return WorkoutAnalyticsAdapter.daySamples(dayKeys: keys, workouts: workouts, scheduledKeys: scheduled) + return WorkoutAnalyticsAdapter.daySamples(dayKeys: keys, workouts: workouts, + scheduledKeys: scheduled, health: snap.healthByDate) } nonisolated private static func raw(from log: WorkoutDayLog, done: Bool) -> RawWorkoutDay { diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift index 06332b5..9c5c8b9 100644 --- a/KisaniCal/ContentView.swift +++ b/KisaniCal/ContentView.swift @@ -114,6 +114,8 @@ struct ContentView: View { Task { await workoutVM.syncFromHealthKit() NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) + await AnalyticsService.shared.refreshHealthReference() + AnalyticsService.shared.reconcile(using: workoutVM) } } .onChange(of: scenePhase) { phase in diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index a12bc33..793f1a4 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -1645,3 +1645,32 @@ last. Device/build QA tracked here. - Self-review confirmed the singleton/actor pattern matches the app; remaining first-build risk is concentrated in `AnalyticsView.swift` (SwiftUI Charts / FlowRow generics) — surface-level, not logic. + +### KC-51 progress — Phase 4e: HealthKit historical reference wired (core-verified) +- `HealthKitManager.dailyReference(from:to:)`: per-day steps/active-calories/ + resting-HR over a range via 3 parallel `HKStatisticsCollectionQuery` (one + bucketed query per metric — efficient over a year, not per-day fetches). + Matches the file's existing `@MainActor` + continuation query pattern. +- `AnalyticsService.refreshHealthReference()`: fetches ~13mo of reference, + caches it, merged into every `DaySample` built afterward. Called from the + existing Health-sync `Task` in `ContentView` (after `syncFromHealthKit`), + then reconciles so new/updated reports capture it. +- `PeriodSummary` gained `avgSteps`/`avgActiveCalories`/`avgRestingHR` (nil when + no reference data in the period — honest, never fabricated). Weekly/monthly + cards in `AnalyticsView` show them when present. +- **Verified standalone** (swiftc, 8/8): HK averages aggregate correctly when + present, stay nil when absent, workout metrics unaffected either way, reports + remain deterministic. Added `testSummarizeAggregatesHealthKitAveragesWhenPresent` + + `testSummarizeHealthKitNilWhenAbsent` to `AnalyticsEngineTests`. +- This completes "available HealthKit trends" for weekly/monthly reports (goal + requirement). HealthKit remains the sole source for these values — nothing is + derived or estimated by the app. + +### KC-51 — status: all logic complete; awaiting first Xcode build (device, user-run) +Every core + integration piece is now written: engine, store, adapter, +coordinator, deep links, HealthKit reference, notifications, and a 6-area UI. +Pure-Swift pieces (~110 assertions total) are compiler-verified standalone; +SwiftUI/app-module pieces are self-reviewed but never compiled (no iOS runtime +in this environment). User will build on device next. Awaiting: first-build +error list (expected — SwiftUI Charts/generics are the likely spots), then +XCTest run, then close out goal conditions #10/#11 and any remaining polish. diff --git a/KisaniCal/Managers/HealthKitManager.swift b/KisaniCal/Managers/HealthKitManager.swift index ef35b49..8ab8332 100644 --- a/KisaniCal/Managers/HealthKitManager.swift +++ b/KisaniCal/Managers/HealthKitManager.swift @@ -160,6 +160,51 @@ final class HealthKitManager: ObservableObject { } } + // MARK: - Historical daily reference (for analytics) + + /// Per-day steps / active calories / resting heart rate over a range, keyed by + /// "yyyy-MM-dd". One bucketed statistics query per metric (efficient over a + /// year). HealthKit is the source of truth; values are read-only reference. + func dailyReference(from start: Date, to end: Date) async -> [String: (steps: Int?, calories: Int?, restingHR: Int?)] { + guard authorized, isAvailable else { return [:] } + let anchor = Calendar.current.startOfDay(for: start) + var interval = DateComponents(); interval.day = 1 + + async let steps = statsCollection(.stepCount, unit: .count(), options: .cumulativeSum, anchor: anchor, end: end, interval: interval) + async let cals = statsCollection(.activeEnergyBurned, unit: .kilocalorie(), options: .cumulativeSum, anchor: anchor, end: end, interval: interval) + async let hr = statsCollection(.restingHeartRate, unit: HKUnit(from: "count/min"), options: .discreteAverage, anchor: anchor, end: end, interval: interval) + let (s, c, h) = await (steps, cals, hr) + + var out: [String: (steps: Int?, calories: Int?, restingHR: Int?)] = [:] + for key in Set(s.keys).union(c.keys).union(h.keys) { + out[key] = (s[key].map(Int.init), c[key].map(Int.init), h[key].map(Int.init)) + } + return out + } + + private func statsCollection(_ id: HKQuantityTypeIdentifier, unit: HKUnit, options: HKStatisticsOptions, + anchor: Date, end: Date, interval: DateComponents) async -> [String: Double] { + guard let type = HKQuantityType.quantityType(forIdentifier: id) else { return [:] } + return await withCheckedContinuation { cont in + let q = HKStatisticsCollectionQuery(quantityType: type, quantitySamplePredicate: nil, + options: options, anchorDate: anchor, intervalComponents: interval) + q.initialResultsHandler = { _, results, _ in + var dict: [String: Double] = [:] + let fmt = DateFormatter() + fmt.calendar = Calendar.current; fmt.timeZone = Calendar.current.timeZone + fmt.locale = Locale(identifier: "en_US_POSIX"); fmt.dateFormat = "yyyy-MM-dd" + results?.enumerateStatistics(from: anchor, to: end) { stat, _ in + let quantity = options.contains(.cumulativeSum) ? stat.sumQuantity() : stat.averageQuantity() + if let v = quantity?.doubleValue(for: unit), v > 0 { + dict[fmt.string(from: stat.startDate)] = v + } + } + cont.resume(returning: dict) + } + store.execute(q) + } + } + // MARK: - Type sets private var readTypes: Set { diff --git a/KisaniCal/Views/AnalyticsView.swift b/KisaniCal/Views/AnalyticsView.swift index 83a1e1d..85bacf3 100644 --- a/KisaniCal/Views/AnalyticsView.swift +++ b/KisaniCal/Views/AnalyticsView.swift @@ -192,6 +192,7 @@ struct AnalyticsView: View { trendBadge(r.progression) } summaryRow(r.summary) + healthRow(r.summary) deltaChips(r.vsPreviousWeek) } .padding(14).cardBG(cs) @@ -206,6 +207,7 @@ struct AnalyticsView: View { trendBadge(r.classification) } summaryRow(r.summary) + healthRow(r.summary) deltaChips(r.vsPreviousMonth) } .padding(14).cardBG(cs) @@ -221,6 +223,17 @@ struct AnalyticsView: View { } } + /// Available HealthKit trends for the period (shown only when present). + @ViewBuilder private func healthRow(_ s: PeriodSummary) -> some View { + if s.avgSteps != nil || s.avgActiveCalories != nil || s.avgRestingHR != nil { + HStack(spacing: 14) { + if let v = s.avgSteps { stat("\(v)", "avg steps") } + if let v = s.avgActiveCalories { stat("\(v)", "avg kcal") } + if let v = s.avgRestingHR { stat("\(v)", "avg bpm") } + } + } + } + private func deltaChips(_ deltas: [String: MetricDelta]) -> some View { let order = ["volume", "sessions", "sets", "duration", "consistency"] return FlowRow(order.compactMap { key -> (String, MetricDelta)? in diff --git a/KisaniCalTests/AnalyticsEngineTests.swift b/KisaniCalTests/AnalyticsEngineTests.swift index 022f461..2c04f47 100644 --- a/KisaniCalTests/AnalyticsEngineTests.swift +++ b/KisaniCalTests/AnalyticsEngineTests.swift @@ -170,6 +170,30 @@ final class AnalyticsEngineTests: XCTestCase { XCTAssertEqual(s.consistency, 2.0/3.0, accuracy: 0.0001) } + // MARK: - HealthKit reference aggregation (partial/missing data honest) + + func testSummarizeAggregatesHealthKitAveragesWhenPresent() { + let days = [ + DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000, + steps: 8000, activeCalories: 500, restingHeartRate: 55), + DaySample(dateKey: "2026-07-07", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 900, + steps: 10000, activeCalories: 600, restingHeartRate: 57), + DaySample(dateKey: "2026-07-08", didWorkout: false, scheduled: false) // no HK reference + ] + let s = AnalyticsEngine.summarize(days) + XCTAssertEqual(s.avgSteps, 9000) + XCTAssertEqual(s.avgActiveCalories, 550) + XCTAssertEqual(s.avgRestingHR, 56) + } + + func testSummarizeHealthKitNilWhenAbsent() { + let s = AnalyticsEngine.summarize([DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sets: 20, volume: 1000)]) + XCTAssertNil(s.avgSteps) + XCTAssertNil(s.avgActiveCalories) + XCTAssertNil(s.avgRestingHR) + XCTAssertEqual(s.volume, 1000) // workout metrics unaffected + } + // MARK: - Reports (idempotency / determinism) func testWeeklyReportDeterministic() {