Merge develop -> main: full session (KC-51 through KC-71) #28

Merged
kutesir merged 64 commits from develop into main 2026-07-12 22:57:14 +00:00
8 changed files with 138 additions and 3 deletions
Showing only changes of commit 839ac72846 - Show all commits

View File

@@ -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] {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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