- `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.
109 lines
4.2 KiB
Swift
109 lines
4.2 KiB
Swift
// 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?
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
}
|