Watch : cible clinique, couleurs alignées sur l'iPhone, aperçus Xcode

- `HeartRateZones` porte `clinicalTargetBpm` et `isUseful(bpm:)`. Sous
  bêtabloquant, le repère utile n'est PAS le bas de Z4 mais HRKarv0,60
  (Díaz-Buschmann 2014, ESC : la Karvonen standard sous-évalue le seuil chez le
  patient traité). Un sceau vert l'indique sous la plage, et VoiceOver le dit —
  une icône seule ne suffit pas.
- Z1 passe de `.blue` à `.cyan` : `CoachLiveView` (iPhone) utilise déjà cyan.
  Le même effort doit avoir la même teinte sur les deux écrans de la même app ;
  j'avais introduit la divergence en écrivant le modèle sans regarder l'existant.
- Deux `#Preview` avec les zones RÉELLES de production (Z1 100-109 … Z5
  134-143) : les cinq états d'un coup, plus le cas « zones inconnues ». 109 bpm
  y figure exprès — c'est le plancher de Z2, il vérifie la règle du
  chevauchement d'un battement.

⚠️ `CoachLiveView.CardiacZones` reste une SECONDE implémentation de la même
règle, avec sa propre table de zones. Non fusionnée ici pour ne pas mélanger
deux chantiers ; c'est le même motif que les deux traductions WorkoutKit
divergentes corrigées ce matin.

⚠️ NON COMPILÉ — build Xcode requis.
This commit is contained in:
Sylvain Bettinelli
2026-08-12 19:20:16 +00:00
parent eb819b68ca
commit fb4033130f
2 changed files with 65 additions and 6 deletions

View File

@@ -25,6 +25,18 @@ struct HeartRateZones: Codable, Equatable, Sendable {
var z5: [Int]? var z5: [Int]?
/// FC max de référence retenue. Sert à situer une FC au-delà de Z5. /// FC max de référence retenue. Sert à situer une FC au-delà de Z5.
var fcmaxUsed: Int? 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?
/// 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. /// Zone contenant cette FC, ou `nil` si aucune ne la couvre.
/// ///
@@ -78,13 +90,15 @@ enum HeartRateZone: String, CaseIterable, Codable, Sendable {
} }
} }
/// 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 » /// La couleur ne porte JAMAIS l'information seule : le libellé « Z3 »
/// l'accompagne toujours. Une pastille de couleur nue est illisible pour /// 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 /// un daltonien, et le contraste d'un écran de montre en plein soleil ne
/// se prête pas aux nuances. /// se prête pas aux nuances.
var color: Color { var color: Color {
switch self { switch self {
case .z1: return .blue case .z1: return .cyan
case .z2: return .green case .z2: return .green
case .z3: return .yellow case .z3: return .yellow
case .z4: return .orange case .z4: return .orange

View File

@@ -202,15 +202,31 @@ private struct HeartRateMetric: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if let zone, let bounds = zones?[zone], bounds.count == 2 { if let zone, let bounds = zones?[zone], bounds.count == 2 {
Text("\(zone.name) · \(bounds[0])\(bounds[1])") HStack(spacing: 4) {
.font(.caption2) Text("\(zone.name) · \(bounds[0])\(bounds[1])")
.foregroundStyle(.secondary) // Sous bêtabloquant, le repère qui compte n'est pas le bas
// de Z4 mais HRKarv0,60 (Díaz-Buschmann 2014, ESC) : la
// Karvonen standard sous-évalue le seuil chez le patient
// traité. L'écran iPhone l'affiche déjà.
if zones?.isUseful(bpm: bpm) == true {
Image(systemName: "checkmark.seal.fill")
.foregroundStyle(.green)
.accessibilityHidden(true)
}
}
.font(.caption2)
.foregroundStyle(.secondary)
} }
} }
.accessibilityElement(children: .ignore) .accessibilityElement(children: .ignore)
.accessibilityLabel("Fréquence cardiaque") .accessibilityLabel("Fréquence cardiaque")
.accessibilityValue(zone.map { "\(bpm) battements par minute, zone \($0.label), \($0.name)" } .accessibilityValue(accessibilityDescription)
?? "\(bpm) battements par minute") }
private var accessibilityDescription: String {
guard let zone else { return "\(bpm) battements par minute" }
let useful = zones?.isUseful(bpm: bpm) == true ? ", zone d'entraînement utile" : ""
return "\(bpm) battements par minute, zone \(zone.label), \(zone.name)\(useful)"
} }
} }
@@ -244,3 +260,32 @@ private struct Metric: View {
.environmentObject(WorkoutManager.shared) .environmentObject(WorkoutManager.shared)
.environmentObject(ConnectivityManager.shared) .environmentObject(ConnectivityManager.shared)
} }
// Zones réelles de production, figées le 12/08/2026 pour voir le rendu sans
// lancer de séance ni brancher la montre. Ouvre ce fichier dans Xcode et
// active le canevas () : les cinq états s'affichent d'un coup.
private let previewZones = HeartRateZones(
z1: [100, 109], z2: [109, 118], z3: [118, 126],
z4: [126, 134], z5: [134, 143],
fcmaxUsed: 143, clinicalTargetBpm: 109
)
#Preview("FC · les 5 zones") {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
// 104 Z1, 115 Z2 (la cible des séances CdC), 122 Z3,
// 130 Z4, 141 Z5. 109 vérifie la règle du chevauchement :
// c'est le plancher de Z2, il ne doit PAS s'afficher Z1.
ForEach([104, 109, 115, 122, 130, 141], id: \.self) { bpm in
HeartRateMetric(bpm: bpm, zones: previewZones)
}
}
.padding(.horizontal, 4)
}
}
#Preview("FC · zones inconnues") {
// Repli quand l'iPhone n'a jamais poussé de snapshot : la FC reste
// lisible, en rouge, sans zone inventée.
HeartRateMetric(bpm: 128, zones: nil)
}