From 131245c55a63ae64fba4a37a00d496caf1c2d35b Mon Sep 17 00:00:00 2001 From: rgvxsthi <65727207+rgvxsthi@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:58:23 +1000 Subject: [PATCH 1/2] fix(ui): refresh stale screens after data changes (coach edit, goals, metric detail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reactivity gaps where a write didn't reach the UI until an unrelated sync or relaunch: - Coach editing a *today* workout used `applyUpdatesNow`, which set the session fields directly and never bumped the change token or routed through `ActivityService.applyEdit` — so Today/Activity showed the old duration/distance/calories and the daily rollup was computed off stale fields. Route type/time through the edit service and notify(), matching the confirmed-edit path in `PendingActionExecutor`. - Editing a daily Goal persisted the `UserGoal` but never bumped the token, and goal targets aren't in Today's data signature, so the rings kept filling against the old goal. notify() after save. - MetricDetailView only reloaded on the period segment; a sync completing while it was open left its chart + Latest/Avg/Min/Max tiles stale. Observe `PulseDataChange.token` and reload. --- PulseLoop/Coach/Tools/ActionTools.swift | 26 ++++++++++++++++--- PulseLoop/Views/MetricDetailView.swift | 3 +++ .../Views/Settings/GoalsSettingsView.swift | 3 +++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/PulseLoop/Coach/Tools/ActionTools.swift b/PulseLoop/Coach/Tools/ActionTools.swift index b7030e54..b502eb54 100644 --- a/PulseLoop/Coach/Tools/ActionTools.swift +++ b/PulseLoop/Coach/Tools/ActionTools.swift @@ -294,15 +294,33 @@ enum ActionTools { // MARK: - shared private static func applyUpdatesNow(_ updates: ActivityUpdates, to session: ActivitySession, context: ModelContext) { - if let type = updates.type { session.type = type } if let notes = updates.notes { session.notes = notes } - if let distanceKm = updates.distanceKm { session.distanceMeters = distanceKm * 1000 } if let effort = updates.perceivedEffort { session.perceivedEffort = effort } - if let start = updates.startTime, let date = CoachDataAccess.parseLocalDate(start) { session.startedAt = date } + + // Type/time changes must route through the edit service so aggregates, the sample window, + // and the daily rollup stay consistent — setting the fields directly left them all stale + // (Today/Activity kept the old duration/distance/calories). Mirrors `PendingActionExecutor`. + let newType = updates.type ?? session.type + var newStart = session.startedAt + if let start = updates.startTime, let date = CoachDataAccess.parseLocalDate(start) { newStart = date } + var newEnd = session.endedAt ?? Date() if let durationMin = updates.durationMin { - session.endedAt = session.startedAt.addingTimeInterval(durationMin * 60 + session.totalPauseSeconds) + newEnd = newStart.addingTimeInterval(durationMin * 60 + session.totalPauseSeconds) + } else if newStart != session.startedAt { + // Start moved without a new duration: shift the whole window, keeping its span. + newEnd = newStart.addingTimeInterval((session.endedAt ?? Date()).timeIntervalSince(session.startedAt)) + } + if newType != session.type || newStart != session.startedAt || newEnd != session.endedAt { + _ = ActivityService.applyEdit( + session: session, newType: newType, newStartedAt: newStart, newEndedAt: newEnd, context: context + ) } + + // A user-stated distance overrides the GPS recompute, so apply it after the edit. + if let distanceKm = updates.distanceKm { session.distanceMeters = distanceKm * 1000 } + session.updatedAt = Date() try? context.save() + PulseDataChange.shared.notify() } } diff --git a/PulseLoop/Views/MetricDetailView.swift b/PulseLoop/Views/MetricDetailView.swift index 02aa7e9f..2924f102 100644 --- a/PulseLoop/Views/MetricDetailView.swift +++ b/PulseLoop/Views/MetricDetailView.swift @@ -16,6 +16,8 @@ struct MetricDetailView: View { @State private var period: DetailPeriod = .today @State private var primary: [MetricSample] = [] @State private var secondary: [MetricSample] = [] // diastolic, for BP + /// Observed so the detail chart/tiles re-fetch when a background sync lands while this is open. + @State private var dataChange = PulseDataChange.shared private var profile: UserPhysiologyProfile { UserPhysiologyProfile(profiles.first) } private var units: UnitsPreference { profiles.first?.units ?? .metric } @@ -78,6 +80,7 @@ struct MetricDetailView: View { // bar (so the zoom transition doesn't reflow the content). .pageChrome(metric.title) .task(id: period) { reload() } + .onChange(of: dataChange.token) { _, _ in reload() } } // MARK: - Period selector diff --git a/PulseLoop/Views/Settings/GoalsSettingsView.swift b/PulseLoop/Views/Settings/GoalsSettingsView.swift index aa53fe2e..0dc39f2a 100644 --- a/PulseLoop/Views/Settings/GoalsSettingsView.swift +++ b/PulseLoop/Views/Settings/GoalsSettingsView.swift @@ -43,6 +43,9 @@ struct GoalsSettingsView: View { } draft.apply(to: goal, units: units, includeWeeklyWorkouts: true) try? modelContext.save() + // Goal targets aren't part of Today's data signature, so the summary won't rebuild on its + // own — nudge dependents so the rings re-fill against the new goal immediately. + PulseDataChange.shared.notify() coordinator.setGoal(steps: Int(draft.steps)) dismiss() } From 0d9da4918cb5a7fbd7594225420fc77c888bf2c9 Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Wed, 15 Jul 2026 14:30:44 -0400 Subject: [PATCH 2/2] Fold goal targets into Today signature so goal edits refresh the rings; drop redundant coach-edit notify --- PulseLoop/Coach/Tools/ActionTools.swift | 7 +++++-- PulseLoop/ViewModels/TodayStore.swift | 8 +++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/PulseLoop/Coach/Tools/ActionTools.swift b/PulseLoop/Coach/Tools/ActionTools.swift index b502eb54..6a506e51 100644 --- a/PulseLoop/Coach/Tools/ActionTools.swift +++ b/PulseLoop/Coach/Tools/ActionTools.swift @@ -310,7 +310,8 @@ enum ActionTools { // Start moved without a new duration: shift the whole window, keeping its span. newEnd = newStart.addingTimeInterval((session.endedAt ?? Date()).timeIntervalSince(session.startedAt)) } - if newType != session.type || newStart != session.startedAt || newEnd != session.endedAt { + let didEdit = newType != session.type || newStart != session.startedAt || newEnd != session.endedAt + if didEdit { _ = ActivityService.applyEdit( session: session, newType: newType, newStartedAt: newStart, newEndedAt: newEnd, context: context ) @@ -321,6 +322,8 @@ enum ActionTools { session.updatedAt = Date() try? context.save() - PulseDataChange.shared.notify() + // `applyEdit` already notified; only notify again when it didn't run or we changed + // something after it (the distance override), so a plain edit doesn't double-bump. + if !didEdit || updates.distanceKm != nil { PulseDataChange.shared.notify() } } } diff --git a/PulseLoop/ViewModels/TodayStore.swift b/PulseLoop/ViewModels/TodayStore.swift index 9c80f470..00de7d6f 100644 --- a/PulseLoop/ViewModels/TodayStore.swift +++ b/PulseLoop/ViewModels/TodayStore.swift @@ -164,13 +164,19 @@ final class TodayStore { let p = MetricPrefsStore.shared.settings let prefSig = "\(p.todayHiddenMetrics.sorted().joined(separator: ","))/\(p.todayResolution.rawValue)" + // The daily goal targets feed the rings tile (via `goalsSummary`) but aren't otherwise + // observed, so a goal edit must change the signature or the rings keep filling against the + // old goal until an unrelated sync bumps it. + let goal = MetricsRepository.goals(context: context) + let goalSig = goal.map { "\($0.steps)/\($0.activeMinutes)/\($0.distanceMeters)/\($0.calories)/\($0.sleepMinutes)/\($0.workoutsPerWeek)" } ?? "·" + return [ latest(.heartRate), latest(.spo2), latest(.stress), latest(.hrv), latest(.temperature), latest(.bloodPressureSystolic), latest(.bloodPressureDiastolic), latest(.bloodSugar), latest(.fatigue), activity.map { "\($0.steps)/\(Int($0.distanceMeters))/\($0.activeMinutes)@\(stamp($0.syncedAt))" } ?? "·", sleep.map { "\($0.totalMinutes)@\(stamp($0.syncedAt))" } ?? "·", device.map { "\($0.batteryPercent)/\($0.state.rawValue)@\(stamp($0.lastSyncAt))" } ?? "·", - calSig, profileSig, prefSig, + calSig, profileSig, prefSig, goalSig, ].joined(separator: "|") } }