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:
@@ -69,6 +69,21 @@ struct CoachWidgetSnapshot: Codable {
|
||||
var kcalToday: Double? = nil
|
||||
var kcalGoal: Double? = nil
|
||||
|
||||
/// Macros du jour et leurs cibles (25/09/2026, pour le suivi au poignet).
|
||||
/// Mêmes règles que l'énergie : les totaux viennent du journal, les cibles
|
||||
/// de `nutrition_goals.py` — rien n'est écrit en dur, pour qu'un changement
|
||||
/// d'objectif suive.
|
||||
/// ⚠️ Les cibles portent côté serveur une forme `{target, min, max}` :
|
||||
/// `widget-bridge.js` n'en pousse que `target`. Une jauge sur une cible de
|
||||
/// type « min » (les protéines) se lit donc comme un plancher atteint, pas
|
||||
/// comme un plafond à ne pas dépasser.
|
||||
var proteinToday: Double? = nil
|
||||
var proteinGoal: Double? = nil
|
||||
var fatToday: Double? = nil
|
||||
var fatGoal: Double? = nil
|
||||
var carbsToday: Double? = nil
|
||||
var carbsGoal: 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
|
||||
@@ -237,6 +252,13 @@ extension CoachWidgetSnapshot {
|
||||
s.waterMlToday = 0
|
||||
s.coffeeCountToday = 0
|
||||
s.kcalToday = 0
|
||||
// ⚠️ Les macros se périment avec l'énergie. Les oublier ici afficherait
|
||||
// les grammes d'hier sous la date du jour — exactement le défaut que
|
||||
// `asOf` existe pour éviter, et qui avait survécu un temps sur les
|
||||
// totaux de boisson.
|
||||
s.proteinToday = 0
|
||||
s.fatToday = 0
|
||||
s.carbsToday = 0
|
||||
s.routine = nil
|
||||
// L'activité de la veille n'est pas celle du jour, et le score de forme
|
||||
// du jour n'est calculé qu'après la nuit : afficher celui d'hier comme
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user