From 1ac4d3c9c6ea0bfa137d00a584b549bf90946efc Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:56:28 +0200 Subject: [PATCH 1/4] Keep training active while its pulse sensor is disconnected --- Strand/App/TrainingPulseGuard.swift | 21 +++++++++--- StrandTests/TrainingPulseGuardTests.swift | 37 +++++++++++++++++++++ StrandiOS/Widgets/TrainingCoordinator.swift | 19 +++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/Strand/App/TrainingPulseGuard.swift b/Strand/App/TrainingPulseGuard.swift index 1ed66d1972..f299001ff0 100644 --- a/Strand/App/TrainingPulseGuard.swift +++ b/Strand/App/TrainingPulseGuard.swift @@ -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 } } diff --git a/StrandTests/TrainingPulseGuardTests.swift b/StrandTests/TrainingPulseGuardTests.swift index 82aeb8e0c7..cfded39517 100644 --- a/StrandTests/TrainingPulseGuardTests.swift +++ b/StrandTests/TrainingPulseGuardTests.swift @@ -2,6 +2,43 @@ import XCTest @testable import Strand final class TrainingPulseGuardTests: XCTestCase { + func testDisconnectionNeverPausesAndReconnectStartsANewWindow() { + var state = TrainingPulseGuard() + state.receive(at: 1_000) + state.setConnected(false, at: 1_599) + XCTAssertNil(state.deadline) + XCTAssertFalse(state.isDue(at: 20_000)) + state.setConnected(true, at: 20_000) + XCTAssertEqual(state.lastSampleSec, 1_000, "a connection must not masquerade as a sample") + XCTAssertFalse(state.isDue(at: 20_599)) + XCTAssertTrue(state.isDue(at: 20_600)) + state.receive(at: 20_500) + XCTAssertEqual(state.deadline, 21_100) + } + + func testReconnectWithoutFirstSampleDoesNotArmAndRepeatedConnectionDoesNotExtend() { + var state = TrainingPulseGuard() + state.setConnected(false, at: 1_000) + state.setConnected(true, at: 2_000) + XCTAssertNil(state.deadline) + state.receive(at: 2_100) + state.setConnected(true, at: 2_699) + XCTAssertTrue(state.isDue(at: 2_700)) + } + + func testOfflineCheckpointSurvivesRestartAndLegacySnapshotsStillDecode() throws { + var state = TrainingPulseGuard() + state.receive(at: 1_000) + state.setConnected(false, at: 1_100) + var restored = try JSONDecoder().decode(TrainingPulseGuard.self, from: JSONEncoder().encode(state.checkpoint)) + XCTAssertNil(restored.deadline) + restored.setConnected(true, at: 30_000) + XCTAssertEqual(restored.deadline, 30_600) + let legacy = try JSONDecoder().decode(TrainingPulseGuard.self, + from: Data("{\"lastSampleSec\":1000,\"recoveryAllowance\":30}".utf8)) + XCTAssertEqual(legacy.deadline, 1_630) + } + func testArmsOnlyAfterAReceiptAndUsesExactBoundary() { var guardState = TrainingPulseGuard() XCTAssertFalse(guardState.isDue(at: 10_000)) diff --git a/StrandiOS/Widgets/TrainingCoordinator.swift b/StrandiOS/Widgets/TrainingCoordinator.swift index 6aa7b4e7cc..463a3032c4 100644 --- a/StrandiOS/Widgets/TrainingCoordinator.swift +++ b/StrandiOS/Widgets/TrainingCoordinator.swift @@ -32,6 +32,7 @@ final class TrainingCoordinator: ObservableObject { var isShowingWorkout: Bool { workoutActivity?.activityState == .active || workoutActivity?.activityState == .stale } private var canStartFromIntent = false private var holdsRealtimeHR = false + private var connectionAvailable = false init(model: AppModel, lift: LiftSessionController, liftActivity: LiftLiveActivityController) { self.model = model; self.lift = lift; self.liftActivity = liftActivity @@ -66,6 +67,10 @@ final class TrainingCoordinator: ObservableObject { lift.changesSettled.sink { [weak self] _ in self?.reconcile() }.store(in: &subscriptions) lift.strapStepTaken.sink { [weak self] _ in self?.publish(alert: true) }.store(in: &subscriptions) model.live.$heartRate.sink { [weak liftActivity] bpm in liftActivity?.updateHeartRate(bpm) }.store(in: &subscriptions) + model.live.$connected.removeDuplicates().sink { [weak self] connected in + self?.connectionAvailable = connected + self?.reconcile() + }.store(in: &subscriptions) NotificationPresenter.shared.onTrainingAction = { [weak self] action, id, completion in Task { defer { completion() } @@ -95,6 +100,8 @@ final class TrainingCoordinator: ObservableObject { private func receivedPulse(at date: Date) { let now = Int(date.timeIntervalSince1970) + // An accepted live packet proves a link even if its connection publication arrives later. + connectionAvailable = true reconcile(at: date, publish: false) for id in ids where !isPaused(id) { var guardState = guards[id] ?? TrainingPulseGuard() @@ -128,6 +135,18 @@ final class TrainingCoordinator: ObservableObject { } for id in ids { let paused = isPaused(id) + if var state = guards[id] { + state.setConnected(connectionAvailable, at: now) + if state != guards[id] { + guards[id] = state + lastCheckpoint.removeValue(forKey: id) + cancelNotification(id) + if !paused, let deadline = state.deadline { + scheduleNotification(id: id, at: Date(timeIntervalSince1970: Double(deadline + 30))) + } + persistGuards() + } + } if wasPaused[id] == true && !paused { guards[id]?.resume(at: now) lastCheckpoint.removeValue(forKey: id) From 111a90b5f39bd45f121c52b721c96ffa579422ef Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:56:53 +0200 Subject: [PATCH 2/4] Prepare Apple test build 448 --- project.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project.yml b/project.yml index f16f4d0e7c..e1f39adfbb 100644 --- a/project.yml +++ b/project.yml @@ -21,7 +21,7 @@ settings: # iOS build number (CFBundleVersion). GLOBAL so the iOS app AND its widget extension inherit the # SAME value — they must match or iOS warns "extension version must match parent app" (#416). # macOS sets its own CFBundleVersion on the Strand target and is unaffected by this. - CURRENT_PROJECT_VERSION: "447" + CURRENT_PROJECT_VERSION: "448" SWIFT_VERSION: "5.0" # Emit localizable strings so Xcode auto-extracts every LocalizedStringKey into the # String Catalog on build (the English (US) base entries). From 57cceee16005ca3c38c73cfcabcf15bfbffbe8da Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:56:53 +0200 Subject: [PATCH 3/4] Review source baseline for Apple build 448 --- scripts/security/baselines/source.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/security/baselines/source.json b/scripts/security/baselines/source.json index c3279a7f65..5df79a73ed 100644 --- a/scripts/security/baselines/source.json +++ b/scripts/security/baselines/source.json @@ -679,7 +679,7 @@ "android/gradlew": "fc977a94723af68aaffa4e5d60496fb4aeed1884b6b19e5e2f2fd7612673313d", "docs/protocol-examples/check_source_references.py": "dce0d33becfc69a42a847ede982799f88f4f6c83ddc34a4a371589d82cc6fc03", "docs/protocol-examples/validate_examples.py": "c94878750e993523d23883934fe81b393f8de3b349f37ef4e5fc34107acbceb5", - "project.yml": "7ed9055e556f5f41eaf9f9ea88d7ece7d44d689408ba80bfd1041874fe901052", + "project.yml": "13d5badbf82bc48d37a91f8de80e82f4f97f59a4ead6e15fc9b3f02d3eec5ee2", "scripts/security/auto_update.py": "514a480ad272c1159bca0bd2dee76fb15cc9ae57f6426169c9b0348afca48478", "scripts/security/binary.py": "d7f5a39aa0e9745c4ce31b7df103b0361461eb52a695a8031c69644ef2ec1e41", "scripts/security/enable-background-install.py": "1847a9a4bcca0b9e069b5196878aa0227ad4a3131db87958d53e68248b13d0e2", From aa08ec0e47834c9782f178c5044b6fd3569000e7 Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:03:27 +0200 Subject: [PATCH 4/4] Retain offline workouts and fill their metrics from synced history --- Strand/App/ActiveWorkoutPersistence.swift | 4 +- Strand/App/AppModel.swift | 22 ++-- Strand/Data/IntelligenceEngine.swift | 63 ++++++++--- Strand/Data/LiftSessionController.swift | 14 +++ Strand/Data/LiftSessionPersistence.swift | 1 + Strand/Data/RecordedWorkoutHistory.swift | 62 +++++++++++ Strand/Data/Repository.swift | 7 +- .../ActiveWorkoutPersistenceTests.swift | 1 + .../DetectedWorkoutReconciliationTests.swift | 27 +++++ StrandTests/LiftSessionSaveFailureTests.swift | 10 +- StrandTests/OfflineWorkoutSaveTests.swift | 59 +++++++++++ StrandTests/RecordedWorkoutHistoryTests.swift | 100 ++++++++++++++++++ 12 files changed, 339 insertions(+), 31 deletions(-) create mode 100644 Strand/Data/RecordedWorkoutHistory.swift create mode 100644 StrandTests/OfflineWorkoutSaveTests.swift create mode 100644 StrandTests/RecordedWorkoutHistoryTests.swift diff --git a/Strand/App/ActiveWorkoutPersistence.swift b/Strand/App/ActiveWorkoutPersistence.swift index a03f28517d..61eefa90fa 100644 --- a/Strand/App/ActiveWorkoutPersistence.swift +++ b/Strand/App/ActiveWorkoutPersistence.swift @@ -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`. @@ -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 ) } diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 52b67b1d6c..71b62e6f26 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -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 } @@ -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 @@ -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, @@ -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 @@ -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, @@ -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( @@ -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 diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index a47bc0fb3f..24f92d0dbd 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -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 @@ -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] = [] @@ -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 { @@ -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 @@ -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 } @@ -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, diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index f7a4b10304..ec7829253d 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -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 } @@ -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() } @@ -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) } @@ -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 @@ -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 @@ -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) } } diff --git a/Strand/Data/LiftSessionPersistence.swift b/Strand/Data/LiftSessionPersistence.swift index 90e0a4e2fd..48ea195a21 100644 --- a/Strand/Data/LiftSessionPersistence.swift +++ b/Strand/Data/LiftSessionPersistence.swift @@ -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 diff --git a/Strand/Data/RecordedWorkoutHistory.swift b/Strand/Data/RecordedWorkoutHistory.swift new file mode 100644 index 0000000000..907d96eab5 --- /dev/null +++ b/Strand/Data/RecordedWorkoutHistory.swift @@ -0,0 +1,62 @@ +import Foundation +import WhoopProtocol +import WhoopStore + +/// Recorded sessions remain eligible as history arrives; hand-entered workouts keep their metrics. +enum RecordedWorkoutHistory { + static let defaultsKey = "noop.recordedWorkoutHistory" + static let retentionSeconds = 14 * 86_400 + + struct Entry: Codable, Equatable { + var deviceId: String + var startTs: Int + var endTs: Int + var sport: String + var pauses: [DateInterval] + + func matches(_ row: WorkoutRow, deviceId: String) -> Bool { + self.deviceId == deviceId && startTs == row.startTs && endTs == row.endTs && sport == row.sport + } + + func activeSamples(_ samples: [HRSample]) -> [HRSample] { + samples.compactMap { sample in + let stamp = Double(sample.ts) + guard sample.ts >= startTs, sample.ts <= endTs, + !pauses.contains(where: { stamp >= $0.start.timeIntervalSince1970 && stamp < $0.end.timeIntervalSince1970 }) + else { return nil } + // Compress completed pauses so the scorer cannot credit the gap as training. + let pausedSeconds = pauses.filter { $0.end.timeIntervalSince1970 <= stamp }.reduce(0) { $0 + $1.duration } + return HRSample(ts: sample.ts - Int(pausedSeconds), bpm: sample.bpm) + } + } + } + + static func load(from defaults: UserDefaults = .standard, now: Int = Int(Date().timeIntervalSince1970)) -> [Entry] { + let entries = defaults.data(forKey: defaultsKey) + .flatMap { try? JSONDecoder().decode([Entry].self, from: $0) } ?? [] + return entries.filter { entry in + entry.startTs > 0 && entry.endTs >= entry.startTs && entry.endTs >= now - retentionSeconds + && entry.endTs <= now + 86_400 && entry.pauses.allSatisfy { + $0.duration.isFinite && $0.start.timeIntervalSince1970.isFinite + && $0.start.timeIntervalSince1970 >= Double(entry.startTs) + && $0.end.timeIntervalSince1970 <= Double(entry.endTs) + } + } + } + + static func remember(_ row: WorkoutRow, deviceId: String, pauses: [DateInterval], + into defaults: UserDefaults = .standard) { + var entries = load(from: defaults) + entries.removeAll { $0.deviceId == deviceId && $0.startTs == row.startTs && $0.sport == row.sport } + let window = DateInterval(start: Date(timeIntervalSince1970: Double(row.startTs)), + end: Date(timeIntervalSince1970: Double(row.endTs))) + let clipped = pauses.compactMap { $0.intersection(with: window) }.filter { $0.duration > 0 } + entries.append(Entry(deviceId: deviceId, startTs: row.startTs, endTs: row.endTs, sport: row.sport, pauses: clipped)) + if let data = try? JSONEncoder().encode(entries) { defaults.set(data, forKey: defaultsKey) } + } + + static func forget(_ row: WorkoutRow, deviceId: String, in defaults: UserDefaults = .standard) { + let entries = load(from: defaults).filter { !$0.matches(row, deviceId: deviceId) } + if let data = try? JSONEncoder().encode(entries) { defaults.set(data, forKey: defaultsKey) } + } +} diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index bfe7ba7034..a825cb01b7 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -3023,7 +3023,12 @@ final class Repository: ObservableObject { } return } - _ = try? await store.upsertWorkouts([row], deviceId: deviceId) + do { + _ = try await store.upsertWorkouts([row], deviceId: deviceId) + if let old, row.avgHr != old.avgHr || row.energyKcal != old.energyKcal { + RecordedWorkoutHistory.forget(old, deviceId: deviceId) + } + } catch { return } } /// Re-label a legacy detected bout: copy it to a manual strap row with the chosen sport, then delete diff --git a/StrandTests/ActiveWorkoutPersistenceTests.swift b/StrandTests/ActiveWorkoutPersistenceTests.swift index 090910dd6a..fae9815e63 100644 --- a/StrandTests/ActiveWorkoutPersistenceTests.swift +++ b/StrandTests/ActiveWorkoutPersistenceTests.swift @@ -39,6 +39,7 @@ final class ActiveWorkoutPersistenceTests: XCTestCase { var original = snapshot(pausedAtSec: 1_700_000_120, pausedDurationSec: 45) original.sessionID = "stable-widget-session" original.pausedForPulseLoss = true + original.completedPauses = [DateInterval(start: Date(timeIntervalSince1970: 1_700_000_050), duration: 45)] let decoded = ActiveWorkoutPersistence.decode(ActiveWorkoutPersistence.encode(original)) XCTAssertEqual(decoded, original) } diff --git a/StrandTests/DetectedWorkoutReconciliationTests.swift b/StrandTests/DetectedWorkoutReconciliationTests.swift index dbb14d7646..9cb775a97f 100644 --- a/StrandTests/DetectedWorkoutReconciliationTests.swift +++ b/StrandTests/DetectedWorkoutReconciliationTests.swift @@ -26,6 +26,7 @@ final class DetectedWorkoutReconciliationTests: XCTestCase { PuffinExperiment.autoDetectWorkoutsKey, WorkoutSource.dismissedDefaultsKey, "testcentre.active.workouts", "testcentre.active.master", + RecordedWorkoutHistory.defaultsKey, ] let saved = keys.map { ($0, defaults.object(forKey: $0)) } defer { @@ -51,6 +52,32 @@ final class DetectedWorkoutReconciliationTests: XCTestCase { return (hr, gravity) } + func testRecordedWorkoutRescoresItsOwnWindowAndExcludesPauses() async throws { + try await withPreferences { + let store = try await WhoopStore.inMemory() + let start = Int(Date().timeIntervalSince1970) - 3_600 + let pause = DateInterval(start: Date(timeIntervalSince1970: Double(start + 300)), duration: 300) + let hr = (0...1_800).map { HRSample(ts: start + $0, bpm: (300..<600).contains($0) ? 190 : 150) } + let gravity = (0...1_800).map { GravitySample(ts: start + $0, x: $0.isMultiple(of: 2) ? -0.6 : 0.6, y: 0, z: 1) } + _ = try await store.insert(Streams(hr: hr, gravity: gravity), deviceId: deviceId) + let workout = WorkoutRow(startTs: start, endTs: start + 1_800, sport: "Running", source: "manual", + durationS: 1_500, energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil, + distanceM: 4_000, zonesJSON: nil, notes: "recorded", steps: nil) + _ = try await store.upsertWorkouts([workout], deviceId: deviceId) + RecordedWorkoutHistory.remember(workout, deviceId: deviceId, pauses: [pause]) + let repo = Repository(deviceId: deviceId, store: store) + let engine = IntelligenceEngine(repo: repo, profile: ProfileStore(), deviceId: deviceId) + await engine.analyzeRecent(maxDays: 2, force: true) + let rows = try await store.workouts(deviceId: deviceId, from: start, to: start + 1_800, limit: 10) + let saved = try XCTUnwrap(rows.first { $0.sport == "Running" }) + XCTAssertEqual(saved.avgHr, 150) + XCTAssertEqual(saved.maxHr, 150, "the 190 bpm readings arrived during a pause") + XCTAssertEqual(saved.durationS, 1_500) + XCTAssertEqual(saved.distanceM, 4_000) + XCTAssertEqual(saved.notes, "recorded") + } + } + func testCandidateSelectionHonorsBothLegacyDismissalStores() { let older = DetectedWorkout(startSec: 1_000, endSec: 1_900, avgBpm: 130, peakBpm: 155, durationMin: 15) diff --git a/StrandTests/LiftSessionSaveFailureTests.swift b/StrandTests/LiftSessionSaveFailureTests.swift index 348030aba7..871c2b49ef 100644 --- a/StrandTests/LiftSessionSaveFailureTests.swift +++ b/StrandTests/LiftSessionSaveFailureTests.swift @@ -12,7 +12,12 @@ final class LiftSessionSaveFailureTests: XCTestCase { let store = try await WhoopStore(path: path) let repo = Repository(deviceId: "training-test", store: store) let controller = LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) - defer { controller.discard() } + let savedHistory = UserDefaults.standard.object(forKey: RecordedWorkoutHistory.defaultsKey) + defer { + controller.discard() + if let savedHistory { UserDefaults.standard.set(savedHistory, forKey: RecordedWorkoutHistory.defaultsKey) } + else { UserDefaults.standard.removeObject(forKey: RecordedWorkoutHistory.defaultsKey) } + } controller.start(plan: [.init(exercise: "Row", targetSets: 2, restSec: 90)], programId: nil, programName: nil) controller.advance() controller.advance() @@ -24,6 +29,8 @@ final class LiftSessionSaveFailureTests: XCTestCase { _ = try await controller.save(repo: repo, completingUnfinished: false, sessionRpe: nil) XCTFail("the database rejected the set write") } catch {} + XCTAssertFalse(RecordedWorkoutHistory.load().contains { $0.deviceId == "training-test" }, + "failed writes must not register a saved workout") XCTAssertTrue(controller.isActive) XCTAssertTrue(controller.engine?.isPaused == true) XCTAssertEqual(LiftSessionPersistence.load()?.sessionID, id) @@ -37,6 +44,7 @@ final class LiftSessionSaveFailureTests: XCTestCase { let sets = try await store.liftSets(sessionId: id) XCTAssertEqual(sets.count, 2) XCTAssertEqual(sets.filter { $0.startTs != nil }.count, 1, "an open set is never invented as completed") + XCTAssertEqual(RecordedWorkoutHistory.load().filter { $0.deviceId == "training-test" }.count, 1) controller.finishedSaving() XCTAssertNil(controller.engine) XCTAssertNil(LiftSessionPersistence.load()) diff --git a/StrandTests/OfflineWorkoutSaveTests.swift b/StrandTests/OfflineWorkoutSaveTests.swift new file mode 100644 index 0000000000..dc2ad43c87 --- /dev/null +++ b/StrandTests/OfflineWorkoutSaveTests.swift @@ -0,0 +1,59 @@ +import XCTest +import WhoopStore +@testable import Strand + +@MainActor +final class OfflineWorkoutSaveTests: XCTestCase { + func testNoPulseOrGPSStillSavesAndFailedWriteKeepsTheSessionRetryable() async throws { + let defaults = UserDefaults.standard + let keys = [ActiveWorkoutPersistence.defaultsKey, RecordedWorkoutHistory.defaultsKey] + let originals = keys.map { ($0, defaults.object(forKey: $0)) } + let previousModel = AppModel.shared + defer { + AppModel.shared = previousModel + for (key, original) in originals { + if let original { defaults.set(original, forKey: key) } + else { defaults.removeObject(forKey: key) } + } + } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appendingPathComponent("workout.sqlite").path + let store = try await WhoopStore(path: path) + let model = AppModel() + model.repo.setStoreForTesting(store) + let started = Date().addingTimeInterval(-600) + model.activeWorkout = AppModel.ActiveWorkout(start: started, sport: "Strength") + let id = try XCTUnwrap(model.activeWorkout?.sessionID) + try execute("CREATE TRIGGER fail_workout_save BEFORE INSERT ON workout BEGIN SELECT RAISE(FAIL, 'test disk error'); END", path: path) + do { try await model.finishWorkout(); XCTFail("the write should fail") } catch {} + XCTAssertEqual(model.activeWorkout?.sessionID, id) + XCTAssertTrue(model.activeWorkout?.isPaused == true) + XCTAssertEqual(ActiveWorkoutPersistence.load()?.sessionID, id) + try execute("DROP TRIGGER fail_workout_save", path: path) + try await model.finishWorkout() + try await model.finishWorkout() + XCTAssertNil(model.activeWorkout) + XCTAssertNil(ActiveWorkoutPersistence.load()) + let rows = try await store.workouts(deviceId: model.deviceId, from: Int(started.timeIntervalSince1970), + to: Int(Date().timeIntervalSince1970), limit: 10) + XCTAssertEqual(rows.count, 1) + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(row.sport, "Strength") + XCTAssertEqual(row.durationS ?? 0, 600, accuracy: 2) + XCTAssertNil(row.avgHr) + XCTAssertNil(row.strain) + XCTAssertNil(row.energyKcal) + XCTAssertTrue(RecordedWorkoutHistory.load().contains { $0.matches(row, deviceId: model.deviceId) }) + } + + private func execute(_ sql: String, path: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") + process.arguments = [path, sql] + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { throw CocoaError(.fileWriteUnknown) } + } +} diff --git a/StrandTests/RecordedWorkoutHistoryTests.swift b/StrandTests/RecordedWorkoutHistoryTests.swift new file mode 100644 index 0000000000..305d18f543 --- /dev/null +++ b/StrandTests/RecordedWorkoutHistoryTests.swift @@ -0,0 +1,100 @@ +import XCTest +import WhoopProtocol +import WhoopStore +import StrandAnalytics +@testable import Strand + +@MainActor +final class RecordedWorkoutHistoryTests: XCTestCase { + private func row(start: Int, sport: String = "Running", kcal: Double? = nil, strain: Double? = nil) -> WorkoutRow { + WorkoutRow(startTs: start, endTs: start + 1_800, sport: sport, source: "manual", + durationS: 1_600, energyKcal: kcal, avgHr: nil, maxHr: nil, strain: strain, + distanceM: 4_000, zonesJSON: "kept", notes: "kept", steps: 2_000) + } + + func testLedgerRoundTripIsIdempotentScopedAndExpiresWithoutDeletingWorkouts() throws { + let suite = "test.recordedHistory.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let start = Int(Date().timeIntervalSince1970) - 2_000 + let workout = row(start: start) + let pause = DateInterval(start: Date(timeIntervalSince1970: Double(start + 200)), duration: 200) + RecordedWorkoutHistory.remember(workout, deviceId: "strap-a", pauses: [pause], into: defaults) + RecordedWorkoutHistory.remember(workout, deviceId: "strap-a", pauses: [pause], into: defaults) + let entry = try XCTUnwrap(RecordedWorkoutHistory.load(from: defaults).first) + XCTAssertEqual(RecordedWorkoutHistory.load(from: defaults).count, 1) + XCTAssertTrue(entry.matches(workout, deviceId: "strap-a")) + XCTAssertFalse(entry.matches(workout, deviceId: "strap-b")) + XCTAssertFalse(entry.matches(row(start: start, sport: "Strength"), deviceId: "strap-a")) + XCTAssertTrue(RecordedWorkoutHistory.load(from: defaults, now: workout.endTs + RecordedWorkoutHistory.retentionSeconds + 1).isEmpty) + XCTAssertEqual(workout.durationS, 1_600) + let input = [199, 200, 399, 400, 401].map { HRSample(ts: start + $0, bpm: 140) } + XCTAssertEqual(entry.activeSamples(input).map(\.ts), [start + 199, start + 200, start + 201]) + } + + func testSuccessiveSyncChunksEnrichRecordedSessionsAndPreserveManualEntries() async throws { + let defaults = UserDefaults.standard + let original = defaults.object(forKey: RecordedWorkoutHistory.defaultsKey) + defer { + if let original { defaults.set(original, forKey: RecordedWorkoutHistory.defaultsKey) } + else { defaults.removeObject(forKey: RecordedWorkoutHistory.defaultsKey) } + } + defaults.removeObject(forKey: RecordedWorkoutHistory.defaultsKey) + let device = "test-history-\(UUID().uuidString)" + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appendingPathComponent("history.sqlite").path + let store = try await WhoopStore(path: path) + let repo = Repository(deviceId: device, store: store) + let engine = IntelligenceEngine(repo: repo, profile: ProfileStore(), deviceId: "canonical-test") + let profile = UserProfile(weightKg: 75, heightCm: 180, age: 30, sex: "male") + let start = Int(Date().timeIntervalSince1970) - 3_600 + let recorded = row(start: start) + let entered = row(start: start, sport: "Cycling", kcal: 300, strain: 5) + _ = try await store.upsertWorkouts([recorded, entered], deviceId: device) + RecordedWorkoutHistory.remember(recorded, deviceId: device, pauses: []) + await engine.rescoreManualWorkouts(store: store, profile: profile, restingHR: 60) + let emptyRows = try await store.workouts(deviceId: device, from: start, to: start + 1_800, limit: 10) + XCTAssertEqual(emptyRows.first { $0.sport == "Running" }, recorded, "no HR must leave the session intact") + _ = try await store.insert(Streams(hr: (0..<1_200).map { HRSample(ts: start + $0, bpm: 150) }), deviceId: device) + try execute("CREATE TRIGGER fail_rescore BEFORE INSERT ON workout BEGIN SELECT RAISE(FAIL, 'test disk error'); END", path: path) + await engine.rescoreManualWorkouts(store: store, profile: profile, restingHR: 60) + try execute("DROP TRIGGER fail_rescore", path: path) + await engine.rescoreManualWorkouts(store: store, profile: profile, restingHR: 60) + let partialRows = try await store.workouts(deviceId: device, from: start, to: start + 1_800, limit: 10) + let partial = try XCTUnwrap(partialRows.first { $0.sport == "Running" }) + XCTAssertGreaterThan(partial.energyKcal ?? 0, 5, "later chunks must remain eligible above the old 5 kcal threshold") + XCTAssertNotNil(partial.strain) + _ = try await store.insert(Streams(hr: (1_200...1_800).map { HRSample(ts: start + $0, bpm: 150) }), deviceId: device) + await engine.rescoreManualWorkouts(store: store, profile: profile, restingHR: 60) + let fullRows = try await store.workouts(deviceId: device, from: start, to: start + 1_800, limit: 10) + let full = try XCTUnwrap(fullRows.first { $0.sport == "Running" }) + XCTAssertGreaterThan(full.energyKcal ?? 0, partial.energyKcal ?? 0) + XCTAssertEqual(full.durationS, recorded.durationS) + XCTAssertEqual(full.endTs, recorded.endTs) + XCTAssertEqual(full.distanceM, 4_000) + XCTAssertEqual(full.notes, "kept") + XCTAssertEqual(full.zonesJSON, "kept") + XCTAssertEqual(full.steps, 2_000) + XCTAssertEqual(fullRows.first { $0.sport == "Cycling" }, entered) + await engine.rescoreManualWorkouts(store: store, profile: profile, restingHR: 60) + let unchanged = try await store.workouts(deviceId: device, from: start, to: start + 1_800, limit: 10) + XCTAssertEqual(unchanged, fullRows) + let override = row(start: start, kcal: 20, strain: full.strain) + await repo.saveManualWorkout(override, replacing: full) + XCTAssertFalse(RecordedWorkoutHistory.load().contains { $0.matches(full, deviceId: device) }) + await engine.rescoreManualWorkouts(store: store, profile: profile, restingHR: 60) + let edited = try await store.workouts(deviceId: device, from: start, to: start + 1_800, limit: 10) + XCTAssertEqual(edited.first { $0.sport == "Running" }, override, "explicit edits must survive subsequent scoring") + } + + private func execute(_ sql: String, path: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") + process.arguments = [path, sql] + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { throw CocoaError(.fileWriteUnknown) } + } +}