HormoneTrack/docs/DEVELOPPEMENT.md
Siphonight 9c27c4d9e2 v1.2.7 : prévision robuste aux oublis/retards + rattrapage complet de la doc de dev
- generateForecastDoses : les créneaux déjà passés (oubli d'injection) ne sont
  plus simulés — avant, dernier+intervalle tombait dans le passé → faux pic
  dans l'historique + rythme décalé ; la prévision démarre au premier créneau
  strictement futur, au rythme configuré
- un retard décale toute la prévision (part de la dernière prise réelle) —
  comportement voulu, épinglé par 2 tests
- doc §7.3b corrigée (mismatch doc-code détecté par l'utilisatrice)
- docs : §13 écrasement, §19/§20 import, §14 #34/#35, §21, historique
  (lignes v1.2.2/6/7), footer — vérification intégrée dans le script de patch
- versionCode 10, versionName 1.2.7 ; 64 tests verts
2026-09-05 22:14:19 +02:00

927 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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)
7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique)
8. [Tests unitaires](#8-tests-unitaires)
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)
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).
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. |
| 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 v2 + MIGRATION_1_2**, `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 (déclaré, non utilisé — supprimable) | idem |
| compileSdk / targetSdk | **37** / 37 ; minSdk 26 ; Java target 17 | 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`** ✓
- **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 2**, 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).
### `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 : `Estrogen.ods de l'utilisatrice (Owncloud)` (30 MB, 13 tables)
- Tables nominatives (6 profils (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 (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 /
batemanParams / concentrationOfDose / computeKa / doseEster / isInjectionRoute`.
Type de retour : `LevelPoint(timestamp, e2, t)`.
## 8. Tests unitaires
**62 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — 44 sans les
données de test locales (cf §8.bis). 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.2.3) — le scénario
**transition** : traitement **EV inactif** (29 doses 2–8 mg, janvier→juillet) +
traitement **EEn actif** (9 doses), 22 labs sur les deux périodes. 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` et `backup-v1.2.0.json`
(copiés tels quels depuis l'export JSON de l'app) ;
- `.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 56 ;
- 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. `git grep` sur **toutes** les
révisions ne trouve aucune donnée réelle.
- **`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).
- **`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).
**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`).
- `ReminderContract` : constantes + **fabrique unique `reminderIntent()`** pour schedule
ET cancel (même action = même PendingIntent — cf bug §14.3)
- `AlarmScheduler` :
- quotidien : `setExactAndAllowWhileIdle` si `canScheduleExact()` (API≥31 :
`alarmManager.canScheduleExactAlarms()`), sinon `setWindow` ±10 min
- permission **SCHEDULE_EXACT_ALARM** : bouton d'octroi dans Paramètres + éditeur
(`Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM`)
- `scheduleDaily` (prochaine occurrence HH:mm), `scheduleSnooze` (+1 h), `rescheduleAll`
- `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()`
- `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert
DoseLog (dose = extra ou standard) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif
- `BootReceiver` : `goAsync()` + thread + **`runBlocking`** + one-shot `getActiveOnce()`
(jamais un Flow en runBlocking !) → reschedule
Manifest : `POST_NOTIFICATIONS`, `SCHEDULE_EXACT_ALARM`, `RECEIVE_BOOT_COMPLETED`, `VIBRATE`.
Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
## 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), carte prochaine dose, 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) : plages 24 h/7 j/30 j ; **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**
- `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 « Fréquence » (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é
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ;
**« 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`).
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=1, exportedAt, treatments[], doseLogs[], labResults[], tConfig}` → Gson
- **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 et les rappels sont reprogrammés
(`rescheduleAll(allTreatmentsOnce())`). 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.
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.
**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. (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** (`RegressionUserCaseTest` = export JSON réel) attrape les bugs de
convention (casse, unités) que les tests synthétiques ratent ; (e) attention aux
identifiants « presque pareils » entre sources (constantes app vs clés d'asset).
## 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 / git
```bash
cd ~/projects/HormoneTrack
./gradlew assembleDebug testDebugUnitTest # build + 62 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`)
- 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é `clé SSH 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
# 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)
for I in cloudyfy farewell; do
python3 scripts/gitea-release.py $I vX.Y.Z /tmp/apks/HormoneTrack-vX.Y.Z-release.apk
python3 scripts/gitea-release.py $I vX.Y.Z /tmp/apks/HormoneTrack-vX.Y.Z-debug.apk
done
```
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) :
`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` — 62 au total,
44 si `local-test-data/` est absent (les régressions réelles sont skippées).
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/gitea-release.py cloudyfy vX.Y.Z <release.apk>` puis
idem avec `<debug.apk>` ; répéter avec l'instance `farewell` (token
`gitea.farewell.dev` requis dans le trousseau, absent à ce jour).
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 possible sur demande ;
**les tests humains sur vrai téléphone restent la référence**
(notifs → montre, UX de saisie, pickers, panoramique du chart)
## 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é)
- `fallbackToDestructiveMigration()` — à retirer à la migration v2 du schéma
- WorkManager déclaré non utilisé
- 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 »
## 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 : **zoom** + tooltip au toucher (le pan est fait, v1.2.0) ;
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), 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), dépôt Gitea
+ releases APK (push session).
8. Phase 2 montre : watchface `.hwt` custom, puis mini-app Lite Wearable (cf §17)
9. Retirer WorkManager ou l'utiliser (reschedule de sécurité quotidien)
## 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 Fréquence 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.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
---
*Doc mise à jour le 5 sept. 2026 (v1.2.7) — build OK, 62/62 tests verts (44 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.*