From 67863fca4486a7e9bdd8fe95387d6c5fa8200445 Mon Sep 17 00:00:00 2001 From: kutesir Date: Tue, 7 Jul 2026 23:39:39 +0300 Subject: [PATCH] analytics(service): capture snapshot value in coordinator closures (concurrency hardening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves sample-building to nonisolated static funcs over a captured WorkoutSnapshot so the coordinator's escaping closures don't touch @MainActor state — avoids a main-actor isolation error. Matches the app's existing @MainActor + static shared pattern (HealthKitManager). Co-Authored-By: Claude Opus 4.8 --- KisaniCal/Analytics/AnalyticsService.swift | 122 ++++++++++----------- KisaniCal/ISSUES.md | 10 ++ 2 files changed, 67 insertions(+), 65 deletions(-) diff --git a/KisaniCal/Analytics/AnalyticsService.swift b/KisaniCal/Analytics/AnalyticsService.swift index 36331ec..76fdc74 100644 --- a/KisaniCal/Analytics/AnalyticsService.swift +++ b/KisaniCal/Analytics/AnalyticsService.swift @@ -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 = [] 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 { - 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() 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 } } diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index c76148f..a12bc33 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -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.