Files
coach-ios/ios/App/App/CoachQuickLog.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

121 lines
5.2 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.
// 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<String>) {
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
}
}