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>
`didCollectDataOf` se déclenche dès qu'UN type est collecté. Quand le capteur
optique décroche — bracelet desserré, froid, arythmie, et la FA thermoablée est
précisément le terrain à risque — les calories continuent d'arriver et `hr`
vaut nil. La ligne `if let hr { heartRate = hr }` gardait alors la dernière
valeur connue, et `sendSample` la renvoyait à l'iPhone avec un `ts` NEUF.
Conséquence en trois étages : au poignet, une FC morte s'affichait stable avec
sa zone colorée, indéfiniment ; côté iPhone, `LiveStore` posait
`lastSampleAt = Date()` et `status = .live`, donc son watchdog — qui ne regarde
que l'arrivée de samples — ne pouvait JAMAIS la déclarer perdue ; et aucun log
ne le signalait. De quoi décider de tenir une pente sur un chiffre qui
n'existait plus.
La FC porte désormais la date de sa dernière mesure réelle. `freshHeartRate`
rend 0 au-delà de `heartRateStaleAfter`, et c'est lui qui est affiché et émis.
0 est la convention « inconnu » déjà partagée par toute la chaîne :
`HeartRateMetric` cesse d'afficher une zone, `heartRateText` rend « — » côté
iPhone. Rien à changer dans LiveStore, qui reste à `.live` à juste titre — la
connexion est vivante, c'est la mesure qui manque, et les deux méritent d'être
distingués.
Le seuil de 10 s est un choix d'ingénierie, pas une donnée physiologique :
Apple ne publie aucune cadence garantie d'échantillonnage en séance. Il est
posé au-dessus du `lostThreshold` de 8 s de LiveStore pour que la FC ne se
périme jamais AVANT que la connexion soit déclarée perdue — deux signaux
contradictoires seraient illisibles au poignet. Commenté comme tel, avec la
consigne de l'augmenter si « — » clignote plutôt que de retirer la péremption.
Trouvé au passage : `WorkoutManager` est un singleton et ne remettait RIEN à
zéro entre deux séances. La FC, les calories et la distance de la séance
précédente restaient affichées jusqu'au premier échantillon de la suivante.
`resetMetrics()` est appelé au démarrage, séance réelle comme simulation.
Limite assumée et commentée : la péremption est évaluée dans `ingest`, donc
elle suppose qu'un échantillon d'un type quelconque continue d'arriver — le cas
visé. Si plus rien n'arrive, l'écran entier se fige, chronomètre compris, ce
qui se voit ; un timer dédié coûterait de la batterie pour un cas déjà visible.
⚠️ Vérifié ici par `swiftc -parse` (syntaxe) et les 88 tests Linux, qui ne
couvrent PAS ce fichier : WatchKit et HealthKit n'existent pas sur Linux. Reste
à compiler et à essayer sur la montre — notamment que « — » n'apparaisse pas en
usage normal.
Relevé § 1.1 de docs/AUDIT-2026-09-22.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Erreur de compilation remontée du Mac :
`Value of optional type 'HKWorkout?' must be unwrapped`.
Le fichier promettait quelque chose d'impossible. Vérifié à la source ce jour
(DocC HealthKit) : la signature est `finishRoute(with workout: HKWorkout,
metadata:)` — non optionnelle — et Apple précise « You must have already saved
this workout to the HealthKit store ». Il n'existe aucune API pour clore une
route orpheline. Le commentaire qui annonçait « la trace sera sauvegardée sans
association plutôt que perdue » décrivait un comportement inatteignable.
Le vrai recours tient à ce que dit le bug d'Apple : quand la montre est
verrouillée, `finishWorkout()` rend `nil` mais la séance EST écrite dans
HealthKit — seul l'objet manque. `WorkoutManager` va donc la rechercher :
dernier workout écrit par CETTE app (HKSource.default(), sinon une séance de
l'app Exercice pourrait récupérer notre trace), croisant les 5 dernières
minutes.
⚠️ La fenêtre porte sur le chevauchement, pas sur `startDate` : filtrer sur le
début raterait toute séance de plus de quelques minutes — le piège qui avait
rendu muettes les notifications de fin de séance côté iPhone.
Si rien n'est récupérable, `discardRoute()` jette la trace explicitement et le
journalise comme une perte : un builder abandonné sans `discard()` laisse ses
données en suspens, et « any further calls to the builder raise an exception ».
Le chemin « séance en salle » (aucune position retenue) reste inchangé,
volontairement sans `discard()` : c'est le cas le plus fréquent, il
fonctionnait, et aucun build ne l'a validé avec cet appel.
`HKSource` est écrit en toutes lettres : `predicateForObjects(from:)` a cinq
surcharges et un `.default()` abrégé s'y résout mal.
swiftc -parse OK, 57 tests Swift verts (inchangés : ces fichiers importent
HealthKit, hors de portée de Linux).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La cible watchOS ne contenait pas une ligne de localisation : ni
CLLocationManager, ni HKWorkoutRouteBuilder, et aucune clé NSLocation dans son
Info.plist. Tant que les séances partaient de l'app Exercice d'Apple, c'est
elle qui écrivait la trace et CoachHealthRoute la relisait après coup. Dès
qu'une séance démarre depuis CoachWatch, plus personne ne l'écrit : la sortie
n'aurait ni carte, ni parcours dans Santé.
Le filtrage vit dans `RouteFilter`, en Foundation pur, donc testable ici :
rejet des points imprécis (> 50 m, seuil de l'exemple Apple), des sauts
impossibles, des doublons et des points arrivés dans le désordre, puis cumul de
la distance (haversine) et du dénivelé.
Le dénivelé a demandé deux passes. L'hystérésis seule laissait passer 297 m de
D+ sur un parcours PLAT : une oscillation d'amplitude égale au seuil est
comptée à chaque alternance, ce qui est inhérent à tout seuil. D'où un lissage
préalable, qui annule le bruit alternant. Contrepartie assumée et testée : une
pointe franchie en quelques points est écrêtée, donc sous-comptée — sans
conséquence, le D+ qui fait foi restant celui, barométrique, qu'Apple écrit
dans les métadonnées de la séance.
Quatre pièges documentés, tous traités dans le code :
- `allowsBackgroundLocationUpdates` sans `UIBackgroundModes` = `location`
TERMINE l'app. Un garde-fou vérifie le plist avant d'armer le drapeau.
- Ne jamais demander « Always » sur watchOS : Apple décrit ce prompt comme
« mostly a placeholder » et le parcours comme un comportement indéfini.
- Interdit de relancer la localisation depuis l'arrière-plan : la pause garde
le flux ouvert et ignore les points, au lieu de couper le manager.
- Le CPU tue le GPS — d'où l'insertion des points par lots et la trace
d'affichage bornée à 1500 points.
Corrigé après lecture de la doc : `finishRoute` s'appelle APRÈS `finishWorkout`
et reçoit le workout pour s'y associer. J'avais écrit l'inverse, avec nil, ce
qui aurait perdu l'association à chaque sortie. Et `finishWorkout()` rend nil
sans erreur quand la montre est verrouillée : on sauvegarde alors la trace sans
association plutôt que de la jeter.
15 tests, dont 4 vérifiés rouges en désactivant le lissage. Les trois fichiers
sont déclarés dans la cible CoachWatch (pbxproj sauvegardé en .bak-outdoor) :
aucun « Add Files » à faire sur le Mac.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suite de la demande : voir des km/h en courant, ce que l'app Exercice
d'Apple ne permet pas (vitesse réservée au vélo, allure min/km imposée en
course, aucun réglage).
La vitesse instantanée vient de HealthKit lui-même : HKLiveWorkoutDataSource
collecte .runningSpeed d'office en course extérieure (Series 6+) et
.cyclingSpeed à vélo (watchOS 11+). Elle est lue en mostRecentQuantity —
c'est la vitesse à l'instant t qu'on veut afficher, pas la moyenne de la
séance. Sur un appareil qui ne la produit pas, repli sur distance/temps.
Affichée sur les trois surfaces : écran de séance de la montre, Live
Activity (bannière + île dépliée) et vue native /live.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comme l'app Exercice : deux boutons sur la bannière de l'écran verrouillé et
dans l'île dépliée. iOS n'expose aucun geste de balayage pour révéler des
actions — depuis iOS 17, ce sont des boutons intégrés (App Intents), et c'est
le seul mécanisme public.
- Intents `CoachTogglePauseIntent` / `CoachEndWorkoutIntent`, conformes à
LiveActivityIntent (sans quoi `perform()` n'est jamais appelé).
- Ils vivent dans CoachLiveActivityAttributes.swift, seul fichier déjà membre
des deux targets : un fichier neuf imposerait une manip Target Membership
dans Xcode, source d'erreurs répétées ici.
- Commandes relayées à la montre par WCSession, avec repli transferUserInfo :
isReachable retombe à false quand la séance tourne en arrière-plan profond,
la commande est alors différée au réveil de l'app montre.
- L'état de pause remonte depuis HKWorkoutSession (seule source fiable : la
montre peut mettre en pause d'elle-même) et bascule le libellé du bouton.
- Le watchdog de LiveStore est neutralisé pendant la pause : sans ça, l'absence
de samples aurait affiché « connexion perdue » puis terminé l'activité.
- L'app watchOS gagne le bouton Pause qui lui manquait, aligné sur le même état.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La montre n'annonçait pas la fin : le flux de samples s'arrêtait, et
l'iPhone ne pouvait pas distinguer « séance terminée » de « montre hors de
portée ». La Live Activity restait donc figée sur la dernière FC reçue —
un cœur à 74 bpm en permanence dans la Dynamic Island.
Trois causes cumulées, toutes corrigées :
1. Aucun message de fin. WorkoutManager envoie maintenant `liveEnded` à
l'arrêt de la session HealthKit, en cas d'échec, et en mode test.
2. Le seul chemin de terminaison côté iPhone était le watchdog de
LiveStore, un Timer du main runloop — qui ne tourne pas quand l'app est
suspendue en arrière-plan, c'est-à-dire pendant toute la séance.
`endLive()` termine désormais l'activité dès réception du message.
3. `dismissalPolicy: .default` laissait la bannière ~4 h APRÈS la fin.
Passé en `.immediate`.
Filet de sécurité : `adoptExisting()` au lancement reprend la main sur une
activité orpheline (app tuée pendant une séance, `current` perdu) et
termine celles de plus de 6 h.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le throttle fixe de 2s ajoutait jusqu'à 2s de latence sur /live vs l'app
Exercice native d'Apple (instantanée). Désormais chaque nouvel échantillon FC
est envoyé immédiatement ; throttle léger 0.5s conservé uniquement pour
calories/distance (anti-saturation BLE).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bouton "Test (FC simulée)" qui envoie des samples synthétiques (FC ~110-150,
calories, distance, durée) via WCSession toutes les 2s, sans démarrer de séance
HealthKit. Permet de valider watch -> iPhone -> /live indépendamment de
l'autorisation Santé (bloquée par un sous-système Santé coincé sur la Watch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Si authorizationStatus(workoutType) == .sharingDenied après requestAuthorization,
on n'essaie pas de démarrer la séance (qui échouerait en HKErrorAuthorizationDenied)
et on affiche un message demandant d'activer Coach dans Réglages → Santé.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Logger os "ch.hypnotruck.coach.watchkitapp/workout" : trace requestAuthorization,
statut de partage, startActivity/beginCollection, transitions de session et
surtout didFailWithError (domaine+code) pour identifier la cause de l'Error(7).
Logger en constante de fichier (Sendable) pour rester accessible aux délégués
nonisolated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Garde anti double-démarrage (guard !isRunning, session == nil) pour éviter la
cascade de HKLiveWorkoutBuilder en Error(7) quand on retape l'activité. Reset
session/builder à nil après échec, fin et erreur de séance pour permettre une
nouvelle tentative propre. Le statusMessage d'erreur reste donc visible sur le
picker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Classe @MainActor (implicitement Sendable) ; callbacks HealthKit nonisolated qui
calculent les valeurs hors-main puis hoppent via Task { @MainActor }. Récupère le
builder depuis la session passée au délégué (plus de capture de self non-Sendable).
Build watchsimulator : 0 warning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HKWorkoutSession(healthStore:configuration:) + associatedWorkoutBuilder +
HKLiveWorkoutDataSource. Collecte FC (mostRecent), calories/distance (sum) via
statistics(for:), publication @Published sur main, envoi throttlé (2s) vers
ConnectivityManager. Clôture async endCollection(at:)/finishWorkout(). Build OK.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>