diff --git a/ios/App/App/CoachHealthRoute.swift b/ios/App/App/CoachHealthRoute.swift index a6a3041..31ee899 100644 --- a/ios/App/App/CoachHealthRoute.swift +++ b/ios/App/App/CoachHealthRoute.swift @@ -67,6 +67,14 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin { for id in Self.runningMetricIdentifiers { types.insert(HKQuantityType(id)) } + // Récupération cardiaque à 1 minute, calculée par la Watch elle-même + // (iOS 16+). La dériver de la série de FC post-séance donne un résultat + // inexploitable : mesuré sur 19 séances réelles, la baisse médiane + // ressort à 2 bpm et 7 valeurs sont nulles ou négatives, parce que la + // fin de séance HealthKit ne coïncide pas avec l'arrêt de l'effort. + if #available(iOS 16.0, *) { + types.insert(HKQuantityType(.heartRateRecoveryOneMinute)) + } if let steps = HKObjectType.quantityType(forIdentifier: .stepCount) { types.insert(steps) } // Share workoutType requis pour deleteWorkout (HKHealthStore.delete // exige share auth). Read-only pour les autres types. @@ -454,6 +462,31 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin { } self.store.execute(q) } + // ⚠️ `heartRateRecoveryOneMinute` n'est PAS rattaché au workout : + // `predicateForObjects(from:)` ne le retournerait jamais. Apple l'écrit + // comme un échantillon indépendant, horodaté juste après la séance — + // d'où une requête par fenêtre temporelle. + if #available(iOS 16.0, *) { + let hrrType = HKQuantityType(.heartRateRecoveryOneMinute) + let window = HKQuery.predicateForSamples( + withStart: workout.endDate, + end: workout.endDate.addingTimeInterval(600), + options: [.strictStartDate] + ) + group.enter() + let hrrQuery = HKSampleQuery(sampleType: hrrType, predicate: window, + limit: 1, sortDescriptors: nil) { _, samples, _ in + defer { group.leave() } + guard let s = (samples?.first as? HKQuantitySample) else { return } + let bpm = s.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())) + guard bpm > 0 else { return } + lock.lock() + out["hr_recovery_1min"] = bpm.rounded() + lock.unlock() + } + self.store.execute(hrrQuery) + } + group.notify(queue: .global()) { completion(out) } } diff --git a/ios/App/CoachWatch/VmaTestView.swift b/ios/App/CoachWatch/VmaTestView.swift index 49d080a..75b1a71 100644 --- a/ios/App/CoachWatch/VmaTestView.swift +++ b/ios/App/CoachWatch/VmaTestView.swift @@ -21,7 +21,10 @@ import WatchKit struct VmaTestView: View { enum Protocole: String, CaseIterable { case stages = "Paliers" - case sixMin = "6 minutes" + case sixMin = "6 min course" + // Test de marche 6 minutes : sous-maximal, référence en cardiologie. + // Le seul des trois qui ne demande aucun effort maximal. + case walk6 = "6 min marche" } @StateObject private var workout = WorkoutManager.shared @@ -80,9 +83,7 @@ struct VmaTestView: View { .labelsHidden() .frame(height: 60) - Text(proto == .stages - ? "Paliers d'une minute, de 8 km/h par pas de 0,5. La montre vibre à chaque changement. Arrête-toi quand tu ne tiens plus l'allure." - : "Six minutes à allure maximale régulière, sur terrain plat. La distance parcourue donne la VMA.") + Text(protocolHelp) .font(.caption2) .foregroundStyle(.secondary) @@ -94,6 +95,17 @@ struct VmaTestView: View { } } + private var protocolHelp: String { + switch proto { + case .stages: + return "Paliers d'une minute, de 8 km/h par pas de 0,5. La montre vibre à chaque changement. Arrête-toi quand tu ne tiens plus l'allure." + case .sixMin: + return "Six minutes à allure maximale régulière, sur terrain plat. La distance parcourue donne la VMA." + case .walk6: + return "Marche six minutes à ton pas le plus rapide, sans courir. Sous-maximal : c'est le test de référence en cardiologie." + } + } + // MARK: - Pendant le test private var runningView: some View { @@ -162,7 +174,8 @@ struct VmaTestView: View { .font(.caption2).foregroundStyle(.secondary) } else { Text(String(format: "%.0f m", workout.distanceMeters)).font(.title3) - Text("en 6 minutes").font(.caption2).foregroundStyle(.secondary) + Text(proto == .walk6 ? "en 6 minutes de marche" : "en 6 minutes") + .font(.caption2).foregroundStyle(.secondary) } if maxHr > 0 { @@ -196,7 +209,12 @@ struct VmaTestView: View { finished = false started = true // startWorkout est async (@MainActor) : même appel que ContentView. - Task { await workout.startWorkout(activityType: .running, locationType: .outdoor) } + // Marche et course sont deux types HealthKit distincts : la distance + // et la vitesse ne sont pas collectées sur le même quantity type. + Task { + await workout.startWorkout(activityType: proto == .walk6 ? .walking : .running, + locationType: .outdoor) + } WKInterfaceDevice.current().play(.start) } @@ -228,7 +246,7 @@ struct VmaTestView: View { sendError = nil var payload: [String: Any] = [ "vmaTest": true, - "protocol": proto == .stages ? "stages" : "cooper6", + "protocol": protocolKey, "maxHr": Int(maxHr.rounded()), ] if proto == .stages { @@ -248,6 +266,14 @@ struct VmaTestView: View { } } + private var protocolKey: String { + switch proto { + case .stages: return "stages" + case .sixMin: return "cooper6" + case .walk6: return "walk6" + } + } + private func timeString(_ s: Double) -> String { let t = Int(s.rounded()) return String(format: "%d:%02d", t / 60, t % 60)