From 31467851d9cf4edacb9e96eb75626ae99032e29c Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Tue, 22 Sep 2026 09:20:43 +0000 Subject: [PATCH] =?UTF-8?q?S=C3=A9ances=20perdues=20en=20silence,=20et=20s?= =?UTF-8?q?ucc=C3=A8s=20journalis=C3=A9s=20sur=20des=20requ=C3=AAtes=20ref?= =?UTF-8?q?us=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois défauts du même chemin, tous vérifiés ligne à ligne avant correction. 1. Toutes les séances d'un lot sauf une étaient perdues DÉFINITIVEMENT. L'ancre est sauvegardée ligne 298, puis seul `latest` était traité ligne 319 — or scheduleNotification était le seul appelant de reportToRoutineIfStrength et reportPlanOrigin. Renfo le matin, vélo le soir, iPhone resté à la maison : au premier fire la requête ancrée rend les deux, le vélo est notifié, le renfo n'est jamais POSTé, et l'ancre ayant avancé il ne reviendra jamais. Le log disait « 2 new workout(s), notifying latest » : exact, et parfaitement muet sur ce qu'il venait de jeter. Les rapports serveur bouclent désormais sur tout le lot ; la notification reste unique, un « 🎉 » par séance serait du bruit. 2. Le statut HTTP n'était jamais lu. Les deux POST faisaient `{ data, _, error in }` et ne regardaient que `error`. Un 401 sur token rotaté, un 404 ou un 500 n'en produisent pas : reportPlanOrigin journalisait donc « plan-origin envoyé » sur une requête refusée, et auto-complete sortait en silence au guard de parsing. Un helper `envoyer(_:libelle:traiter:)` vérifie maintenant (200..<300) et journalise le code — le pattern existait déjà dans CoachRoutineBridge.postDay, à deux fichiers de là. 3. Une séance de musculation classique était étiquetée fonctionnelle. L'activité était écrite en dur alors que le garde accepte aussi traditionalStrengthTraining. Si la règle de correspondance côté serveur discrimine sur ce champ, l'appariement était faussé. L'UUID et l'heure de début sont ajoutés au passage : sans eux le serveur ne peut pas dédupliquer et doit évaluer sa fenêtre de 12 h depuis l'heure de réception — une séance remontée avec six heures de retard était datée de son arrivée. ⚠️ PAS corrigé, et assumé : le `defer { completion() }` dit à iOS que la livraison est traitée alors que les requêtes sont encore en vol, sans beginBackgroundTask. Le faire proprement demande un import UIKit, une bascule sur le fil principal et un essai sur appareil — à traiter à part plutôt qu'en marge. Le statut HTTP étant désormais lu, un échec laissera au moins une trace. Relevé § 2.1, § 2.2 et § 3.2 de docs/AUDIT-2026-09-22.md. Co-Authored-By: Claude Opus 5 (1M context) --- ios/App/App/CoachWorkoutObserver.swift | 93 +++++++++++++++++++++----- 1 file changed, 76 insertions(+), 17 deletions(-) diff --git a/ios/App/App/CoachWorkoutObserver.swift b/ios/App/App/CoachWorkoutObserver.swift index c1d2be4..a8126c9 100644 --- a/ios/App/App/CoachWorkoutObserver.swift +++ b/ios/App/App/CoachWorkoutObserver.swift @@ -310,12 +310,29 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin { NSLog("[CoachWorkoutObserver] anchored fired but 0 new workouts") return } - // Notifier le plus récent (par endDate) — généralement il y - // en a 1 mais Apple peut grouper plusieurs samples. + // ⚠️ Les rapports serveur partent pour TOUTES les séances du lot. + // Ils n'étaient envoyés que pour `latest`, alors que l'ancre venait + // d'être avancée quelques lignes plus haut : les autres séances du + // lot n'étaient donc jamais rapportées, et ne reviendraient jamais. + // + // Le cas se produit dès que l'iPhone a manqué un réveil : renfo le + // matin, vélo le soir, téléphone resté à la maison — au premier + // fire, la requête ancrée rend les deux, le vélo est notifié, et la + // routine du matin n'est jamais cochée. Le log disait « 2 new + // workout(s), notifying latest » : exact, et parfaitement muet sur + // ce qu'il venait de jeter. + for seance in workouts { + self.reportToRoutineIfStrength(seance) + self.reportPlanOrigin(seance) + } + + // Une seule notification en revanche : un « 🎉 » par séance d'un + // même lot serait du bruit. On garde la plus récente. let latest = workouts.max(by: { $0.endDate < $1.endDate })! let uuid = latest.uuid.uuidString UserDefaults.standard.set(uuid, forKey: self.lastNotifiedKey) - NSLog("[CoachWorkoutObserver] %d new workout(s), notifying latest %@", workouts.count, uuid) + NSLog("[CoachWorkoutObserver] %d nouvelle(s) séance(s) rapportée(s), notification pour %@", + workouts.count, uuid) self.scheduleNotification(for: latest) } healthStore.execute(query) @@ -342,6 +359,37 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin { } } + /// Envoie une requête et **lit le statut HTTP**. + /// + /// ⚠️ Les deux POST de ce fichier jetaient la réponse (`{ data, _, error in }`) + /// et ne regardaient que `error`. Or un 401 sur token rotaté, un 404 ou un + /// 500 ne produisent PAS d'`error` : `reportPlanOrigin` journalisait donc + /// « plan-origin envoyé » sur une requête refusée, et `auto-complete` + /// sortait en silence au `guard` de parsing. Le bon pattern existait déjà + /// deux fichiers plus loin — `CoachRoutineBridge.postDay` vérifie + /// `(200..<300)` et journalise le code. + /// + /// `traiter` ne reçoit le corps que sur un statut de succès. + private func envoyer(_ req: URLRequest, libelle: String, + traiter: ((Data) -> Void)? = nil) { + URLSession.shared.dataTask(with: req) { data, reponse, error in + if let error = error { + NSLog("[CoachWorkoutObserver] %@ : %@", libelle, error.localizedDescription) + return + } + guard let http = reponse as? HTTPURLResponse else { + NSLog("[CoachWorkoutObserver] %@ : réponse sans statut HTTP", libelle) + return + } + guard (200..<300).contains(http.statusCode) else { + NSLog("[CoachWorkoutObserver] %@ : HTTP %d — non pris en compte", + libelle, http.statusCode) + return + } + traiter?(data ?? Data()) + }.resume() + } + /// Remonte au serveur la fin d'une séance de renfo, qui décide seul si elle /// correspond à une routine poussée dans l'app Exercice. /// @@ -359,19 +407,31 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin { req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie") + // ⚠️ L'activité était écrite en dur à « functionalStrengthTraining » + // alors que le garde ci-dessus accepte AUSSI traditionalStrengthTraining : + // une séance de musculation classique arrivait au serveur étiquetée + // fonctionnelle. Si la règle de correspondance discrimine sur ce champ + // — et le commentaire ci-dessus dit que les règles vivent là-bas —, + // l'appariement était faussé. + // + // L'UUID et l'heure de début manquaient aussi : sans eux le serveur ne + // peut pas dédupliquer, et il doit évaluer sa fenêtre de 12 h depuis + // l'heure de RÉCEPTION. Une séance remontée avec six heures de retard + // (iPhone resté à la maison) était donc datée de son arrivée. req.httpBody = try? JSONSerialization.data(withJSONObject: [ - "activity": "functionalStrengthTraining", + "activity": activity == .traditionalStrengthTraining + ? "traditionalStrengthTraining" : "functionalStrengthTraining", "duration_min": workout.duration / 60, + "uuid": workout.uuid.uuidString, + "start": ISO8601DateFormatter().string(from: workout.startDate), ]) req.timeoutInterval = 15 - URLSession.shared.dataTask(with: req) { data, _, error in - if let error = error { - NSLog("[CoachWorkoutObserver] auto-complete routine: %@", error.localizedDescription) + envoyer(req, libelle: "auto-complete routine") { data in + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + NSLog("[CoachWorkoutObserver] auto-complete : réponse illisible") return } - guard let data = data, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } if (json["matched"] as? Bool) == true { let n = (json["done"] as? [String])?.count ?? 0 NSLog("[CoachWorkoutObserver] routine '%@' cochée automatiquement (%d exercices)", @@ -379,7 +439,7 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin { } else { NSLog("[CoachWorkoutObserver] pas une routine (%@)", (json["reason"] as? String) ?? "?") } - }.resume() + } } /// Dit au serveur si la séance vient d'une **séance programmée** ou d'un @@ -428,13 +488,12 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin { "plan_name": planName, ]) req.timeoutInterval = 15 - URLSession.shared.dataTask(with: req) { _, _, error in - if let error = error { - NSLog("[CoachWorkoutObserver] plan-origin: %@", error.localizedDescription) - } else { - NSLog("[CoachWorkoutObserver] plan-origin envoyé (from_plan=%@)", fromPlan ? "true" : "false") - } - }.resume() + // Le log de succès ne part plus que sur un 2xx : il s'écrivait + // auparavant sur un 401 ou un 500, puisque seul `error` était lu. + self.envoyer(req, libelle: "plan-origin") { _ in + NSLog("[CoachWorkoutObserver] plan-origin accepté (from_plan=%@)", + fromPlan ? "true" : "false") + } } #endif }