Les bornes cardiaques partaient vers la montre sans que rien ne dise si elles arrivaient

§ 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>
This commit is contained in:
Sylvain Bettinelli
2026-09-24 08:08:02 +00:00
parent 8e0c2738ce
commit 7e7be4a1d1
5 changed files with 194 additions and 11 deletions

View File

@@ -1,6 +1,18 @@
# Diagnostic natif — les cinq constats ouverts de l'audit, vérifiés
**24/09/2026. Lecture seule : aucune ligne de Swift n'a été modifiée.**
> ## ⚠️ § 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 ».
@@ -237,3 +249,67 @@ 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.

View File

@@ -13,10 +13,18 @@
// forme: { score: 72, label:"Prêt" }
// });
import Foundation
import os
import Capacitor
import WidgetKit
import WatchConnectivity
/// ⚠️ Ce pont transporte les bornes de zones cardiaques vers la montre. Il était
/// le seul chemin WatchConnectivity du projet à ne rien journaliser — quatre
/// sorties silencieuses sur sept lignes — alors que `CoachRoutineBridge`, qui
/// porte des exercices de mobilité, journalise et remonte ses échecs.
/// Diagnostic du 24/09/2026 : `docs/DIAGNOSTIC-NATIF-2026-09-24.md` § 1.2.
private let widgetLog = Logger(subsystem: "ch.hypnotruck.coach", category: "widget")
@objc(CoachWidgetBridgePlugin)
public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "CoachWidgetBridgePlugin"
@@ -164,8 +172,15 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
return
}
if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() }
pushToWatch(snapshot)
call.resolve(["ok": true])
// Le statut d'envoi remonte au JS : jusqu'ici, la page n'avait aucun
// moyen de savoir si les bornes cardiaques étaient arrivées au poignet.
// Champs AJOUTÉS, `ok` conservé : aucun appelant existant ne casse.
let raison = pushToWatch(snapshot)
call.resolve([
"ok": true,
"watchPushed": raison == nil,
"watchReason": raison ?? "",
])
}
/// Envoie le snapshot à l'Apple Watch pour ses complications. L'App Group
@@ -173,14 +188,50 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
/// de SA montre, alimenté ici via WatchConnectivity.
/// On n'est PAS le delegate WCSession (c'est CoachLiveBridge) : on se contente
/// d'émettre, ce qui ne requiert pas d'être delegate, juste une session activée.
private func pushToWatch(_ snapshot: CoachWidgetSnapshot) {
guard WCSession.isSupported() else { return }
/// Rend la raison de l'échec, ou `nil` si le snapshot est parti.
///
/// ⚠️ Un échec ici ne fait PAS échouer `setSnapshot` : l'App Group est déjà
/// écrit et les widgets iPhone rafraîchis. Une montre absente ou verrouillée
/// n'invalide pas l'opération — d'où un `resolve` enrichi plutôt qu'un
/// `reject`, contrairement à `CoachRoutineBridge` où l'envoi EST l'opération.
@discardableResult
private func pushToWatch(_ snapshot: CoachWidgetSnapshot) -> String? {
guard WCSession.isSupported() else {
widgetLog.info("snapshot non poussé : WatchConnectivity non supporté")
return "WatchConnectivity non supporté"
}
let session = WCSession.default
guard session.activationState == .activated,
let data = try? JSONEncoder().encode(snapshot) else { return }
guard session.activationState == .activated else {
widgetLog.error("snapshot non poussé : session non activée (état \(session.activationState.rawValue, privacy: .public))")
return "session WatchConnectivity non activée"
}
// `isPaired` / `isWatchAppInstalled` comme dans CoachRoutineBridge : sans
// eux, l'absence de montre se confond avec un échec d'envoi.
guard session.isPaired else {
widgetLog.info("snapshot non poussé : aucune montre appairée")
return "aucune Watch appairée"
}
guard session.isWatchAppInstalled else {
widgetLog.info("snapshot non poussé : app Watch non installée")
return "app Watch non installée"
}
guard let data = try? JSONEncoder().encode(snapshot) else {
widgetLog.error("snapshot non poussé : encodage JSON impossible")
return "encodage du snapshot impossible"
}
do {
// updateApplicationContext : dernier état, sans budget (≠ transferCurrent
// ComplicationUserInfo qui réveille la complication mais est rationné ~50/j).
try? session.updateApplicationContext(["widgetSnapshot": data])
try session.updateApplicationContext(["widgetSnapshot": data])
// Les zones sont la raison d'être de ce log : savoir QUELLES bornes
// sont au poignet, et depuis quand, sans avoir à ouvrir la montre.
let z = snapshot.zones
widgetLog.info("snapshot poussé vers la Watch · zones=\(z != nil ? "oui" : "non", privacy: .public) depuis=\(z?.zonesSince ?? "?", privacy: .public)")
return nil
} catch {
widgetLog.error("updateApplicationContext a échoué : \(error.localizedDescription, privacy: .public)")
return error.localizedDescription
}
}
/// Relit le snapshot RÉELLEMENT stocké dans l'App Group.

View File

@@ -32,6 +32,23 @@ struct HeartRateZones: Codable, Equatable, Sendable {
/// vers Z2-Z3. C'est le repère clinique, celui qui compte ici.
var clinicalTargetBpm: Int?
/// Date de la version FIGÉE dont viennent ces bornes (`YYYY-MM-DD`), telle
/// que servie par `/api/cardiac-zones` (`zones_since`).
///
/// ⚠️ Sans elle, la montre affichait des bornes sans âge. Le backend tient
/// une version datée (`data/hr_zones_history.json`, règle 9 du CLAUDE.md
/// web) parce qu'une référence qui bouge avec son propre bruit ne permet
/// pas de juger une séance ; **cette date se perdait au transport**. Après
/// une révision des bornes, rien au poignet ne distinguait les nouvelles
/// des anciennes — et si le push échouait en silence (cf. § 1.2 du
/// diagnostic du 24/09/2026), les anciennes restaient indéfiniment.
var zonesSince: String? = nil
/// `"frozen"` (version confirmée) ou `"live"` (calcul glissant, avant la
/// première version figée). Distingue « bornes validées » de « bornes
/// provisoires » — la montre ne pouvait pas faire la différence.
var zonesFrom: String? = nil
/// L'effort est-il dans la zone d'entraînement utile ?
func isUseful(bpm: Int) -> Bool {
guard let target = clinicalTargetBpm else { return false }

View File

@@ -18,4 +18,6 @@ struct HeartRateZones: Codable, Equatable, Sendable {
var z5: [Int]?
var fcmaxUsed: Int?
var clinicalTargetBpm: Int?
var zonesSince: String?
var zonesFrom: String?
}

View File

@@ -43,7 +43,8 @@ final class DayRolloverTests: XCTestCase {
s.kcalToday = 1186
s.waterGoalMl = 2750
s.kcalGoal = 1950
s.zones = HeartRateZones(z2: [109, 118], fcmaxUsed: 152, clinicalTargetBpm: 112)
s.zones = HeartRateZones(z2: [109, 118], fcmaxUsed: 152, clinicalTargetBpm: 112,
zonesSince: "2026-08-12", zonesFrom: "frozen")
return s
}
@@ -94,6 +95,42 @@ final class DayRolloverTests: XCTestCase {
"les zones sont une version figée, valable des semaines")
}
/// La date de version des zones doit survivre au transport et au passage de
/// minuit — sans elle, la montre affiche des bornes sans âge, et après une
/// révision rien ne distingue les nouvelles des anciennes.
/// Cf. `docs/DIAGNOSTIC-NATIF-2026-09-24.md` § 1.2.
func testZonesCarryTheirVersionDate() {
let vu = snapshot(at: "2026-08-10 12:00").asOf(date("2026-08-18 09:00"), calendar: zurich)
XCTAssertEqual(vu.zones?.zonesSince, "2026-08-12",
"la date de la version figée se perdait au transport")
XCTAssertEqual(vu.zones?.zonesFrom, "frozen",
"« figée » et « provisoire » ne doivent pas se confondre au poignet")
}
/// Le snapshot voyage en JSON jusqu'à la montre : ce qui n'est pas encodé
/// n'arrive pas. Le test précédent vérifie la recopie en mémoire, celui-ci
/// le passage par le fil.
func testVersionDateSurvivesJsonRoundTrip() throws {
let original = snapshot(at: "2026-08-10 12:00")
let data = try JSONEncoder().encode(original)
let relu = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: data)
XCTAssertEqual(relu.zones?.zonesSince, "2026-08-12")
XCTAssertEqual(relu.zones?.zonesFrom, "frozen")
}
/// ⚠️ Un snapshot écrit AVANT l'ajout de ces champs doit rester décodable :
/// la montre en garde un en cache, et un décodage qui lève la laisserait
/// sans zones du tout — pire que des zones sans date.
func testOlderSnapshotWithoutVersionDateStillDecodes() throws {
// `updatedAt` est un `Date` : l'encodeur par défaut l'écrit en secondes
// depuis la date de référence, pas en ISO — comme les autres tests de
// compatibilité ascendante de ce banc (cf. QuickDrinkTests).
let json = #"{"updatedAt": 1755000000, "zones": {"z2": [109, 118], "fcmaxUsed": 152}}"#
let relu = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: Data(json.utf8))
XCTAssertEqual(relu.zones?.fcmaxUsed, 152)
XCTAssertNil(relu.zones?.zonesSince, "absente, pas inventée")
}
// MARK: - Au-delà du lendemain : ne rien inventer
func testAfterTwoDaysTheSessionIsUnknown() {