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

@@ -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<CoachQuickEntryTimeline>) -> 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

View File

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