§ 1.2 de l'audit, vérifié le matin même puis corrigé sur demande explicite de Sylvain — le gel du 22/09 est levé pour ce point seul, les quatre autres constats restent intouchés. Pourquoi celui-là d'abord : 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 autres perdent de la donnée ou de la batterie, ça se rattrape. Une borne de zone périmée au poignet a déjà servi à décider. **L'envoi ne se tait plus.** `pushToWatch` avait quatre sorties silencieuses sur sept lignes ; il en a maintenant six, toutes nommées et journalisé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 du milieu viennent de `CoachRoutineBridge`, qui les avait déjà : sans elles, l'absence de montre se confond avec un échec d'envoi. Le log de succès nomme ce qui compte — `zones=oui depuis=2026-08-12` — pour savoir quelles bornes sont au poignet 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`) plutôt qu'un `reject`, et `ok` conservé — aucun appelant JS ne casse. **La date de version voyage enfin.** `HeartRateZones` ne portait aucune date, alors que le backend sert une version figée et datée (règle 9 du CLAUDE.md web) : elle se perdait au transport, et après une révision rien au poignet ne distinguait les nouvelles bornes des anciennes. `zonesSince` et `zonesFrom` rejoignent le modèle en optionnels. Le serveur les servait déjà — c'est le JS du dépôt web qui ne les faisait pas monter. Trois tests au banc Linux : la date survit à `asOf()`, elle survit à l'aller-retour JSON (ce qui n'est pas encodé n'arrive jamais), 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. 91 verts (88 avant). ⛔ NON COMPILÉ. Syntaxe vérifiée (`swiftc -parse`), mais Capacitor, WatchConnectivity et SwiftUI n'existent pas sur Linux. Au prochain build : ouvrir la Console sur la catégorie `widget` et vérifier qu'une ligne « snapshot poussé vers la Watch · zones=oui depuis=… » apparaît au lancement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
316 lines
14 KiB
Markdown
316 lines
14 KiB
Markdown
# 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 ? »). Le correctif
|
||
> est décrit en fin de document, § « Ce qui a été corrigé ». **Les quatre autres
|
||
> constats restent ouverts, et le gel tient pour eux.**
|
||
>
|
||
> ⛔ **Rien n'est réel tant que le build Mac n'a pas tourné.** Syntaxe vérifiée
|
||
> (`swiftc -parse`) et 91 tests du banc Linux verts — mais ni `CoachWidgetBridge`
|
||
> ni `HeartRateZones` ne se compilent hors Xcode : Capacitor, WatchConnectivity
|
||
> et SwiftUI sont absents de Swift pour Linux.
|
||
|
||
**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.
|
||
- **Il n'a pas été compilé.** Le geste qui vaut preuve, au prochain build :
|
||
ouvrir la Console sur la catégorie `widget` et vérifier qu'une ligne
|
||
`snapshot poussé vers la Watch · zones=oui depuis=…` apparaît au lancement.
|