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

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