v1.8.0 : recommandation de prochaine prise de sang (page Analyses)

Moteur pk/LabTiming.kt (PUR, miroir web js/pk/lab-timing.js) :
- traitement porteur = E2 injectable ACTIF à Posologie (sinon null) ;
- stabilisation = 1ʳᵉ dose du régime + 5 × t½ terminale (TFS/WHS
  analytiques ; Estrannaise : NOUVEAU PKProfileStore.terminalHalfLifeDays,
  refactor de la pente d'extrapolation de sample() — zéro duplication) ;
- creux = minimum de la courbe E2 prévisionnelle entre 2 injections
  (forme brute — sans calibration) ; ON SAUTE au premier creux dont le
  créneau est stabilisé (décision v1.8.0) ; filtres : ≥ now+6 h, jamais
  un creux déjà mesuré, horizon borné (ester ultra-long → null).

UI LabsScreen : carte « Prochaine prise de sang (suggestion) » (creux
exact + créneau associé + mention « ou la veille » + statut de
stabilisation + disclaimer) OU invite « renseigne une Posologie » quand
un injectable actif en est dépourvu (demande v1.8.0) — mutuellement
exclusives ; calcul produceState + tick minute.

Tests : LabTimingTest (8) + régression n°5 étendue (1) → 220 verts
(190 sans données locales) + lint. 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. versionCode 35 / versionName 1.8.0.
Web sync v1.8.0 (publié séparément).
This commit is contained in:
Siphonight 2026-09-17 18:47:38 +02:00
parent 23c62e85f2
commit 8c19c8f295
12 changed files with 686 additions and 24 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.7.1 — build Android ✅, **lint vert** ✅, **211 tests unitaires** ✅ (181 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.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** ✅
- **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)
@ -64,6 +64,12 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
- **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
- **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),
**au premier creux où ton ester est stabilisé** (~5 demi-vies terminales après
le dernier changement) — déduit des courbes et de la Posologie ; requiert un
injectable E2 actif à Posologie (une invite invite à la renseigner sinon) ;
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),
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
@ -110,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 # 211 tests (181 sans les données locales) ./gradlew testDebugUnitTest # 220 tests (190 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 = 34 versionCode = 35
versionName = "1.7.1" versionName = "1.8.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -0,0 +1,203 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import kotlin.math.ceil
/**
* RECOMMANDATION DE PROCHAINE PRISE DE SANG (v1.8.0) — déduite des courbes,
* affichée dans la page Analyses (« Prochaine analyse recommandée »).
*
* PRINCIPE PHARMACOCINÉTIQUE (choix validé avec l'utilisatrice) :
* 1. **Le creux (trough) juste avant l'injection suivante** est le moment le
* plus informatif et reproductible pour un ester injectable : le pic
* varie énormément d'un cycle à l'autre, le creux reflète le niveau de
* fond réel et l'accumulation ;
* 2. un creux n'est interprétable que si le régime est **stabilisé** — règle
* classique des **5 demi-vies terminales** (≈ 97 % de l'équilibre). Après
* un changement d'ester/dose/rythme, les prises intermédiaires reflètent
* une transition, pas le régime ;
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0) : le
* premier créneau d'injection ≥ date de stabilisation du régime courant.
*
* CALCUL (tout est déduit de ce que l'app connaît déjà) :
* - **traitement porteur** : ESTRADIOL + injectable + ACTIF + Posologie
* renseignée (`forecastIntervalDays > 0`) — sans Posologie, pas de creux
* prévisible → `null` (la page Analyses affiche alors une invite à la
* renseigner) ;
* - **t½ terminale** de l'ester : analytique pour TFS/WHSAH
* (`terminalHalfLifeDays`), lue dans la TABLE pour Estrannaise
* ([PKProfileStore.terminalHalfLifeDays] — la pente que l'extrapolation
* de `sample()` utilise déjà) ;
* - **début du régime courant** = 1ʳᵉ dose du traitement actif — PROXY
* assumé et documenté : l'app ne trace pas l'historique des éditions de
* 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 ») ;
* - **creux** = minimum local de la courbe E2 PRÉVISIONNELLE (doses réelles
* + créneaux [PharmacokineticEngine.generateForecastDoses]) entre deux
* injections. 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
* recommandation quasi-immédiate), creux > dernière prise de sang
* existante (ne jamais recommander un creux déjà mesuré), créneau ≥
* stabilisation.
*
* LIMITES ASSUMÉES (documentées, cf doc §7.11) :
* - le creux est une ESTIMATION du modèle (pas une physiologie mesurée) —
* la carte porte un disclaimer ;
* - pas de Posologie, pas de recommandation (l'invite invite à la
* renseigner) ;
* - horizon borné à [MAX_FORECAST_INTERVALS] créneaux : si la stabilisation
* dépasse (ester ultra-long, ex. EU t½ 31 j → ~155 j), `null` (carte
* cachée plutôt qu'une recommandation douteuse).
*
* PUR (aucune dépendance Android) → testable en JVM (LabTimingTest) ;
* portage web : `js/pk/lab-timing.js` (miroir fidèle).
*/
object LabTiming {
/** Règle de stabilisation : 5 demi-vies terminales (≈ 97 % de l'équilibre). */
const val STABILIZATION_HALF_LIVES = 5
/**
* Horizon de créneaux prévisionnels explorés : assez pour couvrir la
* stabilisation d'un ester raisonnable (ex. EEn TFS : 5 × 4,5 j ≈ 23 j →
* 4 intervalles de 7 j), borné — au-delà (ester ultra-long), `null`.
*/
const val MAX_FORECAST_INTERVALS = 12
/** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */
const val MIN_HORIZON_HOURS = 6L
/** Résultat : tout ce que l'UI affiche sur la carte de recommandation. */
data class LabRecommendation(
/** Date/heure du CREUX exact (minimum prévisionnel entre 2 injections). */
val troughMs: Long,
/** Créneau d'INJECTION associé (juste après le creux). */
val injectionMs: Long,
/** Ester du traitement porteur (ex. "EEN") — pour le texte. */
val ester: String,
/** t½ terminale utilisée (jours) — pour le texte « ~5 demi-vies ». */
val terminalHalfLifeDays: Double,
/** Début du régime courant (1ʳᵉ dose du traitement actif). */
val regimeStartMs: Long,
/** Date de stabilisation = régimeStart + 5 × t½. */
val stabilizedAtMs: Long,
/**
* true = le régime était DÉJÀ stabilisé à l'instant du calcul
* (texte « stabilisé depuis le … ») ; false = la stabilisation
* arrive entre maintenant et le creux recommandé (texte « c'est le
* premier creux fiable »).
*/
val wasAlreadyStabilized: Boolean
)
/**
* Calcule la prochaine prise de sang recommandée, ou `null` si rien n'est
* calculable honnêtement (pas de traitement E2 injectable actif à
* Posologie, pas de doses pour cadrer la grille, stabilisation hors
* horizon, pas de creux exploitable). L'appelant CACHE la carte sur null
* (et peut afficher l'invite « renseigne une Posologie » quand un
* injectable actif en est dépourvu).
*/
fun nextBloodDrawRecommendation(
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
labs: List<LabResult>,
nowMs: Long = System.currentTimeMillis()
): LabRecommendation? {
// ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ─────
val carrier = treatments.firstOrNull {
it.type == TreatmentType.ESTRADIOL &&
it.isActive &&
it.usesProfileModel &&
(it.forecastIntervalDays ?: 0.0) > 0.0
} ?: return null
// ── 2) t½ terminale de l'ester (analytique ou lue dans la table) ────
val tHalfDays = when (carrier.pkModel) {
PKModels.TRANSFEM_SCIENCE ->
com.hormonetrack.pk.TransfemScienceModels.model(carrier.esterType)?.terminalHalfLifeDays
PKModels.WHSAH ->
com.hormonetrack.pk.WhsahModels.model(carrier.esterType)?.terminalHalfLifeDays
PKModels.ESTRANNAISE ->
PKProfileStore.terminalHalfLifeDays(carrier.esterType, carrier.pkModel)
else -> null
} ?: return null
// ── 3) Début du régime courant + date de stabilisation ──────────────
val myDoses = doseLogs.filter { it.treatmentId == carrier.id }
val regimeStartMs = myDoses.minOfOrNull { it.timestamp } ?: return null
val stabilizedAtMs = regimeStartMs +
(STABILIZATION_HALF_LIVES * tHalfDays * 24.0 * 3_600_000.0).toLong()
// ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ──
val intervalDays = carrier.forecastIntervalDays!!
val intervalsNeeded = maxOf(
3L,
ceil(STABILIZATION_HALF_LIVES * tHalfDays / intervalDays).toLong() + 1L
).coerceAtMost(MAX_FORECAST_INTERVALS.toLong())
val horizonMs = nowMs + (intervalsNeeded * intervalDays * 24.0 * 3_600_000.0).toLong()
val slots = PharmacokineticEngine.generateForecastDoses(
carrier, doseLogs, horizonMs, nowMs
)
if (slots.isEmpty()) return null
// ── 5) Courbe E2 fine (FORME brute — sans calibration, le facteur
// multiplicatif ne déplace pas le minimum) ─────────────────────
val curve = PharmacokineticEngine.computeCurve(
treatments, doseLogs + slots, nowMs, slots.last().timestamp,
stepMs = 3_600_000L, tConfig = TConfig()
)
// ── 6) Premier creux STABILISÉ jamais mesuré ─────────────────────────
val lastLabMs = labs.maxOfOrNull { it.timestamp } ?: 0L
val minTroughMs = nowMs + MIN_HORIZON_HOURS * 3_600_000L
var windowStart = nowMs
for (slot in slots) {
// Le creux du créneau = minimum de la courbe entre l'injection
// précédente (ou maintenant) et ce créneau
val window = curve.filter { it.timestamp > windowStart && it.timestamp < slot.timestamp }
val trough = window.minByOrNull { it.e2 }
if (trough != null &&
trough.timestamp >= minTroughMs &&
trough.timestamp > lastLabMs &&
// 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
slot.timestamp >= stabilizedAtMs
) {
return LabRecommendation(
troughMs = trough.timestamp,
injectionMs = slot.timestamp,
ester = carrier.esterType,
terminalHalfLifeDays = tHalfDays,
regimeStartMs = regimeStartMs,
stabilizedAtMs = stabilizedAtMs,
wasAlreadyStabilized = stabilizedAtMs <= nowMs
)
}
windowStart = slot.timestamp
}
return null // stabilisation hors horizon (ester ultra-long) → carte cachée
}
/**
* 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
* renseigner pour recevoir des recommandations (demande v1.8.0 : « il
* faut préciser quelque part sur la page des analyses qu'en l'absence
* de posologie, en ajouter une permet de recevoir des recommandations »).
* La suggestion ne s'affiche QUE si aucune recommandation n'est calculable
* (elle ne remplace pas une vraie reco).
*/
fun shouldSuggestPosology(treatments: List<Treatment>): Boolean =
treatments.any {
it.type == TreatmentType.ESTRADIOL &&
it.isActive &&
it.usesProfileModel &&
(it.forecastIntervalDays ?: 0.0) <= 0.0
}
}

View File

@ -61,6 +61,62 @@ object PKProfileStore {
* linearly interpolated between hourly points; beyond the table, extrapolated * linearly interpolated between hourly points; beyond the table, extrapolated
* with the terminal exponential slope. * with the terminal exponential slope.
*/ */
/**
* Demi-vie TERMINALE (JOURS) estimée depuis la table horaire (v1.8.0) :
* même logique que l'extrapolation de [sample] (dernier point ≥ 1 % du
* pic, pente = décroissance moyenne sur les 48 h précédentes, jamais
* avant le pic) — t½ = ln2 ÷ |pente|.
*
* Pourquoi ce helper : la recommandation de prochaine prise de sang
* ([LabTiming]) a besoin des 5 × t½ terminales de l'ester pour juger de
* la STABILISATION du régime — et le modèle Estrannaise (tables ODS)
* n'a pas de forme close : sa t½ se LIT dans la table. Les modèles TFS/
* WHSAH, eux, exposent leur t½ analytiquement (`terminalHalfLifeDays`).
*
* @return t½ en jours (≥ 0), ou `null` si la table ne permet pas
* d'estimer une décroissance terminale exploitable (ester inconnu,
* pic nul, pas de queue décroissante).
*/
fun terminalHalfLifeDays(ester: String, model: String): Double? {
if (!initialized) return null
val suffix = when (model) {
com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE -> "tfs"
com.hormonetrack.data.model.PKModels.ESTRANNAISE -> "ese"
else -> return 0.0
}
val arr = lookup("${ester}_$suffix") ?: return null
val (j, rate) = terminalDecayParameters(arr) ?: return null
if (rate >= 0.0) return null
return kotlin.math.ln(2.0) / -rate / 24.0 // pente /h → t½ en jours
}
/**
* Paramètres de DÉCROISSANCE TERMINALE d'une table (partagés par
* [sample] — extrapolation au-delà de la fin — et [terminalHalfLifeDays])
* : `(index du dernier point ≥ 1 % du pic, pente log-linéaire /h)` sur
* les 48 h précédentes, jamais avant le pic. `null` si la table n'a ni
* pic ni queue exploitable.
*/
private fun terminalDecayParameters(arr: FloatArray): Pair<Int, Double>? {
var peakIdx = 0
var peakV = 0f
for (idx in arr.indices) {
if (arr[idx] > peakV) {
peakV = arr[idx]
peakIdx = idx
}
}
if (peakV <= 0f) return null
var j = arr.size - 1
while (j > 0 && arr[j] < peakV * 0.01f) j--
if (j <= 0) return null
val window = 48
val j0 = maxOf(peakIdx, j - window)
if (j <= j0) return null
val rate = kotlin.math.ln(arr[j].toDouble() / arr[j0].toDouble().coerceAtLeast(1e-12)) / (j - j0)
return j to rate
}
fun sample(ester: String, model: String, dtHours: Double): Double { fun sample(ester: String, model: String, dtHours: Double): Double {
if (!initialized || dtHours <= 0.0) return 0.0 if (!initialized || dtHours <= 0.0) return 0.0
val suffix = when (model) { val suffix = when (model) {
@ -77,23 +133,7 @@ object PKProfileStore {
// display floor long before the true value vanishes. Extrapolate from the // display floor long before the true value vanishes. Extrapolate from the
// last point still >= 1% of the peak, using the average hourly decay of // last point still >= 1% of the peak, using the average hourly decay of
// the previous 48 h (never sampling before the peak). // the previous 48 h (never sampling before the peak).
var peakIdx = 0 val (j, rate) = terminalDecayParameters(arr) ?: return 0.0
var peakV = 0f
for (idx in arr.indices) {
if (arr[idx] > peakV) {
peakV = arr[idx]
peakIdx = idx
}
}
if (peakV <= 0f) return 0.0
var j = lastIdx
while (j > 0 && arr[j] < peakV * 0.01f) j--
if (j <= 0) return 0.0
val window = 48
val j0 = maxOf(peakIdx, j - window)
val rate = if (j > j0) {
kotlin.math.ln(arr[j].toDouble() / arr[j0].toDouble().coerceAtLeast(1e-12)) / (j - j0)
} else 0.0
return arr[j].toDouble() * kotlin.math.exp(rate * (dtHours - j)) return arr[j].toDouble() * kotlin.math.exp(rate * (dtHours - j))
} }

View File

@ -6,13 +6,16 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Spacer
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.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -24,9 +27,12 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -34,12 +40,15 @@ 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
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.pk.LabTiming
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
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -105,6 +114,11 @@ fun labNotesForDisplay(group: List<LabResult>): List<String> {
fun LabsScreen() { fun LabsScreen() {
val container = LocalAppContainer.current val container = LocalAppContainer.current
val repo = container.repository val repo = container.repository
// ⚠️ TOUS les traitements + toutes les doses (actifs ET inactifs, §6.bis) :
// la recommandation se base sur le traitement E2 injectable ACTIF, mais le
// moteur reçoit l'historique complet (l'inactif participe à la courbe).
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
val labs by repo.allLabResults.collectAsState(initial = emptyList()) val labs by repo.allLabResults.collectAsState(initial = emptyList())
var showLabDialog by remember { mutableStateOf(false) } var showLabDialog by remember { mutableStateOf(false) }
@ -119,6 +133,34 @@ fun LabsScreen() {
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault()) DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault())
} }
// ── Recommandation de prochaine prise de sang (v1.8.0) ───────────────────
// Tick minute (même pattern que ChartScreen) : « maintenant » avance, la
// fenêtre de recherche de creux se rafraîchit sans interaction.
var tick by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(60_000)
tick++
}
}
val nowMs = remember(tick) { System.currentTimeMillis() }
// Calcul HORS UI thread (produceState) — c'est un calcul PK complet
// (courbe prévisionnelle), même discipline que Home/Chart.
val recommendation by produceState(
initialValue = null as LabTiming.LabRecommendation?,
treatments, doseLogs, labs, nowMs
) {
value = withContext(Dispatchers.Default) {
LabTiming.nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs)
}
}
// Invite « renseigne une Posologie » (v1.8.0, demande explicite) : quand
// un traitement E2 injectable actif n'a PAS de Posologie, aucune
// recommandation n'est calculable — on l'explique pour que le lien
// Posologie → recommandation soit découvrable. Ne s'affiche JAMAIS en
// même temps qu'une vraie recommandation.
val suggestPosology = recommendation == null && LabTiming.shouldSuggestPosology(treatments)
Scaffold( Scaffold(
// Insets à zéro : le TopAppBar interne consomme déjà la barre de statut // Insets à zéro : le TopAppBar interne consomme déjà la barre de statut
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
@ -132,6 +174,74 @@ fun LabsScreen() {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.nav_labs)) } title = { Text(stringResource(R.string.nav_labs)) }
) )
// ── Carte « Prochaine analyse recommandée » (v1.8.0) ─────────────
// Affichée seulement si le calcul est honnête (traitement E2
// injectable actif à Posologie + creux stable trouvé). Sinon, si
// un injectable actif est SANS Posologie : invite à la renseigner
// (les deux cartes sont mutuellement exclusives).
if (recommendation != null) {
val r = recommendation!!
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Column(Modifier.padding(12.dp)) {
Text(
stringResource(R.string.labrec_section),
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(4.dp))
// Creux EXACT (choix v1.8.0 : les deux affichés)
Text(
stringResource(R.string.labrec_when, fmt.format(Instant.ofEpochMilli(r.troughMs))),
style = MaterialTheme.typography.bodyLarge
)
Text(
stringResource(
R.string.labrec_before_injection,
r.ester,
fmt.format(Instant.ofEpochMilli(r.injectionMs))
),
style = MaterialTheme.typography.bodyMedium
)
Text(
if (r.wasAlreadyStabilized) {
stringResource(
R.string.labrec_stabilized,
r.ester,
fmt.format(Instant.ofEpochMilli(r.stabilizedAtMs))
)
} else {
stringResource(
R.string.labrec_not_stabilized,
r.ester,
fmt.format(Instant.ofEpochMilli(r.stabilizedAtMs))
)
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
stringResource(R.string.labrec_disclaimer),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else if (suggestPosology) {
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Column(Modifier.padding(12.dp)) {
Text(
stringResource(R.string.labrec_section),
style = MaterialTheme.typography.titleMedium
)
Text(
stringResource(R.string.labrec_needs_posology),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
if (grouped.isEmpty()) { if (grouped.isEmpty()) {
Text( Text(
stringResource(R.string.no_data), stringResource(R.string.no_data),

View File

@ -29,6 +29,14 @@
<string name="chart_24h">24 h</string> <string name="chart_24h">24 h</string>
<string name="chart_7j">7 jours</string> <string name="chart_7j">7 jours</string>
<string name="chart_30j">30 jours</string> <string name="chart_30j">30 jours</string>
<!-- v1.8.0 : recommandation de prochaine prise de sang (creux stabilisé) -->
<string name="labrec_section">Prochaine prise de sang (suggestion)</string>
<string name="labrec_when">Au creux estimé : %1$s</string>
<string name="labrec_before_injection">Juste avant ton injection de %1$s du %2$s — ou simplement la veille de ce créneau.</string>
<string name="labrec_stabilized">Ton %1$s est stabilisé depuis le %2$s (~5 demi-vies terminales).</string>
<string name="labrec_not_stabilized">Ton %1$s n\'était pas stabilisé avant le %2$s (~5 demi-vies terminales) : c\'est le premier creux fiable depuis ton dernier changement.</string>
<string name="labrec_disclaimer">Estimation du modèle pharmacocinétique, pas un avis médical — la prise au creux donne simplement le résultat le plus comparable. Suis toujours ton endocrinologue.</string>
<string name="labrec_needs_posology">Astuce : renseigne une Posologie (intervalle entre prises) sur ton traitement E2 injectable actif (Traitements → éditer) et cette page suggérera quand faire ta prochaine prise de sang (au creux, juste avant l\'injection).</string>
<string name="show_labs">Analyses</string> <string name="show_labs">Analyses</string>
<string name="show_forecast">Prévision</string> <string name="show_forecast">Prévision</string>
<string name="show_extrema">Pics / creux</string> <string name="show_extrema">Pics / creux</string>

View File

@ -29,6 +29,14 @@
<string name="chart_24h">24 h</string> <string name="chart_24h">24 h</string>
<string name="chart_7j">7 days</string> <string name="chart_7j">7 days</string>
<string name="chart_30j">30 days</string> <string name="chart_30j">30 days</string>
<!-- v1.8.0 : recommandation de prochaine prise de sang (creux stabilisé) -->
<string name="labrec_section">Next blood draw (suggested)</string>
<string name="labrec_when">At the estimated trough: %1$s</string>
<string name="labrec_before_injection">Just before your %1$s injection of %2$s — or simply the day before that slot.</string>
<string name="labrec_stabilized">Your %1$s has been stable since %2$s (~5 terminal half-lives).</string>
<string name="labrec_not_stabilized">Your %1$s was not stable until %2$s (~5 terminal half-lives): this is the first reliable trough after your latest change.</string>
<string name="labrec_disclaimer">Estimation from the pharmacokinetic model, not medical advice — blood draw at the trough simply gives the most comparable result. Always follow your endocrinologist.</string>
<string name="labrec_needs_posology">Tip: set a Dosage interval on your active injectable E2 treatment (Treatments → edit) and this page will suggest when to do your next blood draw (at the trough, just before the injection).</string>
<string name="show_labs">Labs</string> <string name="show_labs">Labs</string>
<string name="show_forecast">Forecast</string> <string name="show_forecast">Forecast</string>
<string name="show_extrema">Peaks / troughs</string> <string name="show_extrema">Peaks / troughs</string>

View File

@ -0,0 +1,170 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult
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.Before
import org.junit.Test
import java.io.File
/**
* Tests de la RECOMMANDATION DE PROCHAINE PRISE DE SANG (v1.8.0, cf doc
* §7.11) — [LabTiming.nextBloodDrawRecommendation] :
* - le creux recommandé tombe JUSTE AVANT le créneau d'injection associé ;
* - régime NON stabilisé → ON SAUTE au premier creux stabilisé
* (5 × t½ terminale — décision v1.8.0 : pas de prise intermédiaire
* trompeuse) ;
* - régime déjà stabilisé → premier creux, `wasAlreadyStabilized = true` ;
* - sans Posologie / sans injectable E2 → `null` (pas de recommandation
* honnête — l'UI affiche alors l'invite « renseigne une Posologie ») ;
* - une prise de sang RÉCENTE fait recommander le creux SUIVANT (jamais un
* creux déjà mesuré) ;
* - modèle Estrannaise : t½ LUE dans la table (aucun crash, valeur finie).
*/
class LabTimingTest {
private val DAY_MS = 86_400_000L
private val HOUR_MS = 3_600_000L
/** Maintenant FIXE : les tests sont déterministes (aucun System.currentTimeMillis). */
private val NOW = 1_790_000_000_000L
@Before
fun setup() {
if (!PKProfileStore.hasProfile("EV", "ESE")) {
val candidates = listOf(
File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json")
)
val file = candidates.firstOrNull { it.exists() }
?: error("pk_profiles.json not found for unit tests")
PKProfileStore.initWithJson(file.readText())
}
}
/** Traitement EEn TFS actif, Posologie 7 j (fixture de l'utilisatrice). */
private fun eenTfs(posologyDays: Double? = 7.0) = Treatment(
id = 1, name = "EEn", type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = 5.0, doseUnit = "mg",
esterType = "EEN", pkModel = "TFS",
forecastIntervalDays = posologyDays,
createdAt = 0
)
/** Doses hebdomadaires de [weeks]×7 j à [weeks]−1×7 j avant NOW. */
private fun weeklyDoses(weeks: Int): List<DoseLog> =
(weeks downTo 1).map { k ->
DoseLog(id = k.toLong(), treatmentId = 1, timestamp = NOW - k * 7 * DAY_MS, doseAmount = 5.0)
}
private fun lab(ts: Long) = LabResult(marker = "E2", value = 200.0, unit = "pg/mL", timestamp = ts)
@Test
fun `trough is found just before the associated injection slot`() {
// Régime long (stabilisé) → le 1ᵉʳ creux est recommandé ; il tombe
// juste avant l'injection (le minimum de la fenêtre, à 1 h du créneau)
val doses = weeklyDoses(9)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
val slot = NOW + 7 * DAY_MS
assertEquals(slot, rec.injectionMs)
assertTrue(
"le creux est juste avant l'injection (écart ${(slot - rec.troughMs) / 60_000} min)",
rec.troughMs in (slot - 2 * HOUR_MS) until slot
)
assertTrue(rec.wasAlreadyStabilized)
}
@Test
fun `not stabilized - skips to the first STABILIZED trough`() {
// Une seule dose il y a 7 j → régime commencé hier-semaine : EEn TFS
// t½ = ln2/k3 = 4,53 j → stabilisation ≈ NOW + 15,7 j → les créneaux
// +7 j et +14 j sont TROP TÔT, le +21 j est le premier stable
val doses = weeklyDoses(1)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
assertEquals("sauter au créneau stabilisé", NOW + 21 * DAY_MS, rec.injectionMs)
assertEquals(false, rec.wasAlreadyStabilized)
// stabilisation cohérente : régime (NOW−7 j) + 5 × t½ EEn TFS — le
// t½ est lu du MODÈLE (même expression que le code, zéro arrondi)
val tHalf = com.hormonetrack.pk.TransfemScienceModels.model("EEN")!!.terminalHalfLifeDays
val expectedStable = NOW - 7 * DAY_MS + (5 * tHalf * DAY_MS).toLong()
assertEquals(expectedStable, rec.stabilizedAtMs)
}
@Test
fun `already stabilized - first trough, wasAlreadyStabilized is true`() {
// 9 semaines d'historique : le régime (NOW−63 j) est stabilisé depuis
// bien longtemps (5 × t½ ≈ 22,7 j < 63 j)
val doses = weeklyDoses(9)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
assertEquals(NOW + 7 * DAY_MS, rec.injectionMs)
assertTrue(rec.wasAlreadyStabilized)
assertTrue(rec.stabilizedAtMs < NOW)
}
@Test
fun `a recent blood draw pushes the recommendation to the NEXT trough`() {
// Stabilisé, mais une prise de sang existe juste APRÈS le 1ᵉʳ creux
// → il ne faut PAS recommander deux fois le même creux
val doses = weeklyDoses(9)
val recentLab = lab(NOW + 7 * DAY_MS - 30 * 60_000L) // 30 min avant le créneau
val rec = LabTiming.nextBloodDrawRecommendation(
listOf(eenTfs()), doses, listOf(recentLab), NOW
)!!
assertEquals("le creux suivant est recommandé", NOW + 14 * DAY_MS, rec.injectionMs)
}
@Test
fun `no posology returns null (no predictable trough)`() {
val doses = weeklyDoses(9)
assertNull(
LabTiming.nextBloodDrawRecommendation(
listOf(eenTfs(posologyDays = null)), doses, emptyList(), NOW
)
)
}
@Test
fun `oral-only treatment returns null (no injectable E2 carrier)`() {
// CPA oral : usesProfileModel = false → aucun traitement porteur
val oral = Treatment(
id = 2, name = "CPA", type = TreatmentType.ANTI_ANDROGEN,
route = AdministrationRoute.ORAL,
doseAmount = 12.5, doseUnit = "mg", esterType = "NONE",
forecastIntervalDays = 2.0, createdAt = 0
)
assertNull(LabTiming.nextBloodDrawRecommendation(listOf(oral), weeklyDoses(9), emptyList(), NOW))
// et l'invite Posologie ne doit PAS s'afficher pour un non-injectable
assertTrue(!LabTiming.shouldSuggestPosology(listOf(oral)))
}
@Test
fun `posology suggestion only for an injectable E2 without posology`() {
val noPosology = eenTfs(posologyDays = null)
assertTrue(LabTiming.shouldSuggestPosology(listOf(noPosology)))
// avec Posologie : PAS d'invite (la reco s'en charge)
assertTrue(!LabTiming.shouldSuggestPosology(listOf(eenTfs())))
}
@Test
fun `Estrannaise carrier reads its half-life from the ODS table`() {
// EV / tables ODS : t½ estimée depuis la pente terminale de la table —
// finie et raisonnable, et la recommandation se calcule sans crash
val evEse = eenTfs().copy(esterType = "EV", pkModel = "ESE", forecastIntervalDays = 14.0)
val doses = listOf(
DoseLog(id = 1, treatmentId = 1, timestamp = NOW - 70 * DAY_MS, doseAmount = 4.0),
DoseLog(id = 2, treatmentId = 1, timestamp = NOW - 56 * DAY_MS, doseAmount = 4.0)
)
val tHalf = PKProfileStore.terminalHalfLifeDays("EV", "ESE")
assertNotNull(tHalf)
assertTrue("t½ EV ODS plausible ($tHalf j)", tHalf!! in 0.1..200.0)
val rec = LabTiming.nextBloodDrawRecommendation(listOf(evEse), doses, emptyList(), NOW)
assertNotNull(rec)
assertEquals(tHalf, rec!!.terminalHalfLifeDays, 1e-9)
}
}

View File

@ -6,6 +6,7 @@ import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.TreatmentType import com.hormonetrack.data.model.TreatmentType
import com.hormonetrack.ui.screens.labNotesForDisplay import com.hormonetrack.ui.screens.labNotesForDisplay
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue import org.junit.Assume.assumeTrue
import org.junit.Before import org.junit.Before
@ -152,4 +153,30 @@ class RegressionUserCase5Test {
) )
assertTrue("créneaux prévisionnels générés (Posologie 7 j)", slots.isNotEmpty()) assertTrue("créneaux prévisionnels générés (Posologie 7 j)", slots.isNotEmpty())
} }
/**
* v1.8.0 : la RECOMMANDATION de prochaine prise de sang se calcule sur ce
* profil réel (EEn actif, Posologie 7 j) — future, avec le créneau
* associé juste après le creux, et cohérente avec la dernière prise de
* sang existante (jamais un creux déjà mesuré).
*/
@Test
fun `blood draw recommendation exists on the real profile`() {
val rec = LabTiming.nextBloodDrawRecommendation(
data.treatments, data.doseLogs, data.labResults, data.exportedAt
)
assertNotNull(
"EEn actif + Posologie 7 j → une recommandation est calculable", rec
)
assertTrue("le creux est dans le futur", rec!!.troughMs > data.exportedAt)
assertTrue(
"le créneau d'injection suit le creux de près",
rec.injectionMs > rec.troughMs && rec.injectionMs - rec.troughMs <= 24L * 3_600_000L
)
assertTrue(
"le creux recommandé est postérieur à la dernière prise de sang",
rec.troughMs > data.labResults.maxOf { it.timestamp }
)
assertTrue("ester actif uniquement", rec.ester.equals("EEN", ignoreCase = true))
}
} }

View File

@ -3,6 +3,41 @@
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.0] — 2026-09-17 (versionCode 35)
### Prochaine prise de sang recommandée (page Analyses)
- **Nouvelle carte sur la page Analyses** : « Prochaine prise de sang
(suggestion) » — l'app déduit de ses courbes quand refaire une analyse :
**au creux estimé, juste avant l'injection suivante** (le creux est le
moment le plus informatif et comparable ; l'heure exacte du creux calculé
est affichée, avec la mention « ou simplement la veille du créneau »).
- **Condition de stabilité** (choix v1.8.0) : un creux n'est interprétable
que si l'ester est stabilisé — règle des **~5 demi-vies terminales**
(≈ 97 % de l'équilibre). L'app **saute donc au premier creux stabilisé**
après un changement d'ester/dose/rythme, et la carte l'explique
(« stabilisé depuis le … (~5 demi-vies) » / « c'est le premier creux fiable
depuis ton dernier changement »). Le modèle Estrannaise lit sa demi-vie
dans les tables (nouveau `PKProfileStore.terminalHalfLifeDays`), TFS/WHSAH
la calculent analytiquement.
- **Périmètre honnête** : la carte n'apparaît que s'il existe un traitement
E2 **injectable actif avec une Posologie** (sinon pas de creux prévisible) ;
**et si un injectable actif est SANS Posologie, la page affiche une invite**
« renseigne une Posologie pour recevoir des recommandations » (demande
v1.8.0). Créneaux déjà mesurés jamais re-recommandés (prise en compte des
analyses existantes). Calcul hors UI thread + rafraîchi chaque minute ;
disclaimer estimation visible sur la carte.
- **11 nouveaux tests** (220 au total / 190 sans les données locales) :
LabTimingTest (8 : creux avant créneau, saut de stabilisation, stabilisé,
prise récente → creux suivant, sans Posologie → null, oral → null,
invite, Estrannaise lit sa t½ dans la table) + régression n°5 étendue
(recommandation cohérente sur l'export réel).
- **Porté web** (web v1.8.0, versions sync) : `js/pk/lab-timing.js` miroir +
encart + scénario E2E.
- Validé émulateur §16.ter (APK release, seed réel v1.7.0) : carte rendue —
« creux 20/09 10:37, juste avant l'injection EEN du 20/09 11:20, EEN
stabilisé depuis le 06/02 » — 0 crash.
## [1.7.1] — 2026-09-16 (versionCode 34) ## [1.7.1] — 2026-09-16 (versionCode 34)
### Corrigé — une seule note sur deux s'affichait dans les Analyses ### Corrigé — une seule note sur deux s'affichait dans les Analyses

View File

@ -63,6 +63,7 @@ Fonctionnalités v1 :
| Date | Événement | | Date | Événement |
|---|---| |---|---|
| 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). |
| 12 sept. 2026 (session v1.6.0) | **« Tracé labs » PROLONGÉ au-delà du dernier lab** (demande) : le tracé s'arrêtait AU dernier lab (v1.5.0) — justement la période la plus récente était invisible. FIX : chip `Prolonger` (5ᵉ de la rangée, off par défaut, désactivé tant que Tracé labs est off) → au-delà du dernier lab significatif, `courbe(t) = M(t) × ρ_last` (ρ CONSTANT — pas d'extrapolation de pente, elle divergerait sans base physiologique) ; M(t) inclut AUTOMATIQUEMENT les doses loguées après le dernier lab (une injection EV après des labs EEn refait monter la courbe — épinglé par test) ; horizon = dernière dose E2 + cutoffHours de son traitement (une seule source de vérité : `cutoffHours` rendu public) ; série SPLITTÉE en "LAB" (ancré) / "LABX" (prolongé, rose atténué α 0,55 + légende dédiée — l'estimation ne se confond pas avec le mesuré) ; branche "LABX" EXPLICITE dans le when de légende AVANT le else (leçon #63) ; **AVERTISSEMENT visible sous la légende quand la prolongation est affichée** (demande) : « simple simulation, sans garantie de correspondre au réel, basée sur tes labs qui peuvent eux-mêmes être erronés » (string FR/EN `lab_track_extend_warning`). < 2 ancres, modèle déjà éteint au dernier lab, ou demande finissant avant → pas de prolongation (retour v1.5.0 exact, épinglé bit-compatible). 8 nouveaux tests (191 verts / 168 sans données locales) + lint vert. **+ Rattrapage validation émulateur §16.ter (oubliée, demandée par l'utilisatrice) → bug #64 TROUVÉ** : « Tracé labs » vide EN RELEASE SEULE (R8 full mode avait REMOVÉ la classe — mapping `R8$$REMOVED$$CLASS` — et inliné le calcul dans le producer ; debug OK, 191 tests JVM aveugles). FIX : `-keep class ...LabTrajectoryModel { *; }` ; validé émulateur APK release re-buildé avec les données réelles seedées : ancrée (7090 px rose + légende), prolongée (légende + avertissement), 0 crash. Tag v1.6.0 reposé sur le commit de fix AVANT publication d'APK (checklist §16 étape 4). **Checklist §16 : étape 3.bis « validation émulateur release OBLIGATOIRE avant tag » ajoutée.** | | 12 sept. 2026 (session v1.6.0) | **« Tracé labs » PROLONGÉ au-delà du dernier lab** (demande) : le tracé s'arrêtait AU dernier lab (v1.5.0) — justement la période la plus récente était invisible. FIX : chip `Prolonger` (5ᵉ de la rangée, off par défaut, désactivé tant que Tracé labs est off) → au-delà du dernier lab significatif, `courbe(t) = M(t) × ρ_last` (ρ CONSTANT — pas d'extrapolation de pente, elle divergerait sans base physiologique) ; M(t) inclut AUTOMATIQUEMENT les doses loguées après le dernier lab (une injection EV après des labs EEn refait monter la courbe — épinglé par test) ; horizon = dernière dose E2 + cutoffHours de son traitement (une seule source de vérité : `cutoffHours` rendu public) ; série SPLITTÉE en "LAB" (ancré) / "LABX" (prolongé, rose atténué α 0,55 + légende dédiée — l'estimation ne se confond pas avec le mesuré) ; branche "LABX" EXPLICITE dans le when de légende AVANT le else (leçon #63) ; **AVERTISSEMENT visible sous la légende quand la prolongation est affichée** (demande) : « simple simulation, sans garantie de correspondre au réel, basée sur tes labs qui peuvent eux-mêmes être erronés » (string FR/EN `lab_track_extend_warning`). < 2 ancres, modèle déjà éteint au dernier lab, ou demande finissant avant → pas de prolongation (retour v1.5.0 exact, épinglé bit-compatible). 8 nouveaux tests (191 verts / 168 sans données locales) + lint vert. **+ Rattrapage validation émulateur §16.ter (oubliée, demandée par l'utilisatrice) → bug #64 TROUVÉ** : « Tracé labs » vide EN RELEASE SEULE (R8 full mode avait REMOVÉ la classe — mapping `R8$$REMOVED$$CLASS` — et inliné le calcul dans le producer ; debug OK, 191 tests JVM aveugles). FIX : `-keep class ...LabTrajectoryModel { *; }` ; validé émulateur APK release re-buildé avec les données réelles seedées : ancrée (7090 px rose + légende), prolongée (légende + avertissement), 0 crash. Tag v1.6.0 reposé sur le commit de fix AVANT publication d'APK (checklist §16 étape 4). **Checklist §16 : étape 3.bis « validation émulateur release OBLIGATOIRE avant tag » ajoutée.** |
@ -595,9 +596,47 @@ constant à droite comme l'interpolation aboutit à ρ_last à gauche).
JAMAIS dans `levelAt` (display-end only, série "LABX" purement visuelle). JAMAIS dans `levelAt` (display-end only, série "LABX" purement visuelle).
- 8 nouveaux tests dans `LabTrajectoryModelTest` (191 verts au total). - 8 nouveaux tests dans `LabTrajectoryModelTest` (191 verts au total).
### 7.11 Recommandation de prochaine prise de sang (v1.8.0) — `pk/LabTiming.kt`
Carte « Prochaine prise de sang (suggestion) » de la page Analyses
(miroir web : `js/pk/lab-timing.js`). Recommande **le premier creux
prévisionnel STABILISÉ** — deux idées pharmacocinétiques :
1. la prise la plus informative/comparable pour un ester injectable est au
**creux juste avant l'injection suivante** (le pic varie, le creux reflète
le niveau de fond) ;
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).
- **Traitement porteur** : ESTRADIOL + injectable (`usesProfileModel`) +
**actif** + Posologie > 0. Sinon `null` → carte cachée ; et si un
injectable E2 actif est SANS Posologie, l'UI affiche l'**invite**
([LabTiming.shouldSuggestPosology], demande v1.8.0 : rendre découvrable le
lien Posologie → recommandation). Les deux encarts sont exclusifs.
- **t½ terminale** : TFS/WHSAH analytiques (`terminalHalfLifeDays`) ;
**Estrannaise** : lue dans la table par
[PKProfileStore.terminalHalfLifeDays] (refactor v1.8.0 : la pente
log-linéaire utilisée par l'extrapolation de `sample()` — dernier point
≥ 1 % du pic, 48 h précédentes — est partagée).
- **Début du régime courant** : 1ʳᵉ dose du traitement actif — PROXY assumé
(pas d'historique des éditions de Posologie/dose). Stabilisation =
régimeStart + 5 × t½.
- **Creux** : minimum local de la courbe E2 prévisionnelle (doses réelles +
[PharmacokineticEngine.generateForecastDoses], pas 1 h) entre deux
injections ; 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
recommander un creux déjà mesuré) ; créneau ≥ stabilisation (sinon creux
du créneau suivant — décision v1.8.0 « sauter »).
- **UI** : LabsScreen — calcul `produceState` (Dispatchers.Default) + tick
minute ; carte avec creux exact (« At the estimated trough »), créneau
associé (+ mention « ou simplement la veille »), statut de stabilisation,
disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée.
- 8 tests `LabTimingTest` + 1 test régression n°5.
## 8. Tests unitaires ## 8. Tests unitaires
**211 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **181 sans **220 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **190 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 =
@ -675,8 +714,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 181 tests au lieu de 211 (v1.7.1 ; chiffres historiques : 44/87 à v1.3.x, exécute 190 tests au lieu de 220 (v1.8.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) ;
- 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
@ -2065,6 +2105,14 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
note identique sur les deux → une seule ligne ; la note seule reste note identique sur les deux → une seule ligne ; la note seule reste
brute ; ré-éditer une entrée → sa note se met à jour sans effacer brute ; ré-éditer une entrée → sa note se met à jour sans effacer
l'autre l'autre
- [ ] **v1.8.0** : **recommandation de prochaine prise de sang** : avec un
injectable E2 actif à Posologie, la page Analyses affiche la carte
« Prochaine prise de sang (suggestion) » — creux daté juste avant
l'injection du créneau associé (+ mention « ou la veille »), statut
de stabilisation, disclaimer ; l'analyse réellement faite au creux
recommandé → la carte passe au creux SUIVANT ; enlever la Posologie
→ carte remplacée par l'invite « renseigne une Posologie » ;
traitement oral seul → aucune carte
- [ ] **v1.4.10** : **pan sur la vue 24 h** : glisser horizontalement à - [ ] **v1.4.10** : **pan sur la vue 24 h** : glisser horizontalement à
24 h doit déplacer la courbe (fix #62 : les deltas < 1 h s'accumulent 24 h doit déplacer la courbe (fix #62 : les deltas < 1 h s'accumulent
— avant, le pan ne bougeait jamais sur 24 h) ; tester aussi à 7 j et — avant, le pan ne bougeait jamais sur 24 h) ; tester aussi à 7 j et

View File

@ -122,6 +122,13 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
- **Supprimer** : la corbeille retire la prise entière (E2 + T ensemble) - **Supprimer** : la corbeille retire la prise entière (E2 + T ensemble)
- Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la - Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la
calibration (E2 en pg/mL ; T en ng/mL, ng/dL, ng/L, nmol/L) calibration (E2 en pg/mL ; T en ng/mL, ng/dL, ng/L, nmol/L)
- **Prochaine prise de sang (suggestion, v1.8.0)** : si ton traitement E2
injectable actif a une **Posologie**, la page te suggère quand faire la
prochaine analyse — **au creux estimé, juste avant l'injection suivante**
(moment le plus comparable), au premier creux où ton ester est stabilisé
(~5 demi-vies après le dernier changement). Sans Posologie : la page
t'invite à en renseigner une (Traitements → éditer) pour activer la
suggestion. C'est une estimation du modèle, pas un avis médical
4. **Calibration** (dans l'édition d'un traitement E2) → « Calibrer avec les analyses » 4. **Calibration** (dans l'édition d'un traitement E2) → « Calibrer avec les analyses »
- Paramètres → **Logs de diagnostic** : « Exporter » ouvre le gestionnaire de - Paramètres → **Logs de diagnostic** : « Exporter » ouvre le gestionnaire de
fichiers → choisis où enregistrer le .txt → un message de confirmation fichiers → choisis où enregistrer le .txt → un message de confirmation