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.
123 lines
5.6 KiB
Swift
123 lines
5.6 KiB
Swift
// 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
|
||
}
|
||
}
|
||
}
|