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-
// 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 (oldestnewest) 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 (dateKeyvolume) 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
}
}

View File

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