HormoneTrack/docs/DEVELOPPEMENT.md

1535 lines
107 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)
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)
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)
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 |
|---|---|
| 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. |
| 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)
**Automatique** (`computeEsterScaleFactors` + `scalePerEster`, option « Auto-calibration ») :
- 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<String, Double>?` —
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).
## 8. Tests unitaires
**137 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **115 sans
les données de test locales** (cf §8.bis : les 3 classes de régression,
6 tests chacune, 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) et
`backup-v1.3.1.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** ;
- `.gitignore` contient `local-test-data/` → jamais commités ;
- 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 44 tests au lieu de 87 ;
- 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`** (4) : 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).
- **`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).
- **`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.**
- **`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`.
- **`ChartZoomTest`** (7) : v1.2.9 (pas d'échantillonnage) + v1.4.1 — **horizon de prévision** 12 × Posologie borné 30 j–1 an (`forecastHorizonHours`, null sans Posologie) et **clamp du panoramique** (`clampPanHours` : panHours négatif = futur, borné par l'horizon ; prévision OFF → futur interdit) — la logique de fenêtre du graphique est centralisée et testée.
- **`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.
**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) : **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`).
**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{}`
## 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) :**
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.
**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.
## 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 <hôte> -w`) :
`gitea.cloudyfy.fr` ✓ présent ; `gitea.farewell.dev` → à ajouter :
`security add-internet-password -s gitea.farewell.dev -a Siphonight -w <TOKEN> -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` — 137 au
total, 115 si `local-test-data/` est absent (les 3 classes de régression
réelles sont skippées via `Assume`, 6 tests chacune) ; **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.
4. **Commit** (message descriptif par couche) + **tag annoté** :
`git tag -a vX.Y.Z -m "résumé"`.
5. **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 <cx> <cy> # 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.
**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
---
*Doc mise à jour le 7 sept. 2026 (v1.4.3) — build OK, lint vert, 137/137 tests verts (115 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é). Dette connue : fragments de
labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.*