feat(healthkit): lit le score d'effort saisi sur l'Apple Watch

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 <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-03 21:12:53 +00:00
parent f032ee2126
commit 3e3351f3ca

View File

@@ -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<HKSampleType> = [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")