// 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) } }