From 3e3351f3ca81001a2cf77623cd69fdc406f89265 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Mon, 3 Aug 2026 21:12:53 +0000 Subject: [PATCH] feat(healthkit): lit le score d'effort saisi sur l'Apple Watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit watchOS demande 'Quel a été votre effort ?' après chaque séance et l'user y répond déjà. La réponse est stockée dans HealthKit en workoutEffortScore (iOS 18+, vérifié dans la doc Apple) : on la récupère au lieu de la redemander dans l'app. - getEffortScores({days}) : parcourt les séances de la fenêtre et, pour chacune, interroge les samples d'effort via le prédicat officiel predicateForWorkoutEffortSamplesRelated(workout:activity:). Pas de rapprochement approximatif par horodatage. - unité HKUnit.appleEffortScore(), score arrondi sur 1-10 - requestAuthorization demande désormais la permission de lecture du type : sans elle, la requête renvoie une liste vide SANS erreur — HealthKit ne distingue pas 'refusé' de 'aucune donnée' - guard iOS 18 : sur plus ancien, renvoie [] avec une raison explicite Le pendant serveur est dans coach_sportif (commit 0e79e74) : POST /api/feedback/device/import, qui n'écrase jamais une saisie manuelle. NON COMPILÉ — à inclure au prochain build Xcode. Co-Authored-By: Claude Opus 5 --- ios/App/App/CoachHealthRoute.swift | 94 ++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/ios/App/App/CoachHealthRoute.swift b/ios/App/App/CoachHealthRoute.swift index d1f60e2..fd929cb 100644 --- a/ios/App/App/CoachHealthRoute.swift +++ b/ios/App/App/CoachHealthRoute.swift @@ -26,6 +26,7 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "getRoute", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getCharacteristics", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise), ] private lazy var store = HKHealthStore() @@ -52,6 +53,12 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin { if let dob = HKObjectType.characteristicType(forIdentifier: .dateOfBirth) { types.insert(dob) } if let sex = HKObjectType.characteristicType(forIdentifier: .biologicalSex) { types.insert(sex) } if let blood = HKObjectType.characteristicType(forIdentifier: .bloodType) { types.insert(blood) } + // Score d'effort saisi sur la Watch en fin de séance (iOS 18+). + // Sans cette permission, getEffortScores renvoie une liste vide sans + // erreur — HealthKit ne distingue pas « refusé » de « aucune donnée ». + if #available(iOS 18.0, *) { + types.insert(HKQuantityType(.workoutEffortScore)) + } // Share workoutType requis pour deleteWorkout (HKHealthStore.delete // exige share auth). Read-only pour les autres types. let shareTypes: Set = [HKObjectType.workoutType()] @@ -210,6 +217,93 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin { /// share pour HKWorkoutType (demandée dans requestAuthorization). Renvoie /// {deleted: true} si trouvé+supprimé, {deleted: false, reason: ...} si /// introuvable, ou reject() si erreur HealthKit. + + /// Scores d'effort saisis sur l'Apple Watch en fin de séance. + /// + /// watchOS demande « Quel a été votre effort ? » après chaque entraînement ; + /// la réponse est stockée dans HealthKit en `workoutEffortScore` (iOS 18+). + /// On la récupère au lieu de reposer la question dans l'app. + /// + /// La liaison score ↔ séance passe par le prédicat officiel + /// `predicateForWorkoutEffortSamplesRelated(workout:activity:)` : pas de + /// rapprochement approximatif par horodatage. + /// + /// `getEffortScores({ days: 30 })` → { scores: [{ workout_id, rpe, date }] } + @objc func getEffortScores(_ call: CAPPluginCall) { + guard #available(iOS 18.0, *) else { + call.resolve(["scores": [], "reason": "iOS 18 requis pour workoutEffortScore"]) + return + } + guard HKHealthStore.isHealthDataAvailable() else { + call.resolve(["scores": [], "reason": "HealthKit indisponible"]) + return + } + + let days = max(1, min(365, call.getInt("days") ?? 30)) + let start = Calendar.current.date(byAdding: .day, value: -days, to: Date()) ?? Date() + let workoutPredicate = HKQuery.predicateForSamples(withStart: start, end: nil) + + let workoutQuery = HKSampleQuery( + sampleType: .workoutType(), + predicate: workoutPredicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)] + ) { [weak self] _, samples, error in + guard let self = self else { return } + if let error = error { + call.reject("Lecture des séances impossible : \(error.localizedDescription)") + return + } + let workouts = (samples as? [HKWorkout]) ?? [] + guard !workouts.isEmpty else { + call.resolve(["scores": []]) + return + } + + let effortType = HKQuantityType(.workoutEffortScore) + let group = DispatchGroup() + let lock = NSLock() + var out: [[String: Any]] = [] + + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + fmt.timeZone = TimeZone.current + + for workout in workouts { + group.enter() + let related = HKQuery.predicateForWorkoutEffortSamplesRelated( + workout: workout, activity: nil + ) + let q = HKSampleQuery( + sampleType: effortType, + predicate: related, + limit: 1, + sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)] + ) { _, effortSamples, _ in + defer { group.leave() } + guard let q = (effortSamples as? [HKQuantitySample])?.first else { return } + let score = q.quantity.doubleValue(for: HKUnit.appleEffortScore()) + guard score > 0 else { return } + lock.lock() + out.append([ + "workout_id": workout.uuid.uuidString, + "rpe": Int(score.rounded()), + "date": fmt.string(from: workout.startDate), + ]) + lock.unlock() + } + self.store.execute(q) + } + + group.notify(queue: .main) { + NSLog("[CoachHealthRoute] %d score(s) d'effort trouvé(s) sur %d séance(s)", + out.count, workouts.count) + call.resolve(["scores": out]) + } + } + store.execute(workoutQuery) + } + @objc func deleteWorkout(_ call: CAPPluginCall) { guard HKHealthStore.isHealthDataAvailable() else { call.reject("HealthKit unavailable")