From b8eb1e1b1e413e414ccb49166f0b9405cb9cfaf5 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Tue, 18 Aug 2026 07:11:15 +0000 Subject: [PATCH] Le widget ne passait pas minuit : personne ne lisait updatedAt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ios/App/App/CoachQuickLog.swift | 15 ++-- ios/App/App/CoachQuickSync.swift | 9 ++- ios/App/App/CoachWidgetBridge.swift | 20 ++++++ ios/App/App/CoachWidgetSnapshot.swift | 70 +++++++++++++++++++ .../CoachLiveActivity/CoachQuickWidget.swift | 34 +++++---- ios/App/CoachLiveActivity/CoachWidgets.swift | 31 ++++++-- .../CoachWatchComplications.swift | 21 ++++-- 7 files changed, 172 insertions(+), 28 deletions(-) diff --git a/ios/App/App/CoachQuickLog.swift b/ios/App/App/CoachQuickLog.swift index f01ceaa..caeb43b 100644 --- a/ios/App/App/CoachQuickLog.swift +++ b/ios/App/App/CoachQuickLog.swift @@ -95,18 +95,25 @@ enum CoachQuickLog { /// snapshot) ; on y ajoute ce qui attend encore. Sans cela, le widget /// afficherait un total périmé juste après une saisie, et l'utilisateur /// douterait de ce qu'il vient de faire. - static func todayTotal(kind ref: String, syncedMl: Double = 0) -> Double { + /// ⚠️ **`day` est explicite, et ce n'est pas de la coquetterie.** + /// + /// Ces deux fonctions filtraient sur `isDateInToday`, c'est-à-dire sur + /// l'instant du CALCUL. Or un widget construit sa timeline à l'avance : + /// l'entrée datée de minuit est fabriquée la veille au soir, et « today » + /// y désignait encore la veille. La file locale se serait donc réinitialisée + /// avec un jour de retard, comme les totaux serveur. + static func todayTotal(kind ref: String, syncedMl: Double = 0, day: Date = Date()) -> Double { let calendar = Calendar.current let waiting = pending() - .filter { $0.kind == .drink && $0.ref == ref && calendar.isDateInToday($0.at) } + .filter { $0.kind == .drink && $0.ref == ref && calendar.isDate($0.at, inSameDayAs: day) } .reduce(0) { $0 + $1.amount } return syncedMl + waiting } - static func todayCount(kind ref: String, syncedCount: Int = 0) -> Int { + static func todayCount(kind ref: String, syncedCount: Int = 0, day: Date = Date()) -> Int { let calendar = Calendar.current let waiting = pending() - .filter { $0.kind == .drink && $0.ref == ref && calendar.isDateInToday($0.at) } + .filter { $0.kind == .drink && $0.ref == ref && calendar.isDate($0.at, inSameDayAs: day) } .count return syncedCount + waiting } diff --git a/ios/App/App/CoachQuickSync.swift b/ios/App/App/CoachQuickSync.swift index e6a3cc3..7b83ce4 100644 --- a/ios/App/App/CoachQuickSync.swift +++ b/ios/App/App/CoachQuickSync.swift @@ -77,7 +77,14 @@ enum CoachQuickSync { let today = entries.filter { calendar.isDateInToday($0.at) } guard !today.isEmpty else { return } - var snapshot = CoachWidgetStore.load() + /* ⚠️ **`asOf` AVANT de créditer, sinon on additionne deux journées.** + Cette fonction ajoute au total existant puis réhorodate le snapshot à + maintenant. Sur un snapshot de la veille, 200 ml bus ce matin + donneraient « 2 200 ml » estampillés d'aujourd'hui — et la péremption + ne pourrait plus rien y voir, puisque `updatedAt` serait à jour. + C'est la même cause que le widget figé au jour précédent, à un endroit + où elle produit un total faux plutôt qu'un total périmé. */ + var snapshot = CoachWidgetStore.load()?.asOf(Date()) ?? CoachWidgetSnapshot(updatedAt: Date(), today: nil, forme: nil) let water = today.filter { $0.ref == "water" }.reduce(0) { $0 + $1.amount } let coffees = today.filter { $0.ref == "coffee" }.count diff --git a/ios/App/App/CoachWidgetBridge.swift b/ios/App/App/CoachWidgetBridge.swift index 4cf7ef2..d0d4d6a 100644 --- a/ios/App/App/CoachWidgetBridge.swift +++ b/ios/App/App/CoachWidgetBridge.swift @@ -37,6 +37,18 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { done: t["done"] as? Bool ?? false ) } + /// Séance de DEMAIN — jamais affichée aujourd'hui. Elle permet au + /// widget de basculer seul à minuit sans annoncer « JOUR OFF » : + /// cf. `CoachWidgetSnapshot.asOf(_:)`. + var tomorrow: CoachWidgetSnapshot.TodaySession? + if let t = call.getObject("tomorrow") { + tomorrow = CoachWidgetSnapshot.TodaySession( + sport: t["sport"] as? String ?? "rest", + title: t["title"] as? String ?? "", + subtitle: t["subtitle"] as? String, + done: t["done"] as? Bool ?? false + ) + } var forme: CoachWidgetSnapshot.FormeScore? if let f = call.getObject("forme") { // `as? Int` échoue si le JS envoie un float (72.0) — le nombre transite @@ -90,6 +102,14 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { // réveil, un report aveugle afficherait 24/24 dès le matin. snapshot.routine = routine ?? (Calendar.current.isDateInToday(previous?.updatedAt ?? .distantPast) ? previous?.routine : nil) + /* ⚠️ **Garde du jour OBLIGATOIRE ici**, et pour une raison qui n'est pas + celle de `doneToday`. « Demain » est relatif à `updatedAt` : un + `tomorrow` poussé hier désigne AUJOURD'HUI. Le reporter tel quel dans + un snapshot daté de maintenant le ferait passer pour la séance de + demain — le widget afficherait la séance du jour avec 24 h de retard + au minuit suivant. On ne reporte donc que dans la même journée. */ + snapshot.tomorrow = tomorrow ?? (Calendar.current.isDateInToday(previous?.updatedAt ?? .distantPast) + ? previous?.tomorrow : nil) snapshot.waterMlToday = number("waterMlToday") ?? previous?.waterMlToday // Même règle de report que les autres champs nutrition : un appel // partiel ne doit pas effacer la cible, sinon la jauge disparaîtrait au diff --git a/ios/App/App/CoachWidgetSnapshot.swift b/ios/App/App/CoachWidgetSnapshot.swift index fc3182e..65b290c 100644 --- a/ios/App/App/CoachWidgetSnapshot.swift +++ b/ios/App/App/CoachWidgetSnapshot.swift @@ -91,6 +91,15 @@ struct CoachWidgetSnapshot: Codable { /// 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… @@ -128,3 +137,64 @@ struct CoachWidgetSnapshot: Codable { 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) + } +} diff --git a/ios/App/CoachLiveActivity/CoachQuickWidget.swift b/ios/App/CoachLiveActivity/CoachQuickWidget.swift index 16d44c4..32a9e52 100644 --- a/ios/App/CoachLiveActivity/CoachQuickWidget.swift +++ b/ios/App/CoachLiveActivity/CoachQuickWidget.swift @@ -77,27 +77,37 @@ struct CoachQuickProvider: TimelineProvider { completion(current()) } + /// ⚠️ **Le rendez-vous de minuit existait déjà — et ne servait à rien.** + /// + /// La version précédente programmait bien `.after(midnight)`, avec le bon + /// commentaire (« pour que les totaux du jour repartent de zéro »). Mais au + /// réveil, `current()` relisait `snapshot.waterMlToday` **sans regarder de + /// quand datait ce snapshot** : elle réaffichait donc exactement les mêmes + /// chiffres. Le rechargement avait lieu, la remise à zéro non. + /// + /// C'est `asOf(_:)` qui manquait, pas le réveil. func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - // Une seule entrée, rechargée à chaque saisie par `reloadAllTimelines`. - // Rafraîchissement de sécurité au prochain minuit, pour que les totaux - // du jour repartent de zéro même sans interaction. - let midnight = Calendar.current.nextDate( - after: Date(), matching: DateComponents(hour: 0, minute: 0), matchingPolicy: .nextTime - ) ?? Date().addingTimeInterval(3600) - completion(Timeline(entries: [current()], policy: .after(midnight))) + let now = Date() + let midnight = CoachWidgetSnapshot.nextMidnight(after: now) + completion(Timeline(entries: [current(at: now), current(at: midnight)], + policy: .after(midnight.addingTimeInterval(60)))) } - private func current() -> CoachQuickEntryTimeline { - let snapshot = CoachWidgetStore.load() + private func current(at day: Date = Date()) -> CoachQuickEntryTimeline { + // `asOf` vide les totaux du jour quand le snapshot date d'un autre jour. + let snapshot = CoachWidgetStore.load()?.asOf(day) // Le serveur connaît peut-être déjà des boissons du jour : le snapshot // les porte. On y ajoute ce qui attend dans la file. let syncedWater = snapshot?.waterMlToday ?? 0 let syncedCoffee = snapshot?.coffeeCountToday ?? 0 return CoachQuickEntryTimeline( - date: Date(), - waterMl: CoachQuickLog.todayTotal(kind: "water", syncedMl: syncedWater), + date: day, + waterMl: CoachQuickLog.todayTotal(kind: "water", syncedMl: syncedWater, day: day), waterGoal: snapshot?.waterGoalMl, - coffeeCount: CoachQuickLog.todayCount(kind: "coffee", syncedCount: syncedCoffee), + coffeeCount: CoachQuickLog.todayCount(kind: "coffee", syncedCount: syncedCoffee, day: day), + // ⚠️ `pending` compte ce qui attend d'être envoyé, TOUS jours + // confondus : une saisie d'hier non synchronisée doit encore partir, + // et le badge doit continuer à le signaler. pending: CoachQuickLog.pending().count, kcal: snapshot?.kcalToday, kcalGoal: snapshot?.kcalGoal diff --git a/ios/App/CoachLiveActivity/CoachWidgets.swift b/ios/App/CoachLiveActivity/CoachWidgets.swift index fd1618c..e2fc510 100644 --- a/ios/App/CoachLiveActivity/CoachWidgets.swift +++ b/ios/App/CoachLiveActivity/CoachWidgets.swift @@ -23,16 +23,35 @@ struct CoachWidgetProvider: TimelineProvider { } func getSnapshot(in context: Context, completion: @escaping (CoachSnapshotEntry) -> Void) { - completion(CoachSnapshotEntry(date: Date(), snapshot: CoachWidgetStore.load())) + let now = Date() + completion(CoachSnapshotEntry(date: now, snapshot: CoachWidgetStore.load()?.asOf(now))) } + /// ⚠️ **DEUX entrées, et c'est le correctif.** + /// + /// L'ancienne version n'en produisait qu'une, avec le snapshot brut, et + /// repassait une heure plus tard. Or repasser ne sert à rien : elle relisait + /// le MÊME snapshot périmé dans l'App Group et le réaffichait à l'identique. + /// Les chiffres de la veille restaient donc à l'écran jusqu'à ce que l'app + /// soit rouverte à la main. + /// + /// La seconde entrée est datée de minuit et porte le snapshot passé par + /// `asOf(_:)`. WidgetKit bascule dessus tout seul à l'heure dite, **sans + /// réveiller l'app ni accéder au réseau** — ce qu'un widget ne peut de + /// toute façon pas faire. func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - let entry = CoachSnapshotEntry(date: Date(), snapshot: CoachWidgetStore.load()) + let now = Date() + let stored = CoachWidgetStore.load() + let midnight = CoachWidgetSnapshot.nextMidnight(after: now) + let entries = [ + CoachSnapshotEntry(date: now, snapshot: stored?.asOf(now)), + CoachSnapshotEntry(date: midnight, snapshot: stored?.asOf(midnight)), + ] // L'app rafraîchit explicitement (WidgetCenter.reloadAllTimelines) à - // chaque setSnapshot. On planifie en plus un refresh horaire de courtoisie. - let next = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) - ?? Date().addingTimeInterval(3600) - completion(Timeline(entries: [entry], policy: .after(next))) + // chaque setSnapshot ; on redemande la main peu après minuit pour que + // le jour d'APRÈS soit à son tour préparé. + completion(Timeline(entries: entries, + policy: .after(midnight.addingTimeInterval(60)))) } } diff --git a/ios/App/CoachWatchWidgets/CoachWatchComplications.swift b/ios/App/CoachWatchWidgets/CoachWatchComplications.swift index 0ada56c..a1f5432 100644 --- a/ios/App/CoachWatchWidgets/CoachWatchComplications.swift +++ b/ios/App/CoachWatchWidgets/CoachWatchComplications.swift @@ -51,13 +51,24 @@ struct WatchProvider: TimelineProvider { WatchEntry(date: Date(), snapshot: nil) } func getSnapshot(in context: Context, completion: @escaping (WatchEntry) -> Void) { - completion(WatchEntry(date: Date(), snapshot: CoachWidgetStore.load())) + let now = Date() + completion(WatchEntry(date: now, snapshot: CoachWidgetStore.load()?.asOf(now))) } + /// Même correctif que les widgets iPhone : une entrée maintenant, une à + /// minuit avec le snapshot périmé remis à zéro. Sans elle, la complication + /// affichait les chiffres de la veille jusqu'à réouverture de l'app — + /// et sur une montre, c'est encore plus visible : elle est au poignet + /// avant que l'iPhone ne soit déverrouillé. func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - let entry = WatchEntry(date: Date(), snapshot: CoachWidgetStore.load()) - let next = Calendar.current.date(byAdding: .hour, value: 2, to: Date()) - ?? Date().addingTimeInterval(7200) - completion(Timeline(entries: [entry], policy: .after(next))) + let now = Date() + let stored = CoachWidgetStore.load() + let midnight = CoachWidgetSnapshot.nextMidnight(after: now) + let entries = [ + WatchEntry(date: now, snapshot: stored?.asOf(now)), + WatchEntry(date: midnight, snapshot: stored?.asOf(midnight)), + ] + completion(Timeline(entries: entries, + policy: .after(midnight.addingTimeInterval(60)))) } }