Signale par Sylvain le 2026-08-18 : passe minuit, l'hydratation, les calories et la seance de la veille restaient affichees comme celles du jour. Il fallait ouvrir l'app pour les voir retomber a zero. Le widget n'a AUCUN acces reseau : il rend ce que l'app lui a pousse la derniere fois qu'elle a tourne. Le snapshot portait deja `updatedAt` — rien ne le lisait. CoachQuickWidget programmait meme un reveil a minuit, avec le bon commentaire, mais relisait ensuite les memes chiffres sans regarder leur date : le rechargement avait lieu, la remise a zero non. - `CoachWidgetSnapshot.asOf(_:)` : vide ce qui decrit UNE journee (eau, cafes, kcal, routine, activite faite, score de forme) quand le snapshot date d'un autre jour. Conserve ce qui traverse les jours — objectifs et zones cardiaques. Les compteurs repartent a 0 et non a nil : au matin, « rien bu » est vrai, « je ne sais pas » ne l'est pas. - Les trois providers emettent une 2e entree datee de minuit. WidgetKit bascule seul, sans reveiller l'app. - `tomorrow` : la seance du lendemain, poussee par le pont web. Sans elle, vider la seance ferait annoncer JOUR OFF chaque nuit — l'agacement d'un incident precedent, repete tous les jours. ⚠️ Deuxieme occurrence trouvee en verifiant, et pire : CoachQuickSync.credit() ajoutait au total EXISTANT puis rehorodatait a maintenant. Sur un snapshot de la veille, 200 ml bus le matin donnaient « 2 200 ml » estampilles du jour — un total faux, que la peremption ne pouvait plus rattraper. ⚠️ NON COMPILE : pas de toolchain Swift ici. Build Xcode requis sur le Mac mini pour que le correctif arrive sur l'iPhone et la Watch.
201 lines
9.2 KiB
Swift
201 lines
9.2 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
|
||
|
||
|
||
/// 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).
|
||
struct FormeScore: Codable {
|
||
var score: Int? // 0–100
|
||
var label: String? // "Prêt", "Récup", "Fatigue"…
|
||
}
|
||
}
|
||
|
||
// MARK: - Péremption au changement de jour
|
||
|
||
extension CoachWidgetSnapshot {
|
||
/// 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)
|
||
}
|
||
}
|