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-
|
// 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 (oldest→newest) 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 (dateKey→volume) across stored snapshots.
|
/// Per-exercise volume history (dateKey→volume) 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user