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>
267 lines
12 KiB
Swift
267 lines
12 KiB
Swift
// 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
|
||
|
||
/// 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
|
||
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)
|
||
}
|
||
}
|