Files
coach-ios/ios/App/App/CoachQuickSync.swift
Sylvain Bettinelli b8eb1e1b1e 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.
2026-08-18 07:11:15 +00:00

123 lines
5.6 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// CoachQuickSync.swift
// Transmet au serveur les saisies faites depuis le widget, puis les retire de
// la file partagée.
//
// Cible : App uniquement. L'extension n'appelle jamais ce code elle n'a ni
// session authentifiée ni droit de tenir une requête réseau ; c'est toute la
// raison d'être de la file.
//
// Quand l'appeler : au démarrage et à chaque retour au premier plan
// (cf. AppDelegate). Une saisie faite sur l'écran d'accueil doit remonter à la
// première ouverture qui suit, sans que l'utilisateur ait à faire quoi que ce
// soit.
import Foundation
import WidgetKit
enum CoachQuickSync {
/// Le WebView porte le cookie de session : on réutilise le même magasin,
/// plutôt que de refabriquer une authentification qui divergerait.
private static var session: URLSession {
let config = URLSessionConfiguration.default
config.httpCookieStorage = HTTPCookieStorage.shared
config.httpShouldSetCookies = true
config.timeoutIntervalForRequest = 15
return URLSession(configuration: config)
}
private static let base = URL(string: "https://coach.hypnotruck.ch")!
private static let dayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
// Fuseau LOCAL et non UTC : un verre bu à 23 h doit compter pour le
// jour où il a été bu, pas pour le lendemain.
f.timeZone = TimeZone.current
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
/// Envoie la file, retire ce qui est passé. Rend le nombre transmis.
///
/// Les entrées sont retirées **par identifiant** et seulement après un
/// succès : vider la file en bloc perdrait une saisie faite pendant la
/// synchronisation, sans laisser de trace. Une entrée qui échoue reste en
/// file et repartira à la prochaine ouverture mieux vaut un doublon
/// visible qu'une saisie disparue.
@discardableResult
static func flush() async -> Int {
let queue = CoachQuickLog.pending()
guard !queue.isEmpty else { return 0 }
var sent = Set<String>()
for entry in queue where entry.kind == .drink {
if await post(entry) { sent.insert(entry.id) }
}
if !sent.isEmpty {
/* Le snapshot AVANT de vider la file. Le widget affiche
« snapshot + file » : vider la file sans créditer le snapshot
ferait retomber son total à zéro alors que la saisie vient
justement d'être enregistrée. C'est exactement ce qui se passait.
Le pont web repoussera un snapshot exact au prochain chargement
de page ; en attendant, ce report garde l'affichage juste. */
credit(queue.filter { sent.contains($0.id) })
CoachQuickLog.remove(ids: sent)
WidgetCenter.shared.reloadAllTimelines()
}
return sent.count
}
/// Reporte dans le snapshot ce qui vient de partir au serveur.
///
/// Seules les entrées du JOUR comptent : le snapshot porte des totaux
/// quotidiens, et y verser une saisie d'hier les fausserait.
private static func credit(_ entries: [CoachQuickEntry]) {
let calendar = Calendar.current
let today = entries.filter { calendar.isDateInToday($0.at) }
guard !today.isEmpty else { return }
/* **`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
if water > 0 { snapshot.waterMlToday = (snapshot.waterMlToday ?? 0) + water }
if coffees > 0 { snapshot.coffeeCountToday = (snapshot.coffeeCountToday ?? 0) + coffees }
snapshot.updatedAt = Date()
CoachWidgetStore.save(snapshot)
}
private static func post(_ entry: CoachQuickEntry) async -> Bool {
var request = URLRequest(url: base.appendingPathComponent("api/drinks"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = [
"drink_type": entry.ref,
"volume_ml": entry.amount,
"date": dayFormatter.string(from: entry.at),
]
guard let data = try? JSONSerialization.data(withJSONObject: body) else { return false }
request.httpBody = data
do {
let (_, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { return false }
// 400 = payload refusé : le réessayer indéfiniment bloquerait la
// file. On considère l'entrée traitée et on la retire.
if http.statusCode == 400 { return true }
return (200...299).contains(http.statusCode)
} catch {
// Réseau absent : l'entrée reste en file, c'est le cas nominal du
// widget utilisé hors couverture.
return false
}
}
}