Compare commits

...

2 Commits

Author SHA1 Message Date
Sylvain Bettinelli
31467851d9 Séances perdues en silence, et succès journalisés sur des requêtes refusées
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) <noreply@anthropic.com>
2026-09-22 09:20:43 +00:00
Sylvain Bettinelli
2c9af6c684 Chaque pause effaçait jusqu'à 24 points de trace déjà parcourus
setPaused vidait `pendingRoute` pour « ne pas tracer la pause ». Mais ce tampon
ne contient que des points DÉJÀ acceptés par le filtre et DÉJÀ comptés dans
`distanceMeters` : ils attendent seulement leur écriture, qui ne part qu'au
seuil de routeFlushThreshold (25). À 1 Hz, c'est une douzaine de points en
moyenne, jusqu'à 24.

L'écart était donc visible : la distance affichée les incluait, la
HKWorkoutRoute non. Six arrêts sur une sortie VTTAE — feux, photos, ravito — et
jusqu'à 144 points manquaient à la trace, avec un D+ recalculé depuis elle qui
divergeait d'autant de celui affiché pendant la séance. Aucun log ne
mentionnait l'effacement.

Ils sont désormais écrits, via le même `Task { await flushPendingRoute() }` que
le flush au seuil. Le raisonnement du commentaire d'origine visait le point qui
SUIT la reprise, à des centaines de mètres de là — mais celui-là n'est pas
encore dans le tampon quand la pause commence, et c'est `implausibleSpeed` qui
l'écarte. Nettoyer la pause est l'affaire du filtre, pas du tampon d'écriture.

Pas de course possible : `isPaused` est posé avant, et `ingest` sort dès qu'il
est vrai — aucun point neuf ne peut s'ajouter pendant que le flush s'exécute.

Relevé § 2.5 de docs/AUDIT-2026-09-22.md, seul constat de la section vérifié
ligne à ligne avant correction. 88 tests Linux verts (RouteFilter compris), mais
ils ne couvrent pas ce fichier : CoreLocation n'existe pas sur Linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:20:30 +00:00
2 changed files with 91 additions and 20 deletions

View File

@@ -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
}

View File

@@ -142,9 +142,21 @@ final class LocationTracker: NSObject, ObservableObject {
guard isTracking else { return }
isPaused = paused
if paused {
// Le point qui suivra la reprise serait à des centaines de mètres :
// on repart d'une origine neuve pour ne pas tracer la pause.
pendingRoute.removeAll()
// ⚠️ Ici se jouait une perte silencieuse. `pendingRoute` ne contient
// que des points DÉJÀ acceptés par le filtre et DÉJÀ comptés dans
// `distanceMeters` — ils attendent seulement leur écriture, qui ne
// part qu'au seuil de `routeFlushThreshold` (25). À 1 Hz, c'est une
// douzaine de points en moyenne, jusqu'à 24. Les jeter creusait un
// écart visible entre la distance affichée et la trace enregistrée :
// six arrêts sur une sortie, et jusqu'à 144 points manquaient à la
// HKWorkoutRoute, avec un D+ recalculé qui divergeait d'autant.
//
// On les écrit donc au lieu de les perdre. Le raisonnement du
// commentaire d'origine visait le point qui SUIT la reprise, à des
// centaines de mètres de là — mais celui-là n'est pas encore dans
// le tampon, et c'est `implausibleSpeed` qui l'écarte. Nettoyer la
// pause est l'affaire du filtre, pas du tampon d'écriture.
Task { await flushPendingRoute() }
}
}