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 }