Files
coach-ios/docs/DIAGNOSTIC-NATIF-2026-09-24.md
Sylvain Bettinelli 3b5b61268c Les quatre constats restants, corrigés — dont deux sur le chemin qu'il fallait éviter
Sur demande explicite de Sylvain (« pour le reste vas-y »), après le § 1.2 buildé
et vérifié le matin. Les cinq constats de l'audit sont désormais traités.

⚠️ § 2.3 et § 2.4 touchent `WorkoutManager.swift` — la fin de séance du live
workout, que la consigne du 22/09 demandait de ne pas approcher. Fait en ne
changeant AUCUNE logique : les `try?` deviennent des `do/catch` qui journalisent,
et le chemin d'échec gagne la clôture qui lui manquait. La récupération par
`recentlySavedWorkout()` est intacte.

§ 1.3 — `CoachSleep` teste `authorizationStatus` : `.notDetermined` ne se
confond plus avec une nuit sans sommeil, et le serveur cessera d'enregistrer des
nuits vides sur lesquelles `sleep_score.py` calculait une régularité. Un refus
EXPLICITE reste indiscernable d'une absence de données — HealthKit ne le révèle
pas en lecture, par conception. L'en-tête du fichier dit maintenant ce que la
promesse couvre au lieu de promettre plus.

§ 2.3 — le `else` final distinguait deux situations opposées qui y tombaient
ensemble : montre verrouillée (la séance EST écrite) et `endCollection` qui a
levé (rien n'est écrit). Seule la seconde est une perte, et elle seule renseigne
`statusMessage` — annoncer une perte sur une montre verrouillée serait une
fausse alerte.

§ 2.4 — `stopTracking()` d'abord : c'est le seul geste qui réussit à coup sûr et
le seul dont l'omission se paie en batterie pendant des heures. Puis une
tentative de sauvegarde en best-effort, avec `recentlySavedWorkout()` en second
recours. `builder = nil` n'intervient plus qu'après les tentatives — il
détruisait jusqu'ici la seule référence, rendant la perte définitive dans
l'instruction qui l'ignorait.

§ 2.6 — `NSLock` sur le tampon GPS, comme aux quatre autres endroits du même
fichier. ⚠️ Invérifiable sans son déclencheur : un workout à plusieurs
HKWorkoutRoute. Sa correction ne se constatera pas plus que le défaut ne se
manifestait.

⛔ NON COMPILÉS. Le test qui comptera pour § 2.3 et § 2.4 n'est pas un log :
c'est une séance réelle menée jusqu'au bout, puis retrouvée dans Santé et dans
coach. Tant qu'elle n'a pas eu lieu, ce sont des intentions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 08:27:17 +00:00

405 lines
19 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Diagnostic natif — les cinq constats ouverts de l'audit, vérifiés
> ## ⚠️ § 1.2 A ÉTÉ CORRIGÉ le 24/09, après ce diagnostic
>
> Sylvain a levé le gel sur ce point précis (« tu attends quoi ? »), puis sur
> **les quatre autres** (« pour le reste vas-y »). Les cinq sont donc corrigés :
> § 1.2 buildé et vérifié sur l'appareil, § 1.3 / 2.3 / 2.4 / 2.6 écrits et
> vérifiés en syntaxe, **pas encore compilés**.
>
> ⚠️ **§ 2.3 et § 2.4 touchent `WorkoutManager.swift`**, c'est-à-dire le chemin
> de fin de séance du live workout — ce que la consigne du 22/09 demandait de ne
> pas approcher. Fait sur demande explicite, en ne changeant aucune logique :
> les `try?` deviennent des `do/catch` qui journalisent, et le chemin d'échec
> gagne la clôture qui lui manquait. **À éprouver en séance réelle avant de
> considérer que ça tient.**
>
> ✅ **BUILD FAIT ET VÉRIFIÉ SUR L'APPAREIL, le 24/09.** Le diagnostic de
> `/settings` (« Diagnostiquer et pousser ») affiche sur l'iPhone de Sylvain :
>
> ```
> Montre : snapshot reçu
> Zones envoyées : version frozen du 2026-08-12
> ```
>
> Les quatre causes d'échec silencieux sont donc écartées : session activée,
> montre appairée, app Watch installée, `updateApplicationContext` sans erreur.
> Et la date de version arrive au poignet — elle se perdait au transport.
>
> ⚠️ **Ce que cela ne prouve pas** : `updateApplicationContext` est asynchrone.
> iOS a *accepté* le contexte, il le livre quand il peut. Ce qui est établi,
> c'est que l'iPhone a réussi à le transmettre au système — pas que la montre
> affiche les bonnes bornes. Seul le poignet le dira, pendant une séance.
**24/09/2026. Diagnostic mené en lecture seule** — aucune ligne de Swift n'avait
été modifiée à sa rédaction.
Demandé par Sylvain après l'audit du 22/09, dont cinq constats étaient marqués
**[rapporté]** — issus de revues automatisées, jamais reconfrontés au code.
L'audit lui-même le disait : « les confirmer avant d'agir ».
Ce document dit ce que le code fait **aujourd'hui**, ce que l'audit décrivait
mal, et ce qu'il faudra corriger le jour où le gel sur le natif sera levé. Il ne
propose aucun geste : la correction demande un build Mac, et rien n'est réel
tant que ce build n'a pas tourné.
⚠️ **Les numéros de ligne de l'audit ne valent plus.** Les correctifs du 22/09
au soir ont décalé `WorkoutManager.swift` et `CoachHealthRoute.swift`. Les
références ci-dessous sont celles du dépôt à `6031be8`.
---
## Verdict d'ensemble
**Les cinq sont confirmés.** Aucun faux positif. Deux sont plus graves que
l'audit ne le disait, un est plus nuancé.
| # | Constat | Vérifié | Écart avec l'audit |
|---|---|---|---|
| 1.2 | Zones cardiaques muettes vers la montre | ✅ | **Aggravé** : 4 sorties muettes, pas 1 |
| 1.3 | « Refusé » ≡ « aucune donnée » (sommeil) | ✅ | Exact |
| 2.3 | Séance perdue sans log | ✅ | **Nuancé** : `nil` n'est pas toujours une perte |
| 2.4 | `didFailWithError` laisse le GPS allumé | ✅ | **Aggravé** : `builder = nil` rend la récupération impossible |
| 2.6 | Tampon GPS sans verrou | ✅ | Exact |
---
## 1.2 — Les zones cardiaques peuvent ne jamais atteindre la montre
**`ios/App/App/CoachWidgetBridge.swift:175-183`** (`pushToWatch`)
L'audit ne signalait que le `try?`. Il y a **quatre** sorties silencieuses sur
sept lignes :
```swift
guard WCSession.isSupported() else { return } // 1. muet
let session = WCSession.default
guard session.activationState == .activated, // 2. muet
let data = try? JSONEncoder().encode(snapshot) else { return } // 3. muet
try? session.updateApplicationContext(["widgetSnapshot": data]) // 4. avalé
```
Aucun log, aucune remontée au JS. Le snapshot porte `zones: HeartRateZones?`
(`CoachWidgetSnapshot.swift:96`) — les bornes Z1…Z5 et `clinicalTargetBpm`, la
cible sous bêtabloquant.
**Ce que fait le chemin voisin, dans le même dépôt**
(`CoachRoutineBridge.swift:87-101`) pour la routine de gainage : il vérifie
`isPaired` **et** `isWatchAppInstalled` avec une raison explicite, entoure
l'appel d'un `do/catch`, journalise l'échec et appelle `call.reject`.
Le chemin instrumenté porte des exercices de mobilité. Le chemin muet porte les
bornes cardiaques d'un patient post-infarctus.
### L'aggravant est réel, et il se compose avec le premier
`HeartRateZones` (`ios/App/App/HeartRateZones.swift`) ne porte **aucune date** :
ni `version`, ni `validFrom`, ni `computedAt`. Or le backend sert une version
**figée et datée** (`data/hr_zones_history.json`, via `app.current_zones()`) —
c'est même une règle non négociable du projet web (règle 9 du CLAUDE.md).
**La date existe côté serveur et se perd au transport.**
Et `CoachWidgetSnapshot.asOf(_:)` périme l'eau, le café, les kcal, la routine,
l'activité du jour et le score de forme — mais **pas `zones`**, délibérément
(« version figée, valable des semaines »). Le raisonnement est bon pour une
péremption journalière ; il laisse ouvert le cas d'une **révision** des bornes.
Conséquence composée : après un `POST /api/cardiac-zones/confirm`, si le push
échoue silencieusement, la montre affiche les anciennes bornes **sans limite de
temps et sans que rien ne le signale**, ni au poignet ni à l'iPhone.
**À corriger, le jour venu** : aligner `pushToWatch` sur `CoachRoutineBridge`
(do/catch, log, `isPaired`/`isWatchAppInstalled`), et faire voyager la date de
version des zones pour que la montre puisse dire « bornes du 12/08 » plutôt que
d'afficher un chiffre sans âge.
---
## 1.3 — « Accès refusé » et « aucune donnée » sont rendus identiques
**`ios/App/App/CoachSleep.swift`** — en-tête ligne 30 :
> `// r.available = false si HealthKit est indisponible ou non autorisé`
Le code teste `HKHealthStore.isHealthDataAvailable()` (l. 59, 63) et la
disponibilité du type (l. 68). **`authorizationStatus(for:)` n'apparaît nulle
part dans le fichier.** La requête aboutit et rend :
```swift
call.resolve(["available": true, "samples": out, ...]) // l. 92-96
```
La documentation du fichier promet donc quelque chose que le code ne fait pas.
**La nuance de l'audit est juste** : HealthKit ne distingue pas volontairement
« refusé » de « vide » **en lecture** — c'est une protection de vie privée
documentée par Apple. Mais `authorizationStatus(for:)` renvoie `.notDetermined`
de façon fiable, et c'est le cas fréquent : réinstallation de l'app,
autorisation jamais accordée. C'est celui-là qu'on peut distinguer.
**Conséquence côté serveur** : `sleep_score.py` calcule une régularité de
coucher sur des nuits absentes, sans savoir qu'elles sont absentes parce qu'on
ne nous laisse plus regarder. Une valeur fausse, pas une valeur manquante.
Même famille dans `CoachHealthRoute.swift` (l. 173 et 277 selon l'audit — à
revérifier, les lignes ont bougé).
---
## 2.3 — Une séance peut disparaître sans un seul log
**`ios/App/CoachWatch/WorkoutManager.swift:383-398`**
```swift
LocationTracker.shared.stopTracking()
try? await builder.endCollection(at: date)
let workout = try? await builder.finishWorkout()
if let workout { … } else if let recovered = await self.recentlySavedWorkout() { … }
else { LocationTracker.shared.discardRoute() }
```
**Ce que l'audit ne disait pas, et qui change la lecture** : le commentaire du
code explique que `finishWorkout()` rend `nil` **sans erreur** quand la montre
est verrouillée, et que dans ce cas la séance **est** écrite dans HealthKit —
seul l'objet manque. C'est pour ça que `recentlySavedWorkout()` existe. Un `nil`
n'est donc pas une perte en soi, et le code le sait.
**Le défaut est plus précis que « la séance est perdue » :** le double `try?`
efface la différence entre deux situations opposées —
| Ce qui s'est passé | `workout` | La séance est-elle sauvée ? |
|---|---|---|
| Montre verrouillée à l'arrêt | `nil` | **Oui**, `recentlySavedWorkout()` la retrouve |
| `endCollection` a levé | `nil` | **Non**, il n'y a rien à retrouver |
Les deux chemins convergent vers le même `else`, et `discardRoute()` ne
journalise que la perte de la **trace**. `statusMessage` n'est pas touché :
après deux heures de sortie, l'utilisateur voit l'écran de fin habituel.
**À corriger** : remplacer les deux `try?` par un `do/catch` qui journalise
l'erreur — sans changer la logique de récupération, qui est bonne.
---
## 2.4 — `didFailWithError` ne clôt rien et laisse le GPS allumé
**`ios/App/CoachWatch/WorkoutManager.swift:451-464`**
```swift
workoutLog.error("session didFailWithError domain=… code=…: \(message)")
Task { @MainActor in
self.statusMessage = "Erreur séance : \(message)"
self.isRunning = false
self.session = nil
self.builder = nil
ConnectivityManager.shared.sendEnded(reason: "session-failed")
}
```
Comparé au chemin `.ended` (l. 383-398), il manque **tout** : `stopTracking()`,
`endCollection`, `finishWorkout`, `finishRoute`/`discardRoute`.
**Plus grave que l'audit ne le dit** : `self.builder = nil` détruit la seule
référence au builder. Après cette ligne, plus aucun code ne peut tenter de
sauver ce qu'il avait collecté — la perte devient définitive dans la même
instruction qui l'ignore.
Deux conséquences distinctes :
1. **Le GPS continue de tourner.** `LocationTracker` n'est jamais arrêté, alors
que la séance est finie du point de vue de l'interface. Sur une montre, ça se
paie en batterie, et rien ne l'indique.
2. **Les données collectées sont jetées sans tentative de sauvegarde.**
⚠️ À la différence de § 2.3, ici `statusMessage` **est** renseigné : l'utilisateur
voit « Erreur séance ». Il sait qu'il s'est passé quelque chose ; il ne sait pas
qu'il a perdu la séance, et le GPS tourne toujours.
---
## 2.6 — Accès concurrent non protégé sur le tampon GPS
**`ios/App/App/CoachHealthRoute.swift:183-205`** (`collectLocations`)
```swift
var all: [CLLocation] = []
let group = DispatchGroup()
var pluginErr: Error?
for route in routes {
group.enter()
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
if let err = err { pluginErr = err; group.leave(); return }
if let locs = locations { all.append(contentsOf: locs) } // ⚠️ sans verrou
…
```
`all` et `pluginErr` sont mutés depuis les callbacks de N requêtes, livrées sur
des files HealthKit indépendantes. **Aucun verrou.**
**Le même fichier utilise `NSLock` pour ce motif exact à quatre endroits** :
lignes 283, 380, 442 et 565. Ce n'est donc pas une convention absente du
projet — c'est une omission à un seul endroit.
**Déclencheur** : un workout portant plusieurs `HKWorkoutRoute` (multisport, ou
reprise après interruption). Avec une seule route, la boucle n'a qu'une
itération et le défaut est inobservable.
⚠️ **On ne sait pas s'il s'est déjà produit** : les routes vivent dans
HealthKit, pas sur le serveur, et rien ne journalise `routes.count`. Une
corruption de tableau en Swift ne lève pas forcément — elle peut donner une
trace tronquée ou un crash, sans rien qui désigne la cause.
---
## Ce que ce diagnostic ne dit pas
- **Aucune de ces corrections n'a été écrite.** Le gel du 22/09 sur le natif
tient ; Sylvain a autorisé la lecture, pas la modification.
- **Rien n'est vérifiable sans build Mac.** Ces cinq constats sont établis par
lecture du code, pas par exécution. Une lecture peut manquer un chemin.
- **La dette assumée du 22/09 reste ouverte** : le `defer { completion() }` de
`CoachWorkoutObserver` déclare la livraison traitée alors que les requêtes
sont encore en vol, sans `beginBackgroundTask`. Elle n'est pas revue ici, elle
était déjà documentée et assumée.
- **Le § 4 de l'audit (gravité 3, justesse et cohérence) n'a pas été revérifié.**
## Si un seul devait être corrigé
**§ 1.2**, et pas parce qu'il est le plus spectaculaire : parce que c'est le seul
dont la conséquence est un **chiffre faux affiché pendant l'effort**, chez
quelqu'un qui règle son intensité dessus. Les quatre autres perdent de la donnée
ou de la batterie — c'est réparable après coup. Une borne de zone périmée au
poignet ne se rattrape pas : elle a déjà servi à décider.
---
## Ce qui a été corrigé (§ 1.2, le 24/09 après-midi)
Trois couches, parce que le défaut en traversait trois.
### 1. L'envoi ne se tait plus — `CoachWidgetBridge.swift`
`pushToWatch` rend désormais la **raison** de son échec, ou `nil` s'il a réussi,
et journalise dans `Logger(subsystem: "ch.hypnotruck.coach", category: "widget")`.
Les quatre sorties muettes deviennent **six sorties nommées** :
WatchConnectivity non supportée · session non activée · **aucune montre
appairée** · **app Watch non installée** · encodage impossible · envoi refusé.
Les deux gardes en gras viennent de `CoachRoutineBridge` : sans elles, l'absence
de montre se confondait avec un échec d'envoi.
Le log de succès nomme ce qui compte : `zones=oui depuis=2026-08-12`. Savoir
quelles bornes sont au poignet, et depuis quand, sans ouvrir la montre.
⚠️ **Un échec ne fait PAS échouer `setSnapshot`**, contrairement à
`CoachRoutineBridge` où l'envoi *est* l'opération. Ici l'App Group est déjà écrit
et les widgets iPhone rafraîchis : une montre verrouillée n'invalide rien. D'où
un `resolve` enrichi (`watchPushed`, `watchReason`) et non un `reject` — `ok`
est conservé, aucun appelant JS existant ne casse.
### 2. La date de version voyage — `HeartRateZones.swift` + `widget-bridge.js`
`zonesSince` (date de la version figée) et `zonesFrom` (`"frozen"` ou `"live"`)
rejoignent le modèle, en optionnels — un snapshot écrit avant reste décodable.
**Le serveur les servait déjà** : `/api/cardiac-zones` rend `zones_since` et
`zones_from` depuis `hr_zones_history.effective()`. Rien à changer côté backend ;
c'est `widget-bridge.js` qui ne les faisait pas monter dans le snapshot. Deux
lignes.
La montre peut donc maintenant distinguer des bornes **validées** de bornes
**provisoires**, et dire leur âge. Ce qu'elle en affiche reste à décider — c'est
de l'UI watchOS, hors de ce correctif.
### 3. Le garde-fou — `tests-linux/`
Trois tests dans `DayRolloverTests` : la date survit au passage de minuit
(`asOf`), elle survit à l'aller-retour JSON (ce qui n'est pas encodé n'arrive
pas au poignet), et un snapshot **antérieur** à ces champs se décode toujours —
un décodage qui lèverait laisserait la montre sans zones du tout, pire que des
zones sans date. La doublure `HeartRateZonesStub.swift` est tenue à l'identique.
91 tests verts (88 avant).
### Ce que ce correctif ne fait pas
- **Il ne prouve pas que le push fonctionne.** Il rend son échec *visible*. Si
l'envoi échouait pour une raison qu'on ignore, on le saura au prochain build —
c'était impossible jusqu'ici.
- **Il ne périme pas les zones.** Après une révision, la montre affichera
toujours les anciennes bornes tant qu'un push n'a pas réussi ; elle saura
seulement les dater. Décider qu'une borne de plus de N jours n'est plus
affichable est un choix produit, pas une correction de défaut.
- **Il ne touche pas au flux course à pied** (WorkoutKit, live workout FC, app
Exercice), conformément à la consigne du 22/09.
- ✅ **Compilé et vérifié le 24/09** — sans Console : le diagnostic de
`/settings` rend le statut en clair (`Montre : snapshot reçu` · `Zones
envoyées : version frozen du 2026-08-12`), parce que le plugin remonte
désormais `watchPushed` / `watchReason` jusqu'au JS.
⏳ **Reste à voir au poignet** : que la montre affiche ces bornes pendant une
séance. L'envoi est accepté par iOS ; la livraison est asynchrone.
---
## Les quatre autres, corrigés le 24/09 (« pour le reste vas-y »)
⛔ **Aucun n'est compilé.** Syntaxe vérifiée (`swiftc -parse`), 91 tests du banc
Linux verts — mais ces fichiers importent HealthKit, WatchKit et Capacitor,
absents de Swift pour Linux. Le banc ne les couvre pas et ne peut pas les
couvrir.
### § 1.3 — `.notDetermined` n'est plus confondu avec une nuit vide
`CoachSleep.readSamples` teste désormais `authorizationStatus(for: sleepType)`
et rend `{available: false, reason: "authorization not determined"}`.
⚠️ **Ce qui n'est PAS corrigé, et ne peut pas l'être** : un refus *explicite*
reste indiscernable d'une absence de données. HealthKit ne le révèle pas en
lecture, par conception — c'est une protection de vie privée, pas un oubli
d'Apple. Seul `.notDetermined` est fiable, et c'est le cas fréquent
(réinstallation, permission jamais demandée). L'en-tête du fichier dit
maintenant exactement ce que la promesse couvre, au lieu de promettre plus.
### § 2.3 — « séance sauvée » et « rien de sauvé » ne se confondent plus
Les deux `try?` deviennent des `do/catch`. **La logique de récupération n'a pas
changé** — `recentlySavedWorkout()` reste le repli quand `finishWorkout()` rend
`nil` sur montre verrouillée. Ce qui change : un `endCollection` qui lève est
journalisé, et le `else` final distingue les deux cas, dont un seul est une
perte. Dans ce cas seulement, `statusMessage` le dit à l'écran — une montre
verrouillée n'est pas une perte, et l'annoncer serait une fausse alerte.
### § 2.4 — le GPS s'arrête, et la séance est sauvée si elle peut l'être
`didFailWithError` commence par `stopTracking()` : c'est le seul geste qui
réussit à coup sûr, et le seul dont l'omission se paie en batterie pendant des
heures. Puis une tentative de clôture en best-effort (`endCollection`,
`finishWorkout`), avec `recentlySavedWorkout()` en second recours — une session
qui a échoué refusera souvent de clore, mais ne pas essayer garantissait la
perte. `builder = nil` n'intervient plus qu'à la fin, après les tentatives.
Trois issues, trois messages distincts : « Séance interrompue, mais
enregistrée », « Séance perdue : … », ou le message d'erreur d'origine.
### § 2.6 — le tampon GPS est verrouillé
`collectLocations` protège `all` et `pluginErr` par un `NSLock`, comme les
quatre autres endroits du même fichier le faisaient déjà.
⚠️ **Invérifiable sans le déclencheur** : il faut un workout portant plusieurs
`HKWorkoutRoute` (multisport, reprise après interruption). Avec une seule route,
la boucle n'a qu'une itération — c'est pourquoi le défaut a pu vivre longtemps
sans se manifester, et pourquoi sa correction ne se constatera pas non plus.
### Ce qu'il faut regarder au prochain build
| Quoi | Où | Ce qui prouve |
|---|---|---|
| § 1.3 | `/settings` → synchro santé | Retirer « Sommeil » dans Réglages → Santé, réinstaller : la synchro doit dire « non autorisé », pas enregistrer des nuits vides |
| § 2.3 | Console, catégorie `workout` | Une séance normale ne doit RIEN journaliser de neuf. Les nouveaux logs ne sortent qu'en cas d'échec |
| § 2.4 | Batterie, après une séance interrompue | Le GPS ne doit plus tourner une fois l'écran de fin affiché |
| § 2.6 | — | Rien à voir : le correctif supprime une corruption qui ne se manifestait pas de façon lisible |
⚠️ **§ 2.3 et § 2.4 sont sur le chemin de fin de séance du live workout.** Le
test qui compte n'est pas un log, c'est **une séance réelle menée jusqu'au bout,
puis retrouvée dans Santé et dans coach**. Tant qu'elle n'a pas eu lieu, ces
deux correctifs sont des intentions.