HormoneTrack/docs/DEVELOPPEMENT.md

66 KiB
Raw Blame History

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, GUIDE_INSTALLATION.md, MONTRE-GADGETBRIDGE.md.


Table des matières

  1. Contexte & objectifs
  2. Historique du projet
  3. Stack & versions (épinglées)
  4. Environnement de build (cette machine)
  5. Architecture générale
  6. Modèle de données (Room)
  7. Moteur pharmacocinétique
  8. Tests unitaires
  9. Système de rappels
  10. UI & navigation
  11. Graphiques (CurveChart)
  12. i18n FR/EN
  13. Sauvegarde JSON
  14. Bugs corrigés (historique complet — à ne pas réintroduire)
  15. Comment régénérer l'asset pk_profiles.json
  16. Workflow build / test / install
  17. Montre : Gadgetbridge & options
  18. Espace disque & coûts
  19. Limites connues & choix volontaires
  20. Idées d'évolution (Phase 2+)
  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.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 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

78 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 67 ;
  • 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).
  • 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.
  • 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).

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

É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. 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 + 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
  • 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é
  • 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) ; 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=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é).

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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) :

# 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

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) :

# 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)
#    ⚠️ v1.3.0 : deux invocations RAPPROCHÉES du script se sont écrasées
#    mutuellement (APK release disparu, debug renommé générique). Pattern
#    validé : purge des assets + upload + VÉRIFICATION PAR TÉLÉCHARGEMENT
#    des deux, puis RE-VÉRIFIER LES DEUX à la fin du run (listing peut
#    mentir pendant les uploads rapproché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) ; ⚠️ 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 — 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. 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).
  1. Phase 2 montre : watchface .hwt custom, puis mini-app Lite Wearable (cf §17)
  2. 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 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

Doc mise à jour le 6 sept. 2026 (v1.3.0) — 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.