Skip to content
This repository was archived by the owner on Jun 16, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 42 additions & 12 deletions GooseSwift/ActivitySessionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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() {
Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions GooseSwift/GooseAppModel+Lifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion GooseSwift/GooseAppModel+PacketPublishing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 4 additions & 4 deletions GooseSwift/GooseMessageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
47 changes: 25 additions & 22 deletions GooseSwift/HomeDashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,26 @@ 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,
openCoach: openCoach
)

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
Expand All @@ -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) },
Expand Down Expand Up @@ -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
)
}
Expand All @@ -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),
]
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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) {
Expand Down