// CoachQuickLog.swift // File d'attente de saisies rapides, PARTAGÉE entre l'app, le widget iPhone et // la complication Watch, via le même App Group que le snapshot. // // ⚠️ Ce fichier doit appartenir à TOUTES les cibles qui le touchent : // App + CoachLiveActivity + CoachWatch + CoachWatchWidgets // (cf. docs/widgets-runbook-mac.md, étape « Target Membership »). // // Pourquoi une file, et pas un appel réseau direct depuis le widget // ----------------------------------------------------------------- // Une extension de widget n'a ni session authentifiée, ni droit de faire // tourner du réseau plus de quelques instants. Elle écrit donc l'intention // localement, et l'application la transmet au serveur à sa prochaine ouverture. // C'est le seul schéma qui tient la promesse « noter un verre d'eau sans ouvrir // l'app » ET reste honnête : rien n'est déclaré synchronisé tant qu'il ne l'est // pas. // // Conséquence assumée : une saisie faite depuis le widget n'apparaît sur // coach.hypnotruck.ch qu'après la prochaine ouverture de l'app. Le widget // affiche donc son propre compteur du jour, alimenté par la file — sinon il // afficherait un total périmé et l'utilisateur douterait de ses saisies. import Foundation /// Une saisie en attente d'envoi au serveur. struct CoachQuickEntry: Codable, Identifiable, Equatable { enum Kind: String, Codable { case drink // volume en millilitres case food // aliment favori, identifié par sa référence } let id: String let kind: Kind /// « water », « coffee » pour une boisson ; référence d'aliment pour food. let ref: String /// Millilitres pour une boisson, grammes pour un aliment. let amount: Double /// Horodatage de la SAISIE, pas de l'envoi : c'est l'heure qui compte pour /// un journal alimentaire, et elle serait perdue en la posant à la synchro. let at: Date init(kind: Kind, ref: String, amount: Double, at: Date = Date()) { self.id = UUID().uuidString self.kind = kind self.ref = ref self.amount = amount self.at = at } } enum CoachQuickLog { static let queueKey = "coach_quick_queue" /// Plafond de la file. Au-delà, c'est que l'app n'a pas été ouverte depuis /// très longtemps : on garde les plus RÉCENTES, celles qui ont encore une /// chance d'être justes. static let maxEntries = 200 private static var defaults: UserDefaults? { UserDefaults(suiteName: CoachWidgetStore.appGroup) } static func pending() -> [CoachQuickEntry] { guard let data = defaults?.data(forKey: queueKey) else { return [] } return (try? JSONDecoder().decode([CoachQuickEntry].self, from: data)) ?? [] } /// Ajoute une saisie. Rend `false` si l'App Group est indisponible — le /// widget doit alors le dire plutôt que d'afficher une confirmation fausse. @discardableResult static func append(_ entry: CoachQuickEntry) -> Bool { guard let d = defaults else { return false } var queue = pending() queue.append(entry) if queue.count > maxEntries { queue.removeFirst(queue.count - maxEntries) } guard let data = try? JSONEncoder().encode(queue) else { return false } d.set(data, forKey: queueKey) return true } /// Retire les saisies transmises. On supprime par identifiant et non en /// vidant la file : une saisie faite pendant la synchronisation serait /// sinon perdue sans laisser de trace. static func remove(ids: Set) { guard let d = defaults else { return } let kept = pending().filter { !ids.contains($0.id) } guard let data = try? JSONEncoder().encode(kept) else { return } d.set(data, forKey: queueKey) } // MARK: - Totaux du jour /// Total d'un type de boisson pour aujourd'hui, file comprise. /// /// `synced` est ce que le serveur connaît déjà (poussé par l'app dans le /// 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 /// douterait de ce qu'il vient de faire. /// ⚠️ **`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 waiting = pending() .filter { $0.kind == .drink && $0.ref == ref && calendar.isDate($0.at, inSameDayAs: day) } .reduce(0) { $0 + $1.amount } return syncedMl + waiting } static func todayCount(kind ref: String, syncedCount: Int = 0, day: Date = Date()) -> Int { let calendar = Calendar.current let waiting = pending() .filter { $0.kind == .drink && $0.ref == ref && calendar.isDate($0.at, inSameDayAs: day) } .count return syncedCount + waiting } }