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
4 changes: 3 additions & 1 deletion Strand/App/ActiveWorkoutPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ enum ActiveWorkoutPersistence {
var pausedDurationSec: Int? = nil
var sessionID: String? = nil
var pausedForPulseLoss: Bool? = nil
var completedPauses: [DateInterval]? = nil
}

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

Expand Down
22 changes: 9 additions & 13 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ final class AppModel: ObservableObject {
var pausedDuration: TimeInterval = 0
var sessionID = UUID().uuidString
var pausedForPulseLoss = false
var completedPauses: [DateInterval] = []

var isPaused: Bool { pausedAt != nil }

Expand Down Expand Up @@ -903,7 +904,8 @@ final class AppModel: ObservableObject {
liveStrain: w.liveStrain,
pausedAtSec: w.pausedAt.map { Int($0.timeIntervalSince1970) },
pausedDurationSec: Int(w.pausedDuration),
sessionID: w.sessionID, pausedForPulseLoss: w.pausedForPulseLoss))
sessionID: w.sessionID, pausedForPulseLoss: w.pausedForPulseLoss,
completedPauses: w.completedPauses))
}

/// If a manual workout was in flight when iOS killed the app, rebuild `activeWorkout` from the durable
Expand All @@ -922,6 +924,7 @@ final class AppModel: ObservableObject {
w.pausedDuration = TimeInterval(snap.pausedDurationSec ?? 0)
w.sessionID = snap.sessionID ?? "workout-\(snap.startSec)"
w.pausedForPulseLoss = snap.pausedForPulseLoss ?? false
w.completedPauses = snap.completedPauses ?? []
activeWorkout = w

// Rebuild the transient GPS lifecycle flag as well as the durable workout value. Without this,
Expand Down Expand Up @@ -955,6 +958,7 @@ final class AppModel: ObservableObject {
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.completedPauses.append(DateInterval(start: pausedAt, end: max(pausedAt, date)))
w.pausedAt = nil
w.pausedForPulseLoss = false
activeWorkout = w
Expand Down Expand Up @@ -1013,16 +1017,7 @@ final class AppModel: ObservableObject {
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))
clearActiveWorkout()
return
}
// An intentional workout survives missing live HR/GPS; strap history may arrive later.
let end = Date()
// A session under a minute is a start/stop the wearer did not mean to keep, and it was the thing
// that made deletion feel broken: the list filled with 5-30 second entries (#2278). Discarded HERE,
Expand All @@ -1031,8 +1026,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 Down Expand Up @@ -1081,6 +1074,9 @@ final class AppModel: ObservableObject {
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)
var pauses = w.completedPauses
if let pausedAt = w.pausedAt { pauses.append(DateInterval(start: pausedAt, end: max(pausedAt, end))) }
RecordedWorkoutHistory.remember(row, deviceId: deviceId, pauses: pauses)
clearActiveWorkout()
lastWorkout = row
// Workouts & GPS test mode: one session-end summary tagged `.workouts` (the lastSessionSummary readout
Expand Down
21 changes: 17 additions & 4 deletions Strand/App/TrainingPulseGuard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,29 @@ struct TrainingPulseGuard: Codable, Equatable {
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 let lastSampleSec, lastSampleSec > 0,
lastSampleSec <= Int.max - Self.timeoutSeconds - allowance - Self.checkpointSeconds else { return nil }
return lastSampleSec + Self.timeoutSeconds + allowance
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 }
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 }
}
63 changes: 48 additions & 15 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ final class IntelligenceEngine: ObservableObject {
/// canonical `-noop` sibling. The Repository's union read then surfaces both the canonical computed
/// history and the active strap's live data together. So this never moves after construction.
private let deviceId: String
private var recordedWorkoutFingerprints: [String: String] = [:]

@Published var results: [Computed] = [] // newest first
@Published var computing = false
Expand Down Expand Up @@ -2095,6 +2096,7 @@ final class IntelligenceEngine: ObservableObject {
// analytics/enrichment input, but the opt-in confirmation card is now the only creator of a new
// visible workout; legacy `sport="detected"` rows are preserved rather than reconciled here.
var backfilledByDevice: [String: [WorkoutRow]] = [:]
let recordedWorkouts = RecordedWorkoutHistory.load()
// Rest composite (0–100) per computed night, persisted as the `sleep_performance` metric
// series so the dashboard's Rest score reflects the new composite, not raw efficiency.
var restPoints: [MetricPoint] = []
Expand Down Expand Up @@ -2369,14 +2371,16 @@ final class IntelligenceEngine: ObservableObject {
// thin, silently showing no HR/calories). Same natural key (deviceId, startTs,
// sport), so the upsert below updates the existing row in place rather than
// duplicating it.
let backfilled = WorkoutDetector.backfillWorkout(
let hitDeviceId = hit.source == "apple-health" ? "apple-health" : deviceId
// Recorded sessions use their own window and pauses, not a detected overlapping bout.
let usesHistory = recordedWorkouts.contains { $0.matches(hit, deviceId: hitDeviceId) }
let backfilled = usesHistory ? hit : WorkoutDetector.backfillWorkout(
hit, avgBpm: avgBpm, peakHR: s.peakHR, caloriesKcal: s.caloriesKcal, strain: s.strain)
let didBackfill = backfilled != hit
if didBackfill {
// realWorkouts merges TWO device groups (see above): the strap's own `deviceId`
// (imported WHOOP rows AND manual/re-labelled ones) and "apple-health" — the
// Swift WorkoutRow carries no deviceId of its own, so route by that same split.
let hitDeviceId = hit.source == "apple-health" ? "apple-health" : deviceId
backfilledByDevice[hitDeviceId, default: []].append(backfilled)
}
if workoutsTraceActive {
Expand Down Expand Up @@ -3173,10 +3177,22 @@ final class IntelligenceEngine: ObservableObject {
/// #137: re-score under-sampled manual workouts. A `manual` workout is scored from the live HR
/// captured during the session; on a 5/MG that stream is sparse, so calories/strain land near zero.
/// The strap banks its own HR and offloads it on sync , once that denser HR covers the workout's
/// window, recompute from it. Conservative + idempotent: only `manual` rows that look under-scored
/// (negligible calories), and only when the recompute is a genuine improvement , so a well-scored
/// 4.0 workout is never touched and a still-sparse window is a no-op.
private func rescoreManualWorkouts(store: WhoopStore, profile up: UserProfile,
/// window, recompute from it only when metrics improve. Recorded sessions remain eligible for
/// subsequent sync chunks; hand-entered metrics retain the conservative gate. Stream fingerprints
/// avoid rescoring unchanged recorded windows.
func rescoreManualWorkouts(store: WhoopStore, profile up: UserProfile,
restingHR: Double? = nil, effortMethod: StrainScorer.Method = .edwards) async {
let recorded = RecordedWorkoutHistory.load()
let keys = Set(recorded.map { "\($0.deviceId):\($0.startTs):\($0.sport)" })
recordedWorkoutFingerprints = recordedWorkoutFingerprints.filter { keys.contains($0.key) }
let owners = Set([deviceId] + recorded.map(\.deviceId))
for owner in owners {
await rescoreManualWorkouts(store: store, workoutDeviceId: owner, profile: up,
restingHR: restingHR, effortMethod: effortMethod)
}
}

private func rescoreManualWorkouts(store: WhoopStore, workoutDeviceId: String, profile up: UserProfile,
restingHR: Double? = nil,
// #1545: the recipe THIS PASS scored its days with, threaded in
// rather than re-read. Re-reading would let a toggle flipped
Expand All @@ -3186,20 +3202,34 @@ final class IntelligenceEngine: ObservableObject {
effortMethod: StrainScorer.Method = .edwards) async {
let now = Int(Date().timeIntervalSince1970)
let since = now - 14 * 86_400
guard let rows = try? await store.workouts(deviceId: deviceId, from: since, to: now, limit: 200)
guard let rows = try? await store.workouts(deviceId: workoutDeviceId, from: since, to: now, limit: 200)
else { return }
let hrMax = Double(profile.hrMax)
var updated: [WorkoutRow] = []
let recorded = RecordedWorkoutHistory.load(now: now).filter { $0.deviceId == workoutDeviceId }
var fingerprintsToCommit: [String: String] = [:]
// A manual row is eligible when it looks under-scored (negligible kcal, #137) OR it's missing
// strain (the merged-workout case, where kcal is the SUM of inputs so it never looks under-scored
// yet Effort stays blank forever). `improves` then accepts a strain-only gain for the latter.
for row in rows where row.source == "manual"
&& (ManualWorkoutRescore.looksUnderScored(currentKcal: row.energyKcal) || row.strain == nil) {
guard let samples = try? await store.hrSamples(deviceId: deviceId, from: row.startTs,
to: row.endTs, limit: 20_000),
let s = ManualWorkoutRescore.scored(windowSamples: samples, profile: up, hrMax: hrMax,
restingHR: restingHR,
effortMethod: effortMethod),
for row in rows where row.source == "manual" {
let entry = recorded.first { $0.matches(row, deviceId: workoutDeviceId) }
guard entry != nil || ManualWorkoutRescore.looksUnderScored(currentKcal: row.energyKcal) || row.strain == nil else { continue }
let key = "\(workoutDeviceId):\(row.startTs):\(row.sport)"
let fingerprint = entry == nil ? nil : await repo.hrFingerprintUnion(from: row.startTs, to: row.endTs)
if let fingerprint, recordedWorkoutFingerprints[key] == fingerprint { continue }
let samples: [HRSample]
if entry != nil {
samples = await repo.hrSamples(from: row.startTs, to: row.endTs, limit: 200_000)
} else {
guard let stored = try? await store.hrSamples(deviceId: workoutDeviceId, from: row.startTs, to: row.endTs, limit: 20_000) else { continue }
samples = stored
}
if let fingerprint { fingerprintsToCommit[key] = fingerprint }
let scored = await Task.detached(priority: .utility) {
ManualWorkoutRescore.scored(windowSamples: entry?.activeSamples(samples) ?? samples,
profile: up, hrMax: hrMax, restingHR: restingHR, effortMethod: effortMethod)
}.value
guard let s = scored,
ManualWorkoutRescore.improves(s, over: row.energyKcal, currentStrain: row.strain,
allowStrainOnlyFill: true)
else { continue }
Expand All @@ -3213,7 +3243,10 @@ final class IntelligenceEngine: ObservableObject {
strain: s.strain, distanceM: row.distanceM, zonesJSON: row.zonesJSON, notes: row.notes,
steps: row.steps))
}
if !updated.isEmpty { _ = try? await store.upsertWorkouts(updated, deviceId: deviceId) }
do {
if !updated.isEmpty { _ = try await store.upsertWorkouts(updated, deviceId: workoutDeviceId) }
recordedWorkoutFingerprints.merge(fingerprintsToCommit) { _, new in new }
} catch { /* Keep the old fingerprints so a failed write can be retried. */ }
}

/// Pass 1 has no seeded skin baseline. Attach the deviation before scoring so the score,
Expand Down
14 changes: 14 additions & 0 deletions Strand/Data/LiftSessionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ final class LiftSessionController: ObservableObject {
var isActive: Bool { engine != nil && engine?.isFinished == false }
private(set) var sessionID = UUID().uuidString
private(set) var pausedForPulseLoss = false
private var completedPauses: [DateInterval] = []

func pause(at now: Int = LiftSessionController.unixNow, pulseLoss: Bool = false) {
guard isActive, engine?.isPaused == false else { return }
Expand All @@ -64,6 +65,10 @@ final class LiftSessionController: ObservableObject {
func resume(at now: Int = LiftSessionController.unixNow) {
guard !isSaving, engine?.isPaused == true else { return }
pausedForPulseLoss = false
if let pausedAt = engine?.pausedAt {
completedPauses.append(DateInterval(start: Date(timeIntervalSince1970: Double(pausedAt)),
end: Date(timeIntervalSince1970: Double(max(pausedAt, now)))))
}
engine?.resume(now: now)
persist()
}
Expand Down Expand Up @@ -223,6 +228,12 @@ final class LiftSessionController: ObservableObject {
durationS: Double(duration), energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil,
distanceM: nil, zonesJSON: nil, notes: programName, steps: nil)
_ = try await store.upsertWorkouts([workout], deviceId: repo.deviceId)
var pauses = completedPauses
if let pausedAt = engine?.pausedAt {
pauses.append(DateInterval(start: Date(timeIntervalSince1970: Double(pausedAt)),
end: Date(timeIntervalSince1970: Double(max(pausedAt, endTs)))))
}
RecordedWorkoutHistory.remember(workout, deviceId: repo.deviceId, pauses: pauses)
}
return (finishedEngine.plan, finished)
}
Expand All @@ -236,6 +247,7 @@ final class LiftSessionController: ObservableObject {
lastSessionLoadedID = nil
pausedForPulseLoss = false
sessionRpeText = ""
completedPauses = []
let stamp = Int(Date().timeIntervalSince1970)
engine = LiftSessionEngine(plan: plan, startTs: stamp)
self.programId = programId
Expand Down Expand Up @@ -267,6 +279,7 @@ final class LiftSessionController: ObservableObject {
func resume(from snapshot: LiftSessionPersistence.Snapshot, present: Bool = false) {
sessionID = snapshot.sessionID ?? "lift-\(snapshot.startSec)"
pausedForPulseLoss = snapshot.pausedForPulseLoss ?? false
completedPauses = snapshot.completedPauses ?? []
engine = LiftSessionPersistence.engine(from: snapshot)
programId = snapshot.programId
programName = snapshot.programName
Expand Down Expand Up @@ -852,6 +865,7 @@ final class LiftSessionController: ObservableObject {
snapshot.sessionID = sessionID
snapshot.pausedForPulseLoss = pausedForPulseLoss
snapshot.sessionRpeText = sessionRpeText.isEmpty ? nil : sessionRpeText
snapshot.completedPauses = completedPauses
LiftSessionPersistence.store(snapshot)
}
}
1 change: 1 addition & 0 deletions Strand/Data/LiftSessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ enum LiftSessionPersistence {
var stagePausedDuration: Int? = nil
var pausedForPulseLoss: Bool? = nil
var sessionRpeText: String? = nil
var completedPauses: [DateInterval]? = nil

struct PendingValue: Codable, Equatable {
var exerciseIndex: Int
Expand Down
Loading