Les widgets reçoivent la bande et les préréglages au lieu de les inventer

La couleur du score de forme était décidée ici, avec un barème périmé
(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] :
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 atteint le binaire.

`FormeScore` porte désormais la bande servie par le serveur, et
`resolvedBand` ne sert que de repli — aligné sur les seuils serveur, pas
sur les anciens.

Le widget de saisie rapide enregistrait un café à 100 ml, volume qui ne
correspond à aucun préréglage : le serveur avait séparé l'expresso (60)
du mug (250) parce qu'un volume unique faussait le journal, et ces
préréglages sont personnalisables. Les boutons viennent maintenant du
snapshot ; `fallbackQuickDrinks` reprend les préréglages par défaut du
serveur.

15 tests ajoutés au banc d'essai Linux, qui compile réellement ces deux
fichiers : 25 tests verts. Le reste (SwiftUI, WidgetKit) n'est pas
compilable ici et n'a été que relu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-19 11:42:19 +00:00
parent a965c1ad2b
commit 4cff1d4603
7 changed files with 304 additions and 24 deletions

View File

@@ -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

View File

@@ -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? // 0100
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

View File

@@ -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")
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")
}
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")
}
}
.buttonStyle(.bordered)

View File

@@ -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))

View File

@@ -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")

View File

@@ -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)
}
}

View File

@@ -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)
}
}