Files
coach-ios/ios/App/App/HeartRateZones.swift
Sylvain Bettinelli 7e7be4a1d1 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>
2026-09-24 08:08:02 +00:00

126 lines
5.2 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// HeartRateZones.swift
// Zones cardiaques transportées vers la montre et les widgets.
//
// ⚠️ Ce fichier doit appartenir aux cibles App, CoachLiveActivity ET CoachWatch
// — cf. docs/widgets-runbook-mac.md (étape « Target Membership »). C'est le
// modèle partagé, il ne doit exister qu'en un exemplaire.
//
// **Pourquoi ces zones voyagent.** Pendant une séance, la montre n'affichait
// que la FC brute : « 142 bpm », sans dire dans quelle zone on se trouve. C'est
// pourtant au poignet que le chiffre a besoin d'être traduit — l'écran iPhone
// portait cette information, mais on ne le regarde pas en courant.
//
// ⚠️ Les bornes viennent de `/api/cardiac-zones`, qui sert la version **figée**
// et datée : la même que le calendrier et que les bornes poussées à WorkoutKit.
// Ne jamais les recalculer côté natif — ce serait rouvrir les jeux de zones
// concurrents supprimés le 12/08/2026.
import SwiftUI
/// Bornes Z1…Z5 en battements par minute, telles que servies par le backend.
struct HeartRateZones: Codable, Equatable, Sendable {
var z1: [Int]?
var z2: [Int]?
var z3: [Int]?
var z4: [Int]?
var z5: [Int]?
/// FC max de référence retenue. Sert à situer une FC au-delà de Z5.
var fcmaxUsed: Int?
/// Cible d'entraînement utile sous bêtabloquant : HRKarv0,60 — 60 % de la
/// réserve cardiaque. ⚠️ Ce n'est PAS le bas de Z4 : Díaz-Buschmann 2014
/// (Eur J Prev Cardiol, ESC) montre que la Karvonen standard sous-évalue
/// le seuil anaérobie chez le patient bêtabloqué, et situe la zone utile
/// 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 }
return bpm >= target
}
/// Zone contenant cette FC, ou `nil` si aucune ne la couvre.
///
/// Les bornes se chevauchent d'un battement côté backend (Z1 100-109,
/// Z2 109-118) : on prend la zone la plus HAUTE qui contient la valeur,
/// sinon 109 bpm s'afficherait en Z1 alors que c'est le plancher de Z2.
func zone(for bpm: Int) -> HeartRateZone? {
for zone in HeartRateZone.allCases.reversed() {
guard let bounds = self[zone], bounds.count == 2 else { continue }
if bpm >= bounds[0] && bpm <= bounds[1] { return zone }
}
// Au-dessus de la borne haute de Z5 : on reste en Z5 plutôt que de
// n'afficher rien. Un effort maximal n'est pas une absence de zone.
if let top = self[.z5], top.count == 2, bpm > top[1] { return .z5 }
return nil
}
subscript(zone: HeartRateZone) -> [Int]? {
switch zone {
case .z1: return z1
case .z2: return z2
case .z3: return z3
case .z4: return z4
case .z5: return z5
}
}
}
/// Les cinq zones, de la récupération à la VO₂ max.
enum HeartRateZone: String, CaseIterable, Codable, Sendable {
case z1, z2, z3, z4, z5
var label: String {
switch self {
case .z1: return "Z1"
case .z2: return "Z2"
case .z3: return "Z3"
case .z4: return "Z4"
case .z5: return "Z5"
}
}
/// Nom de la zone, pour VoiceOver et l'écran de fin de séance.
var name: String {
switch self {
case .z1: return "Récupération"
case .z2: return "Endurance"
case .z3: return "Tempo"
case .z4: return "Seuil"
case .z5: return "VO2 max"
}
}
/// ⚠️ Couleurs alignées sur `CoachLiveView` (iPhone) : le même effort doit
/// avoir la même teinte sur les deux écrans de la même app.
/// ⚠️ La couleur ne porte JAMAIS l'information seule : le libellé « Z3 »
/// l'accompagne toujours. Une pastille de couleur nue est illisible pour
/// un daltonien, et le contraste d'un écran de montre en plein soleil ne
/// se prête pas aux nuances.
var color: Color {
switch self {
case .z1: return .cyan
case .z2: return .green
case .z3: return .yellow
case .z4: return .orange
case .z5: return .red
}
}
}