analytics(healthkit): wire historical HealthKit reference into reports (KC-51 ph4e)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -182,9 +182,13 @@ enum AnalyticsEngine {
|
|||||||
let sets = days.reduce(0) { $0 + $1.sets }
|
let sets = days.reduce(0) { $0 + $1.sets }
|
||||||
let volume = days.reduce(0) { $0 + $1.volume }
|
let volume = days.reduce(0) { $0 + $1.volume }
|
||||||
let duration = days.reduce(0) { $0 + $1.durationMinutes }
|
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,
|
return PeriodSummary(sessions: sessions, sets: sets, volume: volume,
|
||||||
durationMinutes: duration, scheduled: scheduled,
|
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] {
|
private static func periodDeltas(current cur: PeriodSummary, previous prev: PeriodSummary) -> [String: MetricDelta] {
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ struct PeriodSummary: Codable, Equatable {
|
|||||||
let scheduled: Int
|
let scheduled: Int
|
||||||
let missed: Int
|
let missed: Int
|
||||||
let consistency: Double // 0...1
|
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
|
/// Immutable weekly report. Once generated with `generatedAt`, later edits to
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ final class AnalyticsService: ObservableObject {
|
|||||||
var workoutDates: Set<String> = []
|
var workoutDates: Set<String> = []
|
||||||
var scheduledWeekdays: Set<Int> = []
|
var scheduledWeekdays: Set<Int> = []
|
||||||
var missedDates: Set<String> = []
|
var missedDates: Set<String> = []
|
||||||
|
var healthByDate: [String: RawHealthDay] = [:] // HealthKit reference
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
init(vm: WorkoutViewModel) {
|
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)
|
// MARK: - Reconcile (call on launch + scenePhase active)
|
||||||
|
|
||||||
/// Snapshot recent days, backfill missing completed weekly/monthly reports,
|
/// Snapshot recent days, backfill missing completed weekly/monthly reports,
|
||||||
/// prune. Idempotent — safe to call every launch/foreground.
|
/// prune. Idempotent — safe to call every launch/foreground.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func reconcile(using vm: WorkoutViewModel, now: Date = Date()) -> AnalyticsCoordinator.ReconcileResult {
|
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
|
snapshot = snap
|
||||||
freezeRecentSnapshots(snap, now: now)
|
freezeRecentSnapshots(snap, now: now)
|
||||||
let result = makeCoordinator(snap, now: now).reconcile()
|
let result = makeCoordinator(snap, now: now).reconcile()
|
||||||
@@ -57,6 +62,14 @@ final class AnalyticsService: ObservableObject {
|
|||||||
return result
|
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 {
|
private func makeCoordinator(_ snap: WorkoutSnapshot, now: Date) -> AnalyticsCoordinator {
|
||||||
let cal = calendar
|
let cal = calendar
|
||||||
return AnalyticsCoordinator(
|
return AnalyticsCoordinator(
|
||||||
@@ -100,7 +113,8 @@ final class AnalyticsService: ObservableObject {
|
|||||||
workouts[key] = RawWorkoutDay(dateKey: key, didWorkout: true, sessions: 1, durationMinutes: 0, exercises: [])
|
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 {
|
nonisolated private static func raw(from log: WorkoutDayLog, done: Bool) -> RawWorkoutDay {
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ struct ContentView: View {
|
|||||||
Task {
|
Task {
|
||||||
await workoutVM.syncFromHealthKit()
|
await workoutVM.syncFromHealthKit()
|
||||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||||
|
await AnalyticsService.shared.refreshHealthReference()
|
||||||
|
AnalyticsService.shared.reconcile(using: workoutVM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { phase in
|
.onChange(of: scenePhase) { phase in
|
||||||
|
|||||||
@@ -1645,3 +1645,32 @@ last. Device/build QA tracked here.
|
|||||||
- Self-review confirmed the singleton/actor pattern matches the app; remaining
|
- Self-review confirmed the singleton/actor pattern matches the app; remaining
|
||||||
first-build risk is concentrated in `AnalyticsView.swift` (SwiftUI Charts /
|
first-build risk is concentrated in `AnalyticsView.swift` (SwiftUI Charts /
|
||||||
FlowRow generics) — surface-level, not logic.
|
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.
|
||||||
|
|||||||
@@ -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
|
// MARK: - Type sets
|
||||||
|
|
||||||
private var readTypes: Set<HKObjectType> {
|
private var readTypes: Set<HKObjectType> {
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ struct AnalyticsView: View {
|
|||||||
trendBadge(r.progression)
|
trendBadge(r.progression)
|
||||||
}
|
}
|
||||||
summaryRow(r.summary)
|
summaryRow(r.summary)
|
||||||
|
healthRow(r.summary)
|
||||||
deltaChips(r.vsPreviousWeek)
|
deltaChips(r.vsPreviousWeek)
|
||||||
}
|
}
|
||||||
.padding(14).cardBG(cs)
|
.padding(14).cardBG(cs)
|
||||||
@@ -206,6 +207,7 @@ struct AnalyticsView: View {
|
|||||||
trendBadge(r.classification)
|
trendBadge(r.classification)
|
||||||
}
|
}
|
||||||
summaryRow(r.summary)
|
summaryRow(r.summary)
|
||||||
|
healthRow(r.summary)
|
||||||
deltaChips(r.vsPreviousMonth)
|
deltaChips(r.vsPreviousMonth)
|
||||||
}
|
}
|
||||||
.padding(14).cardBG(cs)
|
.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 {
|
private func deltaChips(_ deltas: [String: MetricDelta]) -> some View {
|
||||||
let order = ["volume", "sessions", "sets", "duration", "consistency"]
|
let order = ["volume", "sessions", "sets", "duration", "consistency"]
|
||||||
return FlowRow(order.compactMap { key -> (String, MetricDelta)? in
|
return FlowRow(order.compactMap { key -> (String, MetricDelta)? in
|
||||||
|
|||||||
@@ -170,6 +170,30 @@ final class AnalyticsEngineTests: XCTestCase {
|
|||||||
XCTAssertEqual(s.consistency, 2.0/3.0, accuracy: 0.0001)
|
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)
|
// MARK: - Reports (idempotency / determinism)
|
||||||
|
|
||||||
func testWeeklyReportDeterministic() {
|
func testWeeklyReportDeterministic() {
|
||||||
|
|||||||
Reference in New Issue
Block a user