From 8973a26e478cf24727b72a9881a4180b29ce25c3 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Tue, 22 Sep 2026 19:55:48 +0000 Subject: [PATCH] =?UTF-8?q?Les=20pas=20d'une=20s=C3=A9ance=20sont=20lus=20?= =?UTF-8?q?en=20d=C3=A9pliant=20les=20s=C3=A9ries=20condens=C3=A9es=20par?= =?UTF-8?q?=20HealthKit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple, « Accessing condensed workout samples » : HealthKit fusionne les échantillons d'une séance en séries (count > 1), supprime les originaux, « while still preserving data resolution », et le détail se relit avec HKQuantitySeriesSampleQuery. Depuis watchOS 27 (21/09/2026), les pas d'une course arrivent en 4 à 7 tranches de 5 à 12 min ; le plugin Capgo lit quantity.doubleValue sans regarder count. getStepSamples(startDate, endDate) lit les pas de la fenêtre, déplie chaque série et rend aussi les comptes (raw / series / expanded) : c'est la preuve que le détail existe encore — ou qu'il n'existe plus. Lecture seule. Syntaxe vérifiée (swiftc -parse), build Xcode à faire. Co-Authored-By: Claude Fable 5.1 --- ios/App/App/CoachHealthRoute.swift | 88 ++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/ios/App/App/CoachHealthRoute.swift b/ios/App/App/CoachHealthRoute.swift index aedac4a..64aeda1 100644 --- a/ios/App/App/CoachHealthRoute.swift +++ b/ios/App/App/CoachHealthRoute.swift @@ -28,6 +28,7 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getWorkoutDetails", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getStepSamples", returnType: CAPPluginReturnPromise), ] private lazy var store = HKHealthStore() @@ -396,6 +397,93 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin { store.execute(wq) } + /// Pas d'une fenêtre de temps, séries condensées **dépliées**. + /// + /// Apple, *Accessing condensed workout samples* : « HealthKit enumerates the + /// data associated with a workout and adds that data to one or more + /// quantity series samples. After saving all the data to series objects, + /// HealthKit deletes the originals » — « while still preserving data + /// resolution ». Un échantillon condensé a `count > 1`, et son détail se + /// lit avec `HKQuantitySeriesSampleQuery`. Depuis watchOS 27 (constaté le + /// 21/09/2026), les pas d'une course arrivent en 4 à 7 tranches de 5 à + /// 12 min au lieu de ~850 échantillons de 2,5 s ; le plugin Capgo lit + /// `quantity.doubleValue` sans regarder `count`. Cette méthode dit si le + /// détail existe encore, et le rend s'il existe. Lecture seule. + @objc func getStepSamples(_ call: CAPPluginCall) { + guard HKHealthStore.isHealthDataAvailable(), + let stepType = HKObjectType.quantityType(forIdentifier: .stepCount) else { + call.resolve(["samples": [], "reason": "HealthKit unavailable"]) + return + } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let isoPlain = ISO8601DateFormatter() + func date(_ s: String?) -> Date? { + guard let s = s else { return nil } + return iso.date(from: s) ?? isoPlain.date(from: s) + } + guard let start = date(call.getString("startDate")), + let end = date(call.getString("endDate")), end > start else { + call.reject("startDate et endDate requis (ISO 8601), fin après début") + return + } + + let window = HKQuery.predicateForSamples(withStart: start, end: end, options: []) + let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true) + let query = HKSampleQuery(sampleType: stepType, predicate: window, + limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { [weak self] _, results, error in + guard let self = self else { return } + if let error = error { + call.reject("Step fetch failed: \(error.localizedDescription)") + return + } + let samples = (results as? [HKQuantitySample]) ?? [] + let group = DispatchGroup() + let lock = NSLock() + var rows: [[String: Any]] = [] + var nSeries = 0 + var nExpanded = 0 + + for s in samples { + let src = s.sourceRevision.source + let base: [String: Any] = ["source": src.name, "sourceId": src.bundleIdentifier] + if s.count <= 1 { + var row = base + row["startDate"] = iso.string(from: s.startDate) + row["endDate"] = iso.string(from: s.endDate) + row["qty"] = s.quantity.doubleValue(for: .count()) + lock.lock(); rows.append(row); lock.unlock() + continue + } + nSeries += 1 + group.enter() + // Une série : ses quantités individuelles, chacune avec son + // intervalle. `done` marque le dernier appel du handler. + let inSeries = HKQuery.predicateForObject(with: s.uuid) + let detail = HKQuantitySeriesSampleQuery(quantityType: stepType, + predicate: inSeries) { _, quantity, interval, _, done, err in + if let quantity = quantity, let interval = interval { + var row = base + row["startDate"] = iso.string(from: interval.start) + row["endDate"] = iso.string(from: interval.end) + row["qty"] = quantity.doubleValue(for: .count()) + row["fromSeries"] = true + lock.lock(); rows.append(row); nExpanded += 1; lock.unlock() + } + if done || err != nil { group.leave() } + } + self.store.execute(detail) + } + + group.notify(queue: .global()) { + rows.sort { ($0["startDate"] as? String ?? "") < ($1["startDate"] as? String ?? "") } + NSLog("[CoachHealthRoute] pas : %d échantillons, %d séries, %d dépliés", samples.count, nSeries, nExpanded) + call.resolve(["samples": rows, "raw": samples.count, "series": nSeries, "expanded": nExpanded]) + } + } + store.execute(query) + } + /// Assemble métadonnées + statistiques d'une séance. Les champs absents ne /// sont pas émis : le backend distingue « pas mesuré » de « zéro ». private func details(for workout: HKWorkout, completion: @escaping ([String: Any]) -> Void) {