Files
coach-ios/ios/App/App/CoachWidgetSnapshot.swift
Sylvain Bettinelli 4cff1d4603 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>
2026-08-19 11:42:19 +00:00

267 lines
12 KiB
Swift
Raw Permalink 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
/// 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
/// 01, 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? // 0100
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
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)
}
}