Skip to content
Closed
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
7 changes: 4 additions & 3 deletions Strand/App/ActiveWorkoutPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import WhoopProtocol
/// records a route too, and its fixes are banked by `ActiveRouteStore` rather than by this snapshot,
/// which stays the cheap per-sample write it was built as.) This is the Apple analogue of Android's
/// `ActiveWorkoutStore`/`ActiveWorkoutPersistence`: a tiny `Codable` snapshot (start time, sport, the
/// accumulated HR samples + running stats) is written to `UserDefaults` on start and on every captured
/// sample, and read back on launch so an interrupted session can still be ended and saved.
/// accumulated HR samples + running stats) is checkpointed on start and lifecycle transitions. Captured
/// samples queue scoring and encoding off the main actor before the snapshot is written to `UserDefaults`.
/// It is read back on launch so an interrupted session can still be ended and saved.
///
/// On-device only; mirrors the existing `moments` / `sleepMarks` `UserDefaults` persistence in `AppModel`.
/// The encode/decode is pure (no `UserDefaults` dependency on the codec itself) so the persist/rehydrate
Expand Down Expand Up @@ -69,7 +70,7 @@ enum ActiveWorkoutPersistence {
)
}

/// Persist (overwrite) the snapshot. Cheap; called on start + each captured sample.
/// Persist (overwrite) a lifecycle checkpoint synchronously.
static func store(_ snapshot: Snapshot, into defaults: UserDefaults = .standard) {
guard let data = encode(snapshot) else { return }
defaults.set(data, forKey: defaultsKey)
Expand Down
103 changes: 72 additions & 31 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,25 @@ final class AppModel: ObservableObject {
/// Android's `ActiveWorkout.gpsEnabled`.
private var activeWorkoutIsGps = false

private(set) var workoutSampleRevision: UInt64 = 0
private(set) var workoutSampleTask: Task<Void, Never>?
private var pendingWorkoutSample: WorkoutSampleWork?

struct WorkoutSampleWork {
let revision: UInt64
let snapshot: ActiveWorkoutPersistence.Snapshot
let hrMax: Double
let method: StrainScorer.Method
let sex: String

func process() -> (strain: Double, data: Data?) {
var scored = snapshot
scored.liveStrain = StrainScorer.strain(scored.samples, maxHR: hrMax,
method: method, sex: sex) ?? 0
return (scored.liveStrain, ActiveWorkoutPersistence.encode(scored))
}
}

/// A manual workout in progress. `samples` accumulate from the smoothed live `bpm`; `liveStrain`
/// is recomputed as the window grows so the active card can show strain building in real time.
struct ActiveWorkout: Equatable {
Expand Down Expand Up @@ -155,6 +174,15 @@ final class AppModel: ObservableObject {
/// second folded into `peakHr` (`recordSample`). Nil with no samples.
var savedPeak: Int? { samples.map(\.bpm).max().map { max($0, peakHr) } }

var snapshot: ActiveWorkoutPersistence.Snapshot {
ActiveWorkoutPersistence.Snapshot(
startSec: Int(start.timeIntervalSince1970), sport: sport, samples: samples,
avgHr: avgHr, peakHr: peakHr, liveStrain: liveStrain,
pausedAtSec: pausedAt.map { Int($0.timeIntervalSince1970) },
pausedDurationSec: Int(pausedDuration), sessionID: sessionID,
pausedForPulseLoss: pausedForPulseLoss, completedPauses: completedPauses)
}

/// Delegates to `ActiveWorkoutClock` so this and the two card surfaces cannot drift apart again.
func elapsed(at now: Date = Date()) -> TimeInterval {
ActiveWorkoutClock.activeElapsed(start: start, pausedAt: pausedAt,
Expand Down Expand Up @@ -887,25 +915,12 @@ final class AppModel: ObservableObject {
domain: .dataImport)
}

/// Persist the in-flight manual workout to `UserDefaults` so it survives the app being killed mid-
/// session (#529). Called on start + each captured sample. A no-op when nothing is running. The Apple
/// analogue of Android's `persistNonGpsWorkout`. A distance workout records a route as well, and its
/// fixes are banked separately by `ActiveRouteStore`: keeping them out of here is what lets this stay
/// a small per-sample write instead of rewriting a growing route on every beat.
private func persistActiveWorkout() {
/// Bank the latest raw window at a lifecycle boundary, before the app can be suspended.
func persistActiveWorkout() {
workoutSampleRevision &+= 1
pendingWorkoutSample = nil
guard let w = activeWorkout else { return }
ActiveWorkoutPersistence.store(
ActiveWorkoutPersistence.Snapshot(
startSec: Int(w.start.timeIntervalSince1970),
sport: w.sport,
samples: w.samples,
avgHr: w.avgHr,
peakHr: w.peakHr,
liveStrain: w.liveStrain,
pausedAtSec: w.pausedAt.map { Int($0.timeIntervalSince1970) },
pausedDurationSec: Int(w.pausedDuration),
sessionID: w.sessionID, pausedForPulseLoss: w.pausedForPulseLoss,
completedPauses: w.completedPauses))
ActiveWorkoutPersistence.store(w.snapshot)
}

/// If a manual workout was in flight when iOS killed the app, rebuild `activeWorkout` from the durable
Expand Down Expand Up @@ -974,6 +989,8 @@ final class AppModel: ObservableObject {

private func clearActiveWorkout() {
guard activeWorkout != nil else { return }
workoutSampleRevision &+= 1
pendingWorkoutSample = nil
activeWorkout = nil
if activeWorkoutIsGps { gpsRecorder.stop() }
activeWorkoutIsGps = false
Expand Down Expand Up @@ -1091,25 +1108,49 @@ final class AppModel: ObservableObject {
await repo.refresh()
}

/// Append the current smoothed `bpm` to the active workout and recompute its running strain. Called
/// from `ingestHR` on every fresh sample; a no-op when no workout is running. Recomputing strain
/// over the growing window each sample is cheap at the ~1 Hz live-HR cadence.
private func captureWorkoutSample() {
/// Keep every accepted sample; score and encode immutable snapshots off the main actor.
func captureWorkoutSample(at date: Date? = nil) {
guard var w = activeWorkout, !w.isPaused, let hr = bpm else { return }
// A second that already has its sample moves only the peak: publish that, and skip the rescore and the
// snapshot (the next second's sample carries the peak into the snapshot).
let peakBefore = w.peakHr
guard w.recordSample(HRSample(ts: Int(Date().timeIntervalSince1970), bpm: hr)) else {
if w.peakHr != peakBefore { activeWorkout = w }
guard w.recordSample(HRSample(ts: Int((date ?? Date()).timeIntervalSince1970), bpm: hr)) else {
if w.peakHr != peakBefore {
activeWorkout = w
queueWorkoutSample(w)
}
return
}
w.peakHr = max(w.peakHr, hr)
w.avgHr = Int((Double(w.samples.map(\.bpm).reduce(0, +)) / Double(w.samples.count)).rounded())
w.liveStrain = StrainScorer.strain(w.samples, maxHR: Double(profile.hrMax),
method: PuffinExperiment.effortMethod, sex: profile.sex) ?? 0
w.avgHr = Int((Double(w.samples.reduce(0) { $0 + $1.bpm }) / Double(w.samples.count)).rounded())
activeWorkout = w
// Re-snapshot the durable session so a kill keeps the latest accumulated HR window (#529).
persistActiveWorkout()
queueWorkoutSample(w)
}

private func queueWorkoutSample(_ workout: ActiveWorkout) {
workoutSampleRevision &+= 1
pendingWorkoutSample = WorkoutSampleWork(revision: workoutSampleRevision,
snapshot: workout.snapshot, hrMax: Double(profile.hrMax),
method: PuffinExperiment.effortMethod, sex: profile.sex)
guard workoutSampleTask == nil else { return }
workoutSampleTask = Task { [weak self] in
while let work = self?.pendingWorkoutSample {
self?.pendingWorkoutSample = nil
let result = await Task.detached(priority: .utility) { work.process() }.value
self?.applyWorkoutSample(work, result: result)
}
self?.workoutSampleTask = nil
}
}

func applyWorkoutSample(_ work: WorkoutSampleWork, result: (strain: Double, data: Data?)) {
guard work.revision == workoutSampleRevision, var workout = activeWorkout,
workout.sessionID == work.snapshot.sessionID, !workout.isPaused else { return }
if workout.liveStrain != result.strain {
workout.liveStrain = result.strain
activeWorkout = workout
}
if let data = result.data {
UserDefaults.standard.set(data, forKey: ActiveWorkoutPersistence.defaultsKey)
}
}

/// Drop the smoothing window and blank the hero number so a resume / re-attach shows ","
Expand Down
27 changes: 27 additions & 0 deletions Strand/App/ShortcutHealthImportAlert.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import SwiftUI

struct ShortcutHealthImportAlert: ViewModifier {
let model: AppModel
@State private var pending: ShortcutHealthImport.PendingImport?

private var isPresented: Binding<Bool> {
Binding(get: { pending != nil }, set: { showing in
if !showing { model.cancelPendingHealthImport() }
})
}

func body(content: Content) -> some View {
content
.onReceive(model.$pendingShortcutHealthImport.removeDuplicates()) { pending = $0 }
.alert("Import Apple Health data?", isPresented: isPresented) {
Button("Import") { model.confirmPendingHealthImport() }
Button("Cancel", role: .cancel) { model.cancelPendingHealthImport() }
} message: {
if let pending {
Text("A Shortcut wants to add \(pending.daysCount) days and \(pending.workoutsCount) workouts to the Apple Health import source.")
} else {
Text("A Shortcut wants to add data to the Apple Health import source.")
}
}
}
}
5 changes: 4 additions & 1 deletion Strand/App/StrandApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ struct StrandApp: App {
let router = NavRouter()
_router = StateObject(wrappedValue: router)
NotificationPresenter.shared.onCoachBriefTapped = { [weak router] in router?.openCoach() }
_model = State(initialValue: AppModel())
}

@StateObject private var model = AppModel()
// Own the model here; live-value views observe it without invalidating every scene on a pulse.
@State private var model: AppModel
/// Shared cross-screen navigation hook (e.g. Live → Devices). The macOS shell (`RootView`)
/// observes it and drives the sidebar selection.
@StateObject private var router: NavRouter
Expand Down Expand Up @@ -78,6 +80,7 @@ struct StrandApp: App {
// this target is macOS 13.
.onChange(of: scenePhase) { phase in
if phase == .active { model.ble.requestSync(.foreground) }
if phase == .background { model.persistActiveWorkout() }
}
}
.windowStyle(.hiddenTitleBar)
Expand Down
31 changes: 17 additions & 14 deletions Strand/Data/DayCycleIntelligenceIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,21 +219,24 @@ import WhoopStore
if let hit = cache.loads[window.sleepId], hit.key == loadKey, !hrWitness.contains(where: { $0.hasSuffix("=unread") }) {
load = hit
} else {
var hrByTimestamp: [Int: HRSample] = [:]
if hrEndInclusive >= window.onset {
for owner in owners {
let rows = (try? await store.hrSamples(
deviceId: owner, from: window.onset, to: hrEndInclusive, limit: 200_000)) ?? []
for row in rows where hrByTimestamp[row.ts] == nil { hrByTimestamp[row.ts] = row }
// A day of HR merging and scoring must not block scrolling during a foreground rescore.
let scored = await Task.detached(priority: .utility) {
var hrByTimestamp: [Int: HRSample] = [:]
if hrEndInclusive >= window.onset {
for owner in owners {
let rows = (try? await store.hrSamples(
deviceId: owner, from: window.onset, to: hrEndInclusive, limit: 200_000)) ?? []
for row in rows where hrByTimestamp[row.ts] == nil { hrByTimestamp[row.ts] = row }
}
}
}
let cycleHR = hrByTimestamp.values.sorted { $0.ts < $1.ts }
load = CachedLoad(
key: loadKey,
strain: StrainScorer.strain(cycleHR, maxHR: effectiveMaxHR, restingHR: restingHR,
method: effortMethod, sex: profile.sex),
calories: cycleHR.isEmpty ? nil : Calories.estimateDayCalories(
cycleHR, profile: profile, hrmax: effectiveMaxHR, restingHR: restingHR))
let cycleHR = hrByTimestamp.values.sorted { $0.ts < $1.ts }
return (
strain: StrainScorer.strain(cycleHR, maxHR: effectiveMaxHR, restingHR: restingHR,
method: effortMethod, sex: profile.sex),
calories: cycleHR.isEmpty ? nil : Calories.estimateDayCalories(
cycleHR, profile: profile, hrmax: effectiveMaxHR, restingHR: restingHR))
}.value
load = CachedLoad(key: loadKey, strain: scored.strain, calories: scored.calories)
cache.loads[window.sleepId] = load
}
if let strain = load.strain { strains[day] = strain }
Expand Down
9 changes: 8 additions & 1 deletion Strand/Screens/TestCentreView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,9 @@ private struct TestModeRow: View {
@ObservedObject var report: TestCentreReport
@EnvironmentObject var live: LiveState
@EnvironmentObject var model: AppModel
#if os(iOS)
@EnvironmentObject var liftSession: LiftSessionController
#endif
@State private var on: Bool = false

private var elapsed: Double? {
Expand Down Expand Up @@ -1007,7 +1010,11 @@ private struct TestModeRow: View {
// CAPTURE-D (#797): wire the data-volume provider so start() emits one `dataVolume` line read STRAIGHT
// from the store (Repository.dataVolumeSnapshot queries the store, not the @Published caches), so an
// import-driven-lag report shows the read-set behind the frame stats.
DisplayPerformanceMonitor.shared.configure(live: live, repo: model.repo)
#if os(iOS)
DisplayPerformanceMonitor.shared.configure(model: model, lift: liftSession)
#else
DisplayPerformanceMonitor.shared.configure(model: model)
#endif
DisplayPerformanceMonitor.shared.start()
}
}
Expand Down
Loading