Files
coach-ios/ios/App/App/HeartRateZones.swift
Sylvain Bettinelli fb4033130f 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.
2026-08-12 19:20:16 +00:00

109 lines
4.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 Z1Z5 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
}
}
}