Skip to content
Merged
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
29 changes: 25 additions & 4 deletions PulseLoop/Coach/Tools/ActionTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
}
8 changes: 7 additions & 1 deletion PulseLoop/ViewModels/TodayStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "|")
}
}
3 changes: 3 additions & 0 deletions PulseLoop/Views/MetricDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions PulseLoop/Views/Settings/GoalsSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Loading