diff --git a/ios/App/App/CoachWidgetBridge.swift b/ios/App/App/CoachWidgetBridge.swift index d0d4d6a..b0d3c5c 100644 --- a/ios/App/App/CoachWidgetBridge.swift +++ b/ios/App/App/CoachWidgetBridge.swift @@ -55,9 +55,33 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { // en NSNumber via le bridge ; `.intValue` couvre Int comme Double. forme = CoachWidgetSnapshot.FormeScore( score: (f["score"] as? NSNumber)?.intValue, - label: f["label"] as? String + label: f["label"] as? String, + // Bande servie par le serveur (`app.recovery_band`). Les widgets + // la coloraient avec des seuils à eux, périmés : le vert était + // devenu inatteignable. Absente ⇒ `resolvedBand` retombe sur les + // seuils du serveur, jamais sur les anciens. + band: f["band"] as? String ) } + + /* Boutons de saisie rapide, depuis les préréglages de l'utilisateur. + Le widget les avait en dur, dont un café à 100 ml qui ne correspondait + à aucun préréglage. Liste absente ou vide ⇒ repli documenté dans + `CoachWidgetSnapshot.fallbackQuickDrinks`. */ + var quickDrinks: [CoachWidgetSnapshot.QuickDrink]? + if let raw = call.getArray("quickDrinks") as? [[String: Any]] { + let parsed = raw.compactMap { item -> CoachWidgetSnapshot.QuickDrink? in + guard let ref = item["ref"] as? String, + let volume = (item["volumeMl"] as? NSNumber)?.doubleValue, + volume > 0 else { return nil } + return CoachWidgetSnapshot.QuickDrink( + ref: ref, + label: item["label"] as? String ?? ref, + volumeMl: volume + ) + } + if !parsed.isEmpty { quickDrinks = parsed } + } /* Les champs de nutrition sont FACULTATIFS dans l'appel : le pont web peut les omettre (objectifs indisponibles, appel partiel). Dans ce cas on conserve ceux du snapshot existant plutôt que de les effacer. @@ -120,6 +144,9 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { (call.getValue("coffeeCountToday") as? NSNumber)?.intValue ?? previous?.coffeeCountToday snapshot.kcalToday = number("kcalToday") ?? previous?.kcalToday snapshot.kcalGoal = number("kcalGoal") ?? previous?.kcalGoal + // Préréglages : même règle de report, et pas de garde du jour — des + // volumes de verre ne périment pas à minuit. + snapshot.quickDrinks = quickDrinks ?? previous?.quickDrinks /* Zones cardiaques : même règle de report que la nutrition — un appel partiel ne doit pas les effacer, sinon la montre repasserait à la FC nue au premier rafraîchissement incomplet. Pas de garde du jour diff --git a/ios/App/App/CoachWidgetSnapshot.swift b/ios/App/App/CoachWidgetSnapshot.swift index 65b290c..782fc1d 100644 --- a/ios/App/App/CoachWidgetSnapshot.swift +++ b/ios/App/App/CoachWidgetSnapshot.swift @@ -69,6 +69,10 @@ struct CoachWidgetSnapshot: Codable { var kcalToday: Double? = nil var kcalGoal: Double? = nil + /// Boutons de saisie rapide, poussés depuis les préréglages de boisson. + /// `nil` ⇒ repli aligné sur les préréglages par défaut du serveur. + var quickDrinks: [QuickDrink]? = nil + /// Activité RÉELLEMENT réalisée aujourd'hui, indépendante du plan. /// @@ -132,15 +136,77 @@ struct CoachWidgetSnapshot: Codable { } /// Score de forme / récupération (nil = pas encore de score). + /// Un bouton de saisie rapide du widget, tel que l'utilisateur l'a réglé. + /// + /// ⚠️ Les volumes étaient en dur dans le widget, dont un café à 100 ml qui + /// ne correspondait à aucun préréglage : le serveur avait tranché que 60 ml + /// est un expresso et 250 ml un mug, précisément parce qu'un volume unique + /// pour tous les cafés faussait le journal. Ces préréglages sont en outre + /// personnalisables (`/api/drinks/presets`) — les figer ici les ignorait. + struct QuickDrink: Codable, Equatable, Sendable { + var ref: String // "water" | "coffee" | … (drink_type de l'API) + var label: String + var volumeMl: Double + } + + /// Bande de lecture du score de forme, telle que le serveur la nomme + /// (`app.RECOVERY_BANDS`). Le natif la traduit en couleur ; il ne la décide + /// plus. + enum FormeBand: String, Codable, Sendable { + case great, good, medium, low + } + struct FormeScore: Codable { var score: Int? // 0–100 - var label: String? // "Prêt", "Récup", "Fatigue"… + var label: String? // "Excellente", "Bonne", "Correcte", "Basse" + /// Bande servie par le serveur. `nil` pour un snapshot écrit par une + /// version antérieure : on retombe alors sur `resolvedBand`. + var band: String? = nil + + /// Seuils de repli — **ceux du serveur**, pas les anciens. + /// + /// ⚠️ La complication et les widgets coloraient le score avec un barème + /// à eux (vert ≥ 75, jaune ≥ 50). Le serveur avait recalé le sien sur la + /// distribution réelle (65 / 55 / 40) parce que le score est borné à + /// [25, 75] par construction : une bande haute à 75 est inatteignable. + /// Mesuré sur 115 jours de production, le vert n'est jamais sorti et le + /// rouge couvrait 68 jours. La correction n'avait pas suivi jusqu'ici, + /// le binaire ne se déployant qu'au build suivant. + static let fallbackThresholds: [(min: Int, band: FormeBand)] = [ + (65, .great), (55, .good), (40, .medium), + ] + + /// Bande à afficher : celle du serveur, sinon déduite du score. + var resolvedBand: FormeBand? { + if let raw = band, let known = FormeBand(rawValue: raw) { return known } + guard let score = score else { return nil } + for step in Self.fallbackThresholds where score >= step.min { return step.band } + return .low + } } } // MARK: - Péremption au changement de jour extension CoachWidgetSnapshot { + /// Repli des boutons de saisie rapide — **les préréglages par défaut du + /// serveur** (`drink_presets.DEFAULT_PRESETS`), jamais des valeurs inventées. + /// Trois boutons : c'est ce que la largeur du widget permet. + static let fallbackQuickDrinks: [QuickDrink] = [ + QuickDrink(ref: "water", label: "Verre", volumeMl: 200), + QuickDrink(ref: "water", label: "Bouteille", volumeMl: 500), + QuickDrink(ref: "coffee", label: "Expresso", volumeMl: 60), + ] + + /// Boutons à afficher : ceux de l'utilisateur, sinon le repli. + /// + /// Un préréglage sans volume plausible est écarté : un bouton qui + /// enregistrerait 0 ml serait pire que pas de bouton. + var resolvedQuickDrinks: [QuickDrink] { + let usable = (quickDrinks ?? []).filter { $0.volumeMl > 0 && $0.volumeMl <= 2000 } + return usable.isEmpty ? Self.fallbackQuickDrinks : Array(usable.prefix(3)) + } + /// Le snapshot tel qu'il doit s'afficher le jour `day`. /// /// ⚠️ **Le widget n'a aucun accès réseau.** Il rend ce que l'app lui a diff --git a/ios/App/CoachLiveActivity/CoachQuickWidget.swift b/ios/App/CoachLiveActivity/CoachQuickWidget.swift index 32a9e52..f30209b 100644 --- a/ios/App/CoachLiveActivity/CoachQuickWidget.swift +++ b/ios/App/CoachLiveActivity/CoachQuickWidget.swift @@ -63,13 +63,16 @@ struct CoachQuickEntryTimeline: TimelineEntry { let pending: Int let kcal: Double? let kcalGoal: Double? + /// Boutons de saisie rapide, résolus depuis les préréglages de l'utilisateur. + var quickDrinks: [CoachWidgetSnapshot.QuickDrink] = CoachWidgetSnapshot.fallbackQuickDrinks } struct CoachQuickProvider: TimelineProvider { func placeholder(in context: Context) -> CoachQuickEntryTimeline { CoachQuickEntryTimeline( date: Date(), waterMl: 1250, waterGoal: 2750, coffeeCount: 2, pending: 0, - kcal: 1420, kcalGoal: 1950 + kcal: 1420, kcalGoal: 1950, + quickDrinks: CoachWidgetSnapshot.fallbackQuickDrinks ) } @@ -110,7 +113,8 @@ struct CoachQuickProvider: TimelineProvider { // et le badge doit continuer à le signaler. pending: CoachQuickLog.pending().count, kcal: snapshot?.kcalToday, - kcalGoal: snapshot?.kcalGoal + kcalGoal: snapshot?.kcalGoal, + quickDrinks: snapshot?.resolvedQuickDrinks ?? CoachWidgetSnapshot.fallbackQuickDrinks ) } } @@ -121,6 +125,8 @@ struct CoachQuickProvider: TimelineProvider { struct CoachQuickWidgetView: View { var entry: CoachQuickEntryTimeline + private var quickDrinks: [CoachWidgetSnapshot.QuickDrink] { entry.quickDrinks } + private var litres: String { String(format: "%.1f", entry.waterMl / 1000).replacingOccurrences(of: ".", with: ",") } @@ -193,15 +199,18 @@ struct CoachQuickWidgetView: View { } } + // ⚠️ Volumes pilotés par les préréglages de l'utilisateur + // (`/api/drinks/presets`), plus écrits ici. Le café valait 100 ml en + // dur, un volume qui ne correspond à aucun préréglage : le serveur + // avait justement séparé l'expresso (60) du mug (250) parce qu'un + // volume unique faussait le journal. Repli documenté dans + // `CoachWidgetSnapshot.fallbackQuickDrinks`. HStack(spacing: 6) { - Button(intent: LogDrinkIntent(ref: "water", amountMl: 250)) { - Label("25 cl", systemImage: "drop.fill") - } - Button(intent: LogDrinkIntent(ref: "water", amountMl: 500)) { - Label("50 cl", systemImage: "drop.fill") - } - Button(intent: LogDrinkIntent(ref: "coffee", amountMl: 100)) { - Label("Café", systemImage: "cup.and.saucer.fill") + ForEach(Array(quickDrinks.enumerated()), id: \.offset) { _, drink in + Button(intent: LogDrinkIntent(ref: drink.ref, amountMl: drink.volumeMl)) { + Label(drink.label, + systemImage: drink.ref == "coffee" ? "cup.and.saucer.fill" : "drop.fill") + } } } .buttonStyle(.bordered) diff --git a/ios/App/CoachLiveActivity/CoachWidgets.swift b/ios/App/CoachLiveActivity/CoachWidgets.swift index e2fc510..cc7840b 100644 --- a/ios/App/CoachLiveActivity/CoachWidgets.swift +++ b/ios/App/CoachLiveActivity/CoachWidgets.swift @@ -249,11 +249,19 @@ struct CoachTodaySessionWidget: Widget { struct CoachFormeScoreEntryView: View { var entry: CoachSnapshotEntry - private func ringColor(_ score: Int) -> Color { - switch score { - case 75...: return Color(red: 0.20, green: 0.78, blue: 0.35) - case 50..<75: return Color(red: 1.00, green: 0.80, blue: 0.0) - default: return Color(red: 1.00, green: 0.27, blue: 0.23) + /// Couleur de la bande servie par le serveur. + /// + /// ⚠️ Les seuils vivaient ici (75 / 50), périmés face à ceux du serveur + /// (65 / 55 / 40). Le score étant borné à [25, 75] par construction, le vert + /// était inatteignable — 0 jour sur 115 en production. Voir + /// `CoachWidgetSnapshot.FormeScore.resolvedBand`, qui porte le repli. + private func ringColor(_ band: CoachWidgetSnapshot.FormeBand?) -> Color { + switch band { + case .great: return Color(red: 0.20, green: 0.78, blue: 0.35) + case .good: return Color(red: 0.30, green: 0.85, blue: 0.60) + case .medium: return Color(red: 1.00, green: 0.80, blue: 0.0) + case .low: return Color(red: 1.00, green: 0.27, blue: 0.23) + case nil: return Color.gray } } @@ -270,7 +278,7 @@ struct CoachFormeScoreEntryView: View { Circle().stroke(Color.secondary.opacity(0.18), lineWidth: 9) Circle() .trim(from: 0, to: min(max(Double(score) / 100.0, 0), 1)) - .stroke(ringColor(score), style: StrokeStyle(lineWidth: 9, lineCap: .round)) + .stroke(ringColor(forme?.resolvedBand), style: StrokeStyle(lineWidth: 9, lineCap: .round)) .rotationEffect(.degrees(-90)) VStack(spacing: 0) { Text("\(score)").font(.system(size: 30, weight: .bold, design: .rounded)) diff --git a/ios/App/CoachWatchWidgets/CoachWatchComplications.swift b/ios/App/CoachWatchWidgets/CoachWatchComplications.swift index a1f5432..23644a7 100644 --- a/ios/App/CoachWatchWidgets/CoachWatchComplications.swift +++ b/ios/App/CoachWatchWidgets/CoachWatchComplications.swift @@ -90,12 +90,22 @@ struct CoachWatchFormeView: View { var entry: WatchEntry private var score: Int? { entry.snapshot?.forme?.score } + private var band: CoachWidgetSnapshot.FormeBand? { entry.snapshot?.forme?.resolvedBand } - private func tint(_ s: Int) -> Color { - switch s { - case 75...: return .green - case 50..<75: return .yellow - default: return .red + /// Couleur de la bande servie par le serveur. + /// + /// ⚠️ Les seuils vivaient ici (75 / 50) et ne correspondaient plus à ceux du + /// serveur (65 / 55 / 40). Le score étant borné à [25, 75], le vert était + /// inatteignable : sur 115 jours de production, il n'est jamais sorti, et le + /// rouge couvrait 68 jours. La bande arrive maintenant avec le snapshot ; + /// le repli vit dans `FormeScore.resolvedBand`, aligné sur le serveur. + private func tint(_ band: CoachWidgetSnapshot.FormeBand?) -> Color { + switch band { + case .great: return .green + case .good: return .mint + case .medium: return .yellow + case .low: return .red + case nil: return .gray } } @@ -114,7 +124,7 @@ struct CoachWatchFormeView: View { Text("\(s)") } .gaugeStyle(.accessoryCircular) - .tint(tint(s)) + .tint(tint(band)) } } else { Image(systemName: "bolt.heart") diff --git a/tests-linux/Tests/CoachModelTests/FormeBandTests.swift b/tests-linux/Tests/CoachModelTests/FormeBandTests.swift new file mode 100644 index 0000000..bda0d09 --- /dev/null +++ b/tests-linux/Tests/CoachModelTests/FormeBandTests.swift @@ -0,0 +1,77 @@ +// La bande du score de forme vient du serveur, plus de seuils recopiés. +// +// ⚠️ **Ce qui est en jeu.** La complication watchOS et les widgets d'accueil +// coloraient le score avec leurs propres seuils : vert ≥ 75, jaune 50-74, rouge +// en dessous. Le serveur avait pourtant recalé les siens (65/55/40) en +// documentant pourquoi : le score est borné à [25, 75] par construction, donc +// une bande haute à 75 est inatteignable. +// +// Mesuré sur 115 jours de production : le vert n'est jamais sorti (0 jour), et +// le rouge couvrait 68 jours sur 115. La correction serveur n'avait pas atteint +// le binaire, qui ne se déploie qu'au build suivant. +// +// Le natif ne décide donc plus : il reçoit `band` et se contente de la traduire +// en couleur. Le repli, pour un snapshot écrit par une version antérieure, +// reprend les seuils du serveur — jamais les anciens. +import XCTest +@testable import CoachModel + +final class FormeBandTests: XCTestCase { + + private func snapshot(score: Int?, band: String?) -> CoachWidgetSnapshot { + var s = CoachWidgetSnapshot(updatedAt: Date(timeIntervalSince1970: 1_755_000_000)) + s.forme = CoachWidgetSnapshot.FormeScore(score: score, label: nil, band: band) + return s + } + + func testTheBandFromTheServerIsUsedAsIs() { + let s = snapshot(score: 60, band: "good") + XCTAssertEqual(s.forme?.resolvedBand, .good) + } + + func testTheCaseThatWasWrong() { + // 60 : « bonne récupération » côté serveur, rouge sur la montre. + let s = snapshot(score: 60, band: "good") + XCTAssertEqual(s.forme?.resolvedBand, .good) + XCTAssertNotEqual(s.forme?.resolvedBand, .low) + } + + func testAnUnknownBandFallsBackOnTheScore() { + XCTAssertEqual(snapshot(score: 60, band: "farfelu").forme?.resolvedBand, .good) + } + + func testWithoutABandTheServerThresholdsApply() { + // Repli aligné sur RECOVERY_BANDS : 65 / 55 / 40. + XCTAssertEqual(snapshot(score: 69, band: nil).forme?.resolvedBand, .great) + XCTAssertEqual(snapshot(score: 65, band: nil).forme?.resolvedBand, .great) + XCTAssertEqual(snapshot(score: 64, band: nil).forme?.resolvedBand, .good) + XCTAssertEqual(snapshot(score: 55, band: nil).forme?.resolvedBand, .good) + XCTAssertEqual(snapshot(score: 54, band: nil).forme?.resolvedBand, .medium) + XCTAssertEqual(snapshot(score: 40, band: nil).forme?.resolvedBand, .medium) + XCTAssertEqual(snapshot(score: 39, band: nil).forme?.resolvedBand, .low) + } + + func testTheObservedRangeIsCoveredByMoreThanOneBand() { + // Sur 115 jours, les scores vont de 30 à 69. Un barème qui rendrait la + // même bande sur toute cette plage ne dirait plus rien. + let bands = Set([30, 37, 45, 60, 69].map { + snapshot(score: $0, band: nil).forme?.resolvedBand + }) + XCTAssertGreaterThan(bands.count, 1) + XCTAssertTrue(bands.contains(.great), "le haut de la plage doit être atteignable") + } + + func testNoScoreNoBand() { + XCTAssertNil(snapshot(score: nil, band: nil).forme?.resolvedBand) + } + + func testAnOlderSnapshotStillDecodes() throws { + // Un snapshot écrit avant l'ajout du champ `band` doit rester lisible. + let json = """ + {"updatedAt": 1755000000, "forme": {"score": 58, "label": "Modéré"}} + """.data(using: .utf8)! + let decoded = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: json) + XCTAssertEqual(decoded.forme?.score, 58) + XCTAssertEqual(decoded.forme?.resolvedBand, .good) + } +} diff --git a/tests-linux/Tests/CoachModelTests/QuickDrinkTests.swift b/tests-linux/Tests/CoachModelTests/QuickDrinkTests.swift new file mode 100644 index 0000000..dd76bc2 --- /dev/null +++ b/tests-linux/Tests/CoachModelTests/QuickDrinkTests.swift @@ -0,0 +1,83 @@ +// Les boutons de saisie rapide viennent des préréglages de l'utilisateur. +// +// ⚠️ **Ce qui est en jeu.** Le widget enregistrait un café à 100 ml, en dur. Le +// serveur avait pourtant tranché la question, et documenté pourquoi : « 60 ml, +// c'est un expresso. L'ancien bouton "Café" imposait ce volume à tous les +// cafés, y compris un mug de 250 ml : le journal était faux. » Il propose +// désormais expresso 60 et mug 250, et ces préréglages sont **personnalisables** +// (`GET/PUT /api/drinks/presets`). Le widget ignorait tout cela — 100 ml ne +// correspond à aucun préréglage. +// +// Les volumes voyagent donc dans le snapshot. Le repli, pour un snapshot écrit +// par une version antérieure, reprend les valeurs du serveur — jamais les 100 ml. +import XCTest +@testable import CoachModel + +final class QuickDrinkTests: XCTestCase { + + private func snapshot(_ drinks: [CoachWidgetSnapshot.QuickDrink]?) -> CoachWidgetSnapshot { + var s = CoachWidgetSnapshot(updatedAt: Date(timeIntervalSince1970: 1_755_000_000)) + s.quickDrinks = drinks + return s + } + + func testTheUsersPresetsAreUsedAsIs() { + let presets = [ + CoachWidgetSnapshot.QuickDrink(ref: "water", label: "Verre", volumeMl: 200), + CoachWidgetSnapshot.QuickDrink(ref: "coffee", label: "Expresso", volumeMl: 60), + ] + XCTAssertEqual(snapshot(presets).resolvedQuickDrinks, presets) + } + + func testWithoutPresetsTheFallbackMatchesTheServer() { + let fallback = snapshot(nil).resolvedQuickDrinks + XCTAssertEqual(fallback.count, 3) + XCTAssertEqual(fallback.map(\.volumeMl), [200, 500, 60]) + XCTAssertEqual(fallback.map(\.ref), ["water", "water", "coffee"]) + } + + func testTheCoffeeIsNeverOneHundredMillilitres() { + // La valeur qui ne correspondait à aucun préréglage. + for drink in snapshot(nil).resolvedQuickDrinks where drink.ref == "coffee" { + XCTAssertNotEqual(drink.volumeMl, 100) + } + } + + func testAnEmptyListFallsBackRatherThanShowNothing() { + XCTAssertEqual(snapshot([]).resolvedQuickDrinks.count, 3) + } + + func testAtMostThreeButtonsFit() { + let many = (1...8).map { i in + CoachWidgetSnapshot.QuickDrink(ref: "water", label: "P\(i)", + volumeMl: Double(i) * 100) + } + XCTAssertEqual(snapshot(many).resolvedQuickDrinks.count, 3) + } + + func testAnAbsurdVolumeIsRefused() { + // Un préréglage corrompu ne doit pas produire un bouton qui enregistre + // n'importe quoi : on retombe sur le repli. + let bad = [CoachWidgetSnapshot.QuickDrink(ref: "water", label: "X", volumeMl: 0)] + XCTAssertEqual(snapshot(bad).resolvedQuickDrinks.map(\.volumeMl), [200, 500, 60]) + } + + func testAnOlderSnapshotStillDecodes() throws { + let json = """ + {"updatedAt": 1755000000, "waterMlToday": 500} + """.data(using: .utf8)! + let decoded = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: json) + XCTAssertEqual(decoded.waterMlToday, 500) + XCTAssertEqual(decoded.resolvedQuickDrinks.count, 3) + } + + func testPresetsSurviveTheDayRollover() throws { + // Les préréglages ne sont pas une donnée du jour : ils ne se périment pas. + var s = snapshot([CoachWidgetSnapshot.QuickDrink(ref: "coffee", label: "Mug", volumeMl: 250)]) + s.waterMlToday = 800 + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: s.updatedAt)! + let aged = s.asOf(tomorrow) + XCTAssertEqual(aged.waterMlToday, 0, "l'eau du jour se remet à zéro") + XCTAssertEqual(aged.resolvedQuickDrinks.first?.volumeMl, 250) + } +}