Commit Graph

191 Commits

Author SHA1 Message Date
Sylvain Bettinelli
8973a26e47 Les pas d'une séance sont lus en dépliant les séries condensées par HealthKit
Apple, « Accessing condensed workout samples » : HealthKit fusionne les
échantillons d'une séance en séries (count > 1), supprime les originaux,
« while still preserving data resolution », et le détail se relit avec
HKQuantitySeriesSampleQuery. Depuis watchOS 27 (21/09/2026), les pas
d'une course arrivent en 4 à 7 tranches de 5 à 12 min ; le plugin Capgo
lit quantity.doubleValue sans regarder count.

getStepSamples(startDate, endDate) lit les pas de la fenêtre, déplie
chaque série et rend aussi les comptes (raw / series / expanded) : c'est
la preuve que le détail existe encore — ou qu'il n'existe plus. Lecture
seule. Syntaxe vérifiée (swiftc -parse), build Xcode à faire.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 19:55:48 +00:00
Sylvain Bettinelli
b15867273c Le détail natif expose les activités HealthKit de la séance, en lecture seule
À la demande explicite de Sylvain (22/09), après la perte des pas fins
côté montre. Apple documente que les HKWorkoutActivity peuvent
« represent the active and rest periods during interval training », mais
ne dit pas que l'app Exercice en écrit une par étape d'un exercice
personnalisé. Ce champ `activities` (début, fin, durée, type, nombre
d'événements, métadonnées en clair) permet de le constater dans un brut.

Le serveur ne s'en sert pas : rien n'est stocké ni affiché tant que la
donnée n'a pas été vue. Syntaxe vérifiée (swiftc -parse), build Xcode à
faire sur le Mac mini.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 19:32:40 +00:00
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
Sylvain Bettinelli
a5cbb70210 Les deux extensions étaient restées au build 22, l'app était passée à 23
Xcode refuse l'installation quand une extension ne porte pas le même
CFBundleVersion que l'app qui la contient : « The CFBundleVersion of an app
extension ('22') must match that of its containing parent app ('23') ».

CoachWatchWidgetsExtension et CoachLiveActivity étaient à 22 ; App et
CoachWatch à 23. Les quatre configurations concernées passent à 23, ce qui
aligne les huit.

⚠️ Ce décalage reviendra à chaque incrémentation faite target par target. La
parade durable serait de porter CURRENT_PROJECT_VERSION au niveau du projet et
de laisser les targets en hériter, au lieu de le redéfinir huit fois — à faire
un jour où l'on ne court pas après un build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:18:45 +00:00
Sylvain Bettinelli
adb8177f2f Écran noir au lancement : la fenêtre n'était jamais rendue visible
La première version conditionnait la création de la fenêtre à `window == nil`,
par prudence : le manifeste déclarant UISceneStoryboardFile = Main, je
supposais qu'UIKit avait déjà monté la fenêtre depuis le storyboard et qu'il
ne fallait pas l'écraser.

Sur appareil, le résultat est un écran noir permanent, sans erreur, sans log,
sans crash. UIKit fournit bien un objet fenêtre, mais ne le rend pas visible à
notre place : la garde sautait donc makeKeyAndVisible() et rien ne s'affichait
jamais. Le fond noir de capacitor.config.ts rendait le symptôme indiscernable
d'un écran d'attente.

Le template de Capacitor crée la fenêtre sans condition. C'est le chemin
éprouvé, et s'en écarter « par sécurité » aura coûté un aller-retour de build.
Le seul écart qui reste nécessaire est MainViewController au lieu d'un
CAPBridgeViewController nu : notre sous-classe enregistre les neuf plugins
in-app dans capacitorDidLoad(), et ne dépend de rien du storyboard.

⚠️ À retenir pour la suite : sur ce chemin, une garde défensive ne protège de
rien et masque le défaut qu'elle prétend éviter. La condition de succès était
observable (une fenêtre visible), pas déductible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:16:08 +00:00
Sylvain
157250b3eb Package.resolved régénéré pour capacitor-swift-pm 8.5.2
Le fichier épinglait encore 8.3.1, ce qui empêchait Xcode de résoudre le paquet (croix rouge dans Package Dependencies) alors que xcodebuild y parvenait en ligne de commande. Supprimé et régénéré.
2026-09-22 11:09:53 +02:00
Sylvain Bettinelli
bb1261dfd8 Le Package.swift généré était invalide : la faute à une variable Xcode
Le build échouait sur « unterminated string literal » dans
CapApp-SPM/Package.swift ligne 7, et en cascade sur trois « Missing package
product » (CapApp-SPM, PolarBleSdk, GoogleSignIn). Rien de tout cela ne venait
des paquets : SPM ne pouvait simplement pas lire un manifeste qui contenait
platforms: [.iOS(.v"$)].

Cause racine, lue dans le code du CLI (util/spm.js -> ios/common.js) :
getMajoriOSVersion cherche la chaîne « IPHONEOS_DEPLOYMENT_TARGET = » dans le
pbxproj et prend les DEUX caractères qui suivent, sans vérifier que c'est un
nombre. Xcode ayant rempli ce réglage avec "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_
TARGET)" en ouvrant le projet, le CLI récupérait littéralement les caractères
« "$ » et les recopiait dans le manifeste.

Les quatre occurrences de cette variable étaient toutes sur CoachWatch et
CoachWatchWidgetsExtension, où IPHONEOS_DEPLOYMENT_TARGET est INOPÉRANT : un
target watchOS se règle par WATCHOS_DEPLOYMENT_TARGET. Les remplacer par 15.0
— la valeur du projet — ne change donc aucun comportement, et rend au CLI une
valeur qu'il sait lire. Vérifié : la première occurrence rend désormais « 15 »,
le projet se reparse et les quatre targets sont intacts.

⚠️ Constaté au passage, non corrigé parce que c'est une décision produit : le
target App est à IPHONEOS_DEPLOYMENT_TARGET = 26.0. L'app ne s'installera donc
que sur iOS 26 ou plus récent. Si ce n'est pas délibéré, c'est Xcode qui l'a
monté en appliquant ses réglages recommandés, et il faut le redescendre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:53:46 +00:00
Sylvain
316a35f33b Merge branch 'feat/watch-outdoor' of https://git.nexus.hypnotruck.ch/sylvain/coach-ios into feat/watch-outdoor 2026-09-22 10:33:27 +02:00
Sylvain
446b611afd Intégration Xcode du chantier Polar BLE : SDK SPM et sources au target
CoachPolarBLE.swift et PolarPftpStep.swift étaient versionnés mais absents du projet Xcode : rien ne les compilait, et la dépendance PolarBleSdk n'était déclarée nulle part. L'intégration ne vivait que sur le Mac. Inclut aussi ce qu'Xcode a réécrit en ouvrant le projet : renommage Embed App Extensions vers Embed Foundation Extensions, résolution SPM et les deux schémas.
2026-09-22 10:31:52 +02:00
Sylvain Bettinelli
e5c8967ea9 SceneDelegate.swift enregistré dans le target App
Sans cette entrée, Xcode ne compile pas le fichier et SceneDelegate.self reste
introuvable : l'app ne démarrerait pas davantage qu'avant la migration.

Fait avec la bibliothèque `xcode` que Capacitor utilise lui-même pour cette
opération (addSwiftFileToAppTarget), pas à la main. Essai à blanc d'abord pour
confirmer la cible : getFirstTarget() rend bien « App » et non l'un des trois
autres targets (CoachWatch, CoachWatchWidgetsExtension, CoachLiveActivity).

Deux retouches après coup :
- la bibliothèque écrivait `explicitFileType = undefined` sur la référence.
  explicitFileType prime sur la détection automatique, et cette valeur aurait
  pu empêcher Xcode de reconnaître le fichier comme du Swift. Normalisé au
  format exact d'AppDelegate.swift, ligne pour ligne ;
- elle a reformaté le groupe synchronisé CoachWatchWidgets de sa forme compacte
  vers le format multi-lignes. Vérifié champ par champ : même isa, même UUID
  d'exception, mêmes explicitFileTypes et explicitFolders vides, même path.
  Reformatage pur, aucune perte.

Intégrité vérifiée en comparant les deux fichiers parsés, objet par objet :
hors nos deux ajouts, aucune autre différence. Le groupe App passe de 28 à 29
enfants et la phase Sources de 20 à 21 fichiers, avec zéro retrait de part et
d'autre. Le projet se reparse et les quatre targets sont intacts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:15:41 +00:00
Sylvain Bettinelli
3bd90c8bb7 CLAUDE.md : la règle du cookie ne suffit plus, le SceneDelegate en porte la moitié
La règle n°2 protégeait AppDelegate.swift. Depuis l'adoption d'UIScene, trois
callbacks ont déménagé et UIKit ne les appelle plus sur l'AppDelegate même si
on les y remet — un futur correctif posé au mauvais endroit compilerait sans
broncher et casserait l'OAuth ou le flush des saisies widget en silence.

Précisé au passage que didFinishLaunchingWithOptions reste appelé avant la
connexion de la scène : c'est ce qui garantit l'ordre cookie -> WebView, et
c'est documenté par Apple, pas supposé.

Deux commandes ajoutées à la liste de ce qu'on ne lance pas sur ce projet :
npx cap migrate (le guide officiel exclut les AppDelegate à gestion d'URL
personnalisée) et npm audit fix --force (il redescend le CLI en v7, ce qui
supprimerait le support UIScene qu'on vient d'ajouter).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:05:25 +00:00
Sylvain Bettinelli
3755cafa1f Adoption du cycle de vie UIScene : sans lui, l'app ne démarre plus
Le build échouait au lancement sur _UIApplicationEvaluateRuntimeIssueForNo
SceneLifecycleAdoption. Apple est explicite : « Beginning in iOS 27 […] apps
built with the latest SDK must adopt the scene-based life cycle or they fail to
launch » (Transitioning to the UIKit scene-based life cycle). Ce n'est plus un
avertissement comme en iOS 26, c'est un assert. Aucune dérogation n'est
documentée : ni clé Info.plist, ni entitlement, ni date de tolérance — le seul
report possible serait de ne pas construire avec le dernier SDK, ce qui ne fait
que déplacer l'échéance sous contrainte de release.

Capacitor a adopté UIScene en 8.5.0 (PR #8536, « required in iOS 27 »), et
8.5.2 corrige une régression qui faisait échouer chaque démarrage à froid sur
une JS Eval error (#8595). On passe donc de 8.3.1 à 8.5.2, pas à 8.5.0.

⚠️ npx cap migrate N'A PAS été utilisé, et c'est volontaire : le guide officiel
exclut les projets à gestion d'URL personnalisée dans l'AppDelegate — « Projects
with hand-rolled scene delegates or custom AppDelegate URL handling should
follow the manual steps instead ». Notre branche GoogleSignIn en est la
définition. Le SceneDelegate suit le template livré dans le paquet 8.5.2, avec
deux écarts nécessaires décrits ci-dessous.

⚠️ CE QUI CASSE EN SILENCE, et qui est repris dans SceneDelegate.swift. Une
fois le manifeste posé, UIKit cesse d'appeler trois méthodes de l'AppDelegate
« even if you keep them implemented » (Apple) :
- application(_:open:options:) -> le retour OAuth Google partirait vers Safari
  sans jamais revenir. Déprécié iOS 26, remplacé par scene(_:openURLContexts:).
  La signature change : une URL devient un Set, donc on filtre ce que Google
  consomme avant de passer le reste à Capacitor.
- applicationDidBecomeActive -> les saisies du widget (eau, café) resteraient
  bloquées dans l'App Group. Reprise dans sceneDidBecomeActive.
- application(_:continue:restorationHandler:) -> les Universal Links des
  widgets et complications ouvriraient Safari, où le cookie d'auth n'existe pas.
Aucune des trois ne produit d'erreur de compilation. Elles ne sont pas laissées
en doublon dans l'AppDelegate : une méthode morte qui a l'air vivante fait
chercher la panne au mauvais endroit.

✅ CE QUI NE BOUGE PAS, vérifié dans la doc avant d'écrire une ligne :
didFinishLaunchingWithOptions reste appelé, et Apple documente la séquence
(launch -> création des scènes -> scene(_:willConnectTo:)). L'injection du
cookie garde donc son ordre : elle s'exécute avant que la WKWebView charge. La
règle non négociable n°2 du CLAUDE.md est tenue. Les callbacks de notification
distante restent aussi sur l'AppDelegate — UISceneDelegate n'a aucun équivalent
et ils ne sont pas dépréciés.

Écart au template Capacitor : il pose un CAPBridgeViewController() nu comme
rootViewController. L'appliquer tel quel remplacerait MainViewController, qui
porte l'enregistrement des 9 plugins natifs et la config WKWebView — sans
erreur visible. Le manifeste gardant UISceneStoryboardFile = Main, UIKit charge
déjà le storyboard : on ne crée la fenêtre que s'il ne l'a pas fait, et avec
notre sous-classe.

npm audit : 0 vulnérabilité en production. 13 dans l'outillage de build, qui ne
part pas dans l'app. ⛔ Ne PAS lancer npm audit fix --force : il propose de
redescendre @capacitor/cli en 7.6.9, ce qui supprimerait le support UIScene.

⚠️ RESTE À FAIRE SUR LE MAC, et rien ne compilera sans : ajouter
SceneDelegate.swift au target App dans Xcode. Le dossier App/ utilise des
références explicites dans le pbxproj (les groupes synchronisés ne couvrent que
les targets Watch/Widgets), et je n'édite pas un pbxproj à l'aveugle.

Vérifié ici : plist valide et relu par plistlib, syntaxe des deux fichiers
Swift validée par swiftc -parse. Aucune compilation possible sur Linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:04:14 +00:00
Sylvain Bettinelli
d5330b8f1a La FC ne reste plus affichée quand le capteur a décroché
`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>
2026-09-22 07:48:07 +00:00
Sylvain Bettinelli
c16b92ce29 Correction : les rappels SONT natifs, j'avais cherché le mauvais plugin
La version précédente de § 5.1bis affirmait que « la chaîne de notifications
natives n'existe pas de bout en bout ». C'est faux. web/static/local-
notifications.js programme bien, via Capacitor.Plugins.LocalNotifications, le
rappel de séance non faite, le résumé du jour, l'hydratation à 10/13/16 h et le
coucher à 23 h. Le README a donc raison sur le fond ; il attribue seulement la
fonction au mauvais plugin (local-notifications, pas push-notifications).

La méthode qui a produit l'erreur vaut d'être notée, et elle est notée dans le
document : j'avais grepé PushNotifications sans greper LocalNotifications.
Conclure d'un grep négatif qu'une fonctionnalité est absente suppose d'avoir
cherché le bon terme — c'est la même faute que celle qui fait dire à un cron
« rien de neuf ».

Ce qui reste : le chemin ntfy (push_notify.py, api_notifications.py,
tools/notify_morning.py) est résiduel et double une fonction déjà rendue en
natif ; plus aucune notification n'est censée y passer. Et le plugin
@capacitor/push-notifications reste installé avec son entitlement aps-
environment alors que rien ne l'appelle — 2.3.1(a).

⚠️ Ne rien supprimer avant d'avoir vérifié le cron notify_morning sur nexus :
le retirer sous un cron vivant le casserait, et un cron cassé a déjà tenu
quatre mois ici sans être vu. La vérif n'a pas pu être faite depuis le
conteneur de dev (pas de résolution DNS pour nexus) ; la commande est dans le
document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 07:00:49 +00:00
Sylvain Bettinelli
a29569c05c Les push natives ne sont branchées nulle part, et le README affirme l'inverse
Vérifié dans les deux dépôts : web/push_notify.py envoie via ntfy, en se
servant du token du device comme nom de topic. Aucun enregistrement de token
APNs côté serveur (recherche registerToken / /api/devices / device_token :
zéro résultat), et le web n'appelle jamais PushNotifications de Capacitor
(zéro résultat). La moitié native est prête — plugin installé, aps-environment
déclaré, remote-notification dans UIBackgroundModes — et personne ne la
branche. Pour recevoir un rappel, il faut installer l'app ntfy et s'abonner à
un topic.

Deux conséquences. Le README liste « ✅ Push notifications natives » comme
PREMIER argument de sa section anti-rejet Guideline 4.2 : l'argument ne tient
pas si un examinateur teste les rappels, et 4.2 est justement le risque de
cette architecture. Et une capability déclarée mais inutilisée tombe sous
2.3.1(a) (« hidden, dormant, or undocumented features »).

En contrepartie, le risque APNs que l'audit supposait en § 5.1 (token
production envoyé au sandbox, notification jetée silencieusement) est sans
objet : il n'y a pas d'APNs. Un item de moins, un plus sérieux à la place.

La décision est produit avant d'être technique : brancher la chaîne APNs, ou
assumer ntfy et retirer plugin + entitlement + la ligne du README. L'état
actuel est le seul qui cumule les inconvénients des deux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 06:58:17 +00:00
Sylvain Bettinelli
b1e7cf6d47 Audit complet de l'app mobile : natif, conformité Apple, ergonomie
Trois axes, chaque constat marqué [vérifié] / [rapporté] / [doc] — la
distinction n'est pas cosmétique : un constat non revérifié relayé comme un
fait est la dérive que la règle 3 interdit.

Le plus grave (§1.1, vérifié ligne à ligne) : WorkoutManager.swift:199 garde la
dernière FC connue quand le capteur décroche, et :218 la réémet avec un
horodatage NEUF. Le watchdog de LiveStore ne peut donc jamais basculer en
.lost — il reçoit des samples frais. Au poignet, une FC morte s'affiche stable,
avec sa zone colorée, indéfiniment. Pour un post-infarctus sous bêtabloquants
qui lit ce chiffre pour décider d'une pente, ce n'est pas un bug d'affichage.

Quatre affirmations d'audits antérieurs corrigées, toutes vérifiées :
- la politique de confidentialité EXISTE (HTTP 200) — le TODO du README est
  périmé, et « missing privacy policy » circulait encore ;
- la suppression de compte EXISTE (5.1.1(v), motif de rejet fréquent) ;
- 88 tests Swift verts, pas 57 ;
- npm audit réellement propre, et aucun secret jamais commité (git log vide).

aps-environment = development n'est pas le problème qu'on croyait : Apple
documente que la valeur vient du provisioning profile et que TestFlight utilise
production. Le vrai risque est côté backend, sur l'endpoint APNs.

Côté produit : le mot « Démarrer » n'existe nulle part pour une séance
sportive, et widgets/today-session.html:6 documente un CTA que le markup ne
contient pas. L'app ne permet pas de démarrer une séance.

Ce qui n'a pas été audité est dit en § 7 — aucun build Xcode possible ici,
CoachPolarBLE non revu, backend non audité au-delà de l'ergonomie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 06:55:20 +00:00
Sylvain Bettinelli
e6cdc67451 Revert "iOS 27 compat: VMA test — rendre startKmh éditable au lieu de figé à 8"
This reverts commit 263a8f694f.
2026-09-20 15:54:35 +00:00
Sylvain Bettinelli
263a8f694f iOS 27 compat: VMA test — rendre startKmh éditable au lieu de figé à 8
La vitesse de départ du test à paliers (VAMEVAL, Léger 1988) est désormais
saisie par l'utilisateur au lieu d'être figée à 8,0 km/h.

Le web a retiré ce défaut le 2026-08-25 (C9b) parce qu'il ne correspond
à aucun protocole publié et sous-estime la VMA de 0,5 km/h. La montre
suivait une valeur fausse sans permettre à l'utilisateur de la modifier —
tout VAMEVAL lancé au poignet partait donc faux.

Changements :
- startKmh : let constant → @State variable (source de vérité partagée)
- setupView : ajout TextField pour saisir la vitesse (visible si proto == .stages)
- Bouton Démarrer : désactivé tant que startKmh <= 0 en mode paliers

Le changement @State/let ne crée pas d'incompatibilité iOS 27 : @State est
utilisé partout dans la vue. iOS 27 n'en refuse que l'initialisation double
(valeur initiale + assignation en initializer), cas que nous n'avons pas.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-20 15:46:57 +00:00
Sylvain Bettinelli
150e78efa0 Checklist pré-build Mac : feat/watch-outdoor prêt, 4 vérifications device 2026-09-20 15:35:23 +00:00
Sylvain Bettinelli
3d939b8c3c Le build de ce soir tient sur une page, et l'import ne peut plus surprendre
Sylvain build ce soir en rentrant : docs/watch-plan-origin-runbook-mac.md porte
les gestes — les deux fichiers touchés sont déjà dans la target App, donc aucun
Target Membership ni framework à ajouter, et WorkoutKit était déjà lié par
CoachWorkoutKit.swift. Le runbook dit aussi quoi vérifier sans courir (le
message d'envoi doit NOMMER la séance programmée, ou dire l'autorisation
manquante) et comment lire l'origine de la séance après coup.

`import WorkoutKit` passe sous `#if canImport`, comme dans CoachWorkoutKit, et
reportPlanOrigin avec lui : ce code n'ayant jamais vu de compilateur, autant que
l'absence du framework le rende inerte plutôt qu'incompilable. Les trois erreurs
les plus probables sont listées avec leur cause dans le runbook, pour ne pas
avoir à rouvrir la doc DocC devant Xcode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 07:16:50 +00:00
Sylvain Bettinelli
9903424235 Le plugin affirmait l'envoi sans jamais relire ce que la montre avait reçu
Deux épisodes de « la montre ne dit plus rien » ont buté sur la même absence :
rien, côté app, ne distinguait « iOS a accepté l'appel » de « la séance est au
poignet ». Détail du dossier et chronologie dans coach_sportif,
docs/GUIDE-MONTRE.md §5quater et §5quinquies.

CoachWorkoutKit :
- requestAuthorization() n'avale plus son échec. Il était consigné dans un NSLog
  et l'envoi continuait : une réinstallation de l'app remet l'autorisation à
  notDetermined — celle du 31/08, chantier CoachPolarBLE, tombe dans la fenêtre
  du silence — et l'écran affichait « Envoyé » alors que rien ne partait.
- authorizationState est lu et rendu au JS, par isAvailable() comme par la
  nouvelle méthode scheduledWorkouts(). isAvailable() ne répondait jusqu'ici
  qu'« iOS 17+ » : une autorisation révoquée rendait la même réponse.
- schedule() est suivi d'une relecture de WorkoutScheduler.scheduledWorkouts, et
  sendInterval rend ce qu'elle liste (nom, date, blocs, itérations). Une liste
  vide n'est PAS traitée comme un échec : le scheduler publie de façon
  asynchrone et Apple ne garantit aucun délai — inventer une erreur à chaque
  envoi coûterait plus cher que le silence qu'on cherche.

CoachWorkoutObserver : à chaque fin de séance, HKWorkout.workoutPlan dit si elle
vient d'une séance programmée. Le fait part vers /api/workout/plan-origin, sur
le modèle de reportToRoutineIfStrength — faits bruts ici, interprétation côté
serveur, donc ajustable sans rebuild. Une lecture en échec n'envoie rien plutôt
qu'un faux « lancée à la main » : côté serveur, l'absence s'affiche « inconnu ».

⚠️ Rien de tout cela ne se compile ici : WorkoutKit et HealthKit sont absents de
Swift pour Linux, et tests-linux ne couvre que ce qui n'importe que Foundation.
À builder sur le Mac mini. Les signatures ont été vérifiées une par une dans la
doc DocC — authorizationState (get async, 4 cas), scheduledWorkouts,
ScheduledWorkoutPlan.date en DateComponents, WorkoutPlan.workout,
CustomWorkout.displayName optionnel, HKWorkout.workoutPlan (get async throws).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 06:24:08 +00:00
Sylvain Bettinelli
0a721c249b La réinitialisation du 31/08 était un choix, pas une nécessité constatée
Sylvain corrige : il a réinitialisé la montre de son propre chef, la montre
n'avait pas planté. Cette section affirmait « les redémarrages n'ont rien changé,
il a FALLU une réinitialisation » — une inférence présentée comme une
observation, et personne n'avait épuisé les gestes réversibles avant.

Ce qui reste établi : après un essai BLE refusé, l'interface USB ne répondait
plus dans la foulée — plus aucun /dev/cu.usbmodem*, system_profiler muet, même
câble et même port qu'une heure plus tôt. Ce qui ne l'est pas, et qui est retiré :
que ce soit durable, qu'une réinitialisation soit le remède, et que « le firmware
dégrade son état ». Cette dernière formule extrapolait depuis une observation
unique. Le plantage du 17/08 sur requête malformée, lui, reste établi.

Si le cas se reproduit, la marche à suivre demande maintenant d'épuiser les
gestes réversibles — débrancher, changer de port et de câble, redémarrer la
montre, laisser reposer — et de noter lequel a marché. C'est la mesure qui
manque au dossier.

Conséquence sur une décision prise ce matin : le refus d'ajouter un bouton
« Réessayer » s'appuyait sur ce « coût matériel avéré ». La prémisse ne tient
plus telle quelle. Le bouton reste absent pour une autre raison — la voie BLE
n'est pas viable, outiller la répétition d'un chemin qu'on n'emprunte pas serait
à contre-emploi — et la section dit désormais que la décision revient à Sylvain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 10:42:50 +00:00
Sylvain Bettinelli
2a4274b1ad Déconnecter Flow automatiquement est impossible, et le mode avion est le raccourci
Question de Sylvain : déconnecter Polar Flow avant chaque envoi, sans geste
manuel. La réponse est non, et elle vient de la doc Apple plutôt que de mon
souvenir. cancelPeripheralConnection(_:) : « Because other apps may still have a
connection to the peripheral, canceling a local connection does not guarantee
that the underlying physical link is immediately disconnected. » Une app n'annule
que sa propre connexion ; rien dans CoreBluetooth ni dans le SDK Polar ne rompt
celle d'une autre app, et une app ne peut pas davantage couper le Bluetooth
système. C'est une garantie d'isolation entre apps, pas une lacune de notre code.

Ce qui existe en revanche, et que le manuel Polar documente : le mode avion des
Réglages rapides de la Vantage V3, qui « coupe toute communication sans fil » —
donc Flow. ON puis OFF est plus court qu'un redémarrage et rouvre la même
fenêtre. Noté comme hypothèse cohérente avec les envois réussis du 01/09, pas
comme méthode établie : la course n'a pas été reproduite.

Écrit aussi pourquoi l'UI n'aura pas de bouton « Réessayer », alors qu'il serait
trivial : chaque session PsFTP refusée a un coût matériel avéré — celle du 31/08
a désactivé l'USB de la montre jusqu'à une réinitialisation. Un bouton qui
banalise la répétition transformerait un risque connu en réflexe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 09:34:13 +00:00
Sylvain Bettinelli
422b6ac3e6 « Fermer l'app Polar Flow » était un conseil faux, et mesuré tel dès le 31/08
Sylvain enchaîne un troisième envoi et reçoit « le canal est probablement
occupé » — notre propre message, déclenché par watchNotFound avec des montres
muettes : le service PsFTP est là, il ne répond pas.

La chronologie de ses trois envois explique tout. Le premier réussit, montre
fraîchement réinitialisée donc pas encore liée à Flow. Le deuxième renvoie 104
DIRECTORY_EXISTS, ce qui prouve que le canal était ENCORE libre. Le troisième
échoue : Flow a repris la main entre-temps. Le canal n'est donc pas ouvert ou
fermé en général — il y a une fenêtre, qui se referme dès que Flow se
reconnecte.

Le message conseillait de fermer complètement l'app Polar Flow. C'était faux, et
la mesure du 31/08 le disait déjà : la montre affichait « connexion impossible »
pendant que les réglages iOS la disaient toujours connectée à Flow. iOS maintient
le lien d'un accessoire appairé, app fermée ou non. Le message nomme maintenant
le geste qui libère réellement le canal — redémarrer la montre — précise que
fermer l'app n'y change rien, et rappelle que le câble est le chemin fiable.

Corrigé au passage le message des appareils sans PsFTP, qui présentait encore la
question comme « l'inconnue restante » alors qu'elle a été tranchée le 31/08 :
la montre expose bien PsFTP quand elle est connectée. Un test interdit désormais
de la rouvrir dans l'UI.

88 tests Linux verts, dont deux nouveaux qui verrouillent l'absence du conseil
inutile et la présence du geste utile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:30:48 +00:00
Sylvain Bettinelli
7d68605e83 « errorcode 104 » n'était pas un refus : mkdir sur un dossier déjà là
Sylvain a rapporté un errorcode 104 en testant l'envoi. Le code vient du
protocole PFTP de Polar, et pftp_error.proto du SDK officiel le nomme :
104 = DIRECTORY_EXISTS. Ce n'est donc pas un refus de la montre — la session BLE
s'était parfaitement ouverte, le transport fonctionnait, et c'est notre séquence
de mkdir qui n'était pas idempotente.

L'hypothèse fautive était écrite noir sur blanc dans parseSteps : « aucun des
deux n'existe d'avance pour une date neuve ». Vrai d'une date neuve, faux dès le
second envoi vers la même date — exactement ce que Sylvain venait de faire après
avoir réussi un premier envoi.

Un mkdir qui bute sur 104 est désormais considéré comme satisfait, à la manière
d'un mkdir -p : le dossier est là, c'est tout ce qu'on lui demandait. Strictement
limité à la création de dossier — un put de fichier n'est jamais avalé, 105
FILE_EXISTS signifierait que l'objectif est déjà écrit et l'appelant doit le
savoir.

Le renvoi n'est pas passé sous silence pour autant : ecrire() rend un drapeau
dossierPreexistant, le plugin le résout en alreadyExisted, et l'UI de coach
affiche « Un objectif existait déjà à cette date. Redémarrer la montre pour que
le nouveau s'affiche. » C'est la contrepartie de l'index en cache constaté le
31/08 — sans cet avertissement, l'envoi annoncerait un succès que la montre ne
montrerait pas.

Les codes PFTP sont définis dans PolarPftpStep.swift, donc couverts par les
tests Linux : 87 tests verts, dont 3 nouveaux qui vérifient que 104 est
satisfaisant pour un mkdir et que 103, 105, 106 et 108 restent des échecs.

⚠️ CoachPolarBLE.swift n'est pas compilable ici — Capacitor et le SDK Polar
n'existent pas sur Linux. La modification du plugin est à vérifier au prochain
build Xcode ; la logique des codes, elle, est testée.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:25:26 +00:00
Sylvain Bettinelli
474afce348 Le BLE marche sur montre réinitialisée, et ANCS ne portera jamais un .BPB
Deux apports de Sylvain le 01/09, consignés avant qu'ils se perdent.

Premier : après la réinitialisation complète imposée par l'incident USB, l'envoi
Bluetooth a abouti. Il juge lui-même que ce n'est pas viable. Cela confirme le
diagnostic du 31/08 au lieu de l'infirmer — le refus ne venait pas d'une
interdiction des apps tierces mais de l'occupation du canal PsFTP par Flow.
Montre non encore liée, canal libre, session ouverte. Le prix d'accès au BLE
reste donc « ne pas avoir Flow », et ce prix a augmenté ce matin : la Polar étant
devenue la montre de sport, Flow est le seul canal qui fait entrer les séances
dans HealthKit puis dans coach.

Second : sa question sur le protocole des notifications iOS. L'intuition est
bonne — elles arrivent à la montre pendant que Flow est connecté — mais la spec
ANCS d'Apple, lue à la source, ferme la piste pour un transfert de séance. Les
rôles y sont inversés par rapport à PsFTP : l'iPhone est le serveur
(Notification Provider), la montre est cliente (Notification Consumer), d'où
l'absence de conflit avec Flow. Et le service n'expose que trois
caractéristiques — Notification Source, Control Point, Data Source — sans aucun
mécanisme de transfert de binaire ou de payload applicatif.

Ce qu'ANCS permet en revanche, et qui est noté comme piste : afficher du texte au
poignet sans câble, sans dissocier et sans toucher à Flow, via une notification
locale iOS. Le canal est déjà en place côté projet — @capacitor/local-notifications
installé, local-notifications.js chargé par _layout.html — donc sans build Xcode.
Écrit avec ses limites : pas d'objectif structuré, pas d'alerte de zone, pas de
guidage par étape. Un pense-bête, pas un remplacement du .BPB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:21:00 +00:00
Sylvain Bettinelli
5fc19bf095 L'essai BLE a désactivé l'USB de la montre : il a fallu une réinitialisation
Après la connexion Bluetooth refusée par la Vantage (« connexion impossible » à
l'écran), son interface USB a cessé d'exister — plus aucun /dev/cu.usbmodem*,
system_profiler muet, alors que la montre chargeait et que c'était le même
câble, le même port et la même montre qu'une heure plus tôt. Les redémarrages
n'y ont rien fait. Seule une réinitialisation l'a récupérée.

Ce n'est donc pas une hypothèse : une tentative de session PsFTP refusée peut
désactiver durablement l'interface USB de cette montre. Le firmware ne se
contente pas de refuser, il dégrade son état — cohérent avec le plantage du
17/08 sur requête malformée.

Écrit en tête du runbook avec la règle qui en découle : ne pas retenter d'essai
BLE sans nécessité, jamais juste avant une sortie, et écrire la séance par câble
AVANT tout essai. Le repli n'est pas garanti disponible après coup.

C'est le coût réel de la journée sur le matériel, et il devait être consigné
avant tout le reste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 16:11:34 +00:00
Sylvain Bettinelli
adcf4088f3 La Vantage refuse la connexion tierce : la question de fond est tranchée
Cherchée au bon endroit, la montre EST atteinte : « 12 appareils vus, 1 portant
PsFTP ». Le service FEEE est exposé, fetchGattClient rend un BlePsFtpClient.
Tout le chemin fonctionne.

Mais waitPsFtpReady n'aboutit pas, et la montre affiche « connexion impossible »
pendant que les réglages iOS la disent connectée à Flow. Elle refuse la seconde
session. Le canal PsFTP de la V3 n'est donc pas partageable — cohérent avec
l'USB, où il fallait déjà fermer Flow.

C'est la réponse à la question ouverte depuis le 17/08, et elle est négative.
Elle valait d'être obtenue : elle ferme une hypothèse au lieu de la laisser
traîner.

Une dernière porte existe et n'a PAS été poussée : dissocier la montre de Flow
pour voir si elle accepte alors notre connexion. Délibérément non tenté —
sacrifier la synchronisation quotidienne, qui alimente coach en données de
séance, pour un confort d'envoi, n'a pas le bon rapport. C'est écrit pour que
personne ne s'y engage sans avoir mis les deux côtés dans la balance.

Noté aussi le geste de remise en état : après un refus, redémarrer la montre
libère son canal.

Le câble, lui, fonctionne intégralement et a écrit la séance du jour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:46:14 +00:00
Sylvain Bettinelli
3cc7d935ad La montre ne diffuse pas parce qu'elle est déjà connectée : la chercher ailleurs
4020 advertisements vus — HomePod, Furbo, des sans-nom — et aucune Polar. Le
scan marchait donc parfaitement depuis le correctif précédent. C'est la montre
qui ne s'annonce pas.

Et c'est normal : un appareil BLE connecté cesse d'émettre. La Vantage est liée
à l'iPhone par l'app Flow, donc invisible au scan par conception. Tous les
correctifs de scan qui ont précédé ne pouvaient rien y changer — on cherchait
dans le seul endroit où elle ne pouvait pas être.

`search()` prévoit exactement ce cas, mais seulement si on lui passe des UUID :
`manager.retrieveConnectedPeripherals(withServices: uuids!)` rend les
périphériques DÉJÀ connectés exposant le service, et leur fabrique une session.
Je passais `nil`, donc cette branche n'était jamais empruntée.

Au passage, déduplication des sessions : AllowDuplicates est armé côté SDK, d'où
les 4020 pour une poignée d'appareils réels — sans quoi on ouvrirait quarante
fois la même session.

Ce que la journée aura montré : chaque message d'erreur qui affirmait une cause
unique a fait perdre du temps, et chaque message qui RAPPORTAIT ce qu'il avait vu
a fait avancer. La liste des appareils vus valait tous les raisonnements.

84 tests au vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:40:12 +00:00
Sylvain Bettinelli
60dd22eaeb Mon filtre rejetait peut-être la montre, et mon message accusait le scan
Le dépôt était bien à jour : c'est moi qui avais mal lu mon propre code. Le
message incriminé EST atteignable par la nouvelle version — l'état Bluetooth est
publié, on passe au scan, et le publisher se tait quand même.

Deux erreurs à moi, pas une du SDK.

D'abord le message. Le publisher de `search()` reste muet dans DEUX cas : le
scan n'a pas démarré, ou il tourne sans rien découvrir — `scanSubject` n'émet
que sur découverte, et un `prepend` de liste vide n'émet pas. Affirmer « le scan
n'a pas démarré » a fait chercher au mauvais endroit pendant trois itérations.
Le message énonce désormais les deux possibilités, et le test qui verrouillait
l'ancienne formulation vérifie maintenant qu'on n'affirme rien de trop.

Ensuite le filtre. `scanPreFilter` rejetait tout ce qui n'avait ni polarDeviceId
ni « polar » dans le nom. Si la Vantage ne s'annonce pas ainsi, c'est NOUS qui
l'écartions, aucune session n'était créée, et le silence qui suivait était
interprété comme une panne du SDK. On laisse donc tout remonter et on trie
après : un test de ressemblance volontairement large (polarDeviceId,
polarDeviceType, ou nom contenant polar/vantage/grit), et on n'ouvre de session
que sur les candidats — ouvrir 43 sessions coûtait des minutes.

Et si rien ne ressemble à un Polar, on RAPPORTE ce qu'on a vu : nombre et noms
des huit premiers. C'est la seule façon de savoir sous quel nom la montre
s'annonce, ou de constater qu'elle ne s'annonce pas du tout — auquel cas
l'hypothèse du lien exclusif avec Polar Flow devient la bonne.

84 tests au vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:36:28 +00:00
Sylvain Bettinelli
eef76daa45 blePowered() lit le manager, search() lit le sujet : ce ne sont pas les mêmes
Le message n'avait pas bougé alors qu'il n'aurait plus dû pouvoir apparaître :
attendre `blePowered()` avait donc réussi, et le publisher se taisait quand même.
C'est ce qui a montré l'erreur.

`blePowered()` rend `manager.state == .poweredOn` — l'état de CoreBluetooth. Or
`search()` filtre sur `bleStateSubject`, alimenté uniquement par
`centralManagerDidUpdateState`. Le manager peut donc être allumé sans que le
délégué ait publié quoi que ce soit : le sujet reste à `.unknown`, le filtre
bloque, le publisher se tait. Attendre `blePowered()` ne prouvait rien sur ce que
`search()` allait voir — c'était la mauvaise sonde.

On attend désormais sur `monitorBleState()`, qui est public et qui EST la source
lue par search(). `blePowered()` garde un seul rôle : instancier la lazy pour
que le délégué puisse tourner.

L'attente porte son échéance elle-même plutôt que de passer par avecEcheance :
le listener n'est pas Sendable et le faire traverser une TaskGroup se heurterait
à la concurrence stricte de Swift 6. Et `BoiteJeton` garantit une reprise unique
de la continuation — deux chemins de sortie concurrents (l'état publié et
l'échéance), et reprendre deux fois est un crash, pas un avertissement.

82 tests au vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:31:30 +00:00
Sylvain Bettinelli
753d7b248e CurrentValueSubject, pas Passthrough : il fallait réveiller AVANT, pas après
La lecture des sources tranche ce que trois essais n'avaient pas résolu, et
elle retourne mon raisonnement précédent.

`bleStateSubject` est un `CurrentValueSubject<BleState, Never>(.unknown)` : il
rejoue sa valeur à chaque abonnement. Être abonné au moment de l'émission
n'apporte donc rien — et le correctif intermédiaire, qui réveillait le manager
APRÈS s'être abonné « pour ne pas rater l'événement », était un raisonnement de
PassthroughSubject appliqué au mauvais type.

Pire, il exposait à un second piège : `centralManagerDidUpdateState` fait
`BleState(rawValue: self.manager.state.rawValue)`, soit un accès à la lazy var
DEPUIS le délégué. Réveillée au milieu d'un abonnement, la propriété peut se
réentrer avant la fin de sa propre initialisation.

Séquence corrigée : créer le listener, poser le filtre, sonder `blePowered()`
jusqu'à ce qu'il réponde vrai — hors de tout abonnement, le premier appel
instancie, les suivants observent — puis appeler search() une seule fois. Si
l'état n'est pas atteint en 10 s, on le dit au lieu de scanner dans le vide.

Le commentaire qui affirmait « bleStateSubject ne publie que sur changement » est
corrigé plutôt que laissé : il était faux, et un commentaire qui ment coûte plus
cher que pas de commentaire. L'abonnement unique reste, mais pour la vraie
raison — chaque abonnement relance le cycle addClient/removeClient du scanner, et
la découverte BLE demande plusieurs secondes ininterrompues.

82 tests au vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:27:58 +00:00
Sylvain Bettinelli
0f96122cca Arrêt du chantier BLE : l'approche « piloter le listener à la main » est la mauvaise
Cinq causes trouvées et corrigées aujourd'hui, chacune par une mesure — et le
publisher reste muet. Le manager du SDK existe pourtant : l'avertissement
CoreBluetooth sur le restore identifier ne peut apparaître que s'il a été
instancié.

Ce que ça enseigne compte plus que la sixième hypothèse : instancier
CBDeviceListenerImpl soi-même revient à réimplémenter ce que PolarBleApiImpl
fait, sans en voir le détail. On avance d'un cran par essai, et chaque essai
coûte à Sylvain un cycle Xcode complet. Continuer à pousser des correctifs sur
des mécanismes internes que je ne peux pas exécuter, c'est exactement
l'extrapolation que ce projet s'interdit.

Le runbook porte donc les cinq causes déjà écartées — pour que personne ne les
revérifie — et la piste qui me paraît juste : passer par
PolarBleApiDefaultImpl.polarImplementation, le chemin supporté, avec son point
bloquant énoncé (le listener y est privé ; reste à voir si une API publique
donne accès à la session ou au client PsFTP, faute de quoi la voie est fermée
elle aussi).

Rien du travail serveur n'est perdu : le câble fonctionne intégralement, et
c'est lui qui a écrit la séance du jour sur la montre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:25:52 +00:00
Sylvain Bettinelli
01607ed75c Les fenêtres de scan se privaient de l'unique émission d'état
Le manager lazy était bien réveillé — l'avertissement CoreBluetooth sur le
restore identifier le prouve, il ne peut apparaître que si le manager existe.
Et pourtant le publisher restait muet.

La cause est mon découpage en cycles de 4 s. `bleStateSubject` ne publie que sur
CHANGEMENT d'état : le premier cycle réveillait le manager et recevait
`.poweredOn`, mais aux cycles suivants l'état ne changeait plus, donc plus rien
n'était émis. Chaque nouvel abonnement attendait une transition qui avait déjà
eu lieu.

Un seul abonnement, de la durée du timeout, supprime le problème : on reçoit
l'émission là où elle se produit, et le scan tourne sans être relancé. La boucle
d'ouverture de session garde une marge au-delà du scan — couper à la seconde du
timeout ferait échouer la seule montre trouvée.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:23:31 +00:00
Sylvain Bettinelli
94b1f6243d Sans filtre, on ouvrait une session sur les 43 appareils BLE du voisinage
Les traces le disent en clair : le SDK tentait de discuter avec l'Apple Watch.

    API MISUSE: <CBPeripheral … name = Apple Watch de Sylvain,
    state = disconnected> can only accept commands while in the connected state

Sans `scanPreFilter`, le listener remonte TOUS les appareils BLE alentour — 43
mesurés ce matin. Le code ouvrait une session sur chacun à tour de rôle pour lui
demander s'il portait PsFTP, avec 12 s d'échéance par appareil. D'où l'envoi qui
tournait sans fin, et ces erreurs sur un appareil qui n'a rien à voir.

Le SDK officiel pose exactement ce filtre — `deviceFilter` dans
`PolarBleApiImpl` — et je ne l'avais pas repris. Le voilà : `polarDeviceId` non
vide, avec un repli sur le nom quand l'identifiant n'est pas encore décodé au
moment du filtrage.

C'est le pendant de la découverte précédente : le manager lazy empêchait de
voir quoi que ce soit, et une fois réveillé, l'absence de filtre faisait tout
voir. Les deux se cachaient l'un l'autre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:20:05 +00:00
Sylvain Bettinelli
81582ce969 Une attente sans fin ne dit rien : échéances sur les appels du SDK
L'envoi tournait sans jamais rendre la main, ni message ni erreur. Cause : les
`async` du SDK Polar n'ont aucune limite de temps, et `waitPsFtpReady` attend
indéfiniment une montre qui ne finit pas sa négociation — canal déjà pris, écran
éteint, appairage en cours.

Une attente sans fin est pire qu'un échec : elle n'apprend rien, et c'est
exactement ce qu'on cherche à éviter depuis ce matin. `waitPsFtpReady` a
désormais 12 s, chaque écriture 20 s, et un dépassement compte comme une montre
muette — c'est ce qu'elle est. Le message dit quoi faire : réveiller l'écran,
rapprocher, fermer Flow.

Le fait qu'on soit arrivé jusqu'à ce blocage est en soi une information : avant
le réveil du manager lazy, on n'atteignait même pas la phase de connexion.

82 tests au vert sur tests-linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:17:08 +00:00
Sylvain Bettinelli
1389edb1f7 Cause racine : le CBCentralManager du SDK est lazy, et search() ne le réveille jamais
L'instrumentation a tranché : le publisher de search() n'émettait RIEN — ni
valeur, ni complétion, ni erreur. Ce n'était donc pas « aucun appareil », c'était
« on n'a jamais cherché ».

CBDeviceListenerImpl.manager est une `lazy var` : le CBCentralManager n'existe
qu'au premier accès. Or search() commence par
`monitorBleState().filter { $0 == .poweredOn }`, et monitorBleState() se contente
de rendre bleStateSubject — il ne touche jamais manager. Les seuls accès du
chemin de recherche (retrieveConnectedPeripherals, retrievePeripherals) sont à
l'intérieur du flatMap, donc APRÈS le filtre qui bloque. Le manager n'était
jamais créé, centralManagerDidUpdateState jamais appelé, le sujet jamais
alimenté : le filtre attendait un état que rien ne pouvait produire.

`blePowered()` — `return manager.state == .poweredOn` — est le seul accès public
exécuté immédiatement. On l'appelle donc pour instancier le manager, et on le
fait APRÈS s'être abonné : si bleStateSubject est un PassthroughSubject, un état
émis avant l'abonnement serait perdu et on retomberait sur le même silence. Cet
ordre est écrit dans le code, c'est exactement ce qu'une relecture inverserait.

Trois mesures ont conduit ici, aucune supposition : l'absence de ligne Bluetooth
dans les réglages de l'app, puis 43 appareils en scan nu contre 0 par le SDK,
puis le publisher muet. Chacune a éliminé une famille de causes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:11:20 +00:00
Sylvain Bettinelli
3de149591e Le scan du SDK ne démarre pas, et le message le dira au lieu d'accuser la montre
43 appareils vus par un scan CoreBluetooth nu, 0 remonté par le SDK Polar. La
radio, l'autorisation et la montre sont hors de cause — c'est notre usage du SDK.

Trois suspects écartés sur les sources plutôt que supposés, et écrits dans le
runbook pour que personne ne les revérifie : servicesToScanFor à nil est aussi
ce que fait PolarBleApiImpl ; un scanPreFilter nil ACCEPTE les appareils (le
`if let` échoue, le rejet est sauté) ; et addClient() suffit à lancer le scan,
scanningNeeded() rendant vrai dès que clientCount != 0.

Reste une hypothèse : search() commence par
`monitorBleState().filter { $0 == .poweredOn }`. Si cet état n'arrive jamais, le
publisher ne dit RIEN — ni valeur, ni complétion, ni erreur — et le timeout
conclut « aucun appareil » alors qu'on n'a jamais cherché. Ce sont deux
diagnostics opposés que le même message couvrait.

L'instrumentation note donc si le publisher a parlé, et relaie une éventuelle
erreur du SDK telle quelle. Trois messages distincts en sortent, trois tests les
verrouillent.

Piste notée pour la reprise : le SDK pose powerStateObserver et
deviceSessionStateObserver sur son listener et passe identifier: 0 ; nous ne
posons aucun observateur et passons 1.

80 tests au vert sur tests-linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:06:15 +00:00
Sylvain Bettinelli
14133efba9 Quand le SDK ne voit rien, demander à CoreBluetooth ce que LUI voit
Autorisation Bluetooth accordée, alerte acceptée — et toujours zéro appareil.
Or un scan BLE en environnement normal voit toujours quelque chose : le
problème n'est donc pas la montre. Restait à savoir s'il venait de la radio ou
de notre usage du SDK, et rien dans le message ne permettait de trancher.

J'ai vérifié dans les sources ce qui pouvait faire taire le scan côté SDK, et
écarté deux hypothèses plutôt que de les supposer : `servicesToScanFor` est nil
chez eux aussi, et un `scanPreFilter` nil ACCEPTE les appareils découverts
(`if …, let filter = self.scanPreFilter` — la condition échoue, le rejet est
sauté). Ni l'un ni l'autre n'explique le silence.

Donc on mesure. Quand le SDK ne remonte aucune session, la sonde relance un scan
CoreBluetooth NU, sans le SDK, et le nombre part dans le message :

- 0 appareil en scan direct → la radio va bien mais ne voit rien : montre
  éteinte, hors de portée, ou connectée ailleurs de façon exclusive.
- N appareils en scan direct → la radio va bien, le problème est dans notre
  intégration du SDK, ni dans la montre ni dans l'iPhone.

Ce sont deux chantiers opposés, et deviner lequel a déjà coûté trois
allers-retours aujourd'hui. Deux tests verrouillent la distinction.

77 tests au vert sur tests-linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:01:28 +00:00
Sylvain Bettinelli
6deb61e86e iOS n'avait jamais demandé l'autorisation Bluetooth, et le scan attendait un état qui n'arrivait pas
« aucun appareil Bluetooth détecté » après 30 s de scan. La cause n'était pas la
montre : Réglages → coach ne contenait même pas de ligne Bluetooth. L'alerte
d'autorisation n'avait jamais été présentée.

La raison est dans le SDK. `CBDeviceListenerImpl.search()` commence par
`monitorBleState().filter { $0 == .poweredOn }` — tant que cet état n'arrive
pas, le flux n'émet rien et le scan ne démarre jamais. Or l'état ne peut venir
que d'un CBCentralManager, et c'est sa création qui déclenche la demande
d'autorisation. Aucun manager n'existant, on attendait un signal que rien ne
pouvait produire, puis on concluait « aucun appareil » au timeout. Avoir la clé
NSBluetoothAlwaysUsageDescription dans Info.plist ne suffit pas : elle fournit
le texte de l'alerte, elle ne la provoque pas.

`SondeBluetooth` crée donc le manager, attend son premier état non-`.unknown`
(l'état transitoire du démarrage, sur lequel il ne faut pas conclure), et
traduit ce qu'il dit : autorisation refusée, Bluetooth éteint, matériel absent.
Le cas d'erreur est distinct de watchNotFound — on n'a même pas pu chercher, et
parler de la montre envoyait chercher au mauvais endroit. Deux tests verrouillent
cette distinction.

Au premier lancement après ce correctif, iOS présentera l'alerte : il faut
l'accepter. Ensuite seulement on saura si la Vantage accepte une connexion
tierce, qui reste la question de fond.

75 tests au vert sur tests-linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 12:57:02 +00:00
Sylvain Bettinelli
179528d563 Trois causes opposées disaient la même chose : le premier essai BLE n'a rien appris
Sylvain a lancé l'envoi Bluetooth et obtenu « aucune montre Polar exposant
PsFTP ». Ce message ne permet pas de conclure : il couvre à la fois « aucun
appareil vu » (Bluetooth éteint, autorisation refusée à l'app, montre hors de
portée) et « la montre est là mais ne répond pas » (canal déjà pris par Polar
Flow). Ce sont des causes opposées et des gestes différents. Un essai qui
n'apprend rien est un essai perdu, et celui-là demandait de brancher une montre.

L'erreur porte désormais ce qui a été observé — appareils vus, combien sans le
service FEEE, combien avec mais muets — et rend trois messages distincts, chacun
nommant le geste correspondant. Quatre tests verrouillent la distinction, dont
celui qui interdit d'accuser Polar Flow quand rien n'a été vu : ce serait envoyer
sur une fausse piste.

Le scan devient aussi répétitif, par fenêtres de 4 s jusqu'au timeout, au lieu
d'une passe unique de 10 s. CoreBluetooth peut n'être pas encore poweredOn au
premier appel : le scan ne démarre alors jamais et une fenêtre unique conclut à
tort qu'aucun appareil n'existe. C'est une cause plausible du premier échec, et
elle n'était pas couverte.

73 tests au vert sur tests-linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 12:47:28 +00:00
Sylvain Bettinelli
0110368e17 L'heure de l'objectif est une question ouverte, pas un réglage à deviner
Sylvain ne sait pas encore à quelle heure il court, et j'allais lui faire
choisir une valeur comme si elle était contrainte. Elle ne l'est peut-être pas.

Ce qui est établi : le dossier <HHMMSS> et le start_time du fichier doivent
concorder, et les outils les produisent ensemble — rien à accorder à la main.

Ce qui ne l'est pas : la montre conditionne-t-elle l'affichage de l'objectif à
cette heure, ou le propose-t-elle toute la journée ? Le test du 17/08 ne l'a pas
mesuré. Écrit comme observation à faire au premier envoi plutôt que tranché au
jugé, avec les deux conséquences selon la réponse — dont aucune n'est bloquante.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 07:17:45 +00:00
Sylvain Bettinelli
184c08a447 La séance part sur la Polar en Bluetooth, et refuse de partir mal formée
Contrainte posée aujourd'hui : coach → la montre, sans TrainingPeaks, sans
Polar Flow, sans Intervals.icu. Toutes les autres voies passent par un tiers ;
celle-ci est la seule qui reste, et elle était déjà prouvée en USB le 17/08.
Manquait le transport qui la rende utilisable — brancher la montre au Mac cinq
minutes avant de sortir n'est pas un usage.

`CoachPolarBLE` ne fait que transporter. Les octets viennent du serveur
(GET /api/plan/polar-target), produits par un encodeur verrouillé octet pour
octet contre des fichiers relus sur la montre. Rien n'est réencodé ici : le
faire perdrait la seule garantie que le firmware acceptera le fichier.

La chaîne SDK a été vérifiée sur les sources avant d'écrire une ligne, et elle
est entièrement publique — CBDeviceListenerImpl, search, openSessionDirect,
fetchGattClient(PSFTP_SERVICE), waitPsFtpReady, write(header:data:). Ni fork,
ni symbole interne.

Le morceau qui compte vraiment est ailleurs. `PolarPftpStep` est séparé du
plugin et n'importe que Foundation, pour être exécuté sur Linux : il porte
l'en-tête PbPFtpOperation et les deux règles qui décident si une requête part.
Le 17/08, un PUT de 174 octets vers un chemin terminé par « / » a bloqué la
montre — écran noir, appui long sur OK pour la récupérer. Le firmware ne refuse
pas, il s'effondre, et le Bluetooth utilise le même protocole. Ces règles ne
peuvent donc pas être vérifiées « à la relecture » : 12 tests les exécutent,
dont celui qui interdit de reporter ici le cadrage série [0x05, taille, taille]
de la version USB — en BLE c'est le SDK qui cadre, l'ajouter produirait
précisément une requête malformée.

L'en-tête produit par Swift a été comparé à celui de polar_ftp.py sur trois
chemins, dont un de 205 caractères pour le varint sur deux octets : mêmes
octets. 69 tests au vert sur tests-linux.

Un mode dryRun construit et décrit chaque requête sans rien émettre — le
--dry-run qui manquait le jour du plantage. Le runbook Mac dit de s'en servir au
premier essai, et liste les trois inconnues qui ne se lèveront que montre en
main, la première étant : la V3 accepte-t-elle une connexion BLE tierce pendant
qu'elle est appairée à Flow ?

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 07:06:53 +00:00
Sylvain Bettinelli
a44fa0046a Cadrage des vues d'entraînement au choix, et point d'étape du 21/08
Le chantier « écrans configurables façon WorkOutDoors » est cadré, rien n'est
codé. docs/watch-training-screens.md fixe l'analyse pour qu'une session
ultérieure n'ait pas à la refaire.

Ce qui a été établi :

- LiveWorkoutView a cinq métriques ÉCRITES EN DUR ; il n'existe aucune notion
  de champ, de page ni de configuration. Tout est à créer.
- Neuf champs sont disponibles sans aucune collecte nouvelle (dont allure, D+,
  précision GPS, étape du fractionné) ; FC moyenne, temps en zone, cadence,
  puissance et laps demandent du travail.
- Décision : la configuration s'édite côté WEB (TileManager existe déjà) et
  voyage par WCSession — donc changer ses écrans ne demandera aucun rebuild
  Xcode, comme pour la routine.
- Le modèle et le catalogue sont du Foundation pur : écrits et testés sur
  Linux, seul le rendu TabView exige le Mac. Ne pas commencer par le rendu.
- Deux contraintes tenues dès la conception : 1 Hz maximum et page visible
  seule (le CPU suspend l'app et arrête le GPS sans erreur), et espacement des
  mises à jour en luminance réduite.

COWORK gagne le point d'étape du 21/08 : build passé, correctif finishRoute, et
surtout le fait que les 4 vérifications au poignet ne sont PAS encore faites —
les logs du jour ne montrent que l'app iPhone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 09:53:46 +00:00
Sylvain Bettinelli
3e657f5917 Une trace GPS ne peut pas être sauvegardée sans séance
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>
2026-08-21 09:22:50 +00:00
Sylvain Bettinelli
2f2d7696fa COWORK : la couche serveur du plan de séance est livrée
GET /api/plan/session (coach_sportif c8d1541) rend la séance du jour au format
CoachSessionPlan, déployé et vérifié en prod le 21/08 : 32 étapes / 40 min sur
le 15×(1'/1') du jour, alternance work/recovery correcte, bornes issues des
zones datées.

Reste les couches 2 (pousser le plan par WCSession) et 3 (la vue qui appelle
engine.update et joue l'haptique sur les transitions).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 09:11:31 +00:00
Sylvain Bettinelli
891a8bddce IntervalEngine : le commentaire sur les pauses disait l'inverse d'Apple
L'en-tête affirmait que `HKLiveWorkoutBuilder.elapsedTime` « exclut déjà les
pauses », et en tirait que le moteur n'avait rien à savoir de la pause ni de
l'auto-pause.

La doc Apple dit le contraire, vérifié à la source le 2026-08-20 :
« The elapsed time for the workout based on the builder's current contents,
including pauses. »

Conséquence si on câblait le moteur dessus telle quelle : une pause de 5 min
ferait avancer le déroulé de 5 min d'effort — un fractionné mis en pause pour
traverser une route se déroulerait à l'arrêt.

La propriété qui exclut réellement les pauses est
`HKWorkoutBuilder.elapsedTime(at:)` : « The duration of a workout doesn't
include intervals between pause and resume events. » Les deux textes d'Apple
se contredisent frontalement ; le choix de la source de temps reste à faire
avant le câblage.

Le moteur lui-même reste correct : il n'intègre que ce qu'on lui pousse.
Aucun comportement modifié, 57 tests Swift toujours verts.

COWORK gagne l'analyse complète du câblage (3 couches, le serveur sait déjà
produire les étapes via _blocks_from_session) pour reprise à froid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:52:21 +00:00
Sylvain Bettinelli
19fbfd116e pbxproj : IntervalEngine était déclaré deux fois
Deux sessions ont ajouté le même fichier à la cible CoachWatch en parallèle —
l'une depuis Xcode sur le Mac (be1ffec), l'autre en éditant le pbxproj sous
Linux (a04dfc4). Le rebase a fusionné les deux sans conflit, puisqu'elles
touchaient des lignes différentes : le fichier se retrouvait déclaré deux fois
et Xcode aurait refusé de compiler pour redéclaration.

Les entrées Xcode sont conservées, les miennes retirées. RouteFilter et
LocationTracker n'étaient pas concernés.

Repère de contrôle, à refaire après toute fusion touchant le pbxproj : chaque
fichier doit rendre 2 pour `grep -c '<Nom>.swift in Sources'` — une seule
cible. 4 signale un doublon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:37:54 +00:00
Sylvain Bettinelli
eb523ff205 COWORK : les fichiers sont dans la cible, la manip Xcode n'a plus lieu d'être
La note de ce matin demandait un « Add Files » pour IntervalEngine. Les trois
fichiers du chantier sont désormais déclarés dans la cible CoachWatch
directement dans le pbxproj : suivre l'ancienne consigne créerait un doublon.

Ajout de l'ordre de vérification au premier build — la trace dans Santé et la
non-régression de la FC sur /live sont les deux points qui décident si le
chantier tient.

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