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
2 changed files with 67 additions and 65 deletions
Showing only changes of commit 67863fca44 - Show all commits

View File

@@ -5,9 +5,10 @@ import Foundation
// APIs for the Analytics UI. Pure engine/store/coordinator/adapter are unit- // APIs for the Analytics UI. Pure engine/store/coordinator/adapter are unit-
// tested; this layer is thin wiring (compiles in-target; not standalone-tested). // tested; this layer is thin wiring (compiles in-target; not standalone-tested).
// //
// HealthKit per-day historical reference is optional and currently left nil // Sample building is done by nonisolated static functions over a captured
// (honest never fabricated). Wiring historical HK queries is a documented // `WorkoutSnapshot` value, so the coordinator's closures don't touch main-actor
// follow-up; DaySample health fields already degrade gracefully. // state. HealthKit per-day historical reference is optional and currently left
// nil (honest never fabricated); wiring it is a documented follow-up.
@MainActor @MainActor
final class AnalyticsService: ObservableObject { final class AnalyticsService: ObservableObject {
static let shared = AnalyticsService() static let shared = AnalyticsService()
@@ -16,8 +17,8 @@ final class AnalyticsService: ObservableObject {
private let calendar: Calendar private let calendar: Calendar
@Published private(set) var lastReconcile: Date? @Published private(set) var lastReconcile: Date?
/// Cached snapshot from the most recent reconcile, so UI reads (daily /// Snapshot from the most recent reconcile, for UI reads (daily comparison,
/// comparison, per-exercise history) don't need the view model passed around. /// per-exercise history) without passing the view model around.
private var snapshot = WorkoutSnapshot() private var snapshot = WorkoutSnapshot()
init(store: AnalyticsStore = AnalyticsStore(), calendar: Calendar = .current) { init(store: AnalyticsStore = AnalyticsStore(), calendar: Calendar = .current) {
@@ -25,7 +26,7 @@ final class AnalyticsService: ObservableObject {
self.calendar = calendar self.calendar = calendar
} }
// MARK: - Snapshot of live workout data (decouples from the view model) // MARK: - Snapshot of live workout data (a value type)
struct WorkoutSnapshot { struct WorkoutSnapshot {
var logByDate: [String: WorkoutDayLog] = [:] var logByDate: [String: WorkoutDayLog] = [:]
@@ -48,71 +49,61 @@ final class AnalyticsService: ObservableObject {
/// 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 {
snapshot = WorkoutSnapshot(vm: vm) let snap = WorkoutSnapshot(vm: vm)
freezeRecentSnapshots(now: now) snapshot = snap
let result = makeCoordinator(now: now).reconcile() freezeRecentSnapshots(snap, now: now)
let result = makeCoordinator(snap, now: now).reconcile()
lastReconcile = now lastReconcile = now
return result return result
} }
private func makeCoordinator(now: Date) -> AnalyticsCoordinator { private func makeCoordinator(_ snap: WorkoutSnapshot, now: Date) -> AnalyticsCoordinator {
AnalyticsCoordinator( let cal = calendar
store: store, calendar: calendar, retentionMonths: 12, now: { now }, return AnalyticsCoordinator(
weekSamples: { [weak self] key in store: store, calendar: cal, retentionMonths: 12, now: { now },
guard let self else { return ([], []) } weekSamples: { key in
let start = self.date(fromKey: key) ?? now let start = Self.date(fromKey: key, calendar: cal) ?? now
let prevStart = self.calendar.date(byAdding: .weekOfYear, value: -1, to: start) ?? start let prevStart = cal.date(byAdding: .weekOfYear, value: -1, to: start) ?? start
return (self.samples(WorkoutAnalyticsAdapter.weekDayKeys(containing: start, calendar: self.calendar)), return (Self.buildSamples(WorkoutAnalyticsAdapter.weekDayKeys(containing: start, calendar: cal), snap, cal),
self.samples(WorkoutAnalyticsAdapter.weekDayKeys(containing: prevStart, calendar: self.calendar))) Self.buildSamples(WorkoutAnalyticsAdapter.weekDayKeys(containing: prevStart, calendar: cal), snap, cal))
}, },
monthSamples: { [weak self] key in monthSamples: { key in
guard let self, let start = self.date(fromMonthKey: key) else { return ([], []) } guard let start = Self.date(fromMonthKey: key, calendar: cal) else { return ([], []) }
let prevStart = self.calendar.date(byAdding: .month, value: -1, to: start) ?? start let prevStart = cal.date(byAdding: .month, value: -1, to: start) ?? start
return (self.samples(WorkoutAnalyticsAdapter.monthDayKeys(containing: start, calendar: self.calendar)), return (Self.buildSamples(WorkoutAnalyticsAdapter.monthDayKeys(containing: start, calendar: cal), snap, cal),
self.samples(WorkoutAnalyticsAdapter.monthDayKeys(containing: prevStart, calendar: self.calendar))) Self.buildSamples(WorkoutAnalyticsAdapter.monthDayKeys(containing: prevStart, calendar: cal), snap, cal))
}) })
} }
/// Freeze immutable snapshots for completed recent days (yesterday and back a /// Freeze immutable snapshots for completed recent days; refresh today's live.
/// week) so later edits can't rewrite them; refresh today's live. private func freezeRecentSnapshots(_ snap: WorkoutSnapshot, now: Date) {
private func freezeRecentSnapshots(now: Date) {
let today = calendar.startOfDay(for: now) let today = calendar.startOfDay(for: now)
for back in 0...8 { for back in 0...8 {
guard let d = calendar.date(byAdding: .day, value: -back, to: today) else { continue } guard let d = calendar.date(byAdding: .day, value: -back, to: today) else { continue }
let key = AnalyticsEngine.dateKey(for: d, calendar: calendar) let key = AnalyticsEngine.dateKey(for: d, calendar: calendar)
let sample = daySample(forKey: key) store.saveSnapshot(Self.buildSamples([key], snap, calendar).first ?? DaySample(dateKey: key), isPast: back > 0)
store.saveSnapshot(sample, isPast: back > 0)
} }
} }
// MARK: - Sample building // MARK: - Sample building (nonisolated, pure over the snapshot value)
private func samples(_ keys: [String]) -> [DaySample] { nonisolated static func buildSamples(_ keys: [String], _ snap: WorkoutSnapshot, _ cal: Calendar) -> [DaySample] {
WorkoutAnalyticsAdapter.daySamples(dayKeys: keys, var workouts: [String: RawWorkoutDay] = [:]
workouts: workoutsMap(for: keys), var scheduled: Set<String> = []
scheduledKeys: scheduledKeys(for: keys))
}
private func workoutsMap(for keys: [String]) -> [String: RawWorkoutDay] {
var map: [String: RawWorkoutDay] = [:]
for key in keys { for key in keys {
if let log = snapshot.logByDate[key] { if let d = date(fromKey: key, calendar: cal), snap.scheduledWeekdays.contains(cal.component(.weekday, from: d)) {
map[key] = raw(from: log, done: snapshot.workoutDates.contains(key)) scheduled.insert(key)
} else if snapshot.workoutDates.contains(key) { }
map[key] = RawWorkoutDay(dateKey: key, didWorkout: true, sessions: 1, durationMinutes: 0, exercises: []) if let log = snap.logByDate[key] {
workouts[key] = raw(from: log, done: snap.workoutDates.contains(key))
} else if snap.workoutDates.contains(key) {
workouts[key] = RawWorkoutDay(dateKey: key, didWorkout: true, sessions: 1, durationMinutes: 0, exercises: [])
} }
} }
return map return WorkoutAnalyticsAdapter.daySamples(dayKeys: keys, workouts: workouts, scheduledKeys: scheduled)
} }
private func scheduledKeys(for keys: [String]) -> Set<String> { nonisolated private static func raw(from log: WorkoutDayLog, done: Bool) -> RawWorkoutDay {
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 samples = log.exercises.map { ex -> ExerciseSample in
let completed = ex.sets.filter { $0.done }.map { (weight: $0.weight, reps: $0.reps) } let completed = ex.sets.filter { $0.done }.map { (weight: $0.weight, reps: $0.reps) }
return WorkoutAnalyticsAdapter.exerciseSample(name: ex.name, completedSets: completed) return WorkoutAnalyticsAdapter.exerciseSample(name: ex.name, completedSets: completed)
@@ -121,16 +112,11 @@ final class AnalyticsService: ObservableObject {
sessions: 1, durationMinutes: 0, exercises: samples) 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 // MARK: - Read APIs for the UI
func weeklyReports() -> [WeeklyReport] { store.allWeeklyReports().sorted { $0.weekStartKey > $1.weekStartKey } } func weeklyReports() -> [WeeklyReport] { store.allWeeklyReports().sorted { $0.weekStartKey > $1.weekStartKey } }
func monthlyReports() -> [MonthlyReport] { store.allMonthlyReports().sorted { $0.monthKey > $1.monthKey } } func monthlyReports() -> [MonthlyReport] { store.allMonthlyReports().sorted { $0.monthKey > $1.monthKey } }
/// 12-month volume series (oldestnewest) for the trends screen.
func monthlyVolumeTrend() -> [MonthVolumePoint] { func monthlyVolumeTrend() -> [MonthVolumePoint] {
store.allMonthlyReports().sorted { $0.monthKey < $1.monthKey } store.allMonthlyReports().sorted { $0.monthKey < $1.monthKey }
.map { MonthVolumePoint(monthKey: $0.monthKey, volume: $0.summary.volume) } .map { MonthVolumePoint(monthKey: $0.monthKey, volume: $0.summary.volume) }
@@ -151,6 +137,10 @@ final class AnalyticsService: ObservableObject {
sameWeekdayLastWeek: lastWeek.map { daySample(forKey: AnalyticsEngine.dateKey(for: $0, calendar: calendar)) }) sameWeekdayLastWeek: lastWeek.map { daySample(forKey: AnalyticsEngine.dateKey(for: $0, calendar: calendar)) })
} }
private func daySample(forKey key: String) -> DaySample {
Self.buildSamples([key], snapshot, calendar).first ?? DaySample(dateKey: key)
}
/// Per-exercise volume history (dateKeyvolume) across stored snapshots. /// Per-exercise volume history (dateKeyvolume) across stored snapshots.
func exerciseHistory(identity: String) -> [ExerciseVolumePoint] { func exerciseHistory(identity: String) -> [ExerciseVolumePoint] {
store.allSnapshotKeys().sorted().compactMap { key in store.allSnapshotKeys().sorted().compactMap { key in
@@ -160,7 +150,7 @@ final class AnalyticsService: ObservableObject {
} }
} }
/// Distinct exercise identities seen across stored snapshots (for pickers). /// Distinct exercise identities across stored snapshots (for the picker).
func knownExerciseIdentities() -> [String] { func knownExerciseIdentities() -> [String] {
var set = Set<String>() var set = Set<String>()
for key in store.allSnapshotKeys() { for key in store.allSnapshotKeys() {
@@ -169,16 +159,18 @@ final class AnalyticsService: ObservableObject {
return set.sorted() return set.sorted()
} }
// MARK: - Key helpers // MARK: - Key helpers (nonisolated, pure)
private func date(fromKey key: String) -> Date? { nonisolated private static func date(fromKey key: String, calendar: Calendar) -> Date? {
let f = DateFormatter(); f.calendar = calendar; f.timeZone = calendar.timeZone formatter("yyyy-MM-dd", calendar).date(from: key)
f.locale = Locale(identifier: "en_US_POSIX"); f.dateFormat = "yyyy-MM-dd"
return f.date(from: key)
} }
private func date(fromMonthKey key: String) -> Date? { nonisolated private static func date(fromMonthKey key: String, calendar: Calendar) -> Date? {
let f = DateFormatter(); f.calendar = calendar; f.timeZone = calendar.timeZone formatter("yyyy-MM", calendar).date(from: key)
f.locale = Locale(identifier: "en_US_POSIX"); f.dateFormat = "yyyy-MM" }
return f.date(from: key) nonisolated private static func formatter(_ format: String, _ calendar: Calendar) -> DateFormatter {
let f = DateFormatter()
f.calendar = calendar; f.timeZone = calendar.timeZone
f.locale = Locale(identifier: "en_US_POSIX"); f.dateFormat = format
return f
} }
} }

View File

@@ -1635,3 +1635,13 @@ last. Device/build QA tracked here.
launch + `.active` → presents `AnalyticsView(initialLink:)` opened to the launch + `.active` → presents `AnalyticsView(initialLink:)` opened to the
weekly/monthly area. Completes goal condition #7 (report notifications weekly/monthly area. Completes goal condition #7 (report notifications
deep-link to their report). Wiring not build-verified (SwiftUI/app deps). deep-link to their report). Wiring not build-verified (SwiftUI/app deps).
### KC-51 progress — Phase 4d: concurrency hardening (compile-risk reduction)
- Refactored `AnalyticsService`: coordinator sample-building moved to nonisolated
static functions over a captured `WorkoutSnapshot` value, so the coordinator's
escaping closures no longer call `@MainActor` methods (avoids a main-actor
isolation error under strict concurrency). Verified `@MainActor` + `static let
shared` is the codebase's existing pattern (HealthKitManager).
- 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.