Skip to content
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
1 change: 1 addition & 0 deletions Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,7 @@ public struct WorkoutTypeIcon: View {
switch WorkoutTypeIconography.glyph(for: type) {
case .system(let name):
Image(systemName: name)
.resizable().scaledToFit()
.font(.system(size: size, weight: weight))
.symbolRenderingMode(.monochrome)
case .custom(let custom):
Expand Down
4 changes: 4 additions & 0 deletions Strand/App/ActiveWorkoutPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ enum ActiveWorkoutPersistence {
var liveStrain: Double
var pausedAtSec: Int? = nil
var pausedDurationSec: Int? = nil
var sessionID: String? = nil
var pausedForPulseLoss: Bool? = nil
}

/// The single `UserDefaults` key (JSON-encoded `Snapshot`). Namespaced like `moments`/`sleepMarks`.
Expand Down Expand Up @@ -60,6 +62,8 @@ enum ActiveWorkoutPersistence {
liveStrain: raw.liveStrain.isFinite ? max(0, raw.liveStrain) : 0,
pausedAtSec: raw.pausedAtSec.flatMap { $0 > 0 ? $0 : nil },
pausedDurationSec: raw.pausedDurationSec.map { max(0, $0) },
sessionID: raw.sessionID,
pausedForPulseLoss: raw.pausedForPulseLoss,
)
}

Expand Down
105 changes: 58 additions & 47 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ final class AppModel: ObservableObject {
var peakHr: Int = 0
var pausedAt: Date?
var pausedDuration: TimeInterval = 0
var sessionID = UUID().uuidString
var pausedForPulseLoss = false

var isPaused: Bool { pausedAt != nil }

Expand Down Expand Up @@ -889,7 +891,7 @@ final class AppModel: ObservableObject {
/// 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() {
func persistActiveWorkout() {
guard let w = activeWorkout else { return }
ActiveWorkoutPersistence.store(
ActiveWorkoutPersistence.Snapshot(
Expand All @@ -900,7 +902,8 @@ final class AppModel: ObservableObject {
peakHr: w.peakHr,
liveStrain: w.liveStrain,
pausedAtSec: w.pausedAt.map { Int($0.timeIntervalSince1970) },
pausedDurationSec: Int(w.pausedDuration)))
pausedDurationSec: Int(w.pausedDuration),
sessionID: w.sessionID, pausedForPulseLoss: w.pausedForPulseLoss))
}

/// If a manual workout was in flight when iOS killed the app, rebuild `activeWorkout` from the durable
Expand All @@ -917,6 +920,8 @@ final class AppModel: ObservableObject {
w.liveStrain = snap.liveStrain
w.pausedAt = snap.pausedAtSec.map { Date(timeIntervalSince1970: TimeInterval($0)) }
w.pausedDuration = TimeInterval(snap.pausedDurationSec ?? 0)
w.sessionID = snap.sessionID ?? "workout-\(snap.startSec)"
w.pausedForPulseLoss = snap.pausedForPulseLoss ?? false
activeWorkout = w

// Rebuild the transient GPS lifecycle flag as well as the durable workout value. Without this,
Expand All @@ -935,21 +940,35 @@ final class AppModel: ObservableObject {
}

func toggleWorkoutPause() {
guard var w = activeWorkout else { return }
if let pausedAt = w.pausedAt {
w.pausedDuration += Date().timeIntervalSince(pausedAt)
w.pausedAt = nil
if activeWorkoutIsGps { gpsRecorder.resume() }
} else {
w.pausedAt = Date()
if activeWorkoutIsGps { gpsRecorder.pause() }
}
if activeWorkout?.isPaused == true { resumeWorkout() } else { pauseWorkout() }
}

func pauseWorkout(at date: Date = Date(), pulseLoss: Bool = false) {
guard var w = activeWorkout, !w.isPaused else { return }
w.pausedAt = max(w.start, date)
w.pausedForPulseLoss = pulseLoss
activeWorkout = w
if activeWorkoutIsGps { gpsRecorder.pause(at: date) }
persistActiveWorkout()
}

func resumeWorkout(at date: Date = Date()) {
guard var w = activeWorkout, let pausedAt = w.pausedAt, !isFinishingWorkout else { return }
w.pausedDuration += max(0, date.timeIntervalSince(pausedAt))
w.pausedAt = nil
w.pausedForPulseLoss = false
activeWorkout = w
if activeWorkoutIsGps { gpsRecorder.resume() }
persistActiveWorkout()
}

/// Abort the active session without saving a workout.
func discardWorkout() {
guard !isFinishingWorkout else { return }
clearActiveWorkout()
}

private func clearActiveWorkout() {
guard activeWorkout != nil else { return }
activeWorkout = nil
if activeWorkoutIsGps { gpsRecorder.stop() }
Expand All @@ -973,32 +992,32 @@ final class AppModel: ObservableObject {
elapsedSeconds < minimumWorkoutSeconds
}

func endWorkout() {
private(set) var isFinishingWorkout = false

func endWorkout() { Task { try? await finishWorkout() } }

func finishWorkout() async throws {
guard activeWorkout != nil, !isFinishingWorkout else { return }
isFinishingWorkout = true
defer { isFinishingWorkout = false }
pauseWorkout()
guard let w = activeWorkout else { return }
activeWorkout = nil
let wasGps = activeWorkoutIsGps
activeWorkoutIsGps = false
// Drop the durable snapshot the instant the session ends , whether it saves below or is discarded
// as too-short , so a relaunch never rehydrates an already-finished session (#529).
ActiveWorkoutPersistence.clear()
// #524: finalize the GPS route. Stop the recorder and take its captured route , it kept
// Keep the paused snapshot and route until the write succeeds, so a failed save remains retryable.
// #524: capture the GPS route; it kept
// accumulating from CoreLocation independently of the HR window. `capturedRoute()` is nil unless
// ≥2 points actually landed (honest: no route, no distance, when nothing was captured , e.g. a
// Mac with no GPS, or denied permission). A non-GPS session never armed the recorder.
var route: WorkoutRoute?
if wasGps {
gpsRecorder.stop()
route = gpsRecorder.capturedRoute()
}
let samples = w.samples
// Save when there's an HR window OR a real GPS route , a GPS-only walk (HR not streaming) is
// still a workout (parity with Android's `samples.size < 2 && track.size < 2` discard gate).
guard samples.count >= 2 || route != nil else {
// Workouts & GPS test mode: record WHY a session vanished (too short / no route), tagged `.workouts`.
emitWorkoutsTrace(WorkoutsTrace.sessionLine(
event: "discarded", sportKey: WorkoutSource.traceSportKey(w.sport),
hrSamples: samples.count, gpsPoints: route == nil ? 0 : nil))
lastWorkout = nil
clearActiveWorkout()
return
}
let end = Date()
Expand All @@ -1009,8 +1028,6 @@ final class AppModel: ObservableObject {
// and no cloud copy, so a later prune would be irreversible; this is not, because nothing with real
// data is ever removed.
//
// Sits after the sample/route gate above so that gate's meaning is unchanged: a 30-second session
// can easily carry two HR samples and would otherwise have been saved.
let elapsed = w.elapsed(at: end)
if Self.isTooShortToSave(elapsedSeconds: elapsed) {
emitWorkoutsTrace(WorkoutsTrace.sessionLine(
Expand All @@ -1019,7 +1036,7 @@ final class AppModel: ObservableObject {
gpsPoints: wasGps ? gpsRecorder.pointCount : nil))
// Drop the route too: keeping a polyline for a session that was never saved would orphan it in
// RouteStore under a natural key no row claims.
lastWorkout = nil
clearActiveWorkout()
return
}
let avg = samples.isEmpty ? nil
Expand All @@ -1032,22 +1049,19 @@ final class AppModel: ObservableObject {
// disagree with its own re-score. Read once here, at save time; the live readout during the
// session is a transient running estimate and deliberately left alone.
let restingHR = repo.today?.restingHr.map(Double.init) ?? StrainScorer.defaultRestingHR
let strain = samples.count >= 2
? StrainScorer.strain(samples, maxHR: Double(profile.hrMax),
restingHR: restingHR,
method: PuffinExperiment.effortMethod, sex: profile.sex) : nil
// Estimate calories from the captured HR window (same Keytel/Harris–Benedict model the
// auto-detector uses) so a manual session shows energy too, not just duration/strain. (#117)
let up = UserProfile(weightKg: profile.weightKg, heightCm: profile.heightCm,
age: Double(profile.age), sex: profile.sex)
let kcal = samples.count >= 2
// #983: same measured resting HR as the strain above, not nil. The calories model's
// active-vs-resting threshold sits at resting + 30% HRR, so the default silently shifts what
// counts as active — and #972 already threads it in the rescore path, so leaving it nil here
// meant a saved workout's kcal disagreed with its own re-score just as its Effort did.
? Calories.estimateBoutCalories(samples, profile: up, hrmax: Double(profile.hrMax),
restingHR: restingHR).0
: 0
let hrMax = Double(profile.hrMax)
let method = PuffinExperiment.effortMethod
let sex = profile.sex
// Pure scoring uses an immutable snapshot, leaving the main actor free for UI and BLE.
let (strain, kcal) = await Task.detached(priority: .userInitiated) {
let strain = samples.count >= 2
? StrainScorer.strain(samples, maxHR: hrMax, restingHR: restingHR, method: method, sex: sex) : nil
let kcal = samples.count >= 2
? Calories.estimateBoutCalories(samples, profile: up, hrmax: hrMax, restingHR: restingHR).0 : 0
return (strain, kcal)
}.value
let startTs = Int(w.start.timeIntervalSince1970)
let row = WorkoutRow(
startTs: startTs, endTs: Int(end.timeIntervalSince1970),
Expand All @@ -1060,6 +1074,9 @@ final class AppModel: ObservableObject {
// Persist the route polyline under the row's natural key so WorkoutDetailView can draw it. On
// device only; mirrors the moments / sleepMarks UserDefaults persistence. (#524)
if let route { RouteStore.store(route, startTs: startTs, sport: w.sport) }
guard let store = await repo.storeHandle() else { throw CocoaError(.fileWriteUnknown) }
_ = try await store.upsertWorkouts([row], deviceId: deviceId)
clearActiveWorkout()
lastWorkout = row
// Workouts & GPS test mode: one session-end summary tagged `.workouts` (the lastSessionSummary readout
// source) carrying the captured HR window size, the duration, and the accepted GPS point count, so the
Expand All @@ -1070,13 +1087,7 @@ final class AppModel: ObservableObject {
durationSec: Int(w.elapsed(at: end)),
gpsPoints: wasGps ? gpsRecorder.pointCount : nil))
buzz(loops: 2, gate: HapticPrefs.workout)
Task { [weak self] in
guard let self else { return }
if let store = await self.repo.storeHandle() {
_ = try? await store.upsertWorkouts([row], deviceId: self.deviceId)
await self.repo.refresh()
}
}
await repo.refresh()
}

/// Append the current smoothed `bpm` to the active workout and recompute its running strain. Called
Expand Down
4 changes: 2 additions & 2 deletions Strand/App/GpsWorkoutRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -594,9 +594,9 @@ final class GpsWorkoutRecorder: NSObject, ObservableObject {
return final
}

func pause() {
func pause(at date: Date = Date()) {
guard isRecording else { return }
pausedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
pausedAtMs = Int64(date.timeIntervalSince1970 * 1000)
manager.stopUpdatingLocation()
isRecording = false
}
Expand Down
2 changes: 1 addition & 1 deletion Strand/App/LiveSessionRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ final class LiveSessionRunner: ObservableObject {

/// Continuous stale time (seconds) after which the session ends itself — the strap is gone, and a
/// "guarding" screen over no data would be a false promise.
static let autoEndStaleSec = 600
static let autoEndStaleSec = TrainingPulseGuard.timeoutSeconds
/// Where the guarded HR came from. v1 only coaches the strap's live feed (see `LiveSessionRow.hrSource`).
static let hrSource = "whoop"
/// The UI/staleness tick does not need an exact wakeup. A ten-percent tolerance lets the system
Expand Down
9 changes: 9 additions & 0 deletions Strand/App/NavRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ final class NavRouter: ObservableObject {
/// Also the K5 scheduled morning-brief notification's tap-through target.
case coach
case alarms
case trainingFavorites

var id: String { rawValue }

Expand Down Expand Up @@ -70,6 +71,14 @@ final class NavRouter: ObservableObject {
func openCoach() { requestedDestination = .coach }
/// Open the existing wake-alarm and wind-down settings from Sleep.
func openAlarms() { requestedDestination = .alarms }
func openTrainingFavorites() { requestedDestination = .trainingFavorites }

func consumeTrainingFavoritesRequest(isReady: Bool) -> Bool {
guard isReady, requestedDestination == .trainingFavorites else { return false }
requestedDestination = nil
return true
}

/// Open the v5 Insights hub (the n-of-1 "what moves your Charge" surface).
func openInsightsHub() { requestedDestination = .insightsHub }
/// Open the Lab Book (private health-records logbook).
Expand Down
1 change: 1 addition & 0 deletions Strand/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ struct RootView: View {
// stays in exactly one place.
case .coach: selection = .coach
case .alarms: selection = .smartAlarm
case .trainingFavorites: selection = .settings
case nil: break
}
if dest != nil { router.requestedDestination = nil }
Expand Down
34 changes: 34 additions & 0 deletions Strand/App/TrainingPulseGuard.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation

/// Freshness of accepted live samples, independent of the displayed heart-rate value.
struct TrainingPulseGuard: Codable, Equatable {
static let timeoutSeconds = 600
static let checkpointSeconds = 30
var lastSampleSec: Int?
var recoveryAllowance = 0
var disconnected: Bool? = nil
var reconnectedAtSec: Int? = nil

var deadline: Int? {
let allowance = min(Self.checkpointSeconds, max(0, recoveryAllowance))
guard disconnected != true, let lastSampleSec, lastSampleSec > 0 else { return nil }
let reference = max(lastSampleSec, reconnectedAtSec ?? lastSampleSec)
guard reference > 0,
reference <= Int.max - Self.timeoutSeconds - allowance - Self.checkpointSeconds else { return nil }
return reference + Self.timeoutSeconds + allowance
}
func isDue(at now: Int) -> Bool { deadline.map { now >= $0 } ?? false }
mutating func receive(at now: Int) { lastSampleSec = now; recoveryAllowance = 0; reconnectedAtSec = nil }
mutating func resume(at now: Int) { if lastSampleSec != nil { receive(at: now) } }
mutating func setConnected(_ connected: Bool, at now: Int) {
if !connected { disconnected = true; reconnectedAtSec = nil }
else if disconnected == true {
disconnected = false
// A connection is not a sample; allow a new window without claiming a fresh pulse.
reconnectedAtSec = now
recoveryAllowance = 0
}
}
// A checkpoint can lag receipt by 30 seconds. Never pause early after a process restart.
var checkpoint: Self { var copy = self; copy.recoveryAllowance = Self.checkpointSeconds; return copy }
}
3 changes: 2 additions & 1 deletion Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5700,6 +5700,8 @@ public final class BLEManager: NSObject, ObservableObject {
log("HR: skin contact \(m.contact.rawValue) (was \(lastLoggedHRContact?.rawValue ?? "unknown")), \(m.hr) bpm")
lastLoggedHRContact = m.contact
}
// Reconcile a training deadline before either biometric publisher can capture a sample.
if readable { state.noteReadableHeartRate() }
if !rr.isEmpty, readable { state.setRRIntervals(rr) }
// A run of unreadable samples clears the shown heart rate instead of leaving the last one standing.
if heartRateReadability.clearsShownHeartRate(bpm: m.hr, contact: m.contact), state.heartRate != nil {
Expand All @@ -5712,7 +5714,6 @@ public final class BLEManager: NSObject, ObservableObject {
// live perf: only publish on a real change so a steady resting HR doesn't re-render the whole
// Live console every second.
if readable {
state.noteReadableHeartRate()
if state.heartRate != m.hr { state.heartRate = m.hr }
}
// Record it continuously — independent of the realtime stream or the open screen.
Expand Down
4 changes: 4 additions & 0 deletions Strand/BLE/LiveState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -729,10 +729,14 @@ public final class LiveState: ObservableObject {
private var heartRateSilenceTimer: DispatchSourceTimer?
private var heartRateSilenceArmedAt: DispatchTime?

/// Receipt observers run before biometric publication; they do not invalidate SwiftUI views.
public var onReadableHeartRate: ((Date) -> Void)?

/// A readable heart-rate sample arrived (`BLEManager`'s standard profile, `FrameRouter`'s realtime frames): move the
/// silence deadline on. One timer, rescheduled at most every tenth of the wait (once a second in use), so it costs
/// nothing while samples flow and fires once when they stop.
public func noteReadableHeartRate() {
onReadableHeartRate?(Date())
let now = DispatchTime.now()
let rearmNanos = UInt64(heartRateSilence / 10 * 1_000_000_000)
if let armed = heartRateSilenceArmedAt, now.uptimeNanoseconds &- armed.uptimeNanoseconds < rearmNanos { return }
Expand Down
Loading
Loading