diff --git a/README.md b/README.md index 377535c..e9475e2 100644 --- a/README.md +++ b/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 && 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 ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c5c06bc..cc5ccd5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/com/hormonetrack/pk/LabTiming.kt b/app/src/main/java/com/hormonetrack/pk/LabTiming.kt new file mode 100644 index 0000000..f097267 --- /dev/null +++ b/app/src/main/java/com/hormonetrack/pk/LabTiming.kt @@ -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, + doseLogs: List, + labs: List, + 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): Boolean = + treatments.any { + it.type == TreatmentType.ESTRADIOL && + it.isActive && + it.usesProfileModel && + (it.forecastIntervalDays ?: 0.0) <= 0.0 + } +} diff --git a/app/src/main/java/com/hormonetrack/pk/PKProfileStore.kt b/app/src/main/java/com/hormonetrack/pk/PKProfileStore.kt index a80967c..6c4bd47 100644 --- a/app/src/main/java/com/hormonetrack/pk/PKProfileStore.kt +++ b/app/src/main/java/com/hormonetrack/pk/PKProfileStore.kt @@ -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? { + 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)) } diff --git a/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt index 79989f7..ade8e87 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt @@ -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): List { 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), diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b270769..d17683a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -29,6 +29,14 @@ 24 h 7 jours 30 jours + + Prochaine prise de sang (suggestion) + Au creux estimé : %1$s + Juste avant ton injection de %1$s du %2$s — ou simplement la veille de ce créneau. + Ton %1$s est stabilisé depuis le %2$s (~5 demi-vies terminales). + 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. + 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. + 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). Analyses Prévision Pics / creux diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a315fdd..2ec16bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,6 +29,14 @@ 24 h 7 days 30 days + + Next blood draw (suggested) + At the estimated trough: %1$s + Just before your %1$s injection of %2$s — or simply the day before that slot. + Your %1$s has been stable since %2$s (~5 terminal half-lives). + Your %1$s was not stable until %2$s (~5 terminal half-lives): this is the first reliable trough after your latest change. + Estimation from the pharmacokinetic model, not medical advice — blood draw at the trough simply gives the most comparable result. Always follow your endocrinologist. + 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). Labs Forecast Peaks / troughs diff --git a/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt b/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt new file mode 100644 index 0000000..8900d72 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt @@ -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 = + (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) + } +} diff --git a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt index 6b607e8..fee6827 100644 --- a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt +++ b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt @@ -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)) + } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6b98563..2355492 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 8354988..07f5b6a 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -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 diff --git a/docs/GUIDE_INSTALLATION.md b/docs/GUIDE_INSTALLATION.md index c8ac9bc..f4d1b9e 100644 --- a/docs/GUIDE_INSTALLATION.md +++ b/docs/GUIDE_INSTALLATION.md @@ -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