Le widget ne passait pas minuit : personne ne lisait updatedAt

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.
This commit is contained in:
Sylvain Bettinelli
2026-08-18 07:11:15 +00:00
parent 5bf3e91ad2
commit b8eb1e1b1e
7 changed files with 172 additions and 28 deletions

View File

@@ -95,18 +95,25 @@ enum CoachQuickLog {
/// snapshot) ; on y ajoute ce qui attend encore. Sans cela, le widget /// 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 /// afficherait un total périmé juste après une saisie, et l'utilisateur
/// douterait de ce qu'il vient de faire. /// 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 calendar = Calendar.current
let waiting = pending() 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 } .reduce(0) { $0 + $1.amount }
return syncedMl + waiting 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 calendar = Calendar.current
let waiting = pending() 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 .count
return syncedCount + waiting return syncedCount + waiting
} }

View File

@@ -77,7 +77,14 @@ enum CoachQuickSync {
let today = entries.filter { calendar.isDateInToday($0.at) } let today = entries.filter { calendar.isDateInToday($0.at) }
guard !today.isEmpty else { return } 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) ?? CoachWidgetSnapshot(updatedAt: Date(), today: nil, forme: nil)
let water = today.filter { $0.ref == "water" }.reduce(0) { $0 + $1.amount } let water = today.filter { $0.ref == "water" }.reduce(0) { $0 + $1.amount }
let coffees = today.filter { $0.ref == "coffee" }.count let coffees = today.filter { $0.ref == "coffee" }.count

View File

@@ -37,6 +37,18 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
done: t["done"] as? Bool ?? false 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? var forme: CoachWidgetSnapshot.FormeScore?
if let f = call.getObject("forme") { if let f = call.getObject("forme") {
// `as? Int` échoue si le JS envoie un float (72.0) le nombre transite // `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. // réveil, un report aveugle afficherait 24/24 dès le matin.
snapshot.routine = routine ?? (Calendar.current.isDateInToday(previous?.updatedAt ?? .distantPast) snapshot.routine = routine ?? (Calendar.current.isDateInToday(previous?.updatedAt ?? .distantPast)
? previous?.routine : nil) ? 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 snapshot.waterMlToday = number("waterMlToday") ?? previous?.waterMlToday
// Même règle de report que les autres champs nutrition : un appel // 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 // partiel ne doit pas effacer la cible, sinon la jauge disparaîtrait au

View File

@@ -91,6 +91,15 @@ struct CoachWidgetSnapshot: Codable {
/// côté natif (cf. l'en-tête de ce fichier). /// côté natif (cf. l'en-tête de ce fichier).
var zones: HeartRateZones? = nil 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). /// Séance planifiée du jour (nil = jour OFF / pas de séance).
struct TodaySession: Codable { struct TodaySession: Codable {
var sport: String // running, cycling, strength, mobility, hiking, rest var sport: String // running, cycling, strength, mobility, hiking, rest
@@ -128,3 +137,64 @@ struct CoachWidgetSnapshot: Codable {
var label: String? // "Prêt", "Récup", "Fatigue" 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)
}
}

View File

@@ -77,27 +77,37 @@ struct CoachQuickProvider: TimelineProvider {
completion(current()) 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<CoachQuickEntryTimeline>) -> Void) { func getTimeline(in context: Context, completion: @escaping (Timeline<CoachQuickEntryTimeline>) -> Void) {
// Une seule entrée, rechargée à chaque saisie par `reloadAllTimelines`. let now = Date()
// Rafraîchissement de sécurité au prochain minuit, pour que les totaux let midnight = CoachWidgetSnapshot.nextMidnight(after: now)
// du jour repartent de zéro même sans interaction. completion(Timeline(entries: [current(at: now), current(at: midnight)],
let midnight = Calendar.current.nextDate( policy: .after(midnight.addingTimeInterval(60))))
after: Date(), matching: DateComponents(hour: 0, minute: 0), matchingPolicy: .nextTime
) ?? Date().addingTimeInterval(3600)
completion(Timeline(entries: [current()], policy: .after(midnight)))
} }
private func current() -> CoachQuickEntryTimeline { private func current(at day: Date = Date()) -> CoachQuickEntryTimeline {
let snapshot = CoachWidgetStore.load() // `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 // 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. // les porte. On y ajoute ce qui attend dans la file.
let syncedWater = snapshot?.waterMlToday ?? 0 let syncedWater = snapshot?.waterMlToday ?? 0
let syncedCoffee = snapshot?.coffeeCountToday ?? 0 let syncedCoffee = snapshot?.coffeeCountToday ?? 0
return CoachQuickEntryTimeline( return CoachQuickEntryTimeline(
date: Date(), date: day,
waterMl: CoachQuickLog.todayTotal(kind: "water", syncedMl: syncedWater), waterMl: CoachQuickLog.todayTotal(kind: "water", syncedMl: syncedWater, day: day),
waterGoal: snapshot?.waterGoalMl, 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, pending: CoachQuickLog.pending().count,
kcal: snapshot?.kcalToday, kcal: snapshot?.kcalToday,
kcalGoal: snapshot?.kcalGoal kcalGoal: snapshot?.kcalGoal

View File

@@ -23,16 +23,35 @@ struct CoachWidgetProvider: TimelineProvider {
} }
func getSnapshot(in context: Context, completion: @escaping (CoachSnapshotEntry) -> Void) { 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<CoachSnapshotEntry>) -> Void) { func getTimeline(in context: Context, completion: @escaping (Timeline<CoachSnapshotEntry>) -> 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) à // L'app rafraîchit explicitement (WidgetCenter.reloadAllTimelines) à
// chaque setSnapshot. On planifie en plus un refresh horaire de courtoisie. // chaque setSnapshot ; on redemande la main peu après minuit pour que
let next = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) // le jour d'APRÈS soit à son tour préparé.
?? Date().addingTimeInterval(3600) completion(Timeline(entries: entries,
completion(Timeline(entries: [entry], policy: .after(next))) policy: .after(midnight.addingTimeInterval(60))))
} }
} }

View File

@@ -51,13 +51,24 @@ struct WatchProvider: TimelineProvider {
WatchEntry(date: Date(), snapshot: nil) WatchEntry(date: Date(), snapshot: nil)
} }
func getSnapshot(in context: Context, completion: @escaping (WatchEntry) -> Void) { 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<WatchEntry>) -> Void) { func getTimeline(in context: Context, completion: @escaping (Timeline<WatchEntry>) -> Void) {
let entry = WatchEntry(date: Date(), snapshot: CoachWidgetStore.load()) let now = Date()
let next = Calendar.current.date(byAdding: .hour, value: 2, to: Date()) let stored = CoachWidgetStore.load()
?? Date().addingTimeInterval(7200) let midnight = CoachWidgetSnapshot.nextMidnight(after: now)
completion(Timeline(entries: [entry], policy: .after(next))) 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))))
} }
} }