v1.8.1 : fix stabilisation (changements récents vus) + suggestion sur l'Accueil

Critique v1.8.0 : « changé d'ester, de dosage ET de posologie, et l'app
me disait stabilisée depuis février » — le proxy « 1ʳᵉ dose du traitement »
était aveugle aux changements récents.

- LabTiming.regimeStartMs (nouveau, internal) : le régime courant = la
  séquence terminale de doses à (ester effectif, dose mg, écart
  inter-doses) CONSTANTS — l'écart est comparé exactement (7 j ± 1 h casse
  le régime) : tout changement récent réinitialise la stabilisation et la
  reco saute au premier creux post-stabilisation. KDoc complet (règle +
  justification conservatrice des intervalles irréguliers).
- LabRecommendation.ester = l'ESTER EFFECTIF de la dernière dose (override
  compris) — l'encart/carte racontent le bon ester.
- HomeScreen : carte compacte « Prochaine prise de sang (suggestion) »
  (creux daté + créneau associé + mention de stabilisation SEULEMENT si le
  régime n'était pas déjà stable) — même calcul que la page Analyses
  (demande v1.8.1 : suggestion aussi sur l'Accueil).
- 3 nouveaux tests (223 verts / 193 sans données locales) + lint :
  changement de dose / d'intervalle / d'ester → réinitialisation épinglée.
- Validé émulateur §16.ter (release, seed réel v1.7.0) : la carte passe de
  « stabilisé depuis le 06/02 » à « EEN pas stabilisé avant le 29/09 —
  premier creux fiable après ton dernier changement », creux 27/09 22:43,
  0 crash. versionCode 36 / versionName 1.8.1. Web sync v1.8.1.
This commit is contained in:
Siphonight 2026-09-17 19:48:20 +02:00
parent 8c19c8f295
commit 2ec9f63f93
8 changed files with 228 additions and 17 deletions

View File

@ -16,7 +16,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.8.0 — build Android ✅, **lint vert** ✅, **220 tests unitaires** ✅ (190 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ - **Statut** : v1.8.1 — build Android ✅, **lint vert** ✅, **223 tests unitaires** ✅ (193 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), 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)
@ -66,8 +66,8 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
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
- **Prochaine prise de sang suggérée (v1.8.0)** : la page Analyses recommande le - **Prochaine prise de sang suggérée (v1.8.0)** : la page Analyses recommande le
**creux estimé juste avant l'injection suivante** (moment le plus comparable), **creux estimé juste avant l'injection suivante** (moment le plus comparable),
**au premier creux où ton ester est stabilisé** (~5 demi-vies terminales après **au premier creux où ton régime est stabilisé** (~5 demi-vies après ton dernier
le dernier changement) — 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 invite à la renseigner sinon) ; injectable E2 actif à Posologie (une invite invite à la renseigner sinon) ;
estimations, jamais un avis médical estimations, jamais un avis médical
- **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),
@ -116,7 +116,7 @@ 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 # 220 tests (190 sans les données locales) ./gradlew testDebugUnitTest # 223 tests (193 sans les données locales)
./gradlew lint # lint vert obligatoire avant release ./gradlew lint # lint vert obligatoire avant release
``` ```

View File

@ -17,8 +17,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 = 35 versionCode = 36
versionName = "1.8.0" versionName = "1.8.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -130,9 +130,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 }
val regimeStartMs = myDoses.minOfOrNull { it.timestamp } ?: return null if (myDoses.isEmpty()) return null
val regimeStartMs = regimeStartMs(carrier, myDoses)
val stabilizedAtMs = regimeStartMs + val stabilizedAtMs = regimeStartMs +
(STABILIZATION_HALF_LIVES * tHalfDays * 24.0 * 3_600_000.0).toLong() (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 activeEster = PharmacokineticEngine.doseEster(carrier, lastDose)
// ── 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!!
@ -172,7 +177,7 @@ object LabTiming {
return LabRecommendation( return LabRecommendation(
troughMs = trough.timestamp, troughMs = trough.timestamp,
injectionMs = slot.timestamp, injectionMs = slot.timestamp,
ester = carrier.esterType, ester = activeEster,
terminalHalfLifeDays = tHalfDays, terminalHalfLifeDays = tHalfDays,
regimeStartMs = regimeStartMs, regimeStartMs = regimeStartMs,
stabilizedAtMs = stabilizedAtMs, stabilizedAtMs = stabilizedAtMs,
@ -184,6 +189,65 @@ object LabTiming {
return null // stabilisation hors horizon (ester ultra-long) → carte cachée return null // stabilisation hors horizon (ester ultra-long) → carte cachée
} }
/** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */
private const val DOSE_EPS = 1e-6
/**
* DÉBUT DU RÉGIME COURANT (v1.8.1 — **correction du proxy v1.8.0**).
*
* **Critique remontée** (v1.8.0) : « je suis passée sous un autre ester,
* j'ai changé de dosage ET de posologie, et l'app me disait "stabilisée
* depuis février" » — l'ancien proxy (1ʳᵉ dose du traitement) ne voyait
* PAS les changements récents de dose ni d'intervalle.
*
* **NOUVELLE RÈGLE** : le régime courant = la **séquence terminale de
* doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques
* à celles de la dose la plus récente, ET (b) **l'écart entre doses
* consécutives est constant** (= l'écart des deux doses les plus
* récentes). On remonte depuis la dose la plus récente tant que ces
* conditions tiennent ; le régime commence à la dose la plus ancienne
* de la séquence.
*
* Pourquoi chaque composante :
* - **ester effectif** (override par dose compris) : un switch EV → EEn
* réinitialise la stabilisation ;
* - **doseAmount** : 3 mg → 5 mg change le niveau d'équilibre ;
* - **écart inter-doses constant** : 7 j → 2 j → 9 j (posologie
* modifiée/irrégulière) change le creux — conservateur : une série
* d'intervalles chaotiques maintient la carte « non stabilisée », ce
* qui est pharmacocinétiquement vrai (le trough n'est comparable que
* sur un intervalle régulier). La comparaison est EXACTE (arrondi au
* ms) : un écart 7 j → 7 j ± 1 h casse le régime, ce qui reflète un
* vrai changement de créneau.
*
* @param carrier traitement porteur (injectable E2 actif à Posologie)
* @param myDoses doses du porteur (ordre indifférent)
* @return timestamp de la 1ʳᵉ dose du régime courant
*/
internal fun regimeStartMs(carrier: Treatment, myDoses: List<DoseLog>): Long {
val sorted = myDoses.sortedBy { it.timestamp }
var idx = sorted.size - 1
var regimeStart = sorted[idx].timestamp
var current = sorted[idx]
// Écart EXIGÉ pour remonter : celui des deux doses les plus récentes
// (inconnu si une seule dose — la séquence s'arrête là de toute façon)
var gapExpected = -1L
while (idx > 0) {
val prev = sorted[idx - 1]
val gap = current.timestamp - prev.timestamp
val sameMarker = PharmacokineticEngine.doseEster(carrier, current) == PharmacokineticEngine.doseEster(carrier, prev) &&
Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS
// Remonter seulement si même (ester, dose) ET même écart
val sameGap = gapExpected < 0L || gap == gapExpected
if (!sameMarker || !sameGap) break
gapExpected = gap
regimeStart = prev.timestamp
current = prev
idx--
}
return regimeStart
}
/** /**
* Invite d'AFFICHAGE (v1.8.0) : existe-t-il un traitement E2 injectable * Invite d'AFFICHAGE (v1.8.0) : existe-t-il un traitement E2 injectable
* ACTIF SANS Posologie ? Si oui, la page Analyses suggère de la * ACTIF SANS Posologie ? Si oui, la page Analyses suggère de la

View File

@ -48,6 +48,7 @@ import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.LevelPoint
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.LabTiming
import com.hormonetrack.pk.TConfig import com.hormonetrack.pk.TConfig
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.ChartOptions import com.hormonetrack.ui.components.ChartOptions
@ -222,6 +223,69 @@ fun HomeScreen(
NextDoseCard(activeTreatments, doseLogs) NextDoseCard(activeTreatments, doseLogs)
// ── Recommandation de prochaine prise de sang (v1.8.1) ───────────
// Même calcul que la page Analyses (pk/LabTiming) — carte COMPACTE
// sur l'accueil : creux daté + créneau associé + disclaimer court.
// Cachée si non calculable (pas d'injectable actif à Posologie) ;
// l'invite « renseigne une Posologie » reste sur la page Analyses
// (l'accueil ne fait pas doublon d'explication).
val labRecommendation by produceState(
initialValue = null as LabTiming.LabRecommendation?,
allTreatments, doseLogs, labResults, tick
) {
value = withContext(Dispatchers.Default) {
LabTiming.nextBloodDrawRecommendation(
allTreatments, doseLogs, labResults, System.currentTimeMillis()
)
}
}
labRecommendation?.let { rec ->
val fmtDate = remember {
DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault())
.withZone(ZoneId.systemDefault())
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(Modifier.padding(12.dp)) {
Text(
stringResource(R.string.labrec_section),
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))),
style = MaterialTheme.typography.bodyLarge
)
Text(
stringResource(
R.string.labrec_before_injection,
rec.ester,
fmtDate.format(Instant.ofEpochMilli(rec.injectionMs))
),
style = MaterialTheme.typography.bodyMedium
)
if (!rec.wasAlreadyStabilized) {
// Mention SEULEMENT si le régime n'était pas encore
// stabilisé (info utile — sinon le texte est identique
// à chaque ouverture)
Text(
stringResource(
R.string.labrec_not_stabilized,
rec.ester,
fmtDate.format(Instant.ofEpochMilli(rec.stabilizedAtMs))
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)

View File

@ -151,6 +151,53 @@ class LabTimingTest {
assertTrue(!LabTiming.shouldSuggestPosology(listOf(eenTfs()))) assertTrue(!LabTiming.shouldSuggestPosology(listOf(eenTfs())))
} }
// ── v1.8.1 : le régime courant voit les CHANGEMENTS RÉCENTS ─────────────
// Critique v1.8.0 : « j'ai changé d'ester, de dosage ET de posologie, et
// l'app me disait stabilisée depuis février » — l'ancien proxy (1ʳᵉ dose
// du traitement) ne détectait aucun de ces changements. NOUVELLE règle :
// le régime = séquence terminale à (ester, dose, écart inter-doses)
// constants ; tout changement réinitialise la stabilisation.
@Test
fun `recent dose change resets stabilization`() {
// 9 semaines à 5 mg, puis une dose de 3 mg il y a 2 j : le régime
// courant (3 mg) n'a que 2 j → PAS stabilisé → saut au creux du
// créneau ≥ stabilisation (NOW−2 j + ~22,7 j ≈ NOW+20,7 j → créneau +28 j)
val doses = weeklyDoses(9) + DoseLog(
id = 99, treatmentId = 1, timestamp = NOW - 2 * DAY_MS, doseAmount = 3.0
)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
assertEquals(NOW + 26 * DAY_MS, rec.injectionMs)
assertEquals(false, rec.wasAlreadyStabilized)
}
@Test
fun `recent interval change resets stabilization`() {
// 9 semaines à 7 j, puis une dose 2 j après la première des deux plus
// récentes : l'écart terminal (NOW−9 j → NOW−7 j = 2 j) devient
// l'écart EXIGÉ du régime → la dose à NOW−9 j fonde le nouveau régime
// (la dose NOW−14 j avait un écart de 5 j ≠ 2 j → hors régime)
val doses = weeklyDoses(9) + DoseLog(
id = 99, treatmentId = 1, timestamp = NOW - 9 * DAY_MS, doseAmount = 5.0
)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
// régimeStart = NOW−9 j → stabilisation ≈ NOW+13,7 j → 1ᵉʳ créneau : +14 j
assertEquals(NOW + 14 * DAY_MS, rec.injectionMs)
assertEquals(false, rec.wasAlreadyStabilized)
}
@Test
fun `recent ester override resets stabilization`() {
// Même dose (5 mg), même écart (7 j), mais la dernière dose est un
// override d'ESTER (EEn → EV) : switch d'ester = nouveau régime
val doses = weeklyDoses(9) + DoseLog(
id = 99, treatmentId = 1, timestamp = NOW, doseAmount = 5.0, esterType = "EV"
)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
assertEquals("EV", rec.ester)
assertEquals(false, rec.wasAlreadyStabilized)
}
@Test @Test
fun `Estrannaise carrier reads its half-life from the ODS table`() { fun `Estrannaise carrier reads its half-life from the ODS table`() {
// EV / tables ODS : t½ estimée depuis la pente terminale de la table — // EV / tables ODS : t½ estimée depuis la pente terminale de la table —

View File

@ -40,6 +40,8 @@ class RegressionUserCase5Test {
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.7.0.json") ).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.7.0.json")
} }
private val DAY_MS = 86_400_000L
private lateinit var data: BackupData private lateinit var data: BackupData
@Before @Before
@ -170,8 +172,9 @@ class RegressionUserCase5Test {
) )
assertTrue("le creux est dans le futur", rec!!.troughMs > data.exportedAt) assertTrue("le creux est dans le futur", rec!!.troughMs > data.exportedAt)
assertTrue( assertTrue(
"le créneau d'injection suit le creux de près", "le creux est DANS la fenêtre inter-créneau associée (trough=${rec.troughMs}, inj=${rec.injectionMs})",
rec.injectionMs > rec.troughMs && rec.injectionMs - rec.troughMs <= 24L * 3_600_000L rec.troughMs > rec.injectionMs - 7L * DAY_MS - 2L * 3_600_000L &&
rec.troughMs < rec.injectionMs
) )
assertTrue( assertTrue(
"le creux recommandé est postérieur à la dernière prise de sang", "le creux recommandé est postérieur à la dernière prise de sang",

View File

@ -3,6 +3,31 @@
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.8.1] — 2026-09-17 (versionCode 36)
### Corrigé — la stabilisation ignorait les changements récents (dose/ester/intervalle)
- **Bug remonté** : la recommandation disait « stabilisée depuis février »
alors que l'ester, le dosage ET la posologie venaient de changer —
l'ancien calcul partait de la **1ʳᵉ dose du traitement** (proxy aveugle
aux changements récents).
- **Fix** : le régime courant = la **séquence terminale de doses** à
(ester effectif, dose mg, **écart inter-doses**) constants — tout
changement récent réinitialise la stabilisation : la recommandation saute
au premier creux **après** la stabilisation du nouveau régime (règle des
~5 demi-vies conservée, intervalle comparé exactement). La carte porte
désormais l'**ester effectif** de la dernière dose (override compris).
- **Nouveau : suggestion aussi sur l'Accueil** (demande) — carte compacte
(creux daté + créneau associé), avec la mention de stabilisation
seulement si le régime n'était pas déjà stable.
- Validé émulateur sur l'export réel : la carte passe de « stabilisé depuis
le 06/02 » (v1.8.0) à « **EEN pas stabilisé avant le 29/09 — premier
creux fiable après ton dernier changement** », creux recommandé 27/09
22:43, 0 crash.
- 3 nouveaux tests (223 au total / 193 sans les données locales) :
changement de dose / d'intervalle / d'ester → réinitialisation épinglée.
- **Web** : même fix en miroir + encart Accueil (web v1.8.1, versions sync).
## [1.8.0] — 2026-09-17 (versionCode 35) ## [1.8.0] — 2026-09-17 (versionCode 35)
### Prochaine prise de sang recommandée (page Analyses) ### Prochaine prise de sang recommandée (page Analyses)

View File

@ -63,6 +63,7 @@ Fonctionnalités v1 :
| Date | Événement | | Date | Événement |
|---|---| |---|---|
| 17 sept. 2026 (session v1.8.1) | **Fix stabilisation + suggestion sur l'Accueil** (critique v1.8.0 : « changé d'ester, de dosage ET de posologie, et l'app me disait stabilisée depuis février ») : le proxy « 1ʳᵉ dose du traitement » était aveugle aux changements récents. NOUVELLE règle `regimeStartMs` : le régime courant = la **séquence terminale de doses** à (ester effectif, dose mg, **écart inter-doses**) constants (l'écart comparé EXACTEMENT — 7 j ± 1 h casse le régime) ; tout changement récent réinitialise la stabilisation → la reco saute au premier creux post-stabilisation ; l'ester EFFECTIF de la dernière dose (override compris) alimente la carte. **+ Suggestion aussi sur l'Accueil** (demande) : carte compacte (creux daté + créneau, mention de stabilisation seulement si le régime n'était pas déjà stable). 3 nouveaux tests (223 verts / 193 sans données locales). Validé émulateur (release, seed réel) : la carte passe de « stabilisé depuis le 06/02 » à « EEN pas stabilisé avant le 29/09 — premier creux fiable », creux 27/09 22:43, 0 crash. Web v1.8.1 sync (3 tests miroirs + encart Accueil). |
| 17 sept. 2026 (session v1.8.0) | **Recommandation de prochaine prise de sang (page Analyses, Android + web sync)** (demande + choix validés) : carte « Prochaine prise de sang (suggestion) » — creux exact calculé sur la courbe prévisionnelle **juste avant l'injection suivante** (+ mention « ou la veille du créneau »), **saut au premier creux STABILISÉ** (5 × t½ terminale — pas de prise intermédiaire trompeuse), filtres honnêtes (injectable actif + Posologie requis, creux jamais déjà mesuré, horizon borné → carte cachée), **invite « renseigne une Posologie »** quand un injectable actif en est dépourvu (demande explicite). Moteur : `pk/LabTiming.kt` PUR (miroir web `js/pk/lab-timing.js`) ; `PKProfileStore.terminalHalfLifeDays` (refactor de la pente d'extrapolation de `sample()` — le modèle Estrannaise lit sa t½ dans la table, TFS/WHSAH analytiquement). UI : calcul `produceState` + tick minute, disclaimer, mutuellement exclusive avec l'invite. 11 nouveaux tests (220 verts / 190 sans données locales) + lint + check web (152). Validé émulateur §16.ter (release, seed réel v1.7.0) : « creux 20/09 10:37, injection EEN 20/09 11:20, EEN stabilisé depuis le 06/02 », 0 crash. | | 17 sept. 2026 (session v1.8.0) | **Recommandation de prochaine prise de sang (page Analyses, Android + web sync)** (demande + choix validés) : carte « Prochaine prise de sang (suggestion) » — creux exact calculé sur la courbe prévisionnelle **juste avant l'injection suivante** (+ mention « ou la veille du créneau »), **saut au premier creux STABILISÉ** (5 × t½ terminale — pas de prise intermédiaire trompeuse), filtres honnêtes (injectable actif + Posologie requis, creux jamais déjà mesuré, horizon borné → carte cachée), **invite « renseigne une Posologie »** quand un injectable actif en est dépourvu (demande explicite). Moteur : `pk/LabTiming.kt` PUR (miroir web `js/pk/lab-timing.js`) ; `PKProfileStore.terminalHalfLifeDays` (refactor de la pente d'extrapolation de `sample()` — le modèle Estrannaise lit sa t½ dans la table, TFS/WHSAH analytiquement). UI : calcul `produceState` + tick minute, disclaimer, mutuellement exclusive avec l'invite. 11 nouveaux tests (220 verts / 190 sans données locales) + lint + check web (152). Validé émulateur §16.ter (release, seed réel v1.7.0) : « creux 20/09 10:37, injection EEN 20/09 11:20, EEN stabilisé depuis le 06/02 », 0 crash. |
| 16 sept. 2026 (session v1.7.1) | **Fix « une seule note sur deux » dans les Analyses (Android + web)** : une prise E2+T avec des notes DISTINCTES (note clinique E2 + note « DHT » sur T — cas réel) n'en affichait qu'UNE (l'autre conservée mais perdue à l'affichage). Cause : affichage « première note non vide du groupe » — hypothèse historique « toutes identiques » (dialog commun) cassée par l'édition unitaire. FIX : `labNotesForDisplay` PUR des deux côtés (notes distinctes → préfixées du marqueur ; identiques → dédupliquées ; vides ignorées) + UI branchée. + **Nouvel export réel v1.7.0** (3 traitements dont CPA oral, 64 doses, 28 labs, notes doses ET labs) → `local-test-data/backup-v1.7.0.json` (gitigné) + **régression n°5** data-driven (7 tests : parsing, double-note épingle le fix, plausibilité moteur avec CPA, prévision 7 j). 11 nouveaux tests (211 verts / 181 sans données locales) + lint + check web. Validé émulateur §16.ter : seed v1.7.0 → écran Analyses affiche « E2 : … » ET « T : … » (dump), 0 crash. Web v1.7.1 sync (+ leçon : oubli d'entrée CHANGELOG = dialog « Nouveautés » vide, attrapé par l'E2E/§7). | | 16 sept. 2026 (session v1.7.1) | **Fix « une seule note sur deux » dans les Analyses (Android + web)** : une prise E2+T avec des notes DISTINCTES (note clinique E2 + note « DHT » sur T — cas réel) n'en affichait qu'UNE (l'autre conservée mais perdue à l'affichage). Cause : affichage « première note non vide du groupe » — hypothèse historique « toutes identiques » (dialog commun) cassée par l'édition unitaire. FIX : `labNotesForDisplay` PUR des deux côtés (notes distinctes → préfixées du marqueur ; identiques → dédupliquées ; vides ignorées) + UI branchée. + **Nouvel export réel v1.7.0** (3 traitements dont CPA oral, 64 doses, 28 labs, notes doses ET labs) → `local-test-data/backup-v1.7.0.json` (gitigné) + **régression n°5** data-driven (7 tests : parsing, double-note épingle le fix, plausibilité moteur avec CPA, prévision 7 j). 11 nouveaux tests (211 verts / 181 sans données locales) + lint + check web. Validé émulateur §16.ter : seed v1.7.0 → écran Analyses affiche « E2 : … » ET « T : … » (dump), 0 crash. Web v1.7.1 sync (+ leçon : oubli d'entrée CHANGELOG = dialog « Nouveautés » vide, attrapé par l'E2E/§7). |
| 13 sept. 2026 (session v1.7.0) | **Sauvegarde automatique quotidienne (opt-in) + unités des axes** : (a) Paramètres → « Sauvegarde automatique quotidienne » — dossier choisi UNE FOIS via SAF tree (ACTION_OPEN_DOCUMENT_TREE + takePersistableUriPermission, AUCUNE permission de stockage ; typiquement un dossier Owncloud synchronisé), WorkManager périodique 24 h (KEEP au démarrage, worker no-op si désactivé — pattern AlertWorker), **run immédiat à l'activation** (feedback + validation émulateur triviale), rétention configurable 1–30 copies (défaut 7) : fichier horodaté par run (`hormonetrack-auto-YYYYMMDD-HHmm.json`, jamais d'écrasement) + purge par la rétention PURE ([AutoBackupRetention] — ne touche JAMAIS exports manuels/logs/étrangers, keep clampé ≥ 1) ; statut « dernier run » persisté + AppLog complet ; contenu = backup v2 COMPLET (importable tel quel). (b) **Unités des axes du graphique** (remontée « jamais ajoutées depuis v1.0 ») : pg/mL (E2, gauche) / ng/mL (T, droite) au sommet des colonnes de labels (padTop 12→26 dp), portées aussi côté web (versions sync). 9 nouveaux tests (200 verts / 177 sans données locales) + lint vert ; proguard : `-keep` AutoBackupWorker (réflexion WorkManager — leçon #64). Validé émulateur §16.ter APK release (dossier Download via le picker SAF piloté uiautomator, fichier écrit, rétention, unités visibles, 0 crash). | | 13 sept. 2026 (session v1.7.0) | **Sauvegarde automatique quotidienne (opt-in) + unités des axes** : (a) Paramètres → « Sauvegarde automatique quotidienne » — dossier choisi UNE FOIS via SAF tree (ACTION_OPEN_DOCUMENT_TREE + takePersistableUriPermission, AUCUNE permission de stockage ; typiquement un dossier Owncloud synchronisé), WorkManager périodique 24 h (KEEP au démarrage, worker no-op si désactivé — pattern AlertWorker), **run immédiat à l'activation** (feedback + validation émulateur triviale), rétention configurable 1–30 copies (défaut 7) : fichier horodaté par run (`hormonetrack-auto-YYYYMMDD-HHmm.json`, jamais d'écrasement) + purge par la rétention PURE ([AutoBackupRetention] — ne touche JAMAIS exports manuels/logs/étrangers, keep clampé ≥ 1) ; statut « dernier run » persisté + AppLog complet ; contenu = backup v2 COMPLET (importable tel quel). (b) **Unités des axes du graphique** (remontée « jamais ajoutées depuis v1.0 ») : pg/mL (E2, gauche) / ng/mL (T, droite) au sommet des colonnes de labels (padTop 12→26 dp), portées aussi côté web (versions sync). 9 nouveaux tests (200 verts / 177 sans données locales) + lint vert ; proguard : `-keep` AutoBackupWorker (réflexion WorkManager — leçon #64). Validé émulateur §16.ter APK release (dossier Download via le picker SAF piloté uiautomator, fichier écrit, rétention, unités visibles, 0 crash). |
@ -617,9 +618,16 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques :
[PKProfileStore.terminalHalfLifeDays] (refactor v1.8.0 : la pente [PKProfileStore.terminalHalfLifeDays] (refactor v1.8.0 : la pente
log-linéaire utilisée par l'extrapolation de `sample()` — dernier point log-linéaire utilisée par l'extrapolation de `sample()` — dernier point
≥ 1 % du pic, 48 h précédentes — est partagée). ≥ 1 % du pic, 48 h précédentes — est partagée).
- **Début du régime courant** : 1ʳᵉ dose du traitement actif — PROXY assumé - **Début du régime courant** (v1.8.1, **correction du proxy v1.8.0** —
(pas d'historique des éditions de Posologie/dose). Stabilisation = critique : « changé d'ester, de dosage ET de posologie, et l'app disait
régimeStart + 5 × t½. stabilisée depuis février ») : le régime courant = la **séquence terminale
de doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques
à la dose la plus récente, ET (b) **l'écart entre doses consécutives est
constant** (= l'écart des deux doses les plus récentes, comparé
exactement — 7 j ± 1 h casse le régime). Stabilisation = régimeStart +
5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la
carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est
comparable que sur un intervalle régulier).
- **Creux** : minimum local de la courbe E2 prévisionnelle (doses réelles + - **Creux** : minimum local de la courbe E2 prévisionnelle (doses réelles +
[PharmacokineticEngine.generateForecastDoses], pas 1 h) entre deux [PharmacokineticEngine.generateForecastDoses], pas 1 h) entre deux
injections ; FORME brute (sans calibration — le facteur ne déplace pas le injections ; FORME brute (sans calibration — le facteur ne déplace pas le
@ -632,11 +640,11 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques :
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.
- 8 tests `LabTimingTest` + 1 test régression n°5. - 11 tests `LabTimingTest` (8 + 3 v1.8.1 régime récent) + 1 test régression n°5.
## 8. Tests unitaires ## 8. Tests unitaires
**220 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **190 sans **223 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **193 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 5 classes de régression,
6/6/6/5/7 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2. 6/6/6/5/7 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
@ -714,9 +722,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 190 tests au lieu de 220 (v1.8.0 ; chiffres historiques : 44/87 à v1.3.x, exécute 193 tests au lieu de 223 (v1.8.1 ; 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) ; 181/211 à v1.7.1, 190/220 à v1.8.0) ;
- 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