# Documentation de développement — HormoneTrack > Doc de référence pour toute future session (humaine ou IA) : contexte, décisions, > architecture, maths, build, tests, bugs corrigés, montre, évolutions. > Projet : `~/projects/HormoneTrack` — voir aussi [README.md](../README.md), > [GUIDE_INSTALLATION.md](GUIDE_INSTALLATION.md), [MONTRE-GADGETBRIDGE.md](MONTRE-GADGETBRIDGE.md). --- ## Table des matières 1. [Contexte & objectifs](#1-contexte--objectifs) 2. [Historique du projet](#2-historique-du-projet) 3. [Stack & versions (épinglées)](#3-stack--versions-épinglées) 4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine) 5. [Architecture générale](#5-architecture-générale) 6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre) 7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique) + [7.8 Modèle TFS V3C](#78-modèle-tfs-v3c-v140--pktransfemsciencemodelskt) + [7.9 Modèle WHSAH](#79-modèle-whsah-v146--pkwhsahmodelskt) + [7.10 « Tracé labs »](#710--tracé-labs--v150--pklabtrajectorymodelkt) + [7.10.bis Prolongation](#710bis-prolongation-au-delà-du-dernier-lab-v160--pklabtrajectorymodelkt) 8. [Tests unitaires](#8-tests-unitaires) + [8.bis Données de test hors dépôt](#8bis-données-de-test-réelles--hors-dépôt-local-test-data) 9. [Système de rappels](#9-système-de-rappels) + [9.bis Alertes de seuil](#9bis-alertes-de-seuil-reminderalertnotifierkt--worker-v142) 10. [UI & navigation](#10-ui--navigation) 11. [Graphiques (CurveChart)](#11-graphiques-curvechart) 12. [i18n FR/EN](#12-i18n-fren) 13. [Sauvegarde JSON](#13-sauvegarde-json) 14. [Bugs corrigés (historique complet — à ne pas réintroduire)](#14-bugs-corrigés) 15. [Comment régénérer l'asset pk_profiles.json](#15-comment-régénérer-lasset-pk_profilesjson) 16. [Workflow build / test / install](#16-workflow-build--test--install) + [16.bis Releases Gitea](#16bis-releases-gitea-avec-apk-téléchargeable) + [16.ter Test sur émulateur](#16ter-recette-test-manuel-sur-émulateur-reproductible--v134) 17. [Montre : Gadgetbridge & options](#17-montre--gadgetbridge--options) 18. [Espace disque & coûts](#18-espace-disque--coûts) 19. [Limites connues & choix volontaires](#19-limites-connues) 20. [Idées d'évolution (Phase 2+)](#20-idées-dévolution) 21. [Checklist de test manuel](#21-checklist-de-test-manuel) --- ## 1. Contexte & objectifs Utilisatrice : femme trans, THS (thérapie hormonale), injections d'estradiol (esters EV/EU/EEn, switchables) ± anti-androgènes. Elle tient déjà un suivi rigoureux dans **LibreOffice Calc** (`Estrogen.ods`, cf §7.1) avec deux modèles PK : **Estrannaise (EstraNase)** et **Transfem Science**. L'app doit reproduire fidèlement ces modèles. Montre : **Huawei Watch GT 3 (HarmonyOS 4.0.0.120)** = *Lite Wearable*, pas d'apps Android, apps tierces au poignet quasi impossibles (cf §17). Utilisatrice équipe de **Gadgetbridge** (FOSS) sur son téléphone → v1 = **app téléphone + notifications miroir sur la montre** via GB (ou Huawei Health). > **Développement IA-assisté** : le code a été produit avec un assistant IA ; > la contribution humaine = feedback continu, retours utilisateur (tests réels > sur téléphone, bugs avec exports), suggestions et validation des releases > (détail §2 — factuel, session par session). Fonctionnalités v1 : - Courbes estimées heure par heure : E2 (pg/mL) + T (ng/mL) — 24 h / 7 j / 30 j - Deux modèles PK du `.ods` (Estrannaise / TFS) pour injections EV/EU/EEn ; Bateman paramétrable pour gel/patch/oral - Log des doses (date/heure exacte, mg, **ester par injection**) - Labs (E2/T/PRL) + **calibration** (facteur d'échelle + calibration k du modèle T) - Rappels quotidiens, actions « Pris » / « Reporter 1 h » dans la notification - Export/Import JSON, FR/EN, 100 % local ## 2. Historique du projet | Date | Événement | |---|---| | 16 sept. 2026 (session v1.7.1) | **Fix « une seule note sur deux » dans les Analyses (Android + web)** : une prise E2+T avec des notes DISTINCTES (note clinique E2 + note « DHT » sur T — cas réel) n'en affichait qu'UNE (l'autre conservée mais perdue à l'affichage). Cause : affichage « première note non vide du groupe » — hypothèse historique « toutes identiques » (dialog commun) cassée par l'édition unitaire. FIX : `labNotesForDisplay` PUR des deux côtés (notes distinctes → préfixées du marqueur ; identiques → dédupliquées ; vides ignorées) + UI branchée. + **Nouvel export réel v1.7.0** (3 traitements dont CPA oral, 64 doses, 28 labs, notes doses ET labs) → `local-test-data/backup-v1.7.0.json` (gitigné) + **régression n°5** data-driven (7 tests : parsing, double-note épingle le fix, plausibilité moteur avec CPA, prévision 7 j). 11 nouveaux tests (211 verts / 181 sans données locales) + lint + check web. Validé émulateur §16.ter : seed v1.7.0 → écran Analyses affiche « E2 : … » ET « T : … » (dump), 0 crash. Web v1.7.1 sync (+ leçon : oubli d'entrée CHANGELOG = dialog « Nouveautés » vide, attrapé par l'E2E/§7). | | 13 sept. 2026 (session v1.7.0) | **Sauvegarde automatique quotidienne (opt-in) + unités des axes** : (a) Paramètres → « Sauvegarde automatique quotidienne » — dossier choisi UNE FOIS via SAF tree (ACTION_OPEN_DOCUMENT_TREE + takePersistableUriPermission, AUCUNE permission de stockage ; typiquement un dossier Owncloud synchronisé), WorkManager périodique 24 h (KEEP au démarrage, worker no-op si désactivé — pattern AlertWorker), **run immédiat à l'activation** (feedback + validation émulateur triviale), rétention configurable 1–30 copies (défaut 7) : fichier horodaté par run (`hormonetrack-auto-YYYYMMDD-HHmm.json`, jamais d'écrasement) + purge par la rétention PURE ([AutoBackupRetention] — ne touche JAMAIS exports manuels/logs/étrangers, keep clampé ≥ 1) ; statut « dernier run » persisté + AppLog complet ; contenu = backup v2 COMPLET (importable tel quel). (b) **Unités des axes du graphique** (remontée « jamais ajoutées depuis v1.0 ») : pg/mL (E2, gauche) / ng/mL (T, droite) au sommet des colonnes de labels (padTop 12→26 dp), portées aussi côté web (versions sync). 9 nouveaux tests (200 verts / 177 sans données locales) + lint vert ; proguard : `-keep` AutoBackupWorker (réflexion WorkManager — leçon #64). Validé émulateur §16.ter APK release (dossier Download via le picker SAF piloté uiautomator, fichier écrit, rétention, unités visibles, 0 crash). | | 12 sept. 2026 (session v1.6.0) | **« Tracé labs » PROLONGÉ au-delà du dernier lab** (demande) : le tracé s'arrêtait AU dernier lab (v1.5.0) — justement la période la plus récente était invisible. FIX : chip `Prolonger` (5ᵉ de la rangée, off par défaut, désactivé tant que Tracé labs est off) → au-delà du dernier lab significatif, `courbe(t) = M(t) × ρ_last` (ρ CONSTANT — pas d'extrapolation de pente, elle divergerait sans base physiologique) ; M(t) inclut AUTOMATIQUEMENT les doses loguées après le dernier lab (une injection EV après des labs EEn refait monter la courbe — épinglé par test) ; horizon = dernière dose E2 + cutoffHours de son traitement (une seule source de vérité : `cutoffHours` rendu public) ; série SPLITTÉE en "LAB" (ancré) / "LABX" (prolongé, rose atténué α 0,55 + légende dédiée — l'estimation ne se confond pas avec le mesuré) ; branche "LABX" EXPLICITE dans le when de légende AVANT le else (leçon #63) ; **AVERTISSEMENT visible sous la légende quand la prolongation est affichée** (demande) : « simple simulation, sans garantie de correspondre au réel, basée sur tes labs qui peuvent eux-mêmes être erronés » (string FR/EN `lab_track_extend_warning`). < 2 ancres, modèle déjà éteint au dernier lab, ou demande finissant avant → pas de prolongation (retour v1.5.0 exact, épinglé bit-compatible). 8 nouveaux tests (191 verts / 168 sans données locales) + lint vert. **+ Rattrapage validation émulateur §16.ter (oubliée, demandée par l'utilisatrice) → bug #64 TROUVÉ** : « Tracé labs » vide EN RELEASE SEULE (R8 full mode avait REMOVÉ la classe — mapping `R8$$REMOVED$$CLASS` — et inliné le calcul dans le producer ; debug OK, 191 tests JVM aveugles). FIX : `-keep class ...LabTrajectoryModel { *; }` ; validé émulateur APK release re-buildé avec les données réelles seedées : ancrée (7090 px rose + légende), prolongée (légende + avertissement), 0 crash. Tag v1.6.0 reposé sur le commit de fix AVANT publication d'APK (checklist §16 étape 4). **Checklist §16 : étape 3.bis « validation émulateur release OBLIGATOIRE avant tag » ajoutée.** | | 11 sept. 2026 (session v1.5.0) | **« Tracé labs »** (demande débattue) : courbe hybride `M(t)×ρ(t)` ancrée sur les labs — ρ log-linéaire entre labs, garde #61, E2 seul, fenêtre labs, chip off par défaut + scroll horizontal CONFINÉ (chip 4ᵉ coupé hors fenêtre, constat émulateur) + leçon #63 légende dupliquée ("LAB" tombant dans else TFS) ; ChartSeries.showExtrema=false pour la série ; 11 tests LabTrajectoryModelTest (183 verts) + lint ; validé émulateur (courbe passe exactement sur le lab 248, 0 crash). | | 5 sept. 2026 (session 1) | Plan, vérification GT 3 = Lite Wearable, création couche données + ancien moteur Bateman + ancien ReminderManager. Extraction des modèles du `Estrogen.ods` → `/tmp/pk_models.json` (6 profils × 8001 h + params D/k1–k3). | | 5 sept. 2026 | L'utilisatrice mentionne un travail d'un assistant tiers « **Mimo V2.5** » : **aucune trace trouvée** (fichiers identiques à la session 1, timestamps identiques). Reprise depuis l'état existant. Bugs trouvés au passage : settings.gradle, BootReceiver, cancel PendingIntent. | | 5 sept. 2026 (session build) | Redesign données (ester/pkModel/scaleFactor), réécriture moteur PK sur tables ODS, modèle T + calibration, rappels complets, UI 6 écrans, chart Canvas, backup JSON, i18n, wrapper Gradle, guide. **Installation SDK Android (brew) + premier build.** | | 5 sept. 2026 (session tests/docs) | Correction de toutes les erreurs de compilation (dont 3 vrais bugs logiciels trouvés par les tests), **24 tests unitaires verts**, APK debug généré (18 MB), documentation complète (README + docs/), préparation repo git. | | 5 sept. 2026 (session v1.1.0) | Bugs remontés par l'utilisatrice : courbes vides (casse EEn) + pas d'édition des doses → corrigés ; régression épinglée sur ses données réelles ; APK v1.1.0. | | 5 sept. 2026 (session v1.2.0) | **Repo git initialisé** (commits par couche + tags de release) ; montée toolchain AGP 9.4/Gradle 9.7.1/Kotlin 2.3.21/BOM 2026.08.00/compileSdk 37 ; panoramique du chart, superposition des deux modèles, prévision par « Fréquence », calibration automatique optionnelle, intervalles entre doses, TimePicker centré ; **36 tests verts**, APK v1.2.0 (23 MB). | | 5 sept. 2026 (session builds release) | Question debug vs release → **builds release optimisés** (R8 full mode + shrinkResources, 20 Mo → **2,4 Mo**, signés avec la clé debug → upgradables sans perte) ; garde-fous Gson dans `proguard-rules.pro` (réflexion) ; releases publiées avec les **deux APK** (release recommandé + debug) ; 62 tests verts. | | 5 sept. 2026 (session v1.2.5) | Bug graphique : double espace en haut (double insets) → **edge-to-edge propre** (`enableEdgeToEdge` + insets consommés une seule fois) ; 62 tests verts, APK v1.2.5 + release. | | 5 sept. 2026 (session v1.2.4) | Bug `isActive` (simulation effacée) → sémantique « drapeau administratif » (§6.bis) ; régression n°3 sur le 3ᵉ export réel (transition EV inactif → EEn actif, 38 doses, 22 labs) ; **62 tests verts**, APK v1.2.4. | | 5 sept. 2026 (session push Gitea + v1.2.3) | Push initial vers **gitea.cloudyfy.fr/Siphonight/HormoneTrack** (privé) après **anonymisation de l'historique** (filter-branch : les premiers commits embarquaient les valeurs réelles des tests) ; **données de test réelles déplacées hors dépôt** (`local-test-data/` gitignoré, tests `Assume`-skippés) ; **releases avec APK en pièce jointe** ; pics/creux + calibration T par période d'ester ; **56 tests verts**. | | 5 sept. 2026 (session v1.2.1) | Remontées UX/utilisateur : calibration **par période d'ester** (labs EV → doses EV, labs EEn → doses EEn — corrige les courbes gonflées), lab T "pg/mL" neutralisé, **édition des labs**, tap accueil → Graphiques + mini-légende, auto-cal fusionnée (E2+T, un seul switch) ; 2ᵉ régression épinglée sur le nouvel export ; **44 tests verts**, APK v1.2.1. | | 5 sept. 2026 (session v1.2.2) | Toggle T masque aussi les labs T ; **prise de sang E2 + T en une entrée** (chacune optionnelle) affichée **côte à côte** ; sélecteur d'édition par marqueur ; suppression par prise ; 4 tests de regroupement ; **48 tests verts**, APK v1.2.2. | | 5 sept. 2026 (session v1.2.6) | Valeurs estimées affichées sur les pics/creux (drawExtremum) ; **import JSON en mode écrasement** (bug : conflit d'IDs si données existantes) avec restauration du tConfig et reprogrammation des rappels ; docs ; 62 tests verts, APK v1.2.6 + releases. | | 6 sept. 2026 (session v1.3.1) | En-tête « temps sous THS » sur Doses ; **logs de diagnostic** (AppLog : buffer 500 lignes, persisté, exportable de Paramètres) ; **fix toggle agenda** (callback async) ; **87 tests verts**, APK v1.3.1 + releases. | | 6 sept. 2026 (session v1.3.2) | 3 bugs remontés : toggle agenda (callback async fix + ContextCompat au save + Posologie requise), export logs plantait (pattern JSON réutilisé + AppLog), dialog changelog récurrent (version vue mémorisée avant affichage + titre BuildConfig documenté) ; export réel mis à jour (3 traitements : EV inactif + EEn actif + CPA oral) ; **87 tests verts**, APK v1.3.2 + releases. | | 6 sept. 2026 (session farewell) | Repo créé côté farewell → **push SSH (alias `farewell` : giteassh:2222) + 12 releases publiées avec APK vérifiés par téléchargement** ; piège lexicographique `v1.2.10 < v1.2.5` en comparaison de chaînes épinglé (§14 #38) ; les deux instances Gitea sont synchrones. | | 6 sept. 2026 (session v1.3.3) | **Audit de reprise de maintenance** (nouvelle session IA) : 3 bugs racines trouvés — permissions agenda ABSENTES du manifest (jamais déclarées, §14 #44), export logs plantait TOUJOURS (le fix v1.3.2 réimplémentait l'IO au lieu de réutiliser BackupManager, §14 #45), bump de version JAMAIS commité (tags v1.3.0–1.3.2 tous `versionCode 14 / "1.3.0"`, §14 #46). Corrigés + doc rafraîchie (DB v3, targetSdk 36, régression 3 = export v1.3.1, §19/§20 staleness) ; **téléphone de test documenté : Google Pixel 9 /e/OS** (AOSP ; SAF DocumentsUI standard). | | 6 sept. 2026 (session v1.3.4) | **Deux crashs v1.3.3 remontés (Doses + export logs)** → reproduits sur ÉMULATEUR avec les vraies données (recette §16.ter, désormais standard) : (a) Doses = string `hrt_duration` 3 placeholders vs 2 args (**né en v1.3.1**, §14 #47) ; (b) export logs = `LocalDate.format("…-HHmm")` levant `UnsupportedTemporalTypeException` au TAP (§14 #48) — les fixes IO v1.3.2/v1.3.3 étaient à côté du vrai problème. Lint mis en filet bloquant (aurait attrapé les deux) + #49 notif ; helper `ExportFileNames` + 3 tests (90 verts) ; fixes VÉRIFIÉS sur émulateur avec l'APK release + données réelles (flux SAF complet : SAVE → fichier + message). Publication v1.3.4. | | 6 sept. 2026 (session v1.3.5) | **Premier diagnostic à distance RÉUSSI via les logs exportés** : les AppLog de la v1.3.4 prouvent export logs `ok=true` (fix #48 ✓ au téléphone), permission agenda accordée (fix manifest ✓) et révèlent le bug racine EXPLICITE : `ensureCalendar` plantait car l'URI sync-adapter n'embarquait pas ACCOUNT_NAME/ACCOUNT_TYPE (§14 #50). En validant sur émulateur, DEUX bugs découverts : delete d'event sans account (#50 bis — « supprimé » logué mais l'event restait) et `buildTreatment` sans `calendarEventId` (#51 — événements orphelins). Cycle ON→save→OFF→save validé end-to-end (`content query`). Publication v1.3.5. | | 6 sept. 2026 (session v1.4.0) | **Modèle TFS reconstruit sur la méta-analyse officielle** (params V3C du simulateur transfemscience.github.io/injectable-e2-simulator, extraits de `ester-data.js`/`calc-curve.js`) : forme close 3 compartiments, 7 esters (EB/EC/ECS/PEP ajoutés), pics de l'article reproduits à ~1 % (AUC + Figure 11 épinglés). **+ Fix #52** (remontée) : rappels quotidiens même hors jour d'injection → grille Posologie (moteur = source unique), re-programmation après notif et « Pris ». 12 nouveaux tests (102 verts), lint vert. Publication v1.4.0. | | 6 sept. 2026 (session v1.4.1) | **UX graphique et carte « Prochaine dose »** (remontées) : (a) activer la Prévision ne saute PLUS dans le futur — la fenêtre reste en place, la projection s'étend au-delà et se parcourt en tirant vers la gauche (panHours NÉGATIF = futur, clamp unique `clampPanHours`, horizon 12× Posologie borné 30 j–1 an) ; (b) delta en JOURS sur la carte « Prochaine dose » au-delà de 24 h (`HrtDuration.daysAndHours` + `next_dose_days` FR/EN). 5 tests (112 verts). Publication v1.4.1. | | 6 sept. 2026 (session v1.4.2) | **Seuils d'alerte configurables + notification** (demande) : limites E2/T hautes/basses (Paramètres, opt-in, validées haut > bas), cartes d'avertissement sur l'accueil (niveau ESTIMÉ actuel), **notification** via un worker WorkManager périodique 15 min (enfin utilisé !) + check one-time au save, anti-spam par état persisté (`Alerts.encodeState`/`shouldNotify`), canal dédié `hormonetrack_alerts`. + **backup JSON v2** : les PARAMÈTRES utilisateur (langue, auto-cal, seuils) voyagent dans l'export et sont restaurés à l'import (rétrocompat v1, `-keep` R8 pour UserSettings). 9 tests (129 verts). Publication v1.4.2. | | 7 sept. 2026 (session v1.4.3) | **Question pharmacocinétique → 1 bug + 1 confirmation** : « l'estimation ne remonte pas le lendemain de mon injection EEn » → (a) **comportement NORMAL** (contribution J+1 < 12 % du total, plateau d'équilibre — épinglé par la régression n°4 sur le 4ᵉ export réel) ; (b) **bug #53** : le delta « vs il y a 6 h » de l'accueil comparait en réalité il y a 24 h (`firstOrNull` sur la fenêtre 24 h) → helper `pointHoursBefore` + 4 tests. 137 verts. Publication v1.4.3. | | 7 sept. 2026 (session v1.4.4) | **Prévision inaccessible depuis v1.4.1** (#54, remontée) : DEUX causes emboîtées — (a) captures FIGÉES dans `pointerInput(Unit)` (`futurePanHorizon` lu à 0 pour toujours → pan futur interdit) → `rememberUpdatedState` + **garde de source** (`ChartScreenSourceGuardTest`, le lint Compose ne voit pas ce pattern) ; (b) toggle sans effet visible (`endMs = now` → 0 dose générée) → extension contrôlée à droite (`forecastExtensionHours`, jusqu'au 1ᵉʳ créneau, sans déplacer le début). Validé émulateur : drag gauche → panHours < 0, courbe dessinée au-delà de « maintenant » (screencap). 140 verts. Publication v1.4.4. | | 7 sept. 2026 (session v1.4.5) | **« La prévision simule le 13 au lieu du 12 »** (#55) : diagnostic sur données réelles — GÉNÉRATION EXACTE (créneau = dernière dose loguée + 6 j ; subtilité : la dose avait été loguée le LENDENDE de l'injection réelle → dérive d'un jour par cycle, l'app suit les logs) ; illusion causée par l'absence de marqueur + labels X à minuit UTC. FIX : marqueurs de doses (prévisionnels pointillés + réels discrets), labels à minuit LOCAL, fuseau de graphique configurable (Paramètres). **+ #56** : boucle de recomposition (`nowMs` relu à chaque frame → endMs dérive → saturation main thread → taps perdus) → mémoïsé sur le tick. 145 verts. Publication v1.4.5. | | 7 sept. 2026 (session v1.4.6) | **3ᵉ modèle PK « WHSAH »** (demande) : le fit « license-free » du WHSAH Collective publié dans **Mona** (l'app concurrente — son git montre qu'elle utilisait NOS paramètres TFS avant mars 2026) intégré au même niveau : dispatch parallèle (aucun remplacement), 6 presets, dropdown 3 choix, 3ᵉ toggle superposable (vert/violet), PEP non couvert. Comparaison chiffrée TFS vs WHSAF épinglée (EV pic 367 vs 295 ; EEn à J+1 ×3). **+ #57** : l'écran Graphiques défile désormais (rangée de toggles coupée sous le pli, trouvé en émulateur). 155 verts. Publication v1.4.6. | | 7 sept. 2026 (session v1.4.7) | **Toggles alignés sur les traitements** (#58, demande) : au chargement, seuls les modèles utilisés par des traitements à PROFIL PK (`usesProfileModel`) sont ON (l'oral/AA polluait le set — vu en émulateur avec le CPA) ; garde-fou TFS si aucun profil ; une seule init. **Épinglé : la calibration s'applique à TOUTES les courbes** (par ESTER via doseEster, indépendant du modelOverride — test). 158 verts. Publication v1.4.7. | + **#59** : le chip de la carte de traitement affichait « Estrannaise » pour WHSAH (ternaire à 2 branches → `modelLabelRes` centralisé). | 7 sept. 2026 (session v1.4.8) | **Calibration PAR MODÈLE PK** (#60, remontée : « ×2,21 sur WHSAH alors que l'idéal serait < 1 ») : un seul jeu de facteurs (calculé depuis le modèle stocké) était partagé par toutes les courbes → absurde dès qu'un traitement basculait de modèle. FIX : le graphique calcule une AutoCalibrated PAR modèle affiché (ESE/TFS/WHS — modelOverride propagé à computeEsterScaleFactors et computeTKPerEster) → chaque courbe calibrée passe par les labs. Home inchangé (modèle stocké). Test : chaque courbe calibrée ≈ lab (±2 %), échelles TFS ≠ WHS. 159 verts. Publication v1.4.8. | | 7 sept. 2026 (session v1.4.9) | **Fix #61 — calibration tombant à ×2,21 (auto ET manuelle)** : reproduction par test sur données réelles — une dose EEn de test de JANVIER (10 mg) dans l'export, des labs de janvier-mars à 15–76 j → prédiction WHSAH résiduelle (184 → 1) → ratios aberrants (×3,3 → ×391) → médiane ×2,21, dans les DEUX pipelines. FIX : garde de significativité `labIsSignificant` (prédiction ≥ 15 % du max observé, labs par timestamp croissant) sur les 3 pipelines ; facteur WHSAH retombe à 0,55. + Hint UX (auto ON → facteur manuel ignoré à l'affichage) + nettoyage CHANGELOG (4 titres orphelins doublés [1.4.4–1.4.7]). 168 verts. Publication v1.4.9. | | 7 sept. 2026 (session v1.4.10) | **Pan mort sur la vue 24 h** (#62, remontée par test utilisateur Pixel 9 : « le glisser horizontal ne marche pas sur 24 h, uniquement au-dessus ») : les deltas de doigt (~30 px ≈ 0,67 h à 24 h) étaient tronqués à 0 par le cast entier à CHAQUE événement de mouvement → le pan ne bougeait jamais sur 24 h. FIX : cumul fractionnaire (`panDeltaHours` — pur, testé : 2 deltas de 0,67 h → 1 h). **Subtilité de test documentée (§16.ter)** : l'émulateur headless ne délivre pas les mouvements intermédiaires au chart via `adb input swipe` → le pan n'est PAS testable par adb (l'utilisateur a validé sur Pixel 9). 172 verts. Publication v1.4.10. | | 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. | | 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. | | 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. | | 5 sept. 2026 (session v1.2.8) | « Fréquence d'injection » → **« Posologie »** (terme adapté à toutes les voies) ; quirk Gitea découvert (noms d'assets normalisés à l'upload) → renommage PATCH dans le script ; releases avec les 2 APK ; 64 tests verts. | | 5 sept. 2026 (session v1.2.7) | Prévision : les créneaux passés ne sont plus simulés (bug oubli → faux pic historique) ; retard = décalage voulu ; mismatch doc-code §7.3b corrigé ; 2 tests ; **64 tests verts**, APK v1.2.7 + releases. | Leçon importante de la session build : **les erreurs de compilation et les bugs sémantiques (bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant et en testant** — aucun build n'avait été lancé avant la session 3. ## 3. Stack & versions (épinglées — à jour v1.2.0) | Composant | Version | Où | |---|---|---| | Gradle | **9.7.1** (wrapper) | `gradle/wrapper/gradle-wrapper.properties` | | AGP | **9.4.0** — **Kotlin intégré** : ne PAS appliquer `org.jetbrains.kotlin.android` ; `kotlinOptions` supprimé (cible JVM via `compileOptions`, 17) | `build.gradle.kts` racine | | Kotlin | 2.3.21 (plugin compose 2.3.21) | idem | | KSP | 2.3.11 (versionnage indépendant depuis KSP2) | idem | | Compose BOM | **2026.08.00** (Compose 1.12 ; material3 pinné par le BOM) | `app/build.gradle.kts` | | Room | 2.8.4 (KSP) — **DB v3 + MIGRATION_1_2 (forecastIntervalDays) + MIGRATION_2_3 (calendarEventId)**, `fallbackToDestructiveMigration` retiré | idem + `AppDatabase.kt` | | Navigation Compose | 2.10.0 | idem | | AppCompat | 1.8.0 (langue par app) | idem | | DataStore Preferences | 1.2.1 | idem | | Gson | 2.14.0 | idem | | JUnit | 4.13.2 (testImplementation) | idem | | WorkManager | 2.11.2 (**UTILISÉ depuis v1.4.2** : worker périodique des seuils d'alerte, cf §9.bis) | idem | | compileSdk / targetSdk | **37** / **36** ; minSdk 26 ; Java target 17 | app | | buildFeatures | compose + **buildConfig** (VERSION_NAME pour l'app) | app | Notes importantes (v1.2.0) : - **AGP 9** : Kotlin est intégré à AGP — appliquer `org.jetbrains.kotlin.android` est une erreur ; le plugin `org.jetbrains.kotlin.plugin.compose` reste appliqué normalement. - **compileSdk 37** : la plateforme `platforms;android-37` n'était pas dans sdkmanager (API 37 en preview à la date du build) mais **AGP l'a auto-téléchargée** (licences signées) — le build passe. - **Compose 1.12 (BOM 2026.08.00) exige compileSdk ≥ 37 et AGP ≥ 9.1** ; le BOM 2026.06.01 est le dernier compatible compileSdk 36. - **Material Expressive** : `MaterialExpressiveTheme` / `ExperimentalMaterial3ExpressiveApi` sont encore **internal** dans la ligne material3 pinnée par ce BOM (erreur de compilation vérifiée — javap montre `public` JVM mais la visibilité **Kotlin** est internal). `MaterialTheme` standard conservé ; basculer dès que l'API devient publique (NOTE dans `ui/theme/Theme.kt`). - Kotlin 2.0 → compose compiler via `org.jetbrains.kotlin.plugin.compose`. Room convertit les enums ↔ String automatiquement. **Ne pas monter Kotlin/AGP/Gradle sans vérifier la matrice de compatibilité** (les versions sont récupérées via maven-metadata.xml de dl.google.com / repo1.maven.org, pas devinées). ## 4. Environnement de build (cette machine) - **macOS (Apple Silicon), brew présent, Java 21 (Microsoft OpenJDK) sur `/usr/bin/java`** ✓ - **Téléphone de test** : **Google Pixel 9 sous /e/OS** (ROM dé-Googlée, base AOSP) — SAF/DocumentsUI standard (l'export JSON via `CreateDocument` y fonctionne → c'est LE pattern d'IO de référence pour tout export de fichier) ; pas de Play Store, sideload par `adb install` ou APK direct. Les retours utilisateur (bugs, exports JSON) viennent de ce téléphone. (Historique : la montre est une Huawei Watch GT 3 via Gadgetbridge, cf §17 — ce n'est PAS le téléphone.) - **Émulateur local (installé en v1.3.4)** : SDK brew = `/opt/homebrew/share/android-commandlinetools`. Packages installés : `emulator` 37.1.11 + `system-images;android-31;aosp_atd;arm64-v8a` (ATD : léger, boot rapide, MAIS DocumentsUI STUBBÉ (`fakesystemapp`) → SAF non pilotable) et `system-images;android-36;google_apis;arm64-v8a` (image complète — REQUISE pour tester les flux SAF). AVD `hrt` (ATD 31) et `hrt36` (API 36) créés via avdmanager (erreur béninie « devices.xml » au create : l'AVD est créé quand même). Boot headless : `emulator -avd hrt36 -no-window -no-audio -no-boot-anim -no-snapshot -gpu swiftshader_indirect`. Recette de test → **§16.ter**. - **SDK Android** : installé via `brew install --cask android-commandlinetools` → `/opt/homebrew/share/android-commandlinetools` (524 MB) - licences acceptées : `yes | sdkmanager --licenses` - paquets : `platform-tools`, `platforms;android-34`, `build-tools;34.0.0` - **`local.properties`** à la racine (non commité) : `sdk.dir=/opt/homebrew/share/android-commandlinetools` - Gradle 8.9 téléchargé par le wrapper ; caches `~/.gradle` ≈ 1,5 GB - Build validé : `./gradlew assembleDebug testDebugUnitTest` → **BUILD SUCCESSFUL**, APK debug 18 MB (`app/build/outputs/apk/debug/app-debug.apk`) ## 5. Architecture générale Pas de ViewModel ni de DI externe — volontairement simple pour une v1 : ``` HormoneTrackApp (Application) └─ AppContainer ├─ AppDatabase (Room singleton) ├─ HormoneRepository (DAOs : Flow réactifs + one-shots suspend) └─ AppSettings (DataStore : TConfig, langue) MainActivity (AppCompatActivity) └─ setContent { HormoneTrackTheme { HormoneTrackRoot } } ├─ CompositionLocal LocalAppContainer └─ NavHost + NavigationBar (5 tabs + settings + treatment_edit/{id}) Écrans = collectAsState sur les Flows + calcul PK dans produceState(Dispatchers.Default) ``` Points clés : - `HormoneTrackApp.onCreate()` : init `PKProfileStore` (asset), canal de notification - `MainActivity` : applique la langue sauvegardée (`AppCompatDelegate.setApplicationLocales`), demande POST_NOTIFICATIONS (API 33+), lit les extras d'intent `open_log_dose` + `treatment_id` (venus de la notification) → Home pré-ouvre le dialog de log - **Tout calcul PK est hors UI thread** (`produceState` + `Dispatchers.Default`) ## 6. Modèle de données (Room) DB `hormonetrack.db`, **version 3**, migrations explicites (⚠️ plus de `fallbackToDestructiveMigration` — retiré en v1.2.0 car l'utilisatrice a des données réelles ; toute évolution de schéma = `Migration(x, y)` + ALTER TABLE) : - **v1 → v2** : `forecastIntervalDays REAL` (prévision, v1.2.0) - **v2 → v3** : `calendarEventId INTEGER` (agenda récurrent, v1.3.0) ### `Treatment` (treatments) - base : `id`, `name`, `type` (ESTRADIOL/ANTI_ANDROGEN/PROGESTOGEN/OTHER), `route` (ORAL/TRANSDERMAL_GEL/TRANSDERMAL_PATCH/INJECTION_IM/INJECTION_SUBCUT/OTHER), `doseAmount`, `doseUnit`, `isActive`, `notes`, `createdAt` - PK par table : `esterType` ("NONE"/"EV"/"EU"/"EEN" — objets `Esters`), `pkModel` ("ESE"/"TFS" — objets `PKModels`) - PK Bateman : `absorptionHours` (Tmax), `eliminationHalfLifeHours`, `bioavailabilityFraction` - Calibration : `scaleFactor` (défaut 1.0) - **Prévision (v1.2)** : `forecastIntervalDays: Double?` (jours ; null = pas de simulation à venir) — colonne ajoutée par la **migration Room v1→v2** - Rappel : `reminderHour/Minute/Enabled` - Helpers : `isInjection` (IM/SC), `usesProfileModel` (injection **et** ester ≠ NONE) ### `DoseLog` (dose_logs) FK → treatments (CASCADE), index `treatmentId` + `timestamp`. `esterType: String?` = **override par injection** (l'ODS permet de switcher d'ester d'une injection à l'autre) ; null = ester du traitement. ### `LabResult` (lab_results) `marker` libre ("E2", "T", "PRL"…), `value`, `unit` libre. La calibration et les charts comparent `marker.equals("E2", true)` / `"T"` — **les dropdown suggèrent E2/T** ; si l'utilisatrice tape autre chose, la calibration ignorera ces labs. ### DAOs `Flow` pour l'UI + one-shots `suspend *Once()` pour backup/boot/calibration : `TreatmentDao.getActiveOnce/getAllOnce`, `DoseLogDao.getAllOnce`, `LabResultDao.getAllOnce`. ### 6.bis Sémantique `isActive` (v1.2.4) — drapeau administratif, JAMAIS un filtre `Treatment.isActive = false` signifie « ce traitement ne produit plus de nouvelles doses ». **Ce n'est PAS un filtre de données** — passer un traitement à inactif ne doit rien effacer (bug v1.2.4 : Home/Chart collectaient `activeTreatments`, la simulation de tout l'historique EV disparaissait) : | Effet de l'inactivation | Où | |---|---| | ✅ Retiré des chips « Log rapide » (accueil) | `HomeScreen` → `activeTreatments` | | ✅ Retiré du dropdown des **nouvelles** doses | `DosesScreen` : création → actifs only ; **édition → tous** (une dose existante reste rattachée à son traitement inactif) | | ✅ Rappels annulés (au save, au boot, au calcul de « prochaine dose ») | `rescheduleAll` + `nextReminderFireMs` (filtre `isActive`) ; `BootReceiver` lit `getAllOnce()` pour annuler les alarmes des inactifs | | ❌ JAMAIS retiré de la simulation | Home/Chart passent **`allTreatments`** au moteur — les écrans ne filtrent jamais | | ❌ JAMAIS retiré de la calibration (auto) | `autoCalibrated(allTreatments, …)` | | ❌ JAMAIS supprimé de l'historique des doses | `DosesScreen` liste tout, suppression manuelle uniquement | Épinglé par la régression n°3 (`RegressionUserCase3Test`, cf §8) : EV inactif + EEn actif en même temps, les 22 labs couvrant les deux périodes doivent rester simulés et calibrés. ## 7. Moteur pharmacocinétique `pk/PharmacokineticEngine.kt` + `pk/PKProfileStore.kt`. ### 7.1 Source : `Estrogen.ods` - Fichier : le tableur `Estrogen.ods` de l'utilisatrice (Owncloud, 30 MB, 13 tables) - 6 tables nominatives (surnoms anonymisés) : historique injections (datetime, cuisse L/R, ester, dose mg, Z-track) + labs (E2 pg/mL, T ng/mL) + **facteur d'échelle** manuel par profil (valeurs entre 0,5 et 1,4) - Table **« Models »** : paramètres D, k1, k2, k3 par ester×modèle + **profils horaires normalisés (pg/mL par mg) sur 8001 h** — ce sont ces tables qui sont consommées - Les profils affichent 2 décimales → **plancher 0,01 / 0,00** en queue (conséquence importante, cf §7.2) Pics de référence (pg/mL par mg) : | Clé | Modèle | Ester | Pic | Tmax | |---|---|---|---|---| | `EV_ese` | Estrannaise | valerate | 61,12 | ~45 h | | `EU_ese` | Estrannaise | undecylate | 3,44 | ~55 h (plateau très long) | | `EEn_ese` | Estrannaise | enanthate | 31,35 | ~152 h | | `EV_tfs` | Transfem Science | valerate | 58,96 | ~51 h | | `EU_tfs` | Transfem Science | undecylate | 10,11 | ~198 h | | `EEn_tfs` | Transfem Science | enanthate | 31,97 | ~156 h | ### 7.2 `PKProfileStore` (asset loader + échantillonnage) - Asset `app/src/main/assets/pk_profiles.json` : `{ "params": {D/k1/k2/k3…}, "profiles": { "EV_ese": [8001 floats], … } }` (550 KB, parse ~ms via `JsonParser`) - **`initWithJson(json)`** = point d'entrée testable (JVM) ; `init(context)` lit l'asset - `sample(ester, model, dtHours)` : - modèle **strict** : seul "TFS"→`tfs` et "ESE"→`ese` ; tout autre → 0 (piège corrigé, cf §14) - interpolation **linéaire** entre heures entières - **extrapolation terminale** : dernier point **≥ 1 % du pic** (pour éviter le plancher d'affichage 0,01/0,00 de l'ODS), pente = décroissance moyenne sur les 48 h précédentes (jamais avant le pic) - ⚠️ tous les calculs en **Double** (Float×Double n'existe pas en Kotlin — source d'erreurs de compilation, cf §14) ### 7.3 Superposition Contribution d'une dose = `sample(...) × dose_mg` ; niveau total = somme des contributions de toutes les doses E2, chacune multipliée par le `scaleFactor` de son traitement. Coupure par dose : `cutoffHours` = longueur de table (8001 h) pour les profils, `30 × t½` pour Bateman. ### 7.3b Override de modèle + prévision + auto-calibration (v1.2) - **`modelOverride`** : paramètre optionnel de `concentrationOfDose` / `e2At` / `computeCurve` qui force ESE ou TFS pour les traitements par profil — le graphique dessine les deux modèles côte à côte depuis le même traitement (Bateman non concerné : les deux séries y sont identiques). - **`generateForecastDoses(treatment, doseLogs, toMs, nowMs)`** : projette les doses à venir = dernière dose réelle + k × `forecastIntervalDays`, **strictement après `nowMs`** (v1.2.7 : les créneaux déjà passés — cas d'un OUBLI — sont sautés au rythme configuré, sinon faux pic dans l'historique ; un RETARD décale toute la prévision) ; dose = standard du traitement, ester = override de la dernière injection. Jamais persistées : uniquement passées à `computeCurve` par le ChartScreen quand le chip « Prévision » est actif. - **`autoCalibrated(treatments, doseLogs, labs, tConfig)`** : option « Calibration automatique » (v1.2.1+) — renvoie `esterScales` (facteur **par ester**, cf §7.6) + `tKPerEster` (k T **par ester**, cf §7.5) recalculés depuis les labs ; `treatments` et `tConfig` **inchangés** (fallback par ester absent = valeurs stockées). HomeScreen et ChartScreen branchent dessus quand l'option est active. ### 7.4 Bateman (gel/patch/oral) `C(dt) = (F·D·ka/(ka−ke))·(e^(−ke·dt) − e^(−ka·dt))` ; cas dégénéré ka≈ke : `F·D·ke·dt·e^(−ke·dt)`. **`computeKa`** résout `ln(ka/ke) = (ka−ke)·Tmax` par bisection (50 itérations, bornes `ke×1.001 … ke×1000`) — **direction corrigée** (cf §14 : `eq > 0` ⇒ la racine est **au-dessus** de mid ⇒ `lo = mid`). ### 7.5 Courbe T (empirique) `T(t) = floor + (base − floor) / (1 + k(ester actif) · E2_calibrée(t))` [ng/mL]. Défauts `TConfig` : base 6.0, floor 0.2, k 0.19 (→ T≈0,4 à E2≈150). **Non issu du `.ods`** (qui ne modélise pas la T) — modèle d'inhibition simple, étiqueté « estimation » partout. - **k par période d'ester** (v1.2.3) : la suppression T diffère selon l'ester (valerate = pics hauts et courts, enanthate = plateau plus doux) → `computeTKPerEster` attribue chaque lab T à la période de la dernière dose ≤ lab et k = **médiane** des k de cette période ; la courbe utilise à chaque instant le k de l'ester **actif** (`activeEsterAt`, curseur sur les doses triées dans `computeCurve`), fallback = `tConfig.k` stocké. - Formule : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est(t_lab)`, garde k ∈ (1e-4, 10). ⚠️ L'E2 utilisée est la version **calibrée** (scalePerEster) — calibrer k contre une E2 brute faussait les k (corrigé v1.2.3). - `computeTConfigCalibration` (k global unique) reste pour le bouton manuel « Calibrer avec les analyses » des Paramètres. **Unités** : les labs T peuvent être saisis en ng/mL, ng/dL, ng/L ou nmol/L — `convertTToNgMl(value, unit)` normalise (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884, pg/mL ÷1000 défensif) ; appliqué à la calibration ET au rendu du chart (sinon l'axe T est faux d'un facteur 100, bug réel remonté par l'utilisatrice : labs 33/44 ng/dL). ### 7.6 Calibration (v1.2.1 : PAR PÉRIODE D'ESTER pour l'auto) **PORTÉE (v1.4.8, fix #60)** : le facteur est calculé PAR MODÈLE PK — le graphique calcule une `AutoCalibrated` par modèle affiché (ESE/TFS/WHS, via `autoCalibrated(modelOverride = …)`) : chaque courbe est calibrée avec la prédiction DE SON modèle et passe par les labs quel que soit le modèle superposé (test `calibration is per model`). Home (sans courbes superposées) conserve la calibration du modèle STOCKÉ. HISTORIQUE : v1.4.7 partageait UN seul facteur (calculé depuis le modèle stocké) entre toutes les courbes — absurde dès qu'un traitement changeait de modèle (×2,21 sur WHSAH). **GARDE DE SIGNIFICATIVITÉ (v1.4.9, fix #61)** : un lab ne calibre une injection que si la prédiction du modèle à son instant reste **≥ 15 % du maximum de prédiction déjà observé** (`labIsSignificant` — labs traités par timestamp croissant, les 3 pipelines : manuel, échelles par ester, k T). Au-delà de la fenêtre d'action (prédiction résiduelle), le lab reflète AUTRE CHOSE : une dose EEn de test logguée en janvier + des labs de janvier-mars produisaient des ratios ×3,3 → ×391 et la médiane tombait à ×2,21 (dans les DEUX pipelines). Result : facteur WHSAH réel = 0,55. - chaque lab est **attribué à la période d'injection dans laquelle il tombe** = dernière dose E2 ≤ lab (une prise de sang reflète d'abord l'injection qui précède) ; - ratio = lab ÷ prédiction **non calibrée** (toutes doses superposées, scaleFactor forcé 1) ; - facteur final par ester = **médiane** des ratios de sa période (EV/EU/EEN) ; - application : `e2At`/`computeCurve` acceptent `scalePerEster: Map?` — chaque dose est scalée par le facteur de **son** ester (`doseEster`, override compris), fallback = `scaleFactor` stocké du traitement pour les esters sans lab. - **Pourquoi** : un facteur unique par traitement mélangeait les périodes (labs valerate mesurés contre une prédiction enanthate → ratio aberrant → courbes gonflées à 250–375 pg/mL, remontée v1.2.0). Cas vérifié sur données réelles : EEn 5 mg tous les 6–7 j (t½ ≈ 6,7 j) → accumulation ×2 → ~270 pg/mL calibré, cohérent labs 300/250 ; non calibré ≈ 367. - `autoCalibrated()` renvoie `AutoCalibrated(treatments **inchangés**, tConfig recalibré, esterScales, calibratedEsters, tRecalibrated)` — écrans : `scalePerEster = effectiveAuto?.esterScales`. **Manuelle** (`computeScaleFactor`, bouton « Calibrer avec les analyses » dans l'éditeur de traitement) : facteur **unique par traitement** (médiane lab ÷ prédiction, garde prédiction > 0,5), écrit le `scaleFactor` stocké. ⚠️ Limite documentée : en cas de changement d'ester dans un même traitement, la manuelle mélange les périodes — préférer l'auto-calibration dans ce cas. **T** (`computeTConfigCalibration`) : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est`, garde k ∈ (1e-4, 10), **médiane** ; labs normalisés via `convertTToNgMl`. ### 7.7 API du moteur `levelAt / currentLevel / computeCurve(start, end, step=1h, tConfig) / e2At / testosteroneAt / computeScaleFactor / computeTConfigCalibration / nextReminderFireMs(treatments, doseLogs, now) / nextReminderFireFor / batemanParams / concentrationOfDose / computeKa / doseEster / isInjectionRoute`. Type de retour : `LevelPoint(timestamp, e2, t)`. ### 7.8 Modèle TFS V3C (v1.4.0) — `pk/TransfemScienceModels.kt` Le modèle « Transfem Science » n'est PLUS dérivé des tables ODS : c'est la **forme close exacte** du modèle à 3 compartiments de la méta-analyse (https://transfemscience.org/articles/injectable-e2-meta-analysis/), avec les paramètres D/k1/k2/k3 publiés par le simulateur officiel (transfemscience.github.io/injectable-e2-simulator — fichiers `ester-data.js` / `calc-curve.js`, valeurs = données scientifiques des études) : ``` Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + e^(−k3·t)/((k1−k3)(k2−k3)) + e^(−k2·t)·(k3−k1)/((k1−k2)(k1−k3)(k2−k3)) ] (t en JOURS) ``` - `sample(ester, dtHours)` = `Cp(t_jours) / fitDose(5 mg)` → pg/mL PAR mg ; le moteur multiplie par la dose réelle, comme pour les tables ODS. - Branchement dans `concentrationOfDose` : si `model == TFS` et que l'ester a un V3C (EV, EEn, EU, EB, EC, ECS, PEP) → `TransfemScienceModels.sample` ; sinon fallback tables ODS (Estrannaise, ou ester sans modèle). - **Coupure moteur** (`cutoffHours`) : 10 demi-vies TERMINALES (t½ = ln2 / min(k1,k2,k3)) — ex. EV 30 j, PEP 284 j — au lieu de la longueur de table. - Compartiment k2 « ultra-rapide » (EU/PEP : k2 ~10⁵–10⁶ j⁻¹) : son terme décroît instantanément, double précision suffisante (testé). - Sanity checks épinglés (±1–3 %) : pics/t½ des Tableaux 9–10, AUC par mg (EV 377,2 / EEn 436,6 / EC 430 / PEP 65,1 pg·j/mL/mg), état d'équilibre EV 5 mg/7 j (Cmax 384 / Cmin 142 / Cavg 269, Figure 11 de l'article). - ⚠️ EU (undécylate) : ajustement sur données d'étude LIMITÉES (cf article §Limites) — précision moindre, conservé car c'est le meilleur disponible. - Les valeurs des pics par mg restent cohérentes avec l'ancienne table ODS (la table en DÉRIVAIT) : EV 59 pg/mL/mg @ 51 h, EEn 31,97 @ 156 h — le changement est la FORME (queues exactes, pas d'arrondi 2 décimales). ### 7.9 Modèle WHSAH (v1.4.6) — `pk/WhsahModels.kt` 3ᵉ modèle PK, superposable, **demande exprès de l'utilisatrice** (« au même niveau que les deux autres, pas de remplacement »). - **Source** : le fit « license-free » publié par le WHSAH Collective dans l'app open-source **Mona** (projet Flutter voisin `~/projects/mona`, lib/data/model/graph_calculator.dart). Archéologie git de Mona : elle embarquait les paramètres TFS (identiques aux nôtres) depuis mars 2026, puis les a remplacés par ce fit indépendant — **pour des raisons de licence** (les données du simulateur TFS portent un copyright « don't reproduce » ; les valeurs numériques restent des résultats scientifiques publiés, mais Mona a préféré re-fitter). Crédit : alix / WHSAH Collective. - **Formule** (même tri-exponentielle, conventions Mona) : `Cp = dose·F·auc·k1·k2·k3·(e^(−k1t)/((k1−k2)(k1−k3)) − e^(−k2t)/((k1−k2)(k2−k3)) + e^(−k3t)/((k1−k3)(k2−k3)))` — normalisée PAR MG comme les autres modèles ; **F = biodisponibilité explicite < 1** (0,62–0,76) vs ≈100 % chez TFS → courbes brutes ~24 % plus basses (la calibration absorbe). - **Params par ester** (F/auc/k1/k2/k3) : EEn 0.708/875.4/0.09441, 3.354, 0.4078 · EV 0.764/621.3/0.2230, 17.62, 1.305 · EB 0.723/889.9/0.5220, 521.9, 5.223 · EC 0.687/554.5/0.0880, 17.95, 0.7177 · ECS 0.687/852.6/0.0973, 218.67, 6.624 · EU 0.618/385.8/0.02189, 183.4, 1.564. **PEP non couvert** (retiré du fit). - **Différences vs TFS** (5 mg, épinglé par `WhsahModelsTest` fidèle à Mona ±2 %) : EV pic 367 @ 1,7 j (TFS 295 @ 2,1 j, +25 %) ; EEn 188 @ 5 j (TFS 160 @ 6,5 j) ; **EEn à J+1 : 70 vs 22 pg/mL (×3)** ; t½ EEn 7,3 j vs 4,5 j. La MONTÉE rapide + le plateau haut/long de WHSAH expliquent que les deux apps « ne montrent pas la même chose » le lendemain d'une injection (cf #55). - **Dispatch** (`concentrationOfDose`) : `WHSAH && WhsahModels.hasModel` → `WhsahModels.sample` (avant le fallback tables ODS) ; `cutoffHours` = 10 × t½ terminale (EEn ≈ 73 j). Ester sans modèle → 0. - UI : 3ᵉ toggle du graphique (E2 vert plein, T violet pointillé — `ChartWhs`/`ChartTWhs`), 6 presets, dropdown modèle à 3 choix, `Esters.choicesForModel` (TFS=7 / WHS=6 / ESE=3 — testé). ### 7.10 « Tracé labs » (v1.5.0) — `pk/LabTrajectoryModel.kt` Courbe hybride ancrée sur les labs — **demande v1.5.0, débattue avec l'utilisatrice** (hésitation assumée, cf §2) : « biologiquement plausible MAIS passant par tous les points ». - **Formule** : `courbe(t) = M(t) × ρ(t)` où - `M(t)` = niveau du modèle PK BRUT (scaleFactor forcé 1, modèle stocké ou `modelOverride`) — porte toute la physiologie entre les labs (Tmax, queues, accumulation) ; - `ρ_k = lab_k / M(t_k)` à chaque lab ; ENTRE deux labs consécutifs, ρ interpole LOG-LINÉAIREMENT : `ρ(t) = exp(lnρ_k + f·(lnρ_{k+1} − lnρ_k))` — morphing d'amplitude multiplicativement continu (jamais de saut vertical) ; - passage EXACT garanti : courbe(t_k) = M(t_k)·ρ_k = lab_k (épinglé). - **Garde anti-#61** : labs croissants + `labIsSignificant` — un lab hors fenêtre d'action (dose de test de janvier, labs tardifs) N'EST PAS un ancrage (reproduction épinglée dans LabTrajectoryModelTest). - **E2 uniquement** : les labs T sont trop rares + unités variées (bug #26) — hors scope (choix v1.5.0). Pas de courbe T pour la série (`tStyle = null` → rien de dessiné, miroir CurveChart). - **Fenêtre = [1er lab significatif ; dernier lab] ∩ fenêtre du graphique** — toujours bornée aux labs ; vieillir après le dernier lab = rien dessiné (l'app n'invente pas de niveau). ⚠️ v1.6.0 : ce comportement reste le DÉFAUT — avec le chip « Prolonger », la courbe peut se PROLONGER au-delà du dernier lab (ρ constant, horizon = extinction du modèle — cf §7.10.bis). - < 2 labs significatifs → courbe vide (un lab seul = pas d'intervalle ; un point isolé ne définit pas de tracé) — et donc jamais de prolongation. - **Indépendante de la calibration** : elle EST sa propre calibration continue (ratio brut, `scaleFactor` stocké ignoré ET `scalePerEster` ignoré — l'inverse serait une DOUBLE correction, épinglé par test). effet : les modèles rapprochés au sein du même ancrage CONVERGENT (le mi-chemin ESE vs WHS colle à ~2 % — `modelOverride` garde une empreinte intermédiaire, testé avec le seuil du test moteur WHS). - **Hors pics/creux** (chip "Pics / creux") : la série passe `showExtrema = false` — ses extrema reflèteraient l'interp ρ, pas la physiologie du modèle. - **UI** : chip « Tracé labs » / "Lab track" dans la RANGÉE MODÈLES (4ᵉ chip — scroll horizontal CONFINÉ à la rangée ; constat v1.5.0 en émulateur : le 4ᵉ chip était coupé hors fenêtre avec une Row figée). OFF par défaut. v1.6.0 : 5ᵉ chip « Prolonger » (off par défaut, désactivé tant que Tracé labs est off) — prolongation au-delà du dernier lab (§7.10.bis). - ⚠️ **Leçon #63 (v1.5.0, émulateur)** : la clé "LAB" dans la liste `curves` doit être EXCLUE de la boucle de légende (`when (model)` — le `else` = TFS imprimerait la légende TFS une 2ᵉ fois). Série → couleur dédiée `ChartLabTrajectory` #C2185B dashed. v1.6.0 : la clé "LABX" (partie prolongée) a la MÊME exigence — branche explicite dans le `when`, sinon le `else` (= TFS) imprimerait la légende TFS une 2ᵉ fois. - **INVARIANT** : cette courbe n'entre JAMAIS dans `levelAt` — accueil, seuils d'alerte, rappels utilisent toujours le modèle calibré (§9.bis). ### 7.10.bis Prolongation au-delà du dernier lab (v1.6.0) — `pk/LabTrajectoryModel.kt` **Demande v1.6.0** : permettre au tracé labs de s'étendre au-delà du dernier lab, en simulant à partir des labs précédents, du dosage et du type d'ester injecté, et de l'évolution classique de l'ester. Le constat v1.5.0 : la fenêtre s'arrêtait AU dernier lab — précisément la période la plus récente (entre la dernière prise de sang et maintenant) était invisible sur la courbe de comparaison. **Recette** : `extendBeyondLastLab = true` (paramètre de `computeLabAnchoredCurve`, chip `Prolonger` — off par défaut, désactivé tant que Tracé labs est off). La borne droite de la fenêtre devient `min(demande, horizon)` et **la boucle de calcul est INCHANGÉE** : `ratioAt` retournait déjà ρ_last au-delà du dernier ancre (garde défensive v1.5.0) — élargir la fenêtre EST la prolongation. Continuité au point de suture garantie par construction (ρ(t_last) = ρ_last exactement, constant à droite comme l'interpolation aboutit à ρ_last à gauche). - **Formule au-delà du dernier lab** : `courbe(t) = M(t) × ρ_last` où - `M(t)` = le modèle PK brut : porte l'« évolution classique de l'ester » (Tmax, queues, accumulation) ET LES DOSES LOGUÉES après le dernier lab — une nouvelle injection (override d'ester compris : EV après des labs EEn, etc.) fait repartir la courbe en pic à l'amplitude calibrée (épinglé par test `une dose apres le dernier lab refait monter la courbe`) ; - `ρ_last` = ratio lab ÷ prédiction au dernier lab significatif, supposé CONSTANT. **Pourquoi pas une extrapolation de pente** : la pente entre deux labs est un morphing entre deux corrections d'amplitude, pas une tendance physiologique — l'extrapoler divergerait (un ratio qui monte de 5 %/jour finirait en facteurs ×10 inventés). ρ_last = le meilleur estimateur disponible, même sémantique que la calibration classique (le facteur stocké s'applique aux doses futures). - **Horizon (`extensionHorizonEndMs`, internal)** : dernière dose E2 + `cutoffHours(son traitement)` — `cutoffHours` a été rendu PUBLIC (v1.6.0) pour que la prolongation partage la MÊME sémantique d'extinction que le moteur (10 t½ terminales V3C/WHS, fin de table ODS, 30 t½ Bateman). Au-delà, M(t) = 0 : dessiner plus serait une ligne à zéro inventée. Retourne `null` (pas de prolongation) si aucune dose E2 ou si le modèle est DÉJÀ éteint au dernier lab. - **Split visuel "LAB" / "LABX" (ChartScreen)** : `AnchoredCurve` expose `lastAnchorMs` (timestamp du dernier ancre, `null` si < 2 ancres) ; l'écran splitte la série au dernier ancre — "LAB" = partie ancrée (rose plein de la v1.5.0), "LABX" = partie prolongée (MÊME couleur atténuée α 0,55, dashed) avec SA légende (`legend_lab_track_extend` FR/EN) : « estimation, plus ancrée ». L'estimation ne peut pas se confondre avec le mesuré. - **AVERTISSEMENT (demande v1.6.0)** : quand la partie prolongée est visible, un texte s'affiche sous la légende (`lab_track_extend_warning` FR/EN) : « simple simulation, sans garantie de correspondre au réel — extrapole ton modèle à partir de tes résultats de laboratoire, qui peuvent eux-mêmes être erronés ; fie-toi à ta prochaine prise de sang ». Même condition d'affichage que la légende LABX (`labExtendVisible`) : il ne peut jamais y avoir de courbe prolongée sans son avertissement. - **Leçon #63 réappliquée** : la branche `"LABX" ->` du `when` de légende est EXPLICITE et AVANT le `else` — sinon la légende TFS serait imprimée une 2ᵉ fois (le `else` = TFS). - **Garde #61 inchangée** : seuls les labs significatifs ancrent — un lab tardif hors fenêtre d'action ne prolonge PAS la courbe et n'est jamais traversé (épinglé par test). - **Comportement par défaut inchangé** : flag absent ou `false` = v1.5.0 bit-compatible (testé : points identiques). - **Limites assumées (documentées, pas des bugs)** : - changement d'ESTER après le dernier lab : l'amplitude du nouvel ester reste calibrée par le ρ de l'ancien — même limite que la calibration classique (pas de lab de la nouvelle période → fallback) ; - les doses PRÉVISIONNELLES ne participent jamais : la projection future reste la série « Prévision » (modèles calibrés) — le tracé labs ne consomme que des doses RÉELLES ; - **INVARIANT v1.5.0 étendu** : la partie prolongée non plus n'entre JAMAIS dans `levelAt` (display-end only, série "LABX" purement visuelle). - 8 nouveaux tests dans `LabTrajectoryModelTest` (191 verts au total). ## 8. Tests unitaires **211 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **181 sans les données de test locales** (cf §8.bis : les 5 classes de régression, 6/6/6/5/7 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2. Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = `app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`). - **`PKProfileStoreTest`** (8) : les 6 profils présents (8001 pts) ; pic EV_ese = 61,12 @45 h ; zéro avant injection ; interpolation stricte entre points (heures 100/101 — le plateau 45/46 est plat, piège de test) ; modèle inconnu → 0 ; extrapolation terminale décroissante ; esters longs mesurables à 8000 h ; **les 6 pics == valeurs ODS** - **`PharmacokineticEngineTest`** (15) : ke = ln2/t½ ; **pic Bateman ≈ Tmax** (attrape la bisection inversée) ; EV 4 mg → pic ≈ 4×61 pg/mL ; superposition ; linéarité du scaleFactor ; anti-androgène → 0 en E2 ; modèle T monotone/borné ; calibration SF = médiane (0,5/0,9/1,4 → 0,9) ; calibration nulle sans labs ; **calibration T récupère un k planté (0,25)** ; grille horaire clampée à la 1ʳᵉ dose ; vide sans doses ; override d'ester par dose (EV≫EU à 45 h) ; prochain rappel dans le futur ; levelAt combiné - **`BackupGsonTest`** (1) : round-trip JSON complet (enums, IDs, notes, TConfig) - **`RegressionUserCaseTest`** (6) : **régression épinglée sur les données réelles exportées** par l'utilisatrice (backup JSON v1.0.0 : 1 traitement EEn/ESE 5 mg, 1 dose, 4 labs dont T en ng/dL). Vérifie : parsing du JSON réel, courbes non vides et physiologiquement plausibles pour 24 h/7 j/30 j (attrape le bug #22 de casse EEn), labs antérieurs à la 1ʳᵉ dose ignorés par la calibration SF, conversion ng/dL→ng/mL, calibration T avec labs en ng/dL. **En cas de nouveau bug remonté par l'utilisatrice : exporter le JSON, l'épingler ici, reproduire, corriger.** - **`V120FeaturesTest`** (10) : v1.2.0→v1.2.3 — doses prévisionnelles (rythme 7 j depuis la dernière dose réelle, liste exacte J+4/J+11/J+18/J+25 ; vide sans intervalle ou sans doses ; ester override projeté), override de modèle (ESE ≠ TFS à 45 h pour EV ; sans override = modèle du traitement), auto-calibration v1.2.1 (facteur **par ester** depuis un lab planté, T recalibré, **originaux non modifiés** ; inchangée sans lab utilisable), **attribution des labs par période d'ester** (EV calibré par les labs EV, EEn par les labs EEn — le scénario valerate→enanthate de l'utilisatrice) et application de `scalePerEster` par dose. - **`RegressionUserCase2Test`** (6) : **2ᵉ régression épinglée sur données réelles** (export v1.2.0 : 9 doses EEn/TFS 5 mg ~6-7 j, 8 labs dont un T "pg/mL" par erreur, SF stocké 0,72, fréquence 6 j). Vérifie : parsing, **état d'équilibre EEn** (t½ ≈ 6,7 j + doses ~6-7 j → accumulation ×2 → e2 ≈ 270 calibré, cohérent labs 300/250 ; non calibré ≈ 367 = les « 375 » rapportés), facteur unique EEN plausible, lab T en unité aberrante neutralisé, prévision 6 j exacte, auto-cal cohérente. **Tout nouvel export utilisateur = un nouveau test de régression.** - **`RegressionUserCase3Test`** (6) : 3ᵉ régression (export **v1.3.1** — mis à jour à la v1.3.2) — le scénario **transition** : traitement **EV inactif** (29 doses 2–8 mg, janvier→juillet) + traitement **EEn actif** (9 doses) + **CPA oral** (anti-androgène), soit 3 traitements, 51 doses, 26 labs. Vérifie : parsing, **l'inactif reste simulé** (bug v1.2.4 — le moteur reçoit TOUS les traitements), calibration par période couvrant EV **et** EEN, k T par ester, continuité de la courbe pendant la transition, niveau actuel ~EEn équilibre. ### 8.bis Données de test réelles : HORS dépôt (`local-test-data/`) Les deux classes de régression (`RegressionUserCase{,2}Test`) épinglent le comportement du moteur sur les **exports réels** de l'utilisatrice. Ce sont des **données de santé personnelles** : elles ne sont **pas versionnées**, pour ne rien divulguer dans le dépôt (ni maintenant, ni si le repo devient public un jour). - emplacement : `local-test-data/backup-v1.0.0.json`, `backup-v1.2.0.json`, `backup-v1.2.3.json` (ancien export, plus consommé par un test), `backup-v1.3.1.json`, `backup-v1.4.2.json` et `backup-v1.7.0.json` (copiés tels quels depuis l'export JSON de l'app) ; `RegressionUserCaseTest` lit v1.0.0, `RegressionUserCase2Test` lit v1.2.0, `RegressionUserCase3Test` lit **v1.3.1**, `RegressionUserCase4Test` lit **v1.4.2** (premier export avec les settings v2), `RegressionUserCase5Test` lit **v1.7.0** (profil à 3 traitements dont CPA oral ; paire E2+T avec DEUX notes distinctes — épine le fix « une note sur deux » v1.7.1) ; - `.gitignore` contient `local-test-data/` → jamais commités. **GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** : 1. `git check-ignore -v local-test-data/…` → la règle matche ; 2. `git ls-tree -r --name-only | grep local-test-data` → VIDE avant publication ; 3. `git log --all -- local-test-data/` → vide (aucun commit n'y a jamais touché — vérifié à la v1.4.3) ; 4. le scan de confidentialité (working tree + §8.bis dette historique) ; 5. les assertions des régressions sont DATA-DRIVEN (elles lisent le fichier local) — JAMAIS de valeur de lab ni de timestamp réel en dur dans le code (le scanner refuserait, et ce serait une fuite). - les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI exécute 181 tests au lieu de 211 (v1.7.1 ; chiffres historiques : 44/87 à v1.3.x, 150/172 à v1.4.x, 160/183 à v1.5.0, 168/191 à v1.6.0, 177/200 à v1.7.0) ; - le workdir des tests Gradle est le dossier du module (`app/`) → les tests cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ; - **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous le nom attendu dans `local-test-data/` → `./gradlew testDebugUnitTest` ; - ⚠️ **ne jamais embarquer ces données dans les tests** (string inline dans le code) : tout nouvel export réel → fichier gitignore + assertions data-driven ; - **l'historique a été nettoyé avant le premier push** : les premiers commits embarquaient les valeurs (tests + doc) → `git filter-branch --tree-filter` avec un script d'anonymisation (timestamps décalés de +30 j, valeurs perturbées dans la prose des docs), tags réécrits, refs purgeées. ⚠️ **Lint de dette connue (constat v1.3.3)** : le scanner travaillait sur une liste de motifs figée ; depuis que `local-test-data/` s'enrichit de NOUVEAUX exports (ex. backup-v1.3.1.json ajouté à la v1.3.2), la ré-extraction des valeurs fait que le scan `--history` détecte des fragments JSON de labs **encore présents dans l'historique des révisions v1.1.0 → v1.2.3** (tests `RegressionUserCase{,2}` data-driven AVANT la migration fichiers gitignorés ; fragments de la forme « value » + timestamp). Amplitude : quelques valeurs numériques de labs — les mêmes ordres de grandeur figurent déjà en PROSE dans les docs publiés (§14 #23, CHANGELOG 1.1.0). Le working tree est propre. **Décision utilisateur (6 sept. 2026)** : purge complète de l'historique NON relancée (les révisions sont déjà répliquées sur les deux Gitea, aucune fuite nouvelle par push) ; la dette (réécriture d'historique + re-tags + force-push des 2 remotes + re-publish des releases) reste ouverte et doit être re-décisionnée si le dépôt passe PUBLIC. - **`LabsGroupingTest`** (9) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ; ordre chronologique décroissant). v1.7.1 — **notes d'un groupe** (`labNotesForDisplay`) : notes distinctes E2/T → DEUX lignes préfixées du marqueur (fix « une seule note sur deux »), note identique → une seule ligne (dédupliquée), note seule → brute (v1.0 préservé), vides/blanches ignorées, aucune note → aucune ligne. - **`ChangelogHelperTest`** (8) : v1.3.0 — comparaison SemVer **numérique** (1.2.9 < 1.2.10 : la comparaison lexicographique serait fausse), extraction de sections depuis la dernière vue, première installation (section courante seule), plusieurs versions intermédiaires, versions non numériques. - **`AppLogTest`** (4) : v1.3.1 — formatage des lignes horodatées, buffer circulaire (600 → 500 dernières, bordures). - **`HrtDurationTest`** (6) : v1.3.1 — jours depuis la 1re prise (0 si futur/invalide), décomposition mois(30 j)/jours. - **`CalendarRruleTest`** (3) : v1.3.0 — RRULE `FREQ=DAILY;INTERVAL=N`, arrondi demi-supérieur des décimales (6,5 → 7), clamp ≥ 1 jour. - **`ExtremaTest`** (6) : v1.2.3 — détection des pics/creux (`detectExtrema`) : alternance stricte pic/creux en régime d'équilibre (4 doses hebdo → ≥ 4 extrema, pic > creux voisin, valeurs dans les bornes), courbe monotone → vide, série plate → vide, série trop courte → vide, **filtre d'amplitude** (sémantique zigzag : une oscillation sous le seuil produit UN pivot, l'alternance complète apparaît quand le seuil baisse), anti-corrélation E2/T (un pic d'E2 ≈ un creux de T). - **`TransfemScienceModelsTest`** (5) : v1.4.0 — épingle le modèle TFS V3C sur l'article : pics/t½ des Tableaux 9–10 (±1–2 %), AUC par mg (±3 %), état d'équilibre EV 5 mg/7 j = Figure 11 (Cmax 384/Cmin 142/Cavg 269), esters sans modèle → 0, dt ≤ 0 → 0. **Toute retouche de TransfemScienceModels.kt passe par CE test** (c'est la fidélité au simulateur TFS qui est vérifiée). - **`WhsahModelsTest`** (6) : v1.4.6 — fidélité au comportement **Mona** (fit WHSAH) : pics EV 367,5 @ 1,69 j / EEn 187,9 @ 4,99 j (±2 %), t½ par ester, linéarité par mg, PEP non couvert, `choicesForModel` ×3 modèles. - **`WhsahEngineTest`** (5) : v1.4.6/v1.4.7 — dispatch WHSAH (override depuis un identiques au pic par construction) ; + **calibration universelle épinglée** (le facteur par ester s'applique à TOUTES les courbes, indépendamment du modelOverride — demande v1.4.7). traitement ESE, `pkModel = WHS`, coupure 10 × t½, PEP → 0) et **3 courbes EV distinctes** (ESE/TFS/WHS — seuil 2 % : ESE et TFS sont quasi identiques au pic par construction). - **`WhsahEngineTest`** (6) : + v1.4.8 — **calibration PAR MODÈLE** (fix #60) : chaque courbe calibrée avec la prédiction de SON modèle passe par le lab (±2 %) ; échelles TFS ≠ WHS au même lab. - **`ChartZoomTest`** (19) : + v1.4.10 — **pan en cumul fractionnaire** (`panDeltaHours`, fix #62 : les deltas de doigt < 1 h s'accumulent jusqu'à franchir une heure entière — la vue 24 h ne bougeait jamais ; deltas négatifs = troncature vers zéro, cas dégénérés). - **`ScaleFactorWhsahReproTest`** (4) : v1.4.9 — épinglé sur le bug ×2,21 (#61) : facteur manuel WHSAH 0,55 (plus jamais 2,21), cohérence auto↔manuel (±15 %), garde de significativité (séquence réelle 184→1 et cas aux frontières). - **`CalibrationPerModelTest`** (5) : v1.4.8 — **process calibration** : les 3 modèles (ESE inclus) calibrés atterrissent sur le lab ; échelles distinctes (ESE↔TFS ~2 %, WHS ~11 % — nuance documentée : la table ODS dérive du même article) ; **délégation** `autoCalibrated(modelOverride)` → `computeEsterScaleFactors` (implémentation unique — Home et graphique ne peuvent pas diverger) ; **k T par modèle** (k ∝ 1/E2 calibrée : échelle ×2 → k ÷2 ; les k post-calibration convergent entre modèles — même E2 cible) ; labs antérieurs à la 1ʳᵉ dose → échelles vides (par modèle). Toute retouche du pipeline de calibration passe par CE test. - **`ChartZoomTest`** (15) : v1.2.9 (pas d'échantillonnage adaptatif) / v1.4.1 (horizon + clamp panoramique) / v1.4.5 (`pointHoursBefore`, `xLabelTicks` minuit local) / v1.4.7 — **`defaultModelToggles`** (toggles alignés sur les modèles utilisés par les traitements ; garde-fou TFS si aucun profil), horizon + clamp de panoramique, `pointHoursBefore`, extension de prévision, **`xLabelTicks`** (minuit LOCAL, fuseau CHOISI America/New_York, heures rondes, cas dégénérés). - **`ChartScreenSourceGuardTest`** (1) : v1.4.4 — **garde de SOURCE** contre le bug #54 : vérifie que les vals de fenêtre du graphique sont exposées au gestionnaire de gestes via `rememberUpdatedState` (alias `gesture*`) et que la closure `detectTransformGestures` ne les lit jamais en direct (captures figées). Le lint Compose ne détecte pas ce pattern — ce test est le filet. - **`RegressionUserCase4Test`** (4) : v1.4.3 — 4ᵉ régression épinglée sur les données réelles (export v1.4.2, HORS dépôt) : parsing v2 avec **settings** (seuils + auto-cal), scénario transition EV inactif → EEn actif, **signature pharmacocinétique EEn** (contribution d'une dose à J+1 < 12 % du total, plateau 48 h ≤ 25 % — c'est la réponse épinglée à « pourquoi l'estimation ne remonte pas le lendemain »), pas de fausse alerte à l'observation. **Tout nouvel export = une régression data-driven.** - **`RegressionUserCase5Test`** (7) : v1.7.1 — 5ᵉ régression épinglée sur les données réelles (export v1.7.0, HORS dépôt) : profil à 3 traitements (CPA oral Bateman + EEn actif + EV inactif simulé, §6.bis), **le fix « une note sur deux » épinglé sur la VRAIE paire** (dernière prise E2+T avec deux notes distinctes → `labNotesForDisplay` en rend deux, préfixées ; note identique → une seule), notes de DOSES préservées, plausibilité moteur (niveau physiologique, courbe 30 j, auto-calibration couvre un ester), prévision 7 j. **Tout nouvel export = une régression data-driven.** - **`AlertsTest`** (12) : v1.4.2 — logique pure des seuils (`pk/Alerts.kt`) : HIGH/LOW, comparaison STRICTE (valeur == limite → rien, éviter le clignotement d'arrondi), seuil null → jamais d'alerte, cohérence haut > bas, HIGH prime LOW en config incohérente (défensif), les deux marqueurs à la fois (E2 puis T), **codec d'état** (`encodeState`/`parseState` — rétro-résistant aux entrées malformées) et **décision de notification** (`shouldNotify` : nouveau franchissement → notif, même état → pas de spam, retour à la normale → pas de notif mais ré-arme). - **`AlertsEngineTest`** (4) : v1.4.2 — câblage moteur → seuils : le niveau évalué par la NOTIFICATION (`levelAt(scalePerEster)`) est celui de la CARTE accueil (`computeCurve` dernier point, ±10 % de dérive horaire) ; seuil à la moitié du niveau réel → HIGH, seuil au double → rien ; T bas → LOW en ng/mL ; l'état persisté ne contient que les marqueurs en alerte. - **`TransfemScienceEngineTest`** (4) : v1.4.0 — câblage MOTEUR ↔ V3C : EB (sans table ODS) routé en TFS → pic article 971 pg/mL @ 0,65 j ; EB forcé en ESE (`modelOverride`) → 0 partout (comportement documenté : presets EB/EC/ECS/PEP en TFS uniquement) ; PEP 32,5 mg → contribution à 10 j ET 100 j (l'ancienne coupure 24 h du fallback table aurait donné 0) et 0 au-delà de 10 × t½ (par design) ; `computeCurve(modelOverride=TFS)` sur un traitement stocké ESE reproduit le pic V3C EV 295 — c'est le test du dispatch `concentrationOfDose` + de `cutoffHours`. - **`ReminderScheduleTest`** (8) : v1.4.0 — fix #52 : rappel sur la GRILLE Posologie (dose il y a 2 j + intervalle 7 j → alarme dans 5 j à l'heure choisie, PAS demain), heure déjà passée le jour du créneau → créneau suivant, créneau manqué sauté (jamais dans le passé), fallback quotidien sans Posologie ou sans doses, agrégat multi-traitements (le plus tôt gagne), traitements inactifs/désactivés → null. - **`LabTrajectoryModelTest`** (19) : v1.5.0 — « Tracé labs » (§7.10) : passage EXACT sur chaque lab (fp 1e-6), ρ log-linéaire (fonction pure + courbe recomposée via le moteur — M recalcule et ρ = geometric mean), 1 lab → vide, **garde #61** (lab tardif PAS un ancrage + fenêtre bornée au dernier lab SIGNIFICATIF), labs avant 1ʳᵉ dose → 0 ancrage, fenêtre clippée [labs ∩ demande], **indépendance du scaleFactor stocké** (sinon double correction → courbes identiques sous SF 1,0 vs 0,55), `modelOverride` (ESE vs WHS — divergence franche : ESE≈TFS quasi identiques par CONSTRUCTION, seuil 2 % doc §8), labs T ignorés, doublons de timestamp (2ᵉ ratio gagne), bornes défensives ratioAt. v1.6.0 — **prolongation** (§7.10.bis) : identité `M × ρ_last` exacte au-delà du dernier lab (10/20/30 j), suture exacte au dernier ancre, horizon = cutoff du traitement (dernier point ≤ cutoff < dernier point + pas), dose EV loguée après le dernier lab → pic de prolongation (> ×1,3 le niveau pré-injection — la forme suit dosage + ester), flag off = v1.5.0 bit-compatible, garde #61 sur la prolongation (le lab tardif ne prolonge ni n'ancre), gardes du helper d'horizon (pas de dose / modèle éteint → null), `lastAnchorMs` (null si < 2 ancres). **Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu. **Toute modification du moteur passe par ces tests.** Suite envisageable : Robolectric (UI/logic Android), tests Compose, lint. ## 9. Système de rappels `reminder/ReminderManager.kt` (+ `DoseActionReceiver.kt`, `ReminderReceiver`). - `ReminderContract` : constantes + **fabrique unique `reminderIntent()`** pour schedule ET cancel (même action = même PendingIntent — cf bug §14.3) - `AlarmScheduler` : - **`scheduleFor(treatment, doseLogs)` (v1.4.0, source de vérité = moteur)** : la date du prochain déclenchement vient de `PharmacokineticEngine.nextReminderFireFor` — **grille Posologie** si le traitement a `forecastIntervalDays` + des doses (rappel UNIQUEMENT le jour du créneau, à l'heure choisie — fix #52), sinon quotidien (comportement historique, gel/oral ou app sans historique) - alarmes **one-shot** (`setExactAndAllowWhileIdle`) : la suivante est reprogrammée par `ReminderReceiver` (après notif) et `DoseActionReceiver` (après « Pris ») — avant v1.4.0 la chaîne ne se recousait qu'au boot/au save - permission **SCHEDULE_EXACT_ALARM** : bouton d'octroi dans Paramètres + éditeur (`Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM`) - `scheduleSnooze` (+1 h), `rescheduleAll(treatments, doseLogs)` - `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity (`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()` ; **puis reprogramme le prochain déclenchement** (goAsync + IO) - `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance la grille) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif - `BootReceiver` : `goAsync()` + thread + **`runBlocking`** + one-shots `getAllOnce()` (traitements) + `doseLogDao.getAllOnce()` → `rescheduleAll` (jamais un Flow en runBlocking !) **Événements d'agenda (v1.3.0, `reminder/CalendarEvents.kt`)** : switch dans l'éditeur (sous Rappels) → insertion dans un **calendrier local dédié « HormoneTrack »** (CalendarContract, ACCOUNT_TYPE_LOCAL) d'un événement récurrent RRULE `FREQ=DAILY;INTERVAL=N` (N = Posologie, arrondi demi-supérieur **explicite** : `floor(x + 0.5)` — kotlin.math.round arrondit les ties vers l'entier PAIR !), début = prochaine occurrence à l'heure de rappel (ou 12:00). L'id est stocké sur le traitement (`calendarEventId`, Room v3) ; au save : supprimer + recréer (fiable) si changé, supprimer si désactivé. Permissions WRITE_CALENDAR + READ_CALENDAR demandées à l'activation du switch. Manifest : `POST_NOTIFICATIONS`, `SCHEDULE_EXACT_ALARM`, `RECEIVE_BOOT_COMPLETED`, `VIBRATE`, `WRITE_CALENDAR`, `READ_CALENDAR`. ⚠️ **Permissions agenda (v1.3.3)** : WRITE_CALENDAR + READ_CALENDAR ont été ajoutées au manifest seulement en v1.3.3 — elles étaient documentées ici depuis v1.3.0 mais **jamais déclarées dans le XML** (bug #44, §14) : tout jeton « le manifest contient X » doit être VÉRIFIÉ dans le fichier réel. Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17). ### 9.bis Alertes de seuil : `reminder/AlertNotifier.kt` + worker (v1.4.2) - **Seuils** (`pk/Alerts.kt`, PUR) : `Thresholds` (E2 haut/bas pg/mL, T haut/bas ng/mL, null = pas de limite), évaluation STRICTE (valeur == limite → rien), `evaluateAll` (E2 puis T), validation haut > bas (`isCoherent`). - **Worker WorkManager** : `AlertWorker` (CoroutineWorker) périodique **15 min** (minimum WorkManager) + **one-time au save des seuils** (feedback immédiat) ; planifié dans `HormoneTrackApp.onCreate` (`ExistingPeriodicWorkPolicy.KEEP`, survit aux reboots — BootReceiver n'a RIEN à faire). - **Calcul du niveau** : `levelAt(..., scalePerEster, tKPerEster)` — MÊME calibration que la carte accueil (auto-cal incluse si l'option est active). - **Anti-spam** : l'état des alertes déjà notifiées est persisté en DataStore (`alert_notified_state` = `Alerts.encodeState`, ex. « E2:HIGH;T:LOW ») ; `shouldNotify` : nouveau franchissement ou changement H↔L → notif, même état → rien, retour à la normale → état effacé (ré-arme), pas de notif de retour. - **Canal dédié** `hormonetrack_alerts` (IMPORTANCE_HIGH) : réglable indépendamment des rappels ; notification avec texte multi-lignes (BigText), tap → MainActivity. Garde défensive POST_NOTIFICATIONS (API 33+) journalisée. - Journalisation AppLog complète (« notification : E2:HIGH... », « franchissement inchangé », « retour à la normale », erreurs worker) — diagnostic à distance. ## 10. UI & navigation - `HormoneTrackRoot` : NavigationBar 5 tabs (home/chart/doses/labs/treatments) + routes `settings`, `treatment_edit/{id}` (-1 = nouveau) ; barre masquée sur ces 2 routes - `HomeScreen` : bandeau gradient (TransSky→TransPink, discret), carte **niveau actuel** (E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h — **v1.4.3 : comparaison au point le plus proche de −6 h via `pointHoursBefore`, l'ancien code comparait ~24 h**, bug #53), **cartes d'alerte v1.4.2** (si un seuil est franchi : fond `errorContainer`, « ▲ E2 estimé ≈ X pg/mL — au-dessus de ta limite (Y) », basées sur le DERNIER point de courbe = niveau affiché, disclaimer « estimation, pas une mesure »), carte prochaine dose (**v1.4.1 : delta en JOURS au-delà de 24 h — « 5 j 2 h · sam. 6 18:00 » via `HrtDuration.daysAndHours` + `next_dose_days` ; en dessous, h/min**), chips de log rapide (+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`) **cliquable → écran Graphiques** (v1.2.1) avec mini-légende E2/T ; données auto-calibrées si l'option est active (`scalePerEster = effectiveAuto?.esterScales`) ; rafraîchissement `tick` 60 s - `ChartScreen` (v1.2, le plus riche) : **calibration PAR MODÈLE** (v1.4.8, fix #60 : `autoByModel` — une AutoCalibrated par modèle affiché, chaque courbe reçoit SES échelles/k) ; **toggles de modèles initialement alignés sur les traitements** (v1.4.7, fix #58 : `defaultModelToggles` sur les pkModel des traitements à PROFIL PK — init unique au chargement ; garde-fou TFS si aucun profil — les traitements Bateman sont tracés dans chaque série) ; - `ChartScreen` (v1.2, le plus riche) : **fenêtre temporelle v1.4.1** — `endMs = now − panHours` avec `panHours` signé : > 0 = passé (tirer vers la droite), **< 0 = futur** (tirer vers la gauche, uniquement avec la prévision active, borné par `forecastHorizonHours` = 12 × Posologie borné 30 j–1 an). **Activer le chip Prévision ne déplace PAS la fenêtre** (la projection s'étend au-delà et se parcourt au drag) ; désactiver pendant un voyage futur → retour à maintenant ; clamp centralisé dans `clampPanHours` (testé). Plages 24 h/7 j/30 j + **boutons zoom − / +** (v1.2.9) ; **panoramique** (`detectHorizontalDragGestures` — tirer vers la droite remonte dans le passé, `panHours` borné à [0, âge de la 1ʳᵉ dose + plage], bouton « Revenir à maintenant ») ; **toggles indépendants Estrannaise/TFS** → deux `computeCurve` avec `modelOverride` superposées (E2 ESE bleu plein, E2 TFS turquoise, T ESE rose plein, T TFS rose pointillé) ; **chip Prévision** (doses projetées via `generateForecastDoses`, horizon = 2× le plus grand intervalle configuré, borné 7–30 j) ; **auto-calibration** branchée sur les Paramètres ; légende dynamique ; labs T normalisés en ng/mL ; **toggle T = masque aussi les labs T** (v1.2.2) ; **chip « Pics / creux »** (v1.2.3 : triangles ▲▼ aux extrema locaux de chaque courbe, via `detectExtrema` — E2 seuil 2 pg/mL, T seuil 0,02 ng/mL) - `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition** ; **en-tête « temps sous THS »** (v1.3.1) : carte depuis la 1re prise, jours totaux + décomposition mois/jours (`HrtDuration`, mois = 30 j estimés) - `LabsScreen` (v1.2.2) : **prise de sang E2 + T en une entrée** (LabDialog create : deux sections optionnelles → 1 ou 2 LabResult au même timestamp) ; affichage **groupé par timestamp** (`groupLabsForDisplay` : E2 avant T, autres ensuite, tri desc) → « E2 306 pg/mL · T 44 ng/dL » côte à côte ; tap → sélecteur E2/T si paire, édition unitaire pré-remplie ; suppression = **la prise entière** (confirm nommant les valeurs — choix de design : les labs sont prélevés ensemble) ; suggestions d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/L - `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰, badge inactif), FAB → éditeur - `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ; champs conditionnels (ester+modèle si injection, Bateman sinon) ; carte Calibration (scaleFactor + « Calibrer avec les analyses ») ; **section « Posologie » (v1.2 : switch « Simuler les doses à venir » + intervalle en jours)** ; carte Rappel (switch + TimePicker **centré** + avertissement alarmes exactes) ; switch actif ; save → insert/update + schedule/cancel ; delete avec confirmation ; `createdAt` préservé - **Logs de diagnostic (v1.3.1, `util/AppLog.kt`)** : journal horodaté (buffer circulaire 500 lignes, persisté `filesDir/debug-log.txt`), alimenté par l'agenda (permissions, upsert/delete), l'import/export et les erreurs ; **export (SAF) et effacement** dans Paramètres — permet le debug à distance (l'utilisateur joint les logs à son retour). - **Dialog « Nouveautés » (v1.3.0)** : au démarrage, si `BuildConfig.VERSION_NAME` est plus récente que la dernière vue (DataStore `changelog_seen_version`), un AlertDialog affiche les sections CHANGELOG concernées (extraites par `ChangelogHelper.sectionsSince`, asset `changelog.md` synchronisé par la tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ; fermable, ne réapparaît pas avant la prochaine mise à jour. - `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ; **carte « Seuils d'alerte » (v1.4.2)** : 4 champs optionnels (E2 haut/bas pg/mL, T haut/bas ng/mL, vide = pas d'alerte), validation haut > bas (refus + message rouge), Save → persiste + **check one-time immédiat** (`AlertNotifier.checkNow`) ; **version installée + lien cliquable vers les releases Gitea** (v1.3.0, `BuildConfig.VERSION_NAME`, `enableEdgeToEdge`-friendly) ; **« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T, v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton « Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ; Export/Import JSON ; à propos - Composants : `CurveChart` (§11), `DateTimeField` (DatePicker+TimePicker Material3, LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester), `LabDialog`, `formatDose()` (top-level, dans `DoseDialog.kt`) - **Insets edge-to-edge (v1.2.5)** : `enableEdgeToEdge()` dans MainActivity ; les insets sont consommés UNE FOIS — TopAppBar M3 (barre de statut, insets par défaut) et NavigationBar (barre système) ; les `Scaffold` (racine HormoneTrackRoot + imbriqués Doses/Labs) ont `contentWindowInsets = 0` pour ne pas cumuler. Avant : fenêtre AppCompat + padding interne TopAppBar = double espace vide en haut. - Thème M3 custom (`ui/theme/Color.kt` : bleu #4F5BD5, rose #D6589E, labs orange, bandeau TransSky/TransPink), dynamic color désactivé ; `MaterialExpressiveTheme` encore **internal** dans la ligne material3 pinnée (cf §3) → `MaterialTheme` standard - ⚠️ `Card(onClick=…)` et `ExposedDropdownMenuBox` = **API expérimentales M3** → `@OptIn` requis sur chaque composable qui les utilise ## 11. Graphiques (CurveChart) Canvas pur (aucune lib), **multi-séries** (v1.2) : `ChartSeries(points, e2Style, tStyle?)` — le ChartScreen superpose les courbes Estrannaise **et** Transfem Science depuis le même traitement (`modelOverride`), styles plein/pointillé par série. Dual axe : E2 gauche (pg/mL), T droite (ng/mL). Échelle « nice » (`niceCeil` : 1/2/2.5/5/10 × 10ⁿ) partagée entre toutes les séries. Grille 4 lignes ; labels Y gauche/droite ; X : pas 6 h/24 h/5 j selon plage (`SimpleDateFormat` HH'h' / dd/MM). Labs : cercles (E2) et carrés (T) orange + valeur, **T convertie en ng/mL** (`convertTToNgMl`) au rendu. Ligne verticale « maintenant ». **Pics/creux** (v1.2.3, valeurs v1.2.6) : triangles ▲▼ via `PharmacokineticEngine.detectExtrema` **accompagnés de la valeur estimée** du pic (au-dessus) ou du creux (en dessous), dans la couleur de la série (`drawExtremum`). **Séries « Tracé labs » (v1.5.0/v1.6.0)** : le ChartScreen peut ajouter les clés "LAB" (partie ANCRÉE — passe par les labs, rose #C2185B dashed) et "LABX" (partie PROLONGÉE — M × ρ_last constant, même rose atténué α 0,55) — cf §7.10 / §7.10.bis pour le moteur et les gardes ; les deux clés sont SKIPPÉES par la boucle de légende (leçon #63) et ont leurs entrées dédiées + avertissement « simulation » quand LABX est visible. **Zoom (v1.2.9)** : pinch **et** boutons − / + — plage temporelle bornée **6 h → 300 j** (`MIN_RANGE_H`/`MAX_RANGE_H`), point focal du pinch maintenu fixe dans le temps (formule centroid), échantillonnage **adaptatif** (`stepForRange` : 15 min ≤ 12 h, 30 min ≤ 24 h, sinon 1 h — courbes lisses à fort zoom) et labels X adaptatifs (1 h / 3 h ajoutés). Le pan ET le zoom partagent un seul `detectTransformGestures` (un handler = pas de conflit de consommation) ; le zoom/pan restent gérés **par le parent**, le Canvas demeure purement déclaratif. **Convention des boutons** (v1.2.10) : « + » = zoom avant (fenêtre courte), « − » = zoom arrière — le sens initial était inversé (retour utilisateur). Pièges : - `DrawScope` implémente `Density` → `X.dp.toPx()` direct ; ne PAS écrire de helper custom - Tout label passe par `drawContext.canvas.nativeCanvas` + `android.graphics.Paint` - Mélange Double/Float interdit (`1 - i / 4f` et pas `/4.0`) - Le panoramique est géré **par le parent** (ChartScreen change `startMs/endMs`), pas par le Canvas — le chart reste un composant purement déclaratif ## 12. i18n FR/EN - Standard Android : `values/strings.xml` (EN défaut) + `values-fr/strings.xml` (FR). L'objet `Strings.kt` custom de la session 1 a été **supprimé**. - **Langue par app** : AppCompat 1.7 + `AppCompatDelegate.setApplicationLocales` (fonctionne < API 33) ; choix persisté DataStore (`system`/`fr`/`en`), appliqué au démarrage. Thème app = `Theme.AppCompat.DayNight.NoActionBar` (requis par AppCompat). - Notifs localisées via `context.getString(R.string.*)` - ⚠️ **Toute nouvelle string = les DEUX fichiers** (une référence manquante = erreur de compilation `Unresolved reference 'active'` — déjà arrivé) ## 13. Sauvegarde JSON `data/backup/BackupManager.kt` : - `BackupData{version=2, exportedAt, treatments[], doseLogs[], labResults[], tConfig, settings?}` → Gson — **v2 (v1.4.2)** : champ optionnel `settings: UserSettings` (champs PLATS : language, autoCalibrate, alertE2High/Low pg/mL, alertTHigh/Low ng/mL). **Rétrocompat** : les backups v1 (sans settings) restent parsables (null) et importables ; l'import ne vérifie pas strictement la version. `changelog_seen_version` volontairement EXCLU (pas une donnée utile à restaurer). ⚠️ R8 : UserSettings est lue par réflexion Gson → `-keep` explicite dans proguard-rules.pro (sinon objets vides en release seule). - **Les IDs Room sont conservés** dans l'export et réinsérés tels quels → les FK dose→traitement restent valides - Import : **mode ÉCRASEMENT (v1.2.6)** — les données actuelles sont effacées AVANT l'insertion (`deleteAllDoseLogs` → `deleteAllLabResults` → `deleteAllTreatments`, ordre enfants → parents pour la FK CASCADE), puis restauration avec les IDs du backup conservés (FK valides). Avant (v1.2.5) : insertion en « ajout » → conflit d'ID dès que l'app contenait des données → l'import échouait (bug remonté). Après un import : le `tConfig` du backup est restauré dans DataStore, **les paramètres utilisateur sont restaurés** (v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est appliquée sur le thread MAIN car `setApplicationLocales` recrée l'activité) et les rappels sont reprogrammés (`rescheduleAll`). Le dialog prévient que TOUT sera remplacé (bouton « Effacer & restaurer ») - Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture `openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}` ### 13.bis Sauvegarde automatique quotidienne (v1.7.0) — `data/backup/AutoBackupWorker.kt` Opt-in (désactivée par défaut), en PLUS de l'export manuel (inchangé). Vue d'ensemble : **dossier choisi UNE FOIS → WorkManager écrit un backup JSON complet chaque jour, garde les N copies les plus récentes.** - **Réglages (DataStore, [AppSettings])** : `autoBackupEnabled` (bool), `autoBackupTreeUri` (tree URI SAF persisté), `autoBackupKeep` (1..30, défaut 7, borné au SET), `autoBackupLastRun` (résultat + horodatage, affiché dans Paramètres). - **Dossier SAF (sans permission de stockage)** : `ACTION_OPEN_DOCUMENT_TREE` → `takePersistableUriPermission(READ|WRITE)` → la permission SURVIT au reboot ; le worker écrit ensuite via [BackupManager.writeAutoBackup] : tree URI → document URI du dossier (DocumentsContract, SANS la lib androidx.documentfile) → `createDocument` d'un NOUVEAU fichier horodaté (`hormonetrack-auto-YYYYMMDD-HHmm.json` — ExportFileNames, signature LocalDateTime obligatoire, règle anti-#48) → écriture via `writeBackup` TEL QUEL (leçon #45 : l'IO éprouvée de l'export manuel) → purge. - **Rétention ([AutoBackupRetention], PUR et testé)** : décision « quels noms supprimer » séparée de l'IO ; seuls les auto-backups RECONNAISSABLES participent (parse de l'horodatage du NOM — la mtime SAF n'est pas fiable sur tous les providers) ; exports manuels, logs et fichiers étrangers du dossier JAMAIS touchés ; noms malformés jamais supprimés ; `keep` clampé ≥ 1. Pourquoi un fichier PAR RUN au lieu d'écraser un fichier fixe : une écriture interrompue (quota cloud, réseau) ne doit jamais détruire la dernière bonne copie. - **Planification ([AutoBackupScheduler])** : `schedulePeriodic` au démarrage (HormoneTrackApp.onCreate, `ExistingPeriodicWorkPolicy.KEEP` — ne repousse pas le prochain run) ; périodique 24 h (survivre au reboot, Doze géré par WorkManager — pas d'alarme exacte nécessaire) ; `runNow` à l'ACTIVATION (feedback immédiat + validation émulateur triviale). Le worker lit ses options dans DataStore à CHAQUE exécution : changer dossier/copies ne requiert aucun re-enqueue ; option désactivée → no-op immédiat (pattern AlertWorker sans seuils). - **Contenu** : [BackupManager.exportJson] complet (format v2, paramètres inclus) → un auto-backup est IMPORTABLE tel quel (même dialog que l'export manuel). - **R8 (leçon #64)** : `-keep class ...AutoBackupWorker { *; }` — le worker est instancié par WorkManager via réflexion. - **Écran Paramètres** : switch (ON sans dossier → ouvre le picker, la sélection active + premier run), nom du dossier lisible (`folderDisplayName` : « primary:Download » → « Download »), copies conservées (Save borné), statut dernier run (OK/ÉCHEC + date). - 9 nouveaux tests (200 verts). ## 14. Bugs corrigés Historique complet — **à ne pas réintroduire** (utile pour diff/revert) : **Session 1 → 2 (avant tout build) :** 1. `settings.gradle.kts` : `dependencyResolution` (inexistant) → `dependencyResolutionManagement` 2. `BootReceiver` : `runBlocking { flow.collect {…} }` → blocage infini → one-shot + goAsync 3. `AlarmScheduler.cancel` : Intent sans l'action → annulation inopérante → fabrique unique 4. `PKProfileStore` : parsait la racine JSON → crash → lecture de `profiles` **Session build (détectés à la compilation) :** 5. `kotlin.math.ln2` **n'existe pas** (hallucination) → `ln(2.0)` ; cascade d'erreurs sur les lignes suivantes du même fichier (opérateurs sur types error) 6. Mélange **Double/Float** interdit en Kotlin : `mg * bioavailabilityFraction` (Float), `30.0 * t½` (Float), `Float×exp()`… → `.toDouble()` partout 7. `BackupManager.writeBackup` : `return` dans expression body `= try{}` → block body 8. `DateTimeField` : `spacedBy(8f/2f*8)` (Float sans unité) → `8.dp` + import `dp` manquant 9. `DateTimeField` : extension `fun LocalDate.Companion.ofEpochMs` (java.time n'a pas de Companion) → supprimée ; imports nettoyés 10. `CurveChart` : helper `dpToPx()` custom cassé → `dp.toPx()` de `DrawScope` 11. `CurveChart` : labels Y en Double (`i / 4.0`) → `i / 4f` 12. `LabsScreen` / `TreatmentsScreen` : imports `dp` / `fillMaxWidth` manquants 13. `TreatmentEditorScreen` : `R.string.active` inexistante → string ajoutée EN+FR 14. `TreatmentCard` : `Card(onClick=…)` sans `@OptIn(ExperimentalMaterial3Api::class)` 15. Typo `Locale.getDefault` sans parenthèses (SimpleDateFormat) 16. `SettingsScreen` : chips de langue codées en dur → état depuis DataStore 17. `ReminderManager` : constantes d'action mortes → implémentées (`DoseActionReceiver`) 18. `TreatmentEditorScreen` : `createdAt` écrasé à l'édition → préservé **Session tests (bugs SÉMANTIQUES trouvés par les tests unitaires) :** 19. **`computeKa` : bisection inversée** — `if (eq > 0) hi = mid else lo = mid` convergeait vers ka énorme (pic à ~0 h au lieu de Tmax) ; bug présent depuis la session 1, jamais testé. → `if (eq > 0) lo = mid else hi = mid` (eq décroît en mid ; eq>0 ⇒ racine au-dessus) 20. **Plancher d'affichage des profils** : l'ODS arrondit à 2 décimales → queues à 0,01/0,00 ; extrapoler depuis la fin de table donnait 0 à vie (ou une constante plate). → extrapolation depuis le dernier point ≥ 1 % du pic avec pente sur 48 h 21. **Mapping silencieux du modèle** : `profileKey` mappe tout modèle ≠ "TFS" sur "ese" → `sample("EV","XXX")` renvoyait EV_ese. → validation stricte dans `sample` **Session v1.1.0 (remontées par l'utilisatrice, reproduites en test) :** 22. **Casse des clés de profils** — LE bug « les graphiques ne se génèrent pas » : l'asset contient `"EEn_ese"`/`"EEn_tfs"` (casing biologique du `.ods`) mais `Esters.EEN = "EEN"` → lookup exact null → `sample()=0` pour tout traitement EEn (courbe E2 plate à 0, T plate à la base). EV/EU marchaient (casse identique) et les tests profils utilisaient la casse "EEn" — le trou passait entre les deux. → **lookup insensible à la casse** (`PKProfileStore.lookup()`), régression épinglée sur les données réelles (`RegressionUserCaseTest`). 23. **Unités T non converties** : labs saisis en ng/dL (32/45) → axe T du chart à ×100 (courbe T invisible) et calibration T fausse. → `PharmacokineticEngine.convertTToNgMl()` (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884), appliqué à la calibration ; **à utiliser aussi au rendu du chart** pour les dots T (cf §11 — patch UI restant : convertir les valeurs T des labs avant yT()). 24. **Pas d'édition des doses** : suppression+recréation obligatoire. → `DoseDialog` create/edit (préfill, changement de traitement, date/heure, notes, **override d'ester par injection**), appelé depuis DosesScreen (tap sur la ligne) ; `LogDoseDialog` supprimé (attention : `formatDose` vivait dedans → déplacée top-level dans `DoseDialog.kt`). **Session v1.2.1/v1.2.2 (remontées utilisateur) :** 25. **Calibration mélangeant les périodes d'ester** — le fond du « 250–375 » : un facteur unique par traitement comparait des labs valerate à des prédictions enanthate → ratios aberrants → courbes gonflées. → calibration **par période** (`computeEsterScaleFactors` + `scalePerEster`, cf §7.6) ; vérifié que l'état d'équilibre EEn (accumulation ×2) est CORRECT — le « 375 » = calcul non calibré. 26. **Lab T en unité aberrante (« 38 pg/mL », faute de frappe)** : renvoyé brut il écrasait l'axe T et rendait la courbe T invisible. → `convertTToNgMl` avec branche défensive pg/mL ÷1000 (et µg/L). 27. **Pas d'édition des labs** → LabDialog create/edit + tap sur la ligne ; formatLabValue (préserve les décimales, contrairement à formatDose). **Session v1.2.3/push Gitea :** 28. **Commentaires Kotlin imbriqués** : `/**TFS**` dans un KDoc ouvre un bloc `/**` JAMAIS fermé (Kotlin les imbrique !) → « Unclosed comment » → NE PAS mettre `/**` dans un texte de doc ; écrire « EEn + TFS ». 29. **XML de test périmés après échec de compilation** : quand compile échoue, les anciens XML test-results restent → on « corrige » des échecs fantômes. Toujours vérifier que la COMPILATION passe (grep `^e:` du log) avant d'analyser les résultats de tests. 30. **filter-branch non idempotent** : le scrub (45.0→44.0) a corrompu une constante d'un test PUR écrit après coup (assert 0.45 vs 44/100). Règle : les remplacements d'anonymisation doivent être idempotents (les valeurs de remplacement ne re-matchent jamais les motifs) et les tests purs doivent utiliser des constantes hors des motifs de scrub. 31. **Release script : préfixe « v »** — le tag git = `v1.2.3`, la CHANGELOG titre `[1.2.3]` : l'extraction cherchait `[vX.Y.Z]` → fallback silencieux. → `scripts/gitea-release.py` (corps = section CHANGELOG, APK attaché). 34. **Import JSON impossible si l'app contient déjà des données** (v1.2.6) : l'insertion en « ajout » avec les IDs du backup entrait en conflit d'unicité dès la moindre donnée existante → import échouait. → mode ÉCRASEMENT (effacement ordonné enfants→parents puis restauration) + restauration du tConfig + reprogrammation des rappels. 37. **Upload d'asset Gitea instable** (v1.2.10) : le `?name=` du POST et le PATCH de renommage peuvent être ignorés (asset au nom générique, APK release disparu de la release). → `ensure_asset` dans le script : upload + vérification nom/taille + retry PATCH + échec bruyant. 42. **Dialog « Nouveautés » à chaque réouverture** (v1.3.2, remontée) : la version vue était mémorisée APRÈS l'affichage — si l'app est fermée pendant que le dialog est ouvert, DataStore n'avait pas encore flushé → même changelog ressortait. → mémoriser la version vue AVANT d'afficher (l'affichage est purement visuel, la mémoire est déjà faite). Titre = BuildConfig.VERSION_NAME (version de L'APP) : si l'APK installé est v1.3.0, le titre montre 1.3.0 même si le contenu parle d'une version plus récente — installer le dernier APK. 40. **Toggle agenda toujours inopérant** (v1.3.2, remontée) : le FIX v1.3.1 (callback async) était correct mais insuffisant : (a) le switch s'activait même SANS Posologie (l'événement RRULE aurait un INTERVAL invalide) → désormais refusé avec feedback rouge ; (b) au save, la permission était vérifiée via le flag `remembered` (peut être faux après recomposition) → vérification RÉELLE via ContextCompat ; (c) AppLog journalise chaque étape (permission demandée/accordée, upsert, delete) pour le debug à distance. 39. **Toggle de l'agenda inopérant** (v1.3.1, remontée) : le résultat de la permission arrive ASYNCHRONE — le test synchrone juste après `launch()` était toujours faux → le switch rebondissait sans s'activer, sans feedback. → le CALLBACK active le switch ; journalisation AppLog ; leçon : ne jamais lire un état de permission juste après launch(). 41. **Export des logs plantait l'app** (v1.3.2, remontée) : le launcher CreateDocument avec écriture inline dans une coroutine IO non couverte par le même pattern que l'export JSON. → réutilise BackupManager (openOutputStream "wt") + journalisation AppLog à chaque étape. Leçon : quand un pattern d'IO marche (export JSON), le réutiliser tel quel plutôt que d'en écrire un nouveau. 38. **Piège lexicographique de tags/versions** (v1.3.0, publication farewell) : `tag >= "v1.2.5"` en comparaison de CHAÎNES fait `v1.2.10 < v1.2.5` ("1" < "5") → la boucle de publication a sauté v1.2.10. → toujours comparer les versions STRUCTURÉEMENT (tuple numérique, cf ChangelogHelper.isVersionNewer) ; même famille que le bug #22 (casse EEn) : identifiants « presque pareils ». (Numéro corrigé en v1.3.3 : l'entrée était dupliquée sous « #41 » alors que §2/§16 référencent #38.) 43. **Course entre uploads rapprochés** (v1.2.6→v1.3.2, confirmé 4 fois) : publier les 2 APK par DEUX INVOCATIONS rapprochées de gitea-release.py → le second upload écrase le premier (APK release disparu de la release). → `scripts/publish-release.py` : UNE invocation fait purge + upload des 2 APK + vérification PAR TÉLÉCHARGEMENT de chacun (§16.bis). 36. **Sens des boutons de zoom inversé** (v1.2.10, retour utilisateur) : le « + » dézoomait (fenêtre plus longue) et le « − » zoomait — contre la convention carte. → « + » = zoom avant (fenêtre courte), « − » = zoom arrière. Le pinch (écarter = zoom avant) était déjà correct. 35. **Prévision cassée après un oubli d'injection** (v1.2.7) : le premier créneau projeté tombait dans le passé (dernière + intervalle = jour manqué) → faux pic dans l'historique + rythme décalé. → les créneaux passés sont sautés (`while (t <= nowMs) t += intervalMs`), la prévision démarre au premier créneau futur ; un retard décale toute la prévision (comportement voulu, testé). NB : la doc §7.3b affirmait « strictement après nowMs » alors que le code incluait les créneaux passés — mismatch doc-code détecté par l'utilisatrice. 33. **Double insets edge-to-edge** (v1.2.5) : espace vide en haut de l'écran — la fenêtre AppCompat poussait le contenu sous la barre de statut ET les TopAppBar M3 rajoutaient leur padding interne. → `enableEdgeToEdge()` + `contentWindowInsets = 0` sur les Scaffold, insets consommés une seule fois. 32. **`isActive` traité comme filtre de données** (v1.2.4) : passer un traitement à inactif effaçait sa simulation du graphique (écrans collectaient `activeTreatments`) tout en permettant d'y loger des doses. → sémantique documentée §6.bis : l'inactivation ne touche que la SAISIE (chips, dropdown création, rappels) ; simulation et calibration reçoivent TOUS les traitements. Épinglé par la régression n°3. **Session v1.3.3 (audit de reprise de maintenance — session IA démarrant sans aucun contexte, dans l'esprit de cette doc) :** 44. **Permissions agenda absentes du manifest** (v1.3.0 → v1.3.2) : la fonctionnalité « événement d'agenda récurrent » ne pouvait JAMAIS fonctionner — `WRITE_CALENDAR`/`READ_CALENDAR` n'étaient **pas déclarées dans AndroidManifest.xml** (la demande limitée à l'exécution est refusée d'office sans déclaration, et `CalendarEvents.ensureCalendar/upsert` lèvent SecurityException). La doc §9 les listait pourtant depuis v1.3.0 → leçon : « documenté » ≠ « implémenté » ; toute affirmation « le manifest contient X » doit être vérifiée dans le XML réel (30 s de `grep uses-permission`). Les fixes v1.3.1/v1.3.2 du « toggle agenda » réparaient la logique UI mais pas la cause racine. FIX : déclaration + commentaire dans le manifest. 45. **Export des logs plantait TOUJOURS l'app** (le « fix » v1.3.2, #41 plus haut, était insuffisant) : l'IO restait réimplémentée inline (leçon du #41 : réutiliser BackupManager — PAS appliquée) et 3 appels `AppLog.log` du callback étaient HORS try/catch — or une exception non interceptée dans une coroutine à scope racine (`CoroutineScope (Dispatchers.IO)`) remonte au handler de la thread = crash du process. FIX : (a) l'export réutilise `BackupManager.writeBackup` TEL QUEL — le même code path que l'export JSON qui marche sur le Pixel 9 /e/OS ; (b) tout le callback est gardé ; (c) `AppLog.log` est fail-safe (l'IO fichier est avalée dans le wrapper — une erreur disque ne doit jamais tuer l'app pour une ligne de journal) ; (d) feedback visible succès/échec + `logLineCount` rafraîchi (sur Main). 46. **Bump de version jamais commité** (v1.3.0 → v1.3.2) : les tags v1.3.0/v1.3.1/v1.3.2 contenaient TOUS `versionCode = 14` / `versionName = "1.3.0"` → `BuildConfig.VERSION_NAME` était faux dans les APK publiés (Paramètres, titre du dialog « Nouveautés », comparaison `isVersionNewer`). La checklist §16 étape 1 existait mais n'a jamais été appliquée. FIX : versionCode 17 / « 1.3.3 » commité AVANT le tag ; le bump fait partie du commit de release (jamais un état local non commité au moment du build). **Session v1.3.4 (deux crashs remontés → reproduits sur émulateur) :** 47. **L'écran Doses crashait l'app** (`MissingFormatArgumentException: Format specifier '%3$d'`) : la string `hrt_duration` a TROIS placeholders (`%1$d mois, %2$d jours, %3$d total`) mais `stringResource(R.string.hrt_duration, months, days)` ne passait que DEUX arguments. Crash dès que le fragment `totalDays > 0` s'affiche (donc pour TOUTE donnée antérieure à aujourd'hui) — né en v1.3.1 (string + appel dans le même commit fa5d2df, jamais testés ensemble) et passé à travers v1.3.1→v1.3.3. 48. **Le bouton « Exporter » des logs crashait l'app** — LE bug récurrent des v1.3.1→v1.3.3 : le nom de fichier était construit INLINE via `LocalDate.now().format(ofPattern("yyyyMMdd-HHmm"))`. Un `LocalDate` n'a PAS de champ horaire → `UnsupportedTemporalTypeException: Unsupported field: HourOfDay` levée dans le onClick (thread principal, synchrone, touch dispatch) → crash au tap, avant même le sélecteur SAF (stack obtenue sur émulateur : LTSI + frames R8 obfusquées). L'export JSON marchait car `yyyyMMdd` est un pattern valide pour LocalDate. Zones d'ombre emboîtées : v1.3.2 et v1.3.3 « corrigent » l'IO (cf #41/#45) sans voir cette ligne. FIX : `util/ExportFileNames.kt` (pur, testé, documenté — couplage type↔pattern centralisé) ; `FileNamesTest` épinglant les deux helpers (aurait attrapé le bug le jour même). Leçon : la génération de nom de fichier ne doit JAMAIS vivre inline dans un onClick. 49. **StringFormatMatches** (capture du lint, v1.3.4) : la notification de rappel passait un Double à `%s`. Fix `dose.toString()` (l'affichage ne change pas). L'intérêt des règles lint est confirmé PAR CETTE SESSION : `./gradlew lint` aurait signalé #47 dès v1.3.1 → le lint fait désormais partie de la vérification avant release (les checks Compose 1.12+ théoriques `NonObservableLocale`/`LocalContextGetResourceValueCall` sont tombés en warning via `app/lint.xml`, cf §19). **Session v1.3.5 (diagnostic à distance via les logs exportés — la boucle par l'exemple) :** 50. **Création du calendrier d'agenda impossible** (`IllegalArgumentException: Sync adapters must specify an account and account type`, remontée par les AppLog exportés du Pixel 9 v1.3.4) : `CalendarEvents.ensureCalendar` construisait l'URI calendars avec `CALLER_IS_SYNCADAPTER=true` SANS `ACCOUNT_NAME`+`ACCOUNT_TYPE` — requis par le CalendarProvider pour TOUTE opération sync-adapter (requête ET insertion). De plus : - les mêmes logs CONFIRMAIENT au passage les fixes précédents : export logs `ok=true` (#48 ✓ depuis v1.3.4 au téléphone), permission agenda accordée (manifest v1.3.3 ✓) — AppLog fait son job de diagnostic à distance ; - le log « événement agenda supprimé » était imprimé aussi quand le switch était inéligible (posologie absente, permission absente, switch off) → trois messages distincts désormais (créé-mis à jour / supprimé / non activé) ; - contexte : les « événement agenda supprimé » répétés dans les logs = des saves où le switch ne remplissait pas les conditions d'activation (l'utilisatrice avait retourné le switch après les échecs) — les trois messages distincts rendront les prochains logs univoques. FIX : append ACCOUNT_NAME/ACCOUNT_TYPE sur l'URI ; le pattern complet : `syncadapter=true` + account + account_type sur les URIs de CRÉATION (calendriers) ; l'insertion d'EVENTS l'avait déjà (upsertEvent). Validé émulateur : `content query --uri content://com.android.calendar/calendars` montre le calendrier, events avec RRULE corrects. 50.bis. **Delete d'événement silencieusement manqué** (cité au #50) : le même défaut (CALLER_IS_SYNCADAPTER sans account) sur l'URI de DELETE de `deleteEvent` — l'exception est avalée par le catch, le log dit « supprimé » mais l'event reste dans le provider. FIX : mêmes params sur l'URI. Règle : TOUT URI avec CALLER_IS_SYNCADAPTER doit embarquer account+account_type — pas seulement les insertions. 51. **Événement orphelin après re-save** (découvert en validant #50 sur émulateur — le provider montrait un event que la base ne référençait plus) : `buildTreatment()` reconstruisait le Treatment SANS le champ `calendarEventId` → null à chaque save → l'event existant devenait orphelin dès la deuxième sauvegarde, plus jamais désactivable. FIX : `loadedCalendarEventId` chargé dans le LaunchedEffect + renvoyé par buildTreatment(). Leçon : la reconstruction complete d'une entité pour un save doit repartir des champs NON ÉDITABLES (voir `loadedCreatedAt`, déjà protégé, et désormais `loadedCalendarEventId`). **Session v1.4.0 (modèle TFS sur la méta-analyse + fix #52) :** 62. **Pan mort sur la vue 24 h** (v1.4.10, remontée par test utilisateur Pixel 9 : « le glisser horizontal ne marche pas sur 24 h, uniquement au-dessus ») : le pan incrémental tronquait CHAQUE delta en heures entières (`(pan.x / largeur * plage).toLong()`) — à 24 h, le delta d'un doigt réel (~30 px ≈ 0,67 h) tombait à 0 à CHAQUE événement → le pan ne bougeait jamais sur 24 h (sur 7 j : le même delta = 4,7 h). FIX : cumul fractionnaire (`panDeltaHours` — le résiduel < 1 h est conservé d'un événement au suivant, pur et testé). Leçon : un cast en entiers par delta incrémental tue les mouvements fins — cumuler le résiduel fractionnaire. ⚠️ Le pan n'est PAS testable par adb sur l'émulateur headless (les `input swipe` ne délivrent pas les mouvements intermédiaires au chart) — le test humain sur Pixel 9 reste la référence pour les gestes (§16.ter). 61. **Calibration tombant à ×2,21 (auto ET manuelle)** (v1.4.9, remontée « le bouton Calibrer avec les analyses du traitement EEn WHSAH me met toujours à ×2,21 ») : reproduction par test sur données réelles — l'export contenait une dose EEn de test de JANVIER (10 mg) ; les labs de janvier-mars tombaient 15–76 j après, avec une prédiction WHSAH résiduelle (184 → 1 pg/mL) → ratios aberrants (×3,3 → ×391) → la MÉDIANE des ratios tombait à ×2,21, dans les DEUX pipelines (manuel ET auto — même défaut de frontière). FIX : garde de significativité `labIsSignificant` (prédiction ≥ 15 % du max observé, labs par timestamp croissant) appliquée aux 3 pipelines ; facteur WHSAH réel = 0,55 (épinglé). Leçon : une garde de significativité RELATIVE (le lab ne calibre que ce que le modèle « fait » encore) plutôt qu'une fenêtre temporelle fixe — les tests existants (labs à 2-7 j, t½ courte) auraient été cassés par une fenêtre en jours. 59. **« EEN Estrannaise » pour un traitement sous WHSAH** (v1.4.7, remontée) : le chip de `TreatmentsScreen` affichait le modèle via un ternaire à 2 branches (`if (pkModel == TFS) model_tfs else model_ese`) → WHSAH tombait dans le `else`. Fix : helper centralisé `modelLabelRes(pkModel)` (data/model/Treatment.kt) — UNIQUE source du label. Leçon : quand un 3ᵉ choix est ajouté à un champ, GREPPER tous les points d'affichage existants (l'éditeur et le chart avaient été fixés en v1.4.6, la carte de traitement avait été ratée) — l'audit de `grep -rn "model_tfs else"` est passé à côté du pattern retourné multi-ligne ; le vrai filet : chercher les USAGES du champ (`tr.pkModel`) et vérifier chaque site. 57. **Écran Graphiques : rangée des toggles coupée sous le pli** (v1.4.6, découvert en validant WHSAH sur émulateur : le chip était absent du dump UI) : le Column du ChartScreen ne défilait pas — les toggles de modèles (et leur LÉGENDE) étaient invisibles sur un téléphone standard. FIX : écran à défilement vertical. Leçon : un ajout de rangée dans un écran non-scrollable coupe ce qui suit — tester tout nouvel élément UI par dump émulateur, pas seulement le code. 55. **« La prévision simule le 13 au lieu du 12 »** (v1.4.5, remontée) : diagnostic sur les données réelles (régression #4) : la GÉNÉRATION est exacte (créneau = dernière dose LOGUÉE + intervalle en ms exact) — l'illusion venait de trois défauts d'AFFICHAGE : aucun marqueur des doses simulées, labels X alignés sur minuit UTC (= 02:00 FR), courbe encore descendante au créneau (physiologie EEn). FIX : marqueurs (prévisionnels pointillés + réels discrets), `xLabelTicks` à minuit LOCAL dans le fuseau CHOISI (Paramètres, null = téléphone). ⚠️ Subtilité : une dose loguée le LENDENDE de l'injection réelle décale le rythme d'un jour par cycle — l'app suit les LOGS ; recadrer = éditer la dose à son instant réel. 56. **Boucle de recomposition saturant le main thread** (v1.4.5, découvert en validant #55 : les chips ne répondaient plus, « Skipped 52 frames » en continu) : `nowMs = System.currentTimeMillis()` relu frais à CHAQUE recomposition → endMs dérive → les keys du producer (endMs) changent à chaque frame → re-calcul perpétuel. FIX : nowMs mémoïsé sur le tick minute (`remember(tick)`) ; le calcul des créneaux prévisionnels mémoïsé aussi. Leçon : JAMAIS de `System.currentTimeMillis()` nu dans un corps de composable dont il dérive des keys de producer — le mémoïser sur un tick. 54. **Prévision inaccessible : captures figées dans `pointerInput(Unit)`** (v1.4.4, remontée « le toggle activé ne génère pas les prévisions, je ne peux même pas dragger vers la gauche ») : la closure du gestionnaire de gestes est créée UNE FOIS (`pointerInput(Unit)`) ; les `val` calculées de la composition (`futurePanHorizon`, `maxPanHours`, `forecastExtensionH`) y étaient FIGÉES à leur valeur initiale (0, chip désactivé) → le clamp interdisait le pan vers le futur POUR TOUJOURS, même après activation du chip. Fix : `rememberUpdatedState` (les `MutableState` n'ont pas le problème : c'est la fermeture `by` qui lit à jour). + cause secondaire : `generateForecastDoses(toMs = endMs = now)` ne générait rien → extension contrôlée à l'activation (cf §10). **Garde de source** : `ChartScreenSourceGuardTest` (extraire la closure et interdire les lectures des vals brutes). Leçon : dans `pointerInput(Unit)`, TOUTE val calculée lue par la closure doit passer par `rememberUpdatedState` — seul `pointerInput(key)` re-keyé relance la closure (et perd les gestes en cours). 53. **Delta « vs il y a 6 h » = en réalité 24 h** (v1.4.3, remontée) : `NowLevelCard` prenait `curve.firstOrNull { écart ≥ 6 h }` sur une fenêtre de 24 h — le premier point satisfait la condition immédiatement → comparaison à ~24 h. Le lendemain d'une injection EEn (plateau), ce delta 24 h peut être légèrement négatif → fausse impression que « l'injection ne fait rien ». FIX : `pointHoursBefore` (point au plus petit écart ≥ 6 h, testé). Leçon : un `firstOrNull` sur une condition de distance trouve l'élément le PLUS LOIN, jamais le plus proche — écrire l'intention (« le plus proche de N ») explicitement. 52. **Rappels quotidiens même hors jour d'injection** (remontée utilisateur : « je m'injecte tous les samedis à 18 h → je dois recevoir le rappel uniquement le samedi à 18 h ») : `nextReminderFireMs` ignorait doses et Posologie → l'alarme se ré-armait chaque jour à HH:mm. FIX : le prochain déclenchement suit la GRILLE (dernière dose + k × intervalle, créneaux passés sautés — même sémantique que la prévision) à l'heure de rappel choisie ; fallback quotidien sans Posologie/sans doses ; **et** la chaîne one-shot est recousue après notif ET après « Pris » (avant : seulement au boot/save). Leçon : toute logique de scheduling doit vivre au MOTEUR (testable JVM), pas dans les composants Android. 63. **Légende du graphique dupliquée + chip coupé hors fenêtre** (v1.5.0, constat émulateur §16.ter) : l'ajout de la série « Tracé labs » (clé `"LAB"` dans la liste `curves`) tombait dans le `else` de la boucle de légende → la légende « E2/T Transfem Science » s'imprimait une 2ᵉ fois ; et le 4ᵉ chip de la rangée modèles sortait de la fenêtre sur écran 1080 px (rangée à largeur fixe, absent du dump uiautomator — même famille que #57 : un ajout de rangée doit être testé par dump, pas seulement par le code). FIX : (a) branche explicite `"LAB" -> {}` dans la boucle de légende (légende dédiée plus bas) ; (b) `horizontalScroll(rememberScrollState())` CONFINÉ à la RANGÉE de chips (le pan/pinch du graphique reste sur le Card — le fix #62 visait un scroll VERTICAL parent ; ici le scroll est local, pas de conflit). Leçon : quand une liste clé→série reçoit une nouvelle clé, GREPPER chaque consommateur de la liste (légende, stats, styles). **Leçons** : (a) ne jamais croire un build « probablement bon » sans l'avoir lancé ; (b) les tests sémantiques attrapent ce que la compilation ne voit pas ; (c) se méfier des constantes stdlib « de mémoire » (`ln2`), des mélanges Float/Double, et des APIs M3 expérimentales sans `@OptIn` ; (d) **un test de régression sur les VRAIES données utilisateur** attrape les bugs de convention (casse, unités) que les tests synthétiques ratent — mais garde ces données **hors du dépôt** (§8.bis) ; (e) attention aux identifiants « presque pareils » entre sources (constantes app vs clés d'asset) ; (f) après un échec de COMPILATION, jeter les résultats de tests de la même passe (XML périmés) ; (g) tout script de réécriture d'historique doit être idempotent ; (h) dans un KDoc, `/**` imbrique ; (i) quand un pattern d'IO marche (export JSON), le réutiliser TEL QUEL — une réimplémentation « équivalente » perd les garde-fous acquis à l'usage (cf #45) ; (j) vérifier les POSTULATS dans le code réel, pas dans la doc (« le manifest contient WRITE_CALENDAR », cf #44) ; (k) un bump de version non commité = métadonnées fausses dans les APK publiés (cf #46) — le bump fait partie du commit de release ; (l) le scan de confidentialité s'ADAPTE automatiquement aux nouveaux exports (`local-test-data/`) : un historique « propre hier » peut devenir hit dès qu'un nouvel export introduit des motifs qui collent — re-scanner PUIS juger avec l'utilisatrice (dette déjà jugée, cf §8.bis) ; (m) **un crash sans stack trace = d'abord le reproduire** (émulateur + données réelles, §16.ter) — les deux « fixes » aveugles de v1.3.2/v1.3.3 (#41/#45) ont laissé passer un bug trivial (#48) seulement visible sur l'émulateur ; (n) `./gradlew lint` fait désormais partie de la vérification avant release : `StringFormatMatches` aurait signalé #47 dès v1.3.1 ; (o) **les tests unitaires JVM ne voient JAMAIS la minification R8 — la recette §16.ter sur l'APK RELEASE est la seule filet** (bug #64 : feature parfaite en debug, vide en release) ; (p) quand une classe pure disparaît du mapping R8 (`R8$$REMOVED$$CLASS`), la garder explicitement dans proguard-rules.pro plutôt que débattre avec l'optimiseur. ### #64 (v1.6.0) — « Tracé labs » vide EN RELEASE SEULE (R8 full mode) **Symptôme** (émulateur §16.ter, APK release v1.6.0, données réelles seedées) : le chip « Lab track » s'active MAIS la courbe n'est ni dessinée (0 pixel #C2185B au screencap) ni légendée ; « Prolonger » idem. En DEBUG, le même chip sur les mêmes données fonctionne (légende + 7090 px rose après fix). **Les 191 tests JVM sont verts** (ils ne passent JAMAIS par R8). **Diagnostic** (méthode de bisection debug/release puis `mapping.txt`) : `com.hormonetrack.pk.LabTrajectoryModel -> R8$$REMOVED$$CLASS$$…` — R8 full mode a SUPPRIMÉ la classe en inlinant `computeLabAnchoredCurve` (et son synthétique `computeLabAnchoredCurve$default` qui gère les paramètres par défaut) dans le call site du producer Compose ; après fusion, le calcul renvoyait une courbe vide. Les courbes des modèles (même moteur e2At, même fichier) fonctionnaient — seul le chemin inliné cassait. **FIX** : `-keep class com.hormonetrack.pk.LabTrajectoryModel { *; }` dans proguard-rules.pro (garde commentée + renvoi au mapping). Validé émulateur sur l'APK release re-buildé : ancrée (courbe + légende), prolongée (légende + avertissement), 0 crash. ## 15. Comment régénérer l'asset pk_profiles.json Si le `.ods` change (re-fits, nouveaux esters) : ```python # python3 stdlib only : # 1. zipfile.ZipFile(ods).read("content.xml") # 2. ElementTree (ns table/office/text) → table "Models" # 3. lignes 1-4 = D, k1, k2, k3 (colonnes EV/EU/EEn ese + tfs) — informatif, non utilisé # 4. lignes 5+ = profils horaires (00:00 … 8000:00), décimaux FR "61,12" → float # 5. json.dump({"params": …, "profiles": {"EV_ese": [8001], "EU_ese": …, "EEn_ese": …, # "EV_tfs": …, "EU_tfs": …, "EEn_tfs": …}}) # 6. cp vers app/src/main/assets/pk_profiles.json # 7. vérifier : 6 clés × 8001 valeurs, pics == référence (§7.1) ; les tests le vérifient ``` Le script de la session 1 a été exécuté inline (non archivé) — le refaire depuis la structure ci-dessus. **Toute restructuration du JSON impose de mettre à jour `PKProfileStore.initWithJson`.** ## 16. Workflow build / test / install / git ```bash cd ~/projects/HormoneTrack ./gradlew assembleDebug testDebugUnitTest # build + 87 tests (44 sans données locales) ./gradlew assembleRelease # APK optimisé R8 (cf §16.bis) adb install -r app/build/outputs/apk/debug/app-debug.apk ``` **Git (initialisé le 2026-09-05, branche `main`, DEUX remotes Gitea)** : - `origin` → `https://gitea.cloudyfy.fr/Siphonight/HormoneTrack` (privé, HTTPS + trousseau) - `farewell` → `git@farewell:Siphonight/HormoneTrack.git` (SSH, alias `farewell` = `giteassh.farewell.dev:2222` avec clé dédiée, cf `~/.ssh/config`) — **repo créé + push + LES 12 RELEASES publiées avec APK vérifiés par téléchargement (2026-09-06)** ; token `gitea.farewell.dev` au trousseau (scope write:repository) - Historique = commits logiques par couche (toolchain / moteur / UI / docs) ; - **Chaque release = tag annoté** (`v1.1.0`, `v1.2.0`, …) : `git tag -a vX.Y.Z -m "…" && git tag` pour lister ; - `local.properties`, `build/`, `.gradle/`, `.idea/`, **`local-test-data/`** sont ignorés (`.gitignore`) — ce dernier protège les données de santé de test ; - Avant chaque commit de release : `./gradlew testDebugUnitTest` doit être vert ; - Push : `git push origin main --tags` + `git push farewell main --tags` (farewell : auth SSH par clé dédiée ; le push-to-create y est DÉSACTIVÉ → le repo doit exister au préalable sur l'instance). ⚠️ Tokens Gitea : scope `write:repository` suffit pour push, releases et assets ; il NE permet PAS de créer un repo via API (il faut `write:user`) ni d'utiliser push-to-create. ### 16.bis Releases Gitea avec APK téléchargeable Les releases Gitea embarquent l'APK de chaque version — les utilisateurs n'ont pas besoin de compiler. **Le corps de chaque release = la section CHANGELOG de la version** (markdown rendu par Gitea), extrait automatiquement. Le tout est automatisé par `scripts/gitea-release.py` — **multi-instances** (`cloudyfy` par défaut, `farewell`) : ```bash # 0. Le CHANGELOG est embarqué dans l'app (dialog « Nouveautés ») : # la tâche Gradle copyChangelog copie docs/CHANGELOG.md vers # src/main/assets/changelog.md à chaque build (gitignoré, auto) # 1. Construire les DEUX APK au niveau du tag (debug + release R8) git checkout vX.Y.Z ./gradlew assembleRelease assembleDebug # release = recommandé (R8, cf plus bas) cp app/build/outputs/apk/release/app-release.apk /tmp/apks/HormoneTrack-vX.Y.Z-release.apk cp app/build/outputs/apk/debug/app-debug.apk /tmp/apks/HormoneTrack-vX.Y.Z-debug.apk git checkout main # 2. Publier sur chaque instance (corps = CHANGELOG + les 2 APK attachés) # ⚠️ NE JAMAIS comparer les tags en chaînes (v1.2.10 < v1.2.5 # lexicographiquement !) — comparer en tuples numériques (§14 #38) # ⚠️ NE JAMAIS utiliser gitea-release.py en deux invocations rapprochées # (les uploads se remplacent mutuellement, confirmé 4 fois) : # → UNE invocation de **scripts/publish-release.py** par instance fait # tout (purge + upload des 2 APK + vérification par téléchargement). python3 scripts/publish-release.py cloudyfy vX.Y.Z python3 scripts/publish-release.py farewell vX.Y.Z ``` Le script choisit l'instance (URL, owner) et lit le token Gitea correspondant dans le trousseau macOS (`security find-internet-password -s -w`) : `gitea.cloudyfy.fr` ✓ présent ; `gitea.farewell.dev` → à ajouter : `security add-internet-password -s gitea.farewell.dev -a Siphonight -w -U`. Le script : - extrait la section `## [X.Y.Z]` de `docs/CHANGELOG.md` comme corps ; ⚠️ piège : le tag git porte le « v » (`v1.2.3`) mais la CHANGELOG non (`[1.2.3]`) — première version du script cherchait `[vX.Y.Z]` et tombait sur le fallback « Voir docs/CHANGELOG.md » ; - crée la release si absente, sinon **met à jour le corps** (PATCH) ; - attache l'APK (remplace l'asset du même nom si présent) — appeler le script DEUX FOIS pour publier les deux APK (noms distincts) ; ⚠️ vérification intégrée (`ensure_asset`) : le `?name=` de l'upload et/ou le PATCH peuvent être ignorés par les instances (assets au nom générique / APK disparu — v1.2.6, v1.2.10) → le script vérifie **nom ET taille** après chaque upload, retente le PATCH une fois, et **échoue bruyamment** si l'asset ne colle pas — ne pas retirer ; - **Releases antérieures à v1.2.5 = APK debug unique** : backfill des APK release abandonné (best effort) — les anciens `build.gradle.kts` sortaient un `app-release-unsigned.apk` non signé (pas de signing config à l'époque). Les utilisateurs prennent la dernière version. `HormoneTrack-vX.Y.Z-release.apk` (**recommandé**, R8) et `HormoneTrack-vX.Y.Z-debug.apk` ; - lit le token Gitea dans le trousseau macOS (`security find-internet-password`) ; scope requis : `write:repository` (suffit pour releases + assets, pas pour créer un repo — cf plus haut). **Build release (v1.2.5)** : `./gradlew assembleRelease` — R8 full mode + `isShrinkResources` + signé avec la **clé debug** (même signature que les APK debug distribués → mise à jour par-dessus sans perte de données ; 20 Mo → 2,4 Mo). Garde-fous dans `proguard-rules.pro` : Gson lit les champs des modèles par réflexion → `-keep` explicites sur `data.model.**`, `BackupData`, `TConfig` (+ attributs `Signature`) — sinon l'export/import JSON casse UNIQUEMENT en release. ⚠️ À chaque activation d'optimisation : tester sur téléphone l'export/import de backup + les graphiques (R8 ne se vérifie pas en tests JVM). ### Checklist de déploiement complète (de A à Z, pour une session sans contexte) 1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`, `versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y). 2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 191 au total, 168 si `local-test-data/` est absent (les 4 classes de régression réelles sont skippées via `Assume`, 6/6/6/5 tests) ; **lint vert obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet : chaque commit = tests + lint ; release = + scan confidentialité + APK au tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix. 3. **Docs** : section `## [X.Y.Z]` en tête de `docs/CHANGELOG.md` (le corps des releases Gitea en sera extrait automatiquement par le script), + §14 si bug corrigé, + §2 (historique) si notable. 3.bis. **⚠️ VALIDATION ÉMULATEUR §16.ter — OBLIGATOIRE AVANT LE TAG (leçon #64, v1.6.0 : oubliée puis rattrapée, un bug release-only est passé jusque dans l'APK taggué)** : recette complète §16.ter (émulateur headless hrt36 + seed `scripts/seed-emulator.py` avec le dernier backup de `local-test-data/` + APK release installé PAR-DESSUS) sur **l'APK RELEASE** (JAMAIS le debug seul : **R8 ne se vérifie qu'ici** — les 191 tests JVM ne le voient jamais, cf #64). Parcours minimum : l'app démarre 0 crash → écran Graphiques → activer les chips de la release (rangée modèles défilable : `adb shell input swipe 900 716 150 716 400` puis tap aux bounds du `uiautomator dump`) → vérifier dans le dump `text=` que la légende attendue apparaît → `adb logcat -b crash -d | grep -c FATAL` reste à 0. Limites : le pan/pinch n'est PAS testable par adb (§16.ter 4.bis) — le geste reste validé par l'utilisatrice sur Pixel 9. 4. **Commit** (message descriptif par couche) + **tag annoté** : `git tag -a vX.Y.Z -m "…"`. ⚠️ Si un fix arrive APRÈS le tag mais AVANT la publication d'APK (cas #64) : AUCUNE release n'existe encore → `git tag -d vX.Y.Z` + re-tag sur le commit de fix + `push --tags` (jamais de re-tag sur une release PUBLIÉE : bump en X.Y.(Z+1) à la place). 4.bis. **Release SYNC web** (depuis le 8 sept. 2026) : le portage web vit dans son PROPRE dépôt `~/projects/HormoneTrack-web` (2 Gitea, `Siphonight/HormoneTrack-web` — historique 100 % propre, séparé de la dette de confidentialité de ce dépôt). À chaque release : porter la feature web si pas déjà fait, aligner `WEB_VERSION` sur la MÊME `X.Y.Z`, `bash scripts/check.sh` vert (121 tests + E2E), commit + tag `vX.Y.Z` côté web, push des deux dépôts. Changelogs TOTALEMENT SÉPARÉS (`web/docs/CHANGELOG.md` ≠ ce fichier). Process web complet : `../HormoneTrack-web/docs/DEVELOPPEMENT.md` §3 + §10. Publication Gitea web GELÉE tant que la parité qualité n'est pas validée (commit + tag seulement). 5. **Vérif confidentialité** : `git ls-tree -r --name-only | grep local-test-data` → VIDE + scan de confidentialité (déjà exigé avant tout push, §8.bis) ; puis **Push** : `git push origin main --tags` + `git push farewell main --tags` (farewell : le repo doit exister sur l'instance ; push-to-create désactivé). 6. **Construire les 2 APK au niveau du tag** : `git checkout vX.Y.Z` → `./gradlew assembleRelease assembleDebug` → copier `app-release.apk` → `/tmp/apks/HormoneTrack-vX.Y.Z-release.apk` et `app-debug.apk` → `HormoneTrack-vX.Y.Z-debug.apk` → `git checkout main`. 7. **Publier les releases** (corps = section CHANGELOG + les 2 APK attachés) : `python3 scripts/publish-release.py cloudyfy vX.Y.Z` puis idem avec `farewell` (⚠️ UNE invocation = les 2 APK + vérification par téléchargement — NE PAS utiliser gitea-release.py deux fois de suite, les uploads rapprochés se remplacent mutuellement, cf §14 #43 ; token `gitea.farewell.dev` requis dans le trousseau). 8. **Smoke-test R8 sur téléphone** (le release APK n'est pas vérifiable en tests JVM) : installation par-dessus l'existant, graphiques, export ET import d'un backup JSON, rappel. Le debug APK est le repli (même signature). 9. **Mettre à jour §2/§8/§19/§21 si besoin** puis pusher la doc. Notes : - L'APK est une **build debug** signée avec la clé debug locale — installable en sideload, mises à jour entre versions OK (même signature) ; - Le dépôt est **privé** : le téléchargement des releases exige d'être connecté ; rendre le dépôt public rend les APK téléchargeables sans compte (aucune donnée de santé dans le dépôt, cf §8.bis). - Téléphone : mode développeur + Débogage USB (détails : GUIDE_INSTALLATION.md) - À ma charge (assistant) : build + tests JVM ✓ ; **émulateur installé et opérationnel depuis v1.3.4 (recette §16.ter)** ; **les tests humains sur vrai téléphone restent la référence** (notifs → montre, UX de saisie, pickers, panoramique du chart) ### 16.ter Recette : test manuel sur émulateur (reproductible — v1.3.4) But : reproduire/valider un crash ou une UI sur l'app RÉELLE (sans téléphone branché) — c'est cette recette qui a diagnostiqué les crashs #47/#48 en une session. ⚠️ AUCUNE donnée personnelle n'est embarquée : le backup utilisé vit sous `local-test-data/` (gitignoré, §8.bis) et est passé au script en argument. **0. Installation (one-shot, cf §4)** : `sdkmanager "emulator" "system-images;android-31;aosp_atd;arm64-v8a"` (léger, pour le LOGIQUE sans SAF) et/ou `"system-images;android-36;google_apis;arm64-v8a"` (image COMPLÈTE, nécessaire pour piloter les sélecteurs de fichiers). AVD : `avdmanager create avd -n hrt36 -k "system-images;…" -d pixel_6` (l'erreur béninie « devices.xml » ne bloque pas la création). Boot headless : `emulator -avd hrt36 -no-window -no-audio -no-boot-anim -no-snapshot -gpu swiftshader_indirect &` puis `adb wait-for-device` + attente `getprop sys.boot_completed`. **1. Seed des « données réelles »** — la DB est modifiable uniquement sur un APK DEBUGGABLE (`run-as`) : ```bash adb install app/build/outputs/apk/debug/app-debug.apk adb shell am start -n com.hormonetrack/.MainActivity # 1er lancement → crée la DB adb shell am force-stop com.hormonetrack # flush de la DB adb shell run-as com.hormonetrack cat databases/hormonetrack.db > /tmp/hrt.db python3 scripts/seed-emulator.py /tmp/hrt.db local-test-data/backup-v1.3.1.json adb push /tmp/hrt.db /data/local/tmp/hrt.db adb shell run-as com.hormonetrack cp /data/local/tmp/hrt.db databases/hormonetrack.db adb shell run-as com.hormonetrack rm -f databases/hormonetrack.db-wal \ databases/hormonetrack.db-shm # WAL périmé sinon ! (checkpoint implicite fait par le seed) ``` Règles du seed (le script les impose) : colonnes = noms de propriétés Kotlin (Room n'applique pas de snake_case), enums stockés en String — on insère SEULEMENT des lignes, on ne touche NI au schéma NI à `room_master_table` (Room valide le schéma à l'ouverture, pas les lignes). **2. Passer en RELEASE (c'est LE build à tester)** — `install -r` PAR-DESSUS le debug CONSERVE la DB (les APK release/signés debug sont la même clé) : ```bash adb install -r app/build/outputs/apk/release/app-release.apk adb logcat -c && adb shell am start -n com.hormonetrack/.MainActivity ``` **3. Piloter l'UI headless** : à l'aide de `uiautomator dump` + `input tap` — l'UI se pilote SANS écran : ```bash adb shell uiautomator dump /sdcard/ui.xml && adb shell cat /sdcard/ui.xml # → parser text=…/content-desc=… + bounds=[x1,y1][x2,y2] → centre (cx,cy) adb shell input tap # naviguer (5 tabs ≈ y=2280 à 1080×2400) adb shell input keyevent KEYCODE_BACK ``` **4. Attraper le crash** : le process meurt et repasse au launcher — c'est le signal ; stack : `adb logcat -b crash -d` (grep FATAL). **0 crash attendu : `grep -c FATAL` reste à 0** après chaque interaction. **4.bis LIMITATION des gestes dans l'émulateur headless (v1.4.10)** : les `adb shell input swipe` ne délivrent PAS les mouvements intermédiaires au chart (`detectTransformGestures` ne reçoit que down/up sans moves) → le pan et le pinch sont INTESTABLES par adb sur cet environnement (bug #62 non détectable ici). Le pan/pinch se valide sur le Pixel 9 réel (l'appa- rel du test humain). Les TAPS, la navigation et les dialogs fonctionnent normalement par adb. **5. Flux SAF (export JSON/logs)** : sur android-31 ATD le DocumentsUI est STUBBÉ (`fakesystemapp`) → INUTILISABLE ; utiliser l'image android-36 google_apis. Le picker se pilote pareil (bouton `SAVE` en bas à droite) ; vérification : fichier présent dans `/sdcard/Download/` + le message de confirmation (v1.3.3 : « Logs de diagnostic exportés ») visible via un nouveau dump UI. **6. Vérifications post-fix** : Doses (en-tête « temps sous THS » + liste), Settings → Export logs (SAF + SAVE + message), export/import JSON, changelog dialog au premier lancement (sinon re-seed un `changelog_seen_version`). ⚠️ Les coordonnées UI (x/y) évoluent avec l'écran/l'app : TOUJOURS re-dumper `uiautomator` avant de taper — ne jamais figer des coordonnées hors dump. ## 17. Montre : Gadgetbridge & options Doc dédiée : [MONTRE-GADGETBRIDGE.md](MONTRE-GADGETBRIDGE.md). Synthèse : - GT 3 = Lite Wearable ; GB supporte la GT 3 (« mostly supported ») : **notifications ✓, watchfaces `.hwt` ✓, apps `.hap` ✗** - Health et GB ne peuvent pas être appairés simultanément - Watchface via GB : **aucune signature requise** ; app `.hap` : certificat debug AGC + UDID (chaîne DevEco Studio → DevEco Assistant) - Régression connue : HarmonyOS 6.1+ casse l'install `.hwt` via GB (issues #5968/#6005/#6199) ; GT 3 en HarmonyOS 4.0.0.120 probablement OK, à valider - Choix v1 : notifications via GB/Health ; Phase 2 : watchface custom (statique) ou mini-app Lite Wearable autonome (Wear Engine = accès partenaire) ## 18. Espace disque & coûts Mesuré le 5 sept. 2026 (Mac, 228 Go, **33 Go libres** au départ) : | Élément | Taille | |---|---| | SDK Android (cmdline-tools + platforms 34/36/**37** + build-tools 34/36/37 + platform-tools) | ≈ 700 MB | | Cache Gradle (~/.gradle, plusieurs distributions 8.9→9.7.1 + deps AGP 9/Compose 1.12) | ≈ 3–4 GB | | Projet (sources + build outputs) | ≈ 100 MB | | **Total outillage actuel** | **≈ 4–5 GB** | Marges : émulateur + image système ≈ +2–3 GB ; DevEco Studio (Phase 2) ≈ +10 GB → tout rentre très largement. Note : **AGP télécharge automatiquement les plateformes manquantes** (licences signées) — c'est comme ça que android-37 est arrivé. ## 19. Limites connues Volontaires (v1) : - Pas de ViewModel/DI (couplage UI↔repo via CompositionLocal) - Modèle T empirique (non publié) — étiqueté estimation partout - Import JSON = **écrasement** depuis v1.2.6 (mode fusion non implémenté) - Profils par **tables** (pas par formule) : les D/k1–k3 de l'ODS ne sont pas consommés — rétro-ingénierie des fits non tentée ; les tables sont exactes - DST : les rappels quotidiens peuvent glisser d'1 h après changement d'heure, jusqu'au prochain reschedule (boot/save) — mineur - Labs : marqueur libre — E2/T exacts requis pour calibration/charts - `allowBackup=false` → seul backup = export JSON manuel - **APK release (R8)** : la réflexion Gson est couverte par des `-keep` explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur téléphone** (export/import backup, graphiques) avant chaque publication ; signé clé debug → upgradable sans perte, mais pas une signature « officielle » - **Grille de rappel sous-quotidienne** (v1.4.0) : une Posologie < 1 j (ex. 2 injections/jour) + UNE heure de rappel → l'app ne peut exprimer qu'un rappel/jour à heure fixe les jours de créneau (épinglé par `ReminderScheduleTest`) ; deux heures de rappel distinctes = évolution - **Lint (v1.3.4)** : `./gradlew lint` est vert — `StringFormatMatches` et famille restent des ERREURS bloquantes ; les checks Compose 1.12+ théoriques (`NonObservableLocale`, `LocalContextGetResourceValueCall`, staleness de configuration) sont rétrogradés en WARNING via `app/lint.xml` → à re-traiter lors d'une refonte i18n (§20) ## 20. Idées d'évolution 1. **Robolectric + tests Compose** (VM Android en JVM — pas besoin d'appareil) 2. **Émulateur local** pour smoke-tests UI (sur demande, ~2–3 Go) 3. Mode « planifier les injections » (schedule récurrent → pré-remplir le log) 4. Import JSON : détection de doublons / mode fusion optionnel (l'écrasement est fait, v1.2.6) 5. Verrou biométrique (BiometricPrompt), widget, export CSV 6. Charts : tooltip au toucher (le pan est fait v1.2.0, **le zoom v1.2.9**) ; MaterialExpressiveTheme quand l'API passera publique (cf §3) 7. **Vrai keystore de distribution** (signature dédiée ≠ clé debug → nécessite une migration : backup → désinstallation → installation signée → réimport) Fait (à ne pas refaire) : pan du chart (v1.2.0), **zoom du chart (v1.2.9)**, pics/creux (v1.2.3), prévision par fréquence (v1.2.0), calibration par période d'ester E2 **et** T (v1.2.1/v1.2.3), édition doses (v1.1.0) et labs (v1.2.2), E2+T en une entrée (v1.2.2), migration Room v1→v2 sans fallback destructif (v1.2.0), rooms v2→v3 (calendarEventId, v1.3.0), dépôt Gitea + releases APK (push session). 8. Phase 2 montre : watchface `.hwt` custom, puis mini-app Lite Wearable (cf §17) ## 21. Checklist de test manuel Sur le téléphone de test (à compléter par l'utilisatrice) : - [ ] App se lance sans crash (asset chargé — sinon cf §14.4) - [ ] Créer traitement « EV — Estrannaise » 4 mg + rappel 2 min à l'avance - [ ] Notif arrive sur le téléphone **et** la GT 3 (via GB ou Health) - [ ] « Pris » → dose loguée dans Doses ; « Reporter 1 h » → nouvelle notif 1 h après - [ ] Logger 2–3 injections passées → Home affiche E2/T + delta 6 h cohérents (4 mg EV → pic ≈ 4×61×scale ≈ 244 pg/mL à scale=1) - [ ] Ajouter un lab E2 → « Calibrer avec les analyses » → scaleFactor plausible (0,5–1,2) - [ ] Labs T + « Calibrer k » → k mis à jour, courbe T proche des points - [ ] Charts 24 h/7 j/30 j, toggles T/labs, axes lisibles - [ ] Export JSON → fichier inspectable ; ré-import → compteur correct - [ ] Langue FR↔EN↔Système : UI + notifs basculent - [ ] Redémarrer le téléphone → rappel reprogrammé (BootReceiver) - [ ] Désactiver un rappel → plus de notif (cancel — cf §14.3) - [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2) - [ ] **v1.2.x** : graphique panoramique (glisser → passé, bouton « Revenir à maintenant ») - [ ] **v1.2.x** : toggles Estrannaise/TFS indépendants (les deux courbes superposées) - [ ] **v1.2.x** : chip « Prévision » (configurer la Posologie d'un traitement d'abord) - [ ] **v1.2.x** : chip « Pics / creux » (triangles ▲▼ aux extrema E2 et T) - [ ] **v1.2.x** : Calibration automatique ON → courbes ajustées depuis les labs (par période d'ester si changement d'ester), OFF → valeurs stockées - [ ] **v1.2.x** : tap sur le mini-chart de l'accueil → écran Graphiques - [ ] **v1.2.x** : édition d'une dose (tap ligne Doses) et d'un lab (tap ligne Analyses → sélecteur E2/T si paire) ; suppression par prise entière - [ ] **v1.2.x** : prise de sang E2 + T en une entrée (champs optionnels) - [ ] **v1.2.9** : zoom du graphique (pinch 2 doigts + boutons − / + ; labels X adaptatifs ; courbes lisses à fort zoom) - [ ] **v1.2.7** : prévision après un OUBLI (pas de faux pic dans le passé) et après un RETARD (prévision décalée, suivant la dernière prise) - [ ] **v1.2.6** : import d'un backup avec l'app déjà remplie → ÉCRASER & restaurer (message clair), tConfig restauré, rappels reprogrammés - [ ] **v1.2.4** : passer un traitement à inactif → retiré de « Log rapide » et du dropdown des nouvelles doses, rappel annulé, **mais sa simulation reste sur le graphique** et la calibration couvre toujours ses périodes - [ ] **v1.3.3** : **export des logs** → le gestionnaire de fichiers s'ouvre, le .txt est écrit, un message « Logs de diagnostic exportés » s'affiche ; cas d'échec (annulation du chooser = silencieux, échec d'écriture = message d'erreur, l'app ne doit JAMAIS planter — bug #45, Pixel 9 /e/OS) - [ ] **v1.3.3** : **version affichée** « 1.3.3 » dans Paramètres et en titre du dialog « Nouveautés » (bug #46 : les APK v1.3.0–1.3.2 affichaient 1.3.0) ; au premier lancement de v1.3.3, le dialog liste 1.3.1→1.3.3 (dernière vue mémorisée = « 1.3.0 » sur les anciens APK) - [ ] **v1.3.3** : **événement d'agenda** : le switch demande la permission agenda (cette fois le système doit vraiment montrer le dialogue de permission — elle est désormais dans le manifest, bug #44) ; après accord, l'événement récurrent apparaît dans l'app d'agenda du Pixel 9 ; le smoke-test R8 de la checklist §16 doit aussi couvrir export/import de backup + graphiques (réflexion Gson sous R8) - [ ] **v1.3.4** : **écran Doses** s'ouvre et affiche l'en-tête « temps sous THS » avec les données réelles (bug #47 : crash dès que des doses antérieures à aujourd'hui existaient — né v1.3.1) - [ ] **v1.3.4** : **export des logs** ouvre VRAIMENT le gestionnaire de fichiers et le message « Logs de diagnostic exportés » apparaît après la sauvegarde (bug #48 : crash au tap depuis v1.3.1) — vérifié sur émulateur API 36 (§16.ter), à confirmer au téléphone - [ ] **v1.3.4** : rappel de notification affiche la dose proprement (lint #49, ex format « 4.0 ») - [ ] **v1.3.4** : `./gradlew lint` vert — intégré à la vérification de release (step 2 de la checklist §16 désormais : tests + lint) - [ ] **v1.3.5** : **agenda end-to-end** : sur un traitement actif avec Posologie, activer « Événement d'agenda récurrent » → permission accordée → sauvegarder → l'événement existe dans le calendrier local « HormoneTrack » (« créé/mis à jour (id=…) » dans les logs exportés) ; puis **désactiver le switch → re-save → l'événement disparaît RÉELLEMENT** de l'app d'agenda (bugs #50 / #50 bis / #51 : en v1.3.4 ni création ni suppression ne fonctionnaient, et les re-saves orphelinaient les événements — cf §14) - [ ] **v1.4.0** : **rappel = jour du créneau uniquement** (fix #52) : un traitement avec Posologie 7 j + rappel 18 h ne doit sonner que le jour d'injection à 18 h (pas les autres jours) ; la carte « Prochaine dose » de l'accueil montre le même créneau ; après « Pris » dans la notification, le rappel suivant se cale sur le créneau d'après - [ ] **v1.4.0** : **courbes TFS V3C** : superposer Estrannaise/TFS sur un traitement EV — les deux doivent être proches aux pics (58–61 pg/mL/mg) et diverger en queues (V3C = queues exactes) ; vérifier un preset EB/EC/PEP (nouvelles courbes) ; PEP à 32,5 mg → niveaux comparables à un EEn à 5 mg - [ ] **v1.4.1** : **prévision sans saut** : ouvrir Graphiques → activer le chip Prévision → le graphique NE BOUGE PAS (avant v1.4.1 il sautait « tout à droite ») ; tirer vers la GAUCHE → la courbe projetée défile vers le futur (jusqu'à 1 an selon la Posologie) ; « Revenir à maintenant » ramène ; désactiver le chip dans le futur → retour auto - [ ] **v1.4.1** : **carte « Prochaine dose » en jours** : avec un créneau à > 24 h, l'accueil affiche « 5 j 2 h · sam. 6 18:00 (traitement) » (et non « 122h22 ») ; à < 24 h, format heures/minutes inchangé - [ ] **v1.4.2** : **seuils d'alerte** : Paramètres → « Seuils d'alerte » → E2 haut 200 (avec un niveau estimé > 200) → Save → message « Saved » + **notification immédiate** (canal dédié) + carte rouge sur l'accueil (« ▲ E2 estimé ≈ X — au-dessus de ta limite (200) ») ; re-save sans changer → PAS de nouvelle notif (anti-spam) ; seuil haute au-dessus du niveau → tout disparaît ; validation : haut ≤ bas → message rouge - [ ] **v1.4.2** : **backup avec paramètres** : Export JSON → le fichier contient `"settings"` (langue, auto_calibrate, seuils) ; réinstaller + Import → langue/seuils restaurés ; un VIEUX backup (v1.4.1) s'importe sans les réglages (rétrocompat) ; smoke-test R8 : export/import (réflexion Gson sur UserSettings, `-keep` ajouté) - [ ] **v1.4.3** : **delta 6 h de l'accueil** : le lendemain d'une injection EEn (plateau), la carte « niveau actuel » doit comparer à il y a 6 h RÉELLEMENT (bug #53 : avant, la comparaison portait sur 24 h et le delta pouvait être trompeusement négatif) — vérifier la cohérence avec la courbe 24 h du graphique - [ ] **v1.4.4** : **prévision sans saut** : activer le chip ne déplace pas le graphique (il s'étend jusqu'à la prochaine dose) ; drag gauche = futur jusqu'à 1 an ; « Revenir à maintenant » ; désactivation en plein futur = retour auto (fix #54 : avant, le pan futur était figé à 0) - [ ] **v1.4.5** : **marqueurs de doses** : ligne pointillée + heure pour les doses projetées, triangles discrets pour les prises réelles ; labels X à minuit LOCAL du fuseau CHOISI (Paramètres, vide = téléphone) ; la prévision ne devait rien à un décalage de génération (créneau exact) - [ ] **v1.4.6** : **3ᵉ modèle WHSAH** : preset WHSAH → courbe verte superposable aux 2 autres (montée plus rapide à J+1, t½ plus longues que TFS) ; PEP non couvert ; 6 presets ; dropdown modèle à 3 choix - [ ] **v1.5.0** : **Tracé labs** : chip `Tracé labs` (rangée modèles, off par défaut) → la courbe rose foncé pointillé passe EXACTEMENT sur chacun de tes labs E2 entre le 1er et le dernier LAB ; le reste du graphique (toggles modèles, calibration) n'est pas modifié ; les jours sans labs récents : la courbe s'arrête au dernier lab (le vieillir est normal) ; chip décoché = plus de série ; 0 crash - [ ] **v1.6.0** : **Prolonger le tracé labs** : avec `Tracé labs` ON, activer `Prolonger` → la courbe continue APRÈS le dernier lab (rose atténué) jusqu'à l'extinction du modèle, avec légende dédiée ET AVERTISSEMENT « simple simulation, sans garantie… » sous la légende ; une dose loguée après le dernier lab fait repartir la courbe en pic ; désactiver `Prolonger` → retour à la fenêtre [1er ; dernier lab] exacte ; `Tracé labs` OFF → le chip `Prolonger` est désactivé ; l'avertissement disparaît avec la série - [ ] **v1.7.0** : **auto-backup journalier** : Paramètres → « Sauvegarde automatique quotidienne » → activer → le sélecteur de dossier s'ouvre → choisir (ex. Download) → un PREMIER `hormonetrack-auto-…json` apparaît immédiatement dans le dossier (vérifié : `adb shell ls /sdcard/Download/` sur l'émulateur) ; le statut « Dernière sauvegarde : OK (date) » s'affiche ; réduction des copies (garder 2, injecter des vieux fichiers, re-Save → les plus vieux auto-backups partent, JAMAIS les exports manuels) ; désactivation → plus de run ; l'export MANUEL reste inchangé - [ ] **v1.7.0** : **unités des axes** : « pg/mL » au sommet de l'axe gauche (E2) et « ng/mL » au sommet de l'axe droit (T — disparaît avec le toggle T), au-dessus des labels numériques ; portées web (versions sync) - [ ] **v1.7.1** : **notes des analyses** : une prise E2+T avec des notes DISTINCTES affiche LES DEUX lignes (« E2 : … » / « T : … ») ; note identique sur les deux → une seule ligne ; la note seule reste brute ; ré-éditer une entrée → sa note se met à jour sans effacer l'autre - [ ] **v1.4.10** : **pan sur la vue 24 h** : glisser horizontalement à 24 h doit déplacer la courbe (fix #62 : les deltas < 1 h s'accumulent — avant, le pan ne bougeait jamais sur 24 h) ; tester aussi à 7 j et le drag gauche avec prévision (futur) - [ ] **v1.4.9** : **calibration ×2,21 corrigée** (#61) : le bouton « Calibrer avec les analyses » et l'auto-calibration retombent sur un facteur physiologique (< 1,2) même avec des doses de test anciennes ; hint dans l'éditeur quand l'auto-calibration écrase le facteur manuel - [ ] **v1.4.8** : **calibration par modèle** : superposer TFS + WHSAH avec l'auto-calibration ON → CHAQUE courbe colle aux labs (plus de facteur partagé absurde type ×2,21 — fix #60) - [ ] **v1.4.7** : **toggles alignés sur les traitements** : au 1ᵉʳ chargement du graphique, seuls les modèles des traitements à profil sont ON (ex. EV TFS + EEn WHSAH → Estrannaise OFF) ; les autres activables au tap ; et TOUTES les courbes affichées reflètent la calibration (test) --- *Doc mise à jour le 16 sept. 2026 (v1.7.1) — build OK, lint vert, 211/211 tests verts (181 sans les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée de santé dans le dépôt ni l'historique. Fil des corrections : v1.3.3 = reprise de maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51 agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité ~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres ; v1.4.3 = fix du delta 6 h (#53) + régression n°4 (plateau EEn épinglé) ; v1.4.4 = prévision réparée (captures figées #54 + extension de fenêtre) ; v1.4.5 = marqueurs de doses + minuit local + fuseau configurable (#55) + boucle de recomposition tuée (#56) ; v1.4.6 = 3ᵉ modèle PK WHSAH (fit Mona, superposable) + scroll du graphique (#57). Dette connue : fragments de labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.*