Merge develop -> main: full session (KC-51 through KC-71) #28
@@ -5,9 +5,10 @@ import Foundation
|
||||
// APIs for the Analytics UI. Pure engine/store/coordinator/adapter are unit-
|
||||
// tested; this layer is thin wiring (compiles in-target; not standalone-tested).
|
||||
//
|
||||
// HealthKit per-day historical reference is optional and currently left nil
|
||||
// (honest — never fabricated). Wiring historical HK queries is a documented
|
||||
// follow-up; DaySample health fields already degrade gracefully.
|
||||
// Sample building is done by nonisolated static functions over a captured
|
||||
// `WorkoutSnapshot` value, so the coordinator's closures don't touch main-actor
|
||||
// state. HealthKit per-day historical reference is optional and currently left
|
||||
// nil (honest — never fabricated); wiring it is a documented follow-up.
|
||||
@MainActor
|
||||
final class AnalyticsService: ObservableObject {
|
||||
static let shared = AnalyticsService()
|
||||
@@ -16,8 +17,8 @@ final class AnalyticsService: ObservableObject {
|
||||
private let calendar: Calendar
|
||||
|
||||
@Published private(set) var lastReconcile: Date?
|
||||
/// Cached snapshot from the most recent reconcile, so UI reads (daily
|
||||
/// comparison, per-exercise history) don't need the view model passed around.
|
||||
/// Snapshot from the most recent reconcile, for UI reads (daily comparison,
|
||||
/// per-exercise history) without passing the view model around.
|
||||
private var snapshot = WorkoutSnapshot()
|
||||
|
||||
init(store: AnalyticsStore = AnalyticsStore(), calendar: Calendar = .current) {
|
||||
@@ -25,7 +26,7 @@ final class AnalyticsService: ObservableObject {
|
||||
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 {
|
||||
var logByDate: [String: WorkoutDayLog] = [:]
|
||||
@@ -48,71 +49,61 @@ final class AnalyticsService: ObservableObject {
|
||||
/// prune. Idempotent — safe to call every launch/foreground.
|
||||
@discardableResult
|
||||
func reconcile(using vm: WorkoutViewModel, now: Date = Date()) -> AnalyticsCoordinator.ReconcileResult {
|
||||
snapshot = WorkoutSnapshot(vm: vm)
|
||||
freezeRecentSnapshots(now: now)
|
||||
let result = makeCoordinator(now: now).reconcile()
|
||||
let snap = WorkoutSnapshot(vm: vm)
|
||||
snapshot = snap
|
||||
freezeRecentSnapshots(snap, now: now)
|
||||
let result = makeCoordinator(snap, now: now).reconcile()
|
||||
lastReconcile = now
|
||||
return result
|
||||
}
|
||||
|
||||
private func makeCoordinator(now: Date) -> AnalyticsCoordinator {
|
||||
AnalyticsCoordinator(
|
||||
store: store, calendar: calendar, retentionMonths: 12, now: { now },
|
||||
weekSamples: { [weak self] key in
|
||||
guard let self else { return ([], []) }
|
||||
let start = self.date(fromKey: key) ?? now
|
||||
let prevStart = self.calendar.date(byAdding: .weekOfYear, value: -1, to: start) ?? start
|
||||
return (self.samples(WorkoutAnalyticsAdapter.weekDayKeys(containing: start, calendar: self.calendar)),
|
||||
self.samples(WorkoutAnalyticsAdapter.weekDayKeys(containing: prevStart, calendar: self.calendar)))
|
||||
private func makeCoordinator(_ snap: WorkoutSnapshot, now: Date) -> AnalyticsCoordinator {
|
||||
let cal = calendar
|
||||
return AnalyticsCoordinator(
|
||||
store: store, calendar: cal, retentionMonths: 12, now: { now },
|
||||
weekSamples: { key in
|
||||
let start = Self.date(fromKey: key, calendar: cal) ?? now
|
||||
let prevStart = cal.date(byAdding: .weekOfYear, value: -1, to: start) ?? start
|
||||
return (Self.buildSamples(WorkoutAnalyticsAdapter.weekDayKeys(containing: start, calendar: cal), snap, cal),
|
||||
Self.buildSamples(WorkoutAnalyticsAdapter.weekDayKeys(containing: prevStart, calendar: cal), snap, cal))
|
||||
},
|
||||
monthSamples: { [weak self] key in
|
||||
guard let self, let start = self.date(fromMonthKey: key) else { return ([], []) }
|
||||
let prevStart = self.calendar.date(byAdding: .month, value: -1, to: start) ?? start
|
||||
return (self.samples(WorkoutAnalyticsAdapter.monthDayKeys(containing: start, calendar: self.calendar)),
|
||||
self.samples(WorkoutAnalyticsAdapter.monthDayKeys(containing: prevStart, calendar: self.calendar)))
|
||||
monthSamples: { key in
|
||||
guard let start = Self.date(fromMonthKey: key, calendar: cal) else { return ([], []) }
|
||||
let prevStart = cal.date(byAdding: .month, value: -1, to: start) ?? start
|
||||
return (Self.buildSamples(WorkoutAnalyticsAdapter.monthDayKeys(containing: start, calendar: cal), snap, cal),
|
||||
Self.buildSamples(WorkoutAnalyticsAdapter.monthDayKeys(containing: prevStart, calendar: cal), snap, cal))
|
||||
})
|
||||
}
|
||||
|
||||
/// Freeze immutable snapshots for completed recent days (yesterday and back a
|
||||
/// week) so later edits can't rewrite them; refresh today's live.
|
||||
private func freezeRecentSnapshots(now: Date) {
|
||||
/// Freeze immutable snapshots for completed recent days; refresh today's live.
|
||||
private func freezeRecentSnapshots(_ snap: WorkoutSnapshot, now: Date) {
|
||||
let today = calendar.startOfDay(for: now)
|
||||
for back in 0...8 {
|
||||
guard let d = calendar.date(byAdding: .day, value: -back, to: today) else { continue }
|
||||
let key = AnalyticsEngine.dateKey(for: d, calendar: calendar)
|
||||
let sample = daySample(forKey: key)
|
||||
store.saveSnapshot(sample, isPast: back > 0)
|
||||
store.saveSnapshot(Self.buildSamples([key], snap, calendar).first ?? DaySample(dateKey: key), isPast: back > 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sample building
|
||||
// MARK: - Sample building (nonisolated, pure over the snapshot value)
|
||||
|
||||
private func samples(_ keys: [String]) -> [DaySample] {
|
||||
WorkoutAnalyticsAdapter.daySamples(dayKeys: keys,
|
||||
workouts: workoutsMap(for: keys),
|
||||
scheduledKeys: scheduledKeys(for: keys))
|
||||
}
|
||||
|
||||
private func workoutsMap(for keys: [String]) -> [String: RawWorkoutDay] {
|
||||
var map: [String: RawWorkoutDay] = [:]
|
||||
nonisolated static func buildSamples(_ keys: [String], _ snap: WorkoutSnapshot, _ cal: Calendar) -> [DaySample] {
|
||||
var workouts: [String: RawWorkoutDay] = [:]
|
||||
var scheduled: Set<String> = []
|
||||
for key in keys {
|
||||
if let log = snapshot.logByDate[key] {
|
||||
map[key] = raw(from: log, done: snapshot.workoutDates.contains(key))
|
||||
} else if snapshot.workoutDates.contains(key) {
|
||||
map[key] = RawWorkoutDay(dateKey: key, didWorkout: true, sessions: 1, durationMinutes: 0, exercises: [])
|
||||
if let d = date(fromKey: key, calendar: cal), snap.scheduledWeekdays.contains(cal.component(.weekday, from: d)) {
|
||||
scheduled.insert(key)
|
||||
}
|
||||
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> {
|
||||
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 {
|
||||
nonisolated private static func raw(from log: WorkoutDayLog, done: Bool) -> RawWorkoutDay {
|
||||
let samples = log.exercises.map { ex -> ExerciseSample in
|
||||
let completed = ex.sets.filter { $0.done }.map { (weight: $0.weight, reps: $0.reps) }
|
||||
return WorkoutAnalyticsAdapter.exerciseSample(name: ex.name, completedSets: completed)
|
||||
@@ -121,16 +112,11 @@ final class AnalyticsService: ObservableObject {
|
||||
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
|
||||
|
||||
func weeklyReports() -> [WeeklyReport] { store.allWeeklyReports().sorted { $0.weekStartKey > $1.weekStartKey } }
|
||||
func monthlyReports() -> [MonthlyReport] { store.allMonthlyReports().sorted { $0.monthKey > $1.monthKey } }
|
||||
|
||||
/// 12-month volume series (oldest→newest) for the trends screen.
|
||||
func monthlyVolumeTrend() -> [MonthVolumePoint] {
|
||||
store.allMonthlyReports().sorted { $0.monthKey < $1.monthKey }
|
||||
.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)) })
|
||||
}
|
||||
|
||||
private func daySample(forKey key: String) -> DaySample {
|
||||
Self.buildSamples([key], snapshot, calendar).first ?? DaySample(dateKey: key)
|
||||
}
|
||||
|
||||
/// Per-exercise volume history (dateKey→volume) across stored snapshots.
|
||||
func exerciseHistory(identity: String) -> [ExerciseVolumePoint] {
|
||||
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] {
|
||||
var set = Set<String>()
|
||||
for key in store.allSnapshotKeys() {
|
||||
@@ -169,16 +159,18 @@ final class AnalyticsService: ObservableObject {
|
||||
return set.sorted()
|
||||
}
|
||||
|
||||
// MARK: - Key helpers
|
||||
// MARK: - Key helpers (nonisolated, pure)
|
||||
|
||||
private func date(fromKey key: String) -> Date? {
|
||||
let f = DateFormatter(); f.calendar = calendar; f.timeZone = calendar.timeZone
|
||||
f.locale = Locale(identifier: "en_US_POSIX"); f.dateFormat = "yyyy-MM-dd"
|
||||
return f.date(from: key)
|
||||
nonisolated private static func date(fromKey key: String, calendar: Calendar) -> Date? {
|
||||
formatter("yyyy-MM-dd", calendar).date(from: key)
|
||||
}
|
||||
private func date(fromMonthKey key: String) -> Date? {
|
||||
let f = DateFormatter(); f.calendar = calendar; f.timeZone = calendar.timeZone
|
||||
f.locale = Locale(identifier: "en_US_POSIX"); f.dateFormat = "yyyy-MM"
|
||||
return f.date(from: key)
|
||||
nonisolated private static func date(fromMonthKey key: String, calendar: Calendar) -> Date? {
|
||||
formatter("yyyy-MM", calendar).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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1635,3 +1635,13 @@ last. Device/build QA tracked here.
|
||||
launch + `.active` → presents `AnalyticsView(initialLink:)` opened to the
|
||||
weekly/monthly area. Completes goal condition #7 (report notifications
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user