Test de VMA guide au poignet

Pendant un test, le telephone est dans une poche : la montre est le seul ecran
consultable en courant, et c'est elle qui porte deja vitesse, distance et FC via
WorkoutManager.

Deux protocoles. Paliers d'une minute de 8 km/h par pas de 0,5, avec haptique au
changement et l'ecart a l'allure cible affiche — la vitesse brute demanderait un
calcul mental a chaque foulee. Ou six minutes, la distance parcourue donnant
directement la VMA.

⚠️ AUCUN calcul cote montre : elle transmet des mesures (paliers tenus,
distance, FC max), le serveur calcule. Deux implementations d'une meme formule
divergent toujours. Le POST passe par CoachRoutineBridge, qui a deja
l'abonnement NotificationCenter, la base d'API et le cookie — un fichier de plus
devrait etre cable a la main dans le pbxproj.

⚠️ Les paliers avancent sur `elapsedSec` de HKLiveWorkoutBuilder, PAS sur un
compteur accumule au fil du Timer de la vue : ce Timer ne tourne pas quand
l'ecran s'eteint, c'est-a-dire des que le poignet retombe — soit pendant
l'essentiel du test. Meme famille de piege que la Live Activity fantome.

L'envoi reprend la livraison differee de la routine : un test se fait dehors,
l'iPhone peut etre hors de portee, et refaire un test maximal parce qu'un
message s'est perdu n'est pas acceptable. Si l'envoi echoue, les chiffres
restent affiches pour une saisie manuelle.

VmaTestView.swift est un fichier NOUVEAU : cable dans le pbxproj (4 entrees,
sauvegarde project.pbxproj.bak-vma). Verification : `grep -c "VmaTestView.swift
in Sources"` doit rendre 2 (declaration + phase de compilation = 1 cible).

⚠️ NON COMPILE — a builder au prochain passage sur le Mac.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-11 18:47:03 +00:00
parent 3202e03a63
commit a61ba25590
7 changed files with 1435 additions and 0 deletions

View File

@@ -30,6 +30,7 @@
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; };
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; };
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */; };
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */; };
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; };
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; };
880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.framework */; };
@@ -167,6 +168,7 @@
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineGuidedView.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VmaTestView.swift; sourceTree = "<group>"; };
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = "<group>"; };
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; };
D61D7B2F5AFEF834423E07AB /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
@@ -319,6 +321,7 @@
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */,
AA5C5902EFB7F60563678E46 /* RoutineView.swift */,
C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */,
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */,
969DB880395BDEB107C9F2FE /* RoutineStore.swift */,
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */,
3342ACCE085010828CF63565 /* Assets.xcassets */,
@@ -574,6 +577,7 @@
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
95D0478A499056FE922211EA /* RoutineView.swift in Sources */,
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */,
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */,
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
);

File diff suppressed because it is too large Load Diff

View File

@@ -135,6 +135,14 @@ extension CoachLiveBridgePlugin: WCSessionDelegate {
)
return true
}
// Résultat d'un test de VMA fait au poignet. Même aiguillage que la
// routine : ce plugin est l'unique WCSession.delegate côté iPhone.
if message["vmaTest"] as? Bool == true {
NotificationCenter.default.post(
name: .coachVmaFromWatch, object: nil, userInfo: message
)
return true
}
// Pause / reprise : pilote le libellé du bouton de la Live Activity.
if let paused = message["paused"] as? Bool {
liveLog.info("état pause reçu de la Watch : \(paused, privacy: .public)")

View File

@@ -10,6 +10,8 @@ private let routineLog = Logger(subsystem: "ch.hypnotruck.coach", category: "rou
/// message et le relaie ici plutôt que de l'émettre comme sample de séance.
extension Notification.Name {
static let coachRoutineFromWatch = Notification.Name("coachRoutineFromWatch")
/// Résultat d'un test de VMA fait au poignet, relayé par le même aiguillage.
static let coachVmaFromWatch = Notification.Name("coachVmaFromWatch")
}
/// Pont routine quotidienne iPhone Apple Watch.
@@ -46,6 +48,16 @@ public class CoachRoutineBridgePlugin: CAPPlugin, CAPBridgedPlugin {
name: .coachRoutineFromWatch,
object: nil
)
// Le test de VMA vit dans ce plugin plutôt que dans un fichier dédié :
// il a déjà l'abonnement NotificationCenter, la base d'API et le cookie
// d'authentification, et un fichier de plus devrait être câblé à la main
// dans le pbxproj (la cible App n'est pas un dossier synchronisé).
NotificationCenter.default.addObserver(
self,
selector: #selector(handleVmaFromWatch(_:)),
name: .coachVmaFromWatch,
object: nil
)
}
deinit {
@@ -184,6 +196,54 @@ public class CoachRoutineBridgePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
// MARK: - Test de VMA fait au poignet
/// La montre n'envoie que des MESURES (paliers tenus, distance, FC max) ;
/// le calcul de la VMA se fait côté serveur, en un seul endroit. Deux
/// implémentations d'une même formule finissent toujours par diverger.
@objc private func handleVmaFromWatch(_ note: Notification) {
guard let info = note.userInfo as? [String: Any] else { return }
var body: [String: Any] = [:]
body["protocol"] = info["protocol"] as? String ?? "stages"
if let hr = (info["maxHr"] as? NSNumber)?.intValue, hr > 0 { body["max_hr"] = hr }
if let d = (info["distanceM"] as? NSNumber)?.doubleValue { body["distance_m"] = d }
if let s = (info["startKmh"] as? NSNumber)?.doubleValue { body["start_kmh"] = s }
if let s = (info["stepKmh"] as? NSNumber)?.doubleValue { body["step_kmh"] = s }
if let c = (info["completed"] as? NSNumber)?.intValue { body["completed"] = c }
if let r = (info["partialRatio"] as? NSNumber)?.doubleValue { body["partial_ratio"] = r }
body["note"] = "Test guidé depuis l'Apple Watch"
postVma(body) { [weak self] ok, kmh in
routineLog.info("POST /api/vma : \(ok ? "ok" : "echec", privacy: .public)")
DispatchQueue.main.async {
self?.notifyListeners("vmaRecorded", data: ["ok": ok, "vma_kmh": kmh ?? 0])
}
}
}
private func postVma(_ body: [String: Any],
completion: @escaping (Bool, Double?) -> Void) {
guard let url = URL(string: "\(Self.apiBase)/api/vma") else {
completion(false, nil)
return
}
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
req.timeoutInterval = 15
URLSession.shared.dataTask(with: req) { data, response, _ in
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
guard (200..<300).contains(code), let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
completion(false, nil)
return
}
completion(true, (json["vma_kmh"] as? NSNumber)?.doubleValue)
}.resume()
}
private static func todayISO() -> String {
let fmt = DateFormatter()
fmt.dateFormat = "yyyy-MM-dd"

View File

@@ -102,6 +102,27 @@ final class ConnectivityManager: NSObject, ObservableObject {
return true
}
/// Envoie le résultat brut d'un test de VMA (paliers tenus ou distance).
///
/// Livraison différée garantie comme la routine : un test se fait dehors,
/// l'iPhone peut être hors de portée, et refaire un test maximal parce que
/// le message s'est perdu n'est pas acceptable. Le calcul de la VMA se fait
/// côté serveur la montre ne transmet que des mesures.
@discardableResult
func sendVmaResult(_ payload: [String: Any]) -> Bool {
let session = WCSession.default
guard session.activationState == .activated else { return false }
if session.isReachable {
session.sendMessage(payload, replyHandler: nil) { _ in
session.transferUserInfo(payload)
}
} else {
session.transferUserInfo(payload)
}
connLog.info("résultat de test VMA envoyé à l'iPhone")
return true
}
/// Signale à l'iPhone que la séance est en pause / a repris, pour que le
/// bouton de la Live Activity affiche le bon état.
func sendPaused(_ paused: Bool) {

View File

@@ -25,6 +25,7 @@ struct ContentView: View {
struct ActivityPickerView: View {
@EnvironmentObject private var workout: WorkoutManager
@State private var showRoutine = false
@State private var showVma = false
private struct Activity: Identifiable {
let id = UUID()
@@ -63,6 +64,14 @@ struct ActivityPickerView: View {
}
.tint(.cyan)
Button {
showVma = true
} label: {
Label("Test VMA", systemImage: "speedometer")
.frame(maxWidth: .infinity, alignment: .leading)
}
.tint(.orange)
Button {
workout.startSimulation()
} label: {
@@ -83,6 +92,9 @@ struct ActivityPickerView: View {
.sheet(isPresented: $showRoutine) {
RoutineView()
}
.sheet(isPresented: $showVma) {
VmaTestView()
}
}
}

View File

@@ -0,0 +1,255 @@
// VmaTestView.swift
// Test de VMA guidé au poignet, cible CoachWatch.
//
// Pourquoi au poignet plutôt que sur le téléphone : pendant un test, le
// téléphone est dans une poche. La montre est le seul écran consultable en
// courant, et c'est elle qui porte déjà la vitesse, la distance et la FC via
// WorkoutManager (HKLiveWorkoutBuilder).
//
// Aucun calcul de VMA ici : la vue mesure (paliers tenus, distance) et
// envoie ces mesures à l'iPhone, qui les poste à /api/vma. Le calcul vit dans
// web/vma.py, en un seul endroit deux implémentations d'une même formule
// finissent toujours par diverger.
//
// Sous bêtabloquant, la FC ne sert PAS à mesurer : elle est affichée pour
// que l'utilisateur voie son plafond arriver, et transmise pour que le serveur
// signale un test interrompu par le cœur plutôt que par les jambes.
import SwiftUI
import HealthKit
import WatchKit
struct VmaTestView: View {
enum Protocole: String, CaseIterable {
case stages = "Paliers"
case sixMin = "6 minutes"
}
@StateObject private var workout = WorkoutManager.shared
@Environment(\.dismiss) private var dismiss
// Paramètres du protocole à paliers. Départ volontairement bas : la marche
// rapide est à 6 km/h, un départ à 8 laisse une vraie marge de montée.
private let startKmh = 8.0
private let stepKmh = 0.5
private let stageSeconds = 60.0
@State private var proto: Protocole = .stages
@State private var started = false
@State private var finished = false
@State private var stageIndex = 0 // 0 = premier palier en cours
@State private var maxHr = 0.0
@State private var sent = false
@State private var sendError: String?
private let tick = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect()
private var targetKmh: Double { startKmh + stepKmh * Double(stageIndex) }
private var completedStages: Int { stageIndex } // paliers TERMINÉS
private var sixMinRemaining: Double { max(0, 360 - workout.elapsedSec) }
/// Avancement dans le palier courant, dérivé de la durée de séance et NON
/// d'un compteur accumulé au fil du Timer.
///
/// Le Timer de la vue ne tourne pas quand l'écran s'éteint et il
/// s'éteint dès que le poignet retombe, c'est-à-dire pendant l'essentiel du
/// test. Un décompte accumulé dériverait donc de plusieurs secondes par
/// palier. `elapsedSec` vient de HKLiveWorkoutBuilder, qui continue de
/// courir en arrière-plan : c'est la seule horloge fiable ici.
private var stageElapsed: Double {
workout.elapsedSec.truncatingRemainder(dividingBy: stageSeconds)
}
var body: some View {
Group {
if finished { resultView }
else if started { runningView }
else { setupView }
}
.onReceive(tick) { _ in onTick() }
.navigationTitle("Test VMA")
}
// MARK: - Avant le départ
private var setupView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
Picker("Protocole", selection: $proto) {
ForEach(Protocole.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}
.labelsHidden()
.frame(height: 60)
Text(proto == .stages
? "Paliers d'une minute, de 8 km/h par pas de 0,5. La montre vibre à chaque changement. Arrête-toi quand tu ne tiens plus l'allure."
: "Six minutes à allure maximale régulière, sur terrain plat. La distance parcourue donne la VMA.")
.font(.caption2)
.foregroundStyle(.secondary)
Button("Démarrer") { start() }
.buttonStyle(.borderedProminent)
.tint(.orange)
}
.padding(.horizontal, 4)
}
}
// MARK: - Pendant le test
private var runningView: some View {
VStack(spacing: 6) {
if proto == .stages {
Text("Palier \(stageIndex + 1)")
.font(.caption2).foregroundStyle(.secondary)
Text(String(format: "%.1f", targetKmh))
.font(.system(size: 44, weight: .semibold, design: .rounded))
.monospacedDigit()
Text("km/h visés").font(.caption2).foregroundStyle(.secondary)
ProgressView(value: min(1, stageElapsed / stageSeconds))
.tint(.orange)
// Écart à la cible : c'est la seule information qui aide à
// corriger en courant. La vitesse brute demanderait un calcul
// mental à chaque foulée.
Text(paceHint)
.font(.caption)
.foregroundStyle(paceOnTarget ? .green : .orange)
} else {
Text(timeString(sixMinRemaining))
.font(.system(size: 44, weight: .semibold, design: .rounded))
.monospacedDigit()
Text("restant").font(.caption2).foregroundStyle(.secondary)
Text(String(format: "%.0f m", workout.distanceMeters))
.font(.title3).monospacedDigit()
}
HStack(spacing: 10) {
Label(String(format: "%.0f", workout.heartRate), systemImage: "heart.fill")
.foregroundStyle(.red)
Label(String(format: "%.1f", workout.speedKmh), systemImage: "speedometer")
}
.font(.caption2)
.monospacedDigit()
Button("J'arrête") { finish() }
.buttonStyle(.bordered)
.tint(.red)
}
.padding(.horizontal, 4)
}
private var paceOnTarget: Bool { abs(workout.speedKmh - targetKmh) <= 0.4 }
private var paceHint: String {
// La vitesse GPS est erratique les premières secondes d'acquisition :
// pas d'indication tant que la montre n'a rien de crédible.
guard workout.speedKmh > 1 else { return "acquisition…" }
let d = workout.speedKmh - targetKmh
if abs(d) <= 0.4 { return "allure tenue" }
return d < 0 ? String(format: "accélère (%.1f)", -d) : String(format: "ralentis (%.1f)", d)
}
// MARK: - Après
private var resultView: some View {
ScrollView {
VStack(spacing: 8) {
if proto == .stages {
Text("\(completedStages) paliers")
.font(.title3)
Text(completedStages > 0
? String(format: "dernier tenu : %.1f km/h", startKmh + stepKmh * Double(completedStages - 1))
: "aucun palier terminé")
.font(.caption2).foregroundStyle(.secondary)
} else {
Text(String(format: "%.0f m", workout.distanceMeters)).font(.title3)
Text("en 6 minutes").font(.caption2).foregroundStyle(.secondary)
}
if maxHr > 0 {
Text(String(format: "FC max %.0f bpm", maxHr))
.font(.caption2).foregroundStyle(.secondary)
}
if sent {
Label("Envoyé au téléphone", systemImage: "checkmark.circle.fill")
.font(.caption2).foregroundStyle(.green)
} else if let e = sendError {
// Échec d'envoi test perdu : les chiffres restent lisibles
// à l'écran, l'utilisateur peut les saisir dans l'app.
Text(e).font(.caption2).foregroundStyle(.orange)
Button("Réessayer") { send() }.buttonStyle(.bordered)
}
Button("Terminer") { dismiss() }.buttonStyle(.bordered)
}
.padding(.horizontal, 4)
}
}
// MARK: - Machine à états
private func start() {
stageIndex = 0
maxHr = 0
sent = false
sendError = nil
finished = false
started = true
// startWorkout est async (@MainActor) : même appel que ContentView.
Task { await workout.startWorkout(activityType: .running, locationType: .outdoor) }
WKInterfaceDevice.current().play(.start)
}
private func onTick() {
guard started, !finished else { return }
if workout.heartRate > maxHr { maxHr = workout.heartRate }
if proto == .stages {
let reached = Int(workout.elapsedSec / stageSeconds)
if reached > stageIndex {
stageIndex = reached // le palier vient d'être TERMINÉ
WKInterfaceDevice.current().play(.notification)
}
} else if workout.elapsedSec >= 360 {
finish()
}
}
private func finish() {
guard !finished else { return }
finished = true
started = false
workout.end()
WKInterfaceDevice.current().play(.success)
send()
}
private func send() {
sendError = nil
var payload: [String: Any] = [
"vmaTest": true,
"protocol": proto == .stages ? "stages" : "cooper6",
"maxHr": Int(maxHr.rounded()),
]
if proto == .stages {
payload["startKmh"] = startKmh
payload["stepKmh"] = stepKmh
payload["completed"] = completedStages
// Fraction du palier en cours au moment de l'arrêt : le serveur
// n'en crédite que la moitié, et seulement au-delà de 50 %.
payload["partialRatio"] = min(1.0, stageElapsed / stageSeconds)
} else {
payload["distanceM"] = Int(workout.distanceMeters.rounded())
}
if ConnectivityManager.shared.sendVmaResult(payload) {
sent = true
} else {
sendError = "iPhone injoignable — note les chiffres."
}
}
private func timeString(_ s: Double) -> String {
let t = Int(s.rounded())
return String(format: "%d:%02d", t / 60, t % 60)
}
}