Commit Graph

102 Commits

Author SHA1 Message Date
Sylvain Bettinelli
5bf3e91ad2 Widget Boissons : jauge d'hydratation + ouverture sur /hydratation
Le widget affichait un total sans jauge, et c'était une décision documentée
et juste : « aucun objectif de boisson n'est fixé, et en inventer un
donnerait un pourcentage qui n'a pas de sens ». Une cible existe maintenant
côté serveur (`/api/drinks` renvoie `water_target_ml`), donc la jauge peut
apparaître — sans cible, on retombe exactement sur l'ancien comportement.

⚠️ Cette cible n'a **aucune source** : c'est un objectif personnel, pas une
recommandation médicale. D'où le libellé « objectif » et non « besoin » ou
« recommandé ».

⚠️ Les deux côtés du pont bougent ensemble : `widget-bridge.js` pose
`waterGoalMl`, `CoachWidgetBridge` la lit sous le même nom. Le widget
« JOUR OFF » a menti pendant des jours parce que le pont lisait une clé
absente de la réponse serveur.

Ajoute aussi un `widgetURL` vers /hydratation : un tap hors bouton ouvrait
l'app sur sa dernière page consultée, au hasard.

⚠️ **NON COMPILÉ** — pas de Mac ici. À vérifier au prochain build Xcode,
avec les zones cardiaques Watch et `getSnapshot` déjà en attente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 08:33:40 +00:00
Sylvain Bettinelli
fb4033130f Watch : cible clinique, couleurs alignées sur l'iPhone, aperçus Xcode
- `HeartRateZones` porte `clinicalTargetBpm` et `isUseful(bpm:)`. Sous
  bêtabloquant, le repère utile n'est PAS le bas de Z4 mais HRKarv0,60
  (Díaz-Buschmann 2014, ESC : la Karvonen standard sous-évalue le seuil chez le
  patient traité). Un sceau vert l'indique sous la plage, et VoiceOver le dit —
  une icône seule ne suffit pas.
- Z1 passe de `.blue` à `.cyan` : `CoachLiveView` (iPhone) utilise déjà cyan.
  Le même effort doit avoir la même teinte sur les deux écrans de la même app ;
  j'avais introduit la divergence en écrivant le modèle sans regarder l'existant.
- Deux `#Preview` avec les zones RÉELLES de production (Z1 100-109 … Z5
  134-143) : les cinq états d'un coup, plus le cas « zones inconnues ». 109 bpm
  y figure exprès — c'est le plancher de Z2, il vérifie la règle du
  chevauchement d'un battement.

⚠️ `CoachLiveView.CardiacZones` reste une SECONDE implémentation de la même
règle, avec sa propre table de zones. Non fusionnée ici pour ne pas mélanger
deux chantiers ; c'est le même motif que les deux traductions WorkoutKit
divergentes corrigées ce matin.

⚠️ NON COMPILÉ — build Xcode requis.
2026-08-12 19:20:16 +00:00
Sylvain Bettinelli
eb819b68ca Watch : afficher la zone cardiaque pendant la séance
L'app Coach de la montre n'affichait que la FC brute — aucune notion de zone
dans `ContentView.swift`. C'est pourtant au poignet que « 142 bpm » a besoin
d'être traduit ; l'écran iPhone portait déjà l'information, mais on ne le
regarde pas en courant.

- `HeartRateZones.swift` : modèle partagé (App + CoachLiveActivity + les deux
  cibles Watch), câblé dans les 4 phases Sources du pbxproj comme
  `CoachWidgetSnapshot.swift`. ⚠️ Les bornes viennent de `/api/cardiac-zones`,
  version FIGÉE et datée — jamais recalculées côté natif, ce serait rouvrir les
  jeux de zones concurrents supprimés le 12/08.
- `zone(for:)` retient la zone la plus HAUTE qui contient la valeur : les
  bornes se chevauchent d'un battement (Z1 100-109, Z2 109-118), et 109 bpm
  doit se lire Z2, pas Z1. Au-delà du haut de Z5, on reste en Z5 — un effort
  maximal n'est pas une absence de zone.
- `ConnectivityManager` publie les zones reçues avec le snapshot et repart du
  dernier connu au lancement (la montre a pu redémarrer). Un snapshot partiel
  n'efface pas celles en place.
- `HeartRateMetric` : pastille « Z3 », valeur colorée, et la plage sous le
  chiffre. ⚠️ La couleur ne porte jamais l'information seule — illisible pour
  un daltonien, et le contraste d'un écran de montre en plein soleil ne se
  prête pas aux nuances. Libellé VoiceOver explicite.

`switch` en `return` explicites plutôt qu'en expression : la syntaxe courte
exige Swift 5.9 pile, et je ne peux pas compiler ici.

⚠️ NON COMPILÉ — build Xcode requis, avec `getSnapshot` (`8a25f36`) en attente.
2026-08-12 19:13:07 +00:00
Sylvain Bettinelli
8a25f3604a Exposer le snapshot stocke pour trancher un widget fige
Sans lecture de l'App Group, un widget qui n'affiche pas la bonne chose laisse
trois hypotheses ouvertes — rien d'ecrit, ecrit sans le champ, ou ecrit mais
pas reaffiche — et le JS ne peut en departager aucune : l'App Group lui est
invisible.

Vecu ce soir : tout etait correct jusqu'au stockage, seul le rafraichissement
de la timeline manquait. getSnapshot rend hasDoneToday, hasToday, l'horodatage
et le contenu, ce qui designe directement le maillon fautif.

⚠️ NON COMPILE — a embarquer au prochain build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 20:02:15 +00:00
Sylvain Bettinelli
c74faeaad5 Marche 6 minutes, et la vraie recuperation cardiaque
Deux ajouts lies par le meme constat : ce qu'on peut mesurer honnetement.

MARCHE 6 MINUTES — troisieme protocole de la vue de test. Sous-maximal, sans
risque, c'est la reference en cardiologie et le seul des trois qui ne demande
aucun effort maximal. Type d'activite HealthKit .walking (la distance et la
vitesse ne vivent pas sur le meme quantity type que la course).

RECUPERATION CARDIAQUE — le pont collecte desormais
heartRateRecoveryOneMinute, la valeur calculee par la Watch elle-meme.

Pourquoi ne pas la deriver de la serie de FC post-seance, deja en base :
mesure faite sur les 19 seances exploitables de la prod, la baisse mediane a
1 minute ressort a 2 bpm, avec 7 valeurs nulles ou negatives (min -10, max 15).
Ce n'est pas physiologique : la fin de seance HealthKit ne coincide pas avec
l'arret de l'effort. Une courbe batie la-dessus monterait et descendrait sans
rien mesurer.

⚠️ Le sample n'est PAS rattache au workout : predicateForObjects(from:) ne le
rendrait jamais. Apple l'ecrit comme un echantillon independant horodate juste
apres la seance, d'ou une requete par fenetre temporelle.

⚠️ NON COMPILE. La courbe de tendance cote web attend ce build : sans lui, le
champ reste absent et il n'y a rien a tracer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:58:24 +00:00
Sylvain Bettinelli
a61ba25590 Test de VMA guide au poignet
Pendant un test, le telephone est dans une poche : la montre est le seul ecran
consultable en courant, et c'est elle qui porte deja vitesse, distance et FC via
WorkoutManager.

Deux protocoles. Paliers d'une minute de 8 km/h par pas de 0,5, avec haptique au
changement et l'ecart a l'allure cible affiche — la vitesse brute demanderait un
calcul mental a chaque foulee. Ou six minutes, la distance parcourue donnant
directement la VMA.

⚠️ AUCUN calcul cote montre : elle transmet des mesures (paliers tenus,
distance, FC max), le serveur calcule. Deux implementations d'une meme formule
divergent toujours. Le POST passe par CoachRoutineBridge, qui a deja
l'abonnement NotificationCenter, la base d'API et le cookie — un fichier de plus
devrait etre cable a la main dans le pbxproj.

⚠️ Les paliers avancent sur `elapsedSec` de HKLiveWorkoutBuilder, PAS sur un
compteur accumule au fil du Timer de la vue : ce Timer ne tourne pas quand
l'ecran s'eteint, c'est-a-dire des que le poignet retombe — soit pendant
l'essentiel du test. Meme famille de piege que la Live Activity fantome.

L'envoi reprend la livraison differee de la routine : un test se fait dehors,
l'iPhone peut etre hors de portee, et refaire un test maximal parce qu'un
message s'est perdu n'est pas acceptable. Si l'envoi echoue, les chiffres
restent affiches pour une saisie manuelle.

VmaTestView.swift est un fichier NOUVEAU : cable dans le pbxproj (4 entrees,
sauvegarde project.pbxproj.bak-vma). Verification : `grep -c "VmaTestView.swift
in Sources"` doit rendre 2 (declaration + phase de compilation = 1 cible).

⚠️ NON COMPILE — a builder au prochain passage sur le Mac.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:47:03 +00:00
Sylvain Bettinelli
3202e03a63 Trois widgets d'ecran verrouille et une complication Routine
Les familles accessory* n'etaient declarees que cote Watch, alors que ce sont
exactement celles de l'ecran verrouille iOS 16+. Les vues existaient donc
deja : il ne manquait que la declaration cote iPhone.

Ajoutes sur l'ecran verrouille : Forme (circulaire/rectangulaire/inline),
Seance (rectangulaire/inline, avec la bascule doneToday du commit precedent)
et Routine du jour. Sur la Watch : complication Routine, et la seance gagne la
famille circulaire (icone du sport, verte quand elle est faite).

Le snapshot gagne RoutineProgress {done, total} au grain EXERCICE — celui de
la page web et de l'app Watch depuis le 03/08. `fraction` borne le ratio et
protege de la division par zero ; un total nul ne doit pas remplir la jauge.
Meme garde du jour que doneToday : la routine repart a zero au reveil, un
report aveugle afficherait 24/24 des le matin.

Vues dupliquees plutot que partagees entre les deux extensions : elles ne
partagent aucun fichier et l'App Group n'est pas commun entre appareils. Trois
petites vues coutent moins qu'un fichier de plus a cabler dans deux cibles —
et pour la meme raison tout est ajoute a CoachWidgets.swift, deja membre de sa
cible : AUCUN fichier nouveau, donc rien a cabler dans le pbxproj.

⚠️ NON COMPILE — a builder au prochain passage sur le Mac, avec le correctif
« JOUR OFF » (12b56a4).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:08:13 +00:00
Sylvain Bettinelli
12b56a4d39 Le widget disait « JOUR OFF » les jours de sortie non planifiee
Pendant du commit web 25f53b5. Quand la seance prevue est remplacee par une
autre activite, le plan du jour se vide : le widget iPhone affichait « JOUR
OFF / Pas de seance prevue » et la complication Watch « Jour OFF » — le jour
meme d'une sortie de 20 km. Le snapshot ne portait que le PLAN.

CoachWidgetSnapshot gagne doneToday (sport, titre, sous-titre), optionnel :
un snapshot ecrit par une version anterieure reste decodable. today decrit le
plan, doneToday decrit la journee ; les deux peuvent coexister.

CoachWidgetBridge le conserve quand l'appel l'omet, comme la nutrition — mais
seulement si le snapshot precedent date d'aujourd'hui, sinon la sortie de la
veille resterait affichee indefiniment.

Les deux vues basculent sur l'activite realisee, avec la pastille verte
« Realisee » deja utilisee pour une seance planifiee faite. Le sous-titre
(« 20,3 km · 56 min ») passe devant le titre : le titre d'une seance importee
est souvent vide.

⚠️ NON COMPILE — a builder au prochain passage sur le Mac. Aucun fichier
nouveau, donc rien a cabler dans le pbxproj : les quatre fichiers touches sont
deja membres de leurs cibles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:56:53 +00:00
Sylvain Bettinelli
326e73aaae L'extension LiveActivity etait passee en build 23, seule contre les autres
En corrigeant le build de la nouvelle target CoachWatchWidgets (creee en 1 par
Xcode, refusee a l'installation), la valeur a aussi ete changee sur
ch.hypnotruck.coach.LiveActivity - l'extension qui porte les widgets iPhone, la
Live Activity et le widget Saisie rapide. Elle se retrouvait en 23 quand son app
parente reste en 22.

Verifie : avant cette session, aucune ligne du pbxproj n'etait en 23.

En debug sur device, l'ecart passe. A l'archivage, Apple rejette l'upload pour
CFBundleVersion Mismatch - une extension doit porter le meme CFBundleVersion que
l'app qui la contient. Ca se serait vu dans l'Organizer, apres l'archive, au
moment le plus penible.

Les huit configurations sont ramenees a 22. Au prochain TestFlight, monter les
quatre targets ENSEMBLE (App, CoachWatch, CoachWatchWidgets, LiveActivity) -
c'est ce que l'etape 7 du guide prevoit avec 1.3 / build 23.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 14:47:45 +00:00
Sylvain
5984685916 chore(xcode): target CoachWatchWidgets, fichiers du widget Saisie rapide, build 22 aligne 2026-08-11 16:45:51 +02:00
Sylvain Bettinelli
4f8699fd37 Vitesse en km/h sur la montre et dans la Live Activity
Suite de la demande : voir des km/h en courant, ce que l'app Exercice
d'Apple ne permet pas (vitesse réservée au vélo, allure min/km imposée en
course, aucun réglage).

La vitesse instantanée vient de HealthKit lui-même : HKLiveWorkoutDataSource
collecte .runningSpeed d'office en course extérieure (Series 6+) et
.cyclingSpeed à vélo (watchOS 11+). Elle est lue en mostRecentQuantity —
c'est la vitesse à l'instant t qu'on veut afficher, pas la moyenne de la
séance. Sur un appareil qui ne la produit pas, repli sur distance/temps.

Affichée sur les trois surfaces : écran de séance de la montre, Live
Activity (bannière + île dépliée) et vue native /live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:31:13 +00:00
Sylvain Bettinelli
21d39f2e98 Piloter la séance depuis la Live Activity (Pause / Terminer)
Comme l'app Exercice : deux boutons sur la bannière de l'écran verrouillé et
dans l'île dépliée. iOS n'expose aucun geste de balayage pour révéler des
actions — depuis iOS 17, ce sont des boutons intégrés (App Intents), et c'est
le seul mécanisme public.

- Intents `CoachTogglePauseIntent` / `CoachEndWorkoutIntent`, conformes à
  LiveActivityIntent (sans quoi `perform()` n'est jamais appelé).
- Ils vivent dans CoachLiveActivityAttributes.swift, seul fichier déjà membre
  des deux targets : un fichier neuf imposerait une manip Target Membership
  dans Xcode, source d'erreurs répétées ici.
- Commandes relayées à la montre par WCSession, avec repli transferUserInfo :
  isReachable retombe à false quand la séance tourne en arrière-plan profond,
  la commande est alors différée au réveil de l'app montre.
- L'état de pause remonte depuis HKWorkoutSession (seule source fiable : la
  montre peut mettre en pause d'elle-même) et bascule le libellé du bouton.
- Le watchdog de LiveStore est neutralisé pendant la pause : sans ça, l'absence
  de samples aurait affiché « connexion perdue » puis terminé l'activité.
- L'app watchOS gagne le bouton Pause qui lui manquait, aligné sur le même état.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:25:14 +00:00
Sylvain Bettinelli
5e9b4a3df6 La Live Activity restait affichée après la fin de séance
La montre n'annonçait pas la fin : le flux de samples s'arrêtait, et
l'iPhone ne pouvait pas distinguer « séance terminée » de « montre hors de
portée ». La Live Activity restait donc figée sur la dernière FC reçue —
un cœur à 74 bpm en permanence dans la Dynamic Island.

Trois causes cumulées, toutes corrigées :

1. Aucun message de fin. WorkoutManager envoie maintenant `liveEnded` à
   l'arrêt de la session HealthKit, en cas d'échec, et en mode test.
2. Le seul chemin de terminaison côté iPhone était le watchdog de
   LiveStore, un Timer du main runloop — qui ne tourne pas quand l'app est
   suspendue en arrière-plan, c'est-à-dire pendant toute la séance.
   `endLive()` termine désormais l'activité dès réception du message.
3. `dismissalPolicy: .default` laissait la bannière ~4 h APRÈS la fin.
   Passé en `.immediate`.

Filet de sécurité : `adoptExisting()` au lancement reprend la main sur une
activité orpheline (app tuée pendant une séance, `current` perdu) et
termine celles de plus de 6 h.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 12:50:32 +00:00
Sylvain Bettinelli
ebb9273dc3 Le scan de code-barres ne pouvait pas fonctionner sur Android
Le manifest ne declarait que INTERNET. Or /meals scanne via
navigator.mediaDevices.getUserMedia() : Capacitor 8 relaie bien la demande
(BridgeWebChromeClient.onPermissionRequest mappe VIDEO_CAPTURE sur
Manifest.permission.CAMERA, puis lance le permissionLauncher), mais il ne peut
demander a l'utilisateur que ce qui est declare dans le manifest. La permission
etant absente, le scan etait le seul chemin casse - <input capture> continuait
de marcher, puisqu'il delegue a l'app appareil photo par intent.

CAMERA implique android.hardware.camera ET android.hardware.camera.autofocus en
fonctionnalites REQUISES, donc filtrage au Play Store, tant qu'on ne les
redeclare pas en required="false". Les deux sont donc declarees explicitement :
le scan demande facingMode ideal, pas exact, l'app reste utilisable sans.

Cote iOS, les libelles de permission ne decrivaient que le scan de code-barres
et la photo de profil, alors que la camera et la photothèque servent aussi a
photographier une assiette et une etiquette nutritionnelle pour analyse. Un
libelle qui ne couvre pas l'usage reel est un motif classique de rejet App
Store. NSPhotoLibraryAddUsageDescription reste inutile : rien n'ecrit dans la
photothèque (aucun PHPhotoLibrary ni UIImageWriteToSavedPhotosAlbum dans ios/).

Les deux fichiers sont bien formes (xml.etree, plistlib). Rien n'est compile
ici : reste a verifier sur un appareil que le dialogue camera apparait et que
le flux video s'affiche.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 08:10:10 +00:00
Sylvain Bettinelli
1c0e0320a9 Le plugin santé expose les métriques que seule la montre mesure
getWorkoutDetails lit, pour une liste de séances : les métadonnées du
HKWorkout (dénivelé de l'altimètre barométrique, température, humidité,
indoor, METs) et la dynamique de course de watchOS 9+ (puissance, longueur
de foulée, oscillation verticale, temps de contact au sol, vitesse max).

@capgo/capacitor-health n'expose aucun de ces types, et le dénivelé
barométrique est plus juste que le cumul d'altitude GPS que le serveur
calculait faute de mieux. Sur une montre antérieure à la Series 6, les
champs de dynamique sont simplement absents de la réponse.

Le web appelle déjà la méthode derrière un test d'existence : elle ne
prendra effet qu'après ce build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 06:49:28 +00:00
Sylvain Bettinelli
9790f0fa8a fix(widget): le pont effaçait l'hydratation à chaque chargement de page
Voilà la vraie cause du total qui retombait à zéro. `setSnapshot` reconstruisait
le snapshot **de zéro** avec `today` et `forme` seulement : tout ce que
`CoachQuickSync` venait de créditer — hydratation, cafés, calories — était
écrasé au premier chargement de page suivant.

Mon correctif précédent créditait donc un snapshot que le pont effaçait dans la
foulée. Il reste utile (il tient l'affichage entre la synchro et le prochain
chargement), mais il ne pouvait pas suffire seul.

Les champs de nutrition sont désormais FACULTATIFS dans l'appel : absents, on
conserve ceux du snapshot existant au lieu de les vider. Un pont qui n'envoie
qu'une partie des données ne doit pas détruire le reste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:54:32 +00:00
Sylvain Bettinelli
d8adad466b fix(widget): le total retombait à zéro après synchronisation
Le widget affiche « snapshot + file d'attente ». La synchronisation vidait la
file sans créditer le snapshot : le total repassait donc à zéro juste après que
la saisie ait été enregistrée pour de bon. Le pire moment pour douter de son
journal.

`flush()` reporte désormais dans le snapshot ce qu'il vient d'envoyer, AVANT de
vider la file. Seules les entrées du jour sont créditées — le snapshot porte des
totaux quotidiens, y verser une saisie d'hier les fausserait. Le pont web
repoussera un snapshot exact au prochain chargement de page ; ce report garde
l'affichage juste entre-temps.

Les optionnels du snapshot reçoivent `= nil` explicitement : sans valeur par
défaut, l'init memberwise de Swift les aurait exigés à l'appel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:51:22 +00:00
Sylvain Bettinelli
b2b3c10807 L'hydratation est un total, pas une jauge
Décision de l'utilisateur : pas d'objectif de boisson, juste la consommation du
jour. Le code qui attendait un objectif pour afficher une barre est retiré,
champ de snapshot compris — un conditionnel qui ne se déclenchera jamais est une
fausse promesse, et le prochain lecteur y verrait une fonctionnalité en attente.

Le widget affiche « 1,2 L bue aujourd'hui ». Seule l'énergie garde sa jauge :
elle est la seule à avoir une cible fixée (1950 kcal).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:47:05 +00:00
Sylvain Bettinelli
5eafa14b99 Jauges d'hydratation et de calories dans le widget
Deux barres sous le titre : eau bue sur objectif, énergie consommée sur objectif
calorique. La barre des calories vire à l'orange au-dessus de la cible.

La jauge n'apparaît QUE si l'objectif correspondant existe. Aucun objectif
d'hydratation n'est défini dans les objectifs nutritionnels : tant qu'il vaut
nil, le widget affiche le volume bu sans barre. Une jauge sur un objectif
inventé donnerait un pourcentage qui ne veut rien dire — et il aurait l'air
d'un réglage validé.

Les trois champs du snapshot sont optionnels : un instantané écrit par une
version antérieure reste lisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:44:40 +00:00
Sylvain Bettinelli
e4d317f781 Titre dans le widget : « Saisie des repas et boissons »
Un widget se lit hors contexte, au milieu d'autres tuiles : sans titre,
« 0,3 L · 2 cafés » ne dit ni de quoi il s'agit, ni à quoi servent les boutons.

Le nom affiché dans la galerie de widgets est aligné sur ce titre — deux libellés
différents pour la même chose obligeraient à les rapprocher de tête.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:40:38 +00:00
Sylvain Bettinelli
e715804dd9 fix(widget): Photo et Scanner ouvraient une page au hasard
Les deux liens utilisaient un schéma custom `coachapp://` que rien ne route :
le projet passe par les Universal Links (entitlement associated-domains), comme
les widgets Séance du jour et Score de forme, qui pointent tous deux sur des URL
https. L'app s'ouvrait donc sur sa dernière page consultée — le score de sommeil
en l'occurrence.

Les liens passent en https://coach.hypnotruck.ch/meals?photo=1 et ?scan=1, et le
schéma custom que j'avais ajouté à l'Info.plist est retiré : il ne servait à
rien et aurait laissé une fausse piste.

J'avais écrit ces URL sans lire comment les widgets voisins s'y prenaient — même
faute que pour `todayISO`, supposer un mécanisme au lieu de le vérifier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:40:10 +00:00
Sylvain Bettinelli
54108381dd Widget Saisie rapide : bundle, schéma d'URL et synchro câblés
Trois des étapes du runbook sont des fichiers texte, donc faisables hors Xcode :

* `CoachQuickWidget()` enregistré dans `CoachLiveActivityBundle`, sous
  `if #available(iOS 17.0, *)` — le bundle sert aussi des cibles antérieures ;
* schéma `coachapp://` déclaré dans l'Info.plist de l'App. Il n'y était pas :
  seul le schéma Google était présent, et les boutons Photo et Scanner du
  widget n'auraient donc rien ouvert ;
* `CoachQuickSync.flush()` appelé depuis `applicationDidBecomeActive`, qui
  était un corps vide.

Ne restent en manuel que les gestes qui touchent le projet Xcode lui-même :
Target Membership des trois fichiers Swift, puis le build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:24:01 +00:00
Sylvain Bettinelli
823bfb2ab9 Synchronisation des saisies du widget vers le serveur
`CoachQuickSync.flush()` envoie la file partagée vers POST /api/drinks et retire
les entrées transmises. Cible App uniquement : l'extension n'appelle jamais ce
code, elle n'a ni session ni droit de tenir une requête — c'est toute la raison
d'être de la file.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:06:28 +00:00
Sylvain Bettinelli
9fda989afc Widget natif « Saisie rapide » : eau et café sans ouvrir l'app
Boutons d'App Intent (iOS 17+) pour l'eau et le café : ils s'exécutent dans
l'extension, écrivent dans l'App Group et rafraîchissent la timeline. Aucun
appel réseau — une extension n'a ni session authentifiée ni droit de tenir une
requête.

Photo et code-barres OUVRENT l'application, par `coachapp://meals?photo=1` et
`?scan=1` : la caméra exige le premier plan, aucun widget ne peut y échapper.
Le widget économise les navigations, pas le lancement.

`CoachQuickLog` : file d'attente partagée app ↔ widget ↔ montre. Les saisies
partent au serveur à la prochaine ouverture de l'app, et sont retirées PAR
IDENTIFIANT — vider la file perdrait une saisie faite pendant la
synchronisation, sans laisser de trace.

Le widget affiche son propre total du jour : les boissons déjà connues du
serveur (nouveaux champs `waterMlToday` / `coffeeCountToday` du snapshot, tous
deux optionnels pour rester lisibles depuis une version antérieure) PLUS ce qui
attend dans la file. Sans cela, il afficherait un total périmé juste après une
saisie et l'utilisateur douterait de ce qu'il vient de faire. Ce qui n'est pas
encore parti est signalé par une flèche, pas masqué.

Un échec d'écriture de l'App Group remonte en erreur d'intent : afficher
« ajouté » sur une saisie perdue est pire que ne rien afficher.

Runbook Mac complété — Target Membership, enregistrement dans le WidgetBundle,
schéma d'URL, iOS 17 minimum, et le câblage de synchronisation restant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:03:16 +00:00
Sylvain Bettinelli
f480c36dab feat(deeplink): entitlement associated-domains pour coach.hypnotruck.ch
Aucun .widgetURL ne ramenait dans l'app : la Live Activity, les deux widgets
iPhone et les deux complications Watch pointent sur des URL https, mais sans
entitlement associated-domains ni fichier d'association servi, iOS ouvrait
Safari — où le cookie d'auth de la WKWebView n'existe pas.

Le versant serveur est déjà en prod (coach_sportif 8ec0047 : route AASA +
deeplink.js). Reste la capability à vérifier dans Xcode côté Mac, cf. COWORK.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 09:28:21 +00:00
Sylvain
37d52bf18d chore(xcode): signing et capabilities 2026-08-06 11:07:02 +02:00
Sylvain Bettinelli
3e3351f3ca feat(healthkit): lit le score d'effort saisi sur l'Apple Watch
watchOS demande 'Quel a été votre effort ?' après chaque séance et l'user y
répond déjà. La réponse est stockée dans HealthKit en workoutEffortScore
(iOS 18+, vérifié dans la doc Apple) : on la récupère au lieu de la redemander
dans l'app.

- getEffortScores({days}) : parcourt les séances de la fenêtre et, pour chacune,
  interroge les samples d'effort via le prédicat officiel
  predicateForWorkoutEffortSamplesRelated(workout:activity:). Pas de
  rapprochement approximatif par horodatage.
- unité HKUnit.appleEffortScore(), score arrondi sur 1-10
- requestAuthorization demande désormais la permission de lecture du type :
  sans elle, la requête renvoie une liste vide SANS erreur — HealthKit ne
  distingue pas 'refusé' de 'aucune donnée'
- guard iOS 18 : sur plus ancien, renvoie [] avec une raison explicite

Le pendant serveur est dans coach_sportif (commit 0e79e74) :
POST /api/feedback/device/import, qui n'écrase jamais une saisie manuelle.

NON COMPILÉ — à inclure au prochain build Xcode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:12:53 +00:00
Sylvain Bettinelli
f032ee2126 feat(watch): séance guidée au poignet
Pendant natif du lecteur web. Bouton Démarrer par bloc : un exercice à la fois,
décompte en anneau, passage auto, haptique à chaque transition, pause et
navigation. Pendant une routine au sol, la montre est bien plus pratique que
d'aller chercher le téléphone — c'est tout l'intérêt de la version watchOS.

Chaque exercice écoulé est coché via RoutineStore.toggle, donc renvoyé à
l'iPhone puis au serveur : à la fin le bloc est à jour sur les trois surfaces.

La durée par exercice est désormais transmise dans le snapshot (duration_sec,
optionnel pour rester compatible avec les snapshots déjà persistés) : la vue
guidée utilise la vraie durée au lieu de répartir celle du bloc.

RoutineGuidedView.swift est déjà référencé dans le pbxproj (target CoachWatch),
aucun Add Files à faire. Backup : project.pbxproj.bak-guided

NON COMPILÉ — à inclure au prochain build Xcode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:32:29 +00:00
Sylvain Bettinelli
21d819a52b feat(watch): remonte les séances de renfo pour la complétion auto de la routine
Pendant natif de coach_sportif c12caef. À la détection d'un HKWorkout de type
renforcement, POST /api/routine/auto-complete avec les faits bruts.

Volontairement bête côté natif : aucune règle de correspondance ici. Fenêtre de
12 h depuis l'envoi explicite, tolérance de durée, choix du bloc — tout vit
côté serveur et reste donc ajustable sans rebuild iOS.

NON COMPILÉ — à inclure au prochain build Xcode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:13:02 +00:00
Sylvain Bettinelli
d5e42bf662 feat(watch): coches au grain exercice sur la montre
Pendant natif du commit coach_sportif 01bad9e. Les 24 exercices sont
cochables un par un pendant la séance, comme sur le web.

- RoutineExercise + items dans RoutineBlock ; les exercices voyagent avec le
  snapshot, rien n'est codé en dur côté watchOS
- l'en-tête de bloc est tappable : coche/décoche tous ses exercices d'un coup,
  pour enchaîner une série sans s'arrêter à chaque ligne
- compteur n/N par bloc + progression globale en exercices
- CoachRoutineBridge.sanitizeBlocks transmet désormais les items

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:01:40 +00:00
Sylvain Bettinelli
f9f8f95642 fix(watch): transmet les blocs de routine, pas seulement l'état coché
pushToWatch ne lisait que `date` et `done` dans l'appel JS et ignorait
`blocks`. La montre recevait donc un snapshot sans aucun bloc et restait sur
son écran vide « Ouvre l'app Coach sur l'iPhone » — alors que le log confirmait
que l'envoi partait bien (constaté sur device le 2026-08-03).

sanitizeBlocks ne garde que les clés attendues en types property-list stricts :
updateApplicationContext lève une exception ObjC sur tout ce qui ne l'est pas,
et les entiers venus du JS arrivent en NSNumber — un `as? Int` direct échoue
quand JavaScriptCore les passe en double (même piège que le score perdu dans
CoachWidgetBridge).

Le log distingue désormais blocs transmis et blocs faits : « 0 blocs » était
ambigu et désignait le compte des cochés, pas celui des blocs envoyés.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:53:08 +00:00
Sylvain Bettinelli
df843368ce fix(ios): injecte le cookie d'auth dans le store de la WKWebView
AppDelegate écrivait dans HTTPCookieStorage.shared, qui est le store
d'URLSession — pas celui que lit la WKWebView (WKWebsiteDataStore). Ça
fonctionnait jusqu'en mai parce que Capacitor recopiait alors les cookies au
démarrage ; ce n'est plus le cas, et l'app retombait sur l'écran de login
malgré un token valide (constaté sur device le 2026-08-03).

MainViewController.viewDidLoad injecte désormais dans le store de la WebView,
puis recharge une fois pour rejouer la première requête avec le cookie. Pas de
boucle : au passage suivant le cookie existe et on sort avant.

L'injection AppDelegate est conservée : elle sert aux appels natifs URLSession.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:21:28 +00:00
Sylvain
95d2961a56 chore(xcode): App Groups sur App, CoachLiveActivity et CoachWatch 2026-08-03 17:01:53 +02:00
Sylvain Bettinelli
f30cc60da5 chore(xcode): câble les fichiers Swift des widgets aux bonnes targets
Réduit le travail manuel de la session Xcode : CoachWidgetBridge (App),
CoachWidgets (CoachLiveActivity) et CoachWidgetSnapshot (partagé App +
CoachLiveActivity + CoachWatch, un PBXBuildFile par target = ce que fait
"Target Membership") n'étaient référencés nulle part depuis le 2026-06-29.

Reste manuel sur le Mac, car il faut créer une target :
  CoachWatchWidgets/CoachWatchComplications.swift

Backup : project.pbxproj.bak-widgets

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:30:09 +00:00
Sylvain Bettinelli
caeac37f38 feat(watch): routine quotidienne cochable sur l'Apple Watch
Pendant natif du backend coach_sportif (commit 772385b, déjà en prod).

- CoachRoutineBridge (target App) : pousse blocs + état du jour vers la Watch
  via updateApplicationContext, relaie les coches Watch vers /api/routine/day
  puis notifie la WebView (event routineUpdated)
- CoachWatch/RoutineStore : état persisté en UserDefaults, reset au changement
  de jour. Les blocs viennent de l'iPhone — rien codé en dur côté watchOS, donc
  modifier la routine côté web ne demande aucun rebuild
- CoachWatch/RoutineView : liste cochable, progression, haptique, bouton de
  renvoi si la synchro a échoué
- ConnectivityManager (watch) : sendRoutine() en sendMessage avec repli
  transferUserInfo (coche faite iPhone hors de portée -> livraison différée)

⚠️ CoachLiveBridge touché : routeIfNotLiveSample() aiguille les messages
routineDone vers NotificationCenter. WCSession.delegate est unique côté iPhone,
impossible d'en ajouter un second. Le flux live workout n'est pas modifié mais
doit être re-vérifié au build.

Les 3 nouveaux fichiers sont référencés dans project.pbxproj (bonne target) :
aucun "Add Files" à faire sur le Mac. Runbook : docs/routine-watch-runbook-mac.md

NON COMPILÉ — nécessite une session Xcode sur le Mac mini.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:22:33 +00:00
Sylvain Bettinelli
e1adbbb61f fix(ios): robustesse bridge widget + Apple Sign-In
- CoachWidgetBridge : lit le score en NSNumber.intValue (le cast `as? Int`
  renvoyait nil quand le web envoie un float → score widget perdu par
  intermittence). CoachWidgetStore.save renvoie un Bool ; le plugin fait
  call.reject si la sauvegarde échoue au lieu de résoudre ok:true à tort.
- CoachAppleAuth : sur double-tap, rejette l'ancien pendingCall (Promise JS
  ne pend plus indéfiniment) ; accès pendingCall confiné au main thread ;
  erreur d'annulation testée par domaine ASAuthorizationError (pas rawValue nu).

Swift pur, non compilé ici (pas de Xcode) — à valider au prochain build Mac.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:37:42 +00:00
Sylvain Bettinelli
cc88431f6b feat(watch): complications Apple Watch (Forme + Séance du jour)
L'App Group n'étant pas partagé entre appareils, le snapshot transite par
WatchConnectivity : iPhone CoachWidgetBridge.pushToWatch (updateApplicationContext)
-> CoachWatch/ConnectivityManager reçoit -> App Group de la montre + reload des
complications. Nouvelles complications (CoachWatchWidgets/CoachWatchComplications.swift) :
Forme (accessoryCircular/corner, Gauge) + Séance du jour (accessoryRectangular/inline).

⚠️ Reste Mac : créer la target widget watchOS + App Group/capability + Target
Membership. Runbook complet dans docs/widgets-runbook-mac.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 05:50:29 +00:00
Sylvain Bettinelli
a6bdebcfc8 feat(widgets): widgets natifs iPhone (Séance du jour + Score de forme)
Pont WebView→App Group→WidgetKit :
- CoachWidgetSnapshot.swift : modèle partagé App↔extension (App Group group.ch.hypnotruck.coach)
- CoachWidgetBridge.swift : plugin Capacitor setSnapshot/clear + WidgetCenter.reloadAllTimelines
- CoachWidgets.swift : 2 widgets (TimelineProvider + vues SwiftUI, small/medium)
- bundle + enregistrement plugin + entitlements App Group (App + CoachLiveActivity)

⚠️ Reste câblage Mac (App Group portail + capability + Target Membership) :
docs/widgets-runbook-mac.md. Hook web côté coach_sportif.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 05:18:17 +00:00
Sylvain
728d7fb288 chore(ios): link GoogleSignIn to App target 2026-06-05 08:42:31 +02:00
Sylvain
120adbed26 chore(ios): GoogleSignIn SPM 2026-06-05 08:38:47 +02:00
Sylvain Bettinelli
622cc03692 feat(ios): GIDClientID + URL scheme (reversed client id) pour GoogleSignIn
Info.plist : GIDClientID (client iOS Google) + CFBundleURLTypes avec le reversed
client id (com.googleusercontent.apps.817483900105-amoanmsold3j3k71retpjuhvrknsuunh)
pour le retour OAuth Google natif.
2026-06-05 06:20:24 +00:00
Sylvain Bettinelli
947d76714e feat(ios): plugin natif Sign in with Google (CoachGoogleAuth)
- CoachGoogleAuth.swift : plugin Capacitor (SDK GoogleSignIn) → signIn() renvoie
  { idToken, email?, givenName? }, POSTé sur /auth/google/web (aud = client iOS).
  Garde #if canImport(GoogleSignIn) → le repo build même avant l'ajout du SDK.
- MainViewController : enregistre CoachGoogleAuthPlugin.
- AppDelegate : GIDSignIn.sharedInstance.handle(url) dans application(_:open:).
- project.pbxproj : CoachGoogleAuth.swift dans la target App.

⚠️ Xcode (Mac) : ajouter le SDK GoogleSignIn-iOS via SPM + GIDClientID/URL
scheme (reversed client id) dans Info.plist (cf. HANDOFF). Sans le SDK, le
bouton ne s'affiche pas (signIn rejette proprement).
2026-06-05 06:15:17 +00:00
Sylvain Bettinelli
eee199a5ed fix(ios): enregistre le plugin CoachAppleAuth dans capacitorDidLoad
Capacitor 8 n'auto-découvre pas les plugins in-app : il faut les registrer
explicitement via bridge.registerPluginInstance(). CoachAppleAuthPlugin avait
été créé mais pas ajouté à la liste → window.Capacitor.Plugins.CoachAppleAuth
restait undefined → bouton Apple natif absent. Corrigé.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:38:55 +00:00
Sylvain
c407aa8364 fix(workoutkit): efface les séances programmées avant d'en pousser une nouvelle
WorkoutScheduler.schedule() empilait : chaque envoi ajoutait un workout sans
retirer le précédent → un ancien restait dans Exercice → Programmées et on
risquait maxAllowedScheduledWorkoutCount. Ajout de removeAllWorkouts() avant
schedule() (ne touche que nos workouts) : 1 envoi = la séance courante remplace
la précédente. API vérifiée swiftinterface WorkoutKit (iOS 26.5 SDK).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 21:15:55 +02:00
Sylvain Bettinelli
bf975ce8e6 feat(ios): plugin natif Sign in with Apple (CoachAppleAuth)
- CoachAppleAuth.swift : plugin Capacitor (AuthenticationServices) → signIn()
  renvoie { identityToken, user, email?, givenName? } au JS, POSTé sur
  /auth/apple/web (aud = bundle id ch.hypnotruck.coach).
- App.entitlements : capability com.apple.developer.applesignin (Default).
- project.pbxproj : enregistre CoachAppleAuth.swift dans la target App.
- AppDelegate : l'injection du cookie bypass n'écrase plus une session
  existante (sinon le login natif serait remplacé à chaque lancement).

Côté Xcode : ajouter la capability 'Sign in with Apple' puis build device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:05:52 +00:00
Sylvain
585e1233db feat(workoutkit): pousse les zones cardiaques en plage BPM explicite (HeartRateRangeAlert)
Le plugin accepte hr_bpm_min/hr_bpm_max par step et pousse un HeartRateRangeAlert
réel -> ce sont les zones Karvonen HRR (sous bêta-bloquant) qui s'appliquent dans
l'app Exercice de la Watch, pas les zones génériques %FCmax d'Apple. hr_zone reste
en fallback. API vérifiée dans le SDK WorkoutKit (WorkoutAlertMetric.countPerMinute).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 12:11:27 +02:00
Sylvain
5a1568ef2f perf(watchos-live): FC poussée en temps réel à chaque échantillon (throttle 2s -> envoi immédiat)
Le throttle fixe de 2s ajoutait jusqu'à 2s de latence sur /live vs l'app
Exercice native d'Apple (instantanée). Désormais chaque nouvel échantillon FC
est envoyé immédiatement ; throttle léger 0.5s conservé uniquement pour
calories/distance (anti-saturation BLE).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 12:05:27 +02:00
Sylvain
a41a1b1aa9 feat(live-native): cardiac-aware zones temps réel dans CoachLiveView
CoachLiveBridge.openNativeLive lit maintenant le token JS et le passe à
CoachLiveView(authToken:). La vue fetch /api/cardiac-zones au .onAppear
+ stocke en @State.

CardiacZones struct (Decodable) calque la payload du backend :
- méthode (karvonen / fcmax_pct), beta-bloquant, fcmax/fcrest observées, HRR
- Z1-Z5 lo/hi + clinical_target_bpm (HRKarv0.6 sous bb, Díaz-Buschmann 2014)
- zoneFor(hr:) -> ZoneMatch {id, lo, hi, color}

HRMetricCard (variante de MetricCard pour FC) :
- Chiffre + glow change de couleur dynamiquement selon zone courante
  (cyan Z1 récup -> rouge Z5 VO2max). Animation .easeOut 0.25s.
- Pill "Z3 · 128-145" sous l'unité bpm.
- "✓ Utile" si beta-bloqué + HR ≥ clinical_target_bpm.

Méthode 100% alignée Apple Watch Workout app (Karvonen HRR, recalc mensuel
max/rest, 5 zones) + ACSM. Aucune extrapolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:39:16 +02:00
Sylvain
98705981ba feat(ios): active allowsBackForwardNavigationGestures sur WKWebView
Active le swipe-back natif iOS (depuis le bord gauche) dans la WebView.
Indispensable pour pouvoir retirer les flèches de retour du chrome web
(cf. coach_sportif _layout.html commit lié) : sans ce geste, plus aucun
moyen de revenir sur les drill-downs (workout detail, etc.).

Distinct du swipe-to-delete des séances (qui démarre depuis le corps des
cartes, pas l'edge d'écran) -> aucun conflit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:31:37 +02:00
Sylvain
fac7d95cd1 feat(healthkit): deleteWorkout dans CoachHealthRoute (supprime workout d'Apple Santé)
Nouveau plugin method @objc deleteWorkout(workoutUUID:) -> retrouve le HKWorkout
par UUID puis HKHealthStore.delete. requestAuthorization étendu pour inclure
share auth de HKWorkoutType (requise pour delete). Utilisé par la page /workouts
au swipe-to-delete pour synchroniser la suppression avec Apple Santé.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:07:17 +02:00