Files
coach-ios/docs/AUDIT-2026-09-22.md
Sylvain Bettinelli b1e7cf6d47 Audit complet de l'app mobile : natif, conformité Apple, ergonomie
Trois axes, chaque constat marqué [vérifié] / [rapporté] / [doc] — la
distinction n'est pas cosmétique : un constat non revérifié relayé comme un
fait est la dérive que la règle 3 interdit.

Le plus grave (§1.1, vérifié ligne à ligne) : WorkoutManager.swift:199 garde la
dernière FC connue quand le capteur décroche, et :218 la réémet avec un
horodatage NEUF. Le watchdog de LiveStore ne peut donc jamais basculer en
.lost — il reçoit des samples frais. Au poignet, une FC morte s'affiche stable,
avec sa zone colorée, indéfiniment. Pour un post-infarctus sous bêtabloquants
qui lit ce chiffre pour décider d'une pente, ce n'est pas un bug d'affichage.

Quatre affirmations d'audits antérieurs corrigées, toutes vérifiées :
- la politique de confidentialité EXISTE (HTTP 200) — le TODO du README est
  périmé, et « missing privacy policy » circulait encore ;
- la suppression de compte EXISTE (5.1.1(v), motif de rejet fréquent) ;
- 88 tests Swift verts, pas 57 ;
- npm audit réellement propre, et aucun secret jamais commité (git log vide).

aps-environment = development n'est pas le problème qu'on croyait : Apple
documente que la valeur vient du provisioning profile et que TestFlight utilise
production. Le vrai risque est côté backend, sur l'endpoint APNs.

Côté produit : le mot « Démarrer » n'existe nulle part pour une séance
sportive, et widgets/today-session.html:6 documente un CTA que le markup ne
contient pas. L'app ne permet pas de démarrer une séance.

Ce qui n'a pas été audité est dit en § 7 — aucun build Xcode possible ici,
CoachPolarBLE non revu, backend non audité au-delà de l'ergonomie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 06:55:20 +00:00

26 KiB
Raw Blame History

Audit complet — app mobile Coach Hypnotruck

Date : 2026-09-22 · Portée : coach-ios (natif iOS/watchOS) + coach_sportif/web (l'UI réelle, servie dans la WKWebView) · Branche auditée : feat/watch-outdoor @ e6cdc67

Comment lire ce document

Chaque constat porte son statut de vérification. La distinction n'est pas cosmétique : c'est une app de santé, et un constat non vérifié relayé comme un fait est exactement la dérive que la règle 3 interdit.

Marque Signification
[vérifié] Lu ligne à ligne dans le fichier cité, ou calculé, pendant cet audit
[rapporté] Remonté par une revue automatisée, non revérifié — à confirmer avant d'agir
[doc] Adossé à une source Apple citée verbatim, URL donnée

⚠️ Ce document date du jour de son écriture. Il constate, il ne prescrit pas un état. git log fait foi pour ce qui a bougé depuis.


1. Ce qui va bien, et qu'il ne faut pas casser

Le socle est sain, et plusieurs points sont au-dessus de la moyenne du domaine.

  • [vérifié] Aucun secret n'a jamais été commité. git log --all sur coach-ios.local.json et ios/App/App/CoachAuth.swift est vide. Le .gitignore les couvre (lignes 23-24).
  • [vérifié] npm audit : 0 vulnérabilité. Capacitor 8.3.1 homogène sur tous les paquets.
  • [vérifié] 88 tests Swift verts sur Linux (10 DayRollover + 7 FormeBand + 17 IntervalEngine + 31 PolarPftpStep + 8 QuickDrink + 15 RouteFilter). Le chiffre de 57 qui circulait est faux.
  • [vérifié] La politique de confidentialité existe — HTTP 200 sur /privacy, liée depuis settings.html:796 et landing.html:207. Le TODO du README (« à héberger ») est périmé, de même que la ligne « missing privacy policy » de tout audit antérieur.
  • [vérifié] La suppression de compte existe (settings.html:797 → POST /account/delete, routers/auth.py:313). C'est l'exigence 5.1.1(v), motif de rejet fréquent : elle est déjà satisfaite.
  • [vérifié] Universal Links opérationnels — AASA servi en application/json, Team ID TZ2PVTRKNY, routes /live /routine /forme /calendar.
  • [vérifié] Purpose strings HealthKit précises et en français, nommant les types de données et leur finalité. Conforme à la lettre de 5.1.1(ii).
  • [rapporté] CoachWorkoutKit.swift est le fichier le plus sain du chemin santé : autorisation vérifiée deux fois, échec remonté jusqu'à l'écran, relecture post-envoi qui rend ce qui est constaté et non ce qui est supposé.
  • [rapporté] Aucune confusion kcal/kJ, m/s ↔ km/h, ni mètres/miles dans le code Swift. Aucun @MainActor manquant, aucun cycle de rétention.
  • [vérifié] La barre d'onglets web est le seul composant d'UI protégé par un test (tests/test_bottom_tabs.py) — et c'est le seul qui n'a pas dérivé. Le lien de cause à effet mérite d'être noté.

2. Gravité 1 — sécurité des données de santé

1.1 [vérifié] La FC au poignet se fige sur la dernière valeur connue, sans le dire

ios/App/CoachWatch/WorkoutManager.swift:199 et :217-224

if let hr { heartRate = hr }       // 199 — hr nil → l'ancienne valeur reste
...
ConnectivityManager.shared.sendSample([
    "heartRate": heartRate,        // 218 — la propriété stockée, PAS `hr`
    ...
    "ts": now.timeIntervalSince1970 // 223 — horodatage NEUF
])

didCollectDataOf se déclenche dès qu'un type est collecté. Quand le capteur optique décroche — bracelet desserré, froid, arythmie : la FA thermoablée est précisément le terrain à risque — les calories continuent d'arriver, hr vaut nil, et la ligne 218 réémet la dernière FC connue avec un horodatage frais.

Conséquence en trois étages :

  1. Au poignet, l'écran affiche une FC stable et sa zone colorée, indéfiniment.
  2. Sur l'iPhone, LiveStore.ingest pose lastSampleAt = Date() et status = .live. Le watchdog ne basculera jamais en .lost : il reçoit des samples frais.
  3. Aucun log, aucun compteur.

C'est le défaut le plus grave de l'audit. Un utilisateur post-infarctus sous bêtabloquants lit 138 bpm en Z3, conclut qu'il peut tenir la pente, et sa FC réelle est inconnue. Ce n'est pas une erreur de logique : c'est une information absente du modèle. heartRate a besoin d'un heartRateAt: Date posé uniquement quand hr != nil, d'un seuil d'âge au-delà duquel la vue affiche « — » (elle sait déjà le faire, LiveStore.heartRateText:152), et sendSample d'un champ d'âge pour que l'iPhone applique la même règle.

1.2 [rapporté] Les zones cardiaques peuvent ne jamais atteindre la montre

ios/App/App/CoachWidgetBridge.swift:183 — try? session.updateApplicationContext(...)

try? avale l'échec (payload trop gros, session non activée, montre non appairée). Ce snapshot transporte les bornes de zones affichées pendant l'effort. Deux fichiers plus loin, CoachRoutineBridge.pushToWatch:92-101 appelle la même API avec do/catch, log et call.reject.

Le chemin instrumenté est celui de la routine de gainage ; le chemin muet est celui qui porte les bornes cardiaques d'un patient post-infarctus. Aggravant : HeartRateZones ne porte aucune date de validité, donc après une révision des bornes la montre peut afficher les anciennes indéfiniment. C'est le motif « zones datées » déjà tranché côté serveur (app.current_zones()), non répercuté au poignet.

1.3 [rapporté] « Accès refusé » et « aucune donnée » sont rendus identiques

ios/App/App/CoachSleep.swift:92-96 — l'en-tête du fichier (ligne 30) promet available = false si non autorisé ; le code ne teste jamais l'autorisation et rend {available: true, samples: []}.

Si l'utilisateur retire « Sommeil » dans Réglages → Santé, le backend enregistre des nuits vides et sleep_score.py calcule une régularité sur des trous. Rien ne distingue « il n'a pas dormi » de « on ne nous laisse plus regarder ». HealthKit ne distingue effectivement pas refusé de vide en lecture, mais authorizationStatus(for:) couvre .notDetermined, qui est le cas fréquent (réinstallation, permission jamais accordée). Même famille : CoachHealthRoute.swift:173 et :277.


3. Gravité 2 — perte silencieuse de données

Le projet a déjà vécu ce scénario côté backend : un cron a privé quatre mois de séances de leur charge cardiaque en répondant « rien de neuf » — ce qui était vrai de son point de vue. Les trois défauts ci-dessous ont la même forme.

2.1 [rapporté] Séances multiples : toutes sauf une sont perdues définitivement

ios/App/App/CoachWorkoutObserver.swift:297-319. L'ancre est sauvegardée (l. 298) avant que seul latest (l. 315) soit traité. Or scheduleNotification est le seul appelant de reportToRoutineIfStrength et reportPlanOrigin. Renfo le matin + vélo le soir, iPhone resté à la maison : au premier fire, le vélo est notifié, le renfo n'est jamais POSTé, et l'ancre ayant avancé, il ne reviendra jamais. Le log dit « 2 new workout(s), notifying latest » — exact, et parfaitement muet sur ce qui vient d'être jeté.

Correction : boucler sur workouts pour les rapports serveur ; garder latest uniquement pour la notification locale.

2.2 [rapporté] Les POST natifs partent, puis l'app se déclare suspendable

Même fichier, :266-268, :352-382, :398-438. Le defer { completion() } s'exécute dès que le corps rend la main, alors que deux requêtes sont en vol sur URLSession.shared, sans beginBackgroundTask. iOS est libre de suspendre.

Aggravant, indépendamment vérifiable : le statut HTTP n'est jamais lu (l. 368 et 431). Un 401 sur token rotaté, un 500, un 404 ne produisent pas d'error — et plan-origin envoyé est journalisé comme un succès. Le bon pattern existe déjà à deux fichiers de là (CoachRoutineBridge.postDay:154-159 vérifie (200..<300).contains(http.statusCode)).

2.3 [rapporté] Une séance entière peut disparaître sans un seul log

ios/App/CoachWatch/WorkoutManager.swift:310-324. try? await builder.endCollection(...) puis try? await builder.finishWorkout() : si le premier lève, le second lève aussi, la récupération ne trouve rien, et le chemin finit sur discardRoute() qui logue la perte de la trace mais pas de la séance. statusMessage n'est pas touché : l'utilisateur voit l'écran de fin normal après 2 h de sortie.

2.4 [rapporté] didFailWithError ne clôt rien et laisse le GPS allumé

Même fichier, :377-389. Comparé au chemin .ended, il manque stopTracking(), endCollection, finishWorkout, finishRoute/discardRoute. Tout ce que le builder a collecté est perdu sans tentative de sauvegarde, et le GPS continue de tourner après la fin apparente de la séance.

2.5 [vérifié] Chaque pause efface jusqu'à 24 points de trace antérieurs

ios/App/CoachWatch/LocationTracker.swift:147 — pendingRoute.removeAll().

Le commentaire vise les points postérieurs à la pause. Mais pendingRoute ne contient que des points déjà acceptés par le filtre et déjà comptés dans la distance affichée, en attente d'écriture (seuil de flush : 25). À 1 Hz, c'est une douzaine de points en moyenne, jusqu'à 24. L'écart est donc visible : la distance les inclut, la trace HealthKit non. Six arrêts sur une sortie VTTAE → jusqu'à 144 points disparus, et un D+ recalculé qui diverge de celui affiché.

Correction : await flushPendingRoute() avant de vider, ou ne pas vider — le point de reprise est de toute façon filtré par implausibleSpeed.

2.6 [rapporté] Accès concurrent non protégé sur le tampon GPS

ios/App/App/CoachHealthRoute.swift:182-203 : all et pluginErr sont mutés depuis les callbacks de N requêtes livrées sur des files HealthKit indépendantes, sans verrou — alors que getEffortScores:282 et getWorkoutDetails:379 du même fichier utilisent un NSLock pour ce motif exact. Déclencheur : un workout portant plusieurs HKWorkoutRoute (multisport, reprise après interruption).


4. Gravité 3 — justesse et cohérence

# Constat Emplacement
3.1 [rapporté] humidity_pct vaut 0 ou 1, jamais 65 — HKUnit.percent() est une fraction (1.0 = 100 %), le × 100 manque CoachHealthRoute.swift:415-417
3.2 [rapporté] Une séance traditionalStrengthTraining est POSTée en functionalStrengthTraining (valeur en dur l. 363 alors que le garde accepte deux types). Le payload n'a ni UUID ni horodatage de début → serveur incapable de dédupliquer CoachWorkoutObserver.swift:352-364
3.3 [rapporté] value > 0 confond un zéro mesuré avec une absence de mesure, alors que le commentaire l. 399 promet l'inverse. L'erreur de la requête est jetée CoachHealthRoute.swift:449-462
3.4 [rapporté] startObserving répond observing: true même sur refus (le success est ignoré, et Apple le met à true dès l'affichage de la feuille). getStatus montre trois voyants verts et zéro donnée CoachWorkoutObserver.swift:218-228
3.5 [rapporté] La garde anti-doublon lastNotifiedKey est écrite (l. 317) et jamais lue. Le commentaire affirme une protection absente CoachWorkoutObserver.swift:60-63
3.6 [rapporté] Un cookie d'auth périmé ne peut jamais être remplacé : les deux injections testent le nom du cookie, jamais sa valeur ni son expiration. Les appels natifs continuent de partir avec la constante compilée AppDelegate.swift:31-34, MainViewController.swift:63-65
3.7 [vérifié] STYLE_GUIDE.md:233 annonce --text-secondary à 9,7:1 ; le calcul donne 6,36:1 sur noir, 5,95:1 sur .ap-card docs/STYLE_GUIDE.md

Le flux d'injection du cookie lui-même (3.6) est correct et bien documenté — la règle non négociable n° 2 du CLAUDE.md est respectée. Le défaut est le critère de remplacement, pas le mécanisme.


5. Conformité App Store — sources Apple

Méthode : developer.apple.com uniquement, verbatim + URL. La distinction exige / recommande / ne dit pas est le cœur de cette section.

5.1 [doc] aps-environment = development — non-problème, sous réserve d'une vérification

App.entitlements porte development. La doc Apple dit verbatim :

« Xcode sets the value of the entitlement based on your app's current provisioning profile. […] Production provisioning profile and [prerelease versions and beta testers] use production. »

TestFlight utilise donc production, et TN2265 ajoute que Xcode re-signe à la soumission avec le profil de distribution.

Ce que la doc ne dit pas : si Xcode réécrit le fichier source .entitlements. N'éditez pas le fichier sur la foi d'un blog. Apple publie une procédure de vérification, et c'est la seule qui conclut :

Organizer → archive → Distribute/Validate → feuille Summary → dépliant Binary and Entitlements → lire la valeur réelle dans le binaire signé.

Le vrai risque est ailleurs, et il est côté backend : un token production envoyé vers api.sandbox.push.apple.com (ou l'inverse) est traité comme invalide et la notification est jetée silencieusement (TN2265). À vérifier dans web/push_notify.py.

5.2 [doc] Ce qu'Apple exige, et où on en est

Exigence Source État
Privacy policy URL dans App Store Connect 5.1.1(i) + ASC Help, « required for all apps » à renseigner à la soumission
Privacy policy accessible dans l'app 5.1.1(i), « in an easily accessible manner » [vérifié] fait — settings.html:796
Privacy policy pour toute app HealthKit doc HealthKit [vérifié] fait
Contenu : collecte + usages + tiers + rétention/suppression et révocation 5.1.1(i) à relire — c'est le point que les policies génériques oublient
Suppression de compte dans l'app 5.1.1(v) [vérifié] fait — POST /account/delete
Purpose strings précises 5.1.1(ii) + doc HealthKit [vérifié] fait
Nutrition labels renseignés « required to submit » à faire : Health, Fitness, Precise Location, identifiants, photos — tous « Linked to You » (il y a un compte)
Aucune donnée HealthKit en pub/marketing 5.1.3(i), 5.1.2(vi), 2.5.18 conforme
Aucune donnée de santé dans iCloud 5.1.3(ii) [vérifié] aucun entitlement iCloud
Notes for Review spécifiques + compte de démo avec backend allumé 2.3.1(a), 2.1(a) à préparer

⚠️ Piège propre à l'architecture webview, User Privacy and Data Use :

« If you are using a webview for app functionality, it should be treated the same way as native functionality in your app »

Tout ce que fait coach.hypnotruck.ch dans la WKWebView compte comme du natif pour les nutrition labels et pour l'ATT. Si le site embarque le moindre analytics tiers, il doit être déclaré.

5.3 [doc] Ce que la doc Apple ne tranche pas — à connaître, pas à corriger

  • Guideline 4.2 n'a aucun critère mesurable. « Useful, unique, app-like » n'est pas quantifié. Personne ne peut certifier que HealthKit + widgets + Live Activity + watchOS + BLE « suffit ». Le levier réel est 2.3.1(a) : décrire nommément les fonctions natives dans les Notes for Review.
  • 2.5.2 (« may not download […] code which introduces or changes features ») n'a pas d'exception publiée pour une UI web distante chargée via server.url. Zone non tranchée — c'est le risque documentaire réel de l'architecture, et il est structurel, pas corrigeable.
  • « Third party » n'est pas défini : la doc ne dit pas si le backend du développeur en est un. Aucune exigence technique publiée sur le stockage (chiffrement, juridiction, rétention).
  • limitsNavigationsToAppBoundDomains: false : la page Apple ne décrit aucune conséquence, et WKAppBoundDomains est absente de la référence Info.plist. Aucun lien publié entre app-bound domains et données de santé. La config actuelle est cohérente (pas de WKAppBoundDomains + flag false = comportement pré-iOS 14, sans restriction), et passer à true casserait evaluateJavaScript et messageHandlers, dont Capacitor a besoin. Ne pas y toucher pour des raisons de conformité.
  • Aucune clé Info.plist de privacy policy n'existe. Si une source en réclame une, c'est inventé.

5.4 [doc] Signal de positionnement

5.1.1(ix) vise les « highly regulated fields (such as […] healthcare) » avec un « should » (pas « must »). Mais 1.4.1 prévient :

« Medical apps that could provide inaccurate data or information […] may be reviewed with greater scrutiny. »

Le vocabulaire de la fiche App Store a donc un effet documenté sur le niveau de scrutin : « coaching sportif » et « suivi cardiaque post-infarctus » ne déclenchent pas la même lecture.


6. Ergonomie — l'app est-elle utilisable par un sportif ?

C'est l'exigence produit : simple, ergonomique, pour des sportifs. Rappel d'architecture — l'UI réelle vit dans coach_sportif/web/, le wrapper ne fait que l'afficher. Les constats ci-dessous portent donc sur le web.

6.1 [vérifié] Le geste central du sportif n'existe pas

Le mot « Démarrer » n'apparaît que deux fois dans tout web/templates/, et les deux fois pour la routine anti-douleurs (routine.html:27,42). La troisième occurrence est un commentaire qui décrit une fonctionnalité absente : widgets/today-session.html:6 annonce un CTA « Démarrer » / « Voir détails » que le markup ne contient pas.

Action Taps
Voir la séance du jour 0 (mais ~420 px sous le haut de page)
Voir ses étapes et sa zone cardiaque cible 2
Envoyer la séance à la Watch 3 + scroll dans la modale
Démarrer la séance impossible — /live est passif, et hors onglets par défaut

Avant l'effort : 3 taps pour préparer. Pendant l'effort : l'app ne sert à rien sans la montre. Pour une app « prévue pour les sportifs », c'est le trou central.

6.2 [vérifié] La typographie descend à 8 px sur l'information la plus sensible

17 règles CSS descendent à ≤ 0,62 rem, la plupart en --text-tertiary :

Sélecteur Taille Ce que ça porte
.ap-tile__pill (style.css:7053) 0,5 rem = 8 px l'intensité de la séance, « ADAPTÉ IA », « FAIT ✓ »
.ap-tile__meta (:7068) 0,62 rem ≈ 9,9 px durée de la séance, km, FC
.entry-src, .entry-ecart 0,58 rem ≈ 9,3 px provenance / écart d'une mesure

L'intensité d'effort d'un post-infarctus est affichée en majuscules de 8 px, en gris, sur noir. Au total, 127 règles combinent --text-tertiary avec une police < 0,78 rem.

Ratios réels [vérifié par calcul], fonction validée sur le témoin WCAG officiel (#767676/blanc = 4,54) :

Token sur #000 sur .ap-card sur --bg-tertiary
--text-secondary 6,36 (le guide annonce 9,7 — faux) 5,95 5,27
--text-tertiary 5,42 5,22 4,68 (limite AA)

Les tokens passent AA, mais avec deux fois moins de marge que le guide ne le croit. Et le problème n'est pas le ratio : c'est la taille. Aucune échappatoire : pas de mode clair (_layout.html:20 force background:#000 !important, et les tokens clairs de critical.css:153-166 sont du code mort), pas de Dynamic Type (-webkit-text-size-adjust: 100% + rem sur racine fixe), aucun réglage de taille dans /settings.

Un thème sombre est le pire cas en extérieur : c'est le fond — la plus grande surface — qui est noyé par la lumière réfléchie.

6.3 [rapporté] Les cibles tactiles fautives sont exactement les actions fréquentes

Les composants génériques respectent le 44×44 du style guide (.ap-icon-btn, .tab-item, .more-item, .coach-fab). Mais :

Élément Taille Rôle
.qr-action ~29 × 35 ajout rapide d'un favori — le geste le plus fréquent
.hero-tab ~23 px sélecteur primaire de /meals
.ap-home-hero__cta-mini 32 × 32 le CTA du hero d'accueil
.meal-section__add-btn 34 × 34 porte d'entrée de la saisie de repas
.ap-icon-btn.--sm 36 × 36 navigation jour de /meals — usage primaire, ce que §2 du guide interdit

Saisir un repas connu = 3 taps, dont 2 sous le seuil. Pire : dans .quick-row, le bouton favori (étoile) et le bouton ajouter (+) sont adjacents, même taille ~29 px : avec des doigts moites ou des gants, on met en favori au lieu d'ajouter, sans retour d'erreur puisque les deux « marchent ».

Aucun test ne verrouille le 44×44 ni le contraste — alors que le CLAUDE.md pose « un garde-fou contre la récidive → un test, pas une phrase ». Les règles d'ergonomie sont les seules du projet à n'être qu'une phrase. D'où la dérive.

6.4 [rapporté] Densité et navigation

  • Accueil : 28 chiffres, dont un seul concerne la séance à faire (sa durée, en 9,9 px gris). Les 27 autres sont du constat sur le passé.
  • /workout : 20 tuiles toutes visibles par défaut, 100-130 valeurs avec les tableaux. /sleep : 8 tuiles.
  • 19 sections de navigation, 4 visibles, avec des doublons sémantiques réels : /mesures + /body + /profile pour le corps ; /calendar + /week + /dashboards + /reviews pour la planification.
  • La place la plus accessible au pouce — le FAB 56 px, fixe, sur toutes les pages — est occupée par le chat IA, pas par « logger » ou « démarrer ».

6.5 Le meilleur travail d'ergonomie de l'app est éteint

drinks-quick.html et meal-capture.html ont des boutons min-height: 44px, un geste unique, et un commentaire qui pose le bon raisonnement (« boire se note vingt fois par jour ; un geste qui coûte deux navigations ne se fait pas »). Ils ne sont ni dans le layout par défaut, ni en production. Les allumer dans _default_home_layout() est un gain immédiat pour un coût nul.

6.6 La tension honnêteté / ergonomie, à trancher

Le hero affiche TSB signé, HRV en ms, FC repos + écart % — quatre grandeurs à interpréter, dont aucune ne dit quoi faire. La seule phrase actionnable est suivie d'un encadré de 40 mots à 11,2 px expliquant qu'aucune publication ne la fonde.

Ce disclaimer est juste : c'est la règle 3ter, et sur une app de santé il n'est pas négociable. Mais son coût est payé au pire endroit. La question de conception est : comment rester honnête sans occuper le premier écran ? Le composant existe déjà — <details class="settings-fold">, dont §3c du guide décrit les deux règles.


7. Ce qui n'a pas été audité

Par honnêteté sur la couverture :

  • Aucun build Xcode — impossible depuis Linux. WidgetKit, SwiftUI, HealthKit et WatchKit ne compilent pas ici. Rien de ce qui touche à l'UI native, à la signature ou au comportement sur device n'a été vérifié autrement que par lecture.
  • CoachPolarBLE.swift (644 L, 86 commits de diagnostic entre le 11/08 et le 20/09) n'a pas été revu. C'est le chantier le plus instable du dépôt.
  • Android : seul le manifest a été lu. Health Connect n'est pas intégré, et la validation Play Console reste en attente.
  • Le backend (coach_sportif/web) n'a été audité que sur l'ergonomie et deux points de conformité. Ni sécurité serveur, ni justesse des formules.
  • web/push_notify.py n'a pas été lu — c'est pourtant là que se joue le risque APNs de §5.1.

8. Ordre de traitement proposé

D'abord, parce que la conséquence est une décision d'intensité prise sur un chiffre mort :

  1. §1.1 — horodater la FC et afficher « — » au-delà d'un seuil d'âge, des deux côtés du pont. Correctif petit et local.
  2. §6.2 — remonter le plancher typographique : interdire < 0,7 rem, bannir --text-tertiary sous 0,78 rem. En priorité .ap-tile__pill (l'intensité de séance en 8 px).

Ensuite, la perte de données :

  1. §2.1 + §2.2 ensemble — même scénario, même correctif structurel : boucler sur le lot, tenir un beginBackgroundTask, lire le statut HTTP comme CoachRoutineBridge le fait déjà.
  2. §2.3 + §2.4 — deux faces de la même omission dans le cycle de vie de la séance watchOS.
  3. §2.5 — une ligne.

Puis le produit :

  1. §6.1 — un bouton « Démarrer » ≥ 44 px sur la tuile du jour, avec la zone cible lisible à côté.
  2. §6.5 — allumer drinks-quick et meal-capture. Coût nul.
  3. §6.3 — porter les cibles primaires à 44 px, et écarter l'étoile du +.

Avant soumission :

  1. Vérifier aps-environment dans le Summary de l'archive et l'endpoint APNs du backend (§5.1).
  2. Nutrition labels, Notes for Review nommant les fonctions natives, compte de démo avec backend allumé (§5.2).

Garde-fous, parce que « un test, pas une phrase » :

  1. Deux tests web : 44×44 sur les classes primaires, ratio de contraste des tokens. Ce sont les seules règles du projet non verrouillées, et ce sont celles qui ont dérivé.

Audit mené par lecture du code et des sources Apple. Les constats [rapporté] proviennent de revues automatisées et n'ont pas été revérifiés ligne à ligne : les confirmer avant d'agir.