diff --git a/PulseLoop/Coach/Tools/ActionTools.swift b/PulseLoop/Coach/Tools/ActionTools.swift index b7030e5..6a506e5 100644 --- a/PulseLoop/Coach/Tools/ActionTools.swift +++ b/PulseLoop/Coach/Tools/ActionTools.swift @@ -294,15 +294,36 @@ 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)) + } + 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 + ) } + + // 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() + // `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 9c80f47..00de7d6 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: "|") } } diff --git a/PulseLoop/Views/MetricDetailView.swift b/PulseLoop/Views/MetricDetailView.swift index 02aa7e9..2924f10 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 aa53fe2..0dc39f2 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() }