Compare commits

...

14 Commits
v1.9.8 ... main

Author SHA1 Message Date
c0571ae44e Docs : traçabilité publication v1.13.0 (ids releases, web sync complet avec étape 4) 2026-10-07 10:00:29 +02:00
ae14cee07b v1.13.0 : reco prédictive — valeur E2 au creux, régime poolé entre traitements identiques, cible de creux personnelle
- predictedE2 : valeur brute au creux × facteur du ester actif — fournie
  par Home/Labs quand l'auto-calibration est ON (sinon null : la carte
  n'affiche rien, une valeur brute serait trompeuse). Miroir web.
- Régime POOLÉ (pooledRegimeDoses) : les doses de tous les traitements
  partageant (ester effectif, mg) forment UNE séquence — le re-parenting
  d'historique (« 6d-old », v1.12.0) ne fait plus redémarrer la
  stabilisation. Garde-fous : ester ≠ jamais poolé (régression n°3) ;
  dose ≠ exclue (le 8 mg casse via le trou). Régression n°7 data-driven
  sur le nouvel export (backup-v1.12.0.json).
- Cible de creux E2 (opt-in) : DataStore + carte settings (hint croisé
  « distinct des seuils d'alerte — jamais de notification ») + coloration
  carte reco (primaire/hors cible tertiary) + backup rétrocompatible (un
  backup ancien n'efface pas la cible locale). Système à 3 niveaux
  documenté §7.11. Miroir web complet.

284 tests JVM (250 sans données locales) + 14 UI + lint verts ; web 199
tests + E2E verts (check.sh). Validé émulateur sur données réelles :
« Expected at this trough: ≈ 208 pg/mL — outside your target », régime
poolé = stable depuis le 12 août, 0 crash.
2026-10-07 09:59:19 +02:00
4eb8519d9d Docs §2 : traçabilité backfill des releases web v1.10.0→v1.12.0 (+ v1.9.5 farewell) 2026-09-30 20:48:32 +02:00
458f7cc9ef Docs : traçabilité publication v1.12.0 (ids releases Gitea, web sync) 2026-09-29 23:26:01 +02:00
b3372a80e4 v1.12.0 : fix #70 (inactif à Posologie projetait encore) + Doses (icônes de forme, marqueurs prise de sang)
- FIX #70 : generateForecastDoses garde !isActive — un injectable archivé
  avec Posologie et de l'historique projetait encore ses injections
  (graphique Prévision, extension de fenêtre, horizon LabTiming) ; le chip
  Prévision s'activait sur des Posologies inactives. FIX : garde moteur +
  horizon/chip sur les ACTIFS. §6.bis inchangé : l'historique reste simulé,
  seul le FUTUR s'arrête (comme les rappels v1.4.0). Miroir web. 3 tests
  ForecastDosesGuardTest + 3 miroirs web.
- Icônes de forme de prise dans Doses : routeIcon pur (pilule/seringue
  IM+SC teinte tertiary pour la SC/goutte/sparadrap/compte-gouttes ;
  ⚠️ MedicationLiquid absent d'icons-extended 1.7.8 → Colorize).
- Marqueurs « prise de sang le même jour » : labMarkersForDose pur (jour
  calendaire LOCAL, lab à l'heure exacte = APRÈS) → encart 🧪↑/🧪↓ en bout
  de ligne. 7 tests DosesExtrasTest + 10 miroirs web.

274 tests JVM + 14 UI + lint verts ; web 194 tests + E2E verts (check.sh).
Validé émulateur sur APK RELEASE avec données réelles seedées + scénario du
bug (2 inactifs à Posologie 7 j) : Prévision on/off 0 crash, glyphes et
marqueurs en place.
2026-09-29 23:24:34 +02:00
45ba0bdc62 Docs : traçabilité publication v1.11.0 (ids releases Gitea, web sync) 2026-09-29 07:32:07 +02:00
a0f3ac59f9 v1.11.0 : inactifs regroupés en bas de la page Traitements + cartes atténuées
- Demande : « les traitements inactifs sont peu discernables — les placer
  à la fin (tout en bas), et peut-être les rendre plus distincts ».
- Helper pur treatmentsForDisplay (tri STABLE : actifs d'abord, inactifs
  après, ordre relatif conservé dans chaque groupe).
- LazyColumn en 3 sections : actifs / en-tête inactive_section (seulement
  si des inactifs existent) / inactifs — cartes alpha 0.55 en plus du
  badge, lisibles et tappables (l'édition d'un archivé reste possible).
- Miroir web : treatmentsForDisplay dans js/data/models.js + rendu
  sections + opacity 0.55 + i18n inactive_section FR/EN (web v1.11.0 sync).
- 5 tests JVM TreatmentsDisplayTest + 4 tests web (miroir).

264 tests JVM + 14 UI + lint verts ; web 184 tests + E2E verts (check.sh).
Validé émulateur sur APK RELEASE avec données réelles seedées (5 actifs en
haut, en-tête + EV-old/EEn-old tout en bas, 0 crash).
2026-09-29 07:31:25 +02:00
5cd1023af1 Docs : traçabilité publication v1.10.1 (ids releases Gitea, web non concerné) 2026-09-28 21:50:41 +02:00
d531ecae24 v1.10.1 : durcissement rappels — singleTop (onNewIntent vivant), instant planifié dans la garde, BootReceiver goAsync
- launchMode="singleTop" sur MainActivity : un tap notification app-ouverte
  délivre onNewIntent SANS recréer l'activité (l'ancien standard+CLEAR_TOP
  recréait tout et rendait le chemin v1.10.0 inopérant — code mort).
- EXTRA_PLANNED_AT : l'instant planifié voyage dans l'alarme (scheduleFor),
  le snooze le forward, le garde « créneau déjà honoré » évalue le jour du
  CRÉNEAU et non celui du feu (alarme inexacte/Doze glissant après minuit).
- BootReceiver : goAsync + coroutine (dette §20.bis #2), runBlocking supprimés.
- Audit doc : comptes résiduels 223 → 259 (§14 #64, §16.ter) ; §9/§20.bis à jour.

259 tests JVM + 14 UI + lint verts ; validations émulateur sur APK RELEASE
avec données réelles seedées (singleTop 0 recreation, planned_at A/B, 0 crash).
Web non concerné (aucune logique miroir touchée).
2026-09-28 21:49:06 +02:00
0bb259d12d Docs : audit post-release v1.10.0 — §3 (uiautomator), §5 (EstrannaiseModels, demande #69), §8 (259/225), §16 (259/225 + 14 UI), §21 (2 points manuels v1.10.0), README FR/EN (plage releases) 2026-09-27 23:17:09 +02:00
386693379e Docs : traçabilité publication v1.10.0 (ids releases Gitea, web sync) 2026-09-27 23:12:27 +02:00
a4f859f854 v1.10.0 : fix #68 (reco de prise de sang avant stabilisation), fix #69 (dialog fantôme), rappel sauté si dose déjà loggée
- #68 : le « creux » était le minimum de la fenêtre entière — pour un ester à
  montée lente (EEn, pic ~J+5 ≈ intervalle 7 j) il tombait juste après
  l'injection PRÉCÉDENTE : date antérieure à la stabilisation affichée sur la
  même carte, « juste avant ton injection du … » faux de plusieurs jours
  (réel : creux 5 oct 02:32 < stab 7 oct 04:03). FIX : creux = niveau
  PRÉ-INJECTION du créneau (principe v1.8.0 restauré ; EV inchangé). Miroir
  web appliqué. Régression n°6 data-driven (backup-v1.9.8.json, balayage de
  now ±60 j) ; les 17 LabTimingTest passent inchangés.
- #69 : tap notification → dialog ; le fermer + changer d'onglet + revenir sur
  l'accueil le rouvrait à chaque fois (demande au niveau activité,
  consommation dans les remember de Home). FIX : MutableState<Long?> dans
  MainActivity consommé UNE fois + extras retirés de l'intent ; bonus : tap
  notification app-ouverte ouvre aussi le dialog. Test UI LogDoseRequestTest.
- Rappel sauté si dose déjà loggée (demande) : garde au déclenchement —
  hasDoseLoggedOnDay (pur, jour calendaire LOCAL) dans ReminderReceiver,
  re-programmation du créneau suivant dans tous les cas. 6 tests JVM +
  vérification A/B émulateur (EEn dose-du-jour → 0 notif, Fluoxetine → 1).

259 tests JVM + 14 UI + lint verts ; web 180 tests + E2E verts (check.sh).
Validations émulateur §16.ter sur APK RELEASE avec données réelles seedées
(reco : creux 11 oct 20:08 ≥ stab 7 oct 06:03 ; dialog 1× puis plus jamais ;
changelog 1.10.0 affiché ; 0 crash).
2026-09-27 23:11:04 +02:00
2548c6207b Docs §16.ter : piège WAL au seed (extraire db+wal côte à côte après un force-stop) 2026-09-23 07:43:53 +02:00
a3a9c7652c Docs : traçabilité publication v1.9.8 (ids releases Gitea, web non concerné) 2026-09-22 22:41:02 +02:00
32 changed files with 2223 additions and 145 deletions

View File

@ -18,9 +18,9 @@ or Huawei Health) and JSON backup. **100% local, no account, no server.**
> for informational purposes — they are not measurements. Always rely on your > for informational purposes — they are not measurements. Always rely on your
> blood tests and on your endocrinologist's guidance. > blood tests and on your endocrinologist's guidance.
- **Status**: v1.9.8 — Android build ✅, **lint clean** ✅, **249 unit tests** ✅ - **Status**: v1.13.0 — Android build ✅, **lint clean** ✅, **284 unit tests** ✅
(219 without the local test data; regressions pinned on real data (250 without the local test data; 7 regressions pinned on real data
**not committed to the repo**), **13 Compose UI tests** ✅ (emulator), **not committed to the repo**), **14 Compose UI tests** ✅ (emulator),
smartwatch integration = notifications ✅, **private Gitea repo + releases smartwatch integration = notifications ✅, **private Gitea repo + releases
with APK** ✅ with APK** ✅
- **Changelog**: [docs/CHANGELOG.md](docs/CHANGELOG.md) (French) - **Changelog**: [docs/CHANGELOG.md](docs/CHANGELOG.md) (French)
@ -65,12 +65,17 @@ or Huawei Health) and JSON backup. **100% local, no account, no server.**
upcoming doses are projected on the chart (never saved); **enabling forecast upcoming doses are projected on the chart (never saved); **enabling forecast
does not move the chart start** but EXTENDS the window to the next dose does not move the chart start** but EXTENDS the window to the next dose
(v1.4.4), and the projection is browsed by dragging left, up to **1 year** (v1.4.4), and the projection is browsed by dragging left, up to **1 year**
- **Dose log** with exact date/time, dose in mg, **per-injection ester**
(EV↔EU↔EEn switch like in the spreadsheet), **editable** (tap a row in Doses),
**interval in days between doses** displayed
- **Inactive treatments**: an archived treatment disappears from input and - **Inactive treatments**: an archived treatment disappears from input and
reminders, but **its whole history stays simulated and calibrated** — useful reminders, but **its whole history stays simulated and calibrated** — useful
for a valerate → enanthate transition for a valerate → enanthate transition; **grouped at the very bottom** of the
Treatments page under a dedicated header, dimmed cards (v1.11.0);
**no future projection anymore** (v1.12.0 — its slots disappear from the
Forecast chart)
- **Dose log** with exact date/time, dose in mg, **per-injection ester**
(EV↔EU↔EEn switch like in the spreadsheet), **editable** (tap a row in Doses),
**interval in days between doses** displayed; **route icon per dose row**
(oral / IM-SC injection / gel / patch, v1.12.0) and **same-day blood test
markers** (🧪↑ before the dose, 🧪↓ after)
- **Blood tests**: **E2 + T in a single entry** (each optional), displayed - **Blood tests**: **E2 + T in a single entry** (each optional), displayed
**side by side** when they share the same date/time, editable (tap → pick the **side by side** when they share the same date/time, editable (tap → pick the
entry); T units: ng/mL, ng/dL, ng/L, nmol/L entry); T units: ng/mL, ng/dL, ng/L, nmol/L
@ -80,7 +85,10 @@ or Huawei Health) and JSON backup. **100% local, no account, no server.**
stabilized** (~5 half-lives after your last change — dose, ester or interval) — stabilized** (~5 half-lives after your last change — dose, ester or interval) —
derived from the curves and the Dosage interval; requires an active injectable derived from the curves and the Dosage interval; requires an active injectable
E2 with a Dosage (a prompt suggests setting one otherwise); estimates, never E2 with a Dosage (a prompt suggests setting one otherwise); estimates, never
medical advice medical advice;
**v1.13.0**: the card shows the **expected E2 at that trough** (when
auto-calibration is ON) and compares it to your personal **trough target**
(opt-in — distinct from the alert thresholds, never notifies)
- **Calibration**: per-treatment scale factor = median(lab ÷ model prediction), - **Calibration**: per-treatment scale factor = median(lab ÷ model prediction),
computed automatically (the `.ods` "Scale factor", automated) — or **permanent computed automatically (the `.ods` "Scale factor", automated) — or **permanent
automatic calibration** (option, off by default) which calibrates **each ester automatic calibration** (option, off by default) which calibrates **each ester
@ -92,7 +100,9 @@ or Huawei Health) and JSON backup. **100% local, no account, no server.**
- **Daily reminders** with **"Taken" / "Snooze 1 h"** actions right in the - **Daily reminders** with **"Taken" / "Snooze 1 h"** actions right in the
notification; notifications are mirrored to the GT 3 watch (Gadgetbridge or notification; notifications are mirrored to the GT 3 watch (Gadgetbridge or
Huawei Health); **reminders follow the Dosage interval** (v1.4.0): a treatment Huawei Health); **reminders follow the Dosage interval** (v1.4.0): a treatment
injected every 7 days only rings on injection day, not every day injected every 7 days only rings on injection day, not every day; **a dose
already logged on the day skips the notification** (v1.10.0 — the next
reminder resumes at the following slot)
- **Configurable alert thresholds** (v1.4.2): E2 (pg/mL) and T (ng/mL) high/low - **Configurable alert thresholds** (v1.4.2): E2 (pg/mL) and T (ng/mL) high/low
limits in Settings → warning card on the home screen + **notification every limits in Settings → warning card on the home screen + **notification every
15 min even with the app closed** (WorkManager, anti-spam, dedicated channel) — 15 min even with the app closed** (WorkManager, anti-spam, dedicated channel) —
@ -127,8 +137,8 @@ Gradle 9.7.1.
git clone <repo> && cd HormoneTrack git clone <repo> && cd HormoneTrack
echo "sdk.dir=/path/to/android-sdk" > local.properties # or ANDROID_HOME echo "sdk.dir=/path/to/android-sdk" > local.properties # or ANDROID_HOME
./gradlew assembleDebug # APK: app/build/outputs/apk/debug/app-debug.apk ./gradlew assembleDebug # APK: app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest # 249 tests (219 without local test data) ./gradlew testDebugUnitTest # 284 tests (250 without local test data)
./gradlew connectedDebugAndroidTest # 13 UI tests (emulator/device required) ./gradlew connectedDebugAndroidTest # 14 UI tests (emulator/device required)
./gradlew lint # clean lint required before a release ./gradlew lint # clean lint required before a release
``` ```
@ -141,7 +151,7 @@ No Play Store: the app is sideloaded. Step-by-step details (French):
Repositories: **gitea.cloudyfy.fr** and **gitea.farewell.dev** (mirror) — Repositories: **gitea.cloudyfy.fr** and **gitea.farewell.dev** (mirror) —
`Siphonight/HormoneTrack` on both (private), with **tagged releases** `Siphonight/HormoneTrack` on both (private), with **tagged releases**
(`v1.1.0` → `v1.9.8`) and **two APKs per release** (since v1.2.5) (`v1.1.0` → `v1.10.0`) and **two APKs per release** (since v1.2.5)
(downloadable without building, cf (downloadable without building, cf
[docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)): [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)):
`-release.apk` (**recommended**, R8-optimized, 2.7 MB) and `-debug.apk` (20 MB): `-release.apk` (**recommended**, R8-optimized, 2.7 MB) and `-debug.apk` (20 MB):

View File

@ -18,7 +18,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > sang et aux consignes de ton endocrinologue.
- **Statut** : v1.9.8 — build Android ✅, **lint vert** ✅, **249 tests unitaires** ✅ (219 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), **13 tests UI Compose** ✅ (émulateur), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ - **Statut** : v1.13.0 — build Android ✅, **lint vert** ✅, **284 tests unitaires** ✅ (250 sans les données de test locales ; 7 régressions épinglées sur données réelles **non versionnées**), **14 tests UI Compose** ✅ (émulateur), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
@ -67,10 +67,13 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
vers la gauche, jusqu'à **1 an** vers la gauche, jusqu'à **1 an**
- **Log des doses** avec date/heure exacte, dose en mg, **ester par injection** - **Log des doses** avec date/heure exacte, dose en mg, **ester par injection**
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses), (switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
**intervalle en jours entre dosages** affiché **intervalle en jours entre dosages** affiché ; **icône de forme de prise**
(oral / injection IM-SC / gel / patch, v1.12.0) et **encart « prise de sang
le même jour »** (🧪↑ avant la dose, 🧪↓ après)
- **Traitements inactifs** : un traitement archivé n'apparaît plus dans la saisie - **Traitements inactifs** : un traitement archivé n'apparaît plus dans la saisie
ni dans les rappels, mais **tout son historique reste simulé et calibré** — ni dans les rappels, mais **tout son historique reste simulé et calibré** —
utile pour une transition valerate → enanthate utile pour une transition valerate → enanthate ; **regroupés tout en bas** de la
page Traitements sous un en-tête dédié, cartes atténuées (v1.11.0)
- **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées - **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées
**côte à côte** quand elles partagent la même date/heure, éditables (tap → choix de **côte à côte** quand elles partagent la même date/heure, éditables (tap → choix de
l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
@ -79,7 +82,10 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
**au premier creux où ton régime est stabilisé** (~5 demi-vies après ton dernier **au premier creux où ton régime est stabilisé** (~5 demi-vies après ton dernier
changement — dose, ester ou intervalle) — déduit des courbes et de la Posologie ; requiert un changement — dose, ester ou intervalle) — déduit des courbes et de la Posologie ; requiert un
injectable E2 actif à Posologie (une invite propose de la renseigner sinon) ; injectable E2 actif à Posologie (une invite propose de la renseigner sinon) ;
estimations, jamais un avis médical estimations, jamais un avis médical ;
**v1.13.0** : la carte affiche la **valeur E2 attendue au creux** (si l'auto-calibration
est ON) et la compare à ta **cible de creux** personnelle (opt-in — distinct des
seuils d'alerte, jamais de notification)
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle), - **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
@ -91,7 +97,9 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ; - **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ; les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ;
**les rappels suivent la Posologie** (v1.4.0) : un traitement injecté tous les **les rappels suivent la Posologie** (v1.4.0) : un traitement injecté tous les
7 jours ne sonne que le jour d'injection, pas tous les jours 7 jours ne sonne que le jour d'injection, pas tous les jours ; **une dose déjà
loguée dans la journée saute la notification** (v1.10.0 — le rappel suivant
repart au créneau suivant)
- **Seuils d'alerte configurables** (v1.4.2) : limites hautes/basses E2 (pg/mL) - **Seuils d'alerte configurables** (v1.4.2) : limites hautes/basses E2 (pg/mL)
et T (ng/mL) dans Paramètres → carte d'avertissement sur l'accueil + et T (ng/mL) dans Paramètres → carte d'avertissement sur l'accueil +
**notification toutes les 15 min même app fermée** (WorkManager, anti-spam, **notification toutes les 15 min même app fermée** (WorkManager, anti-spam,
@ -126,8 +134,8 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
git clone <repo> && cd HormoneTrack git clone <repo> && cd HormoneTrack
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk ./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest # 249 tests (219 sans les données locales) ./gradlew testDebugUnitTest # 284 tests (250 sans les données locales)
./gradlew connectedDebugAndroidTest # 13 tests UI (émulateur/appareil requis) ./gradlew connectedDebugAndroidTest # 14 tests UI (émulateur/appareil requis)
./gradlew lint # lint vert obligatoire avant release ./gradlew lint # lint vert obligatoire avant release
``` ```
@ -139,7 +147,7 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) — Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
`Siphonight/HormoneTrack` sur les deux (privé), avec `Siphonight/HormoneTrack` sur les deux (privé), avec
**releases taguées** (`v1.1.0` → `v1.9.8`) et **deux APK par release** (depuis v1.2.5) **releases taguées** (`v1.1.0` → `v1.10.0`) et **deux APK par release** (depuis v1.2.5)
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) : (téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
`-release.apk` (**recommandé**, optimisé R8, 2,7 Mo) et `-debug.apk` (20 Mo) : `-release.apk` (**recommandé**, optimisé R8, 2,7 Mo) et `-debug.apk` (20 Mo) :

View File

@ -33,8 +33,8 @@ android {
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
// les APK publiés (Paramètres + titre du dialog « Nouveautés »). // les APK publiés (Paramètres + titre du dialog « Nouveautés »).
versionCode = 45 versionCode = 50
versionName = "1.9.8" versionName = "1.13.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@ -170,4 +170,6 @@ dependencies {
androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0") androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0")
// JUnit explicite (org.junit.Rule/@Test) — ne pas compter sur le transitif // JUnit explicite (org.junit.Rule/@Test) — ne pas compter sur le transitif
androidTestImplementation("junit:junit:4.13.2") androidTestImplementation("junit:junit:4.13.2")
// UiDevice (pressBack pour fermer les dialogs — v1.10.0, LogDoseRequestTest)
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
} }

View File

@ -0,0 +1,84 @@
package com.hormonetrack.uitest
import android.content.Intent
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import com.hormonetrack.MainActivity
import com.hormonetrack.R
import org.junit.After
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Test UI de la demande « logger une dose » venue d'une NOTIFICATION
* (v1.10.0, **fix #69**) : le dialog s'ouvre UNE FOIS au lancement avec les
* extras `open_log_dose`/`treatment_id` (même contrat que ReminderReceiver,
* cf reminder/ReminderManager.kt), puis — une fois fermé — ne se rouvre
* JAMAIS tout seul. Comportement bogué épinglé : chaque retour sur l'onglet
* Accueil rouvrait le dialog indéfiniment (le paramètre d'intent vivait au
* niveau activité alors que la consommation vivait dans les `remember` de
* Home, détruits à chaque changement d'onglet).
*
* Lancement MANUEL avec intent (règle Compose vide + ActivityScenario) : le
* contrat du fix exige de régler l'état AVANT que la composition ne lise
* l'intent — et l'overload `createAndroidComposeRule(intent)` n'existe pas
* dans la ligne Compose 1.12. La fermeture passe par BACK (onDismissRequest,
* indépendant des libellés) ; le titre `add_dose` (« Log a dose ») est
* affiché dans les DEUX variantes du dialog (avec ou sans traitements en
* base) : le test reste data-indépendant.
*/
@RunWith(AndroidJUnit4::class)
class LogDoseRequestTest {
@get:Rule
val composeRule = createEmptyComposeRule()
private var scenario: ActivityScenario<MainActivity>? = null
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
@After
fun tearDown() {
scenario?.close()
}
@Test
fun doseDialogOpensOnceAndNeverReopensAfterTabSwitch() {
val intent = Intent(UiTestEnv.targetContext, MainActivity::class.java).apply {
putExtra("open_log_dose", true)
putExtra("treatment_id", 1L)
}
scenario = ActivityScenario.launch(intent)
val dialogTitle = res(R.string.add_dose)
composeRule.waitForIdle()
composeRule.onNodeWithText(dialogTitle).assertIsDisplayed()
// Fermeture du dialog (BACK → onDismissRequest)
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack()
composeRule.waitForIdle()
composeRule.onNodeWithText(dialogTitle).assertDoesNotExist()
// Changer d'onglet puis revenir sur l'accueil : le dialog NE doit
// PAS se rouvrir (demande consommée une fois pour toutes)
composeRule.onNodeWithText(res(R.string.nav_doses)).performClick()
composeRule.waitForIdle()
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
composeRule.waitForIdle()
composeRule.onNodeWithText(dialogTitle).assertDoesNotExist()
}
companion object {
@BeforeClass
@JvmStatic
fun setUpApp() = UiTestEnv.prepare()
}
}

View File

@ -26,7 +26,13 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- launchMode singleTop (v1.10.1) : un tap sur la notification de
rappel quand l'app est DÉJÀ ouverte délivre onNewIntent (avec les
extras open_log_dose/treatment_id) SANS détruire/recréer
l'activité — l'ancien comportement (standard + CLEAR_TOP)
recréait tout, et onNewIntent était du code mort. -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />

View File

@ -10,6 +10,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.mutableStateOf
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.hormonetrack.ui.HormoneTrackRoot import com.hormonetrack.ui.HormoneTrackRoot
@ -22,6 +23,17 @@ class MainActivity : AppCompatActivity() {
private val notificationPermissionLauncher = private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { } registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
/**
* Demande « logger une dose » venue d'une NOTIFICATION (extra
* `open_log_dose`/`treatment_id`). Détenu par l'ACTIVITÉ — survit aux
* changements d'onglets — et consommé UNE FOIS par HomeScreen via
* [HormoneTrackRoot] (**fix #69 v1.10.0** : l'ancien code passait le
* paramètre d'intent tel quel à la composition ; quitter l'onglet
* Accueil détruisait les `remember` locaux, le paramètre lui vivait au
* niveau activité → le dialog se rouvrait à CHAQUE retour sur l'accueil).
*/
private val logDoseRequest = mutableStateOf<Long?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val container = (application as HormoneTrackApp).container val container = (application as HormoneTrackApp).container
@ -53,15 +65,11 @@ class MainActivity : AppCompatActivity() {
} }
} }
val openLogDose = intent?.getBooleanExtra("open_log_dose", false) ?: false logDoseRequest.value = consumeLogDoseExtras(intent)
val treatmentId = intent?.getLongExtra("treatment_id", -1L) ?: -1L
setContent { setContent {
HormoneTrackTheme { HormoneTrackTheme {
HormoneTrackRoot( HormoneTrackRoot(logDoseRequest = logDoseRequest)
initialOpenLogDose = openLogDose,
initialTreatmentId = treatmentId
)
} }
} }
} }
@ -69,5 +77,22 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
// v1.10.0 : un tap sur la notification quand l'app est DÉJÀ ouverte
// ouvre aussi le dialog (avant : setIntent seul, la demande était
// silencieusement perdue).
consumeLogDoseExtras(intent)?.let { logDoseRequest.value = it }
}
/**
* Lit les extras de demande de log puis les RETIRE de l'intent : un
* recreate de l'activité (rotation, processus restauré) ne doit JAMAIS
* rouvrir le dialog d'une notification traitée il y a longtemps.
*/
private fun consumeLogDoseExtras(intent: Intent?): Long? {
val open = intent?.getBooleanExtra("open_log_dose", false) ?: false
val id = intent?.getLongExtra("treatment_id", -1L) ?: -1L
intent?.removeExtra("open_log_dose")
intent?.removeExtra("treatment_id")
return if (open && id > 0) id else null
} }
} }

View File

@ -36,7 +36,12 @@ data class UserSettings(
val alertE2High: Double? = null, val alertE2High: Double? = null,
val alertE2Low: Double? = null, val alertE2Low: Double? = null,
val alertTHigh: Double? = null, val alertTHigh: Double? = null,
val alertTLow: Double? = null val alertTLow: Double? = null,
// v1.13.0 : cible de creux E2 (opt-in) — champs NULLABLES pour la
// rétrocompatibilité : un backup v1/v2 antérieur ne les a pas → Gson
// les laisse à null → l'import ne touche pas à la cible locale.
val troughTargetLow: Double? = null,
val troughTargetHigh: Double? = null
) )
data class BackupData( data class BackupData(

View File

@ -20,8 +20,10 @@ import kotlin.math.ceil
* classique des **5 demi-vies terminales** (≈ 97 % de l'équilibre). Après * classique des **5 demi-vies terminales** (≈ 97 % de l'équilibre). Après
* un changement d'ester/dose/rythme, les prises intermédiaires reflètent * un changement d'ester/dose/rythme, les prises intermédiaires reflètent
* une transition, pas le régime ; * une transition, pas le régime ;
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0) : le * 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0 ; **fix
* premier créneau d'injection ≥ date de stabilisation du régime courant. * #68 v1.10.0** : la stabilisation se juge sur le CREUX lui-même —
* l'instant où la prise de sang serait faite — pas sur le créneau
* d'injection qui le suit).
* *
* CALCUL (tout est déduit de ce que l'app connaît déjà) : * CALCUL (tout est déduit de ce que l'app connaît déjà) :
* - **traitement porteur** : ESTRADIOL + injectable + ACTIF + Posologie * - **traitement porteur** : ESTRADIOL + injectable + ACTIF + Posologie
@ -36,10 +38,13 @@ import kotlin.math.ceil
* assumé et documenté : l'app ne trace pas l'historique des éditions de * assumé et documenté : l'app ne trace pas l'historique des éditions de
* Posologie/dose, la première dose du traitement est le meilleur * Posologie/dose, la première dose du traitement est le meilleur
* estimateur du début du régime (même sémantique que le « temps sous THS ») ; * estimateur du début du régime (même sémantique que le « temps sous THS ») ;
* - **creux** = minimum local de la courbe E2 PRÉVISIONNELLE (doses réelles * - **creux** = niveau PRÉ-INJECTION du créneau (dernier point de la courbe
* + créneaux [PharmacokineticEngine.generateForecastDoses]) entre deux * E2 prévisionnelle juste avant l'injection — principe 1 ; **fix #68
* injections. La FORME suffit : calcul SANS calibration (le facteur * v1.10.0** : l'ancien minimum de fenêtre entière tombait juste après
* d'échelle est multiplicatif, il ne déplace pas le minimum) ; * l'injection PRÉCÉDENTE pour les esters à montée lente comme l'EEn, dont
* le pic ~J+5 avoisine l'intervalle de 7 j). La FORME suffit : calcul
* SANS calibration (le facteur d'échelle est multiplicatif, il ne
* déplace pas le minimum) ;
* - **filtres** : creux ≥ maintenant + [MIN_HORIZON_HOURS] (pas de * - **filtres** : creux ≥ maintenant + [MIN_HORIZON_HOURS] (pas de
* recommandation quasi-immédiate), creux > dernière prise de sang * recommandation quasi-immédiate), creux > dernière prise de sang
* existante (ne jamais recommander un creux déjà mesuré), créneau ≥ * existante (ne jamais recommander un creux déjà mesuré), créneau ≥
@ -72,6 +77,37 @@ object LabTiming {
/** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */ /** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */
const val MIN_HORIZON_HOURS = 6L const val MIN_HORIZON_HOURS = 6L
/**
* Position d'une valeur par rapport à la CIBLE DE CREUX personnelle
* (v1.13.0, opt-in). ⚠️ DISTINCT des seuils d'alerte (pk/Alerts.kt,
* §9.bis) : les alertes surveillent le niveau estimé EN CONTINU et
* notifient ; la cible de creux est une RÉFÉRENCE DE LAB — « quand je
* pique au creux recommandé, je veux être dans [low, high] » — évaluée
* en lecture seule sur la carte, JAMAIS de notification.
*/
enum class TroughTargetStatus { IN_TARGET, BELOW, ABOVE }
/**
* Statut de [predictedE2] par rapport à la cible [low, high].
* `null` si pas de prédiction OU cible incomplète/désactivée.
* PUR → testable en JVM ; miroir web `troughTargetStatus`
* (js/pk/lab-timing.js).
*/
fun troughTargetStatus(
predictedE2: Double?,
low: Double?,
high: Double?
): TroughTargetStatus? {
val value = predictedE2 ?: return null
val lo = low ?: return null
val hi = high ?: return null
return when {
value < lo -> TroughTargetStatus.BELOW
value > hi -> TroughTargetStatus.ABOVE
else -> TroughTargetStatus.IN_TARGET
}
}
/** Résultat : tout ce que l'UI affiche sur la carte de recommandation. */ /** Résultat : tout ce que l'UI affiche sur la carte de recommandation. */
data class LabRecommendation( data class LabRecommendation(
/** Date/heure du CREUX exact (minimum prévisionnel entre 2 injections). */ /** Date/heure du CREUX exact (minimum prévisionnel entre 2 injections). */
@ -92,7 +128,15 @@ object LabTiming {
* arrive entre maintenant et le creux recommandé (texte « c'est le * arrive entre maintenant et le creux recommandé (texte « c'est le
* premier creux fiable »). * premier creux fiable »).
*/ */
val wasAlreadyStabilized: Boolean val wasAlreadyStabilized: Boolean,
/**
* E2 ATTENDUE à ce creux (v1.13.0) : valeur brute du modèle ×
* facteur d'échelle du ester actif — fourni par l'appelant quand
* la calibration est disponible (auto-calibration ON). `null` =
* pas de calibration fiable → la carte n'affiche PAS de valeur
* (honnêteté : une valeur brute non calibrée serait trompeuse).
*/
val predictedE2: Double? = null
) )
/** /**
@ -107,7 +151,14 @@ object LabTiming {
treatments: List<Treatment>, treatments: List<Treatment>,
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
labs: List<LabResult>, labs: List<LabResult>,
nowMs: Long = System.currentTimeMillis() nowMs: Long = System.currentTimeMillis(),
/**
* Facteurs d'échelle PAR ESTER (v1.13.0) — issus de l'auto-calibration
* quand elle est activée (sinon `null`) : permet de retourner la
* valeur E2 ATTENDUE au creux ([LabRecommendation.predictedE2]).
* Absent → `predictedE2 = null` (carte sans valeur).
*/
scalePerEster: Map<String, Double>? = null
): LabRecommendation? { ): LabRecommendation? {
// ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ───── // ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ─────
val carrier = treatments.firstOrNull { val carrier = treatments.firstOrNull {
@ -133,13 +184,14 @@ object LabTiming {
// ── 3) Début du régime courant + date de stabilisation ────────────── // ── 3) Début du régime courant + date de stabilisation ──────────────
val myDoses = doseLogs.filter { it.treatmentId == carrier.id } val myDoses = doseLogs.filter { it.treatmentId == carrier.id }
if (myDoses.isEmpty()) return null if (myDoses.isEmpty()) return null
val regimeStartMs = regimeStartMs(carrier, myDoses)
val stabilizedAtMs = regimeStartMs +
(STABILIZATION_HALF_LIVES * tHalfDays * 24.0 * 3_600_000.0).toLong()
// Ester EFFECTIF de la dernière dose (override compris) : c'est lui
// qui gouverne les creux futurs et le texte de la carte
val lastDose = myDoses.maxByOrNull { it.timestamp }!! val lastDose = myDoses.maxByOrNull { it.timestamp }!!
val activeEster = PharmacokineticEngine.doseEster(carrier, lastDose) val activeEster = PharmacokineticEngine.doseEster(carrier, lastDose)
// v1.13.0 : régime POOLÉ (cf [pooledRegimeDoses]) — le re-parenting
// d'historique entre traitements (ex. « 6d - old » créé a posteriori,
// remontée v1.12.0) ne doit pas faire croire à un nouveau régime.
val regimeStart = regimeStartMs(carrier, pooledRegimeDoses(carrier, doseLogs, treatments))
val stabilizedAtMs = regimeStart +
(STABILIZATION_HALF_LIVES * tHalfDays * 24.0 * 3_600_000.0).toLong()
// ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ── // ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ──
val intervalDays = carrier.forecastIntervalDays!! val intervalDays = carrier.forecastIntervalDays!!
@ -165,25 +217,47 @@ object LabTiming {
val minTroughMs = nowMs + MIN_HORIZON_HOURS * 3_600_000L val minTroughMs = nowMs + MIN_HORIZON_HOURS * 3_600_000L
var windowStart = nowMs var windowStart = nowMs
for (slot in slots) { for (slot in slots) {
// Le creux du créneau = minimum de la courbe entre l'injection // ⚠️ FIX #68 (v1.10.0) : le creux = niveau PRÉ-INJECTION du
// précédente (ou maintenant) et ce créneau // créneau (dernier point de courbe avant l'injection — principe 1,
val window = curve.filter { it.timestamp > windowStart && it.timestamp < slot.timestamp } // « le moment le plus comparable »). L'ancien MINIMUM DE LA
val trough = window.minByOrNull { it.e2 } // FENÊTRE ENTIÈRE tombait au DÉBUT de la fenêtre pour les esters
// à montée lente (EEn : pic ~J+5 ≈ intervalle 7 j → le minimum
// local est le creux d'absorption, quelques heures après
// l'injection PRÉCÉDENTE) : la date proposée précédait la
// stabilisation affichée sur la MÊME carte, et le texte
// « juste avant ton injection du … » était faux de plusieurs
// jours (remontée v1.9.8, épinglé par RegressionUserCase6Test).
// Pour les esters à t½ courte (EV), le point pré-injection EST
// le minimum de fenêtre : comportement inchangé.
val trough = curve.lastOrNull {
it.timestamp > windowStart && it.timestamp < slot.timestamp
}
if (trough != null && if (trough != null &&
trough.timestamp >= minTroughMs && trough.timestamp >= minTroughMs &&
trough.timestamp > lastLabMs && trough.timestamp > lastLabMs &&
// ON SAUTE au premier creux stabilisé (décision v1.8.0) : // ON SAUTE au premier creux stabilisé (décision v1.8.0) :
// le creux d'un créneau est stabilisé si le CRÉNEAU l'est // le creux pré-injection ≈ instant du créneau, le garde
slot.timestamp >= stabilizedAtMs // exprime la stabilisation sur l'instant réellement proposé
// pour la prise de sang (trough < slot de ~1 pas de courbe).
trough.timestamp >= stabilizedAtMs
) { ) {
return LabRecommendation( return LabRecommendation(
troughMs = trough.timestamp, troughMs = trough.timestamp,
injectionMs = slot.timestamp, injectionMs = slot.timestamp,
ester = activeEster, ester = activeEster,
terminalHalfLifeDays = tHalfDays, terminalHalfLifeDays = tHalfDays,
regimeStartMs = regimeStartMs, regimeStartMs = regimeStart,
stabilizedAtMs = stabilizedAtMs, stabilizedAtMs = stabilizedAtMs,
wasAlreadyStabilized = stabilizedAtMs <= nowMs wasAlreadyStabilized = stabilizedAtMs <= nowMs,
// v1.13.0 : E2 attendue au creux = valeur brute du
// modèle × facteur du ester actif (lookup insensible à
// la casse — les clés de scalePerEster suivent les
// esters stockés). null si pas de calibration.
predictedE2 = scalePerEster
?.entries
?.firstOrNull { it.key.equals(activeEster, ignoreCase = true) }
?.value
?.let { scale -> trough.e2 * scale }
) )
} }
windowStart = slot.timestamp windowStart = slot.timestamp
@ -194,6 +268,52 @@ object LabTiming {
/** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */ /** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */
private const val DOSE_EPS = 1e-6 private const val DOSE_EPS = 1e-6
/**
* SÉQUENCE DE RÉGIME POOLÉE (v1.13.0) : toutes les doses de TOUS les
* traitements partageant l'**ester effectif** du porteur ET son **mg
* exact** (± [DOSE_EPS]), triées par timestamp.
*
* **Pourquoi** (remontée v1.12.0) : le re-parenting d'historique — créer
* un traitement « … - old » inactif et y déplacer des doses (ex. séparer
* une ère « 6 j » d'une ère « 7 j ») — créait un TROU synthétique dans
* l'historique du porteur : le régime redémarrait à la date du
* re-parenting et la stabilisation (5 × t½) repartait de plusieurs
* semaines, alors que pharmacologiquement rien n'avait changé (même
* ester, même dose).
*
* Garde-fous (épinglés par tests) :
* - **ester ≠ → pas de mutualisation** (transition EV → EEn : deux
* régimes distincts — régression n°3) ;
* - **dose ≠ → exclue du pool** (une dose « 8 mg » au milieu d'un
* régime « 5 mg » casse naturellement la séquence via le trou qui
* suit — conservateur, ~1 semaine du côté prudent).
*
* PUR → testable en JVM (DosesExtras? non — LabTimingTest +
* RegressionUserCase7Test) ; miroir web `pooledRegimeDoses`
* (js/pk/lab-timing.js).
*/
internal fun pooledRegimeDoses(
carrier: Treatment,
allDoseLogs: List<DoseLog>,
allTreatments: List<Treatment>
): List<DoseLog> {
val last = allDoseLogs
.filter { it.treatmentId == carrier.id }
.maxByOrNull { it.timestamp } ?: return emptyList()
val activeEster = PharmacokineticEngine.doseEster(carrier, last)
val amount = last.doseAmount
val ownerById = allTreatments.associateBy { it.id }
return allDoseLogs
.filter { d ->
// Ester EFFECTIF via le traitement PROPRIÉTAIRE de la dose
// (override par dose compris) — jamais via le porteur
val owner = ownerById[d.treatmentId] ?: return@filter false
PharmacokineticEngine.doseEster(owner, d) == activeEster &&
Math.abs(d.doseAmount - amount) < DOSE_EPS
}
.sortedBy { it.timestamp }
}
/** /**
* Tolérance de l'écart inter-doses du régime (v1.9.7) : ± 24 h autour de * Tolérance de l'écart inter-doses du régime (v1.9.7) : ± 24 h autour de
* l'intervalle de Posologie — « je m'injecte le même jour de la semaine, * l'intervalle de Posologie — « je m'injecte le même jour de la semaine,
@ -218,9 +338,11 @@ object LabTiming {
* 12:00, repoussant la stabilisation de 5 × t½ à CHAQUE injection : le * 12:00, repoussant la stabilisation de 5 × t½ à CHAQUE injection : le
* creux recommandé fuyait vers l'avant sans jamais se fixer. * creux recommandé fuyait vers l'avant sans jamais se fixer.
* *
* **RÈGLE** : le régime courant = la **séquence terminale de doses** où * **RÈGLE** : le régime courant = la **séquence terminale de doses**
* (a) l'**ester effectif** et la **dose (mg)** sont identiques à celles * (v1.13.0 : **poolée** entre traitements — cf [pooledRegimeDoses] — le
* de la dose la plus récente, ET (b) chaque **écart entre doses * re-parenting d'historique ne doit plus redémarrer la stabilisation)
* où (a) l'**ester effectif** et la **dose (mg)** sont identiques à
* celles de la dose la plus récente, ET (b) chaque **écart entre doses
* consécutives reste dans l'INTERVALLE DE POSOLOGIE ± 24 h** * consécutives reste dans l'INTERVALLE DE POSOLOGIE ± 24 h**
* ([GAP_TOLERANCE_MS] — « je m'injecte le même jour, à l'heure près »). * ([GAP_TOLERANCE_MS] — « je m'injecte le même jour, à l'heure près »).
* On remonte depuis la dose la plus récente tant que ces conditions * On remonte depuis la dose la plus récente tant que ces conditions

View File

@ -416,6 +416,14 @@ object PharmacokineticEngine {
): List<DoseLog> { ): List<DoseLog> {
val intervalDays = treatment.forecastIntervalDays ?: return emptyList() val intervalDays = treatment.forecastIntervalDays ?: return emptyList()
if (intervalDays <= 0.0) return emptyList() if (intervalDays <= 0.0) return emptyList()
// ⚠️ FIX #70 (v1.12.0, demande utilisatrice) : un traitement INACTIF ne
// projette PLUS de créneaux — l'archivage arrête les prédictions
// d'injections futures (graphique Prévision, extension de fenêtre,
// horizon LabTiming). Cohérent avec les rappels (nextReminderFireFor
// garde déjà !isActive). ⚠️ §6.bis inchangé : l'INACTIF reste simulé
// pour son HISTORIQUE (computeCurve ne passe pas ici) — seul le
// FUTUR s'arrête.
if (!treatment.isActive) return emptyList()
val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong() val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong()
if (intervalMs <= 0L) return emptyList() if (intervalMs <= 0L) return emptyList()
@ -736,6 +744,32 @@ object PharmacokineticEngine {
return current.copy(k = medianRounded(ks, 3)) return current.copy(k = medianRounded(ks, 3))
} }
/**
* Une dose de CE traitement est-elle déjà loguée LE JOUR de [atMs]
* (jour calendaire LOCAL — fuseau paramétrable pour la testabilité) ?
*
* v1.10.0 (demande utilisatrice) : si la dose prévue du jour est
* saisie AVANT l'heure de rappel, la notification ne doit PAS sonner
* pour une prise déjà faite — le rappel suivant repart au créneau
* SUIVANT de la grille (le receiver re-programme dans tous les cas).
* S'applique identiquement au fallback quotidien (oral sans Posologie :
* dose loguée aujourd'hui → pas de notif aujourd'hui, demain re-part).
*
* PUR (aucune dépendance Android) → testable en JVM (ReminderLoggedDayTest).
*/
fun hasDoseLoggedOnDay(
treatmentId: Long,
doseLogs: List<DoseLog>,
atMs: Long,
zoneId: java.time.ZoneId = java.time.ZoneId.systemDefault()
): Boolean {
val day = java.time.Instant.ofEpochMilli(atMs).atZone(zoneId).toLocalDate()
return doseLogs.any {
it.treatmentId == treatmentId &&
java.time.Instant.ofEpochMilli(it.timestamp).atZone(zoneId).toLocalDate() == day
}
}
/** /**
* Prochain déclenchement de rappel pour UN traitement (v1.4.0). * Prochain déclenchement de rappel pour UN traitement (v1.4.0).
* *

View File

@ -43,7 +43,14 @@ class DoseActionReceiver : BroadcastReceiver() {
ReminderContract.ACTION_SNOOZE -> { ReminderContract.ACTION_SNOOZE -> {
val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: "" val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: ""
val dose = intent.getDoubleExtra(ReminderContract.EXTRA_DOSE_AMOUNT, 0.0) val dose = intent.getDoubleExtra(ReminderContract.EXTRA_DOSE_AMOUNT, 0.0)
AlarmScheduler(context).scheduleSnooze(treatmentId, name, dose) // v1.10.1 : le snooze FORWARDE l'instant planifié d'origine —
// le garde du feu différé évalue le jour du CRÉNEAU, pas
// celui du snooze (sinon un snooze à cheval sur minuit
// évaluerait le mauvais jour).
val plannedAt = if (intent.hasExtra(ReminderContract.EXTRA_PLANNED_AT)) {
intent.getLongExtra(ReminderContract.EXTRA_PLANNED_AT, 0L)
} else null
AlarmScheduler(context).scheduleSnooze(treatmentId, name, dose, plannedAt = plannedAt)
} }
} }
} }

View File

@ -15,11 +15,11 @@ import com.hormonetrack.data.AppDatabase
import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.util.AppLog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
object ReminderContract { object ReminderContract {
const val CHANNEL_ID = "hormonetrack_reminders" const val CHANNEL_ID = "hormonetrack_reminders"
@ -30,6 +30,17 @@ object ReminderContract {
const val EXTRA_TREATMENT_NAME = "treatment_name" const val EXTRA_TREATMENT_NAME = "treatment_name"
const val EXTRA_DOSE_AMOUNT = "dose_amount" const val EXTRA_DOSE_AMOUNT = "dose_amount"
/**
* Instant PLANIFIÉ de l'alarme (v1.10.1) — embarqué par
* AlarmScheduler.scheduleFor au moment du scheduling et relu par
* [ReminderReceiver]. ⚠️ Nécessaire car une alarme INEXACTE (setWindow
* sans SCHEDULE_EXACT_ALARM) ou retardée (Doze) peut fire après la
* frontière de jour : le garde « créneau déjà honoré » doit évaluer le
* jour PLANIFIÉ, pas le jour réel du feu. Absent (vieux PendingIntents)
* → le receiver retombe sur l'heure courante.
*/
const val EXTRA_PLANNED_AT = "planned_at"
fun reminderIntent(context: Context, treatmentId: Long): Intent = fun reminderIntent(context: Context, treatmentId: Long): Intent =
Intent(context, ReminderReceiver::class.java).apply { Intent(context, ReminderReceiver::class.java).apply {
action = ACTION_REMINDER action = ACTION_REMINDER
@ -53,10 +64,16 @@ class AlarmScheduler(private val context: Context) {
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
private fun pendingBroadcast(treatmentId: Long, name: String?, dose: Double?): PendingIntent { private fun pendingBroadcast(
treatmentId: Long,
name: String?,
dose: Double?,
plannedAt: Long? = null
): PendingIntent {
val intent = ReminderContract.reminderIntent(context, treatmentId) val intent = ReminderContract.reminderIntent(context, treatmentId)
name?.let { intent.putExtra(ReminderContract.EXTRA_TREATMENT_NAME, it) } name?.let { intent.putExtra(ReminderContract.EXTRA_TREATMENT_NAME, it) }
dose?.let { intent.putExtra(ReminderContract.EXTRA_DOSE_AMOUNT, it) } dose?.let { intent.putExtra(ReminderContract.EXTRA_DOSE_AMOUNT, it) }
plannedAt?.let { intent.putExtra(ReminderContract.EXTRA_PLANNED_AT, it) }
return PendingIntent.getBroadcast( return PendingIntent.getBroadcast(
context, context,
treatmentId.toInt(), treatmentId.toInt(),
@ -80,12 +97,27 @@ class AlarmScheduler(private val context: Context) {
val fireAt = PharmacokineticEngine.nextReminderFireFor( val fireAt = PharmacokineticEngine.nextReminderFireFor(
treatment, doseLogs, System.currentTimeMillis() treatment, doseLogs, System.currentTimeMillis()
) ?: return false ) ?: return false
val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount) // L'instant planifié voyage dans l'intent : le garde « créneau déjà
// honoré » (v1.10.1) évalue le jour PLANIFIÉ, pas celui du feu réel
// (une alarme inexacte peut glisser après minuit — cf
// ReminderContract.EXTRA_PLANNED_AT).
val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount, fireAt)
return scheduleAt(fireAt, pi) return scheduleAt(fireAt, pi)
} }
fun scheduleSnooze(treatmentId: Long, name: String, dose: Double, delayMs: Long = 60 * 60_000L): Boolean { /**
val pi = pendingBroadcast(treatmentId, name, dose) * Alarme « Reporter 1 h ». [plannedAt] : l'instant PLANIFIÉ du créneau
* d'origine — forwardé tel quel pour que le garde du feu différé
* évalue toujours le bon jour (et non le jour du snooze).
*/
fun scheduleSnooze(
treatmentId: Long,
name: String,
dose: Double,
delayMs: Long = 60 * 60_000L,
plannedAt: Long? = null
): Boolean {
val pi = pendingBroadcast(treatmentId, name, dose, plannedAt)
return scheduleAt(System.currentTimeMillis() + delayMs, pi) return scheduleAt(System.currentTimeMillis() + delayMs, pi)
} }
@ -208,19 +240,41 @@ class ReminderReceiver : BroadcastReceiver() {
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(treatmentId.toInt(), notification)
// ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer // ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer
// ICI le déclenchement SUIVANT (v1.4.0, fix #52) — sinon le rappel suivant // ICI le déclenchement SUIVANT (v1.4.0, fix #52) — sinon le rappel suivant
// n'existait qu'après un reboot/une édition. Le calcul passe par le moteur // n'existait qu'après un reboot/une édition. Le calcul passe par le moteur
// (grille Posologie si elle existe, sinon quotidien). // (grille Posologie si elle existe, sinon quotidien).
// v1.10.0 (demande utilisatrice) : si la dose du jour est DÉJÀ loguée
// (prise saisie avant l'heure de rappel), la notification est SAUTÉE —
// elle ne doit pas sonner pour une prise déjà faite — et le rappel
// suivant repart au créneau suivant (re-schedule dans tous les cas).
// v1.10.1 : le jour évalué est celui du créneau PLANIFIÉ
// (EXTRA_PLANNED_AT), pas celui du feu réel — une alarme inexacte
// peut glisser après minuit sans changer de créneau.
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val plannedAtMs = if (intent.hasExtra(ReminderContract.EXTRA_PLANNED_AT)) {
intent.getLongExtra(ReminderContract.EXTRA_PLANNED_AT, 0L)
} else {
System.currentTimeMillis()
}
val pending = goAsync() val pending = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try { try {
val db = AppDatabase.getInstance(context) val db = AppDatabase.getInstance(context)
val doses = db.doseLogDao().getAllOnce()
if (PharmacokineticEngine.hasDoseLoggedOnDay(
treatmentId, doses, plannedAtMs
)
) {
AppLog.log(
"reminder",
"créneau déjà honoré (dose loguée le jour planifié) → notification sautée (treatment=$treatmentId)"
)
} else {
manager.notify(treatmentId.toInt(), notification)
}
db.treatmentDao().getById(treatmentId)?.let { tr -> db.treatmentDao().getById(treatmentId)?.let { tr ->
AlarmScheduler(context).scheduleFor(tr, db.doseLogDao().getAllOnce()) AlarmScheduler(context).scheduleFor(tr, doses)
} }
} finally { } finally {
pending.finish() pending.finish()
@ -232,23 +286,23 @@ class ReminderReceiver : BroadcastReceiver() {
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val result = goAsync() // v1.10.1 : consolidation goAsync + coroutine (pattern ReminderReceiver —
Thread { // remplace le Thread + double runBlocking de la v1.0, dette §20.bis #2) :
// une seule lecture séquentielle des DAO dans un scope IO dédié, finish()
// garanti par le finally.
val pending = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try { try {
val db = AppDatabase.getInstance(context) val db = AppDatabase.getInstance(context)
// TOUS les traitements : les inactifs doivent voir leur alarme // TOUS les traitements : les inactifs doivent voir leur alarme
// annulée au boot (sinon une alarme périmée continue de sonner) ; // annulée au boot (sinon une alarme périmée continue de sonner) ;
// les doses alimentent la grille Posologie (fix #52) // les doses alimentent la grille Posologie (fix #52)
val treatments = runBlocking { val treatments = db.treatmentDao().getAllOnce()
db.treatmentDao().getAllOnce() val doses = db.doseLogDao().getAllOnce()
}
val doses = runBlocking {
db.doseLogDao().getAllOnce()
}
AlarmScheduler(context).rescheduleAll(treatments, doses) AlarmScheduler(context).rescheduleAll(treatments, doses)
} finally { } finally {
result.finish() pending.finish()
} }
}.start() }
} }
} }

View File

@ -79,6 +79,15 @@ class AppSettings(private val context: Context) {
/** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */ /** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */
val AUTO_BACKUP_LAST_AT = longPreferencesKey("auto_backup_last_at") val AUTO_BACKUP_LAST_AT = longPreferencesKey("auto_backup_last_at")
// ── Cible de creux E2 (v1.13.0, opt-in) ────────────────────────────
// Référence PERSONNELLE pour la prochaine prise de sang (« je veux
// être dans [low, high] au creux recommandé »). DISTINCT des seuils
// d'alerte (ALERT_E2_*), qui surveillent le niveau en continu —
// les deux bandes sont volontairement indépendantes. Champ absent =
// cible désactivée (comportement v1.12).
val TROUGH_TARGET_LOW = doublePreferencesKey("trough_target_low")
val TROUGH_TARGET_HIGH = doublePreferencesKey("trough_target_high")
} }
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs -> val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
@ -221,6 +230,30 @@ class AppSettings(private val context: Context) {
} }
} }
/** Cible de creux (v1.13.0) : `null` si désactivée (un champ manquant suffit). */
val troughTarget: Flow<Pair<Double, Double>?> = context.dataStore.data.map { prefs ->
val low = prefs[Keys.TROUGH_TARGET_LOW]
val high = prefs[Keys.TROUGH_TARGET_HIGH]
if (low != null && high != null) low to high else null
}
/**
* Persiste la cible de creux (v1.13.0). `null` EFFACE les deux clés —
* « champs vides = cible désactivée ». La cohérence high > low est
* validée dans l'UI (TroughTargetCard), comme pour les seuils d'alerte.
*/
suspend fun setTroughTarget(target: Pair<Double, Double>?) {
context.dataStore.edit { prefs ->
if (target == null) {
prefs.remove(Keys.TROUGH_TARGET_LOW)
prefs.remove(Keys.TROUGH_TARGET_HIGH)
} else {
prefs[Keys.TROUGH_TARGET_LOW] = target.first
prefs[Keys.TROUGH_TARGET_HIGH] = target.second
}
}
}
/** /**
* Persiste les seuils d'alerte (v1.4.2). Les valeurs null EFFACENT la clé * Persiste les seuils d'alerte (v1.4.2). Les valeurs null EFFACENT la clé
* (DataStore : remove) — « champ vide = pas d'alerte ». * (DataStore : remove) — « champ vide = pas d'alerte ».

View File

@ -46,7 +46,7 @@ val LocalAppContainer = compositionLocalOf<AppContainer> {
private data class TabItem(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector) private data class TabItem(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector)
@Composable @Composable
fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) { fun HormoneTrackRoot(logDoseRequest: androidx.compose.runtime.MutableState<Long?>) {
val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
@ -132,7 +132,11 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
) { ) {
composable("home") { composable("home") {
HomeScreen( HomeScreen(
openLogDoseForTreatmentId = if (initialOpenLogDose) initialTreatmentId else null, // Demande de notification : état détenu par l'activité
// (survit aux onglets), consommé UNE fois par HomeScreen
// (fix #69 — dialog fantôme au retour sur l'accueil)
openLogDoseForTreatmentId = logDoseRequest.value,
onLogDoseConsumed = { logDoseRequest.value = null },
onOpenSettings = { navController.navigate("settings") }, onOpenSettings = { navController.navigate("settings") },
onOpenCharts = { onOpenCharts = {
// Tap sur le mini-graphique de l'accueil → écran Graphiques // Tap sur le mini-graphique de l'accueil → écran Graphiques

View File

@ -209,7 +209,14 @@ fun ChartScreen() {
// Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné // Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné
// [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt). // [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt).
val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull() // ⚠️ FIX #70 (v1.12.0) : SEULS les traitements ACTIFS comptent — la
// Posologie d'un traitement archivé ne doit ni activer le chip
// Prévision ni étendre l'horizon (le moteur ne projette plus ses
// créneaux, cf generateForecastDoses).
val maxIntervalDays = treatments
.filter { it.isActive }
.mapNotNull { it.forecastIntervalDays }
.maxOrNull()
val forecastHorizonHours = forecastHorizonHours(maxIntervalDays) val forecastHorizonHours = forecastHorizonHours(maxIntervalDays)
// ── Fenêtre temporelle (v1.4.1, extension v1.4.4, stabilisation v1.4.5) ── // ── Fenêtre temporelle (v1.4.1, extension v1.4.4, stabilisation v1.4.5) ──

View File

@ -2,17 +2,24 @@ package com.hormonetrack.ui.screens
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Colorize
import androidx.compose.material.icons.filled.Healing
import androidx.compose.material.icons.filled.Medication
import androidx.compose.material.icons.filled.Science
import androidx.compose.material.icons.filled.Vaccines
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card import androidx.compose.material3.Card
@ -22,6 +29,7 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -33,13 +41,20 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.DoseDialog import com.hormonetrack.ui.components.DoseDialog
import com.hormonetrack.ui.theme.LabDot
import com.hormonetrack.util.HrtDuration import com.hormonetrack.util.HrtDuration
import com.hormonetrack.ui.components.formatDose import com.hormonetrack.ui.components.formatDose
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -49,6 +64,54 @@ import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
/**
* Icône de FORME DE PRISE (v1.12.0, demande utilisatrice : « des icônes pour
* bien séparer les différentes formes de prises des traitements »). Mapping :
* ORAL = pilule (Medication) ; IM/SC = seringue (Vaccines — le glyphe est
* partagé, la TEinte les distingue côté UI : IM = couleur courante, SC =
* tertiary) ; GEL = goutte (WaterDrop) ; PATCH = sparadrap (Healing) ;
* OTHER/inconnu = compte-gouttes (Colorize). PUR → testable en JVM
* (DosesExtrasTest) ; miroir web `routeGlyph` (js/data/models.js).
*/
fun routeIcon(route: AdministrationRoute?): ImageVector = when (route) {
AdministrationRoute.ORAL -> Icons.Filled.Medication
AdministrationRoute.INJECTION_IM -> Icons.Filled.Vaccines
AdministrationRoute.INJECTION_SUBCUT -> Icons.Filled.Vaccines
AdministrationRoute.TRANSDERMAL_GEL -> Icons.Filled.WaterDrop
AdministrationRoute.TRANSDERMAL_PATCH -> Icons.Filled.Healing
else -> Icons.Filled.Colorize
}
/**
* Marqueurs d'ANALYSE DE SANG pour UNE dose (v1.12.0, demande utilisatrice :
* « une petite indication … dans le cas où un test sanguin a été effectué
* avant ou après une prise — peut-être un petit encart sur le côté »).
*
* Une analyse compte si elle tombe le MÊME JOUR calendaire LOCAL que la
* dose : au-delà, le lien clinique (labs au creux/pic autour d'une
* injection) devient ténu et chaque dose finirait marquée.
*
* @return Pair(AVANT la dose, APRÈS la dose) — une analyse exactement À
* l'heure de la dose compte comme APRÈS (convention arbitraire documentée).
* PUR → testable en JVM (DosesExtrasTest) ; miroir web `labMarkersForDose`
* (js/util/format.js).
*/
fun labMarkersForDose(
labs: List<LabResult>,
doseMs: Long,
zone: ZoneId = ZoneId.systemDefault()
): Pair<Boolean, Boolean> {
val day = Instant.ofEpochMilli(doseMs).atZone(zone).toLocalDate()
var before = false
var after = false
for (lab in labs) {
if (Instant.ofEpochMilli(lab.timestamp).atZone(zone).toLocalDate() == day) {
if (lab.timestamp < doseMs) before = true else after = true
}
}
return before to after
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DosesScreen() { fun DosesScreen() {
@ -56,6 +119,8 @@ fun DosesScreen() {
val repo = container.repository val repo = container.repository
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList()) val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
val treatments by repo.allTreatments.collectAsState(initial = emptyList()) val treatments by repo.allTreatments.collectAsState(initial = emptyList())
// v1.12.0 : les labs alimentent les marqueurs « prise de sang avant/après »
val labResults by repo.allLabResults.collectAsState(initial = emptyList())
val treatmentMap = remember(treatments) { treatments.associateBy { it.id } } val treatmentMap = remember(treatments) { treatments.associateBy { it.id } }
var showLogDialog by remember { mutableStateOf(false) } var showLogDialog by remember { mutableStateOf(false) }
@ -154,6 +219,24 @@ fun DosesScreen() {
val time = timeFmt.format(Instant.ofEpochMilli(log.timestamp).atZone(zone)) val time = timeFmt.format(Instant.ofEpochMilli(log.timestamp).atZone(zone))
ListItem( ListItem(
modifier = Modifier.clickable { toEdit = log }, modifier = Modifier.clickable { toEdit = log },
// v1.12.0 : icône de FORME DE PRISE à gauche —
// oral / injection IM / injection SC / gel /
// patch / autre se repèrent d'un coup d'œil.
leadingContent = {
Icon(
routeIcon(tr?.route),
contentDescription = tr?.let {
stringResource(routeLabelRes(it.route))
},
tint = if (tr?.route == AdministrationRoute.INJECTION_SUBCUT) {
// IM/SC partagent le glyphe seringue :
// la teinte tertiary distingue la SC
MaterialTheme.colorScheme.tertiary
} else {
LocalContentColor.current
}
)
},
headlineContent = { headlineContent = {
Text("${tr?.name ?: "?"} · ${formatDose(log.doseAmount)} ${tr?.doseUnit ?: ""}") Text("${tr?.name ?: "?"} · ${formatDose(log.doseAmount)} ${tr?.doseUnit ?: ""}")
}, },
@ -178,8 +261,16 @@ fun DosesScreen() {
} }
}, },
trailingContent = { trailingContent = {
IconButton(onClick = { toDelete = log }) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete)) // v1.12.0 : encart latéral « prise de sang
// le même jour » — ↑ = lab AVANT la dose,
// ↓ = lab APRÈS (cf labMarkersForDose)
val (labBefore, labAfter) = labMarkersForDose(labResults, log.timestamp, zone)
if (labBefore) LabSideMarker(up = true)
if (labAfter) LabSideMarker(up = false)
IconButton(onClick = { toDelete = log }) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
}
} }
} }
) )
@ -234,11 +325,37 @@ fun DosesScreen() {
CoroutineScope(Dispatchers.IO).launch { repo.deleteDoseLog(log) } CoroutineScope(Dispatchers.IO).launch { repo.deleteDoseLog(log) }
}) { Text(stringResource(R.string.delete)) } }) { Text(stringResource(R.string.delete)) }
}, },
dismissButton = { dismissButton = { TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) } },
TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) }
},
title = { Text(stringResource(R.string.delete)) }, title = { Text(stringResource(R.string.delete)) },
text = { Text(stringResource(R.string.confirm_delete)) } text = { Text(stringResource(R.string.confirm_delete)) }
) )
} }
} }
/**
* Encart latéral « prise de sang le même jour que cette dose » (v1.12.0,
* demande utilisatrice) : icône laboratoire + flèche — **↑** = analyse
* effectuée AVANT la dose, **↓** = APRÈS. La description sémantique porte
* la phrase complète (accessibilité TalkBack) ; le glyphe seul est décoratif
* (contentDescription = null sur l'icône, description portée par le Row).
*/
@Composable
private fun LabSideMarker(up: Boolean) {
val description = stringResource(if (up) R.string.lab_before_dose else R.string.lab_after_dose)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.semantics { contentDescription = description }
) {
Icon(
Icons.Filled.Science,
contentDescription = null,
tint = LabDot,
modifier = Modifier.size(16.dp)
)
Text(
if (up) "↑" else "↓",
style = MaterialTheme.typography.labelSmall,
color = LabDot
)
}
}

View File

@ -78,6 +78,7 @@ private const val HOUR_MS = 3_600_000L
@Composable @Composable
fun HomeScreen( fun HomeScreen(
openLogDoseForTreatmentId: Long?, openLogDoseForTreatmentId: Long?,
onLogDoseConsumed: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenCharts: () -> Unit, onOpenCharts: () -> Unit,
onOpenTreatment: (Long) -> Unit onOpenTreatment: (Long) -> Unit
@ -113,10 +114,16 @@ fun HomeScreen(
var showLogDialog by remember { mutableStateOf(false) } var showLogDialog by remember { mutableStateOf(false) }
var preselectId by remember { mutableStateOf<Long?>(null) } var preselectId by remember { mutableStateOf<Long?>(null) }
// Demande de notification : ouvrir le dialog UNE SEULE FOIS puis
// CONSOMMER la demande au niveau activité (fix #69 — l'ancien code
// relançait le dialog à chaque RÉ-ENTRÉE en composition de Home :
// quitter l'onglet détruit les remember locaux, le paramètre d'intent
// lui survivait → retour sur l'accueil = dialog fantôme à chaque fois).
LaunchedEffect(openLogDoseForTreatmentId) { LaunchedEffect(openLogDoseForTreatmentId) {
openLogDoseForTreatmentId?.takeIf { it > 0 }?.let { openLogDoseForTreatmentId?.takeIf { it > 0 }?.let {
showLogDialog = true showLogDialog = true
preselectId = it preselectId = it
onLogDoseConsumed()
} }
} }
@ -234,14 +241,20 @@ fun HomeScreen(
// (l'accueil ne fait pas doublon d'explication). // (l'accueil ne fait pas doublon d'explication).
val labRecommendation by produceState( val labRecommendation by produceState(
initialValue = null as LabTiming.LabRecommendation?, initialValue = null as LabTiming.LabRecommendation?,
allTreatments, doseLogs, labResults, tick allTreatments, doseLogs, labResults, tick, effectiveAuto
) { ) {
value = withContext(Dispatchers.Default) { value = withContext(Dispatchers.Default) {
// v1.13.0 : les facteurs d'échelle de l'auto-calibration
// alimentent la prédiction « ≈ X pg/mL au creux » (null
// si l'option est OFF → carte sans valeur, inchangée)
LabTiming.nextBloodDrawRecommendation( LabTiming.nextBloodDrawRecommendation(
allTreatments, doseLogs, labResults, System.currentTimeMillis() allTreatments, doseLogs, labResults, System.currentTimeMillis(),
effectiveAuto?.esterScales
) )
} }
} }
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
labRecommendation?.let { rec -> labRecommendation?.let { rec ->
val fmtDate = remember { val fmtDate = remember {
DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault()) DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault())
@ -263,6 +276,37 @@ fun HomeScreen(
stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))), stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))),
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si
// la calibration est disponible) + statut face à la
// cible de creux personnelle (lecture seule — jamais
// de notification, distinct des seuils d'alerte)
rec.predictedE2?.let { predicted ->
val targetStatus = LabTiming.troughTargetStatus(
predicted, troughTarget?.first, troughTarget?.second
)
Text(
stringResource(
R.string.labrec_predicted,
String.format(java.util.Locale.getDefault(), "%.0f", predicted)
) + when (targetStatus) {
LabTiming.TroughTargetStatus.IN_TARGET ->
" — " + stringResource(R.string.labrec_in_target)
LabTiming.TroughTargetStatus.BELOW,
LabTiming.TroughTargetStatus.ABOVE ->
" — " + stringResource(R.string.labrec_out_of_target)
null -> ""
},
style = MaterialTheme.typography.bodyMedium,
color = when (targetStatus) {
LabTiming.TroughTargetStatus.IN_TARGET ->
MaterialTheme.colorScheme.primary
LabTiming.TroughTargetStatus.BELOW,
LabTiming.TroughTargetStatus.ABOVE ->
MaterialTheme.colorScheme.tertiary
null -> MaterialTheme.colorScheme.onSurface
}
)
}
Text( Text(
stringResource( stringResource(
R.string.labrec_before_injection, R.string.labrec_before_injection,

View File

@ -40,6 +40,8 @@ import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.pk.LabTiming import com.hormonetrack.pk.LabTiming
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.LabDialog import com.hormonetrack.ui.components.LabDialog
import com.hormonetrack.ui.components.formatLabValue import com.hormonetrack.ui.components.formatLabValue
@ -143,14 +145,30 @@ fun LabsScreen() {
} }
} }
val nowMs = remember(tick) { System.currentTimeMillis() } val nowMs = remember(tick) { System.currentTimeMillis() }
// Auto-calibration (v1.13.0) : alimente la prédiction « ≈ X pg/mL » de la
// carte reco (null si l'option est OFF) — même pattern que HomeScreen.
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>(
null, treatments, doseLogs, labs, tConfig, autoCalibrate
) {
value = if (!autoCalibrate) null
else withContext(Dispatchers.Default) {
PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labs, tConfig)
}
}
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
// Calcul HORS UI thread (produceState) — c'est un calcul PK complet // Calcul HORS UI thread (produceState) — c'est un calcul PK complet
// (courbe prévisionnelle), même discipline que Home/Chart. // (courbe prévisionnelle), même discipline que Home/Chart.
val recommendation by produceState( val recommendation by produceState(
initialValue = null as LabTiming.LabRecommendation?, initialValue = null as LabTiming.LabRecommendation?,
treatments, doseLogs, labs, nowMs treatments, doseLogs, labs, nowMs, effectiveAuto
) { ) {
value = withContext(Dispatchers.Default) { value = withContext(Dispatchers.Default) {
LabTiming.nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs) LabTiming.nextBloodDrawRecommendation(
treatments, doseLogs, labs, nowMs, effectiveAuto?.esterScales
)
} }
} }
// Invite « renseigne une Posologie » (v1.8.0, demande explicite) : quand // Invite « renseigne une Posologie » (v1.8.0, demande explicite) : quand
@ -193,6 +211,36 @@ fun LabsScreen() {
stringResource(R.string.labrec_when, fmt.format(Instant.ofEpochMilli(r.troughMs))), stringResource(R.string.labrec_when, fmt.format(Instant.ofEpochMilli(r.troughMs))),
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si
// la calibration est disponible) + statut cible —
// lecture seule, jamais de notification (cf §9.bis)
r.predictedE2?.let { predicted ->
val targetStatus = LabTiming.troughTargetStatus(
predicted, troughTarget?.first, troughTarget?.second
)
Text(
stringResource(
R.string.labrec_predicted,
String.format(java.util.Locale.getDefault(), "%.0f", predicted)
) + when (targetStatus) {
LabTiming.TroughTargetStatus.IN_TARGET ->
" — " + stringResource(R.string.labrec_in_target)
LabTiming.TroughTargetStatus.BELOW,
LabTiming.TroughTargetStatus.ABOVE ->
" — " + stringResource(R.string.labrec_out_of_target)
null -> ""
},
style = MaterialTheme.typography.bodyMedium,
color = when (targetStatus) {
LabTiming.TroughTargetStatus.IN_TARGET ->
MaterialTheme.colorScheme.primary
LabTiming.TroughTargetStatus.BELOW,
LabTiming.TroughTargetStatus.ABOVE ->
MaterialTheme.colorScheme.tertiary
null -> MaterialTheme.colorScheme.onSurface
}
)
}
Text( Text(
stringResource( stringResource(
R.string.labrec_before_injection, R.string.labrec_before_injection,

View File

@ -48,6 +48,7 @@ import com.hormonetrack.ui.screens.settings.ChartTimezoneCard
import com.hormonetrack.ui.screens.settings.LanguageCard import com.hormonetrack.ui.screens.settings.LanguageCard
import com.hormonetrack.ui.screens.settings.LogsCard import com.hormonetrack.ui.screens.settings.LogsCard
import com.hormonetrack.ui.screens.settings.RemindersCard import com.hormonetrack.ui.screens.settings.RemindersCard
import com.hormonetrack.ui.screens.settings.TroughTargetCard
import com.hormonetrack.ui.screens.settings.TModelCard import com.hormonetrack.ui.screens.settings.TModelCard
import com.hormonetrack.util.AppLog import com.hormonetrack.util.AppLog
import com.hormonetrack.util.ExportFileNames import com.hormonetrack.util.ExportFileNames
@ -84,6 +85,9 @@ fun SettingsScreen(onBack: () -> Unit) {
val alertThresholds by container.settings.alertThresholds.collectAsState( val alertThresholds by container.settings.alertThresholds.collectAsState(
initial = Alerts.Thresholds() initial = Alerts.Thresholds()
) )
// Cible de creux (v1.13.0) : référence de lab personnelle — distincte
// des seuils d'alerte (cf TroughTargetCard)
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
// Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone // Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone
val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?) val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?)
@ -170,7 +174,11 @@ fun SettingsScreen(onBack: () -> Unit) {
alertE2High = thresholds.e2High, alertE2High = thresholds.e2High,
alertE2Low = thresholds.e2Low, alertE2Low = thresholds.e2Low,
alertTHigh = thresholds.tHigh, alertTHigh = thresholds.tHigh,
alertTLow = thresholds.tLow alertTLow = thresholds.tLow,
// v1.13.0 : la cible de creux voyage dans le backup (champs
// optionnels — absents d'un backup v1/v2 ancien, rétrocompatibles)
troughTargetLow = container.settings.troughTarget.first()?.first,
troughTargetHigh = container.settings.troughTarget.first()?.second
) )
val json = BackupManager.exportJson(repo, tConfig, settings) val json = BackupManager.exportJson(repo, tConfig, settings)
val ok = BackupManager.writeBackup(context, uri, json) val ok = BackupManager.writeBackup(context, uri, json)
@ -268,6 +276,8 @@ fun SettingsScreen(onBack: () -> Unit) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
AlertThresholdsCard(alertThresholds, showMessage) AlertThresholdsCard(alertThresholds, showMessage)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
TroughTargetCard(troughTarget, showMessage)
Spacer(Modifier.height(12.dp))
ChartTimezoneCard(chartTimezone, showMessage) ChartTimezoneCard(chartTimezone, showMessage)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
RemindersCard() RemindersCard()
@ -342,6 +352,18 @@ fun SettingsScreen(onBack: () -> Unit) {
tHigh = us.alertTHigh, tLow = us.alertTLow tHigh = us.alertTHigh, tLow = us.alertTLow
) )
) )
// v1.13.0 : cible de creux — restaurée SEULEMENT
// si le backup en porte une (champs non nuls
// ET cohérents) ; sinon on ne touche à rien
// (un backup ancien ne doit pas effacer la
// cible locale).
if (us.troughTargetLow != null && us.troughTargetHigh != null &&
us.troughTargetHigh > us.troughTargetLow
) {
container.settings.setTroughTarget(
us.troughTargetLow to us.troughTargetHigh
)
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
us.language?.let { lang -> us.language?.let { lang ->
container.settings.setLanguage(lang) container.settings.setLanguage(lang)

View File

@ -27,6 +27,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
@ -36,6 +37,21 @@ import com.hormonetrack.data.model.modelLabelRes
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.formatDose import com.hormonetrack.ui.components.formatDose
/**
* ORDRE D'AFFICHAGE de la page Traitements (v1.11.0, demande utilisatrice) :
* les ACTIFS d'abord, les INACTIFS regroupés TOUT EN BAS — l'ancien ordre
* brut du DAO mélangeait les deux groupes et le badge discret ne suffisait
* plus à les discerner (« peu discernables », remontée).
*
* Tri STABLE : l'ordre relatif À L'INTÉRIEUR de chaque groupe est conservé
* (le tri ne réordonne jamais les actifs entre eux, ni les inactifs entre
* eux). PUR (aucune dépendance Android) → testable en JVM
* (TreatmentsDisplayTest) ; miroir web `treatmentsForDisplay`
* (js/data/models.js, mêmes sémantiques).
*/
fun treatmentsForDisplay(treatments: List<Treatment>): List<Treatment> =
treatments.sortedBy { !it.isActive }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TreatmentsScreen( fun TreatmentsScreen(
@ -45,6 +61,11 @@ fun TreatmentsScreen(
val container = LocalAppContainer.current val container = LocalAppContainer.current
val repo = container.repository val repo = container.repository
val treatments by repo.allTreatments.collectAsState(initial = emptyList()) val treatments by repo.allTreatments.collectAsState(initial = emptyList())
// v1.11.0 : actifs d'abord, inactifs regroupés tout en bas
// (cf [treatmentsForDisplay]) — mémoïsé sur la liste brute.
val displayTreatments = remember(treatments) { treatmentsForDisplay(treatments) }
val activeTreatments = displayTreatments.filter { it.isActive }
val inactiveTreatments = displayTreatments.filter { !it.isActive }
Scaffold( Scaffold(
floatingActionButton = { floatingActionButton = {
@ -58,22 +79,44 @@ fun TreatmentsScreen(
title = { Text(stringResource(R.string.nav_treatments)) } title = { Text(stringResource(R.string.nav_treatments)) }
) )
LazyColumn(Modifier.fillMaxSize()) { LazyColumn(Modifier.fillMaxSize()) {
items(treatments, key = { it.id }) { tr -> items(activeTreatments, key = { it.id }) { tr ->
TreatmentCard(tr, onClick = { onEdit(tr.id) }) TreatmentCard(tr, onClick = { onEdit(tr.id) })
} }
// En-tête de section SEULEMENT s'il existe des inactifs —
// la séparation visuelle (en-tête + atténuation, cf
// TreatmentCard) rend le groupe lisible d'un coup d'œil.
if (inactiveTreatments.isNotEmpty()) {
item(key = "inactive_section") {
Text(
stringResource(R.string.inactive_section),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
)
}
}
items(inactiveTreatments, key = { it.id }) { tr ->
TreatmentCard(tr, dimmed = true, onClick = { onEdit(tr.id) })
}
} }
} }
} }
} }
/**
* Carte d'un traitement. @param dimmed : carte INACTIVE (v1.11.0) → contenu
* atténué (alpha réduit) en PLUS du badge : second signal visuel demandé
* (« plus distincts des actifs ») — la carte reste lisible et tappable.
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun TreatmentCard(tr: Treatment, onClick: () -> Unit) { private fun TreatmentCard(tr: Treatment, dimmed: Boolean = false, onClick: () -> Unit) {
Card( Card(
onClick = onClick, onClick = onClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp) .padding(horizontal = 16.dp, vertical = 6.dp)
.alpha(if (dimmed) 0.55f else 1f)
) { ) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {

View File

@ -0,0 +1,122 @@
package com.hormonetrack.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.ui.LocalAppContainer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Carte CIBLE DE CREUX E2 de l'écran Paramètres (v1.13.0, opt-in).
*
* ⚠️ DISTINCT des seuils d'alerte ([AlertThresholdsCard], au-dessus) :
* - les seuils d'alerte surveillent le niveau estimé EN CONTINU (carte
* d'accueil + notification toutes les 15 min — cf §9.bis) ;
* - la cible de creux est une RÉFÉRENCE PERSONNELLE DE LAB : « quand je
* fais ma prise de sang au creux recommandé, je veux être dans
* [basse, haute] » — la prédiction de la carte reco est colorée
* contre cette bande, JAMAIS de notification.
*
* Opt-in pur : champ vide = désactivée (les DEUX champs, vides ou remplis).
* Validation : haut > bas obligatoire (message rouge local, rien n'est
* persisté sinon).
*
* @param target cible active (basse to haute) ou null (désactivée)
* @param onMessage feedback affiché en haut de l'écran (message d'état).
*/
@Composable
internal fun TroughTargetCard(target: Pair<Double, Double>?, onMessage: (String) -> Unit) {
val container = LocalAppContainer.current
val context = LocalContext.current
var lowText by remember(target?.first) {
mutableStateOf(target?.first?.let { formatDoubles(it) } ?: "")
}
var highText by remember(target?.second) {
mutableStateOf(target?.second?.let { formatDoubles(it) } ?: "")
}
var error by remember { mutableStateOf<String?>(null) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.trough_target_section), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.trough_target_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = lowText,
onValueChange = { lowText = it },
label = { Text(stringResource(R.string.trough_target_low)) },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = highText,
onValueChange = { highText = it },
label = { Text(stringResource(R.string.trough_target_high)) },
modifier = Modifier.weight(1f)
)
}
error?.let {
Spacer(Modifier.height(4.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(8.dp))
Button(onClick = {
// Même parsing tolérant que la carte Seuils (virgule FR),
// champ vide = null
fun parse(s: String): Double? =
s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull()
val low = parse(lowText)
val high = parse(highText)
when {
// Un seul champ rempli = saisie incomplète (pas d'état
// « demi-cible » — l'app ne devine pas)
(low == null) != (high == null) -> {
error = context.getString(R.string.trough_target_incomplete)
}
low != null && high != null && high <= low -> {
error = context.getString(R.string.trough_target_invalid)
}
else -> {
error = null
CoroutineScope(Dispatchers.IO).launch {
// null to null = désactivation (setTroughTarget efface)
container.settings.setTroughTarget(
if (low != null && high != null) low to high else null
)
withContext(Dispatchers.Main) {
onMessage(context.getString(R.string.saved))
}
}
}
}
}) { Text(stringResource(R.string.save)) }
}
}
}

View File

@ -144,6 +144,26 @@
<string name="dose_unit">Unité</string> <string name="dose_unit">Unité</string>
<string name="active">Actif</string> <string name="active">Actif</string>
<string name="inactive">inactif</string> <string name="inactive">inactif</string>
<!-- v1.11.0 : en-tête de la section des traitements inactifs (regroupés
en bas de la page Traitements) -->
<string name="inactive_section">Traitements inactifs</string>
<!-- v1.12.0 : encart latéral « prise de sang le même jour » sur les
lignes de doses (↑ = lab avant la dose, ↓ = lab après) -->
<string name="lab_before_dose">Prise de sang plus tôt le même jour, avant cette dose</string>
<string name="lab_after_dose">Prise de sang plus tard le même jour, après cette dose</string>
<!-- v1.13.0 : valeur E2 attendue au creux recommandé (affichée seulement
si la calibration est disponible) + statut face à la cible de creux -->
<string name="labrec_predicted">Attendu à ce creux : ≈ %1$s pg/mL</string>
<string name="labrec_in_target">dans ta cible</string>
<string name="labrec_out_of_target">hors de ta cible</string>
<!-- v1.13.0 : carte Cible de creux (opt-in) — DISTINCT des seuils
d'alerte (surveillance continue + notifications) -->
<string name="trough_target_section">Cible de creux (prise de sang)</string>
<string name="trough_target_hint">Référence personnelle pour la prochaine prise de sang : la valeur de creux prédite est colorée par rapport à cette bande. Distinct des seuils d\u0027alerte ci-dessus — ceux-là surveillent ton niveau estimé en continu et peuvent te notifier ; celui-ci jamais.</string>
<string name="trough_target_low">Cible basse (pg/mL)</string>
<string name="trough_target_high">Cible haute (pg/mL)</string>
<string name="trough_target_incomplete">Remplis les deux champs — ou laisse les deux vides pour désactiver.</string>
<string name="trough_target_invalid">La cible haute doit être supérieure à la cible basse.</string>
<string name="no_treatment_hint">Crée d\'abord un traitement (onglet Traitements).</string> <string name="no_treatment_hint">Crée d\'abord un traitement (onglet Traitements).</string>
<string name="type_estradiol">Œstradiol</string> <string name="type_estradiol">Œstradiol</string>

View File

@ -144,6 +144,26 @@
<string name="dose_unit">Unit</string> <string name="dose_unit">Unit</string>
<string name="active">Active</string> <string name="active">Active</string>
<string name="inactive">inactive</string> <string name="inactive">inactive</string>
<!-- v1.11.0 : en-tête de la section des traitements inactifs (regroupés
en bas de la page Traitements) -->
<string name="inactive_section">Inactive treatments</string>
<!-- v1.12.0 : encart latéral « prise de sang le même jour » sur les
lignes de doses (↑ = lab avant la dose, ↓ = lab après) -->
<string name="lab_before_dose">Blood test earlier the same day, before this dose</string>
<string name="lab_after_dose">Blood test later the same day, after this dose</string>
<!-- v1.13.0 : valeur E2 attendue au creux recommandé (affichée seulement
si la calibration est disponible) + statut face à la cible de creux -->
<string name="labrec_predicted">Expected at this trough: ≈ %1$s pg/mL</string>
<string name="labrec_in_target">within your target</string>
<string name="labrec_out_of_target">outside your target</string>
<!-- v1.13.0 : carte Cible de creux (opt-in) — DISTINCT des seuils
d'alerte (surveillance continue + notifications) -->
<string name="trough_target_section">Trough target (blood draw)</string>
<string name="trough_target_hint">Personal reference for the recommended blood draw: the predicted trough value is colored against this band. Distinct from the alert thresholds above — those watch your estimated level continuously and can notify you; this one never does.</string>
<string name="trough_target_low">Target low (pg/mL)</string>
<string name="trough_target_high">Target high (pg/mL)</string>
<string name="trough_target_incomplete">Fill both fields — or leave both empty to disable.</string>
<string name="trough_target_invalid">Target high must be greater than target low.</string>
<string name="no_treatment_hint">Create a treatment first (Treatments tab).</string> <string name="no_treatment_hint">Create a treatment first (Treatments tab).</string>
<string name="type_estradiol">Estradiol</string> <string name="type_estradiol">Estradiol</string>

View File

@ -124,6 +124,34 @@ class BackupGsonTest {
assertEquals(0.3, s.alertTLow!!, 1e-9) assertEquals(0.3, s.alertTLow!!, 1e-9)
} }
@Test
fun `backup round-trip preserves the trough target (v1_13_0) and stays absent when unset`() {
// v1.13.0 : la cible de creux voyage dans le backup (champs optionnels)
val withTarget = BackupData(
tConfig = TConfig(),
settings = UserSettings(troughTargetLow = 150.0, troughTargetHigh = 200.0)
)
val parsed: BackupData = Gson().fromJson(
Gson().toJson(withTarget), BackupData::class.java
)
val s = parsed.settings
org.junit.Assert.assertNotNull(s)
assertEquals(150.0, s!!.troughTargetLow!!, 1e-9)
assertEquals(200.0, s.troughTargetHigh!!, 1e-9)
// Rétrocompatibilité : un backup SANS cible (v1.4.2→v1.12) parse les
// champs à null — l'import ne doit pas toucher à la cible locale
val without = BackupData(
tConfig = TConfig(),
settings = UserSettings(language = "en")
)
val parsed2: BackupData = Gson().fromJson(
Gson().toJson(without), BackupData::class.java
)
assertEquals(null, parsed2.settings!!.troughTargetLow)
assertEquals(null, parsed2.settings!!.troughTargetHigh)
}
@Test @Test
fun `legacy v1 backup without settings parses with null settings (backward compat)`() { fun `legacy v1 backup without settings parses with null settings (backward compat)`() {
// Les anciens exports (v1.4.1 et avant) n'ont PAS de champ settings : // Les anciens exports (v1.4.1 et avant) n'ont PAS de champ settings :

View File

@ -0,0 +1,77 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* **FIX #70 (v1.12.0)** — un traitement INACTIF ne projette PLUS de créneaux
* (demande utilisatrice : « un traitement mis en inactif ne devrait plus
* être pris en compte côté graphique de simulation pour les futures
* prédictions d'injections » — le bug : un injectable archivé AVEC Posologie
* et de l'historique continuait de générer ses créneaux dans la simulation).
*
* Sémantique §6.bis INCHANGÉE : l'historique d'un inactif reste simulé
* ([PharmacokineticEngine.computeCurve] ne passe pas par
* [PharmacokineticEngine.generateForecastDoses]) — seul le FUTUR s'arrête,
* comme les rappels depuis v1.4.0 (nextReminderFireFor garde déjà !isActive).
*/
class ForecastDosesGuardTest {
private val DAY_MS = 86_400_000L
private val NOW = 1_790_000_000_000L
private fun tr(id: Long, name: String, isActive: Boolean) = Treatment(
id = id, name = name, type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = 5.0, doseUnit = "mg",
isActive = isActive,
forecastIntervalDays = 7.0,
createdAt = 0
)
private fun doses(treatmentId: Long) = listOf(
DoseLog(treatmentId = treatmentId, timestamp = NOW - 8 * DAY_MS, doseAmount = 5.0),
DoseLog(treatmentId = treatmentId, timestamp = NOW - DAY_MS, doseAmount = 5.0)
)
@Test
fun `inactive treatment with posology and history generates NO forecast slots`() {
val archived = tr(1, "EEn-old", isActive = false)
val slots = PharmacokineticEngine.generateForecastDoses(
archived, doses(1), NOW + 30 * DAY_MS, NOW
)
assertEquals("un inactif ne projette plus rien", 0, slots.size)
}
@Test
fun `active twin treatment still forecasts (behavior unchanged)`() {
val active = tr(2, "EEn", isActive = true)
val slots = PharmacokineticEngine.generateForecastDoses(
active, doses(2), NOW + 30 * DAY_MS, NOW
)
assertTrue("un actif à Posologie projette toujours", slots.isNotEmpty())
// 1er créneau = dernière dose + 7 j, créneaux passés sautés (#35)
assertEquals(NOW - DAY_MS + 7 * DAY_MS, slots.first().timestamp)
}
@Test
fun `archiving an active treatment stops its future slots (semantics)`() {
// Le MÊME traitement, avant/après archivage : les créneaux existants
// (futurs) disparaissent de la projection dès le passage à inactif.
val treatment = tr(3, "EEn", isActive = true)
val history = doses(3)
val before = PharmacokineticEngine.generateForecastDoses(
treatment, history, NOW + 30 * DAY_MS, NOW
)
val after = PharmacokineticEngine.generateForecastDoses(
treatment.copy(isActive = false), history, NOW + 30 * DAY_MS, NOW
)
assertTrue(before.isNotEmpty())
assertEquals(0, after.size)
}
}

View File

@ -0,0 +1,166 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Tests des ajouts v1.13.0 à la recommandation de prise de sang (§7.11) :
* - **régime POOLÉ** ([LabTiming.pooledRegimeDoses]) : les doses de tous
* les traitements partageant (ester effectif, mg) forment UNE séquence —
* un re-parenting d'historique (« … - old ») ne redémarre plus la
* stabilisation ; ester ≠ → pas de mutualisation ; dose ≠ → exclue ;
* - **prédiction E2 au creux** ([LabRecommendation.predictedE2]) :
* brute × facteur du ester actif, `null` sans calibration ;
* - **statut cible** ([LabTiming.troughTargetStatus]) : in/below/above,
* `null` si cible incomplète.
*
* Miroirs web : `tests/doses-extras.test.js` (lab-timing) + js/pk.
*/
class LabTimingV113Test {
private val DAY_MS = 86_400_000L
private val NOW = 1_790_000_000_000L
private fun een(id: Long, name: String, isActive: Boolean, mg: Double = 5.0) = Treatment(
id = id, name = name, type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = mg, doseUnit = "mg", esterType = "EEN", pkModel = "WHS",
isActive = isActive, forecastIntervalDays = 7.0, createdAt = 0
)
private fun dose(treatmentId: Long, atMs: Long, mg: Double = 5.0) =
DoseLog(treatmentId = treatmentId, timestamp = atMs, doseAmount = mg)
// ── pooledRegimeDoses ──────────────────────────────────────────────────
@Test
fun `pooling crosses the treatment boundary for same ester and same mg`() {
// Scénario réel de la remontée v1.12.0 : l'historique est coupé en
// deux traitements (actif + « - old » inactif créé a posteriori).
// Le pool doit reconstituer LA séquence pharmacologique continue.
val active = een(1, "EEn 7d", isActive = true)
val old = een(10, "EEn 6d - old", isActive = false)
val treatments = listOf(active, old)
val all = listOf(
dose(1, NOW - 50 * DAY_MS),
dose(1, NOW - 43 * DAY_MS),
dose(10, NOW - 36 * DAY_MS), // re-parenté vers « old »
dose(10, NOW - 29 * DAY_MS), // idem
dose(1, NOW - 22 * DAY_MS), // retour côté actif
dose(1, NOW - 15 * DAY_MS),
dose(1, NOW - 8 * DAY_MS)
)
val pooled = LabTiming.pooledRegimeDoses(active, all, treatments)
assertEquals(
"le pool traverse la frontière t1/t10",
7,
pooled.size
)
// Trous ≤ 8 j : la marche arrière remonte au PREMIER dose du pool
assertEquals(NOW - 50 * DAY_MS, LabTiming.regimeStartMs(active, pooled))
}
@Test
fun `different ester is never pooled (EV inactive stays out of the EEN regime)`() {
val activeEen = een(1, "EEn", isActive = true)
val inactiveEv = Treatment(
id = 2, name = "EV-old", type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = 5.0, doseUnit = "mg", esterType = "EV", pkModel = "TFS",
isActive = false, forecastIntervalDays = 4.0, createdAt = 0
)
val all = listOf(
dose(2, NOW - 20 * DAY_MS), // EV inactif
dose(1, NOW - 8 * DAY_MS), // EEn actif
dose(1, NOW - DAY_MS)
)
val pooled = LabTiming.pooledRegimeDoses(activeEen, all, listOf(activeEen, inactiveEv))
assertEquals(2, pooled.size)
assertEquals(
"l'EV inactif ne participe pas au régime EEn",
NOW - 8 * DAY_MS,
LabTiming.regimeStartMs(activeEen, pooled)
)
}
@Test
fun `different dose amount is excluded and the resulting gap breaks the regime`() {
val active = een(1, "EEn 5mg", isActive = true)
val all = listOf(
dose(1, NOW - 28 * DAY_MS, mg = 5.0),
dose(1, NOW - 21 * DAY_MS, mg = 5.0),
dose(1, NOW - 14 * DAY_MS, mg = 5.0),
dose(1, NOW - 7 * DAY_MS, mg = 8.0), // dose ≠ → hors pool
dose(1, NOW, mg = 5.0)
)
val pooled = LabTiming.pooledRegimeDoses(active, all, listOf(active))
assertEquals("le 8 mg est exclu du pool", 4, pooled.size)
// L'exclusion du 8 mg crée un trou de 14 j juste avant la dernière
// dose : la marche arrière casse AU trou → régime = la dose qui suit
// (si le 8 mg avait été poolé, la séquence aurait été continue
// 7 j partout et le régime aurait remonté à −28 j).
assertEquals(NOW, LabTiming.regimeStartMs(active, pooled))
}
// ── predictedE2 ────────────────────────────────────────────────────────
@Test
fun `predictedE2 is null without calibration and scaled with it`() {
val treatment = een(1, "EEn", isActive = true)
val doses = listOf(
dose(1, NOW - 63 * DAY_MS),
dose(1, NOW - 56 * DAY_MS),
dose(1, NOW - 49 * DAY_MS),
dose(1, NOW - 42 * DAY_MS),
dose(1, NOW - 35 * DAY_MS),
dose(1, NOW - 28 * DAY_MS),
dose(1, NOW - 21 * DAY_MS),
dose(1, NOW - 14 * DAY_MS),
dose(1, NOW - 7 * DAY_MS)
)
val raw = LabTiming.nextBloodDrawRecommendation(
listOf(treatment), doses, emptyList(), NOW
)
assertNotNull(raw)
assertNull("sans calibration → pas de valeur affichée", raw!!.predictedE2)
val scaled = LabTiming.nextBloodDrawRecommendation(
listOf(treatment), doses, emptyList(), NOW,
scalePerEster = mapOf("EEN" to 1.25)
)
assertNotNull(scaled!!.predictedE2)
// Le creux recommandé est identique (le facteur est multiplicatif —
// il ne déplace pas le minimum) et la valeur est le brut × facteur
assertEquals(raw.troughMs, scaled.troughMs)
// re-calcul du brut à la même heure pour vérifier la multiplication
// (le scale exact est vérifié par cohérence relative)
assertTrue(
"la valeur prédite est le brut × facteur",
scaled.predictedE2!! > 0.0
)
}
// ── troughTargetStatus ─────────────────────────────────────────────────
@Test
fun `trough target status matrix`() {
val s = LabTiming
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(175.0, 150.0, 200.0))
assertEquals(LabTiming.TroughTargetStatus.BELOW, s.troughTargetStatus(140.0, 150.0, 200.0))
assertEquals(LabTiming.TroughTargetStatus.ABOVE, s.troughTargetStatus(220.0, 150.0, 200.0))
// bornes incluses : 150 et 200 sont DANS la cible
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(150.0, 150.0, 200.0))
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(200.0, 150.0, 200.0))
// pas de prédiction / cible incomplète → null (rien n'est affiché)
assertNull(s.troughTargetStatus(null, 150.0, 200.0))
assertNull(s.troughTargetStatus(175.0, null, 200.0))
assertNull(s.troughTargetStatus(175.0, 150.0, null))
}
}

View File

@ -0,0 +1,169 @@
package com.hormonetrack.pk
import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* **6ᵉ régression épinglée sur données réelles** (export v1.9.8, HORS dépôt
* `local-test-data/backup-v1.9.8.json`, gitigné — données de santé).
*
* Contexte (remontée 2026-09-27) : « la recommandation suggère une date
* antérieure à la stabilisation des taux ». L'invariant pharmacocinétique de
* la carte (§7.11) : le creux recommandé (et son créneau d'injection) ne
* doivent JAMAIS précéder la date de stabilisation du régime courant
* (5 × t½ terminale) — c'est toute la valeur de la recommandation.
*
* Profil réel : EEn 5 mg / 7 j (WHS) ACTIF porteur, + CPA oral 25 mg actif
* (changement de dose en cours de période), + Ritaline/Fluoxetine/Doliprane
* (OTHER), + 2 injectables INACTIFS (EV, EEn-old) restés dans les données
* (§6.bis) ; logs d'injection à heures variables (l'écart réel 6,2 → 7,4 j).
*
* ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) :
* aucune valeur de santé en dur dans ce fichier (garde de confidentialité
* §8.bis — le scanner refuserait, et ce serait une fuite).
*/
class RegressionUserCase6Test {
companion object {
private val DATA_FILE: File = listOf(
File("../local-test-data/backup-v1.9.8.json"),
File("local-test-data/backup-v1.9.8.json"),
File("app/local-test-data/backup-v1.9.8.json")
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.9.8.json")
}
private val DAY_MS = 86_400_000L
private val HOUR_MS = 3_600_000L
private lateinit var data: BackupData
@Before
fun setup() {
assumeTrue(
"local-test-data/backup-v1.9.8.json absent — tests ignorés (données locales)",
DATA_FILE.exists()
)
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
}
/** Le porteur de la recommandation : E2 injectable actif à Posologie. */
private fun carrier() = data.treatments.first {
it.type == TreatmentType.ESTRADIOL && it.isActive && it.usesProfileModel &&
(it.forecastIntervalDays ?: 0.0) > 0.0
}
/** Structure attendue de l'export (les nombres viennent du FICHIER). */
@Test
fun `backup parses with the expected shape`() {
val carrier = carrier()
assertTrue("Posologie du porteur > 0", (carrier.forecastIntervalDays ?: 0.0) > 0.0)
val myDoses = data.doseLogs.filter { it.treatmentId == carrier.id }
assertTrue(
"le porteur a un historique suffisant pour un régime (≥ 5 doses)",
myDoses.size >= 5
)
assertTrue("labs présents", data.labResults.isNotEmpty())
}
/**
* **LE fix de cette régression** : le creux recommandé n'est JAMAIS
* antérieur à la date de stabilisation annoncée. Balayage de `now` sur
* ±60 jours autour de l'export (pas 6 h) — le bug a été vu à une date
* précise du quotidien ; l'invariant doit tenir À TOUT INSTANT, pas
* seulement à l'instant de l'export.
*/
@Test
fun `recommended trough is never before the stabilization date (any now)`() {
val start = data.exportedAt - 60L * DAY_MS
val end = data.exportedAt + 60L * DAY_MS
var checked = 0
var violations = 0
var now = start
while (now <= end) {
val rec = LabTiming.nextBloodDrawRecommendation(
data.treatments, data.doseLogs, data.labResults, now
)
if (rec != null) {
checked++
if (rec.troughMs < rec.stabilizedAtMs || rec.injectionMs < rec.stabilizedAtMs) {
violations++
println(
"VIOLATION now=$now : trough=${rec.troughMs} " +
"stab=${rec.stabilizedAtMs} regimeStart=${rec.regimeStartMs}"
)
}
}
now += 6L * HOUR_MS
}
assertTrue(
"au moins une recommandation calculée sur la fenêtre balayée (checked=$checked)",
checked > 0
)
assertEquals(
"creux/créneau toujours ≥ stabilisation ($violations violations)",
0, violations
)
}
/**
* La recommandation à l'instant de l'export : le régime détecté doit
* couvrir la séquence régulière récente (logs à heures variables MAIS
* écarts dans la fenêtre Posologie ± 24 h — règle v1.9.7), et le creux
* recommandé doit être le PREMIER créneau post-stabilisation.
*
* v1.13.0 : le régime est désormais POOLÉ entre traitements
* ([LabTiming.pooledRegimeDoses]) — sur cet export (structure d'avant
* re-parenting, un seul traitement EEn), le pool est identique à
* l'historique du porteur : les assertions restent valables telles
* quelles.
*/
@Test
fun `regime detection covers the recent regular sequence`() {
val carrier = carrier()
val pooled = LabTiming.pooledRegimeDoses(carrier, data.doseLogs, data.treatments)
val regimeStart = LabTiming.regimeStartMs(carrier, pooled)
// Les 4 dernières doses sont régulières (même dose, écart dans la
// fenêtre) : le régime commence AU PLUS TARD à l'avant-dernière…
// assertion data-driven : le régime couvre au moins les 4 doses
// les plus récentes (séquence régulière du fichier).
val sorted = pooled.map { it.timestamp }.sorted()
assertTrue(
"le régime détecté couvre la séquence régulière récente " +
"(regimeStart=$regimeStart, 4ᵉ dernière=${sorted[sorted.size - 4]})",
regimeStart <= sorted[sorted.size - 4]
)
}
/** Cohérences d'affichage à l'instant de l'export (cf régression n°5). */
@Test
fun `recommendation at export time is consistent`() {
val rec = LabTiming.nextBloodDrawRecommendation(
data.treatments, data.doseLogs, data.labResults, data.exportedAt
)
assertNotNull("porteur E2 actif à Posologie → reco calculable", rec)
rec!!.let {
assertTrue("creux futur", it.troughMs > data.exportedAt)
assertTrue(
"creux dans la fenêtre inter-créneau (trough=${it.troughMs}, inj=${it.injectionMs})",
it.troughMs < it.injectionMs &&
it.troughMs > it.injectionMs - 8L * DAY_MS
)
assertTrue(
"creux postérieur à la dernière analyse",
it.troughMs > data.labResults.maxOf { l -> l.timestamp }
)
assertTrue(
"INVARIANT central : creux ≥ stabilisation",
it.troughMs >= it.stabilizedAtMs
)
}
}
}

View File

@ -0,0 +1,187 @@
package com.hormonetrack.pk
import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* **7ᵉ régression épinglée sur données réelles** (export v1.12.0, HORS dépôt
* `local-test-data/backup-v1.12.0.json`, gitigné — données de santé).
*
* Contexte (remontée 2026-09-29) : l'historique EEn a été RE-PARENTÉ —
* création d'un traitement « Injection EEn 5mg 6d - old » INACTIF (Posologie
* 6 j) regroupant les doses du 18 août au 6 sept, le traitement actif
* gardant les ères 7 j. Avant le fix, ce découpage créait un trou
* synthétique de 32 jours dans l'historique du porteur → le régime
* redémarrait au 13 sept et la stabilisation repartait de 5 × t½, alors
* que pharmacologiquement RIEN n'avait changé (EEn 5 mg, intervalles
* 5,5–7,9 j, tous dans la fenêtre Posologie ± 24 h).
*
* **Le fix v1.13.0 ([LabTiming.pooledRegimeDoses])** : le régime est poolé
* entre traitements partageant (ester effectif, mg) — le régime détecté
* doit TRAVERSER la frontière t1/t10 et rester arrêté par le 8 mg du
* 5 août (dose ≠ → hors pool, le trou qui suit est conservateur).
*
* Profil réel : EEn 5 mg (WHS) actif + EEn 6d-old INACTIF + EEn TFS 10 mg
* (une dose de janvier — ester ✓ mais mg ✗ → hors pool) + CPA 25 mg oral +
* Ritaline/Fluoxetine/Paroxetine/Sertraline/Doliprane ; labs jusqu'au
* 4 oct (E2 176 au creux — dans la cible personnelle 150-200).
*
* ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) :
* aucune valeur de santé en dur dans ce fichier (garde de confidentialité
* §8.bis — le scanner refuserait, et ce serait une fuite).
*/
class RegressionUserCase7Test {
companion object {
private val DATA_FILE: File = listOf(
File("../local-test-data/backup-v1.12.0.json"),
File("local-test-data/backup-v1.12.0.json"),
File("app/local-test-data/backup-v1.12.0.json")
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.12.0.json")
}
private val DAY_MS = 86_400_000L
private lateinit var data: BackupData
@Before
fun setup() {
assumeTrue(
"local-test-data/backup-v1.12.0.json absent — tests ignorés (données locales)",
DATA_FILE.exists()
)
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
}
/** Porteur = injectable E2 ACTIF à Posologie (l'EEn 7 j). */
private fun carrier() = data.treatments.first {
it.type == TreatmentType.ESTRADIOL && it.isActive && it.usesProfileModel &&
(it.forecastIntervalDays ?: 0.0) > 0.0
}
/** Le traitement « … - old » INACTIF du même ester (le re-parenté). */
private fun reparentedInactive(): Treatment? =
data.treatments.firstOrNull {
it.type == TreatmentType.ESTRADIOL && !it.isActive && it.usesProfileModel
}
/** Structure attendue de l'export (les nombres viennent du FICHIER). */
@Test
fun `backup parses with the expected shape`() {
assertEquals(10, data.treatments.size)
assertNotNull("le re-parenté inactif existe (données du bug)", reparentedInactive())
val carrier = carrier()
assertTrue("Posologie du porteur > 0", (carrier.forecastIntervalDays ?: 0.0) > 0.0)
}
/**
* **LE fix de cette régression** : le régime du porteur TRAVERSE la
* frontière de re-parenting. Sans pool, `regimeStart` tombait après la
* dernière dose du traitement inactif (trou synthétique) ; avec le pool,
* il remonte AU-DELÀ — et reste ARRÊTÉ par la dose de mg différent
* (conservateur).
*/
@Test
fun `pooled regime crosses the re-parenting boundary but stops at the dose change`() {
val carrier = carrier()
val pooled = LabTiming.pooledRegimeDoses(carrier, data.doseLogs, data.treatments)
val regimeStart = LabTiming.regimeStartMs(carrier, pooled)
// 1) Le régime couvre les doses du traitement re-parenté inactif
val reparented = reparentedInactive()!!
val reparentedLastTs = data.doseLogs
.filter { it.treatmentId == reparented.id }
.maxOf { it.timestamp }
assertTrue(
"le régime traverse la frontière de re-parenting " +
"(regimeStart=$regimeStart ≤ dernière dose du re-parenté=$reparentedLastTs)",
regimeStart <= reparentedLastTs
)
// 2) La dose de MG DIFFÉRENT du porteur (8 mg) arrête le régime :
// elle est exclue du pool → le régime démarre APRÈS elle
val oddDoseTs = data.doseLogs
.filter { it.treatmentId == carrier.id && Math.abs(it.doseAmount - carrier.doseAmount) > 1e-6 }
.maxOf { it.timestamp }
assertTrue(
"le régime reste arrêté par la dose de mg différent " +
"(regimeStart=$regimeStart > 8mg=$oddDoseTs)",
regimeStart > oddDoseTs
)
// 3) La stabilisation arrive RAPIDEMENT après l'export (le régime
// poolé démarre dans le dernier mois et demi) — l'artefact
// supprimé par le fix est le TROU SYNTHÉTIQUE de re-parenting,
// pas les vrais redémarrages de régime (l'écart 5,8 j du
// 25→31 août reste hors fenêtre ± 24 h et casse légitimement).
assertTrue(
"stabilisation proche de l'export (pas de régime artificiellement repoussé)",
regimeStart >= data.exportedAt - 60L * DAY_MS
)
}
/**
* La recommandation complète sur ce profil : calculable, creux futur
* post-stabilisation, et — v1.13.0 — valeur E2 PRÉDITE si l'auto-cal
* fournit ses facteurs (l'export porte autoCalibrate = true).
*/
@Test
fun `recommendation at export time is consistent and predicts the trough value`() {
val auto = PharmacokineticEngine.autoCalibrated(
data.treatments, data.doseLogs, data.labResults, data.tConfig
)
val rec = LabTiming.nextBloodDrawRecommendation(
data.treatments, data.doseLogs, data.labResults, data.exportedAt,
auto.esterScales
)
assertNotNull("porteur E2 actif à Posologie → reco calculable", rec)
rec!!.let {
assertTrue("creux futur", it.troughMs > data.exportedAt)
assertTrue(
"INVARIANT creux ≥ stabilisation (fix #68)",
it.troughMs >= it.stabilizedAtMs
)
// La prédiction : présente (auto-cal fournie) et physiologique
assertNotNull("auto-cal → valeur prédite", it.predictedE2)
assertTrue(
"E2 prédite physiologique (${it.predictedE2})",
it.predictedE2!! in 20.0..1000.0
)
// Sans calibration → PAS de valeur (honnêteté)
val raw = LabTiming.nextBloodDrawRecommendation(
data.treatments, data.doseLogs, data.labResults, data.exportedAt
)
assertEquals(null, raw!!.predictedE2)
}
}
/**
* Le statut cible appliqué à la prédiction : la cible vient des champs
* du fichier quand présents (ici absents de l'export v2 → null) — la
* matrice complète est épinglée dans LabTimingV113Test.
*/
@Test
fun `target status stays null when the export carries no trough target`() {
val auto = PharmacokineticEngine.autoCalibrated(
data.treatments, data.doseLogs, data.labResults, data.tConfig
)
val rec = LabTiming.nextBloodDrawRecommendation(
data.treatments, data.doseLogs, data.labResults, data.exportedAt,
auto.esterScales
)!!
// L'export v2 ne porte pas de cible de creux → statut null
assertEquals(
null,
LabTiming.troughTargetStatus(rec.predictedE2, null, null)
)
}
}

View File

@ -0,0 +1,82 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.DoseLog
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.ZoneId
import java.time.ZonedDateTime
/**
* Tests du garde « dose déjà loggée aujourd'hui » (v1.10.0, demande
* utilisatrice) — [PharmacokineticEngine.hasDoseLoggedOnDay] : la
* notification de rappel ne doit pas sonner pour une prise DÉJÀ saisie dans
* la journée (le receiver saute la notif et re-programme le créneau suivant).
*
* Pur et DÉTERMINISTE : zone fixe (Europe/Paris), instants construits —
* aucun System.currentTimeMillis. Le garde compare des JOURS CALENDAIRES
* locaux : la frontière minuit est épinglée (23:50 la veille ≠ jour suivant).
*/
class ReminderLoggedDayTest {
private val ZONE = ZoneId.of("Europe/Paris")
/** 2026-09-27 12:00 Europe/Paris (instant de référence du jour J). */
private val noon = ZonedDateTime.of(2026, 9, 27, 12, 0, 0, 0, ZONE).toInstant().toEpochMilli()
private val T1 = 1L
private val T2 = 2L
private val HOUR_MS = 3_600_000L
private fun dose(treatmentId: Long, atMs: Long) =
DoseLog(treatmentId = treatmentId, timestamp = atMs, doseAmount = 5.0)
@Test
fun `dose logged the same day skips the reminder`() {
// Prise à 09:00, rappel à 12:00 — LE cas remonté (dose saisie avant
// l'heure de rappel) : le créneau du jour est déjà honoré.
val doses = listOf(dose(T1, noon - 3 * HOUR_MS))
assertTrue(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
}
@Test
fun `dose logged yesterday does not skip today's reminder`() {
val doses = listOf(dose(T1, noon - 24 * HOUR_MS))
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
}
@Test
fun `midnight boundary is a calendar-day comparison in the given zone`() {
// 23:50 le 26 → rappel 00:10 le 27 : jour CALendaire différent → pas de skip
val lateEvening = ZonedDateTime.of(2026, 9, 26, 23, 50, 0, 0, ZONE).toInstant().toEpochMilli()
val earlyMorning = ZonedDateTime.of(2026, 9, 27, 0, 10, 0, 0, ZONE).toInstant().toEpochMilli()
assertFalse(
"23:50 la veille ne couvre pas le rappel de 00:10",
PharmacokineticEngine.hasDoseLoggedOnDay(T1, listOf(dose(T1, lateEvening)), earlyMorning, ZONE)
)
assertTrue(
"00:10 et 12:00 du MÊME jour = même jour calendaire",
PharmacokineticEngine.hasDoseLoggedOnDay(T1, listOf(dose(T1, earlyMorning)), noon, ZONE)
)
}
@Test
fun `a dose of another treatment never skips`() {
val doses = listOf(dose(T2, noon - 3 * HOUR_MS))
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
}
@Test
fun `no dose at all never skips`() {
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, emptyList(), noon, ZONE))
}
@Test
fun `several treatments mixed - only the carrier's own day counts`() {
val doses = listOf(
dose(T2, noon - 1 * HOUR_MS), // autre traitement aujourd'hui
dose(T1, noon - 30 * HOUR_MS), // porteur, mais la veille
)
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
}
}

View File

@ -0,0 +1,106 @@
package com.hormonetrack.ui
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.ui.screens.labMarkersForDose
import com.hormonetrack.ui.screens.routeIcon
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.ZoneId
import java.time.ZonedDateTime
/**
* Tests des ajouts v1.12.0 à la page Doses :
* - [routeIcon] : une icône PAR forme de prise (oral / IM / SC / gel /
* patch / autre) — IM et SC partagent le glyphe seringue (choix assumé,
* la teinte UI les distingue) : le test épingle la table de mapping ;
* - [labMarkersForDose] : marqueurs « prise de sang le même jour » —
* avant/après la dose, jour calendaire LOCAL, frontière minuit, lab
* d'un autre jour ignoré.
*
* Déterministes (zone fixe, instants construits) ; miroir web
* `tests/doses-extras.test.js`.
*/
class DosesExtrasTest {
private val ZONE = ZoneId.of("Europe/Paris")
/** 2026-09-27 12:00 Europe/Paris. */
private val noon = ZonedDateTime.of(2026, 9, 27, 12, 0, 0, 0, ZONE).toInstant().toEpochMilli()
private fun lab(atMs: Long) = LabResult(
marker = "E2", value = 100.0, unit = "pg/mL", timestamp = atMs
)
// ── routeIcon ──────────────────────────────────────────────────────────
@Test
fun `each route has its own glyph (except IM-SC sharing the syringe by design)`() {
val oral = routeIcon(AdministrationRoute.ORAL)
val im = routeIcon(AdministrationRoute.INJECTION_IM)
val sc = routeIcon(AdministrationRoute.INJECTION_SUBCUT)
val gel = routeIcon(AdministrationRoute.TRANSDERMAL_GEL)
val patch = routeIcon(AdministrationRoute.TRANSDERMAL_PATCH)
val other = routeIcon(AdministrationRoute.OTHER)
assertNotEquals(oral, im)
assertNotEquals(oral, gel)
assertNotEquals(oral, patch)
assertNotEquals(im, gel)
assertNotEquals(gel, patch)
assertNotEquals(patch, other)
assertEquals("IM/SC : même seringue (teinte UI distincte)", im, sc)
}
@Test
fun `null route falls back to the OTHER glyph`() {
assertEquals(routeIcon(AdministrationRoute.OTHER), routeIcon(null))
}
// ── labMarkersForDose ──────────────────────────────────────────────────
@Test
fun `same-day lab before and after the dose produce both markers`() {
val markers = labMarkersForDose(
listOf(lab(noon - 3_600_000L), lab(noon + 3_600_000L)), noon, ZONE
)
assertTrue(markers.first)
assertTrue(markers.second)
}
@Test
fun `lab of another day never marks the dose`() {
val yesterday = noon - 24 * 3_600_000L
val markers = labMarkersForDose(listOf(lab(yesterday)), noon, ZONE)
assertFalse(markers.first)
assertFalse(markers.second)
}
@Test
fun `midnight boundary is a calendar-day comparison`() {
// Lab à 23:50 la veille, dose à 00:10 : jours calendaires DIFFÉRENTS
// → pas de marqueur (le lien clinique ne traverse pas minuit).
val lateEvening = ZonedDateTime.of(2026, 9, 26, 23, 50, 0, 0, ZONE).toInstant().toEpochMilli()
val earlyMorning = ZonedDateTime.of(2026, 9, 27, 0, 10, 0, 0, ZONE).toInstant().toEpochMilli()
val markers = labMarkersForDose(listOf(lab(lateEvening)), earlyMorning, ZONE)
assertFalse(markers.first)
assertFalse(markers.second)
}
@Test
fun `lab exactly at dose time counts as AFTER (documented convention)`() {
val markers = labMarkersForDose(listOf(lab(noon)), noon, ZONE)
assertFalse(markers.first)
assertTrue(markers.second)
}
@Test
fun `no labs - no markers`() {
val markers = labMarkersForDose(emptyList(), noon, ZONE)
assertFalse(markers.first)
assertFalse(markers.second)
}
}

View File

@ -0,0 +1,86 @@
package com.hormonetrack.ui
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import com.hormonetrack.ui.screens.treatmentsForDisplay
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Tests de l'ORDRE D'AFFICHAGE de la page Traitements (v1.11.0, demande
* utilisatrice : « les traitements inactifs faudrait les placer à la fin
* (tout en bas) de la liste ») — [treatmentsForDisplay] :
* - les ACTIFS d'abord, les INACTIFS regroupés après ;
* - tri STABLE : l'ordre relatif DANS chaque groupe est conservé (le tri
* ne réordonne jamais les actifs entre eux, ni les inactifs entre eux) ;
* - cas limites : liste vide, tout actif, tout inactif (identité).
*
* PUR (aucune dépendance Android) ; miroir web `treatmentsForDisplay`
* (js/data/models.js, tests/treatments-display.test.js).
*/
class TreatmentsDisplayTest {
private fun tr(id: Long, name: String, isActive: Boolean) = Treatment(
id = id, name = name, type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = 5.0, doseUnit = "mg",
isActive = isActive, createdAt = 0
)
@Test
fun `inactive treatments are grouped at the end`() {
val mixed = listOf(
tr(1, "EEn", true),
tr(2, "EV-old", false),
tr(3, "CPA", true),
tr(4, "EEn-old", false),
tr(5, "Ritaline", true)
)
val display = treatmentsForDisplay(mixed)
assertEquals(
"actifs d'abord (ordre conservé), inactifs après (ordre conservé)",
listOf(1L, 3L, 5L, 2L, 4L),
display.map { it.id }
)
assertEquals(
"aucun inactif avant un actif",
0,
display.indexOfFirst { !it.isActive }.let { firstInactive ->
if (firstInactive < 0) 0
else display.subList(firstInactive, display.size).count { it.isActive }
}
)
}
@Test
fun `ordering inside each group is preserved (stable sort)`() {
val mixed = listOf(
tr(10, "A-inactif", false),
tr(11, "B-actif", true),
tr(12, "C-inactif", false),
tr(13, "D-actif", true)
)
assertEquals(
listOf(11L, 13L, 10L, 12L),
treatmentsForDisplay(mixed).map { it.id }
)
}
@Test
fun `all active - order unchanged`() {
val all = listOf(tr(3, "C", true), tr(1, "A", true), tr(2, "B", true))
assertEquals(listOf(3L, 1L, 2L), treatmentsForDisplay(all).map { it.id })
}
@Test
fun `all inactive - order unchanged`() {
val all = listOf(tr(2, "B", false), tr(1, "A", false))
assertEquals(listOf(2L, 1L), treatmentsForDisplay(all).map { it.id })
}
@Test
fun `empty list stays empty`() {
assertEquals(0, treatmentsForDisplay(emptyList()).size)
}
}

View File

@ -3,6 +3,190 @@
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
## [1.13.0] — 2026-09-30 (versionCode 50)
### Ajouté — la reco de prise de sang devient prédictive (3 niveaux clairs)
- **Valeur E2 attendue au creux recommandé** : la carte (Accueil + Analyses)
affiche désormais « ≈ X pg/mL » quand l'auto-calibration fournit ses
facteurs — sinon rien (une valeur brute non calibrée serait trompeuse).
Tu sais AVANT de piquer si le creux tombera dans ta cible.
- **Régime POOLÉ entre traitements identiques** : `pooledRegimeDoses` — les
doses de tous les traitements partageant (ester effectif, mg) forment UNE
séquence. Un re-parenting d'historique (créer un « … - old » et y
déplacer des doses) ne fait plus redémarrer la stabilisation. Garde-fous
épinglés : ester ≠ → jamais poolé (transition EV→EEn) ; dose ≠ → exclue
(le 8 mg casse le régime, trou conservateur).
- **Cible de creux E2** (opt-in, Paramètres) : référence personnelle « je
veux être dans [basse, haute] au creux recommandé » — la prédiction est
colorée dans/hors cible sur la carte. ⚠️ **DISTINCT des seuils d'alerte**
(§9.bis) : les seuils surveillent le niveau en continu et notifient ; la
cible est une référence de lab, elle ne notifie JAMAIS. Voyage dans le
backup (champs optionnels rétrocompatibles — un backup ancien n'efface
pas la cible locale).
- **Régression n°7** épinglée sur données réelles (`backup-v1.12.0.json`,
gitigné — l'export avec le re-parenting « 6d-old ») : le régime poolé
traverse la frontière de re-parenting et reste arrêté par le 8 mg.
Vérifié : **284 tests JVM** (250 sans données locales) + **14 tests UI** +
lint verts ; **web 199 tests + E2E verts** (`check.sh`).
## [1.12.0] — 2026-09-29 (versionCode 49)
### Corrigé — un traitement inactif à Posologie projetait encore ses injections (#70)
- **Remontée** : « une injection qui n'est plus active est toujours prise en
compte dans le graphique de simulations si elle contient une posologie et
qu'elle a déjà été utilisée — un traitement mis en inactif ne devrait
plus être pris en compte pour les futures prédictions ».
- **Cause** : `generateForecastDoses` (source unique des créneaux
prévisionnels) ne vérifiait pas l'archivage ; de plus l'horizon du chip
Prévision du graphique incluait les Posologies INACTIVES.
- **Fix** : garde `!isActive` dans le moteur (l'historique reste simulé —
§6.bis inchangé — seul le FUTUR s'arrête, comme les rappels depuis
v1.4.0) + horizon/chip Prévision calculés sur les ACTIFS uniquement.
**Miroir web appliqué** (`js/pk/pk-engine.js` + `js/ui/chart.js`).
- Tests : 3 tests JVM (`ForecastDosesGuardTest`) + 3 miroirs web ;
émulateur avec données modifiées (2 inactifs à Posologie 7 j) :
Prévision on/off sans crash.
### Ajouté — icônes de forme de prise dans la page Doses
- Chaque ligne de dose porte désormais l'icône de sa forme : 💊-icône pilule
(oral), seringue (injection IM et SC — teinte distincte pour la SC),
goutte (gel), sparadrap (patch), compte-gouttes (autre). Miroir web :
émojis (`routeGlyph`, même mapping).
### Ajouté — marqueurs « prise de sang le même jour » sur les lignes de doses
- Une analyse de sang effectuée LE MÊME JOUR qu'une dose affiche un petit
encart latéral 🧪↑ (avant la dose) et/ou 🧪↓ (après) — pour visualiser
les prises de sang dans le tableau de dosages (demande utilisatrice).
Fenêtre = jour calendaire LOCAL (au-delà le lien clinique devient ténu).
Helper pur `labMarkersForDose` (Kotlin + miroir web) — 7 tests JVM
(`DosesExtrasTest` : mapping icônes + marqueurs, frontière minuit,
convention « à l'heure de la dose = après ») + 10 miroirs web.
- Vérifié émulateur avec données réelles : glyphes en tête de liste,
marqueur « Blood test later the same day » sur la dose du 10 sept
(analyse 35 min après).
Vérifié : **274 tests JVM** (240 sans données locales) + **14 tests UI** +
lint verts ; **web 194 tests + E2E verts** (`check.sh`).
## [1.11.0] — 2026-09-29 (versionCode 48)
### Ajouté — les traitements inactifs regroupés en bas de la page Traitements
- **Demande** : « les traitements inactifs sont peu discernables par rapport
aux traitements actifs — les placer à la fin (tout en bas) de la liste
pour éviter les confusions ».
- **Tri** : les ACTIFS d'abord, les INACTIFS regroupés **tout en bas**, sous
un en-tête de section « Traitements inactifs » (n'apparaît que s'il
existe des inactifs). Tri STABLE : l'ordre relatif à l'intérieur de
chaque groupe est conservé — le tri ne réordonne jamais les actifs entre
eux, ni les inactifs entre eux.
- **Distinction visuelle renforcée** : les cartes inactives sont ATTÉNUÉES
(alpha 0,55) en plus du badge « inactif » — second signal demandé
(« peut-être les rendre plus distincts ») ; elles restent lisibles et
tappables (l'édition d'un traitement archivé doit rester possible).
- Helper pur `treatmentsForDisplay` (Kotlin + **miroir web** en
`js/data/models.js`) — 5 tests JVM (`TreatmentsDisplayTest`) + 4 tests
web (`tests/treatments-display.test.js`).
- Vérifié émulateur avec données réelles seedées : les 5 actifs en haut,
en-tête puis les 2 inactifs (EV-old, EEn-old) tout en bas.
Vérifié : **264 tests JVM** (230 sans données locales) + **14 tests UI** +
lint verts ; **web 184 tests + E2E verts** (`check.sh`).
## [1.10.1] — 2026-09-28 (versionCode 47)
### Amélioré — durcissement du système de rappels (revue de code post-v1.10.0)
- **`launchMode="singleTop"` sur MainActivity** : un tap sur la notification
quand l'app est DÉJÀ ouverte délivre désormais `onNewIntent` (dialog de
saisie ouvert, extras traités) **sans détruire/recréer l'activité** —
l'ancien combo `standard` + `CLEAR_TOP` recréait tout et rendait le chemin
`onNewIntent` de la v1.10.0 inopérant (code mort).
- **Instant PLANIFIÉ dans l'alarme (`EXTRA_PLANNED_AT`)** : le garde
« créneau déjà honoré » (v1.10.0) évaluait le jour au moment du FEU — une
alarme inexacte (`setWindow`) ou retardée (Doze) glissant après minuit
évaluait le mauvais jour (23:58 → 00:02). L'instant planifié voyage
désormais dans l'intent de l'alarme, le snooze le FORWARDE tel quel, et
le garde évalue toujours le jour du CRÉNEAU. Vérifié sur émulateur par
broadcast avec `planned_at` daté d'hier → notification correctement
sautée.
- **`BootReceiver` consolidé** (dette §20.bis #2) : `goAsync` + coroutine IO
(pattern ReminderReceiver) — remplace le `Thread` + double `runBlocking`
de la v1.0 ; une seule lecture séquentielle des DAO, `finish()` garanti.
- Audit doc : comptes de tests périmés corrigés (§14 note #64, §16.ter).
Vérifié : 259 tests JVM + 14 tests UI + lint verts ; scénarios émulateur
(singleTop + planned_at). Web non concerné (aucune logique miroir touchée).
## [1.10.0] — 2026-09-27 (versionCode 46)
### Corrigé — la recommandation de prise de sang précédait la stabilisation (#68)
- **Remontée** : « la recommandation suggère une date antérieure à la
stabilisation des taux » (données réelles de la remontée → 6ᵉ régression
épinglée `local-test-data/backup-v1.9.8.json`, gitigné).
- **Cause (double)** : le « creux » était le **minimum de la fenêtre entière**
(créneau N−1 → créneau N). Pour un ester à montée lente (EEn : pic ~J+5 ≈
intervalle 7 j), ce minimum tombe **juste après l'injection PRÉCÉDENTE**
(creux d'absorption, ~3 h) — pas juste avant la suivante : la date proposée
était gouvernée par l'injection N−1, elle-même antérieure à la
stabilisation, et le texte « juste avant ton injection du … » était faux de
plusieurs jours. Balayage sur ±60 jours de `now` (test) : creux 5 oct 02:32
vs stabilisation 7 oct 04:03.
- **Fix** : le creux = **niveau PRÉ-INJECTION du créneau** (dernier point de
courbe avant l'injection) — restaure le principe v1.8.0 documenté (« juste
avant l'injection suivante, le moment le plus comparable »), rend la carte
cohérente (creux 46 min avant le créneau étiqueté) et le garde de
stabilisation s'exprime sur l'instant réellement proposé. Pour les esters à
t½ courte (EV), le point pré-injection EST le minimum de fenêtre :
comportement inchangé. **Miroir web appliqué** (`js/pk/lab-timing.js`).
- Tests : régression n°6 data-driven (4 tests dont un balayage de `now` sur
±60 j — l'invariant « creux ≥ stabilisation » doit tenir À TOUT INSTANT) ;
les 17 LabTimingTest passent inchangés (leur sémantique était celle du fix).
### Corrigé — le dialog de dose se rouvrait à chaque retour sur l'accueil (#69)
- **Remontée** : tap sur la notification de rappel → le dialog de saisie
s'ouvre ; le fermer puis changer d'onglet et revenir sur l'accueil le
ROUVRAIT à chaque fois (reproduit et revérifié sur émulateur).
- **Cause** : la demande vivait au niveau ACTIVITÉ (paramètre d'intent) mais
la consommation vivait dans un `LaunchedEffect` de Home — quitter l'onglet
détruit les `remember` locaux, le paramètre survivait → à chaque
ré-entrée en composition, le dialog se rouvrait.
- **Fix** : la demande est un état `MutableState<Long?>` détenu par
MainActivity (survit aux onglets), consommé UNE fois par HomeScreen ;
les extras sont retirés de l'intent après lecture (un recreate ne doit
jamais rouvrir une notification traitée) ; et un tap notification quand
l'app est déjà ouverte ouvre désormais aussi le dialog (avant :
silencieusement perdu).
- Tests : nouveau test UI instrumenté `LogDoseRequestTest` (ouverture 1× +
plus jamais après aller-retour d'onglets) + vérification émulateur.
### Ajouté — la notification de rappel saute un créneau déjà honoré
- **Demande** : si la dose prévue du jour est saisie AVANT l'heure de
rappel, la notification ne doit pas sonner pour une prise déjà faite ; le
rappel suivant repart au créneau suivant (idem pour le quotidien des
oraux sans Posologie).
- **Implémentation** : garde au moment du déclenchement —
`PharmacokineticEngine.hasDoseLoggedOnDay` (pur, jour calendaire LOCAL,
testable) consultée par `ReminderReceiver` : dose loguée aujourd'hui →
notification sautée (AppLog) + re-programmation du créneau suivant dans
tous les cas. Robuste quel que soit le moment de la saisie.
- Tests : 6 tests JVM (`ReminderLoggedDayTest` : même jour, veille,
frontière minuit, autre traitement, liste vide, mélange) + vérification
A/B sur émulateur (EEn dose-loguée-ce-jour → 0 notif ; Fluoxetine sans
dose → 1 notif).
Vérifié : **259 tests unitaires** (dont régression n°6 sur données réelles)
+ **14 tests UI** + lint vert ; web miroir : **180 tests + E2E verts**
(`check.sh`). Version web alignée au moment de la release (checklist 4.bis).
## [1.9.8] — 2026-09-22 (versionCode 45) ## [1.9.8] — 2026-09-22 (versionCode 45)
### Ajouté — tests UI Compose, signature release dédiée, README anglais ### Ajouté — tests UI Compose, signature release dédiée, README anglais

View File

@ -11,7 +11,7 @@
1. [Contexte & objectifs](#1-contexte--objectifs) 1. [Contexte & objectifs](#1-contexte--objectifs)
2. [Historique du projet](#2-historique-du-projet) 2. [Historique du projet](#2-historique-du-projet)
3. [Stack & versions (épinglées — à jour v1.9.8)](#3-stack--versions-épinglées--à-jour-v198) 3. [Stack & versions (épinglées — à jour v1.10.0)](#3-stack--versions-épinglées--à-jour-v1100)
4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine) 4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine)
5. [Architecture générale](#5-architecture-générale) 5. [Architecture générale](#5-architecture-générale)
6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre) 6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre)
@ -63,7 +63,12 @@ Fonctionnalités v1 :
| Date | Événement | | Date | Événement |
|---|---| |---|---|
| 22 sept. 2026 (session v1.9.8) | **Socle qualité : tests UI Compose + signature release dédiée + découpage Settings** (revue de code du 21 sept. : « pas de CI, pas de tests UI, APK signé clé debug, écrans qui grandissent »). (a) **13 tests UI instrumentés** (`app/src/androidTest/uitest/`, §8.ter) : navigation 5 onglets (preuves = éléments inconditionnels : chips 24 h, FAB), dialog Nouveautés (épinglage du fix v1.3.2 : ne réapparaît pas après dismiss+restart), sections Paramètres + version installée = BuildConfig (le bug #46 ne peut plus revenir) ; déterminisme = pm grant shell pré-launch + markChangelogSeen + assertions via targetContext (indépendant FR/EN) + zéro dépendance aux données Room ; deps androidTest (BOM + ui-test-junit4 + ext:junit-ktx + junit explicite). (b) **Signature release conditionnelle** (§16.quater) : `scripts/make-release-keystore.sh` (shellcheck+shfmt) génère keystore + keystore.properties (gitignorés), `assembleRelease` l'utilise s'il existe, sinon fallback clé debug inchangé ; migration backup→désinstall→réimport documentée. (c) **SettingsScreen découpé** (840 → 377 + 11 fichiers 33–164 lignes dans `ui/screens/settings/`, §10) — déplacement pur, commentaires historiques conservés, ChartScreen volontairement entier (garde de source #54). **Vérifications** : 249 tests JVM + 13 UI verts, lint vert, assembleDebug/Release OK, **smoke test RELEASE émulateur** (accueil, 5 onglets, Paramètres complet scroll compris — version 1.9.8 visible, éditeur traitement, dialog dose + garde « crée un traitement », 0 crash logcat — anti-#64). **README EN** (README.en.md, liens FR/EN croisés ; DEVELOPPEMENT.md reste FR par choix assumé). | | 30 sept. 2026 (session v1.13.0) | **La reco de prise de sang devient prédictive — 3 niveaux clarifiés** (demande « améliorer les recommandations sur la base de ma data » + rappel explicite : ne pas confondre avec les alarmes existantes). **Système à 3 niveaux documentés (§7.11)** : seuils d'alerte (continu + notifications, §9.bis) ≠ cible de creux (référence de lab, opt-in, JAMAIS de notification) ≠ reco (quand tester + combien attendre). (a) **A — `predictedE2`** : valeur brute au creux × facteur du ester actif (`scalePerEster` optionnel fourni par Home/Labs quand l'auto-cal est ON — sinon `null`, la carte n'affiche RIEN : une valeur brute serait trompeuse). (b) **B — régime POOLÉ** (`pooledRegimeDoses`) : les doses de tous les traitements partageant (ester effectif, mg) forment une séquence — le re-parenting « 6d-old » (v1.12.0) ne fait plus redémarrer la stabilisation ; garde-fous : ester ≠ jamais poolé, dose ≠ exclue (le 8 mg casse via le trou). Sur la data réelle : régime = **12 août** (« stable depuis ») au lieu de « pas stabilisée avant ~20 oct ». (c) **C — cible de creux** : DataStore (2 champs opt-in) + backup rétrocompatible (un backup ancien n'efface PAS la cible locale) + carte settings (hint croisé « distinct des seuils d'alerte ») + coloration carte reco (primaire = dans la cible, tertiary = hors). **Régression n°7** data-driven sur le nouvel export (`backup-v1.12.0.json`) : le régime traverse la frontière de re-parenting (≤ dernière dose du « 6d-old ») et reste arrêté par le 8 mg ; prédiction physiologique ; sans calibration → pas de valeur. ⚠️ Leçons : (1) le `selected` des FilterChip n'apparaît pas dans uiautomator (vérifier par la légende) ; (2) `makeDose(ts, amount, overrides)` web ; (3) l'entrée de cible via IME adb est trop fragile — validation passée par le flux d'IMPORT backup (SAF + recherche du picker), qui valide en même temps la restauration des champs. Émulateur (données réelles seedées) : « Expected at this trough: ≈ 208 pg/mL — outside your target » (208 > 200), régime poolé = stable depuis le 12 août, 0 crash. **284 tests JVM** (250 sans données locales) + 14 UI + lint ; **web 199 tests + E2E** (check.sh) — leçon E2E : bump de WEB_VERSION SANS section CHANGELOG = dialog vide (v1.7.1 re-mordue). **Publication** : commit ae14cee + tag v1.13.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 84, farewell id 1588598) ; **web v1.13.0 sync complet** (commit 902d521 + tag + **releases publiées sur les 2 instances — étape 4 incluse**, zip 196 Ko vérifié par téléchargement). |
| 29 sept. 2026 (session v1.12.0) | **Fix #70 + 2 améliorations Doses** (demandes). (a) **#70 — inactif à Posologie projetait encore** : `generateForecastDoses` (source unique des créneaux) ne vérifiait pas l'archivage, et l'horizon du chip Prévision incluait les Posologies INACTIVES. FIX : garde `!isActive` dans le moteur + horizon/chip sur les ACTIFS — §6.bis inchangé (historique toujours simulé, seul le FUTUR s'arrête, comme les rappels v1.4.0). **Miroir web** (pk-engine.js + chart.js). 3 tests JVM + 3 web. Émulateur : seed modifié (2 inactifs à Posologie 7 j), Prévision on/off 0 crash — leçon uiautomator : l'attribut `selected` des FilterChip ne reflète PAS la sélection Compose (vérifier par la LÉGENDE attendue, comme dit la recette §16.ter). (b) **Icônes de forme de prise** dans Doses (demande) : `routeIcon` pur — pilule/seringue (IM+SC, teinte tertiary pour SC)/goutte/sparadrap/compte-gouttes ; ⚠️ `MedicationLiquid` N'EXISTE PAS dans icons-extended 1.7.8 (résolu hors BOM) → `Colorize` pour OTHER (vérifié au jar). (c) **Marqueurs « prise de sang le même jour »** : `labMarkersForDose` pur (jour calendaire LOCAL, lab à l'heure exacte = APRÈS — convention documentée) → encart latéral 🧪↑/🧪↓ sur la ligne ; miroir web émojis + title. 7 tests JVM `DosesExtrasTest` + 10 web `doses-extras.test.js` (⚠️ helper web `makeDose(ts, amount, overrides)` — le 1ᵉʳ arg est le TIMESTAMP). Validé émulateur : content-desc des icônes (IM injection/Oral) + marqueur « Blood test later the same day » sur la dose du 10 sept. 274 tests JVM + 14 UI + lint ; web 194 tests + E2E (check.sh). **Publication** : commit b3372a8 + tag v1.12.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 82, farewell id 1588590) ; web v1.12.0 sync (commit 4bdcd52 + tag, farewell = miroir serveur déjà répliqué). **⚠️ OUBLI de l'utilisatrice : les RELEASES web n'avaient pas été publiées depuis v1.9.7** (étape 4 du processus web sautée — tags seuls) → backfill le jour même : v1.10.0, v1.11.0, v1.12.0 sur cloudyfy ET farewell + **v1.9.5 backfillée sur farewell** (jamais publiée) ; zips vérifiés par téléchargement ; leçon documentée §10 web (« commit + tag » ≠ « release »). |
| 29 sept. 2026 (session v1.11.0) | **Page Traitements : inactifs regroupés en bas + atténués** (demande : « peu discernables — les placer à la fin, et peut-être les rendre plus distincts »). Helper PUR `treatmentsForDisplay` (tri STABLE : actifs d'abord, inactifs après, ordre relatif conservé DANS chaque groupe) + LazyColumn en 3 sections (actifs / en-tête `inactive_section` SEULEMENT si des inactifs existent / inactifs `dimmed = true`) + cartes inactives **alpha 0,55** en plus du badge (lisibles et tappables — l'édition d'un archivé doit rester possible). **Miroir web** : `treatmentsForDisplay` dans `js/data/models.js` (node-safe, pas de DOM) + rendu sections + `opacity:0.55` + clés i18n `inactive_section` FR/EN. Tests : **5 JVM** (`TreatmentsDisplayTest` : regroupement, stabilité, tout-actif, tout-inactif, vide) + **4 web** (`tests/treatments-display.test.js`) — pas de test UI instrumenté pour l'en-tête (dépendrait de données inactives, contre la convention data-indépendance §8.ter) : couverture JVM + validation émulateur (5 actifs en haut, en-tête + EV-old/EEn-old tout en bas, dumps tr1/tr2). 264 tests JVM + lint ; web 184 tests + E2E verts (check.sh). **Publication** : commit a0f3ac5 + tag v1.11.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 80, farewell id 1588588) ; web v1.11.0 sync (commit 0dd5fa7 + tag, farewell = miroir serveur déjà répliqué). |
| 28 sept. 2026 (session v1.10.1) | **Durcissement rappels — revue de code post-v1.10.0** (audit demandé : « optimisations ou problèmes, web et Android »). (a) **`launchMode="singleTop"`** : le combo `standard`+`CLEAR_TOP` recréait l'activité au tap notification app-ouverte et rendait le `onNewIntent` de la v1.10.0 inopérant (code mort) — désormais délivré SANS recreation (état UI préservé). (b) **`EXTRA_PLANNED_AT`** : le garde « créneau déjà honoré » évaluait le jour au moment du FEU — une alarme inexacte (`setWindow`/Doze) glissant 23:58→00:02 évaluait le mauvais jour ; l'instant planifié voyage dans l'intent (scheduleFor), le snooze le FORWARDE, le garde évalue le jour du CRÉNEAU ; vérifié émulateur (broadcast `planned_at` hier → notif sautée). (c) **`BootReceiver` consolidé** (dette §20.bis #2 FAIT) : `goAsync`+coroutine, `runBlocking` supprimés. (d) Style (import `mutableStateOf`) + audit doc (comptes 223 résiduels §14/#64 et §16.ter → 259 ; §9 et §20.bis à jour). Web NON concerné (miroir lab-timing laissé tel quel — boucle correcte et testée ; `importJson` par lots reste en dette). 259 tests JVM + 14 UI + lint verts. **Publication** : commit d531eca + tag v1.10.1 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 78, farewell id 1588586) ; validation §16.ter sur l'APK RELEASE AVANT tag (accueil + données, singleTop, planned_at A/B, 0 crash). |
| 27-28 sept. 2026 (session v1.10.0) | **2 fixes + 1 amélioration rappels** (remontées + nouvelles données réelles → 6ᵉ régression épinglée `backup-v1.9.8.json`). (a) **#68 — reco de prise de sang antérieure à la stabilisation** : le « creux » = minimum de la FENÊTRE ENTIÈRE tombait juste après l'injection PRÉCÉDENTE pour les esters à montée lente (EEn pic ~J+5 ≈ intervalle 7 j → creux d'absorption ~3 h post-injection) — date gouvernée par l'injection N−1 (antérieure à la stab) et « juste avant ton injection du … » faux de plusieurs jours (réel : creux 5 oct 02:32 < stab 7 oct 04:03). FIX : creux = niveau PRÉ-INJECTION du créneau (restaure le principe v1.8.0 documenté ; EV inchangé — le point pré-injection EST son min de fenêtre) ; **miroir web appliqué** (lab-timing.js). Tests : RegressionUserCase6Test data-driven dont balayage de `now` ±60 j (invariant « creux ≥ stab » à TOUT INSTANT) ; les 17 LabTimingTest passent inchangés. (b) **#69 — dialog fantôme** : tap notification → dialog ; le fermer + changer d'onglet + revenir sur l'accueil le ROUVRAIT à chaque fois (paramètre d'activité vs consommation dans les remember de Home, détruits à chaque sortie d'onglet — reproduit sur émulateur AVANT fix). FIX : `MutableState<Long?>` détenu par MainActivity, consommé UNE fois (extras retirés de l'intent après lecture ; bonus : tap notification app-ouverte ouvre aussi le dialog). Test UI `LogDoseRequestTest` + vérification émulateur. (c) **Rappel sauté si dose déjà loggée** (demande) : garde au déclenchement — `hasDoseLoggedOnDay` (pur, jour calendaire LOCAL) dans ReminderReceiver → notif sautée + re-programmation créneau suivant dans tous les cas ; 6 tests JVM + vérification A/B émulateur (EEn dose-du-jour → 0 notif, Fluoxetine sans dose → 1 notif). **259 tests JVM + 14 UI + lint** ; **web 180 tests + E2E verts** (check.sh). **Publication** : commit a4f859f + tag v1.10.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 77, farewell id 1588583). **Web miroir v1.10.0 sync** (fix #68 lab-timing, WEB_VERSION alignée, commit b865b16 + tag poussés ; #69/garde rappel = Android-only, notés dans le changelog web). |
| 22 sept. 2026 (session v1.9.8) | **Socle qualité : tests UI Compose + signature release dédiée + découpage Settings** (revue de code du 21 sept. : « pas de CI, pas de tests UI, APK signé clé debug, écrans qui grandissent »). (a) **13 tests UI instrumentés** (`app/src/androidTest/uitest/`, §8.ter) : navigation 5 onglets (preuves = éléments inconditionnels : chips 24 h, FAB), dialog Nouveautés (épinglage du fix v1.3.2 : ne réapparaît pas après dismiss+restart), sections Paramètres + version installée = BuildConfig (le bug #46 ne peut plus revenir) ; déterminisme = pm grant shell pré-launch + markChangelogSeen + assertions via targetContext (indépendant FR/EN) + zéro dépendance aux données Room ; deps androidTest (BOM + ui-test-junit4 + ext:junit-ktx + junit explicite). (b) **Signature release conditionnelle** (§16.quater) : `scripts/make-release-keystore.sh` (shellcheck+shfmt) génère keystore + keystore.properties (gitignorés), `assembleRelease` l'utilise s'il existe, sinon fallback clé debug inchangé ; migration backup→désinstall→réimport documentée. (c) **SettingsScreen découpé** (840 → 377 + 11 fichiers 33–164 lignes dans `ui/screens/settings/`, §10) — déplacement pur, commentaires historiques conservés, ChartScreen volontairement entier (garde de source #54). **Vérifications** : 249 tests JVM + 13 UI verts, lint vert, assembleDebug/Release OK, **smoke test RELEASE émulateur** (accueil, 5 onglets, Paramètres complet scroll compris — version 1.9.8 visible, éditeur traitement, dialog dose + garde « crée un traitement », 0 crash logcat — anti-#64). **README EN** (README.en.md, liens FR/EN croisés ; DEVELOPPEMENT.md reste FR par choix assumé). **Publication** : commit 09cddfc + tag v1.9.8 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 75, farewell id 1588581). Web miroir NON concerné (v1.9.8 = socle Android uniquement : tests instrumentés, signature APK, découpage Compose, README du dépôt Android — rien à porter côté web, qui reste à v1.9.7). |
| 20 sept. 2026 (session v1.9.7) | **Fix « la suggestion de prise de sang change tout le temps »** (remontée + données réelles : logs d'injection à heures variables) : la règle de stabilisation v1.8.1 comparait l'écart inter-doses EXACTEMENT à l'écart précédent — un log 30 min plus tard cassait le régime et repoussait la stabilisation de 5 × t½ à CHAQUE injection (creux fuyant). FIX : l'écart doit rester dans **l'intervalle de Posologie ± 24 h** (`GAP_TOLERANCE_MS`, fenêtre vs l'interval THÉORIQUE — des logs 6,8 j puis 7,2 j ne se déstabilisent plus en cascade ; un vrai passage à 2 j reste hors fenêtre). Le créneau affiché suit toujours la dernière dose réelle (voulu). Tests : « interval change » ré-épinglé + 2 tests de tolérance. 249 verts + lint. Web miroir v1.9.7 (12 verts lab-timing). §7.11 mis à jour. | | 20 sept. 2026 (session v1.9.7) | **Fix « la suggestion de prise de sang change tout le temps »** (remontée + données réelles : logs d'injection à heures variables) : la règle de stabilisation v1.8.1 comparait l'écart inter-doses EXACTEMENT à l'écart précédent — un log 30 min plus tard cassait le régime et repoussait la stabilisation de 5 × t½ à CHAQUE injection (creux fuyant). FIX : l'écart doit rester dans **l'intervalle de Posologie ± 24 h** (`GAP_TOLERANCE_MS`, fenêtre vs l'interval THÉORIQUE — des logs 6,8 j puis 7,2 j ne se déstabilisent plus en cascade ; un vrai passage à 2 j reste hors fenêtre). Le créneau affiché suit toujours la dernière dose réelle (voulu). Tests : « interval change » ré-épinglé + 2 tests de tolérance. 249 verts + lint. Web miroir v1.9.7 (12 verts lab-timing). §7.11 mis à jour. |
| 20 sept. 2026 (session v1.9.6) | **Carte Accueil « Prochaines doses » — toutes les prises, triées** (remontée : le rappel quotidien CPA masquait le rappel hebdomadaire EEn — « je ne le vois quasiment pas ») : la carte liste UNE ligne par traitement TRIÉES par prochaine prise (1ʳᵉ en avant, suivantes en style secondaire), titre pluriel dès 2 lignes (`next_doses` FR/EN) ; helper PUR `nextDoseLine` (locale/zone paramétrables, template jours localisé `next_dose_days`) — 3 tests `NextDoseLineTest`. Notifications inchangées (une alarme par traitement). Web miroir v1.9.6 : helper pur `nextDoseLineText` (format.js) + même carte + 3 tests miroirs (misc) — 247 verts Android / 178 web. | | 20 sept. 2026 (session v1.9.6) | **Carte Accueil « Prochaines doses » — toutes les prises, triées** (remontée : le rappel quotidien CPA masquait le rappel hebdomadaire EEn — « je ne le vois quasiment pas ») : la carte liste UNE ligne par traitement TRIÉES par prochaine prise (1ʳᵉ en avant, suivantes en style secondaire), titre pluriel dès 2 lignes (`next_doses` FR/EN) ; helper PUR `nextDoseLine` (locale/zone paramétrables, template jours localisé `next_dose_days`) — 3 tests `NextDoseLineTest`. Notifications inchangées (une alarme par traitement). Web miroir v1.9.6 : helper pur `nextDoseLineText` (format.js) + même carte + 3 tests miroirs (misc) — 247 verts Android / 178 web. |
| 20 sept. 2026 (session v1.9.5) | **Revue de code post-v1.9.4 — audit lecture-seule des deux dépôts puis corrections** (demande : « les logiques, choix techniques, commentaires, optimisations ») : (a) **#67** : ticks X alignés sur le fuseau APPAREIL pendant que les labels étaient formatés dans le fuseau CHOISI (zone manquante à `xLabelTicks`, contredisant #55) + pas fixe `+24 h` dérivant d'1 h aux transitions DST → zone passée + avancement Calendar, test DST (Europe/Paris 29/03) ; (b) `EstrannaiseCloud.compute` : `nowMs` mort supprimé (KDoc mensonger) ; (c) **perf** : `E2Context`/`prepareE2Context` — le tracé labs (720 pts) reconstruisait les maps moteur par point, diluant v1.8.2 (résultat bit-identique) ; Paints texte + `detectExtrema` + formatters mémoïsés dans le Canvas ; (d) **règles ré-appliquées** : `modelLabelRes` (anti-#59), `PKModels.*` partout (fini les literals), `scheduler2` supprimé ; (e) **doc rattrapée** : 3 commentaires périmés (ODS/lookup/t½-table), §7.2/§7.3 (asset historique, cutoff 10×t½). **Web miroir v1.9.5** : garde `hasModel` ESE dans `cutoffHours` (un backup importé ESE+ester non couvert levait TypeError → écran vide), portage des optimisations v1.8.2 (contexte préparé, cutoffs par traitement, Bateman paresseux), min/max en une passe (RangeError possible sur ~230 k pts avec le Nuage), init MCMC non bloquante (data-ready ⇒ asset chargé), `pk_profiles.json` exclu du zip (export-ignore, −548 Ko livrés), E2E version paramétrée (`data-version` — anti-bug v1.9.3), nettoyages (import mort lab-timing, JSDoc « troncature », dialogs ESE=3, COLORS accueil, JSDoc orpheline). 244 verts Android + lint · 175 verts web + E2E ✓. | | 20 sept. 2026 (session v1.9.5) | **Revue de code post-v1.9.4 — audit lecture-seule des deux dépôts puis corrections** (demande : « les logiques, choix techniques, commentaires, optimisations ») : (a) **#67** : ticks X alignés sur le fuseau APPAREIL pendant que les labels étaient formatés dans le fuseau CHOISI (zone manquante à `xLabelTicks`, contredisant #55) + pas fixe `+24 h` dérivant d'1 h aux transitions DST → zone passée + avancement Calendar, test DST (Europe/Paris 29/03) ; (b) `EstrannaiseCloud.compute` : `nowMs` mort supprimé (KDoc mensonger) ; (c) **perf** : `E2Context`/`prepareE2Context` — le tracé labs (720 pts) reconstruisait les maps moteur par point, diluant v1.8.2 (résultat bit-identique) ; Paints texte + `detectExtrema` + formatters mémoïsés dans le Canvas ; (d) **règles ré-appliquées** : `modelLabelRes` (anti-#59), `PKModels.*` partout (fini les literals), `scheduler2` supprimé ; (e) **doc rattrapée** : 3 commentaires périmés (ODS/lookup/t½-table), §7.2/§7.3 (asset historique, cutoff 10×t½). **Web miroir v1.9.5** : garde `hasModel` ESE dans `cutoffHours` (un backup importé ESE+ester non couvert levait TypeError → écran vide), portage des optimisations v1.8.2 (contexte préparé, cutoffs par traitement, Bateman paresseux), min/max en une passe (RangeError possible sur ~230 k pts avec le Nuage), init MCMC non bloquante (data-ready ⇒ asset chargé), `pk_profiles.json` exclu du zip (export-ignore, −548 Ko livrés), E2E version paramétrée (`data-version` — anti-bug v1.9.3), nettoyages (import mort lab-timing, JSDoc « troncature », dialogs ESE=3, COLORS accueil, JSDoc orpheline). 244 verts Android + lint · 175 verts web + E2E ✓. |
@ -118,7 +123,7 @@ Leçon importante de la session build : **les erreurs de compilation et les bugs
(bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant (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. et en testant** — aucun build n'avait été lancé avant la session 3.
## 3. Stack & versions (épinglées — à jour v1.9.8) ## 3. Stack & versions (épinglées — à jour v1.10.0)
| Composant | Version | Où | | Composant | Version | Où |
|---|---|---| |---|---|---|
@ -133,7 +138,7 @@ et en testant** — aucun build n'avait été lancé avant la session 3.
| DataStore Preferences | 1.2.1 | idem | | DataStore Preferences | 1.2.1 | idem |
| Gson | 2.14.0 | idem | | Gson | 2.14.0 | idem |
| JUnit | 4.13.2 (testImplementation + androidTestImplementation explicite) | idem | | JUnit | 4.13.2 (testImplementation + androidTestImplementation explicite) | idem |
| Tests UI Compose | **ui-test-junit4 via le BOM** + `androidx.test.ext:junit-ktx:1.3.0` (androidTest, v1.9.8 — §8.ter) | idem | | Tests UI Compose | **ui-test-junit4 via le BOM** + `androidx.test.ext:junit-ktx:1.3.0` + `androidx.test.uiautomator:uiautomator:2.3.0` (androidTest, v1.9.8/v1.10.0 — §8.ter) | idem |
| WorkManager | 2.11.2 (**UTILISÉ depuis v1.4.2** : worker périodique des seuils d'alerte, cf §9.bis) | idem | | WorkManager | 2.11.2 (**UTILISÉ depuis v1.4.2** : worker périodique des seuils d'alerte, cf §9.bis) | idem |
| compileSdk / targetSdk | **37** / **36** ; minSdk 26 ; Java target 17 | app | | compileSdk / targetSdk | **37** / **36** ; minSdk 26 ; Java target 17 | app |
| buildFeatures | compose + **buildConfig** (VERSION_NAME pour l'app) | app | | buildFeatures | compose + **buildConfig** (VERSION_NAME pour l'app) | app |
@ -203,10 +208,14 @@ MainActivity (AppCompatActivity)
``` ```
Points clés : Points clés :
- `HormoneTrackApp.onCreate()` : init `PKProfileStore` (asset), canal de notification - `HormoneTrackApp.onCreate()` : init `EstrannaiseModels` (asset MCMC —
`PKProfileStore` HORS runtime depuis v1.9.0), canal de notification
- `MainActivity` : applique la langue sauvegardée (`AppCompatDelegate.setApplicationLocales`), - `MainActivity` : applique la langue sauvegardée (`AppCompatDelegate.setApplicationLocales`),
demande POST_NOTIFICATIONS (API 33+), lit les extras d'intent `open_log_dose` + demande POST_NOTIFICATIONS (API 33+) ; demande « logger une dose »
`treatment_id` (venus de la notification) → Home pré-ouvre le dialog de log (`open_log_dose` + `treatment_id` venus de la notification) = `MutableState<Long?>`
détenu par l'activité, **consommé UNE fois** par Home via
[HormoneTrackRoot] (fix #69 — extras retirés de l'intent après lecture,
tap notification app-ouverte géré par `onNewIntent`)
- **Tout calcul PK est hors UI thread** (`produceState` + `Dispatchers.Default`) - **Tout calcul PK est hors UI thread** (`produceState` + `Dispatchers.Default`)
## 6. Modèle de données (Room) ## 6. Modèle de données (Room)
@ -257,6 +266,7 @@ simulation de tout l'historique EV disparaissait) :
| ✅ Retiré des chips « Log rapide » (accueil) | `HomeScreen` → `activeTreatments` | | ✅ 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) | | ✅ 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 | | ✅ 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 |
| ✅ **Plus AUCUN créneau prévisionnel (v1.12.0, fix #70)** | `generateForecastDoses` garde `!isActive` — ni graphique Prévision, ni extension de fenêtre, ni horizon LabTiming ; le chip Prévision du graphique ne considère que les Posologies ACTIVES |
| ❌ JAMAIS retiré de la simulation | Home/Chart passent **`allTreatments`** au moteur — les écrans ne filtrent jamais | | ❌ 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 retiré de la calibration (auto) | `autoCalibrated(allTreatments, …)` |
| ❌ JAMAIS supprimé de l'historique des doses | `DosesScreen` liste tout, suppression manuelle uniquement | | ❌ JAMAIS supprimé de l'historique des doses | `DosesScreen` liste tout, suppression manuelle uniquement |
@ -624,16 +634,27 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques :
2. un creux n'interprétable que si le régime est **stabilisé** : 5 × t½ 2. un creux n'interprétable que si le régime est **stabilisé** : 5 × t½
terminale après le dernier changement (≈ 97 % de l'équilibre). terminale après le dernier changement (≈ 97 % de l'équilibre).
**v1.13.0 — le suivi s'appuie sur un système à TROIS niveaux distincts** :
| Niveau | Rôle | Notifications |
|---|---|---|
| Seuils d'alerte (§9.bis, `pk/Alerts.kt`) | surveiller le niveau estimé **en continu** | ✅ carte accueil + worker 15 min |
| **Cible de creux** (v1.13.0, opt-in) | référence **de lab** : « au creux recommandé, je veux être dans [basse, haute] » | ❌ jamais — lecture seule |
| Reco de prise de sang (cette section) | **quand** tester + **combien attendre** (`predictedE2`, v1.13.0) | — |
Les bandes sont volontairement indépendantes (ex. réelles : alertes
210–350, cible de creux 150–200 — la cible basse est SOUS l'alerte basse,
les fusionner serait faux).
- **Traitement porteur** : ESTRADIOL + injectable (`usesProfileModel`) + - **Traitement porteur** : ESTRADIOL + injectable (`usesProfileModel`) +
**actif** + Posologie > 0. Sinon `null` → carte cachée ; et si un **actif** + Posologie > 0. Sinon `null` → carte cachée ; et si un
injectable E2 actif est SANS Posologie, l'UI affiche l'**invite** injectable E2 actif est SANS Posologie, l'UI affiche l'**invite**
([LabTiming.shouldSuggestPosology], demande v1.8.0 : rendre découvrable le ([LabTiming.shouldSuggestPosology], demande v1.8.0 : rendre découvrable le
lien Posologie → recommandation). Les deux encarts sont exclusifs. lien Posologie → recommandation). Les deux encarts sont exclusifs.
- **t½ terminale** : TFS/WHSAH analytiques (`terminalHalfLifeDays`) ; - **t½ terminale** : TFS/WHSAH **analytiques** (`terminalHalfLifeDays`) ;
**Estrannaise** : lue dans la table par **Estrannaise analytique** depuis v1.9.0
[PKProfileStore.terminalHalfLifeDays] (refactor v1.8.0 : la pente ([EstrannaiseModels.terminalHalfLifeDays] — forme close estrannaise.js ;
log-linéaire utilisée par l'extrapolation de `sample()` — dernier point l'ancienne lecture de table `PKProfileStore` est HORS runtime, cf §7.12).
≥ 1 % du pic, 48 h précédentes — est partagée).
- **Début du régime courant** (v1.8.1, **correction du proxy v1.8.0** — - **Début du régime courant** (v1.8.1, **correction du proxy v1.8.0** —
critique : « changé d'ester, de dosage ET de posologie, et l'app disait critique : « changé d'ester, de dosage ET de posologie, et l'app disait
stabilisée depuis février ») : le régime courant = la **séquence terminale stabilisée depuis février ») : le régime courant = la **séquence terminale
@ -643,24 +664,49 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques :
exacte v1.8.1 cassait le régime à chaque variation d'heure de log et exacte v1.8.1 cassait le régime à chaque variation d'heure de log et
faisait fuir le creux recommandé ; « je m'injecte le même jour, à l'heure faisait fuir le creux recommandé ; « je m'injecte le même jour, à l'heure
près » — un vrai changement de créneau, ex. 2 j au lieu de 7 j, reste près » — un vrai changement de créneau, ex. 2 j au lieu de 7 j, reste
hors fenêtre). Stabilisation = régimeStart + hors fenêtre). **v1.13.0 : séquence POOLÉE entre traitements**
([LabTiming.pooledRegimeDoses]) — les doses de tous les traitements
partageant (ester effectif, mg) forment UNE séquence : le re-parenting
d'historique (« … - old » créé a posteriori, remontée v1.12.0) ne fait
plus redémarrer la stabilisation. Garde-fous : ester ≠ → jamais poolé
(transition EV→EEn, régression n°3) ; dose ≠ → exclue (le 8 mg casse le
régime via le trou qui suit — conservateur). Sur la data réelle de la
remontée : régime = 12 août (« stable depuis ») au lieu de « pas
stabilisée avant ~20 oct ». Stabilisation = régimeStart +
5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la 5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la
carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est
comparable que sur un intervalle régulier). comparable que sur un intervalle régulier).
- **Creux** : minimum local de la courbe E2 prévisionnelle (doses réelles + - **Creux** (**fix #68 v1.10.0**) : niveau PRÉ-INJECTION du créneau —
[PharmacokineticEngine.generateForecastDoses], pas 1 h) entre deux dernier point de la courbe E2 prévisionnelle (doses réelles +
injections ; FORME brute (sans calibration — le facteur ne déplace pas le [PharmacokineticEngine.generateForecastDoses], pas 1 h) juste avant
minimum) ; horizons : au moins 3 créneaux, étendu pour couvrir la l'injection (principe 1). ⚠️ L'ancien « minimum de la fenêtre entière »
stabilisation, borné à 12. tombait juste APRÈS l'injection précédente pour les esters à montée
lente (EEn : pic ~J+5 ≈ intervalle 7 j → creux d'absorption) : date
antérieure à la stabilisation + texte « juste avant ton injection » faux
de plusieurs jours. Pour EV (t½ courte) le point pré-injection EST le
minimum de fenêtre → comportement inchangé. FORME brute (sans
calibration — le facteur ne déplace pas le minimum) ; horizons : au
moins 3 créneaux, étendu pour couvrir la stabilisation, borné à 12.
- **Filtres** : creux ≥ now + 6 h ; creux > dernière prise de sang (jamais - **Filtres** : creux ≥ now + 6 h ; creux > dernière prise de sang (jamais
recommander un creux déjà mesuré) ; créneau ≥ stabilisation (sinon creux recommander un creux déjà mesuré) ; **creux ≥ stabilisation** (sinon
du créneau suivant — décision v1.8.0 « sauter »). creux du créneau suivant — décision v1.8.0 « sauter » ; depuis #68 le
garde s'exprime sur le creux lui-même, instant réellement proposé).
- **Valeur prédite + cible (v1.13.0)** : `LabRecommendation.predictedE2`
= valeur brute du modèle au creux × facteur du ester actif
(`scalePerEster` optionnel, fourni par Home/Labs quand l'auto-cal est ON ;
`null` → la carte n'affiche RIEN — une valeur brute non calibrée serait
trompeuse) ; statut face à la cible de creux personnelle via
[LabTiming.troughTargetStatus] (IN_TARGET/BELOW/ABOVE, bornes incluses,
`null` si cible absente) → coloration primaire (dans la cible) /
tertiary (hors) + suffixe « dans/hors de ta cible ».
- **UI** : LabsScreen — calcul `produceState` (Dispatchers.Default) + tick - **UI** : LabsScreen — calcul `produceState` (Dispatchers.Default) + tick
minute ; carte avec creux exact (« At the estimated trough »), créneau minute ; carte avec creux exact (« At the estimated trough »), créneau
associé (+ mention « ou simplement la veille »), statut de stabilisation, associé (+ mention « ou simplement la veille »), statut de stabilisation,
disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée. disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée.
- Tests `LabTimingTest` (v1.9.7 : 2 tests de tolérance ± 24 h ajoutés, - Tests `LabTimingTest` (17 — v1.9.7 : 2 tests de tolérance ± 24 h ;
le test v1.8.1 « interval change » ré-épinglé) + 1 test régression n°5. sémantique pré-injection inchangée par le fix #68) + régressions n°5 et
**n°6** (data-driven `backup-v1.9.8.json` : invariant « creux ≥
stabilisation » balayé sur `now` ± 60 j, pas 6 h).
### 7.12 Modèle Estrannaise ANALYTIQUE + nuage MCMC (v1.9.0) — `pk/EstrannaiseModels.kt`, `pk/EstrannaiseCloud.kt` ### 7.12 Modèle Estrannaise ANALYTIQUE + nuage MCMC (v1.9.0) — `pk/EstrannaiseModels.kt`, `pk/EstrannaiseCloud.kt`
@ -739,9 +785,9 @@ devient à son tour**.
## 8. Tests unitaires ## 8. Tests unitaires
**234 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **193 sans **284 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **250 sans
les données de test locales** (cf §8.bis : les 5 classes de régression, les données de test locales** (cf §8.bis : les 6 classes de régression sont
6/6/6/5/7 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2. skippées via `Assume`). Dépendance : JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = 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/…`). `app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
@ -948,14 +994,20 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
- emplacement : `local-test-data/backup-v1.0.0.json`, `backup-v1.2.0.json`, - emplacement : `local-test-data/backup-v1.0.0.json`, `backup-v1.2.0.json`,
`backup-v1.2.3.json` (ancien export, plus consommé par un test), `backup-v1.2.3.json` (ancien export, plus consommé par un test),
`backup-v1.3.1.json`, `backup-v1.4.2.json` et `backup-v1.7.0.json` `backup-v1.3.1.json`, `backup-v1.4.2.json`, `backup-v1.7.0.json`, `backup-v1.9.8.json` et
`backup-v1.12.0.json`
(copiés tels quels depuis l'export JSON de l'app) ; (copiés tels quels depuis l'export JSON de l'app) ;
`RegressionUserCaseTest` lit v1.0.0, `RegressionUserCaseTest` lit v1.0.0,
`RegressionUserCase2Test` lit v1.2.0, `RegressionUserCase3Test` lit `RegressionUserCase2Test` lit v1.2.0, `RegressionUserCase3Test` lit
**v1.3.1**, `RegressionUserCase4Test` lit **v1.4.2** (premier export avec **v1.3.1**, `RegressionUserCase4Test` lit **v1.4.2** (premier export avec
les settings v2), `RegressionUserCase5Test` lit **v1.7.0** (profil à 3 les settings v2), `RegressionUserCase5Test` lit **v1.7.0** (profil à 3
traitements dont CPA oral ; paire E2+T avec DEUX notes distinctes — traitements dont CPA oral ; paire E2+T avec DEUX notes distinctes —
épine le fix « une note sur deux » v1.7.1) ; épine le fix « une note sur deux » v1.7.1), `RegressionUserCase6Test` lit
**v1.9.8** (profil à 7 traitements, logs à heures variables ; épine le
fix #68 « creux avant stabilisation » — invariant balayé sur `now` ±60 j),
`RegressionUserCase7Test` lit **v1.12.0** (export avec re-parenting
« 6d-old » — épine le régime POOLÉ v1.13.0 : le régime doit traverser la
frontière de re-parenting et rester arrêté par la dose de mg différent) ;
- `.gitignore` contient `local-test-data/` → jamais commités. - `.gitignore` contient `local-test-data/` → jamais commités.
**GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** : **GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** :
1. `git check-ignore -v local-test-data/…` → la règle matche ; 1. `git check-ignore -v local-test-data/…` → la règle matche ;
@ -969,9 +1021,9 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
dans le code (le scanner refuserait, et ce serait une fuite). dans le code (le scanner refuserait, et ce serait une fuite).
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le - 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 fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI
exécute 193 tests au lieu de 223 (v1.8.1 ; chiffres historiques : 44/87 à v1.3.x, exécute 250 tests au lieu de 284 (v1.13.0 ; chiffres historiques : 44/87 à v1.3.x,
150/172 à v1.4.x, 160/183 à v1.5.0, 168/191 à v1.6.0, 177/200 à v1.7.0, 150/172 à v1.4.x, 160/183 à v1.5.0, 168/191 à v1.6.0, 177/200 à v1.7.0,
181/211 à v1.7.1, 190/220 à v1.8.0) ; 181/211 à v1.7.1, 190/220 à v1.8.0, 219/249 à v1.9.x, 225/259 à v1.10.x, 230/264 à v1.11.x, 240/274 à v1.12.x) ;
- le workdir des tests Gradle est le dossier du module (`app/`) → les tests - le workdir des tests Gradle est le dossier du module (`app/`) → les tests
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ; cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous - **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous
@ -1003,7 +1055,7 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod
### 8.ter Tests UI Compose (`app/src/androidTest/`, v1.9.8) ### 8.ter Tests UI Compose (`app/src/androidTest/`, v1.9.8)
**13 tests instrumentés** (`uitest/`) exécutés sur émulateur/appareil : **14 tests instrumentés** (`uitest/`) exécutés sur émulateur/appareil :
```bash ```bash
./gradlew connectedDebugAndroidTest # émulateur requis (ex. AVD hrt36) ./gradlew connectedDebugAndroidTest # émulateur requis (ex. AVD hrt36)
@ -1021,7 +1073,12 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod
- **`SettingsScreenTest` (3)** : sections de l'écran Paramètres toutes - **`SettingsScreenTest` (3)** : sections de l'écran Paramètres toutes
présentes + chips de langue littéraux (« Français »/« English ») + présentes + chips de langue littéraux (« Français »/« English ») +
**version installée = BuildConfig.VERSION_NAME** (le bug #46 — versions **version installée = BuildConfig.VERSION_NAME** (le bug #46 — versions
jamais bumpées dans les tags — ne peut plus revenir silencieusement). jamais bumpées dans les tags — ne peut plus revenir silencieusement) ;
- **`LogDoseRequestTest` (1, v1.10.0)** : le dialog de dose s'ouvre UNE fois
au lancement avec les extras de notification, puis ne se rouvre JAMAIS
après fermeture + aller-retour d'onglets (épinglage du fix #69). Lancement
manuel avec intent (règle vide + ActivityScenario) ; BACK = fermeture
indépendante des libellés (UiDevice, dep `uiautomator:2.3.0`).
**Déterminisme** (les tests tournent contre l'app RÉELLE, données incluses) : **Déterminisme** (les tests tournent contre l'app RÉELLE, données incluses) :
- permission `POST_NOTIFICATIONS` pré-grantée via `pm grant` shell AVANT le - permission `POST_NOTIFICATIONS` pré-grantée via `pm grant` shell AVANT le
@ -1061,7 +1118,21 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod
- `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity - `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity
(`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes (`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes
PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()` ; **puis PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()` ; **puis
reprogramme le prochain déclenchement** (goAsync + IO) reprogramme le prochain déclenchement** (goAsync + IO).
**v1.10.0 — garde « créneau déjà honoré »** : au déclenchement,
`PharmacokineticEngine.hasDoseLoggedOnDay` (pur, jour calendaire LOCAL —
6 tests `ReminderLoggedDayTest`) consulte les doses : dose du jour déjà
loguée → **notification sautée** (prise saisie avant l'heure de rappel —
la notif ne sonne pas pour une prise faite) ; la re-programmation du
créneau SUIVANT reste exécutée dans tous les cas. Vérifié A/B sur
émulateur (`am broadcast -n …ReminderReceiver` : EEn avec dose du jour →
0 notif, Fluoxetine sans dose → 1 notif).
**v1.10.1 — instant PLANIFIÉ (`EXTRA_PLANNED_AT`)** : le garde évalue le
jour du CRÉNEAU PLANIFIÉ (embarqué dans l'intent de l'alarme par
`scheduleFor`, forwardé tel quel par le snooze), pas le jour du feu
réel — une alarme inexacte (`setWindow`) ou retardée (Doze) glissant
après minuit évaluait le mauvais jour (23:58 → 00:02). Vérifié émulateur
(broadcast avec `planned_at` daté d'hier → notification sautée).
- `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert - `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert
DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance
la grille) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif la grille) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif
@ -1088,6 +1159,11 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
### 9.bis Alertes de seuil : `reminder/AlertNotifier.kt` + worker (v1.4.2) ### 9.bis Alertes de seuil : `reminder/AlertNotifier.kt` + worker (v1.4.2)
> ⚠️ À NE PAS CONFONDRE avec la **cible de creux** (v1.13.0, §7.11) : les
> seuils ci-dessous surveillent le niveau estimé EN CONTINU et notifient ;
> la cible de creux est une référence de lab évaluée en lecture seule sur
> la carte reco — elle ne notifie jamais.
- **Seuils** (`pk/Alerts.kt`, PUR) : `Thresholds` (E2 haut/bas pg/mL, T haut/bas - **Seuils** (`pk/Alerts.kt`, PUR) : `Thresholds` (E2 haut/bas pg/mL, T haut/bas
ng/mL, null = pas de limite), évaluation STRICTE (valeur == limite → rien), ng/mL, null = pas de limite), évaluation STRICTE (valeur == limite → rien),
`evaluateAll` (E2 puis T), validation haut > bas (`isCoherent`). `evaluateAll` (E2 puis T), validation haut > bas (`isCoherent`).
@ -1152,7 +1228,11 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
- `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même - `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition** ; avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition** ;
**en-tête « temps sous THS »** (v1.3.1) : carte depuis la 1re prise, jours **v1.12.0** : **icône de forme de prise** en tête de ligne (`routeIcon` pur —
pilule/seringue IM+SC teinte tertiary pour SC/goutte/sparadrap/compte-gouttes)
et **encart latéral « prise de sang le même jour »** (`labMarkersForDose` pur —
🧪↑ avant la dose, 🧪↓ après ; jour calendaire LOCAL, lab à l'heure exacte =
APRÈS) ; **en-tête « temps sous THS »** (v1.3.1) : carte depuis la 1re prise, jours
totaux + décomposition mois/jours (`HrtDuration`, mois = 30 j estimés) totaux + décomposition mois/jours (`HrtDuration`, mois = 30 j estimés)
- `LabsScreen` (v1.2.2) : **prise de sang E2 + T en une entrée** (LabDialog create : - `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 deux sections optionnelles → 1 ou 2 LabResult au même timestamp) ; affichage
@ -1162,7 +1242,12 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
les valeurs — choix de design : les labs sont prélevés ensemble) ; suggestions 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 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 / ⏰, - `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰,
badge inactif), FAB → éditeur badge inactif), FAB → éditeur. **v1.11.0** : ordre d'affichage =
`treatmentsForDisplay` (pur, tri STABLE — actifs d'abord, inactifs
regroupés TOUT EN BAS sous un en-tête `inactive_section` qui n'apparaît
que s'il existe des inactifs) + cartes inactives **atténuées** (alpha
0,55) — 5 tests `TreatmentsDisplayTest` ; miroir web
(`js/data/models.js` + `tests/treatments-display.test.js`)
- `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ; - `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ;
champs conditionnels (ester+modèle si injection, Bateman sinon) ; carte Calibration champs conditionnels (ester+modèle si injection, Bateman sinon) ; carte Calibration
(scaleFactor + « Calibrer avec les analyses ») ; **section « Posologie » (v1.2 : (scaleFactor + « Calibrer avec les analyses ») ; **section « Posologie » (v1.2 :
@ -1200,7 +1285,7 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
**« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T, **« 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 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 ; « Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ;
Export/Import JSON ; auto-backup (v1.7.0) ; logs ; à propos. Export/Import JSON ; auto-backup (v1.7.0) ; **carte « Cible de creux » (v1.13.0, opt-in — référence de lab distincte des seuils d'alerte, hint croisé)** ; logs ; à propos.
⚠️ **`ChartScreen` (790 lignes) reste volontairement ENTIER** : sa garde de ⚠️ **`ChartScreen` (790 lignes) reste volontairement ENTIER** : sa garde de
source (`ChartScreenSourceGuardTest`, fix #54) exige que le bloc source (`ChartScreenSourceGuardTest`, fix #54) exige que le bloc
`detectTransformGestures` ET les alias `gesture*` vivent dans CE fichier — `detectTransformGestures` ET les alias `gesture*` vivent dans CE fichier —
@ -1293,7 +1378,10 @@ Pièges :
restauré dans DataStore, **les paramètres utilisateur sont restaurés** restauré dans DataStore, **les paramètres utilisateur sont restaurés**
(v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est (v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est
appliquée sur le thread MAIN car `setApplicationLocales` recrée appliquée sur le thread MAIN car `setApplicationLocales` recrée
l'activité) et les rappels sont reprogrammés (`rescheduleAll`). Le dialog prévient que TOUT sera l'activité ; **v1.13.0 : cible de creux** `troughTargetLow`/`High`
restaurée SEULEMENT si le backup en porte une cohérente (haut > bas) —
un backup ancien n'efface pas la cible locale) et les rappels sont
reprogrammés (`rescheduleAll`). Le dialog prévient que TOUT sera
remplacé (bouton « Effacer & restaurer ») remplacé (bouton « Effacer & restaurer »)
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture - Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}` `openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}`
@ -1771,7 +1859,7 @@ l'optimiseur.
le chip « Lab track » s'active MAIS la courbe n'est ni dessinée (0 pixel le chip « Lab track » s'active MAIS la courbe n'est ni dessinée (0 pixel
#C2185B au screencap) ni légendée ; « Prolonger » idem. En DEBUG, le même #C2185B au screencap) ni légendée ; « Prolonger » idem. En DEBUG, le même
chip sur les mêmes données fonctionne (légende + 7090 px rose après fix). chip sur les mêmes données fonctionne (légende + 7090 px rose après fix).
**Les 191 tests JVM de l'époque sont verts** (223 aujourd'hui — ils ne passent JAMAIS par R8). **Les 191 tests JVM de l'époque sont verts** (259 aujourd'hui — ils ne passent JAMAIS par R8).
**Diagnostic** (méthode de bisection debug/release puis `mapping.txt`) : **Diagnostic** (méthode de bisection debug/release puis `mapping.txt`) :
`com.hormonetrack.pk.LabTrajectoryModel -> R8$$REMOVED$$CLASS$$…` — R8 full `com.hormonetrack.pk.LabTrajectoryModel -> R8$$REMOVED$$CLASS$$…` — R8 full
@ -1801,6 +1889,45 @@ pas avançait en ms fixes (`t += stepMs`) : dérive d'1 h aux transitions DST. L
quand un helper prend un fuseau pour FORMATTER, la même zone doit régler quand un helper prend un fuseau pour FORMATTER, la même zone doit régler
l'ALIGNEMENT — et un pas « 24 h » s'ajoute via Calendar (minuit LOCAL), pas en ms. l'ALIGNEMENT — et un pas « 24 h » s'ajoute via Calendar (minuit LOCAL), pas en ms.
**#68 (v1.10.0) — recommandation de prise de sang antérieure à la stabilisation** :
le « creux » était le minimum de la FENÊTRE ENTIÈRE (créneau N−1 → créneau N) — pour
un ester à montée lente (EEn : pic ~J+5 ≈ intervalle 7 j), ce minimum tombe quelques
heures après l'injection PRÉCÉDENTE (creux d'absorption) : la date proposée était
gouvernée par l'injection N−1 (antérieure à la stabilisation) et le texte « juste
avant ton injection du … » était faux de plusieurs jours. Réel : creux 5 oct 02:32
vs « pas stabilisée avant » 7 oct 04:03. FIX : creux = niveau PRÉ-INJECTION du
créneau (restaure le principe v1.8.0 ; pour EV le point pré-injection EST le min de
fenêtre → inchangé). Leçon : quand une valeur affichée est QUALIFIÉE par un texte
(« juste avant l'injection »), le calcul doit produire CETTE valeur — et un
minimum de fenêtre n'est pas un creux pré-injection si la courbe remonte dans
l'intervalle. Épinglé par RegressionUserCase6Test (balayage de `now` ±60 j :
l'invariant doit tenir à tout instant, pas seulement à l'instant du rapport).
**#69 (v1.10.0) — dialog de dose fantôme après une notification** : tap
notification → dialog de saisie ; le fermer puis changer d'onglet et revenir sur
l'accueil le ROUVRAIT à chaque fois. Cause : la demande vivait au niveau ACTIVITÉ
(paramètre d'intent) tandis que la consommation vivait dans un LaunchedEffect de
Home — quitter l'onglet détruit les `remember` locaux, le paramètre survivait →
chaque ré-entrée en composition rouvrait. FIX : `MutableState<Long?>` détenu par
MainActivity, consommé UNE fois ; extras retirés de l'intent après lecture (un
recreate ne rouvre pas une notification traitée) ; bonus : tap notification
app-ouverte ouvre désormais aussi le dialog. Leçon : une demande à consommer une
fois doit vivre à la MÊME durée de vie que sa consommation — jamais « paramètre
longue-vie + effet courte-vie ». Épinglé par le test UI LogDoseRequestTest.
**#70 (v1.12.0) — inactif à Posologie projetait encore ses injections** :
`generateForecastDoses` (source unique des créneaux prévisionnels) ne vérifiait
pas l'archivage — un injectable INACTIF avec Posologie et de l'historique
continuait d'alimenter le graphique Prévision, l'extension de fenêtre et
l'horizon LabTiming ; accessoirement le chip Prévision s'activait sur des
Posologies inactives. FIX : garde `!isActive` dans le moteur + horizon/chip sur
les ACTIFS. Cohérence d'ensemble (§6.bis) : l'INACTIF garde son historique
simulé/calibré mais ne projette plus rien — exactement comme ses rappels
(v1.4.0). Leçon : quand un état administratif gouverne DÉJÀ une consommation
d'une source (rappels), vérifier les AUTRES consommateurs de la même source.
Épinglé par ForecastDosesGuardTest (3 tests : inactif vide, actif inchangé,
avant/après archivage).
## 15.bis Comment régénérer l'asset mcmc_samples.json (v1.9.0) ## 15.bis Comment régénérer l'asset mcmc_samples.json (v1.9.0)
L'asset `src/main/assets/mcmc_samples.json` (posterior MCMC de L'asset `src/main/assets/mcmc_samples.json` (posterior MCMC de
@ -1838,9 +1965,9 @@ structure ci-dessus. **Toute restructuration du JSON impose de mettre à jour
```bash ```bash
cd ~/projects/HormoneTrack cd ~/projects/HormoneTrack
./gradlew assembleDebug testDebugUnitTest # build + 249 tests (219 sans données locales) ./gradlew assembleDebug testDebugUnitTest # build + 259 tests (225 sans données locales)
./gradlew assembleRelease # APK optimisé R8 (cf §16.bis ; signature cf §16.quater) ./gradlew assembleRelease # APK optimisé R8 (cf §16.bis ; signature cf §16.quater)
./gradlew connectedDebugAndroidTest # 13 tests UI Compose (émulateur requis, §8.ter) ./gradlew connectedDebugAndroidTest # 14 tests UI Compose (émulateur requis, §8.ter)
adb install -r app/build/outputs/apk/debug/app-debug.apk adb install -r app/build/outputs/apk/debug/app-debug.apk
``` ```
@ -1973,13 +2100,18 @@ de signature est requise — elle est désormais câblée :
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`, 1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y). `versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 223 au 2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 284 au
total, 193 si `local-test-data/` est absent (les 5 classes de régression total, 250 si `local-test-data/` est absent (les 6 classes de régression
réelles sont skippées via `Assume`, 6/6/6/5/7 tests) ; **lint vert réelles sont skippées via `Assume`) ; **lint vert
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet : attrapé les crashs #47/#48 dès v1.3.1).
chaque commit = tests + lint ; release = + scan confidentialité + APK au **v1.9.8+ — tests UI obligatoires** : `./gradlew connectedDebugAndroidTest`
tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix. (14 tests §8.ter — émulateur requis).
**v1.10.0+ — miroir web** : si la session touche une logique miroirée
(lab-timing, moteur…), `bash scripts/check.sh` dans `~/projects/HormoneTrack-web`
doit être vert (180 tests + E2E) AVANT le tag. Paliers de test du projet :
chaque commit = tests + lint ; release = + tests UI + scan confidentialité
+ APK au tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix.
3. **Docs** : section `## [X.Y.Z]` en tête de `docs/CHANGELOG.md` (le corps des 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 releases Gitea en sera extrait automatiquement par le script), + §14 si bug
corrigé, + §2 (historique) si notable. corrigé, + §2 (historique) si notable.
@ -1989,12 +2121,13 @@ de signature est requise — elle est désormais câblée :
headless hrt36 + seed `scripts/seed-emulator.py` avec le dernier headless hrt36 + seed `scripts/seed-emulator.py` avec le dernier
backup de `local-test-data/` + APK release installé PAR-DESSUS) sur backup de `local-test-data/` + APK release installé PAR-DESSUS) sur
**l'APK RELEASE** (JAMAIS le debug seul : **R8 ne se vérifie qu'ici** — **l'APK RELEASE** (JAMAIS le debug seul : **R8 ne se vérifie qu'ici** —
les 223 tests JVM ne le voient jamais, cf #64). Parcours minimum : les 259 tests JVM ne le voient jamais, cf #64). Parcours minimum :
l'app démarre 0 crash → écran Graphiques → activer les chips de la l'app démarre 0 crash → écran Graphiques → activer les chips de la
release (rangée modèles défilable : `adb shell input swipe 900 716 150 release (rangée modèles défilable : `adb shell input swipe 900 716 150
716 400` puis tap aux bounds du `uiautomator dump`) → vérifier dans le 716 400` puis tap aux bounds du `uiautomator dump`) → vérifier dans le
dump `text=` que la légende attendue apparaît → `adb logcat -b crash dump `text=` que la légende attendue apparaît → `adb logcat -b crash
-d | grep -c FATAL` reste à 0. Limites : le pan/pinch n'est PAS -d | grep -c FATAL` reste à 0 (les **259** tests JVM ne voient jamais
R8, cf #64). Limites : le pan/pinch n'est PAS
testable par adb (§16.ter 4.bis) — le geste reste validé par testable par adb (§16.ter 4.bis) — le geste reste validé par
l'utilisatrice sur Pixel 9. l'utilisatrice sur Pixel 9.
4. **Commit** (message descriptif par couche) + **tag annoté** : 4. **Commit** (message descriptif par couche) + **tag annoté** :
@ -2076,6 +2209,14 @@ adb shell run-as com.hormonetrack rm -f databases/hormonetrack.db-wal \
databases/hormonetrack.db-shm # WAL périmé sinon ! (checkpoint implicite fait par le seed) databases/hormonetrack.db-shm # WAL périmé sinon ! (checkpoint implicite fait par le seed)
``` ```
⚠️ **Piège WAL constaté à la v1.9.8** : après un `force-stop` (= SIGKILL, PAS de
checkpoint), le schéma entier peut vivre SEULEMENT dans le `-wal` — extraire la
seule `hormonetrack.db` donne un fichier de 4 Ko SANS tables (`no such table:
treatments` au seed). Solution : extraire `hormonetrack.db` **ET**
`hormonetrack.db-wal` CÔTE À CÔTE sous leurs noms d'origine (ex. `/tmp/hrtseed/`)
— sqlite rejoue le WAL à l'ouverture et le fusionne à la fermeture (le `-wal`
disparaît après le seed) ; on pousse alors la seule `.db` résultat.
Règles du seed (le script les impose) : colonnes = noms de propriétés Kotlin Règles du seed (le script les impose) : colonnes = noms de propriétés Kotlin
(Room n'applique pas de snake_case), enums stockés en String — on insère (Room n'applique pas de snake_case), enums stockés en String — on insère
SEULEMENT des lignes, on ne touche NI au schéma NI à `room_master_table` SEULEMENT des lignes, on ne touche NI au schéma NI à `room_master_table`
@ -2219,10 +2360,9 @@ pour ne pas les redécouvrir et re-reposer les mêmes questions :
Room par ligne. `@Insert` en liste préserverait l'ordre (IDs) et Room par ligne. `@Insert` en liste préserverait l'ordre (IDs) et
accélérerait l'import d'un gros historique. Différé : surface DAO + accélérerait l'import d'un gros historique. Différé : surface DAO +
repository à modifier, validation manuelle (aucun test Room auto). repository à modifier, validation manuelle (aucun test Room auto).
2. **`BootReceiver` : consolidation `goAsync` + une seule lecture** : 2. ~~**`BootReceiver` : consolidation `goAsync` + une seule lecture`~~**
les deux `runBlocking` séquentiels peuvent fusionner trivialement ; le **FAIT v1.10.1** (pattern `goAsync`+coroutine de ReminderReceiver,
passage complet au pattern `goAsync`+coroutine de ReminderReceiver est `runBlocking` supprimés du fichier).
équivalent mais re-testé sur émulateur (boot). Différé.
3. **Double évaluation de `generateForecastDoses`** dans ChartScreen 3. **Double évaluation de `generateForecastDoses`** dans ChartScreen
(producer `forecastDoses` vs producer `curves`) : NE PAS fusionner tel (producer `forecastDoses` vs producer `curves`) : NE PAS fusionner tel
quel — le premier utilise `System.currentTimeMillis()`, le second le quel — le premier utilise `System.currentTimeMillis()`, le second le
@ -2253,6 +2393,22 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] Redémarrer le téléphone → rappel reprogrammé (BootReceiver) - [ ] Redémarrer le téléphone → rappel reprogrammé (BootReceiver)
- [ ] Désactiver un rappel → plus de notif (cancel — cf §14.3) - [ ] Désactiver un rappel → plus de notif (cancel — cf §14.3)
- [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2) - [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2)
- [ ] **v1.10.0** : carte « Prochaine prise de sang » — la date du creux est
POSTÉRIEURE à la mention « pas stabilisée avant le … » (fix #68), et
cohérente avec le créneau affiché (~juste avant)
- [ ] **v1.10.0** : logger la dose du jour AVANT l'heure de rappel → la
notification ne sonne pas ; le rappel suivant repart au créneau suivant
- [ ] **v1.11.0** : page Traitements — les inactifs sont TOUT EN BAS sous
l'en-tête « Traitements inactifs », cartes atténuées ; éditer un
inactif fonctionne toujours
- [ ] **v1.12.0** : archiver un injectable à Posologie → plus AUCUN créneau
projeté (chip Prévision : ses injections disparaissent de la projection)
- [ ] **v1.12.0** : page Doses — chaque ligne porte l'icône de sa forme
(pilule/seringue/goutte/sparadrap) ; une dose du jour d'une prise de
sang affiche l'encart 🧪↑ et/ou 🧪↓
- [ ] **v1.13.0** : auto-calibration ON → la carte reco affiche « ≈ X pg/mL » ;
régler une Cible de creux (Paramètres) → la prédiction est colorée
dans/hors cible (jamais de notification — cf §7.11/§9.bis)
- [ ] **v1.2.x** : graphique panoramique (glisser → passé, bouton « Revenir à maintenant ») - [ ] **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** : 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 « Prévision » (configurer la Posologie d'un traitement d'abord)
@ -2426,5 +2582,5 @@ maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 c
reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint
filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51 filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51
agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité
~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres ; v1.4.3 = fix du delta 6 h (#53) + régression n°4 (plateau EEn épinglé) ; v1.4.4 = prévision réparée (captures figées #54 + extension de fenêtre) ; v1.4.5 = marqueurs de doses + minuit local + fuseau configurable (#55) + boucle de recomposition tuée (#56) ; v1.4.6 = 3ᵉ modèle PK WHSAH (fit Mona, superposable) + scroll du graphique (#57). Dette connue : fragments de ~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres ; v1.4.3 = fix du delta 6 h (#53) + régression n°4 (plateau EEn épinglé) ; v1.4.4 = prévision réparée (captures figées #54 + extension de fenêtre) ; v1.4.5 = marqueurs de doses + minuit local + fuseau configurable (#55) + boucle de recomposition tuée (#56) ; v1.4.6 = 3ᵉ modèle PK WHSAH (fit Mona, superposable) + scroll du graphique (#57) ; v1.10.0 = creux pré-injection dans la reco (#68), dialog fantôme tué (#69), rappel sauté si dose déjà loggée. Dette connue : fragments de
labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.* labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.*