Synchronisation des saisies du widget vers le serveur

`CoachQuickSync.flush()` envoie la file partagée vers POST /api/drinks et retire
les entrées transmises. Cible App uniquement : l'extension n'appelle jamais ce
code, elle n'a ni session ni droit de tenir une requête — c'est toute la raison
d'être de la file.

Le cookie de session du WebView est réutilisé plutôt que de refabriquer une
authentification qui divergerait.

Trois comportements voulus, documentés pour qu'on ne les « corrige » pas :
une entrée qui échoue reste en file et repartira — mieux vaut un doublon visible
qu'une saisie disparue ; un 400 est considéré comme traité, sans quoi un payload
refusé bloquerait la file indéfiniment ; la date est formatée en fuseau local,
pour qu'un verre bu à 23 h compte pour le jour où il a été bu.

Reste un appel à poser dans AppDelegate.applicationDidBecomeActive — le
one-liner est dans le runbook.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-06 13:06:28 +00:00
parent 7778e62c40
commit 823bfb2ab9
2 changed files with 106 additions and 8 deletions

View File

@@ -141,13 +141,21 @@ file, l'app la vide.
6. Builder sur un device réel — les App Intents ne s'exécutent pas de façon 6. Builder sur un device réel — les App Intents ne s'exécutent pas de façon
fiable en simulateur. fiable en simulateur.
### Ce qui reste à câbler côté app ### Synchronisation (fait — `CoachQuickSync.swift`, cible **App** seule)
La file `CoachQuickLog.pending()` doit être transmise au serveur à l'ouverture `CoachQuickSync.flush()` envoie la file vers `POST /api/drinks` et retire les
de l'app, puis retirée par `CoachQuickLog.remove(ids:)` — **par identifiant, pas entrées transmises **par identifiant**. Reste à l'appeler depuis `AppDelegate` :
en vidant la file** : une saisie faite pendant la synchronisation serait sinon
perdue sans laisser de trace. Endpoint : `POST /api/drinks`
(`drink_type`, `volume_ml`, `date`).
Tant que ce câblage manque, les saisies restent locales : le widget affiche une ```swift
flèche de synchronisation et son compteur reste juste. func applicationDidBecomeActive(_ application: UIApplication) {
Task { await CoachQuickSync.flush() }
}
```
Comportements voulus, à ne pas « corriger » :
- une entrée qui échoue **reste en file** et repartira — mieux vaut un doublon
visible qu'une saisie disparue ;
- un **400** est considéré comme traité : le réessayer indéfiniment bloquerait
la file ;
- la date est formatée en **fuseau local** — un verre bu à 23 h compte pour le
jour où il a été bu.

View File

@@ -0,0 +1,90 @@
// 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 {
CoachQuickLog.remove(ids: sent)
// Le widget doit cesser d'afficher la flèche d'attente, et son
// total doit repasser sur la source serveur.
WidgetCenter.shared.reloadAllTimelines()
}
return sent.count
}
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
}
}
}