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:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user