Compare commits

..

19 Commits

Author SHA1 Message Date
Sylvain Bettinelli
6aa7265147 Les macros arrivent au poignet — affichage, pas saisie
Sylvain, après avoir reçu le widget Hydratation : « il manque juste le suivi
des repas calories et lipides etc ».

⚠️ Sa demande est plus fine que ce que j'avais écarté. J'avais dit non à la
SAISIE des repas sur la montre — pas de caméra, pas de scanner, pas de saisie
de texte : un bouton n'aurait fait que renvoyer vers l'iPhone. Mais VOIR où
l'on en est n'a aucune de ces limites.

Ce qui manquait, et où
-----------------------
Le snapshot portait `kcalToday`/`kcalGoal` et rien d'autre. Les macros
existaient pourtant côté serveur, agrégées et servies :

  /api/meals            -> kcal, protein_g, carbs_g, fat_g, fiber_g
  /api/nutrition/goals  -> cibles {target, min, max} pour chacune

Il manquait uniquement le transport. Trois maillons ajoutés : `widget-bridge.js`
les pousse, `CoachWidgetSnapshot` les porte, `CoachWatchNutritionWidget` les
affiche.

⛔ Rien n'est calculé côté natif. Les totaux viennent du journal, les cibles de
`nutrition_goals.py`. Le seul calcul est un reste (cible − consommé), la même
soustraction que /meals affiche déjà.

Le piège évité, parce qu'il est documenté dans ce fichier
---------------------------------------------------------
`asOf(_:)` remet les totaux du jour à zéro quand le snapshot date d'hier. Les
macros y sont ajoutées : les oublier aurait affiché les grammes de la veille
sous la date du jour — le défaut même que cette fonction existe pour empêcher,
et qui avait survécu un temps sur les totaux de boisson.

⚠️ Les CIBLES ne se périment pas, elles : une cible n'est pas une mesure. Sans
ça, la jauge disparaîtrait chaque nuit jusqu'à la prochaine ouverture de l'app.
Deux tests Linux le verrouillent (`testMacrosResetLikeEnergy`,
`testMacroGoalsSurviveTheNight`) — 93 au vert.

⚠️ Une cible de type « min » (les protéines) se lit comme un plancher atteint,
pas comme un plafond à ne pas dépasser. `widget-bridge.js` ne pousse que
`target` ; le widget n'en sait pas plus et ne prétend pas le contraire.

Le widget compile-t-il ? Inconnu, comme les précédents : WidgetKit n'existe pas
sur Linux. Vérifié en revanche que chaque champ appelé existe, et qu'aucun
symbole n'est déclaré deux fois.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 07:46:32 +00:00
Sylvain Bettinelli
d41e8a457b Premier build : deux dialogues Siri refusés, la faute à une concaténation
Erreurs remontées par Xcode sur le Mac :

  CoachIntents.swift:126  Cannot convert value of type 'String' to expected
                          argument type 'IntentDialog'
  CoachIntents.swift:152  idem

`IntentDialog` est `ExpressibleByStringInterpolation` : un littéral interpolé
passe — et les sept autres dialogues du fichier passent pour cette raison —
mais l'opérateur `+` produit une `String`, que le compilateur refuse. Mes deux
seules phrases écrites sur plusieurs lignes avec `+` étaient donc les deux
seules à casser.

Corrigé : un littéral d'un seul tenant pour la phrase courte, et
`IntentDialog(stringLiteral:)` pour celle qui se construit à partir de trois
valeurs. Le commentaire dit pourquoi, pour que la prochaine phrase longue ne
reparte pas sur un `+`.

⚠️ Exactement le genre d'erreur annoncée au commit précédent : rien de ce code
ne compile sur Linux, la vérification croisée des symboles ne voit pas les
types. Il fallait le build pour la trouver.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 07:15:37 +00:00
Sylvain Bettinelli
0d34908f4f Runbook : la cible watchOS s'appelle CoachWatchWidgetsExtension, pas CoachWatchWidgets
Vérifié dans project.pbxproj avant d'envoyer Sylvain dans Xcode : les quatre
cibles réelles sont App, CoachLiveActivity, CoachWatch et
CoachWatchWidgetsExtension. Le runbook donnait le nom du DOSSIER
(CoachWatchWidgets) pour celui de la cible — il aurait cherché un intitulé
inexistant dans l'inspecteur de Target Membership, à l'étape précisément
décrite comme celle qui décide de tout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 07:02:29 +00:00
Sylvain Bettinelli
98b1cd0c89 Siri connaît enfin l'app, et la montre sait noter un verre
Demandé par Sylvain : des widgets, puis « comment lier Siri à mon appli ».
Constat avant de commencer :

  App Intents déclarés      : 1  (LogDrinkIntent)
  AppShortcutsProvider      : 0  -> Siri n'avait AUCUNE phrase à écouter
  Saisie depuis la montre   : 0  -> complications d'affichage seulement

Et la cause commune : `LogDrinkIntent` vivait dans `CoachQuickWidget.swift`,
qui n'appartient qu'à l'extension widget iPhone. Ni l'app (d'où Siri découvre
les raccourcis), ni la cible Watch ne pouvaient le voir.

Ce qui est ajouté
------------------
- `CoachIntents.swift` — les intents, partagés entre les TROIS cibles.
  `LogDrinkIntent` y est déplacé (pas dupliqué : deux définitions du même
  symbole dans une cible qui compile les deux fichiers casseraient le build),
  plus `HydrationStatusIntent` et `TodaySessionIntent`.
- `CoachAppShortcuts.swift` — 5 raccourcis, 16 phrases françaises. Cible App
  UNIQUEMENT : Apple attend un seul AppShortcutsProvider par application.
- `CoachWatchHydration.swift` — widget Smart Stack de la montre : total du
  jour, jauge, et deux boutons +cl. Le geste se fait au poignet.

Choix qui méritent d'être dits
-------------------------------
⛔ Pas de saisie des repas sur la montre. Pas de caméra, pas de scanner, pas de
saisie de texte confortable : le bouton ne ferait que renvoyer vers l'iPhone.
Tranché avec Sylvain.

⛔ Pas de volume libre dans les phrases Siri. Un intent paramétré oblige Siri à
poser une question — plus long que de sortir le téléphone, et le raccourci perd
sa raison d'être. Volumes fixés : 250 ml, 500 ml, café 60 ml.

⛔ Aucune borne en bpm dans les réponses vocales. `TodaySession` n'en porte pas
(vérifié : sport, title, subtitle, done) et il ne faut pas en ajouter — une
fourchette cardiaque annoncée par haut-parleur, hors de la recommandation du
jour et de ses réserves, reviendrait à prescrire une intensité à un
post-infarctus. Siri annonce QUOI ; l'app dit COMMENT.

Deux boutons et pas trois sur la montre : sur 160 pt, un troisième descend les
cibles sous le seuil tactile, pour un geste qu'on fait en marchant.

⚠️ RIEN DE CE CODE N'EST COMPILÉ
---------------------------------
AppIntents, WidgetKit et SwiftUI sont absents de Swift pour Linux. Ce qui a été
vérifié à la place, par lecture croisée des sources : que chaque symbole appelé
existe, et qu'aucun n'est déclaré deux fois. Deux erreurs ont d'ailleurs été
trouvées ainsi avant commit — `asOf()` ne rend pas un optionnel, et
`TodaySession` n'a pas de `durationMin`. Les erreurs de type, de concurrence et
d'API n'apparaîtront qu'au build.

`docs/siri-et-widget-watch-runbook-mac.md` porte les étapes Xcode, le tableau
de Target Membership, les cinq vérifications à faire sur l'appareil, et les
trois questions que seul le build tranchera — dont celle-ci : une complication
de cadran accepte-t-elle un bouton interactif, ou est-ce réservé à la Smart
Stack ? Par prudence, le cadran n'est ici qu'un affichage.

91 tests Swift Linux au vert (non-régression CoachQuickLog / snapshot).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 06:58:11 +00:00
Sylvain Bettinelli
3b5b61268c Les quatre constats restants, corrigés — dont deux sur le chemin qu'il fallait éviter
Sur demande explicite de Sylvain (« pour le reste vas-y »), après le § 1.2 buildé
et vérifié le matin. Les cinq constats de l'audit sont désormais traités.

⚠️ § 2.3 et § 2.4 touchent `WorkoutManager.swift` — la fin de séance du live
workout, que la consigne du 22/09 demandait de ne pas approcher. Fait en ne
changeant AUCUNE logique : les `try?` deviennent des `do/catch` qui journalisent,
et le chemin d'échec gagne la clôture qui lui manquait. La récupération par
`recentlySavedWorkout()` est intacte.

§ 1.3 — `CoachSleep` teste `authorizationStatus` : `.notDetermined` ne se
confond plus avec une nuit sans sommeil, et le serveur cessera d'enregistrer des
nuits vides sur lesquelles `sleep_score.py` calculait une régularité. Un refus
EXPLICITE reste indiscernable d'une absence de données — HealthKit ne le révèle
pas en lecture, par conception. L'en-tête du fichier dit maintenant ce que la
promesse couvre au lieu de promettre plus.

§ 2.3 — le `else` final distinguait deux situations opposées qui y tombaient
ensemble : montre verrouillée (la séance EST écrite) et `endCollection` qui a
levé (rien n'est écrit). Seule la seconde est une perte, et elle seule renseigne
`statusMessage` — annoncer une perte sur une montre verrouillée serait une
fausse alerte.

§ 2.4 — `stopTracking()` d'abord : c'est le seul geste qui réussit à coup sûr et
le seul dont l'omission se paie en batterie pendant des heures. Puis une
tentative de sauvegarde en best-effort, avec `recentlySavedWorkout()` en second
recours. `builder = nil` n'intervient plus qu'après les tentatives — il
détruisait jusqu'ici la seule référence, rendant la perte définitive dans
l'instruction qui l'ignorait.

§ 2.6 — `NSLock` sur le tampon GPS, comme aux quatre autres endroits du même
fichier. ⚠️ Invérifiable sans son déclencheur : un workout à plusieurs
HKWorkoutRoute. Sa correction ne se constatera pas plus que le défaut ne se
manifestait.

⛔ NON COMPILÉS. Le test qui comptera pour § 2.3 et § 2.4 n'est pas un log :
c'est une séance réelle menée jusqu'au bout, puis retrouvée dans Santé et dans
coach. Tant qu'elle n'a pas eu lieu, ce sont des intentions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 08:27:17 +00:00
Sylvain Bettinelli
a794323314 Le snapshot arrive à la montre, et on peut enfin le dire
Vérifié sur l'iPhone de Sylvain le 24/09, après build :

    Montre : snapshot reçu
    Zones envoyées : version frozen du 2026-08-12

Les quatre causes d'échec silencieux du § 1.2 sont écartées — session activée,
montre appairée, app Watch installée, updateApplicationContext sans erreur — et
la date de version arrive au poignet, elle qui se perdait au transport. C'est la
première fois que ce chemin peut être affirmé plutôt que supposé : il était muet
depuis son écriture.

⚠️ Ce que ça ne prouve pas, et c'est écrit dans le document :
`updateApplicationContext` est asynchrone. iOS a ACCEPTÉ le contexte, il le
livre quand il peut. Ce qui est établi, c'est que l'iPhone a réussi à le
transmettre au système — pas que la montre affiche les bonnes bornes. Seul le
poignet le dira, pendant une séance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 08:20:52 +00:00
Sylvain Bettinelli
7e7be4a1d1 Les bornes cardiaques partaient vers la montre sans que rien ne dise si elles arrivaient
§ 1.2 de l'audit, vérifié le matin même puis corrigé sur demande explicite de
Sylvain — le gel du 22/09 est levé pour ce point seul, les quatre autres
constats restent intouchés.

Pourquoi celui-là d'abord : c'est le seul dont la conséquence est un chiffre
faux affiché PENDANT l'effort, chez quelqu'un qui règle son intensité dessus.
Les autres perdent de la donnée ou de la batterie, ça se rattrape. Une borne de
zone périmée au poignet a déjà servi à décider.

**L'envoi ne se tait plus.** `pushToWatch` avait quatre sorties silencieuses sur
sept lignes ; il en a maintenant six, toutes nommées et journalisées —
WatchConnectivity non supportée, session non activée, aucune montre appairée,
app Watch non installée, encodage impossible, envoi refusé. Les deux gardes du
milieu viennent de `CoachRoutineBridge`, qui les avait déjà : sans elles,
l'absence de montre se confond avec un échec d'envoi. Le log de succès nomme ce
qui compte — `zones=oui depuis=2026-08-12` — pour savoir quelles bornes sont au
poignet sans ouvrir la montre.

⚠️ Un échec ne fait PAS échouer `setSnapshot`, contrairement à
`CoachRoutineBridge` où l'envoi EST l'opération. Ici l'App Group est déjà écrit
et les widgets iPhone rafraîchis : une montre verrouillée n'invalide rien. D'où
un `resolve` enrichi (`watchPushed`, `watchReason`) plutôt qu'un `reject`, et
`ok` conservé — aucun appelant JS ne casse.

**La date de version voyage enfin.** `HeartRateZones` ne portait aucune date,
alors que le backend sert une version figée et datée (règle 9 du CLAUDE.md web) :
elle se perdait au transport, et après une révision rien au poignet ne
distinguait les nouvelles bornes des anciennes. `zonesSince` et `zonesFrom`
rejoignent le modèle en optionnels. Le serveur les servait déjà — c'est le JS du
dépôt web qui ne les faisait pas monter.

Trois tests au banc Linux : la date survit à `asOf()`, elle survit à
l'aller-retour JSON (ce qui n'est pas encodé n'arrive jamais), et un snapshot
antérieur à ces champs se décode toujours — un décodage qui lèverait laisserait
la montre sans zones du tout, pire que des zones sans date. 91 verts (88 avant).

⛔ NON COMPILÉ. Syntaxe vérifiée (`swiftc -parse`), mais Capacitor,
WatchConnectivity et SwiftUI n'existent pas sur Linux. Au prochain build :
ouvrir la Console sur la catégorie `widget` et vérifier qu'une ligne
« snapshot poussé vers la Watch · zones=oui depuis=… » apparaît au lancement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 08:08:02 +00:00
Sylvain Bettinelli
8e0c2738ce Les cinq constats natifs de l'audit, vérifiés ligne à ligne — sans rien corriger
L'audit du 22/09 marquait cinq constats **[rapporté]** : issus de revues
automatisées, jamais reconfrontés au code, et il le disait lui-même — « les
confirmer avant d'agir ». Sylvain a autorisé la lecture, pas la modification :
aucune ligne de Swift ne bouge ici.

**Les cinq sont confirmés, aucun faux positif.** Trois lectures s'écartent
toutefois de ce que l'audit décrivait :

§ 1.2 est **plus grave**. L'audit ne voyait que le `try?` ; `pushToWatch` a
quatre sorties silencieuses sur sept lignes — WCSession non supportée, session
non activée, encodage échoué, envoi refusé. Et l'aggravant se vérifie :
`HeartRateZones` ne porte aucune date, alors que le backend sert une version
figée et datée (règle 9 du CLAUDE.md web). La date existe côté serveur et se
perd au transport. `asOf()` périme l'eau, les kcal, la routine et le score de
forme, mais pas les zones — délibérément, et c'est défendable pour une
péremption journalière ; ça laisse ouvert le cas d'une révision des bornes.

§ 2.3 est **plus nuancé**. Le code sait déjà que `finishWorkout()` rend nil sans
erreur quand la montre est verrouillée, et que la séance est alors bien écrite —
d'où `recentlySavedWorkout()`. Le défaut n'est donc pas « la séance est perdue »
mais que le double `try?` rend indiscernables deux situations opposées : montre
verrouillée (séance sauvée) et `endCollection` qui a levé (rien de sauvé). Les
deux tombent dans le même `else`.

§ 2.4 est **plus grave** aussi : `self.builder = nil` détruit la seule référence
au builder. La perte devient définitive dans l'instruction même qui l'ignore, et
le GPS continue de tourner après la fin apparente de la séance.

§ 1.3 et § 2.6 sont exacts tels que décrits. Pour § 2.6, un fait qui manquait :
le même fichier utilise `NSLock` pour ce motif à quatre autres endroits — c'est
une omission ponctuelle, pas une convention absente.

⚠️ Les numéros de ligne de l'audit ne valent plus : les correctifs du 22/09 au
soir ont décalé `WorkoutManager.swift` et `CoachHealthRoute.swift`. Le document
porte les références à jour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 08:00:30 +00:00
Sylvain Bettinelli
6031be8c1c CLAUDE.md : Capgo ne déplie pas les séries condensées, getStepSamples si
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:00:11 +00:00
Sylvain Bettinelli
8973a26e47 Les pas d'une séance sont lus en dépliant les séries condensées par HealthKit
Apple, « Accessing condensed workout samples » : HealthKit fusionne les
échantillons d'une séance en séries (count > 1), supprime les originaux,
« while still preserving data resolution », et le détail se relit avec
HKQuantitySeriesSampleQuery. Depuis watchOS 27 (21/09/2026), les pas
d'une course arrivent en 4 à 7 tranches de 5 à 12 min ; le plugin Capgo
lit quantity.doubleValue sans regarder count.

getStepSamples(startDate, endDate) lit les pas de la fenêtre, déplie
chaque série et rend aussi les comptes (raw / series / expanded) : c'est
la preuve que le détail existe encore — ou qu'il n'existe plus. Lecture
seule. Syntaxe vérifiée (swiftc -parse), build Xcode à faire.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 19:55:48 +00:00
Sylvain Bettinelli
b15867273c Le détail natif expose les activités HealthKit de la séance, en lecture seule
À la demande explicite de Sylvain (22/09), après la perte des pas fins
côté montre. Apple documente que les HKWorkoutActivity peuvent
« represent the active and rest periods during interval training », mais
ne dit pas que l'app Exercice en écrit une par étape d'un exercice
personnalisé. Ce champ `activities` (début, fin, durée, type, nombre
d'événements, métadonnées en clair) permet de le constater dans un brut.

Le serveur ne s'en sert pas : rien n'est stocké ni affiché tant que la
donnée n'a pas été vue. Syntaxe vérifiée (swiftc -parse), build Xcode à
faire sur le Mac mini.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 19:32:40 +00:00
Sylvain Bettinelli
31467851d9 Séances perdues en silence, et succès journalisés sur des requêtes refusées
Trois défauts du même chemin, tous vérifiés ligne à ligne avant correction.

1. Toutes les séances d'un lot sauf une étaient perdues DÉFINITIVEMENT.
L'ancre est sauvegardée ligne 298, puis seul `latest` était traité ligne 319 —
or scheduleNotification était le seul appelant de reportToRoutineIfStrength et
reportPlanOrigin. Renfo le matin, vélo le soir, iPhone resté à la maison : au
premier fire la requête ancrée rend les deux, le vélo est notifié, le renfo
n'est jamais POSTé, et l'ancre ayant avancé il ne reviendra jamais. Le log
disait « 2 new workout(s), notifying latest » : exact, et parfaitement muet sur
ce qu'il venait de jeter. Les rapports serveur bouclent désormais sur tout le
lot ; la notification reste unique, un « 🎉 » par séance serait du bruit.

2. Le statut HTTP n'était jamais lu. Les deux POST faisaient
`{ data, _, error in }` et ne regardaient que `error`. Un 401 sur token rotaté,
un 404 ou un 500 n'en produisent pas : reportPlanOrigin journalisait donc
« plan-origin envoyé » sur une requête refusée, et auto-complete sortait en
silence au guard de parsing. Un helper `envoyer(_:libelle:traiter:)` vérifie
maintenant (200..<300) et journalise le code — le pattern existait déjà dans
CoachRoutineBridge.postDay, à deux fichiers de là.

3. Une séance de musculation classique était étiquetée fonctionnelle.
L'activité était écrite en dur alors que le garde accepte aussi
traditionalStrengthTraining. Si la règle de correspondance côté serveur
discrimine sur ce champ, l'appariement était faussé. L'UUID et l'heure de début
sont ajoutés au passage : sans eux le serveur ne peut pas dédupliquer et doit
évaluer sa fenêtre de 12 h depuis l'heure de réception — une séance remontée
avec six heures de retard était datée de son arrivée.

⚠️ PAS corrigé, et assumé : le `defer { completion() }` dit à iOS que la
livraison est traitée alors que les requêtes sont encore en vol, sans
beginBackgroundTask. Le faire proprement demande un import UIKit, une bascule
sur le fil principal et un essai sur appareil — à traiter à part plutôt qu'en
marge. Le statut HTTP étant désormais lu, un échec laissera au moins une trace.

Relevé § 2.1, § 2.2 et § 3.2 de docs/AUDIT-2026-09-22.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:20:43 +00:00
Sylvain Bettinelli
2c9af6c684 Chaque pause effaçait jusqu'à 24 points de trace déjà parcourus
setPaused vidait `pendingRoute` pour « ne pas tracer la pause ». Mais ce tampon
ne contient que des points DÉJÀ acceptés par le filtre et DÉJÀ comptés dans
`distanceMeters` : ils attendent seulement leur écriture, qui ne part qu'au
seuil de routeFlushThreshold (25). À 1 Hz, c'est une douzaine de points en
moyenne, jusqu'à 24.

L'écart était donc visible : la distance affichée les incluait, la
HKWorkoutRoute non. Six arrêts sur une sortie VTTAE — feux, photos, ravito — et
jusqu'à 144 points manquaient à la trace, avec un D+ recalculé depuis elle qui
divergeait d'autant de celui affiché pendant la séance. Aucun log ne
mentionnait l'effacement.

Ils sont désormais écrits, via le même `Task { await flushPendingRoute() }` que
le flush au seuil. Le raisonnement du commentaire d'origine visait le point qui
SUIT la reprise, à des centaines de mètres de là — mais celui-là n'est pas
encore dans le tampon quand la pause commence, et c'est `implausibleSpeed` qui
l'écarte. Nettoyer la pause est l'affaire du filtre, pas du tampon d'écriture.

Pas de course possible : `isPaused` est posé avant, et `ingest` sort dès qu'il
est vrai — aucun point neuf ne peut s'ajouter pendant que le flush s'exécute.

Relevé § 2.5 de docs/AUDIT-2026-09-22.md, seul constat de la section vérifié
ligne à ligne avant correction. 88 tests Linux verts (RouteFilter compris), mais
ils ne couvrent pas ce fichier : CoreLocation n'existe pas sur Linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:20:30 +00:00
Sylvain Bettinelli
a5cbb70210 Les deux extensions étaient restées au build 22, l'app était passée à 23
Xcode refuse l'installation quand une extension ne porte pas le même
CFBundleVersion que l'app qui la contient : « The CFBundleVersion of an app
extension ('22') must match that of its containing parent app ('23') ».

CoachWatchWidgetsExtension et CoachLiveActivity étaient à 22 ; App et
CoachWatch à 23. Les quatre configurations concernées passent à 23, ce qui
aligne les huit.

⚠️ Ce décalage reviendra à chaque incrémentation faite target par target. La
parade durable serait de porter CURRENT_PROJECT_VERSION au niveau du projet et
de laisser les targets en hériter, au lieu de le redéfinir huit fois — à faire
un jour où l'on ne court pas après un build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:18:45 +00:00
Sylvain Bettinelli
adb8177f2f Écran noir au lancement : la fenêtre n'était jamais rendue visible
La première version conditionnait la création de la fenêtre à `window == nil`,
par prudence : le manifeste déclarant UISceneStoryboardFile = Main, je
supposais qu'UIKit avait déjà monté la fenêtre depuis le storyboard et qu'il
ne fallait pas l'écraser.

Sur appareil, le résultat est un écran noir permanent, sans erreur, sans log,
sans crash. UIKit fournit bien un objet fenêtre, mais ne le rend pas visible à
notre place : la garde sautait donc makeKeyAndVisible() et rien ne s'affichait
jamais. Le fond noir de capacitor.config.ts rendait le symptôme indiscernable
d'un écran d'attente.

Le template de Capacitor crée la fenêtre sans condition. C'est le chemin
éprouvé, et s'en écarter « par sécurité » aura coûté un aller-retour de build.
Le seul écart qui reste nécessaire est MainViewController au lieu d'un
CAPBridgeViewController nu : notre sous-classe enregistre les neuf plugins
in-app dans capacitorDidLoad(), et ne dépend de rien du storyboard.

⚠️ À retenir pour la suite : sur ce chemin, une garde défensive ne protège de
rien et masque le défaut qu'elle prétend éviter. La condition de succès était
observable (une fenêtre visible), pas déductible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:16:08 +00:00
Sylvain
157250b3eb Package.resolved régénéré pour capacitor-swift-pm 8.5.2
Le fichier épinglait encore 8.3.1, ce qui empêchait Xcode de résoudre le paquet (croix rouge dans Package Dependencies) alors que xcodebuild y parvenait en ligne de commande. Supprimé et régénéré.
2026-09-22 11:09:53 +02:00
Sylvain Bettinelli
bb1261dfd8 Le Package.swift généré était invalide : la faute à une variable Xcode
Le build échouait sur « unterminated string literal » dans
CapApp-SPM/Package.swift ligne 7, et en cascade sur trois « Missing package
product » (CapApp-SPM, PolarBleSdk, GoogleSignIn). Rien de tout cela ne venait
des paquets : SPM ne pouvait simplement pas lire un manifeste qui contenait
platforms: [.iOS(.v"$)].

Cause racine, lue dans le code du CLI (util/spm.js -> ios/common.js) :
getMajoriOSVersion cherche la chaîne « IPHONEOS_DEPLOYMENT_TARGET = » dans le
pbxproj et prend les DEUX caractères qui suivent, sans vérifier que c'est un
nombre. Xcode ayant rempli ce réglage avec "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_
TARGET)" en ouvrant le projet, le CLI récupérait littéralement les caractères
« "$ » et les recopiait dans le manifeste.

Les quatre occurrences de cette variable étaient toutes sur CoachWatch et
CoachWatchWidgetsExtension, où IPHONEOS_DEPLOYMENT_TARGET est INOPÉRANT : un
target watchOS se règle par WATCHOS_DEPLOYMENT_TARGET. Les remplacer par 15.0
— la valeur du projet — ne change donc aucun comportement, et rend au CLI une
valeur qu'il sait lire. Vérifié : la première occurrence rend désormais « 15 »,
le projet se reparse et les quatre targets sont intacts.

⚠️ Constaté au passage, non corrigé parce que c'est une décision produit : le
target App est à IPHONEOS_DEPLOYMENT_TARGET = 26.0. L'app ne s'installera donc
que sur iOS 26 ou plus récent. Si ce n'est pas délibéré, c'est Xcode qui l'a
monté en appliquant ses réglages recommandés, et il faut le redescendre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:53:46 +00:00
Sylvain
316a35f33b Merge branch 'feat/watch-outdoor' of https://git.nexus.hypnotruck.ch/sylvain/coach-ios into feat/watch-outdoor 2026-09-22 10:33:27 +02:00
Sylvain
446b611afd Intégration Xcode du chantier Polar BLE : SDK SPM et sources au target
CoachPolarBLE.swift et PolarPftpStep.swift étaient versionnés mais absents du projet Xcode : rien ne les compilait, et la dépendance PolarBleSdk n'était déclarée nulle part. L'intégration ne vivait que sur le Mac. Inclut aussi ce qu'Xcode a réécrit en ouvrant le projet : renommage Embed App Extensions vers Embed Foundation Extensions, résolution SPM et les deux schémas.
2026-09-22 10:31:52 +02:00
24 changed files with 1722 additions and 127 deletions

View File

@@ -126,5 +126,6 @@ l'emplacement de la toolchain.
## ⚠️ Limites connues sync
- **Apple Watch sync** : impossible via API tierce — le wrapper HealthKit dans cette app est obligatoire pour récupérer les données Watch.
- **⚠️ `@capgo/capacitor-health` ne déplie pas les séries condensées** (`HKQuantitySample.count > 1`, `Health.swift::readSamples` lit `quantity.doubleValue` seul). HealthKit condense les séances Apple — et depuis iOS/watchOS 27 en quelques heures : les pas d'une course sont devenus 4 tranches de 12 min (22/09/2026). `CoachHealthRoute.getStepSamples` déplie avec `HKQuantitySeriesSampleQuery` ; tout autre type lu par Capgo (FC en tête) peut subir le même sort — même remède si ça arrive. `getWorkoutDetails` expose aussi `activities` (une `HKWorkoutActivity` par étape d'un exercice personnalisé, constaté le même jour).
- **Polar AccessLink** : pas d'historique pré-enregistrement (limitation API).
- **Push training-targets vers Polar** : nécessiterait migration AccessLink v4 (backlog).

View File

@@ -0,0 +1,404 @@
# Diagnostic natif — les cinq constats ouverts de l'audit, vérifiés
> ## ⚠️ § 1.2 A ÉTÉ CORRIGÉ le 24/09, après ce diagnostic
>
> Sylvain a levé le gel sur ce point précis (« tu attends quoi ? »), puis sur
> **les quatre autres** (« pour le reste vas-y »). Les cinq sont donc corrigés :
> § 1.2 buildé et vérifié sur l'appareil, § 1.3 / 2.3 / 2.4 / 2.6 écrits et
> vérifiés en syntaxe, **pas encore compilés**.
>
> ⚠️ **§ 2.3 et § 2.4 touchent `WorkoutManager.swift`**, c'est-à-dire le chemin
> de fin de séance du live workout — ce que la consigne du 22/09 demandait de ne
> pas approcher. Fait sur demande explicite, en ne changeant aucune logique :
> les `try?` deviennent des `do/catch` qui journalisent, et le chemin d'échec
> gagne la clôture qui lui manquait. **À éprouver en séance réelle avant de
> considérer que ça tient.**
>
> ✅ **BUILD FAIT ET VÉRIFIÉ SUR L'APPAREIL, le 24/09.** Le diagnostic de
> `/settings` (« Diagnostiquer et pousser ») affiche sur l'iPhone de Sylvain :
>
> ```
> Montre : snapshot reçu
> Zones envoyées : version frozen du 2026-08-12
> ```
>
> Les quatre causes d'échec silencieux sont donc écartées : session activée,
> montre appairée, app Watch installée, `updateApplicationContext` sans erreur.
> Et la date de version arrive au poignet — elle se perdait au transport.
>
> ⚠️ **Ce que cela ne prouve pas** : `updateApplicationContext` est asynchrone.
> iOS a *accepté* le contexte, il le livre quand il peut. Ce qui est établi,
> c'est que l'iPhone a réussi à le transmettre au système — pas que la montre
> affiche les bonnes bornes. Seul le poignet le dira, pendant une séance.
**24/09/2026. Diagnostic mené en lecture seule** — aucune ligne de Swift n'avait
été modifiée à sa rédaction.
Demandé par Sylvain après l'audit du 22/09, dont cinq constats étaient marqués
**[rapporté]** — issus de revues automatisées, jamais reconfrontés au code.
L'audit lui-même le disait : « les confirmer avant d'agir ».
Ce document dit ce que le code fait **aujourd'hui**, ce que l'audit décrivait
mal, et ce qu'il faudra corriger le jour où le gel sur le natif sera levé. Il ne
propose aucun geste : la correction demande un build Mac, et rien n'est réel
tant que ce build n'a pas tourné.
⚠️ **Les numéros de ligne de l'audit ne valent plus.** Les correctifs du 22/09
au soir ont décalé `WorkoutManager.swift` et `CoachHealthRoute.swift`. Les
références ci-dessous sont celles du dépôt à `6031be8`.
---
## Verdict d'ensemble
**Les cinq sont confirmés.** Aucun faux positif. Deux sont plus graves que
l'audit ne le disait, un est plus nuancé.
| # | Constat | Vérifié | Écart avec l'audit |
|---|---|---|---|
| 1.2 | Zones cardiaques muettes vers la montre | ✅ | **Aggravé** : 4 sorties muettes, pas 1 |
| 1.3 | « Refusé » ≡ « aucune donnée » (sommeil) | ✅ | Exact |
| 2.3 | Séance perdue sans log | ✅ | **Nuancé** : `nil` n'est pas toujours une perte |
| 2.4 | `didFailWithError` laisse le GPS allumé | ✅ | **Aggravé** : `builder = nil` rend la récupération impossible |
| 2.6 | Tampon GPS sans verrou | ✅ | Exact |
---
## 1.2 — Les zones cardiaques peuvent ne jamais atteindre la montre
**`ios/App/App/CoachWidgetBridge.swift:175-183`** (`pushToWatch`)
L'audit ne signalait que le `try?`. Il y a **quatre** sorties silencieuses sur
sept lignes :
```swift
guard WCSession.isSupported() else { return } // 1. muet
let session = WCSession.default
guard session.activationState == .activated, // 2. muet
let data = try? JSONEncoder().encode(snapshot) else { return } // 3. muet
try? session.updateApplicationContext(["widgetSnapshot": data]) // 4. avalé
```
Aucun log, aucune remontée au JS. Le snapshot porte `zones: HeartRateZones?`
(`CoachWidgetSnapshot.swift:96`) — les bornes Z1…Z5 et `clinicalTargetBpm`, la
cible sous bêtabloquant.
**Ce que fait le chemin voisin, dans le même dépôt**
(`CoachRoutineBridge.swift:87-101`) pour la routine de gainage : il vérifie
`isPaired` **et** `isWatchAppInstalled` avec une raison explicite, entoure
l'appel d'un `do/catch`, journalise l'échec et appelle `call.reject`.
Le chemin instrumenté porte des exercices de mobilité. Le chemin muet porte les
bornes cardiaques d'un patient post-infarctus.
### L'aggravant est réel, et il se compose avec le premier
`HeartRateZones` (`ios/App/App/HeartRateZones.swift`) ne porte **aucune date** :
ni `version`, ni `validFrom`, ni `computedAt`. Or le backend sert une version
**figée et datée** (`data/hr_zones_history.json`, via `app.current_zones()`) —
c'est même une règle non négociable du projet web (règle 9 du CLAUDE.md).
**La date existe côté serveur et se perd au transport.**
Et `CoachWidgetSnapshot.asOf(_:)` périme l'eau, le café, les kcal, la routine,
l'activité du jour et le score de forme — mais **pas `zones`**, délibérément
(« version figée, valable des semaines »). Le raisonnement est bon pour une
péremption journalière ; il laisse ouvert le cas d'une **révision** des bornes.
Conséquence composée : après un `POST /api/cardiac-zones/confirm`, si le push
échoue silencieusement, la montre affiche les anciennes bornes **sans limite de
temps et sans que rien ne le signale**, ni au poignet ni à l'iPhone.
**À corriger, le jour venu** : aligner `pushToWatch` sur `CoachRoutineBridge`
(do/catch, log, `isPaired`/`isWatchAppInstalled`), et faire voyager la date de
version des zones pour que la montre puisse dire « bornes du 12/08 » plutôt que
d'afficher un chiffre sans âge.
---
## 1.3 — « Accès refusé » et « aucune donnée » sont rendus identiques
**`ios/App/App/CoachSleep.swift`** — en-tête ligne 30 :
> `// r.available = false si HealthKit est indisponible ou non autorisé`
Le code teste `HKHealthStore.isHealthDataAvailable()` (l. 59, 63) et la
disponibilité du type (l. 68). **`authorizationStatus(for:)` n'apparaît nulle
part dans le fichier.** La requête aboutit et rend :
```swift
call.resolve(["available": true, "samples": out, ...]) // l. 92-96
```
La documentation du fichier promet donc quelque chose que le code ne fait pas.
**La nuance de l'audit est juste** : HealthKit ne distingue pas volontairement
« refusé » de « vide » **en lecture** — c'est une protection de vie privée
documentée par Apple. Mais `authorizationStatus(for:)` renvoie `.notDetermined`
de façon fiable, et c'est le cas fréquent : réinstallation de l'app,
autorisation jamais accordée. C'est celui-là qu'on peut distinguer.
**Conséquence côté serveur** : `sleep_score.py` calcule une régularité de
coucher sur des nuits absentes, sans savoir qu'elles sont absentes parce qu'on
ne nous laisse plus regarder. Une valeur fausse, pas une valeur manquante.
Même famille dans `CoachHealthRoute.swift` (l. 173 et 277 selon l'audit — à
revérifier, les lignes ont bougé).
---
## 2.3 — Une séance peut disparaître sans un seul log
**`ios/App/CoachWatch/WorkoutManager.swift:383-398`**
```swift
LocationTracker.shared.stopTracking()
try? await builder.endCollection(at: date)
let workout = try? await builder.finishWorkout()
if let workout { … } else if let recovered = await self.recentlySavedWorkout() { … }
else { LocationTracker.shared.discardRoute() }
```
**Ce que l'audit ne disait pas, et qui change la lecture** : le commentaire du
code explique que `finishWorkout()` rend `nil` **sans erreur** quand la montre
est verrouillée, et que dans ce cas la séance **est** écrite dans HealthKit —
seul l'objet manque. C'est pour ça que `recentlySavedWorkout()` existe. Un `nil`
n'est donc pas une perte en soi, et le code le sait.
**Le défaut est plus précis que « la séance est perdue » :** le double `try?`
efface la différence entre deux situations opposées —
| Ce qui s'est passé | `workout` | La séance est-elle sauvée ? |
|---|---|---|
| Montre verrouillée à l'arrêt | `nil` | **Oui**, `recentlySavedWorkout()` la retrouve |
| `endCollection` a levé | `nil` | **Non**, il n'y a rien à retrouver |
Les deux chemins convergent vers le même `else`, et `discardRoute()` ne
journalise que la perte de la **trace**. `statusMessage` n'est pas touché :
après deux heures de sortie, l'utilisateur voit l'écran de fin habituel.
**À corriger** : remplacer les deux `try?` par un `do/catch` qui journalise
l'erreur — sans changer la logique de récupération, qui est bonne.
---
## 2.4 — `didFailWithError` ne clôt rien et laisse le GPS allumé
**`ios/App/CoachWatch/WorkoutManager.swift:451-464`**
```swift
workoutLog.error("session didFailWithError domain=… code=…: \(message)")
Task { @MainActor in
self.statusMessage = "Erreur séance : \(message)"
self.isRunning = false
self.session = nil
self.builder = nil
ConnectivityManager.shared.sendEnded(reason: "session-failed")
}
```
Comparé au chemin `.ended` (l. 383-398), il manque **tout** : `stopTracking()`,
`endCollection`, `finishWorkout`, `finishRoute`/`discardRoute`.
**Plus grave que l'audit ne le dit** : `self.builder = nil` détruit la seule
référence au builder. Après cette ligne, plus aucun code ne peut tenter de
sauver ce qu'il avait collecté — la perte devient définitive dans la même
instruction qui l'ignore.
Deux conséquences distinctes :
1. **Le GPS continue de tourner.** `LocationTracker` n'est jamais arrêté, alors
que la séance est finie du point de vue de l'interface. Sur une montre, ça se
paie en batterie, et rien ne l'indique.
2. **Les données collectées sont jetées sans tentative de sauvegarde.**
⚠️ À la différence de § 2.3, ici `statusMessage` **est** renseigné : l'utilisateur
voit « Erreur séance ». Il sait qu'il s'est passé quelque chose ; il ne sait pas
qu'il a perdu la séance, et le GPS tourne toujours.
---
## 2.6 — Accès concurrent non protégé sur le tampon GPS
**`ios/App/App/CoachHealthRoute.swift:183-205`** (`collectLocations`)
```swift
var all: [CLLocation] = []
let group = DispatchGroup()
var pluginErr: Error?
for route in routes {
group.enter()
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
if let err = err { pluginErr = err; group.leave(); return }
if let locs = locations { all.append(contentsOf: locs) } // ⚠️ sans verrou
…
```
`all` et `pluginErr` sont mutés depuis les callbacks de N requêtes, livrées sur
des files HealthKit indépendantes. **Aucun verrou.**
**Le même fichier utilise `NSLock` pour ce motif exact à quatre endroits** :
lignes 283, 380, 442 et 565. Ce n'est donc pas une convention absente du
projet — c'est une omission à un seul endroit.
**Déclencheur** : un workout portant plusieurs `HKWorkoutRoute` (multisport, ou
reprise après interruption). Avec une seule route, la boucle n'a qu'une
itération et le défaut est inobservable.
⚠️ **On ne sait pas s'il s'est déjà produit** : les routes vivent dans
HealthKit, pas sur le serveur, et rien ne journalise `routes.count`. Une
corruption de tableau en Swift ne lève pas forcément — elle peut donner une
trace tronquée ou un crash, sans rien qui désigne la cause.
---
## Ce que ce diagnostic ne dit pas
- **Aucune de ces corrections n'a été écrite.** Le gel du 22/09 sur le natif
tient ; Sylvain a autorisé la lecture, pas la modification.
- **Rien n'est vérifiable sans build Mac.** Ces cinq constats sont établis par
lecture du code, pas par exécution. Une lecture peut manquer un chemin.
- **La dette assumée du 22/09 reste ouverte** : le `defer { completion() }` de
`CoachWorkoutObserver` déclare la livraison traitée alors que les requêtes
sont encore en vol, sans `beginBackgroundTask`. Elle n'est pas revue ici, elle
était déjà documentée et assumée.
- **Le § 4 de l'audit (gravité 3, justesse et cohérence) n'a pas été revérifié.**
## Si un seul devait être corrigé
**§ 1.2**, et pas parce qu'il est le plus spectaculaire : parce que c'est le seul
dont la conséquence est un **chiffre faux affiché pendant l'effort**, chez
quelqu'un qui règle son intensité dessus. Les quatre autres perdent de la donnée
ou de la batterie — c'est réparable après coup. Une borne de zone périmée au
poignet ne se rattrape pas : elle a déjà servi à décider.
---
## Ce qui a été corrigé (§ 1.2, le 24/09 après-midi)
Trois couches, parce que le défaut en traversait trois.
### 1. L'envoi ne se tait plus — `CoachWidgetBridge.swift`
`pushToWatch` rend désormais la **raison** de son échec, ou `nil` s'il a réussi,
et journalise dans `Logger(subsystem: "ch.hypnotruck.coach", category: "widget")`.
Les quatre sorties muettes deviennent **six sorties nommées** :
WatchConnectivity non supportée · session non activée · **aucune montre
appairée** · **app Watch non installée** · encodage impossible · envoi refusé.
Les deux gardes en gras viennent de `CoachRoutineBridge` : sans elles, l'absence
de montre se confondait avec un échec d'envoi.
Le log de succès nomme ce qui compte : `zones=oui depuis=2026-08-12`. Savoir
quelles bornes sont au poignet, et depuis quand, sans ouvrir la montre.
⚠️ **Un échec ne fait PAS échouer `setSnapshot`**, contrairement à
`CoachRoutineBridge` où l'envoi *est* l'opération. Ici l'App Group est déjà écrit
et les widgets iPhone rafraîchis : une montre verrouillée n'invalide rien. D'où
un `resolve` enrichi (`watchPushed`, `watchReason`) et non un `reject` — `ok`
est conservé, aucun appelant JS existant ne casse.
### 2. La date de version voyage — `HeartRateZones.swift` + `widget-bridge.js`
`zonesSince` (date de la version figée) et `zonesFrom` (`"frozen"` ou `"live"`)
rejoignent le modèle, en optionnels — un snapshot écrit avant reste décodable.
**Le serveur les servait déjà** : `/api/cardiac-zones` rend `zones_since` et
`zones_from` depuis `hr_zones_history.effective()`. Rien à changer côté backend ;
c'est `widget-bridge.js` qui ne les faisait pas monter dans le snapshot. Deux
lignes.
La montre peut donc maintenant distinguer des bornes **validées** de bornes
**provisoires**, et dire leur âge. Ce qu'elle en affiche reste à décider — c'est
de l'UI watchOS, hors de ce correctif.
### 3. Le garde-fou — `tests-linux/`
Trois tests dans `DayRolloverTests` : la date survit au passage de minuit
(`asOf`), elle survit à l'aller-retour JSON (ce qui n'est pas encodé n'arrive
pas au poignet), et un snapshot **antérieur** à ces champs se décode toujours —
un décodage qui lèverait laisserait la montre sans zones du tout, pire que des
zones sans date. La doublure `HeartRateZonesStub.swift` est tenue à l'identique.
91 tests verts (88 avant).
### Ce que ce correctif ne fait pas
- **Il ne prouve pas que le push fonctionne.** Il rend son échec *visible*. Si
l'envoi échouait pour une raison qu'on ignore, on le saura au prochain build —
c'était impossible jusqu'ici.
- **Il ne périme pas les zones.** Après une révision, la montre affichera
toujours les anciennes bornes tant qu'un push n'a pas réussi ; elle saura
seulement les dater. Décider qu'une borne de plus de N jours n'est plus
affichable est un choix produit, pas une correction de défaut.
- **Il ne touche pas au flux course à pied** (WorkoutKit, live workout FC, app
Exercice), conformément à la consigne du 22/09.
- ✅ **Compilé et vérifié le 24/09** — sans Console : le diagnostic de
`/settings` rend le statut en clair (`Montre : snapshot reçu` · `Zones
envoyées : version frozen du 2026-08-12`), parce que le plugin remonte
désormais `watchPushed` / `watchReason` jusqu'au JS.
⏳ **Reste à voir au poignet** : que la montre affiche ces bornes pendant une
séance. L'envoi est accepté par iOS ; la livraison est asynchrone.
---
## Les quatre autres, corrigés le 24/09 (« pour le reste vas-y »)
⛔ **Aucun n'est compilé.** Syntaxe vérifiée (`swiftc -parse`), 91 tests du banc
Linux verts — mais ces fichiers importent HealthKit, WatchKit et Capacitor,
absents de Swift pour Linux. Le banc ne les couvre pas et ne peut pas les
couvrir.
### § 1.3 — `.notDetermined` n'est plus confondu avec une nuit vide
`CoachSleep.readSamples` teste désormais `authorizationStatus(for: sleepType)`
et rend `{available: false, reason: "authorization not determined"}`.
⚠️ **Ce qui n'est PAS corrigé, et ne peut pas l'être** : un refus *explicite*
reste indiscernable d'une absence de données. HealthKit ne le révèle pas en
lecture, par conception — c'est une protection de vie privée, pas un oubli
d'Apple. Seul `.notDetermined` est fiable, et c'est le cas fréquent
(réinstallation, permission jamais demandée). L'en-tête du fichier dit
maintenant exactement ce que la promesse couvre, au lieu de promettre plus.
### § 2.3 — « séance sauvée » et « rien de sauvé » ne se confondent plus
Les deux `try?` deviennent des `do/catch`. **La logique de récupération n'a pas
changé** — `recentlySavedWorkout()` reste le repli quand `finishWorkout()` rend
`nil` sur montre verrouillée. Ce qui change : un `endCollection` qui lève est
journalisé, et le `else` final distingue les deux cas, dont un seul est une
perte. Dans ce cas seulement, `statusMessage` le dit à l'écran — une montre
verrouillée n'est pas une perte, et l'annoncer serait une fausse alerte.
### § 2.4 — le GPS s'arrête, et la séance est sauvée si elle peut l'être
`didFailWithError` commence par `stopTracking()` : c'est le seul geste qui
réussit à coup sûr, et le seul dont l'omission se paie en batterie pendant des
heures. Puis une tentative de clôture en best-effort (`endCollection`,
`finishWorkout`), avec `recentlySavedWorkout()` en second recours — une session
qui a échoué refusera souvent de clore, mais ne pas essayer garantissait la
perte. `builder = nil` n'intervient plus qu'à la fin, après les tentatives.
Trois issues, trois messages distincts : « Séance interrompue, mais
enregistrée », « Séance perdue : … », ou le message d'erreur d'origine.
### § 2.6 — le tampon GPS est verrouillé
`collectLocations` protège `all` et `pluginErr` par un `NSLock`, comme les
quatre autres endroits du même fichier le faisaient déjà.
⚠️ **Invérifiable sans le déclencheur** : il faut un workout portant plusieurs
`HKWorkoutRoute` (multisport, reprise après interruption). Avec une seule route,
la boucle n'a qu'une itération — c'est pourquoi le défaut a pu vivre longtemps
sans se manifester, et pourquoi sa correction ne se constatera pas non plus.
### Ce qu'il faut regarder au prochain build
| Quoi | Où | Ce qui prouve |
|---|---|---|
| § 1.3 | `/settings` → synchro santé | Retirer « Sommeil » dans Réglages → Santé, réinstaller : la synchro doit dire « non autorisé », pas enregistrer des nuits vides |
| § 2.3 | Console, catégorie `workout` | Une séance normale ne doit RIEN journaliser de neuf. Les nouveaux logs ne sortent qu'en cas d'échec |
| § 2.4 | Batterie, après une séance interrompue | Le GPS ne doit plus tourner une fois l'écran de fin affiché |
| § 2.6 | — | Rien à voir : le correctif supprime une corruption qui ne se manifestait pas de façon lisible |
⚠️ **§ 2.3 et § 2.4 sont sur le chemin de fin de séance du live workout.** Le
test qui compte n'est pas un log, c'est **une séance réelle menée jusqu'au bout,
puis retrouvée dans Santé et dans coach**. Tant qu'elle n'a pas eu lieu, ces
deux correctifs sont des intentions.

View File

@@ -0,0 +1,107 @@
# Runbook Mac — Siri + widget Hydratation de la montre
**Écrit le 25/09/2026, depuis Linux.** Le code Swift est committé ; il reste les
étapes Xcode, qui ne peuvent pas se faire d'ici.
> ⚠️ **Nom des cibles, vérifié le 25/09** : la cible watchOS s'appelle
> **`CoachWatchWidgetsExtension`** — le DOSSIER, lui, s'appelle
> `CoachWatchWidgets`. Ce runbook disait d'abord le nom du dossier ; corrigé.
> Les quatre cibles réelles : `App`, `CoachLiveActivity`, `CoachWatch`,
> `CoachWatchWidgetsExtension`.
> ⛔ **Rien de ce code n'a été compilé.** `AppIntents`, `WidgetKit` et `SwiftUI`
> sont des frameworks Apple, absents de Swift pour Linux — le CLAUDE.md de ce
> dépôt le dit. Ce qui a été vérifié à la place, par lecture croisée des
> sources : que chaque symbole appelé existe (`CoachQuickLog.append`,
> `todayTotal`, `pending`, `CoachWidgetStore.load`, `nextMidnight`,
> `fallbackQuickDrinks`, `resolvedQuickDrinks`, `waterMlToday`, `waterGoalMl`,
> `asOf`), et qu'aucun symbole n'est déclaré deux fois. **Les erreurs de type,
> de concurrence et d'API n'apparaîtront qu'au build.**
## Ce que ça apporte
| | Avant | Après |
|---|---|---|
| Siri | rien — aucun `AppShortcutsProvider` | 5 raccourcis, 16 phrases |
| Montre | complications d'affichage | widget Smart Stack qui **note un verre** |
| Intents | 1, enfermé dans l'extension iPhone | 3, partagés entre 3 cibles |
## Fichiers ajoutés / modifiés
- **`ios/App/App/CoachIntents.swift`** *(nouveau)* — `LogDrinkIntent` (déplacé),
`HydrationStatusIntent`, `TodaySessionIntent`, `CoachQuickError`.
- **`ios/App/App/CoachAppShortcuts.swift`** *(nouveau)* — les phrases Siri.
- **`ios/App/CoachWatchWidgets/CoachWatchHydration.swift`** *(nouveau)* — widget
Smart Stack de la montre.
- `ios/App/CoachLiveActivity/CoachQuickWidget.swift` — l'intent en a été
**retiré** (il vivait là, invisible des deux autres cibles).
- `ios/App/CoachWatchWidgets/CoachWatchComplications.swift` — le widget rejoint
le `WidgetBundle`.
## Étapes Xcode
⚠️ **Quitter Xcode puis `git pull` d'abord** (règle COWORK : jamais de pull
Xcode ouvert).
### 1. Target Membership — c'est l'étape qui décide de tout
| Fichier | App | CoachLiveActivity | CoachWatchWidgetsExtension |
|---|:--:|:--:|:--:|
| `CoachIntents.swift` | ✅ | ✅ | ✅ |
| `CoachAppShortcuts.swift` | ✅ | ❌ | ❌ |
| `CoachWatchHydration.swift` | ❌ | ❌ | ✅ |
| `CoachQuickLog.swift` | ✅ | ✅ | ✅ *(à vérifier)* |
| `CoachWidgetSnapshot.swift` | ✅ | ✅ | ✅ *(déjà le cas)* |
⚠️ **`CoachAppShortcuts.swift` dans la cible App UNIQUEMENT.** Apple attend un
seul `AppShortcutsProvider` par application ; un second dans une extension
produirait au mieux un doublon.
⚠️ **`CoachQuickLog.swift` doit être dans `CoachWatchWidgetsExtension`**, sinon le widget
de la montre compile mais n'écrit nulle part. Son en-tête le réclamait déjà.
### 2. Vérifier l'App Group sur la cible Watch
`group.ch.hypnotruck.coach` sur **CoachWatch** ET **CoachWatchWidgetsExtension**.
Vérifié depuis Linux le 25/09 : les quatre `.entitlements` le portent et les
`CODE_SIGN_ENTITLEMENTS` sont câblés. À reconfirmer dans Signing & Capabilities.
### 3. Build, puis les vérifications qui comptent
1. **Siri** — dire « Note un verre d'eau dans Coach ». Attendu : « Noté.
X,XX L d'eau aujourd'hui », **sans que l'app s'ouvre**.
2. **Raccourcis** — l'app *Raccourcis* doit lister les 5 actions sous « Coach ».
3. **Smart Stack** — ajouter le widget Hydratation, toucher « +25 cl », vérifier
que le total monte **et** que le badge des saisies en attente s'incrémente.
4. **Synchro** — ouvrir l'app iPhone, vérifier que la saisie faite au poignet
remonte sur `coach.hypnotruck.ch`.
5. **Minuit** — le total doit repartir de zéro (c'est `asOf(_:)` qui l'assure,
pas le réveil de la timeline : le piège a déjà été payé côté iPhone).
## Les trois questions que seul le build tranchera
1. **Une complication de CADRAN accepte-t-elle un bouton d'App Intent, ou est-ce
réservé à la Smart Stack ?** La doc Apple n'était pas récupérable depuis
Linux. Par prudence, `.accessoryCircular` n'est ici qu'un **affichage** ; si
le cadran accepte l'interactivité, on pourra l'enrichir.
2. **Les phrases Siri passent-elles la validation ?** Chaque phrase contient
`\(.applicationName)`, comme Apple l'exige, et le nom supposé est « Coach »
(`CFBundleDisplayName`). Si le nom affiché diffère, les phrases ne se
déclencheront pas — c'est silencieux.
3. **`ProvidesDialog` sur watchOS.** Les intents rendent
`some IntentResult & ProvidesDialog` pour que Siri réponde à voix haute. À
confirmer que la cible watchOS l'accepte sans ajustement.
## Ce qui a été volontairement écarté
- ⛔ **Saisie des repas depuis la montre.** Pas de caméra, pas de scanner de
code-barres, pas de saisie de texte confortable : un bouton « repas » au
poignet ne ferait que renvoyer vers l'iPhone. Décidé avec Sylvain le 25/09.
- ⛔ **Un paramètre libre de volume dans les phrases Siri.** Un intent
paramétré oblige Siri à poser une question — ce qui coûte plus de temps que
de sortir le téléphone, et vide le raccourci de son intérêt. Les volumes sont
donc fixés (250 ml, 500 ml, café 60 ml).
- ⛔ **Toute borne en bpm dans les réponses de Siri.** `TodaySession` n'en porte
pas, et il ne faut pas en ajouter : une fourchette cardiaque annoncée par
haut-parleur, hors de la recommandation du jour et de ses réserves,
reviendrait à prescrire une intensité à un post-infarctus.

View File

@@ -18,8 +18,6 @@
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; };
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */; };
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */; };
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */; };
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
@@ -31,9 +29,13 @@
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */; };
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; };
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; };
60BB59C4096F4C8EB22DE183 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */; };
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */; };
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; };
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; };
8807F51A3045719400A4D4E8 /* PolarPftpStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8807F5193045719400A4D4E8 /* PolarPftpStep.swift */; };
8807F51B3045719400A4D4E8 /* CoachPolarBLE.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8807F5183045719400A4D4E8 /* CoachPolarBLE.swift */; };
8807F51D3045721100A4D4E8 /* PolarBleSdk in Frameworks */ = {isa = PBXBuildFile; productRef = 8807F51C3045721100A4D4E8 /* PolarBleSdk */; };
880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.framework */; };
880A76D3302B601000F582D5 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D2302B601000F582D5 /* SwiftUI.framework */; };
880A76DE302B601100F582D5 /* CoachWatchWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -50,7 +52,7 @@
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; };
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */; };
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; };
@@ -61,6 +63,8 @@
C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4E1Z0005HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */; };
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */; };
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; };
@@ -68,7 +72,6 @@
E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */; };
F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */; };
F1ABCDEF0123456789AB0003 /* CoachSleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0004 /* CoachSleep.swift */; };
60BB59C4096F4C8EB22DE183 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -107,15 +110,15 @@
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */ = {
C4916F81E61FABAAD62EE4F9 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */,
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed Foundation Extensions */,
);
name = "Embed App Extensions";
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */ = {
@@ -152,6 +155,8 @@
60061E60061E60061E060002 /* CoachGoogleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachGoogleAuth.swift; sourceTree = "<group>"; };
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveStore.swift; sourceTree = "<group>"; };
82122ACF7E5A608AD1D55D24 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
8807F5183045719400A4D4E8 /* CoachPolarBLE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachPolarBLE.swift; sourceTree = "<group>"; };
8807F5193045719400A4D4E8 /* PolarPftpStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolarPftpStep.swift; sourceTree = "<group>"; };
880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachWatchWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
880A76D0302B601000F582D5 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
880A76D2302B601000F582D5 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
@@ -165,8 +170,6 @@
88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = CoachAuth.swift.example; sourceTree = "<group>"; };
88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; };
8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RouteFilter.swift; sourceTree = "<group>"; };
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationTracker.swift; sourceTree = "<group>"; };
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; };
@@ -178,9 +181,12 @@
A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = "<group>"; };
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; };
AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = "<group>"; };
B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HeartRateZones.swift; sourceTree = "<group>"; };
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RouteFilter.swift; sourceTree = "<group>"; };
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationTracker.swift; sourceTree = "<group>"; };
C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineGuidedView.swift; sourceTree = "<group>"; };
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = "<group>"; };
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; };
@@ -191,7 +197,6 @@
F1ABCDEF0123456789AB0004 /* CoachSleep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachSleep.swift; sourceTree = "<group>"; };
F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWatchApp.swift; sourceTree = "<group>"; };
FBFCA969A1B083CD3178D9B2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -205,18 +210,7 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
880A76D4302B601000F582D5 /* CoachWatchWidgets */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
880A76E2302B601100F582D5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = CoachWatchWidgets;
sourceTree = "<group>";
};
880A76D4302B601000F582D5 /* CoachWatchWidgets */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (880A76E2302B601100F582D5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = CoachWatchWidgets; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -225,6 +219,7 @@
buildActionMask = 2147483647;
files = (
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
8807F51D3045721100A4D4E8 /* PolarBleSdk in Frameworks */,
88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -307,6 +302,8 @@
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
8807F5183045719400A4D4E8 /* CoachPolarBLE.swift */,
8807F5193045719400A4D4E8 /* PolarPftpStep.swift */,
88ABE0023024C45A002636FD /* CoachQuickLog.swift */,
88ABE0033024C45A002636FD /* CoachQuickSync.swift */,
88A837392FAA741C00DDD144 /* App.entitlements */,
@@ -408,7 +405,7 @@
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */,
C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */,
C4916F81E61FABAAD62EE4F9 /* Embed Foundation Extensions */,
);
buildRules = (
);
@@ -420,6 +417,7 @@
packageProductDependencies = (
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
88924CCD2FD2A733002F3A80 /* GoogleSignIn */,
8807F51C3045721100A4D4E8 /* PolarBleSdk */,
);
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
@@ -474,7 +472,7 @@
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 2660;
LastUpgradeCheck = 2650;
LastUpgradeCheck = 2700;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
@@ -498,6 +496,7 @@
packageReferences = (
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */,
8807F51730456ED400A4D4E8 /* XCRemoteSwiftPackageReference "polar-ble-sdk" */,
);
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
@@ -584,6 +583,8 @@
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */,
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */,
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,
8807F51A3045719400A4D4E8 /* PolarPftpStep.swift in Sources */,
8807F51B3045719400A4D4E8 /* CoachPolarBLE.swift in Sources */,
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */,
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */,
@@ -673,10 +674,11 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 23;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = CoachWatch/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -691,7 +693,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
@@ -700,6 +702,7 @@
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -763,6 +766,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -824,10 +828,10 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 23;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -849,10 +853,10 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 23;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -873,10 +877,11 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 23;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = CoachWatch/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -892,7 +897,7 @@
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
@@ -906,12 +911,13 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 23;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = CoachWatchWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -947,12 +953,13 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 23;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = CoachWatchWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -983,7 +990,7 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = TZ2PVTRKNY;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = NO;
@@ -1012,7 +1019,7 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = TZ2PVTRKNY;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = NO;
@@ -1093,6 +1100,14 @@
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
8807F51730456ED400A4D4E8 /* XCRemoteSwiftPackageReference "polar-ble-sdk" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/polarofficial/polar-ble-sdk";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 8.2.0;
};
};
88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/google/GoogleSignIn-iOS";
@@ -1109,6 +1124,11 @@
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
productName = "CapApp-SPM";
};
8807F51C3045721100A4D4E8 /* PolarBleSdk */ = {
isa = XCSwiftPackageProductDependency;
package = 8807F51730456ED400A4D4E8 /* XCRemoteSwiftPackageReference "polar-ble-sdk" */;
productName = PolarBleSdk;
};
88924CCD2FD2A733002F3A80 /* GoogleSignIn */ = {
isa = XCSwiftPackageProductDependency;
package = 88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */;

View File

@@ -1,5 +1,5 @@
{
"originHash" : "a07fb3cab82bafe1a29362b82b6680a574f65c3772bc11737e36d9b15949d1dd",
"originHash" : "71d869ed5304dbc1f922c9e16fb695db4fb02f0cacefa12d4a10572958e936b8",
"pins" : [
{
"identity" : "app-check",
@@ -24,8 +24,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/ionic-team/capacitor-swift-pm.git",
"state" : {
"revision" : "f1a8fadf1437c23b825c818fb6509c9dbbae2f61",
"version" : "8.3.1"
"revision" : "0b6882e9a3288342aacf36348e5a94e4f1dd7b13",
"version" : "8.5.2"
}
},
{
@@ -73,6 +73,15 @@
"version" : "2.1.1"
}
},
{
"identity" : "polar-ble-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/polarofficial/polar-ble-sdk",
"state" : {
"revision" : "3d15da61dd0c63e6be2582d03fd80f6c7ba02e11",
"version" : "8.2.0"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
@@ -81,6 +90,24 @@
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8",
"version" : "1.38.1"
}
},
{
"identity" : "zip",
"kind" : "remoteSourceControl",
"location" : "https://github.com/marmelroy/Zip.git",
"state" : {
"revision" : "67fa55813b9e7b3b9acee9c0ae501def28746d76",
"version" : "2.1.2"
}
}
],
"version" : 3

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
LastUpgradeVersion = "2700"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
LastUpgradeVersion = "2700"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@@ -55,7 +55,6 @@
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5BF41648D334DC24770B9D2"
BuildableName = "CoachWatch.app"
BlueprintName = "CoachWatch"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
@@ -72,7 +71,6 @@
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5BF41648D334DC24770B9D2"
BuildableName = "CoachWatch.app"
BlueprintName = "CoachWatch"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>

View File

@@ -0,0 +1,89 @@
// CoachAppShortcuts.swift
// Les phrases que Siri écoute. C'est ce qui manquait pour « lier Siri à
// l'app » : les App Intents existaient (`CoachIntents.swift`), mais sans
// `AppShortcutsProvider` aucune phrase ne les déclenche — Siri ne sait pas
// quoi écouter, et les actions ne remontent que dans l'app Raccourcis.
//
// ⚠️ **Cible App UNIQUEMENT**, pas les extensions.
// Apple attend un seul `AppShortcutsProvider` par application ; en déclarer un
// second dans une extension widget produirait au mieux un doublon.
// Les INTENTS, eux, sont partagés (cf. `CoachIntents.swift`).
//
// ⚠️ **Chaque phrase doit contenir `\(.applicationName)`** — Apple le refuse
// autrement, et le raccourci n'apparaît simplement pas. Le nom vient de
// `CFBundleDisplayName` ; les variantes ci-dessous supposent « Coach ».
//
// ⚠️ **Dix phrases maximum par intent**, et les premières comptent le plus :
// ce sont celles que Siri propose spontanément.
import AppIntents
@available(iOS 17.0, watchOS 10.0, *)
struct CoachAppShortcuts: AppShortcutsProvider {
/// Couleur de la tuile dans l'app Raccourcis.
static var shortcutTileColor: ShortcutTileColor = .teal
static var appShortcuts: [AppShortcut] {
// ── Noter un verre d'eau ────────────────────────────────────────
// Le geste à une vingtaine de fois par jour : c'est lui qui justifie
// Siri. Les paramètres sont FIXÉS ici (250 ml) — un intent avec
// paramètre libre oblige Siri à poser une question, ce qui coûte plus
// de temps que de sortir le téléphone.
AppShortcut(
intent: LogDrinkIntent(ref: "water", amountMl: 250),
phrases: [
"Note un verre d'eau dans \(.applicationName)",
"J'ai bu un verre d'eau dans \(.applicationName)",
"Ajoute un verre d'eau dans \(.applicationName)",
"Un verre d'eau dans \(.applicationName)",
],
shortTitle: "Un verre d'eau",
systemImageName: "drop.fill"
)
AppShortcut(
intent: LogDrinkIntent(ref: "water", amountMl: 500),
phrases: [
"Note une bouteille d'eau dans \(.applicationName)",
"J'ai bu une bouteille dans \(.applicationName)",
"Ajoute une grande eau dans \(.applicationName)",
],
shortTitle: "Une bouteille d'eau",
systemImageName: "waterbottle.fill"
)
AppShortcut(
intent: LogDrinkIntent(ref: "coffee", amountMl: 60),
phrases: [
"Note un café dans \(.applicationName)",
"J'ai bu un café dans \(.applicationName)",
"Ajoute un café dans \(.applicationName)",
],
shortTitle: "Un café",
systemImageName: "cup.and.saucer.fill"
)
// ── Consulter ───────────────────────────────────────────────────
AppShortcut(
intent: HydrationStatusIntent(),
phrases: [
"Combien j'ai bu dans \(.applicationName)",
"Où j'en suis côté hydratation dans \(.applicationName)",
"Mon hydratation dans \(.applicationName)",
],
shortTitle: "Mon hydratation",
systemImageName: "drop.circle"
)
AppShortcut(
intent: TodaySessionIntent(),
phrases: [
"Quelle est ma séance du jour dans \(.applicationName)",
"Ma séance dans \(.applicationName)",
"Qu'est-ce que je fais aujourd'hui dans \(.applicationName)",
],
shortTitle: "Ma séance du jour",
systemImageName: "figure.run"
)
}
}

View File

@@ -28,6 +28,7 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getWorkoutDetails", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getStepSamples", returnType: CAPPluginReturnPromise),
]
private lazy var store = HKHealthStore()
@@ -183,17 +184,36 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
var all: [CLLocation] = []
let group = DispatchGroup()
var pluginErr: Error?
/* ⚠️ `all` et `pluginErr` sont mutés depuis les callbacks de N requêtes,
livrées sur des files HealthKit indépendantes. Sans verrou, deux
`append` concurrents corrompent le tableau — silencieusement, ou par
un crash sans rapport apparent avec la cause.
Ce fichier verrouillait déjà ce motif à quatre autres endroits
(`getEffortScores`, `getWorkoutDetails`, et deux fois plus bas) :
c'était une omission ponctuelle, pas une convention absente.
§ 2.6 du diagnostic du 24/09/2026.
Déclencheur : un workout portant PLUSIEURS `HKWorkoutRoute`
(multisport, reprise après interruption). Avec une seule route la
boucle n'a qu'une itération, et le défaut est inobservable — c'est
pourquoi il a pu vivre longtemps sans se manifester. */
let lock = NSLock()
for route in routes {
group.enter()
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
if let err = err {
lock.lock()
pluginErr = err
lock.unlock()
group.leave()
return
}
if let locs = locations {
lock.lock()
all.append(contentsOf: locs)
lock.unlock()
}
if done {
group.leave()
@@ -396,6 +416,93 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
store.execute(wq)
}
/// Pas d'une fenêtre de temps, séries condensées **dépliées**.
///
/// Apple, *Accessing condensed workout samples* : « HealthKit enumerates the
/// data associated with a workout and adds that data to one or more
/// quantity series samples. After saving all the data to series objects,
/// HealthKit deletes the originals » — « while still preserving data
/// resolution ». Un échantillon condensé a `count > 1`, et son détail se
/// lit avec `HKQuantitySeriesSampleQuery`. Depuis watchOS 27 (constaté le
/// 21/09/2026), les pas d'une course arrivent en 4 à 7 tranches de 5 à
/// 12 min au lieu de ~850 échantillons de 2,5 s ; le plugin Capgo lit
/// `quantity.doubleValue` sans regarder `count`. Cette méthode dit si le
/// détail existe encore, et le rend s'il existe. Lecture seule.
@objc func getStepSamples(_ call: CAPPluginCall) {
guard HKHealthStore.isHealthDataAvailable(),
let stepType = HKObjectType.quantityType(forIdentifier: .stepCount) else {
call.resolve(["samples": [], "reason": "HealthKit unavailable"])
return
}
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoPlain = ISO8601DateFormatter()
func date(_ s: String?) -> Date? {
guard let s = s else { return nil }
return iso.date(from: s) ?? isoPlain.date(from: s)
}
guard let start = date(call.getString("startDate")),
let end = date(call.getString("endDate")), end > start else {
call.reject("startDate et endDate requis (ISO 8601), fin après début")
return
}
let window = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
let query = HKSampleQuery(sampleType: stepType, predicate: window,
limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { [weak self] _, results, error in
guard let self = self else { return }
if let error = error {
call.reject("Step fetch failed: \(error.localizedDescription)")
return
}
let samples = (results as? [HKQuantitySample]) ?? []
let group = DispatchGroup()
let lock = NSLock()
var rows: [[String: Any]] = []
var nSeries = 0
var nExpanded = 0
for s in samples {
let src = s.sourceRevision.source
let base: [String: Any] = ["source": src.name, "sourceId": src.bundleIdentifier]
if s.count <= 1 {
var row = base
row["startDate"] = iso.string(from: s.startDate)
row["endDate"] = iso.string(from: s.endDate)
row["qty"] = s.quantity.doubleValue(for: .count())
lock.lock(); rows.append(row); lock.unlock()
continue
}
nSeries += 1
group.enter()
// Une série : ses quantités individuelles, chacune avec son
// intervalle. `done` marque le dernier appel du handler.
let inSeries = HKQuery.predicateForObject(with: s.uuid)
let detail = HKQuantitySeriesSampleQuery(quantityType: stepType,
predicate: inSeries) { _, quantity, interval, _, done, err in
if let quantity = quantity, let interval = interval {
var row = base
row["startDate"] = iso.string(from: interval.start)
row["endDate"] = iso.string(from: interval.end)
row["qty"] = quantity.doubleValue(for: .count())
row["fromSeries"] = true
lock.lock(); rows.append(row); nExpanded += 1; lock.unlock()
}
if done || err != nil { group.leave() }
}
self.store.execute(detail)
}
group.notify(queue: .global()) {
rows.sort { ($0["startDate"] as? String ?? "") < ($1["startDate"] as? String ?? "") }
NSLog("[CoachHealthRoute] pas : %d échantillons, %d séries, %d dépliés", samples.count, nSeries, nExpanded)
call.resolve(["samples": rows, "raw": samples.count, "series": nSeries, "expanded": nExpanded])
}
}
store.execute(query)
}
/// Assemble métadonnées + statistiques d'une séance. Les champs absents ne
/// sont pas émis : le backend distingue « pas mesuré » de « zéro ».
private func details(for workout: HKWorkout, completion: @escaping ([String: Any]) -> Void) {
@@ -423,6 +530,38 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
out["average_mets"] = (mets.doubleValue(for: unit) * 10).rounded() / 10
}
// Activités de la séance — lecture seule, pour VOIR ce qu'Exercice écrit.
// Apple : « you can use workout activities to […] represent the active
// and rest periods during interval training. All HKWorkout instances
// have at least one associated HKWorkoutActivity » (doc HKWorkoutActivity,
// lue le 22/09/2026). Apple ne dit PAS que l'app Exercice en écrit une
// par étape d'un exercice personnalisé : c'est ce que ce champ permet
// de constater. Le serveur ne s'en sert pas tant que ce n'est pas vu.
if #available(iOS 16.0, *) {
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var activities: [[String: Any]] = []
for a in workout.workoutActivities {
var row: [String: Any] = [
"start": iso.string(from: a.startDate),
"duration_s": (a.duration * 10).rounded() / 10,
"activity_type": Int(a.workoutConfiguration.activityType.rawValue),
"location": Int(a.workoutConfiguration.locationType.rawValue),
"events": a.workoutEvents.count,
]
if let end = a.endDate { row["end"] = iso.string(from: end) }
// Métadonnées en clair uniquement (String/Number/Bool) : une
// HKQuantity ne se sérialise pas, et on ne veut rien deviner.
var plain: [String: Any] = [:]
for (k, v) in a.metadata ?? [:] {
if v is String || v is NSNumber { plain[k] = v }
}
if !plain.isEmpty { row["metadata"] = plain }
activities.append(row)
}
out["activities"] = activities
}
// Statistiques agrégées sur la fenêtre exacte de la séance.
let samplePredicate = HKQuery.predicateForObjects(from: workout)
var requests: [(key: String, id: HKQuantityTypeIdentifier, unit: HKUnit, option: HKStatisticsOptions, scale: Double)] = []

View File

@@ -0,0 +1,182 @@
// CoachIntents.swift
// App Intents partagés : app iPhone, widget iPhone, widget Watch, Siri.
//
// ⚠️ **Ce fichier doit appartenir à TOUTES les cibles qui s'en servent** :
// App + CoachLiveActivity + CoachWatchWidgets
// (cf. docs/widgets-runbook-mac.md, étape « Target Membership »).
//
// Pourquoi il existe
// ------------------
// `LogDrinkIntent` vivait dans `CoachQuickWidget.swift`, qui n'appartient qu'à
// l'extension widget iPhone. Deux conséquences, constatées le 25/09/2026 :
//
// - **Siri ne pouvait pas y accéder.** Les raccourcis (`AppShortcutsProvider`)
// se déclarent dans l'application principale, qui ne voyait pas cet intent.
// - **La montre non plus.** La cible `CoachWatchWidgets` ne portait que des
// complications d'affichage : aucune saisie n'était possible au poignet,
// alors que c'est le geste que Sylvain fait une vingtaine de fois par jour.
//
// L'extraire ici, plutôt que le dupliquer, évite deux implémentations qui
// divergeraient — le défaut déjà payé sur l'envoi vers la Watch.
//
// ⛔ Aucun appel réseau ici, et c'est structurel : une extension n'a ni session
// authentifiée ni droit de tenir une requête. L'intent écrit dans la file de
// `CoachQuickLog` (App Group), et l'application la transmet au serveur à sa
// prochaine ouverture. Rien n'est déclaré synchronisé tant qu'il ne l'est pas.
import AppIntents
import Foundation
#if canImport(WidgetKit)
import WidgetKit
#endif
// MARK: - Erreurs
enum CoachQuickError: Error, CustomLocalizedStringResourceConvertible {
case unavailable
var localizedStringResource: LocalizedStringResource {
"Stockage partagé indisponible — ouvrez l'app une fois."
}
}
// MARK: - Noter une boisson
/// Ajoute une boisson sans quitter l'écran d'accueil, le cadran, ou en dictant
/// à Siri.
@available(iOS 17.0, watchOS 10.0, *)
struct LogDrinkIntent: AppIntent {
static var title: LocalizedStringResource = "Noter une boisson"
static var description = IntentDescription(
"Ajoute un verre d'eau ou un café au journal, sans ouvrir l'application."
)
/// `false` : tout l'intérêt est de NE PAS ouvrir l'app.
static var openAppWhenRun: Bool = false
@Parameter(title: "Type") var ref: String
@Parameter(title: "Volume (ml)") var amountMl: Double
init() {}
init(ref: String, amountMl: Double) {
self.ref = ref
self.amountMl = amountMl
}
func perform() async throws -> some IntentResult & ProvidesDialog {
let ok = CoachQuickLog.append(
CoachQuickEntry(kind: .drink, ref: ref, amount: amountMl)
)
#if canImport(WidgetKit)
WidgetCenter.shared.reloadAllTimelines()
#endif
// Un échec d'App Group doit remonter : afficher « ajouté » sur une
// saisie perdue est pire que ne rien afficher.
guard ok else { throw CoachQuickError.unavailable }
// ⚠️ Le dialogue annonce ce qui a été NOTÉ, pas ce qui a été envoyé au
// serveur — la file part à la prochaine ouverture de l'app. Dire
// « enregistré sur ton compte » serait faux pendant plusieurs heures.
let total = CoachQuickLog.todayTotal(
kind: "water",
syncedMl: CoachWidgetStore.load()?.asOf(Date()).waterMlToday ?? 0
)
if ref == "water" {
return .result(dialog: "Noté. \(Self.enLitres(total)) d'eau aujourd'hui.")
}
return .result(dialog: "Noté.")
}
/// « 1,25 L » plutôt que « 1250 ml » : Siri le lit à voix haute.
static func enLitres(_ ml: Double) -> String {
let litres = ml / 1000
let f = NumberFormatter()
f.numberStyle = .decimal
f.maximumFractionDigits = litres < 1 ? 2 : 1
return (f.string(from: NSNumber(value: litres)) ?? "\(litres)") + " L"
}
}
// MARK: - Où j'en suis
/// Répond « combien j'ai bu aujourd'hui », sans ouvrir l'app.
///
/// ⚠️ Cet intent ne fait que LIRE ce que l'appareil sait déjà : le snapshot
/// poussé par l'app plus la file locale. Il n'interroge pas le serveur — une
/// extension n'en a pas le droit, et Siri doit répondre tout de suite.
@available(iOS 17.0, watchOS 10.0, *)
struct HydrationStatusIntent: AppIntent {
static var title: LocalizedStringResource = "Où j'en suis côté hydratation"
static var description = IntentDescription(
"Dit combien d'eau a été notée aujourd'hui, et la cible du jour."
)
static var openAppWhenRun: Bool = false
func perform() async throws -> some IntentResult & ProvidesDialog {
let snapshot = CoachWidgetStore.load()?.asOf(Date())
let total = CoachQuickLog.todayTotal(
kind: "water", syncedMl: snapshot?.waterMlToday ?? 0)
guard let objectif = snapshot?.waterGoalMl, objectif > 0 else {
// Pas de cible connue : on donne le total, sans inventer de reste.
return .result(dialog: "\(LogDrinkIntent.enLitres(total)) d'eau aujourd'hui.")
}
let reste = max(0, objectif - total)
if reste <= 0 {
return .result(dialog: "\(LogDrinkIntent.enLitres(total)) : objectif atteint.")
}
// ⚠️ UN SEUL littéral interpolé, jamais de concaténation.
// `IntentDialog` est `ExpressibleByStringInterpolation` : un littéral
// passe, mais l'opérateur `+` produit une `String`, que le compilateur
// refuse — « Cannot convert value of type 'String' to expected argument
// type 'IntentDialog' ». Erreur rencontrée au premier build, 25/09.
let phrase = "\(LogDrinkIntent.enLitres(total)) sur \(LogDrinkIntent.enLitres(objectif)). Il reste \(LogDrinkIntent.enLitres(reste))."
return .result(dialog: IntentDialog(stringLiteral: phrase))
}
}
// MARK: - La séance du jour
/// Dit la séance prévue aujourd'hui, telle que l'app l'a poussée.
///
/// ⛔ **Aucune borne en bpm n'est prononcée.** `TodaySession` n'en porte pas —
/// vérifié, elle a `sport`, `title`, `subtitle`, `done` — et il ne faut pas en
/// ajouter ici : une fourchette cardiaque dite par haut-parleur, hors de la
/// recommandation du jour et de ses réserves, reviendrait à prescrire une
/// intensité à un post-infarctus. Siri annonce QUOI ; l'app dit COMMENT.
/// `subtitle` (« 1h30 · Z2 ») est répété tel que l'app l'affiche, pas
/// reformulé : une zone nommée n'est pas une cible en battements.
@available(iOS 17.0, watchOS 10.0, *)
struct TodaySessionIntent: AppIntent {
static var title: LocalizedStringResource = "Ma séance du jour"
static var description = IntentDescription(
"Annonce le sport et la durée prévus aujourd'hui."
)
static var openAppWhenRun: Bool = false
func perform() async throws -> some IntentResult & ProvidesDialog {
guard let today = CoachWidgetStore.load()?.asOf(Date()).today else {
// Idem : pas de `+` dans un dialogue (cf. HydrationStatusIntent).
return .result(dialog: "Je n'ai pas encore ta séance du jour. Ouvre l'app une fois.")
}
let sport = Self.sportEnFrancais(today.sport)
if today.done {
return .result(dialog: "\(sport) : \(today.title). Déjà faite aujourd'hui.")
}
// `subtitle` porte l'objectif court tel que l'app l'affiche
// (« 1h30 · Z2 ») : on le répète, on ne le reformule pas.
if let objectif = today.subtitle, !objectif.isEmpty {
return .result(dialog: "\(sport) : \(today.title), \(objectif).")
}
return .result(dialog: "\(sport) : \(today.title).")
}
static func sportEnFrancais(_ sport: String) -> String {
switch sport {
case "running": return "Course"
case "cycling": return "Vélo"
case "walking": return "Marche"
case "hiking": return "Randonnée"
case "strength": return "Renforcement"
case "mobility": return "Mobilité"
case "rest": return "Repos"
default: return sport.capitalized
}
}
}

View File

@@ -27,7 +27,10 @@
// startDate: '2026-08-01T00:00:00Z', endDate: '2026-08-19T00:00:00Z',
// });
// // r.samples = [{startDate, endDate, sleepState, sourceName, timeZone, tzSource}, ...]
// // r.available = false si HealthKit est indisponible ou non autorisé
// // r.available = false si HealthKit est indisponible, si le type manque,
// // ou si l'autorisation n'a jamais été demandée (`.notDetermined`).
// // ⚠️ Un refus EXPLICITE reste indiscernable d'une absence de données :
// // HealthKit ne le révèle pas en lecture, par conception (cf. readSamples).
import Foundation
import UIKit
@@ -68,6 +71,28 @@ public class CoachSleepPlugin: CAPPlugin, CAPBridgedPlugin {
call.resolve(["available": false, "samples": [], "reason": "sleepAnalysis unavailable"])
return
}
/* ⚠️ Sans ce test, « on ne nous laisse plus regarder » et « il n'a pas
dormi » rendaient la MÊME réponse : `{available: true, samples: []}`.
Le serveur enregistrait alors des nuits vides, et `sleep_score.py`
calculait une régularité de coucher sur des trous — une valeur fausse,
pas une valeur manquante. L'en-tête de ce fichier promettait pourtant
`available = false` si non autorisé. § 1.3 du diagnostic du 24/09/2026.
Ce que HealthKit permet, et ce qu'il ne permet pas : Apple ne
distingue PAS volontairement « refusé » de « vide » en LECTURE, pour
ne pas révéler qu'une donnée existe. `.sharingDenied` n'est donc pas
fiable pour un type lu. En revanche `.notDetermined` l'est, et c'est
le cas fréquent : réinstallation de l'app, permission jamais demandée.
On ne traite que celui-là — le reste serait une affirmation qu'Apple
ne garantit pas. */
if store.authorizationStatus(for: sleepType) == .notDetermined {
call.resolve([
"available": false,
"samples": [],
"reason": "authorization not determined",
])
return
}
guard let start = Self.parseISO(call.getString("startDate")),
let end = Self.parseISO(call.getString("endDate")) else {
call.reject("startDate et endDate requis (ISO 8601)")

View File

@@ -13,10 +13,18 @@
// forme: { score: 72, label:"Prêt" }
// });
import Foundation
import os
import Capacitor
import WidgetKit
import WatchConnectivity
/// ⚠️ Ce pont transporte les bornes de zones cardiaques vers la montre. Il était
/// le seul chemin WatchConnectivity du projet à ne rien journaliser — quatre
/// sorties silencieuses sur sept lignes — alors que `CoachRoutineBridge`, qui
/// porte des exercices de mobilité, journalise et remonte ses échecs.
/// Diagnostic du 24/09/2026 : `docs/DIAGNOSTIC-NATIF-2026-09-24.md` § 1.2.
private let widgetLog = Logger(subsystem: "ch.hypnotruck.coach", category: "widget")
@objc(CoachWidgetBridgePlugin)
public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "CoachWidgetBridgePlugin"
@@ -164,8 +172,15 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
return
}
if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() }
pushToWatch(snapshot)
call.resolve(["ok": true])
// Le statut d'envoi remonte au JS : jusqu'ici, la page n'avait aucun
// moyen de savoir si les bornes cardiaques étaient arrivées au poignet.
// Champs AJOUTÉS, `ok` conservé : aucun appelant existant ne casse.
let raison = pushToWatch(snapshot)
call.resolve([
"ok": true,
"watchPushed": raison == nil,
"watchReason": raison ?? "",
])
}
/// Envoie le snapshot à l'Apple Watch pour ses complications. L'App Group
@@ -173,14 +188,50 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
/// de SA montre, alimenté ici via WatchConnectivity.
/// On n'est PAS le delegate WCSession (c'est CoachLiveBridge) : on se contente
/// d'émettre, ce qui ne requiert pas d'être delegate, juste une session activée.
private func pushToWatch(_ snapshot: CoachWidgetSnapshot) {
guard WCSession.isSupported() else { return }
/// Rend la raison de l'échec, ou `nil` si le snapshot est parti.
///
/// ⚠️ Un échec ici ne fait PAS échouer `setSnapshot` : l'App Group est déjà
/// écrit et les widgets iPhone rafraîchis. Une montre absente ou verrouillée
/// n'invalide pas l'opération — d'où un `resolve` enrichi plutôt qu'un
/// `reject`, contrairement à `CoachRoutineBridge` où l'envoi EST l'opération.
@discardableResult
private func pushToWatch(_ snapshot: CoachWidgetSnapshot) -> String? {
guard WCSession.isSupported() else {
widgetLog.info("snapshot non poussé : WatchConnectivity non supporté")
return "WatchConnectivity non supporté"
}
let session = WCSession.default
guard session.activationState == .activated,
let data = try? JSONEncoder().encode(snapshot) else { return }
guard session.activationState == .activated else {
widgetLog.error("snapshot non poussé : session non activée (état \(session.activationState.rawValue, privacy: .public))")
return "session WatchConnectivity non activée"
}
// `isPaired` / `isWatchAppInstalled` comme dans CoachRoutineBridge : sans
// eux, l'absence de montre se confond avec un échec d'envoi.
guard session.isPaired else {
widgetLog.info("snapshot non poussé : aucune montre appairée")
return "aucune Watch appairée"
}
guard session.isWatchAppInstalled else {
widgetLog.info("snapshot non poussé : app Watch non installée")
return "app Watch non installée"
}
guard let data = try? JSONEncoder().encode(snapshot) else {
widgetLog.error("snapshot non poussé : encodage JSON impossible")
return "encodage du snapshot impossible"
}
do {
// updateApplicationContext : dernier état, sans budget (≠ transferCurrent
// ComplicationUserInfo qui réveille la complication mais est rationné ~50/j).
try? session.updateApplicationContext(["widgetSnapshot": data])
try session.updateApplicationContext(["widgetSnapshot": data])
// Les zones sont la raison d'être de ce log : savoir QUELLES bornes
// sont au poignet, et depuis quand, sans avoir à ouvrir la montre.
let z = snapshot.zones
widgetLog.info("snapshot poussé vers la Watch · zones=\(z != nil ? "oui" : "non", privacy: .public) depuis=\(z?.zonesSince ?? "?", privacy: .public)")
return nil
} catch {
widgetLog.error("updateApplicationContext a échoué : \(error.localizedDescription, privacy: .public)")
return error.localizedDescription
}
}
/// Relit le snapshot RÉELLEMENT stocké dans l'App Group.

View File

@@ -69,6 +69,21 @@ struct CoachWidgetSnapshot: Codable {
var kcalToday: Double? = nil
var kcalGoal: Double? = nil
/// Macros du jour et leurs cibles (25/09/2026, pour le suivi au poignet).
/// Mêmes règles que l'énergie : les totaux viennent du journal, les cibles
/// de `nutrition_goals.py` — rien n'est écrit en dur, pour qu'un changement
/// d'objectif suive.
/// ⚠️ Les cibles portent côté serveur une forme `{target, min, max}` :
/// `widget-bridge.js` n'en pousse que `target`. Une jauge sur une cible de
/// type « min » (les protéines) se lit donc comme un plancher atteint, pas
/// comme un plafond à ne pas dépasser.
var proteinToday: Double? = nil
var proteinGoal: Double? = nil
var fatToday: Double? = nil
var fatGoal: Double? = nil
var carbsToday: Double? = nil
var carbsGoal: Double? = nil
/// Boutons de saisie rapide, poussés depuis les préréglages de boisson.
/// `nil` ⇒ repli aligné sur les préréglages par défaut du serveur.
var quickDrinks: [QuickDrink]? = nil
@@ -237,6 +252,13 @@ extension CoachWidgetSnapshot {
s.waterMlToday = 0
s.coffeeCountToday = 0
s.kcalToday = 0
// ⚠️ Les macros se périment avec l'énergie. Les oublier ici afficherait
// les grammes d'hier sous la date du jour — exactement le défaut que
// `asOf` existe pour éviter, et qui avait survécu un temps sur les
// totaux de boisson.
s.proteinToday = 0
s.fatToday = 0
s.carbsToday = 0
s.routine = nil
// L'activité de la veille n'est pas celle du jour, et le score de forme
// du jour n'est calculé qu'après la nuit : afficher celui d'hier comme

View File

@@ -310,12 +310,29 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
NSLog("[CoachWorkoutObserver] anchored fired but 0 new workouts")
return
}
// Notifier le plus récent (par endDate) — généralement il y
// en a 1 mais Apple peut grouper plusieurs samples.
// ⚠️ Les rapports serveur partent pour TOUTES les séances du lot.
// Ils n'étaient envoyés que pour `latest`, alors que l'ancre venait
// d'être avancée quelques lignes plus haut : les autres séances du
// lot n'étaient donc jamais rapportées, et ne reviendraient jamais.
//
// Le cas se produit dès que l'iPhone a manqué un réveil : renfo le
// matin, vélo le soir, téléphone resté à la maison — au premier
// fire, la requête ancrée rend les deux, le vélo est notifié, et la
// routine du matin n'est jamais cochée. Le log disait « 2 new
// workout(s), notifying latest » : exact, et parfaitement muet sur
// ce qu'il venait de jeter.
for seance in workouts {
self.reportToRoutineIfStrength(seance)
self.reportPlanOrigin(seance)
}
// Une seule notification en revanche : un « 🎉 » par séance d'un
// même lot serait du bruit. On garde la plus récente.
let latest = workouts.max(by: { $0.endDate < $1.endDate })!
let uuid = latest.uuid.uuidString
UserDefaults.standard.set(uuid, forKey: self.lastNotifiedKey)
NSLog("[CoachWorkoutObserver] %d new workout(s), notifying latest %@", workouts.count, uuid)
NSLog("[CoachWorkoutObserver] %d nouvelle(s) séance(s) rapportée(s), notification pour %@",
workouts.count, uuid)
self.scheduleNotification(for: latest)
}
healthStore.execute(query)
@@ -342,6 +359,37 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
}
}
/// Envoie une requête et **lit le statut HTTP**.
///
/// ⚠️ Les deux POST de ce fichier jetaient la réponse (`{ data, _, error in }`)
/// et ne regardaient que `error`. Or un 401 sur token rotaté, un 404 ou un
/// 500 ne produisent PAS d'`error` : `reportPlanOrigin` journalisait donc
/// « plan-origin envoyé » sur une requête refusée, et `auto-complete`
/// sortait en silence au `guard` de parsing. Le bon pattern existait déjà
/// deux fichiers plus loin — `CoachRoutineBridge.postDay` vérifie
/// `(200..<300)` et journalise le code.
///
/// `traiter` ne reçoit le corps que sur un statut de succès.
private func envoyer(_ req: URLRequest, libelle: String,
traiter: ((Data) -> Void)? = nil) {
URLSession.shared.dataTask(with: req) { data, reponse, error in
if let error = error {
NSLog("[CoachWorkoutObserver] %@ : %@", libelle, error.localizedDescription)
return
}
guard let http = reponse as? HTTPURLResponse else {
NSLog("[CoachWorkoutObserver] %@ : réponse sans statut HTTP", libelle)
return
}
guard (200..<300).contains(http.statusCode) else {
NSLog("[CoachWorkoutObserver] %@ : HTTP %d — non pris en compte",
libelle, http.statusCode)
return
}
traiter?(data ?? Data())
}.resume()
}
/// Remonte au serveur la fin d'une séance de renfo, qui décide seul si elle
/// correspond à une routine poussée dans l'app Exercice.
///
@@ -359,19 +407,31 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
// ⚠️ L'activité était écrite en dur à « functionalStrengthTraining »
// alors que le garde ci-dessus accepte AUSSI traditionalStrengthTraining :
// une séance de musculation classique arrivait au serveur étiquetée
// fonctionnelle. Si la règle de correspondance discrimine sur ce champ
// — et le commentaire ci-dessus dit que les règles vivent là-bas —,
// l'appariement était faussé.
//
// L'UUID et l'heure de début manquaient aussi : sans eux le serveur ne
// peut pas dédupliquer, et il doit évaluer sa fenêtre de 12 h depuis
// l'heure de RÉCEPTION. Une séance remontée avec six heures de retard
// (iPhone resté à la maison) était donc datée de son arrivée.
req.httpBody = try? JSONSerialization.data(withJSONObject: [
"activity": "functionalStrengthTraining",
"activity": activity == .traditionalStrengthTraining
? "traditionalStrengthTraining" : "functionalStrengthTraining",
"duration_min": workout.duration / 60,
"uuid": workout.uuid.uuidString,
"start": ISO8601DateFormatter().string(from: workout.startDate),
])
req.timeoutInterval = 15
URLSession.shared.dataTask(with: req) { data, _, error in
if let error = error {
NSLog("[CoachWorkoutObserver] auto-complete routine: %@", error.localizedDescription)
envoyer(req, libelle: "auto-complete routine") { data in
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
NSLog("[CoachWorkoutObserver] auto-complete : réponse illisible")
return
}
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
if (json["matched"] as? Bool) == true {
let n = (json["done"] as? [String])?.count ?? 0
NSLog("[CoachWorkoutObserver] routine '%@' cochée automatiquement (%d exercices)",
@@ -379,7 +439,7 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
} else {
NSLog("[CoachWorkoutObserver] pas une routine (%@)", (json["reason"] as? String) ?? "?")
}
}.resume()
}
}
/// Dit au serveur si la séance vient d'une **séance programmée** ou d'un
@@ -428,13 +488,12 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
"plan_name": planName,
])
req.timeoutInterval = 15
URLSession.shared.dataTask(with: req) { _, _, error in
if let error = error {
NSLog("[CoachWorkoutObserver] plan-origin: %@", error.localizedDescription)
} else {
NSLog("[CoachWorkoutObserver] plan-origin envoyé (from_plan=%@)", fromPlan ? "true" : "false")
// Le log de succès ne part plus que sur un 2xx : il s'écrivait
// auparavant sur un 401 ou un 500, puisque seul `error` était lu.
self.envoyer(req, libelle: "plan-origin") { _ in
NSLog("[CoachWorkoutObserver] plan-origin accepté (from_plan=%@)",
fromPlan ? "true" : "false")
}
}.resume()
}
#endif
}

View File

@@ -32,6 +32,23 @@ struct HeartRateZones: Codable, Equatable, Sendable {
/// vers Z2-Z3. C'est le repère clinique, celui qui compte ici.
var clinicalTargetBpm: Int?
/// Date de la version FIGÉE dont viennent ces bornes (`YYYY-MM-DD`), telle
/// que servie par `/api/cardiac-zones` (`zones_since`).
///
/// ⚠️ Sans elle, la montre affichait des bornes sans âge. Le backend tient
/// une version datée (`data/hr_zones_history.json`, règle 9 du CLAUDE.md
/// web) parce qu'une référence qui bouge avec son propre bruit ne permet
/// pas de juger une séance ; **cette date se perdait au transport**. Après
/// une révision des bornes, rien au poignet ne distinguait les nouvelles
/// des anciennes — et si le push échouait en silence (cf. § 1.2 du
/// diagnostic du 24/09/2026), les anciennes restaient indéfiniment.
var zonesSince: String? = nil
/// `"frozen"` (version confirmée) ou `"live"` (calcul glissant, avant la
/// première version figée). Distingue « bornes validées » de « bornes
/// provisoires » — la montre ne pouvait pas faire la différence.
var zonesFrom: String? = nil
/// L'effort est-il dans la zone d'entraînement utile ?
func isUseful(bpm: Int) -> Bool {
guard let target = clinicalTargetBpm else { return false }

View File

@@ -33,21 +33,24 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
// Le manifeste déclare `UISceneStoryboardFile = Main` : UIKit a donc
// déjà chargé Main.storyboard et instancié `MainViewController`, qui
// porte l'enregistrement des plugins natifs et la config WKWebView.
// ⚠️ Ne PAS conditionner cette création à `window == nil`.
//
// Le template de Capacitor pose ici un `CAPBridgeViewController()` nu.
// L'appliquer tel quel remplacerait notre sous-classe et ferait perdre
// cette configuration **sans erreur visible**. On ne crée donc la
// fenêtre que si le storyboard ne l'a pas fait, et avec notre
// contrôleur.
if window == nil {
// C'était ma première version, par prudence : le manifeste déclarant
// `UISceneStoryboardFile = Main`, je supposais qu'UIKit avait déjà
// monté la fenêtre et qu'il ne fallait pas l'écraser. Résultat sur
// appareil : **écran noir permanent, sans la moindre erreur**. UIKit
// fournit bien une fenêtre, mais ne la rend pas visible à notre place ;
// la garde sautait donc `makeKeyAndVisible()` et rien ne s'affichait.
//
// Le template de Capacitor crée la fenêtre sans condition, et c'est le
// chemin éprouvé. Le seul écart nécessaire est `MainViewController` au
// lieu d'un `CAPBridgeViewController` nu : notre sous-classe enregistre
// les neuf plugins in-app dans `capacitorDidLoad()`. Elle ne dépend de
// rien du storyboard, donc l'instancier ici est sans perte.
let fenetre = UIWindow(windowScene: windowScene)
fenetre.rootViewController = MainViewController()
window = fenetre
fenetre.makeKeyAndVisible()
}
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
}

View File

@@ -4,7 +4,7 @@ import PackageDescription
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
let package = Package(
name: "CapApp-SPM",
platforms: [.iOS(.v15)],
platforms: [.iOS(.v"$)],
products: [
.library(
name: "CapApp-SPM",

View File

@@ -17,41 +17,14 @@ import SwiftUI
import WidgetKit
// MARK: - Intents
/// Ajoute une boisson sans quitter l'écran d'accueil.
@available(iOS 17.0, watchOS 10.0, *)
struct LogDrinkIntent: AppIntent {
static var title: LocalizedStringResource = "Noter une boisson"
/// `false` : tout l'intérêt est de NE PAS ouvrir l'app.
static var openAppWhenRun: Bool = false
@Parameter(title: "Type") var ref: String
@Parameter(title: "Volume (ml)") var amountMl: Double
init() {}
init(ref: String, amountMl: Double) {
self.ref = ref
self.amountMl = amountMl
}
func perform() async throws -> some IntentResult {
let ok = CoachQuickLog.append(
CoachQuickEntry(kind: .drink, ref: ref, amount: amountMl)
)
WidgetCenter.shared.reloadAllTimelines()
// Un échec d'App Group doit remonter : afficher « ajouté » sur une
// saisie perdue est pire que ne rien afficher.
guard ok else { throw CoachQuickError.unavailable }
return .result()
}
}
enum CoachQuickError: Error, CustomLocalizedStringResourceConvertible {
case unavailable
var localizedStringResource: LocalizedStringResource {
"Stockage partagé indisponible — ouvrez l'app une fois."
}
}
//
// ⚠️ `LogDrinkIntent` et `CoachQuickError` ont DÉMÉNAGÉ dans
// `ios/App/App/CoachIntents.swift` le 25/09/2026, pour être partagés entre
// trois cibles : ce widget, l'app (d'où Siri les découvre) et le widget Watch.
// Les laisser ici les rendait invisibles aux deux autres — Siri n'avait aucun
// intent à écouter, et la montre ne savait pas noter un verre.
// ⛔ Ne pas les redéclarer ici : deux définitions du même symbole dans une
// cible qui compile les deux fichiers, et le build casse.
// MARK: - Timeline

View File

@@ -142,9 +142,21 @@ final class LocationTracker: NSObject, ObservableObject {
guard isTracking else { return }
isPaused = paused
if paused {
// Le point qui suivra la reprise serait à des centaines de mètres :
// on repart d'une origine neuve pour ne pas tracer la pause.
pendingRoute.removeAll()
// ⚠️ Ici se jouait une perte silencieuse. `pendingRoute` ne contient
// que des points DÉJÀ acceptés par le filtre et DÉJÀ comptés dans
// `distanceMeters` — ils attendent seulement leur écriture, qui ne
// part qu'au seuil de `routeFlushThreshold` (25). À 1 Hz, c'est une
// douzaine de points en moyenne, jusqu'à 24. Les jeter creusait un
// écart visible entre la distance affichée et la trace enregistrée :
// six arrêts sur une sortie, et jusqu'à 144 points manquaient à la
// HKWorkoutRoute, avec un D+ recalculé qui divergeait d'autant.
//
// On les écrit donc au lieu de les perdre. Le raisonnement du
// commentaire d'origine visait le point qui SUIT la reprise, à des
// centaines de mètres de là — mais celui-là n'est pas encore dans
// le tampon, et c'est `implausibleSpeed` qui l'écarte. Nettoyer la
// pause est l'affaire du filtre, pas du tampon d'écriture.
Task { await flushPendingRoute() }
}
}

View File

@@ -381,13 +381,33 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
// sauvegarder la séance, et SEULEMENT ensuite clore la trace en
// l'associant au workout obtenu.
LocationTracker.shared.stopTracking()
try? await builder.endCollection(at: date)
/* ⚠️ Les deux `try?` d'avant rendaient indiscernables deux
situations OPPOSÉES, toutes deux menant à `workout == nil` :
· montre verrouillée à l'arrêt → la séance EST écrite ;
· `endCollection` a levé → il n'y a rien à retrouver.
Elles tombaient dans le même `else`, et seul le sort de la TRACE
était journalisé — pas celui de la séance. Après deux heures de
sortie, l'écran de fin restait le même.
§ 2.3 du diagnostic du 24/09/2026. La logique de récupération
ci-dessous n'a pas changé : seul l'échec devient visible. */
var collecteClose = true
do {
try await builder.endCollection(at: date)
} catch {
collecteClose = false
workoutLog.error("endCollection a echoue : \(error.localizedDescription, privacy: .public)")
}
// ⚠️ `finishWorkout()` rend `nil` SANS erreur quand la montre est
// verrouillée : ce n'est pas un échec, la séance EST écrite dans
// HealthKit — seul l'objet manque. Comme `finishRoute(with:)`
// exige un `HKWorkout` non optionnel (vérifié dans la doc Apple),
// on va rechercher celle qui vient d'être sauvegardée.
let workout = try? await builder.finishWorkout()
var workout: HKWorkout?
do {
workout = try await builder.finishWorkout()
} catch {
workoutLog.error("finishWorkout a echoue : \(error.localizedDescription, privacy: .public)")
}
if let workout {
await LocationTracker.shared.finishRoute(with: workout)
} else if let recovered = await self.recentlySavedWorkout() {
@@ -395,6 +415,18 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
await LocationTracker.shared.finishRoute(with: recovered)
} else {
LocationTracker.shared.discardRoute()
if collecteClose {
// La collecte s'est close proprement mais rien n'est
// retrouvable : anormal, et jamais journalisé jusqu'ici.
workoutLog.error("seance introuvable apres un finishWorkout() nil : rien a associer a la trace")
} else {
workoutLog.error("SEANCE PERDUE : endCollection a leve, rien n'a ete ecrit dans HealthKit")
/* Le dire à l'écran. C'est le cœur du constat : l'utilisateur
voyait l'écran de fin habituel après une séance perdue.
Uniquement dans ce cas — une montre verrouillée n'est pas
une perte, et l'annoncer serait une fausse alerte. */
self.statusMessage = "Séance non enregistrée — rien n'a pu être écrit dans Santé"
}
}
}
Task { @MainActor in
@@ -453,9 +485,43 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
let message = error.localizedDescription
let ns = error as NSError
workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)")
let builder = workoutSession.associatedWorkoutBuilder()
Task { @MainActor in
self.statusMessage = "Erreur séance : \(message)"
/* ⚠️ Ce chemin ne clôturait RIEN. Comparé à `.ended`, il manquait
`stopTracking()`, `endCollection`, `finishWorkout` et
`finishRoute`/`discardRoute` — et `self.builder = nil` détruisait
la seule référence au builder : la perte devenait définitive dans
l'instruction même qui l'ignorait. Le GPS, lui, continuait de
tourner après la fin apparente de la séance, sans que rien ne
l'indique. § 2.4 du diagnostic du 24/09/2026.
Le GPS d'abord : c'est le seul geste qui réussit à coup sûr, et
le seul dont l'omission se paie en batterie pendant des heures.
La sauvegarde ensuite, en best-effort — une session qui a échoué
refusera souvent de clore, mais ne pas essayer garantit la perte. */
LocationTracker.shared.stopTracking()
var sauvee: HKWorkout?
do {
try await builder.endCollection(at: Date())
sauvee = try await builder.finishWorkout()
} catch {
workoutLog.error("cloture apres echec impossible : \(error.localizedDescription, privacy: .public)")
}
if let sauvee {
workoutLog.info("seance sauvee malgre l'echec de session")
await LocationTracker.shared.finishRoute(with: sauvee)
self.statusMessage = "Séance interrompue, mais enregistrée"
} else if let recovered = await self.recentlySavedWorkout() {
workoutLog.info("seance retrouvee dans HealthKit apres l'echec de session")
await LocationTracker.shared.finishRoute(with: recovered)
self.statusMessage = "Séance interrompue, mais enregistrée"
} else {
LocationTracker.shared.discardRoute()
workoutLog.error("SEANCE PERDUE apres didFailWithError : rien n'a pu etre sauve")
self.statusMessage = "Séance perdue : \(message)"
}
self.isRunning = false
self.isPaused = false
self.session = nil
self.builder = nil
ConnectivityManager.shared.sendEnded(reason: "session-failed")

View File

@@ -300,5 +300,11 @@ struct CoachWatchWidgetsBundle: WidgetBundle {
CoachWatchFormeWidget()
CoachWatchSessionWidget()
CoachWatchRoutineWidget()
// Smart Stack : le seul widget de la montre qui fasse AGIR — noter un
// verre sans sortir le téléphone. Ajouté le 25/09/2026.
if #available(watchOS 10.0, *) {
CoachWatchHydrationWidget()
CoachWatchNutritionWidget()
}
}
}

View File

@@ -0,0 +1,327 @@
// CoachWatchHydration.swift
// Widget « Hydratation » pour la Smart Stack de l'Apple Watch : voir où l'on
// en est, et **noter un verre sans sortir le téléphone**.
//
// ⚠️ Cible : CoachWatchWidgets. Dépend de `CoachIntents.swift`,
// `CoachQuickLog.swift` et `CoachWidgetSnapshot.swift`, qui doivent appartenir
// à CETTE cible aussi (cf. docs/widgets-runbook-mac.md).
//
// Pourquoi ce widget, et pas un de plus pour les repas
// -----------------------------------------------------
// Tranché avec Sylvain le 25/09/2026. Boire se note une vingtaine de fois par
// jour : c'est le seul geste où la montre bat vraiment le téléphone — le
// poignet est déjà levé. Les repas, non : pas de caméra, pas de scanner de
// code-barres, pas de saisie de texte confortable. Un bouton « repas » au
// poignet ne ferait que renvoyer vers l'iPhone, pour du code à maintenir.
//
// ⚠️ **Rien ne part sur le réseau ici.** Comme sur l'iPhone, l'intent écrit
// dans la file de `CoachQuickLog` (App Group de la montre) et l'application la
// transmet à sa prochaine ouverture. Le widget affiche donc son propre total —
// snapshot serveur + file locale — sinon il afficherait un chiffre périmé juste
// après une saisie, et on douterait de ce qu'on vient de faire.
//
// ⚠️ **Interactivité : Smart Stack, pas cadran.** Les boutons d'App Intent
// demandent watchOS 10+. Reste à confirmer AU BUILD si une complication de
// cadran les accepte : dans le doute, la famille `.accessoryCircular` ci-dessous
// n'est qu'un AFFICHAGE (un tap ouvre l'app), et seule `.accessoryRectangular`
// porte les boutons.
import AppIntents
import SwiftUI
import WidgetKit
// MARK: - Timeline
struct CoachWatchHydrationEntry: TimelineEntry {
let date: Date
let waterMl: Double
let goalMl: Double?
/// Saisies encore en attente d'envoi, tous jours confondus.
let pending: Int
let quickDrinks: [CoachWidgetSnapshot.QuickDrink]
/// 0…1, borné. Sans cible connue, pas de jauge inventée.
var fraction: Double? {
guard let goal = goalMl, goal > 0 else { return nil }
return min(1, max(0, waterMl / goal))
}
}
struct CoachWatchHydrationProvider: TimelineProvider {
func placeholder(in context: Context) -> CoachWatchHydrationEntry {
CoachWatchHydrationEntry(
date: Date(), waterMl: 1250, goalMl: 2750, pending: 0,
quickDrinks: CoachWidgetSnapshot.fallbackQuickDrinks)
}
func getSnapshot(in context: Context,
completion: @escaping (CoachWatchHydrationEntry) -> Void) {
completion(current())
}
/// Réveil à minuit, comme le widget iPhone : les totaux du jour doivent
/// repartir de zéro. ⚠️ `asOf(_:)` est ce qui le rend effectif — sans lui
/// le rechargement a lieu mais réaffiche les chiffres de la veille, défaut
/// déjà payé côté iPhone.
func getTimeline(in context: Context,
completion: @escaping (Timeline<CoachWatchHydrationEntry>) -> Void) {
let now = Date()
let minuit = CoachWidgetSnapshot.nextMidnight(after: now)
completion(Timeline(entries: [current(at: now), current(at: minuit)],
policy: .after(minuit.addingTimeInterval(60))))
}
private func current(at day: Date = Date()) -> CoachWatchHydrationEntry {
let snapshot = CoachWidgetStore.load()?.asOf(day)
return CoachWatchHydrationEntry(
date: day,
waterMl: CoachQuickLog.todayTotal(
kind: "water", syncedMl: snapshot?.waterMlToday ?? 0, day: day),
goalMl: snapshot?.waterGoalMl,
pending: CoachQuickLog.pending().count,
quickDrinks: snapshot?.resolvedQuickDrinks
?? CoachWidgetSnapshot.fallbackQuickDrinks)
}
}
// MARK: - Vues
/// Les deux premières boissons à base d'eau, sinon le repli. Deux et pas trois :
/// sur 160 pt de large, un troisième bouton descend les cibles sous le seuil
/// tactile, et c'est un geste qu'on fait en marchant.
private func deuxBoutonsEau(_ drinks: [CoachWidgetSnapshot.QuickDrink])
-> [CoachWidgetSnapshot.QuickDrink] {
let eau = drinks.filter { $0.ref == "water" }
let retenus = eau.isEmpty ? CoachWidgetSnapshot.fallbackQuickDrinks
.filter({ $0.ref == "water" }) : eau
return Array(retenus.prefix(2))
}
private func enLitres(_ ml: Double) -> String {
let l = ml / 1000
return String(format: l < 1 ? "%.2f L" : "%.1f L", l)
}
@available(watchOS 10.0, *)
struct CoachWatchHydrationView: View {
@Environment(\.widgetFamily) private var family
let entry: CoachWatchHydrationEntry
var body: some View {
switch family {
case .accessoryRectangular:
rectangulaire
case .accessoryInline:
Text("Eau \(enLitres(entry.waterMl))")
default:
circulaire
}
}
/// La seule famille qui porte les boutons : il y faut la place.
private var rectangulaire: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 4) {
Image(systemName: "drop.fill")
Text(enLitres(entry.waterMl))
.font(.headline)
if let goal = entry.goalMl {
Text("/ \(enLitres(goal))")
.font(.caption2)
.foregroundStyle(.secondary)
}
// Ce qui attend d'être envoyé se dit, plutôt que de laisser
// croire que tout est synchronisé.
if entry.pending > 0 {
Text("·\(entry.pending)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if let f = entry.fraction {
ProgressView(value: f).progressViewStyle(.linear)
}
HStack(spacing: 6) {
ForEach(deuxBoutonsEau(entry.quickDrinks), id: \.volumeMl) { d in
Button(intent: LogDrinkIntent(ref: d.ref, amountMl: d.volumeMl)) {
Text("+\(Int(d.volumeMl / 10)) cl")
.font(.caption2)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
}
.containerBackground(.clear, for: .widget)
}
/// Cadran : affichage seul. Un tap ouvre l'app — voir l'avertissement en
/// tête de fichier sur l'interactivité hors Smart Stack.
private var circulaire: some View {
Gauge(value: entry.fraction ?? 0) {
Image(systemName: "drop.fill")
} currentValueLabel: {
Text(enLitres(entry.waterMl)).font(.system(size: 12))
}
.gaugeStyle(.accessoryCircular)
.containerBackground(.clear, for: .widget)
}
}
// MARK: - Widget
@available(watchOS 10.0, *)
struct CoachWatchHydrationWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "CoachWatchHydration",
provider: CoachWatchHydrationProvider()) { entry in
CoachWatchHydrationView(entry: entry)
}
.configurationDisplayName("Hydratation")
.description("Où j'en suis, et noter un verre sans ouvrir l'app.")
.supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryInline])
}
}
// MARK: - Nutrition
//
// ⚠️ **Affichage seulement, et c'est délibéré.** La saisie des repas au poignet
// a été écartée le 25/09 : pas de caméra, pas de scanner de code-barres, pas de
// saisie de texte confortable — un bouton n'aurait fait que renvoyer vers
// l'iPhone. Mais VOIR où l'on en est n'a aucune de ces limites, et c'est ce que
// Sylvain a demandé ensuite.
//
// ⛔ Rien n'est calculé ici. Les totaux viennent du journal et les cibles de
// `nutrition_goals.py`, tous deux poussés par l'app. Le seul calcul est un
// reste (cible − consommé), la même soustraction que la page /meals affiche.
struct CoachWatchNutritionEntry: TimelineEntry {
let date: Date
let kcal: Double?
let kcalGoal: Double?
let protein: Double?
let proteinGoal: Double?
let fat: Double?
let fatGoal: Double?
let carbs: Double?
let carbsGoal: Double?
/// Reste d'énergie. `nil` sans cible — une jauge sans objectif n'a pas de
/// sens, et un « reste » inventé encore moins.
var kcalRestantes: Double? {
guard let g = kcalGoal, let c = kcal else { return nil }
return max(0, g - c)
}
var fraction: Double? {
guard let g = kcalGoal, g > 0, let c = kcal else { return nil }
return min(1, max(0, c / g))
}
}
struct CoachWatchNutritionProvider: TimelineProvider {
func placeholder(in context: Context) -> CoachWatchNutritionEntry {
CoachWatchNutritionEntry(date: Date(), kcal: 1186, kcalGoal: 1908,
protein: 74, proteinGoal: 118,
fat: 41, fatGoal: 59,
carbs: 158, carbsGoal: 225)
}
func getSnapshot(in context: Context,
completion: @escaping (CoachWatchNutritionEntry) -> Void) {
completion(current())
}
func getTimeline(in context: Context,
completion: @escaping (Timeline<CoachWatchNutritionEntry>) -> Void) {
let now = Date()
let minuit = CoachWidgetSnapshot.nextMidnight(after: now)
completion(Timeline(entries: [current(at: now), current(at: minuit)],
policy: .after(minuit.addingTimeInterval(60))))
}
/// `asOf(_:)` remet les totaux du jour à zéro quand le snapshot date
/// d'hier — macros comprises depuis le 25/09. Sans lui, le réveil de
/// minuit réafficherait les grammes de la veille.
private func current(at day: Date = Date()) -> CoachWatchNutritionEntry {
let s = CoachWidgetStore.load()?.asOf(day)
return CoachWatchNutritionEntry(
date: day,
kcal: s?.kcalToday, kcalGoal: s?.kcalGoal,
protein: s?.proteinToday, proteinGoal: s?.proteinGoal,
fat: s?.fatToday, fatGoal: s?.fatGoal,
carbs: s?.carbsToday, carbsGoal: s?.carbsGoal)
}
}
@available(watchOS 10.0, *)
struct CoachWatchNutritionView: View {
@Environment(\.widgetFamily) private var family
let entry: CoachWatchNutritionEntry
/// « 74/118 g » ; « 74 g » sans cible. Jamais de cible inventée.
private func macro(_ nom: String, _ valeur: Double?, _ cible: Double?) -> String {
guard let v = valeur else { return "\(nom) —" }
if let c = cible { return "\(nom) \(Int(v))/\(Int(c))" }
return "\(nom) \(Int(v))"
}
var body: some View {
switch family {
case .accessoryRectangular:
rectangulaire
case .accessoryInline:
Text(entry.kcalRestantes.map { "\(Int($0)) kcal restantes" }
?? "\(Int(entry.kcal ?? 0)) kcal")
default:
circulaire
}
}
private var rectangulaire: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "fork.knife")
if let reste = entry.kcalRestantes {
Text("\(Int(reste))").font(.headline)
Text("kcal restantes").font(.caption2).foregroundStyle(.secondary)
} else {
Text("\(Int(entry.kcal ?? 0)) kcal").font(.headline)
}
}
if let f = entry.fraction {
ProgressView(value: f).progressViewStyle(.linear)
}
// P / L / G sur une ligne : trois valeurs tiennent, pas davantage.
Text([macro("P", entry.protein, entry.proteinGoal),
macro("L", entry.fat, entry.fatGoal),
macro("G", entry.carbs, entry.carbsGoal)].joined(separator: " "))
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.containerBackground(.clear, for: .widget)
}
private var circulaire: some View {
Gauge(value: entry.fraction ?? 0) {
Image(systemName: "fork.knife")
} currentValueLabel: {
Text("\(Int(entry.kcalRestantes ?? entry.kcal ?? 0))")
.font(.system(size: 12))
}
.gaugeStyle(.accessoryCircular)
.containerBackground(.clear, for: .widget)
}
}
@available(watchOS 10.0, *)
struct CoachWatchNutritionWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "CoachWatchNutrition",
provider: CoachWatchNutritionProvider()) { entry in
CoachWatchNutritionView(entry: entry)
}
.configurationDisplayName("Nutrition")
.description("Calories restantes et macros du jour.")
.supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryInline])
}
}

View File

@@ -18,4 +18,6 @@ struct HeartRateZones: Codable, Equatable, Sendable {
var z5: [Int]?
var fcmaxUsed: Int?
var clinicalTargetBpm: Int?
var zonesSince: String?
var zonesFrom: String?
}

View File

@@ -41,9 +41,16 @@ final class DayRolloverTests: XCTestCase {
s.waterMlToday = 1660
s.coffeeCountToday = 3
s.kcalToday = 1186
s.proteinToday = 74
s.fatToday = 41
s.carbsToday = 158
s.waterGoalMl = 2750
s.kcalGoal = 1950
s.zones = HeartRateZones(z2: [109, 118], fcmaxUsed: 152, clinicalTargetBpm: 112)
s.proteinGoal = 118
s.fatGoal = 59
s.carbsGoal = 225
s.zones = HeartRateZones(z2: [109, 118], fcmaxUsed: 152, clinicalTargetBpm: 112,
zonesSince: "2026-08-12", zonesFrom: "frozen")
return s
}
@@ -68,6 +75,28 @@ final class DayRolloverTests: XCTestCase {
XCTAssertEqual(vu.kcalToday, 0)
}
func testMacrosResetLikeEnergy() {
// ⚠️ Ajoutées le 25/09/2026 pour le suivi au poignet. Les oublier dans
// `asOf` afficherait les grammes d'hier sous la date du jour — le
// défaut même que cette fonction existe pour empêcher, et qui avait
// survécu un temps sur les totaux de boisson.
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
XCTAssertEqual(vu.proteinToday, 0)
XCTAssertEqual(vu.fatToday, 0)
XCTAssertEqual(vu.carbsToday, 0)
}
func testMacroGoalsSurviveTheNight() {
// Une CIBLE n'est pas une mesure : elle ne se périme pas à minuit.
// Sans elle, la jauge disparaîtrait chaque nuit jusqu'à la prochaine
// ouverture de l'app.
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
XCTAssertEqual(vu.proteinGoal, 118)
XCTAssertEqual(vu.fatGoal, 59)
XCTAssertEqual(vu.carbsGoal, 225)
XCTAssertEqual(vu.kcalGoal, 1950)
}
func testYesterdaysDayScopedFieldsAreDropped() {
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
XCTAssertNil(vu.routine, "la routine se recoche chaque jour")
@@ -94,6 +123,42 @@ final class DayRolloverTests: XCTestCase {
"les zones sont une version figée, valable des semaines")
}
/// La date de version des zones doit survivre au transport et au passage de
/// minuit — sans elle, la montre affiche des bornes sans âge, et après une
/// révision rien ne distingue les nouvelles des anciennes.
/// Cf. `docs/DIAGNOSTIC-NATIF-2026-09-24.md` § 1.2.
func testZonesCarryTheirVersionDate() {
let vu = snapshot(at: "2026-08-10 12:00").asOf(date("2026-08-18 09:00"), calendar: zurich)
XCTAssertEqual(vu.zones?.zonesSince, "2026-08-12",
"la date de la version figée se perdait au transport")
XCTAssertEqual(vu.zones?.zonesFrom, "frozen",
"« figée » et « provisoire » ne doivent pas se confondre au poignet")
}
/// Le snapshot voyage en JSON jusqu'à la montre : ce qui n'est pas encodé
/// n'arrive pas. Le test précédent vérifie la recopie en mémoire, celui-ci
/// le passage par le fil.
func testVersionDateSurvivesJsonRoundTrip() throws {
let original = snapshot(at: "2026-08-10 12:00")
let data = try JSONEncoder().encode(original)
let relu = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: data)
XCTAssertEqual(relu.zones?.zonesSince, "2026-08-12")
XCTAssertEqual(relu.zones?.zonesFrom, "frozen")
}
/// ⚠️ Un snapshot écrit AVANT l'ajout de ces champs doit rester décodable :
/// la montre en garde un en cache, et un décodage qui lève la laisserait
/// sans zones du tout — pire que des zones sans date.
func testOlderSnapshotWithoutVersionDateStillDecodes() throws {
// `updatedAt` est un `Date` : l'encodeur par défaut l'écrit en secondes
// depuis la date de référence, pas en ISO — comme les autres tests de
// compatibilité ascendante de ce banc (cf. QuickDrinkTests).
let json = #"{"updatedAt": 1755000000, "zones": {"z2": [109, 118], "fcmaxUsed": 152}}"#
let relu = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: Data(json.utf8))
XCTAssertEqual(relu.zones?.fcmaxUsed, 152)
XCTAssertNil(relu.zones?.zonesSince, "absente, pas inventée")
}
// MARK: - Au-delà du lendemain : ne rien inventer
func testAfterTwoDaysTheSessionIsUnknown() {