From 6aa7265147cee6a089a176ce06dda4946d842769 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Fri, 25 Sep 2026 07:46:32 +0000 Subject: [PATCH] =?UTF-8?q?Les=20macros=20arrivent=20au=20poignet=20?= =?UTF-8?q?=E2=80=94=20affichage,=20pas=20saisie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ios/App/App/CoachWidgetSnapshot.swift | 22 +++ .../CoachWatchComplications.swift | 1 + .../CoachWatchHydration.swift | 144 ++++++++++++++++++ .../CoachModelTests/DayRolloverTests.swift | 28 ++++ 4 files changed, 195 insertions(+) diff --git a/ios/App/App/CoachWidgetSnapshot.swift b/ios/App/App/CoachWidgetSnapshot.swift index 782fc1d..2533984 100644 --- a/ios/App/App/CoachWidgetSnapshot.swift +++ b/ios/App/App/CoachWidgetSnapshot.swift @@ -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 diff --git a/ios/App/CoachWatchWidgets/CoachWatchComplications.swift b/ios/App/CoachWatchWidgets/CoachWatchComplications.swift index 4dc268b..3344f79 100644 --- a/ios/App/CoachWatchWidgets/CoachWatchComplications.swift +++ b/ios/App/CoachWatchWidgets/CoachWatchComplications.swift @@ -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() } } } diff --git a/ios/App/CoachWatchWidgets/CoachWatchHydration.swift b/ios/App/CoachWatchWidgets/CoachWatchHydration.swift index 2dad9bd..56e6289 100644 --- a/ios/App/CoachWatchWidgets/CoachWatchHydration.swift +++ b/ios/App/CoachWatchWidgets/CoachWatchHydration.swift @@ -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) -> 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]) + } +} diff --git a/tests-linux/Tests/CoachModelTests/DayRolloverTests.swift b/tests-linux/Tests/CoachModelTests/DayRolloverTests.swift index cc6bfb2..ea80813 100644 --- a/tests-linux/Tests/CoachModelTests/DayRolloverTests.swift +++ b/tests-linux/Tests/CoachModelTests/DayRolloverTests.swift @@ -41,8 +41,14 @@ final class DayRolloverTests: XCTestCase { s.waterMlToday = 1660 s.coffeeCountToday = 3 s.kcalToday = 1186 + s.proteinToday = 74 + s.fatToday = 41 + s.carbsToday = 158 s.waterGoalMl = 2750 s.kcalGoal = 1950 + s.proteinGoal = 118 + s.fatGoal = 59 + s.carbsGoal = 225 s.zones = HeartRateZones(z2: [109, 118], fcmaxUsed: 152, clinicalTargetBpm: 112, zonesSince: "2026-08-12", zonesFrom: "frozen") return s @@ -69,6 +75,28 @@ final class DayRolloverTests: XCTestCase { XCTAssertEqual(vu.kcalToday, 0) } + func testMacrosResetLikeEnergy() { + // ⚠️ Ajoutées le 25/09/2026 pour le suivi au poignet. Les oublier dans + // `asOf` afficherait les grammes d'hier 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. + let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich) + XCTAssertEqual(vu.proteinToday, 0) + XCTAssertEqual(vu.fatToday, 0) + XCTAssertEqual(vu.carbsToday, 0) + } + + func testMacroGoalsSurviveTheNight() { + // Une CIBLE n'est pas une mesure : elle ne se périme pas à minuit. + // Sans elle, la jauge disparaîtrait chaque nuit jusqu'à la prochaine + // ouverture de l'app. + let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich) + XCTAssertEqual(vu.proteinGoal, 118) + XCTAssertEqual(vu.fatGoal, 59) + XCTAssertEqual(vu.carbsGoal, 225) + XCTAssertEqual(vu.kcalGoal, 1950) + } + func testYesterdaysDayScopedFieldsAreDropped() { let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich) XCTAssertNil(vu.routine, "la routine se recoche chaque jour")