diff --git a/GooseSwift/ActivitySessionModel.swift b/GooseSwift/ActivitySessionModel.swift index f71ec2abe..2c63e69d9 100644 --- a/GooseSwift/ActivitySessionModel.swift +++ b/GooseSwift/ActivitySessionModel.swift @@ -14,6 +14,16 @@ final class ActivitySessionModel: ObservableObject { @Published private(set) var maxHeartRate: Int? @Published private(set) var zoneDurations: [Int: TimeInterval] = [:] + // Private backing stores updated at full 60 Hz sample rate. + // @Published properties are only flushed at uiPublishInterval to avoid + // driving a SwiftUI re-render on every sample tick. + private var _elapsed: TimeInterval = 0 + private var _averageHeartRate: Int? + private var _maxHeartRate: Int? + private var _zoneDurations: [Int: TimeInterval] = [:] + private var lastPublishedAt: Date = .distantPast + private static let uiPublishInterval: TimeInterval = 1.0 / 4.0 + private var lastTick: Date? private var heartRateWeightedTotal: Double = 0 private var heartRateMeasuredSeconds: TimeInterval = 0 @@ -71,6 +81,7 @@ final class ActivitySessionModel: ObservableObject { return } tick(now: now, heartRate: heartRate) + flushToUI(now: now) // ensure latest samples are visible before pausing isPaused = true lastTick = nil timer?.invalidate() @@ -82,6 +93,7 @@ final class ActivitySessionModel: ObservableObject { return } tick(now: now, heartRate: heartRate) + flushToUI(now: now) // flush final sample before clearing state isActive = false isPaused = false endedAt = now @@ -97,18 +109,31 @@ final class ActivitySessionModel: ObservableObject { } let previousTick = lastTick ?? now let delta = max(0, now.timeIntervalSince(previousTick)) - elapsed += delta + _elapsed += delta lastTick = now - guard delta > 0, let heartRate else { - return + if delta > 0, let heartRate { + let zoneID = HeartRateZone.zoneID(for: heartRate) + _zoneDurations[zoneID, default: 0] += delta + heartRateWeightedTotal += Double(heartRate) * delta + heartRateMeasuredSeconds += delta + _averageHeartRate = Int((heartRateWeightedTotal / max(heartRateMeasuredSeconds, 1)).rounded()) + _maxHeartRate = max(_maxHeartRate ?? heartRate, heartRate) + } + + if now.timeIntervalSince(lastPublishedAt) >= Self.uiPublishInterval { + flushToUI(now: now) } - let zoneID = HeartRateZone.zoneID(for: heartRate) - zoneDurations[zoneID, default: 0] += delta - heartRateWeightedTotal += Double(heartRate) * delta - heartRateMeasuredSeconds += delta - averageHeartRate = Int((heartRateWeightedTotal / max(heartRateMeasuredSeconds, 1)).rounded()) - maxHeartRate = max(maxHeartRate ?? heartRate, heartRate) + } + + // Copies backing-store values into the @Published properties in a single + // synchronous block so SwiftUI coalesces them into one re-render. + private func flushToUI(now: Date) { + lastPublishedAt = now + elapsed = _elapsed + averageHeartRate = _averageHeartRate + maxHeartRate = _maxHeartRate + zoneDurations = _zoneDurations } private func scheduleTimer() { @@ -130,13 +155,18 @@ final class ActivitySessionModel: ObservableObject { if !keepingSelection { selectedActivity = .run } + _elapsed = 0 + _averageHeartRate = nil + _maxHeartRate = nil + _zoneDurations = [:] + heartRateWeightedTotal = 0 + heartRateMeasuredSeconds = 0 + lastTick = nil + lastPublishedAt = .distantPast elapsed = 0 averageHeartRate = nil maxHeartRate = nil zoneDurations = [:] - heartRateWeightedTotal = 0 - heartRateMeasuredSeconds = 0 - lastTick = nil startedAt = nil endedAt = nil isActive = false diff --git a/GooseSwift/GooseAppModel+Lifecycle.swift b/GooseSwift/GooseAppModel+Lifecycle.swift index 5a693b8fd..11db315ea 100644 --- a/GooseSwift/GooseAppModel+Lifecycle.swift +++ b/GooseSwift/GooseAppModel+Lifecycle.swift @@ -73,8 +73,14 @@ extension GooseAppModel { } func applyHeartRateTimelineSnapshot(_ snapshot: HeartRateTimelineSnapshot) { - heartRateHourlyRanges = snapshot.ranges - heartRateStorageStatus = snapshot.status + // Equality guard: the pipeline fires every 1 s; avoid a spurious objectWillChange + // (and full-view re-render of all GooseAppModel observers) when the data is unchanged. + if snapshot.ranges != heartRateHourlyRanges { + heartRateHourlyRanges = snapshot.ranges + } + if snapshot.status != heartRateStorageStatus { + heartRateStorageStatus = snapshot.status + } } func handleBLEConnectionStateChange(_ state: String) { diff --git a/GooseSwift/GooseAppModel+PacketPublishing.swift b/GooseSwift/GooseAppModel+PacketPublishing.swift index 7a02f68c4..964e058a3 100644 --- a/GooseSwift/GooseAppModel+PacketPublishing.swift +++ b/GooseSwift/GooseAppModel+PacketPublishing.swift @@ -666,7 +666,10 @@ extension GooseAppModel { } movementPacketValidation.ingest(sample) - movementPacketValidationStatus = movementPacketValidation.statusSummary + let newValidationStatus = movementPacketValidation.statusSummary + if newValidationStatus != movementPacketValidationStatus { + movementPacketValidationStatus = newValidationStatus + } ble.record( level: .debug, source: "activity.detect", @@ -703,6 +706,9 @@ extension GooseAppModel { for event in events { switch event { case .status(let status): + // Guard prevents @Published objectWillChange from firing when the status string + // repeats unchanged across consecutive movement packets. + guard status != activityDetectionStatus else { break } activityDetectionStatus = status case .primeGPS(let reason): activityDetectionStatus = "Movement detected; priming GPS" diff --git a/GooseSwift/GooseMessageStore.swift b/GooseSwift/GooseMessageStore.swift index 434a3e7cd..7e006c4af 100644 --- a/GooseSwift/GooseMessageStore.swift +++ b/GooseSwift/GooseMessageStore.swift @@ -47,10 +47,10 @@ final class GooseMessageStore: ObservableObject { return } - messages.insert(contentsOf: pendingMessages.reversed(), at: 0) + // Concatenation is O(n+k); insert-at-0 with shift is also O(n) but forces a copy of the + // entire backing store. Using + avoids mutating the existing buffer in-place. + let merged = pendingMessages.reversed() + messages pendingMessages.removeAll(keepingCapacity: true) - if messages.count > maximumMessages { - messages.removeLast(messages.count - maximumMessages) - } + messages = merged.count > maximumMessages ? Array(merged.prefix(maximumMessages)) : Array(merged) } } diff --git a/GooseSwift/HomeDashboardView.swift b/GooseSwift/HomeDashboardView.swift index 542898413..c45eee696 100644 --- a/GooseSwift/HomeDashboardView.swift +++ b/GooseSwift/HomeDashboardView.swift @@ -11,10 +11,12 @@ struct HomeDashboardView: View { @State private var selectedHealthMonitorTrend: HealthMetricSnapshot? var body: some View { + // Compute once per render — avoids calling healthStore.landingSnapshots(…) 9× per body pass. + let cached = landingSnapshots ScrollView { LazyVStack(alignment: .leading, spacing: 18) { HomeDailyScoreCard( - scores: scoreSnapshots, + scores: scoreSnapshots(using: cached), actionSummary: dailyActionSummary, coachTip: CoachTipFactory.homeTip(healthStore: healthStore, appModel: model), openScore: openHealth, @@ -22,13 +24,13 @@ struct HomeDashboardView: View { ) HomeStressEnergySection( - stress: landingSnapshot(for: .stress), - energy: landingSnapshot(for: .energyBank), + stress: landingSnapshot(for: .stress, in: cached), + energy: landingSnapshot(for: .energyBank, in: cached), openStress: { openHealth(.stress) } ) HomeCardioLoadWidget( - snapshot: landingSnapshot(for: .cardioLoad), + snapshot: landingSnapshot(for: .cardioLoad, in: cached), days: healthStore.cardioLoadWeeklyPoints() ) { showingCardioLoadSheet = true @@ -41,9 +43,9 @@ struct HomeDashboardView: View { ) HomeTimelineSection( - sleep: homeSnapshot(for: .sleep), - activity: homeSnapshot(for: .strain), - recovery: homeSnapshot(for: .recovery), + sleep: homeSnapshot(for: .sleep, in: cached), + activity: homeSnapshot(for: .strain, in: cached), + recovery: homeSnapshot(for: .recovery, in: cached), activities: model.homeActivityTimelineItems, openSleep: { openHealth(.sleep) }, openActivity: { openHealth(.strain) }, @@ -100,10 +102,11 @@ struct HomeDashboardView: View { model.refreshActivityTimeline(for: newValue) } .sheet(isPresented: $showingScoreDatePicker) { + let cached = landingSnapshots ScoreDatePickerSheet( title: "Daily Scores", routes: [.sleep, .recovery, .strain], - snapshots: scorePickerSnapshots, + snapshots: scorePickerSnapshots(using: cached), selectedDate: $selectedDate ) } @@ -115,19 +118,19 @@ struct HomeDashboardView: View { } } - private var scoreSnapshots: [HealthMetricSnapshot] { + private func scoreSnapshots(using cached: [HealthMetricSnapshot]) -> [HealthMetricSnapshot] { [ - datedHomeSnapshot(for: .sleep), - datedHomeSnapshot(for: .recovery), - datedHomeSnapshot(for: .strain), + datedHomeSnapshot(for: .sleep, in: cached), + datedHomeSnapshot(for: .recovery, in: cached), + datedHomeSnapshot(for: .strain, in: cached), ] } - private var scorePickerSnapshots: [HealthMetricSnapshot] { + private func scorePickerSnapshots(using cached: [HealthMetricSnapshot]) -> [HealthMetricSnapshot] { [ - homeSnapshot(for: .sleep), - homeSnapshot(for: .recovery), - homeSnapshot(for: .strain), + homeSnapshot(for: .sleep, in: cached), + homeSnapshot(for: .recovery, in: cached), + homeSnapshot(for: .strain, in: cached), ] } @@ -165,12 +168,12 @@ struct HomeDashboardView: View { ) } - private func landingSnapshot(for route: HealthRoute) -> HealthMetricSnapshot { - landingSnapshots.first { $0.route == route } ?? healthStore.snapshot(for: route) + private func landingSnapshot(for route: HealthRoute, in snapshots: [HealthMetricSnapshot]) -> HealthMetricSnapshot { + snapshots.first { $0.route == route } ?? healthStore.snapshot(for: route) } - private func homeSnapshot(for route: HealthRoute) -> HealthMetricSnapshot { - let snapshot = landingSnapshot(for: route) + private func homeSnapshot(for route: HealthRoute, in snapshots: [HealthMetricSnapshot]) -> HealthMetricSnapshot { + let snapshot = landingSnapshot(for: route, in: snapshots) guard route == .strain, snapshot.unit != "%" else { return snapshot } @@ -193,8 +196,8 @@ struct HomeDashboardView: View { ) } - private func datedHomeSnapshot(for route: HealthRoute) -> HealthMetricSnapshot { - ScoreDateTimeline.datedSnapshot(from: homeSnapshot(for: route), date: selectedDate) + private func datedHomeSnapshot(for route: HealthRoute, in snapshots: [HealthMetricSnapshot]) -> HealthMetricSnapshot { + ScoreDateTimeline.datedSnapshot(from: homeSnapshot(for: route, in: snapshots), date: selectedDate) } private func openHealth(_ route: HealthRoute) {