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:
@@ -1,6 +1,18 @@
|
|||||||
# Diagnostic natif — les cinq constats ouverts de l'audit, vérifiés
|
# 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
|
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.
|
**[rapporté]** — issus de revues automatisées, jamais reconfrontés au code.
|
||||||
L'audit lui-même le disait : « les confirmer avant d'agir ».
|
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
|
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
|
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.
|
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.
|
||||||
|
|||||||
@@ -13,10 +13,18 @@
|
|||||||
// forme: { score: 72, label:"Prêt" }
|
// forme: { score: 72, label:"Prêt" }
|
||||||
// });
|
// });
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
import Capacitor
|
import Capacitor
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
import WatchConnectivity
|
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)
|
@objc(CoachWidgetBridgePlugin)
|
||||||
public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
public let identifier = "CoachWidgetBridgePlugin"
|
public let identifier = "CoachWidgetBridgePlugin"
|
||||||
@@ -164,8 +172,15 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() }
|
if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() }
|
||||||
pushToWatch(snapshot)
|
// Le statut d'envoi remonte au JS : jusqu'ici, la page n'avait aucun
|
||||||
call.resolve(["ok": true])
|
// 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
|
/// 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.
|
/// de SA montre, alimenté ici via WatchConnectivity.
|
||||||
/// On n'est PAS le delegate WCSession (c'est CoachLiveBridge) : on se contente
|
/// 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.
|
/// d'émettre, ce qui ne requiert pas d'être delegate, juste une session activée.
|
||||||
private func pushToWatch(_ snapshot: CoachWidgetSnapshot) {
|
/// Rend la raison de l'échec, ou `nil` si le snapshot est parti.
|
||||||
guard WCSession.isSupported() else { return }
|
///
|
||||||
|
/// ⚠️ 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
|
let session = WCSession.default
|
||||||
guard session.activationState == .activated,
|
guard session.activationState == .activated else {
|
||||||
let data = try? JSONEncoder().encode(snapshot) else { return }
|
widgetLog.error("snapshot non poussé : session non activée (état \(session.activationState.rawValue, privacy: .public))")
|
||||||
// updateApplicationContext : dernier état, sans budget (≠ transferCurrent
|
return "session WatchConnectivity non activée"
|
||||||
// ComplicationUserInfo qui réveille la complication mais est rationné ~50/j).
|
}
|
||||||
try? session.updateApplicationContext(["widgetSnapshot": data])
|
// `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.
|
/// Relit le snapshot RÉELLEMENT stocké dans l'App Group.
|
||||||
|
|||||||
@@ -32,6 +32,23 @@ struct HeartRateZones: Codable, Equatable, Sendable {
|
|||||||
/// vers Z2-Z3. C'est le repère clinique, celui qui compte ici.
|
/// vers Z2-Z3. C'est le repère clinique, celui qui compte ici.
|
||||||
var clinicalTargetBpm: Int?
|
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 ?
|
/// L'effort est-il dans la zone d'entraînement utile ?
|
||||||
func isUseful(bpm: Int) -> Bool {
|
func isUseful(bpm: Int) -> Bool {
|
||||||
guard let target = clinicalTargetBpm else { return false }
|
guard let target = clinicalTargetBpm else { return false }
|
||||||
|
|||||||
@@ -18,4 +18,6 @@ struct HeartRateZones: Codable, Equatable, Sendable {
|
|||||||
var z5: [Int]?
|
var z5: [Int]?
|
||||||
var fcmaxUsed: Int?
|
var fcmaxUsed: Int?
|
||||||
var clinicalTargetBpm: Int?
|
var clinicalTargetBpm: Int?
|
||||||
|
var zonesSince: String?
|
||||||
|
var zonesFrom: String?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ final class DayRolloverTests: XCTestCase {
|
|||||||
s.kcalToday = 1186
|
s.kcalToday = 1186
|
||||||
s.waterGoalMl = 2750
|
s.waterGoalMl = 2750
|
||||||
s.kcalGoal = 1950
|
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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +95,42 @@ final class DayRolloverTests: XCTestCase {
|
|||||||
"les zones sont une version figée, valable des semaines")
|
"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
|
// MARK: - Au-delà du lendemain : ne rien inventer
|
||||||
|
|
||||||
func testAfterTwoDaysTheSessionIsUnknown() {
|
func testAfterTwoDaysTheSessionIsUnknown() {
|
||||||
|
|||||||
Reference in New Issue
Block a user