Files
coach-ios/ios/App/App/CoachWidgetSnapshot.swift
Sylvain Bettinelli 6aa7265147 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>
2026-09-25 07:46:32 +00:00

289 lines
14 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// CoachWidgetSnapshot.swift
// Modèle de données PARTAGÉ entre l'app (cible App) et l'extension widget
// (cible CoachLiveActivity). L'app écrit le snapshot du jour dans l'App Group
// via CoachWidgetBridge ; les widgets d'écran d'accueil le lisent.
//
// ⚠️ Ce fichier doit appartenir aux DEUX cibles (App + CoachLiveActivity) —
// cf. docs/widgets-runbook-mac.md (étape « Target Membership »).
//
// App Group requis : group.ch.hypnotruck.coach (à créer sur le portail Apple
// et activer comme capability sur les deux cibles).
import Foundation
enum CoachWidgetStore {
/// Identifiant App Group partagé app ↔ extension. Doit correspondre
/// EXACTEMENT à la capability "App Groups" des deux cibles.
static let appGroup = "group.ch.hypnotruck.coach"
static let snapshotKey = "coach_widget_snapshot"
static var defaults: UserDefaults? { UserDefaults(suiteName: appGroup) }
/// Renvoie `false` si l'App Group est indisponible ou l'encodage échoue —
/// permet à l'appelant (bridge) de signaler l'échec au lieu de mentir « ok ».
@discardableResult
static func save(_ snapshot: CoachWidgetSnapshot) -> Bool {
guard let d = defaults, let data = try? JSONEncoder().encode(snapshot) else { return false }
d.set(data, forKey: snapshotKey)
return true
}
static func load() -> CoachWidgetSnapshot? {
guard let d = defaults, let data = d.data(forKey: snapshotKey) else { return nil }
return try? JSONDecoder().decode(CoachWidgetSnapshot.self, from: data)
}
static func clear() {
defaults?.removeObject(forKey: snapshotKey)
}
}
/// Instantané des données affichées par les widgets. Volontairement minimal :
/// le widget n'a pas d'accès réseau, tout vient de ce snapshot poussé par l'app.
struct CoachWidgetSnapshot: Codable {
var updatedAt: Date
var today: TodaySession? = nil
var forme: FormeScore? = nil
/// Boissons du jour DÉJÀ connues du serveur, en millilitres et en nombre de
/// cafés. Le widget de saisie rapide y ajoute ce qui attend encore dans la
/// file locale : sans ces deux champs, il afficherait « 0,3 L » au lieu de
/// « 1,5 L » tant que l'app n'a pas resynchronisé, et l'utilisateur
/// douterait de saisies pourtant enregistrées.
/// Optionnels : un snapshot écrit par une version antérieure reste lisible.
var waterMlToday: Double? = nil
var coffeeCountToday: Int? = nil
/// Objectif d'hydratation du jour, servi par `/api/drinks` depuis la refonte
/// de la page /hydratation (2026-08-13).
///
/// ⚠️ **Cette cible n'a aucune source** : c'est un objectif personnel, pas
/// une recommandation médicale (voir `nutrition_goals.HYDRATION_DEFAULT_ML`
/// côté serveur). Le widget peut donc l'afficher comme une jauge, jamais
/// comme un seuil de santé. `nil` ⇒ jauge masquée, total seul — c'était le
/// comportement d'origine et il reste le repli.
var waterGoalMl: Double? = nil
/// Énergie du jour et objectif, poussés par l'app depuis le journal.
/// `kcalGoal` vient des objectifs nutritionnels réels (1950 au 06/08/2026) :
/// il n'est pas écrit en dur ici, pour qu'un changement d'objectif suive.
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
/// Activité RÉELLEMENT réalisée aujourd'hui, indépendante du plan.
///
/// Sans elle, un jour où la séance prévue est remplacée par une autre
/// activité (bouton « j'ai fait du VTT », qui décale la séquence et vide
/// le jour) s'affiche « JOUR OFF » — le jour même d'une sortie de 20 km.
/// `today` décrit le plan, `doneToday` décrit la journée : les deux
/// peuvent coexister, l'un être nil sans l'autre.
/// Optionnel : un snapshot écrit par une version antérieure reste lisible.
var doneToday: DoneActivity? = nil
/// Avancement de la routine quotidienne (nil = non poussée).
/// Optionnel, comme les autres ajouts : un snapshot d'une version
/// antérieure reste décodable.
var routine: RoutineProgress? = nil
/// Zones cardiaques en vigueur, pour que la montre traduise la FC en zone
/// pendant la séance. Modèle dans `HeartRateZones.swift`.
/// ⚠️ Version FIGÉE servie par `/api/cardiac-zones` — jamais recalculée
/// côté natif (cf. l'en-tête de ce fichier).
var zones: HeartRateZones? = nil
/// Séance planifiée de DEMAIN. N'est jamais affichée le jour même : elle
/// sert au widget à passer minuit sans accès réseau.
///
/// ⚠️ Sans elle, `asOf(_:)` n'aurait d'autre choix que de vider la séance
/// au changement de jour — et le widget annoncerait « JOUR OFF » chaque
/// nuit jusqu'à ce que l'app soit rouverte, y compris la veille d'une
/// sortie longue. Poussée par `widget-bridge.js` depuis `tomorrow_session`.
var tomorrow: TodaySession? = nil
/// Séance planifiée du jour (nil = jour OFF / pas de séance).
struct TodaySession: Codable {
var sport: String // running, cycling, strength, mobility, hiking, rest…
var title: String // "Sortie longue", "Renfo bas du corps"…
var subtitle: String? // objectif court : "1h30 · Z2", "4×12"…
var done: Bool // séance déjà réalisée aujourd'hui
}
/// Exercices de la routine cochés aujourd'hui, sur le total du jour.
/// Le grain est l'EXERCICE, pas le bloc : c'est celui de la page web et
/// de l'app Watch depuis le 2026-08-03, et deux grains concurrents
/// donneraient deux comptes différents du même geste.
struct RoutineProgress: Codable {
var done: Int
var total: Int
/// 0…1, borné. `total` nul ne doit jamais produire de division par zéro
/// ni une jauge pleine par accident.
var fraction: Double {
guard total > 0 else { return 0 }
return min(1, max(0, Double(done) / Double(total)))
}
}
/// Ce qui a été fait dans la journée : sport, et de quoi le qualifier.
struct DoneActivity: Codable {
var sport: String // running, cycling, walking, strength…
var title: String? // nom de la séance, souvent absent
var subtitle: String? // "20,3 km · 56 min"
}
/// 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? // 0–100
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
/// poussé la dernière fois qu'elle a tourné — et rien ne l'obligeait à
/// vérifier de QUAND datait ce qu'il affichait. Passé minuit, l'hydratation,
/// les calories, la routine et la séance de la veille restaient à l'écran,
/// présentées comme celles du jour. Il fallait ouvrir l'app pour les voir
/// retomber à zéro. Signalé le 2026-08-18.
///
/// `updatedAt` existait déjà : **personne ne le lisait**. C'est tout le
/// correctif.
///
/// Ce qui est vidé est ce qui décrit UNE journée. Ce qui traverse les jours
/// est conservé, et la distinction n'est pas cosmétique :
///
/// | conservé | pourquoi |
/// |---|---|
/// | `waterGoalMl`, `kcalGoal` | ce sont des objectifs, pas des mesures |
/// | `zones` | version figée, valable des semaines |
///
/// Les compteurs repartent à **0 et non à `nil`** : au matin d'un jour
/// neuf, « rien bu » est une information vraie, alors que `nil` signifierait
/// « je ne sais pas ».
func asOf(_ day: Date, calendar: Calendar = .current) -> CoachWidgetSnapshot {
if calendar.isDate(updatedAt, inSameDayAs: day) { return self }
var s = self
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
// étant celui d'aujourd'hui serait faux, pas seulement périmé.
s.doneToday = nil
s.forme = nil
// La séance : c'est ici que `tomorrow` évite le « JOUR OFF » nocturne.
// Elle ne vaut que pour le LENDEMAIN du snapshot — au-delà, on ne sait
// plus, et un plan inventé serait pire qu'une case vide.
let lendemain = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: updatedAt))
if let lendemain, calendar.isDate(lendemain, inSameDayAs: day) {
s.today = tomorrow
} else {
s.today = nil
}
s.tomorrow = nil
return s
}
/// Prochain minuit strictement après `date`. Sert aux trois widgets à
/// programmer l'entrée qui fait basculer l'affichage sans réveiller l'app.
static func nextMidnight(after date: Date, calendar: Calendar = .current) -> Date {
calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: date))
?? date.addingTimeInterval(86_400)
}
}