v1.2.7 : prévision robuste aux oublis/retards + rattrapage complet de la doc de dev

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

View File

@ -12,8 +12,8 @@ android {
applicationId = "com.hormonetrack" applicationId = "com.hormonetrack"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 9 versionCode = 10
versionName = "1.2.6" versionName = "1.2.7"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -319,6 +319,15 @@ object PharmacokineticEngine {
val forecast = mutableListOf<DoseLog>() val forecast = mutableListOf<DoseLog>()
var t = last.timestamp + intervalMs var t = last.timestamp + intervalMs
// ⚠️ Oubli d'une injection : le premier créneau théorique tombe dans le
// PASSÉ (dernière prise + intervalle = jour de l'injection manquée) —
// on ne simule JAMAIS un créneau passé (faux pic dans l'historique) :
// on avance au premier créneau STRICTEMENT FUTUR, au rythme configuré.
// Un retard, lui, décale naturellement toute la prévision (elle part de
// la dernière prise réelle).
while (t <= nowMs) {
t += intervalMs
}
while (t <= toMs) { while (t <= toMs) {
forecast.add( forecast.add(
DoseLog( DoseLog(

View File

@ -50,6 +50,46 @@ class V120FeaturesTest {
forecastIntervalDays = intervalDays forecastIntervalDays = intervalDays
) )
@Test
fun `forecast skips past slots after a missed dose (oubli)`() {
// Rythme 6 j, dernier rappel... dernière PRISE il y a 12 j (une injection
// a été manquée il y a 6 j) : le créneau théorique BASE-6j est passé.
val tr = eenTreatment(intervalDays = 6.0)
val doses = listOf(
DoseLog(treatmentId = 1, timestamp = BASE - 18 * DAY, doseAmount = 5.0),
DoseLog(treatmentId = 1, timestamp = BASE - 12 * DAY, doseAmount = 5.0)
)
val forecast = PharmacokineticEngine.generateForecastDoses(
tr, doses, toMs = BASE + 30 * DAY, nowMs = BASE
)
// Aucun créneau simulé dans le passé : premier = BASE+6 j (dernière+12 j
// est le premier créneau strictement futur au rythme de 6 j)
assertTrue(forecast.isNotEmpty())
forecast.forEach { f ->
assertTrue("créneau passé simulé : ${f.timestamp}", f.timestamp > BASE)
}
assertEquals(BASE + 6 * DAY, forecast.first().timestamp)
forecast.zipWithNext { prev, cur ->
assertEquals(6 * DAY, cur.timestamp - prev.timestamp)
}
}
@Test
fun `forecast follows a delayed last dose (retard d un jour)`() {
// Rythme 6 j, dernière prise faite avec 1 jour de retard (à J-5 au lieu
// de J-6) : TOUTE la prévision se décale d'un jour, en partant de la
// dernière prise réelle.
val tr = eenTreatment(intervalDays = 6.0)
val doses = listOf(
DoseLog(treatmentId = 1, timestamp = BASE - 5 * DAY, doseAmount = 5.0)
)
val forecast = PharmacokineticEngine.generateForecastDoses(
tr, doses, toMs = BASE + 30 * DAY, nowMs = BASE
)
assertEquals(BASE + 1 * DAY, forecast.first().timestamp)
assertTrue(forecast.all { it.timestamp > BASE })
}
@Test @Test
fun `forecast doses follow the configured interval from the last real dose`() { fun `forecast doses follow the configured interval from the last real dose`() {
val tr = eenTreatment(intervalDays = 7.0) val tr = eenTreatment(intervalDays = 7.0)

View File

@ -1,7 +1,21 @@
# Changelog — HormoneTrack # Changelog — HormoneTrack
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.2.6`, …). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.2.7`, …).
## [1.2.7] — 2026-09-05 (versionCode 10)
### Corrigé
- **Prévision cassée après un oubli d'injection** : le premier créneau projeté
tombait dans le PASSÉ (dernière prise + intervalle = jour de l'injection
manquée) → la courbe peignait un **faux pic dans l'historique** et tout le
rythme projeté était décalé. Désormais les créneaux déjà passés ne sont
jamais simulés : la prévision avance au premier créneau **strictement
futur** au rythme configuré. Un simple **retard**, lui, décale naturellement
toute la prévision (elle part de la dernière prise réelle — comportement
voulu, désormais testé).
- 2 nouveaux tests (64 au total) : oubli (créneaux passés sautés, espacement
conservé) et retard (décalage complet de la prévision).
## [1.2.6] — 2026-09-05 (versionCode 9) ## [1.2.6] — 2026-09-05 (versionCode 9)

View File

@ -69,6 +69,9 @@ Fonctionnalités v1 :
| 5 sept. 2026 (session v1.2.4) | Bug `isActive` (simulation effacée) → sémantique « drapeau administratif » (§6.bis) ; régression n°3 sur le 3ᵉ export réel (transition EV inactif → EEn actif, 38 doses, 22 labs) ; **62 tests verts**, APK v1.2.4. | | 5 sept. 2026 (session v1.2.4) | Bug `isActive` (simulation effacée) → sémantique « drapeau administratif » (§6.bis) ; régression n°3 sur le 3ᵉ export réel (transition EV inactif → EEn actif, 38 doses, 22 labs) ; **62 tests verts**, APK v1.2.4. |
| 5 sept. 2026 (session push Gitea + v1.2.3) | Push initial vers **gitea.cloudyfy.fr/Siphonight/HormoneTrack** (privé) après **anonymisation de l'historique** (filter-branch : les premiers commits embarquaient les valeurs réelles des tests) ; **données de test réelles déplacées hors dépôt** (`local-test-data/` gitignoré, tests `Assume`-skippés) ; **releases avec APK en pièce jointe** ; pics/creux + calibration T par période d'ester ; **56 tests verts**. | | 5 sept. 2026 (session push Gitea + v1.2.3) | Push initial vers **gitea.cloudyfy.fr/Siphonight/HormoneTrack** (privé) après **anonymisation de l'historique** (filter-branch : les premiers commits embarquaient les valeurs réelles des tests) ; **données de test réelles déplacées hors dépôt** (`local-test-data/` gitignoré, tests `Assume`-skippés) ; **releases avec APK en pièce jointe** ; pics/creux + calibration T par période d'ester ; **56 tests verts**. |
| 5 sept. 2026 (session v1.2.1) | Remontées UX/utilisateur : calibration **par période d'ester** (labs EV → doses EV, labs EEn → doses EEn — corrige les courbes gonflées), lab T "pg/mL" neutralisé, **édition des labs**, tap accueil → Graphiques + mini-légende, auto-cal fusionnée (E2+T, un seul switch) ; 2ᵉ régression épinglée sur le nouvel export ; **44 tests verts**, APK v1.2.1. | | 5 sept. 2026 (session v1.2.1) | Remontées UX/utilisateur : calibration **par période d'ester** (labs EV → doses EV, labs EEn → doses EEn — corrige les courbes gonflées), lab T "pg/mL" neutralisé, **édition des labs**, tap accueil → Graphiques + mini-légende, auto-cal fusionnée (E2+T, un seul switch) ; 2ᵉ régression épinglée sur le nouvel export ; **44 tests verts**, APK v1.2.1. |
| 5 sept. 2026 (session v1.2.2) | Toggle T masque aussi les labs T ; **prise de sang E2 + T en une entrée** (chacune optionnelle) affichée **côte à côte** ; sélecteur d'édition par marqueur ; suppression par prise ; 4 tests de regroupement ; **48 tests verts**, APK v1.2.2. |
| 5 sept. 2026 (session v1.2.6) | Valeurs estimées affichées sur les pics/creux (drawExtremum) ; **import JSON en mode écrasement** (bug : conflit d'IDs si données existantes) avec restauration du tConfig et reprogrammation des rappels ; docs ; 62 tests verts, APK v1.2.6 + releases. |
| 5 sept. 2026 (session v1.2.7) | Prévision : les créneaux passés ne sont plus simulés (bug oubli → faux pic historique) ; retard = décalage voulu ; mismatch doc-code §7.3b corrigé ; 2 tests ; **64 tests verts**, APK v1.2.7 + releases. |
Leçon importante de la session build : **les erreurs de compilation et les bugs sémantiques Leçon importante de la session build : **les erreurs de compilation et les bugs sémantiques
(bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant (bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant
@ -256,14 +259,17 @@ Coupure par dose : `cutoffHours` = longueur de table (8001 h) pour les profils,
dessine les deux modèles côte à côte depuis le même traitement (Bateman non concerné : dessine les deux modèles côte à côte depuis le même traitement (Bateman non concerné :
les deux séries y sont identiques). les deux séries y sont identiques).
- **`generateForecastDoses(treatment, doseLogs, toMs, nowMs)`** : projette les doses à - **`generateForecastDoses(treatment, doseLogs, toMs, nowMs)`** : projette les doses à
venir = dernière dose réelle + k × `forecastIntervalDays` jusqu'à `toMs`, strictement venir = dernière dose réelle + k × `forecastIntervalDays`, **strictement après
après `nowMs` ; dose = standard du traitement, ester = override de la dernière `nowMs`** (v1.2.7 : les créneaux déjà passés — cas d'un OUBLI — sont sautés au
injection. Jamais persistées : uniquement passées à `computeCurve` par le ChartScreen rythme configuré, sinon faux pic dans l'historique ; un RETARD décale toute la
quand le chip « Prévision » est actif. prévision) ; dose = standard du traitement, ester = override de la dernière
injection. Jamais persistées : uniquement passées à `computeCurve` par le
ChartScreen quand le chip « Prévision » est actif.
- **`autoCalibrated(treatments, doseLogs, labs, tConfig)`** : option « Calibration - **`autoCalibrated(treatments, doseLogs, labs, tConfig)`** : option « Calibration
automatique » — renvoie des **copies** de traitements avec les scale factors automatique » (v1.2.1+) — renvoie `esterScales` (facteur **par ester**, cf §7.6)
recalculés (médiane lab ÷ prédiction) + TConfig recalibré. Les valeurs stockées ne + `tKPerEster` (k T **par ester**, cf §7.5) recalculés depuis les labs ;
bougent jamais ; HomeScreen et ChartScreen branchent dessus quand l'option est active. `treatments` et `tConfig` **inchangés** (fallback par ester absent = valeurs
stockées). HomeScreen et ChartScreen branchent dessus quand l'option est active.
### 7.4 Bateman (gel/patch/oral) ### 7.4 Bateman (gel/patch/oral)
@ -535,8 +541,15 @@ Pièges :
- `BackupData{version=1, exportedAt, treatments[], doseLogs[], labResults[], tConfig}` → Gson - `BackupData{version=1, exportedAt, treatments[], doseLogs[], labResults[], tConfig}` → Gson
- **Les IDs Room sont conservés** dans l'export et réinsérés tels quels → les FK - **Les IDs Room sont conservés** dans l'export et réinsérés tels quels → les FK
dose→traitement restent valides dose→traitement restent valides
- Import = **ajout** (traitements → doses → labs) ; ré-import du même fichier → conflit - Import : **mode ÉCRASEMENT (v1.2.6)** — les données actuelles sont effacées
d'ID unique → exception catchée → `import_fail` (voulu ; un mode « replace » est en §20) AVANT l'insertion (`deleteAllDoseLogs` → `deleteAllLabResults` →
`deleteAllTreatments`, ordre enfants → parents pour la FK CASCADE), puis
restauration avec les IDs du backup conservés (FK valides). Avant (v1.2.5) :
insertion en « ajout » → conflit d'ID dès que l'app contenait des données →
l'import échouait (bug remonté). Après un import : le `tConfig` du backup est
restauré dans DataStore et les rappels sont reprogrammés
(`rescheduleAll(allTreatmentsOnce())`). Le dialog prévient que TOUT sera
remplacé (bouton « Effacer & restaurer »)
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture - 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{}`
@ -627,6 +640,19 @@ Historique complet — **à ne pas réintroduire** (utile pour diff/revert) :
titre `[1.2.3]` : l'extraction cherchait `[vX.Y.Z]` → fallback silencieux. titre `[1.2.3]` : l'extraction cherchait `[vX.Y.Z]` → fallback silencieux.
→ `scripts/gitea-release.py` (corps = section CHANGELOG, APK attaché). → `scripts/gitea-release.py` (corps = section CHANGELOG, APK attaché).
34. **Import JSON impossible si l'app contient déjà des données** (v1.2.6) :
l'insertion en « ajout » avec les IDs du backup entrait en conflit
d'unicité dès la moindre donnée existante → import échouait. → mode
ÉCRASEMENT (effacement ordonné enfants→parents puis restauration) +
restauration du tConfig + reprogrammation des rappels.
35. **Prévision cassée après un oubli d'injection** (v1.2.7) : le premier
créneau projeté tombait dans le passé (dernière + intervalle = jour
manqué) → faux pic dans l'historique + rythme décalé. → les créneaux
passés sont sautés (`while (t <= nowMs) t += intervalMs`), la prévision
démarre au premier créneau futur ; un retard décale toute la prévision
(comportement voulu, testé). NB : la doc §7.3b affirmait « strictement
après nowMs » alors que le code incluait les créneaux passés —
mismatch doc-code détecté par l'utilisatrice.
33. **Double insets edge-to-edge** (v1.2.5) : espace vide en haut de l'écran — 33. **Double insets edge-to-edge** (v1.2.5) : espace vide en haut de l'écran —
la fenêtre AppCompat poussait le contenu sous la barre de statut ET les la fenêtre AppCompat poussait le contenu sous la barre de statut ET les
TopAppBar M3 rajoutaient leur padding interne. → `enableEdgeToEdge()` + TopAppBar M3 rajoutaient leur padding interne. → `enableEdgeToEdge()` +
@ -826,7 +852,7 @@ manquantes** (licences signées) — c'est comme ça que android-37 est arrivé.
Volontaires (v1) : Volontaires (v1) :
- Pas de ViewModel/DI (couplage UI↔repo via CompositionLocal) - Pas de ViewModel/DI (couplage UI↔repo via CompositionLocal)
- Modèle T empirique (non publié) — étiqueté estimation partout - Modèle T empirique (non publié) — étiqueté estimation partout
- Import JSON = ajout seulement (pas de mode replace/dédup) - Import JSON = **écrasement** depuis v1.2.6 (mode fusion non implémenté)
- `fallbackToDestructiveMigration()` — à retirer à la migration v2 du schéma - `fallbackToDestructiveMigration()` — à retirer à la migration v2 du schéma
- WorkManager déclaré non utilisé - WorkManager déclaré non utilisé
- Profils par **tables** (pas par formule) : les D/k1–k3 de l'ODS ne sont pas consommés — - Profils par **tables** (pas par formule) : les D/k1–k3 de l'ODS ne sont pas consommés —
@ -845,7 +871,7 @@ Volontaires (v1) :
1. **Robolectric + tests Compose** (VM Android en JVM — pas besoin d'appareil) 1. **Robolectric + tests Compose** (VM Android en JVM — pas besoin d'appareil)
2. **Émulateur local** pour smoke-tests UI (sur demande, ~2–3 Go) 2. **Émulateur local** pour smoke-tests UI (sur demande, ~2–3 Go)
3. Mode « planifier les injections » (schedule récurrent → pré-remplir le log) 3. Mode « planifier les injections » (schedule récurrent → pré-remplir le log)
4. Import JSON : mode **replace** (wipe + insert) + détection de doublons 4. Import JSON : détection de doublons / mode fusion optionnel (l'écrasement est fait, v1.2.6)
5. Verrou biométrique (BiometricPrompt), widget, export CSV 5. Verrou biométrique (BiometricPrompt), widget, export CSV
6. Charts : **zoom** + tooltip au toucher (le pan est fait, v1.2.0) ; 6. Charts : **zoom** + tooltip au toucher (le pan est fait, v1.2.0) ;
MaterialExpressiveTheme quand l'API passera publique (cf §3) MaterialExpressiveTheme quand l'API passera publique (cf §3)
@ -888,9 +914,13 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] **v1.2.x** : édition d'une dose (tap ligne Doses) et d'un lab (tap ligne - [ ] **v1.2.x** : édition d'une dose (tap ligne Doses) et d'un lab (tap ligne
Analyses → sélecteur E2/T si paire) ; suppression par prise entière Analyses → sélecteur E2/T si paire) ; suppression par prise entière
- [ ] **v1.2.x** : prise de sang E2 + T en une entrée (champs optionnels) - [ ] **v1.2.x** : prise de sang E2 + T en une entrée (champs optionnels)
- [ ] **v1.2.7** : prévision après un OUBLI (pas de faux pic dans le passé)
et après un RETARD (prévision décalée, suivant la dernière prise)
- [ ] **v1.2.6** : import d'un backup avec l'app déjà remplie → ÉCRASER &
restaurer (message clair), tConfig restauré, rappels reprogrammés
- [ ] **v1.2.4** : passer un traitement à inactif → retiré de « Log rapide » et - [ ] **v1.2.4** : passer un traitement à inactif → retiré de « Log rapide » et
du dropdown des nouvelles doses, rappel annulé, **mais sa simulation reste du dropdown des nouvelles doses, rappel annulé, **mais sa simulation reste
sur le graphique** et la calibration couvre toujours ses périodes sur le graphique** et la calibration couvre toujours ses périodes
--- ---
*Doc mise à jour le 5 sept. 2026 (v1.2.6) — build OK, 62/62 tests verts (44 sans les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée de santé dans le dépôt ni l'historique.* *Doc mise à jour le 5 sept. 2026 (v1.2.7) — build OK, 62/62 tests verts (44 sans les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée de santé dans le dépôt ni l'historique.*