diff --git a/docs/DIAGNOSTIC-NATIF-2026-09-24.md b/docs/DIAGNOSTIC-NATIF-2026-09-24.md index 6907a72..5340038 100644 --- a/docs/DIAGNOSTIC-NATIF-2026-09-24.md +++ b/docs/DIAGNOSTIC-NATIF-2026-09-24.md @@ -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. diff --git a/ios/App/App/CoachWidgetBridge.swift b/ios/App/App/CoachWidgetBridge.swift index b0d3c5c..1184b9f 100644 --- a/ios/App/App/CoachWidgetBridge.swift +++ b/ios/App/App/CoachWidgetBridge.swift @@ -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. diff --git a/ios/App/App/HeartRateZones.swift b/ios/App/App/HeartRateZones.swift index 25c0d44..4425716 100644 --- a/ios/App/App/HeartRateZones.swift +++ b/ios/App/App/HeartRateZones.swift @@ -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 } diff --git a/tests-linux/Sources/CoachModel/HeartRateZonesStub.swift b/tests-linux/Sources/CoachModel/HeartRateZonesStub.swift index 502f484..2beb3a8 100644 --- a/tests-linux/Sources/CoachModel/HeartRateZonesStub.swift +++ b/tests-linux/Sources/CoachModel/HeartRateZonesStub.swift @@ -18,4 +18,6 @@ struct HeartRateZones: Codable, Equatable, Sendable { var z5: [Int]? var fcmaxUsed: Int? var clinicalTargetBpm: Int? + var zonesSince: String? + var zonesFrom: String? } diff --git a/tests-linux/Tests/CoachModelTests/DayRolloverTests.swift b/tests-linux/Tests/CoachModelTests/DayRolloverTests.swift index 03a7393..cc6bfb2 100644 --- a/tests-linux/Tests/CoachModelTests/DayRolloverTests.swift +++ b/tests-linux/Tests/CoachModelTests/DayRolloverTests.swift @@ -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() {