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

@@ -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 }
// updateApplicationContext : dernier état, sans budget (≠ transferCurrent
// ComplicationUserInfo qui réveille la complication mais est rationné ~50/j).
try? session.updateApplicationContext(["widgetSnapshot": data])
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])
// 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 }