Les macros arrivent au poignet — affichage, pas saisie

Sylvain, après avoir reçu le widget Hydratation : « il manque juste le suivi
des repas calories et lipides etc ».

⚠️ Sa demande est plus fine que ce que j'avais écarté. J'avais dit non à la
SAISIE des repas sur la montre — pas de caméra, pas de scanner, pas de saisie
de texte : un bouton n'aurait fait que renvoyer vers l'iPhone. Mais VOIR où
l'on en est n'a aucune de ces limites.

Ce qui manquait, et où
-----------------------
Le snapshot portait `kcalToday`/`kcalGoal` et rien d'autre. Les macros
existaient pourtant côté serveur, agrégées et servies :

  /api/meals            -> kcal, protein_g, carbs_g, fat_g, fiber_g
  /api/nutrition/goals  -> cibles {target, min, max} pour chacune

Il manquait uniquement le transport. Trois maillons ajoutés : `widget-bridge.js`
les pousse, `CoachWidgetSnapshot` les porte, `CoachWatchNutritionWidget` les
affiche.

⛔ Rien n'est calculé côté natif. Les totaux viennent du journal, les cibles de
`nutrition_goals.py`. Le seul calcul est un reste (cible − consommé), la même
soustraction que /meals affiche déjà.

Le piège évité, parce qu'il est documenté dans ce fichier
---------------------------------------------------------
`asOf(_:)` remet les totaux du jour à zéro quand le snapshot date d'hier. Les
macros y sont ajoutées : les oublier aurait affiché les grammes de la veille
sous la date du jour — le défaut même que cette fonction existe pour empêcher,
et qui avait survécu un temps sur les totaux de boisson.

⚠️ Les CIBLES ne se périment pas, elles : une cible n'est pas une mesure. Sans
ça, la jauge disparaîtrait chaque nuit jusqu'à la prochaine ouverture de l'app.
Deux tests Linux le verrouillent (`testMacrosResetLikeEnergy`,
`testMacroGoalsSurviveTheNight`) — 93 au vert.

⚠️ Une cible de type « min » (les protéines) se lit comme un plancher atteint,
pas comme un plafond à ne pas dépasser. `widget-bridge.js` ne pousse que
`target` ; le widget n'en sait pas plus et ne prétend pas le contraire.

Le widget compile-t-il ? Inconnu, comme les précédents : WidgetKit n'existe pas
sur Linux. Vérifié en revanche que chaque champ appelé existe, et qu'aucun
symbole n'est déclaré deux fois.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-09-25 07:46:32 +00:00
parent d41e8a457b
commit 6aa7265147
4 changed files with 195 additions and 0 deletions

View File

@@ -304,6 +304,7 @@ struct CoachWatchWidgetsBundle: WidgetBundle {
// verre sans sortir le téléphone. Ajouté le 25/09/2026.
if #available(watchOS 10.0, *) {
CoachWatchHydrationWidget()
CoachWatchNutritionWidget()
}
}
}

View File

@@ -181,3 +181,147 @@ struct CoachWatchHydrationWidget: Widget {
.supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryInline])
}
}
// MARK: - Nutrition
//
// ⚠️ **Affichage seulement, et c'est délibéré.** La saisie des repas au poignet
// a été écartée le 25/09 : pas de caméra, pas de scanner de code-barres, pas de
// saisie de texte confortable — un bouton n'aurait fait que renvoyer vers
// l'iPhone. Mais VOIR où l'on en est n'a aucune de ces limites, et c'est ce que
// Sylvain a demandé ensuite.
//
// ⛔ Rien n'est calculé ici. Les totaux viennent du journal et les cibles de
// `nutrition_goals.py`, tous deux poussés par l'app. Le seul calcul est un
// reste (cible − consommé), la même soustraction que la page /meals affiche.
struct CoachWatchNutritionEntry: TimelineEntry {
let date: Date
let kcal: Double?
let kcalGoal: Double?
let protein: Double?
let proteinGoal: Double?
let fat: Double?
let fatGoal: Double?
let carbs: Double?
let carbsGoal: Double?
/// Reste d'énergie. `nil` sans cible — une jauge sans objectif n'a pas de
/// sens, et un « reste » inventé encore moins.
var kcalRestantes: Double? {
guard let g = kcalGoal, let c = kcal else { return nil }
return max(0, g - c)
}
var fraction: Double? {
guard let g = kcalGoal, g > 0, let c = kcal else { return nil }
return min(1, max(0, c / g))
}
}
struct CoachWatchNutritionProvider: TimelineProvider {
func placeholder(in context: Context) -> CoachWatchNutritionEntry {
CoachWatchNutritionEntry(date: Date(), kcal: 1186, kcalGoal: 1908,
protein: 74, proteinGoal: 118,
fat: 41, fatGoal: 59,
carbs: 158, carbsGoal: 225)
}
func getSnapshot(in context: Context,
completion: @escaping (CoachWatchNutritionEntry) -> Void) {
completion(current())
}
func getTimeline(in context: Context,
completion: @escaping (Timeline<CoachWatchNutritionEntry>) -> Void) {
let now = Date()
let minuit = CoachWidgetSnapshot.nextMidnight(after: now)
completion(Timeline(entries: [current(at: now), current(at: minuit)],
policy: .after(minuit.addingTimeInterval(60))))
}
/// `asOf(_:)` remet les totaux du jour à zéro quand le snapshot date
/// d'hier — macros comprises depuis le 25/09. Sans lui, le réveil de
/// minuit réafficherait les grammes de la veille.
private func current(at day: Date = Date()) -> CoachWatchNutritionEntry {
let s = CoachWidgetStore.load()?.asOf(day)
return CoachWatchNutritionEntry(
date: day,
kcal: s?.kcalToday, kcalGoal: s?.kcalGoal,
protein: s?.proteinToday, proteinGoal: s?.proteinGoal,
fat: s?.fatToday, fatGoal: s?.fatGoal,
carbs: s?.carbsToday, carbsGoal: s?.carbsGoal)
}
}
@available(watchOS 10.0, *)
struct CoachWatchNutritionView: View {
@Environment(\.widgetFamily) private var family
let entry: CoachWatchNutritionEntry
/// « 74/118 g » ; « 74 g » sans cible. Jamais de cible inventée.
private func macro(_ nom: String, _ valeur: Double?, _ cible: Double?) -> String {
guard let v = valeur else { return "\(nom) —" }
if let c = cible { return "\(nom) \(Int(v))/\(Int(c))" }
return "\(nom) \(Int(v))"
}
var body: some View {
switch family {
case .accessoryRectangular:
rectangulaire
case .accessoryInline:
Text(entry.kcalRestantes.map { "\(Int($0)) kcal restantes" }
?? "\(Int(entry.kcal ?? 0)) kcal")
default:
circulaire
}
}
private var rectangulaire: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "fork.knife")
if let reste = entry.kcalRestantes {
Text("\(Int(reste))").font(.headline)
Text("kcal restantes").font(.caption2).foregroundStyle(.secondary)
} else {
Text("\(Int(entry.kcal ?? 0)) kcal").font(.headline)
}
}
if let f = entry.fraction {
ProgressView(value: f).progressViewStyle(.linear)
}
// P / L / G sur une ligne : trois valeurs tiennent, pas davantage.
Text([macro("P", entry.protein, entry.proteinGoal),
macro("L", entry.fat, entry.fatGoal),
macro("G", entry.carbs, entry.carbsGoal)].joined(separator: " "))
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.containerBackground(.clear, for: .widget)
}
private var circulaire: some View {
Gauge(value: entry.fraction ?? 0) {
Image(systemName: "fork.knife")
} currentValueLabel: {
Text("\(Int(entry.kcalRestantes ?? entry.kcal ?? 0))")
.font(.system(size: 12))
}
.gaugeStyle(.accessoryCircular)
.containerBackground(.clear, for: .widget)
}
}
@available(watchOS 10.0, *)
struct CoachWatchNutritionWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "CoachWatchNutrition",
provider: CoachWatchNutritionProvider()) { entry in
CoachWatchNutritionView(entry: entry)
}
.configurationDisplayName("Nutrition")
.description("Calories restantes et macros du jour.")
.supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryInline])
}
}