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:
parent
23c62e85f2
commit
8c19c8f295
10
README.md
10
README.md
@ -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
|
||||
> 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)
|
||||
- **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)
|
||||
@ -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
|
||||
**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
|
||||
- **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),
|
||||
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
|
||||
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
|
||||
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
||||
./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
|
||||
```
|
||||
|
||||
|
||||
@ -17,8 +17,8 @@ android {
|
||||
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
||||
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
||||
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
||||
versionCode = 34
|
||||
versionName = "1.7.1"
|
||||
versionCode = 35
|
||||
versionName = "1.8.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
203
app/src/main/java/com/hormonetrack/pk/LabTiming.kt
Normal file
203
app/src/main/java/com/hormonetrack/pk/LabTiming.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@ -61,6 +61,62 @@ object PKProfileStore {
|
||||
* linearly interpolated between hourly points; beyond the table, extrapolated
|
||||
* 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 {
|
||||
if (!initialized || dtHours <= 0.0) return 0.0
|
||||
val suffix = when (model) {
|
||||
@ -77,23 +133,7 @@ object PKProfileStore {
|
||||
// display floor long before the true value vanishes. Extrapolate from the
|
||||
// last point still >= 1% of the peak, using the average hourly decay of
|
||||
// the previous 48 h (never sampling before the peak).
|
||||
var peakIdx = 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
|
||||
val (j, rate) = terminalDecayParameters(arr) ?: return 0.0
|
||||
return arr[j].toDouble() * kotlin.math.exp(rate * (dtHours - j))
|
||||
}
|
||||
|
||||
|
||||
@ -6,13 +6,16 @@ import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
@ -24,9 +27,12 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -34,12 +40,15 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.pk.LabTiming
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.components.LabDialog
|
||||
import com.hormonetrack.ui.components.formatLabValue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@ -105,6 +114,11 @@ fun labNotesForDisplay(group: List<LabResult>): List<String> {
|
||||
fun LabsScreen() {
|
||||
val container = LocalAppContainer.current
|
||||
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())
|
||||
|
||||
var showLabDialog by remember { mutableStateOf(false) }
|
||||
@ -119,6 +133,34 @@ fun LabsScreen() {
|
||||
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(
|
||||
// Insets à zéro : le TopAppBar interne consomme déjà la barre de statut
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
@ -132,6 +174,74 @@ fun LabsScreen() {
|
||||
TopAppBar(
|
||||
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()) {
|
||||
Text(
|
||||
stringResource(R.string.no_data),
|
||||
|
||||
@ -29,6 +29,14 @@
|
||||
<string name="chart_24h">24 h</string>
|
||||
<string name="chart_7j">7 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_forecast">Prévision</string>
|
||||
<string name="show_extrema">Pics / creux</string>
|
||||
|
||||
@ -29,6 +29,14 @@
|
||||
<string name="chart_24h">24 h</string>
|
||||
<string name="chart_7j">7 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_forecast">Forecast</string>
|
||||
<string name="show_extrema">Peaks / troughs</string>
|
||||
|
||||
170
app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt
Normal file
170
app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import com.hormonetrack.ui.screens.labNotesForDisplay
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
@ -152,4 +153,30 @@ class RegressionUserCase5Test {
|
||||
)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,41 @@
|
||||
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`, …).
|
||||
|
||||
## [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)
|
||||
|
||||
### Corrigé — une seule note sur deux s'affichait dans les Analyses
|
||||
|
||||
@ -63,6 +63,7 @@ Fonctionnalités v1 :
|
||||
|
||||
| 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). |
|
||||
| 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.** |
|
||||
@ -595,9 +596,47 @@ constant à droite comme l'interpolation aboutit à ρ_last à gauche).
|
||||
JAMAIS dans `levelAt` (display-end only, série "LABX" purement visuelle).
|
||||
- 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
|
||||
|
||||
**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,
|
||||
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 =
|
||||
@ -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).
|
||||
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le
|
||||
fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI
|
||||
exécute 181 tests au lieu de 211 (v1.7.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) ;
|
||||
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,
|
||||
181/211 à v1.7.1) ;
|
||||
- le workdir des tests Gradle est le dossier du module (`app/`) → les tests
|
||||
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
|
||||
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous
|
||||
@ -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
|
||||
brute ; ré-éditer une entrée → sa note se met à jour sans effacer
|
||||
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 à
|
||||
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
|
||||
|
||||
@ -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)
|
||||
- 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)
|
||||
- **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 »
|
||||
- Paramètres → **Logs de diagnostic** : « Exporter » ouvre le gestionnaire de
|
||||
fichiers → choisis où enregistrer le .txt → un message de confirmation
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user