diff --git a/README.en.md b/README.en.md index 9a7a510..fc11f5d 100644 --- a/README.en.md +++ b/README.en.md @@ -18,8 +18,8 @@ or Huawei Health) and JSON backup. **100% local, no account, no server.** > for informational purposes — they are not measurements. Always rely on your > blood tests and on your endocrinologist's guidance. -- **Status**: v1.12.0 — Android build ✅, **lint clean** ✅, **274 unit tests** ✅ - (240 without the local test data; 6 regressions pinned on real data +- **Status**: v1.13.0 — Android build ✅, **lint clean** ✅, **284 unit tests** ✅ + (250 without the local test data; 7 regressions pinned on real data **not committed to the repo**), **14 Compose UI tests** ✅ (emulator), smartwatch integration = notifications ✅, **private Gitea repo + releases with APK** ✅ @@ -85,7 +85,10 @@ or Huawei Health) and JSON backup. **100% local, no account, no server.** stabilized** (~5 half-lives after your last change — dose, ester or interval) — derived from the curves and the Dosage interval; requires an active injectable E2 with a Dosage (a prompt suggests setting one otherwise); estimates, never - medical advice + medical advice; + **v1.13.0**: the card shows the **expected E2 at that trough** (when + auto-calibration is ON) and compares it to your personal **trough target** + (opt-in — distinct from the alert thresholds, never notifies) - **Calibration**: per-treatment scale factor = median(lab ÷ model prediction), computed automatically (the `.ods` "Scale factor", automated) — or **permanent automatic calibration** (option, off by default) which calibrates **each ester @@ -134,7 +137,7 @@ Gradle 9.7.1. git clone && cd HormoneTrack echo "sdk.dir=/path/to/android-sdk" > local.properties # or ANDROID_HOME ./gradlew assembleDebug # APK: app/build/outputs/apk/debug/app-debug.apk -./gradlew testDebugUnitTest # 274 tests (240 without local test data) +./gradlew testDebugUnitTest # 284 tests (250 without local test data) ./gradlew connectedDebugAndroidTest # 14 UI tests (emulator/device required) ./gradlew lint # clean lint required before a release ``` diff --git a/README.md b/README.md index a812b98..8359fba 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,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.12.0 — build Android ✅, **lint vert** ✅, **274 tests unitaires** ✅ (240 sans les données de test locales ; 6 régressions épinglées sur données réelles **non versionnées**), **14 tests UI Compose** ✅ (émulateur), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ +- **Statut** : v1.13.0 — build Android ✅, **lint vert** ✅, **284 tests unitaires** ✅ (250 sans les données de test locales ; 7 régressions épinglées sur données réelles **non versionnées**), **14 tests UI Compose** ✅ (émulateur), 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) @@ -82,7 +82,10 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** **au premier creux où ton régime est stabilisé** (~5 demi-vies après ton dernier changement — dose, ester ou intervalle) — déduit des courbes et de la Posologie ; requiert un injectable E2 actif à Posologie (une invite propose de la renseigner sinon) ; - estimations, jamais un avis médical + estimations, jamais un avis médical ; + **v1.13.0** : la carte affiche la **valeur E2 attendue au creux** (si l'auto-calibration + est ON) et la compare à ta **cible de creux** personnelle (opt-in — distinct des + seuils d'alerte, jamais de notification) - **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 @@ -131,7 +134,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 # 274 tests (240 sans les données locales) +./gradlew testDebugUnitTest # 284 tests (250 sans les données locales) ./gradlew connectedDebugAndroidTest # 14 tests UI (émulateur/appareil requis) ./gradlew lint # lint vert obligatoire avant release ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2c14360..1a9f5ad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,8 +33,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 = 49 - versionName = "1.12.0" + versionCode = 50 + versionName = "1.13.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/data/backup/BackupManager.kt b/app/src/main/java/com/hormonetrack/data/backup/BackupManager.kt index 00937b5..55e010a 100644 --- a/app/src/main/java/com/hormonetrack/data/backup/BackupManager.kt +++ b/app/src/main/java/com/hormonetrack/data/backup/BackupManager.kt @@ -36,7 +36,12 @@ data class UserSettings( val alertE2High: Double? = null, val alertE2Low: Double? = null, val alertTHigh: Double? = null, - val alertTLow: Double? = null + val alertTLow: Double? = null, + // v1.13.0 : cible de creux E2 (opt-in) — champs NULLABLES pour la + // rétrocompatibilité : un backup v1/v2 antérieur ne les a pas → Gson + // les laisse à null → l'import ne touche pas à la cible locale. + val troughTargetLow: Double? = null, + val troughTargetHigh: Double? = null ) data class BackupData( diff --git a/app/src/main/java/com/hormonetrack/pk/LabTiming.kt b/app/src/main/java/com/hormonetrack/pk/LabTiming.kt index e2e3450..6783185 100644 --- a/app/src/main/java/com/hormonetrack/pk/LabTiming.kt +++ b/app/src/main/java/com/hormonetrack/pk/LabTiming.kt @@ -77,6 +77,37 @@ object LabTiming { /** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */ const val MIN_HORIZON_HOURS = 6L + /** + * Position d'une valeur par rapport à la CIBLE DE CREUX personnelle + * (v1.13.0, opt-in). ⚠️ DISTINCT des seuils d'alerte (pk/Alerts.kt, + * §9.bis) : les alertes surveillent le niveau estimé EN CONTINU et + * notifient ; la cible de creux est une RÉFÉRENCE DE LAB — « quand je + * pique au creux recommandé, je veux être dans [low, high] » — évaluée + * en lecture seule sur la carte, JAMAIS de notification. + */ + enum class TroughTargetStatus { IN_TARGET, BELOW, ABOVE } + + /** + * Statut de [predictedE2] par rapport à la cible [low, high]. + * `null` si pas de prédiction OU cible incomplète/désactivée. + * PUR → testable en JVM ; miroir web `troughTargetStatus` + * (js/pk/lab-timing.js). + */ + fun troughTargetStatus( + predictedE2: Double?, + low: Double?, + high: Double? + ): TroughTargetStatus? { + val value = predictedE2 ?: return null + val lo = low ?: return null + val hi = high ?: return null + return when { + value < lo -> TroughTargetStatus.BELOW + value > hi -> TroughTargetStatus.ABOVE + else -> TroughTargetStatus.IN_TARGET + } + } + /** 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). */ @@ -97,7 +128,15 @@ object LabTiming { * arrive entre maintenant et le creux recommandé (texte « c'est le * premier creux fiable »). */ - val wasAlreadyStabilized: Boolean + val wasAlreadyStabilized: Boolean, + /** + * E2 ATTENDUE à ce creux (v1.13.0) : valeur brute du modèle × + * facteur d'échelle du ester actif — fourni par l'appelant quand + * la calibration est disponible (auto-calibration ON). `null` = + * pas de calibration fiable → la carte n'affiche PAS de valeur + * (honnêteté : une valeur brute non calibrée serait trompeuse). + */ + val predictedE2: Double? = null ) /** @@ -112,7 +151,14 @@ object LabTiming { treatments: List, doseLogs: List, labs: List, - nowMs: Long = System.currentTimeMillis() + nowMs: Long = System.currentTimeMillis(), + /** + * Facteurs d'échelle PAR ESTER (v1.13.0) — issus de l'auto-calibration + * quand elle est activée (sinon `null`) : permet de retourner la + * valeur E2 ATTENDUE au creux ([LabRecommendation.predictedE2]). + * Absent → `predictedE2 = null` (carte sans valeur). + */ + scalePerEster: Map? = null ): LabRecommendation? { // ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ───── val carrier = treatments.firstOrNull { @@ -138,13 +184,14 @@ object LabTiming { // ── 3) Début du régime courant + date de stabilisation ────────────── val myDoses = doseLogs.filter { it.treatmentId == carrier.id } if (myDoses.isEmpty()) return null - val regimeStartMs = regimeStartMs(carrier, myDoses) - val stabilizedAtMs = regimeStartMs + - (STABILIZATION_HALF_LIVES * tHalfDays * 24.0 * 3_600_000.0).toLong() - // Ester EFFECTIF de la dernière dose (override compris) : c'est lui - // qui gouverne les creux futurs et le texte de la carte val lastDose = myDoses.maxByOrNull { it.timestamp }!! val activeEster = PharmacokineticEngine.doseEster(carrier, lastDose) + // v1.13.0 : régime POOLÉ (cf [pooledRegimeDoses]) — le re-parenting + // d'historique entre traitements (ex. « 6d - old » créé a posteriori, + // remontée v1.12.0) ne doit pas faire croire à un nouveau régime. + val regimeStart = regimeStartMs(carrier, pooledRegimeDoses(carrier, doseLogs, treatments)) + val stabilizedAtMs = regimeStart + + (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!! @@ -199,9 +246,18 @@ object LabTiming { injectionMs = slot.timestamp, ester = activeEster, terminalHalfLifeDays = tHalfDays, - regimeStartMs = regimeStartMs, + regimeStartMs = regimeStart, stabilizedAtMs = stabilizedAtMs, - wasAlreadyStabilized = stabilizedAtMs <= nowMs + wasAlreadyStabilized = stabilizedAtMs <= nowMs, + // v1.13.0 : E2 attendue au creux = valeur brute du + // modèle × facteur du ester actif (lookup insensible à + // la casse — les clés de scalePerEster suivent les + // esters stockés). null si pas de calibration. + predictedE2 = scalePerEster + ?.entries + ?.firstOrNull { it.key.equals(activeEster, ignoreCase = true) } + ?.value + ?.let { scale -> trough.e2 * scale } ) } windowStart = slot.timestamp @@ -212,6 +268,52 @@ object LabTiming { /** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */ private const val DOSE_EPS = 1e-6 + /** + * SÉQUENCE DE RÉGIME POOLÉE (v1.13.0) : toutes les doses de TOUS les + * traitements partageant l'**ester effectif** du porteur ET son **mg + * exact** (± [DOSE_EPS]), triées par timestamp. + * + * **Pourquoi** (remontée v1.12.0) : le re-parenting d'historique — créer + * un traitement « … - old » inactif et y déplacer des doses (ex. séparer + * une ère « 6 j » d'une ère « 7 j ») — créait un TROU synthétique dans + * l'historique du porteur : le régime redémarrait à la date du + * re-parenting et la stabilisation (5 × t½) repartait de plusieurs + * semaines, alors que pharmacologiquement rien n'avait changé (même + * ester, même dose). + * + * Garde-fous (épinglés par tests) : + * - **ester ≠ → pas de mutualisation** (transition EV → EEn : deux + * régimes distincts — régression n°3) ; + * - **dose ≠ → exclue du pool** (une dose « 8 mg » au milieu d'un + * régime « 5 mg » casse naturellement la séquence via le trou qui + * suit — conservateur, ~1 semaine du côté prudent). + * + * PUR → testable en JVM (DosesExtras? non — LabTimingTest + + * RegressionUserCase7Test) ; miroir web `pooledRegimeDoses` + * (js/pk/lab-timing.js). + */ + internal fun pooledRegimeDoses( + carrier: Treatment, + allDoseLogs: List, + allTreatments: List + ): List { + val last = allDoseLogs + .filter { it.treatmentId == carrier.id } + .maxByOrNull { it.timestamp } ?: return emptyList() + val activeEster = PharmacokineticEngine.doseEster(carrier, last) + val amount = last.doseAmount + val ownerById = allTreatments.associateBy { it.id } + return allDoseLogs + .filter { d -> + // Ester EFFECTIF via le traitement PROPRIÉTAIRE de la dose + // (override par dose compris) — jamais via le porteur + val owner = ownerById[d.treatmentId] ?: return@filter false + PharmacokineticEngine.doseEster(owner, d) == activeEster && + Math.abs(d.doseAmount - amount) < DOSE_EPS + } + .sortedBy { it.timestamp } + } + /** * Tolérance de l'écart inter-doses du régime (v1.9.7) : ± 24 h autour de * l'intervalle de Posologie — « je m'injecte le même jour de la semaine, @@ -236,9 +338,11 @@ object LabTiming { * 12:00, repoussant la stabilisation de 5 × t½ à CHAQUE injection : le * creux recommandé fuyait vers l'avant sans jamais se fixer. * - * **RÈGLE** : le régime courant = la **séquence terminale de doses** où - * (a) l'**ester effectif** et la **dose (mg)** sont identiques à celles - * de la dose la plus récente, ET (b) chaque **écart entre doses + * **RÈGLE** : le régime courant = la **séquence terminale de doses** + * (v1.13.0 : **poolée** entre traitements — cf [pooledRegimeDoses] — le + * re-parenting d'historique ne doit plus redémarrer la stabilisation) + * où (a) l'**ester effectif** et la **dose (mg)** sont identiques à + * celles de la dose la plus récente, ET (b) chaque **écart entre doses * consécutives reste dans l'INTERVALLE DE POSOLOGIE ± 24 h** * ([GAP_TOLERANCE_MS] — « je m'injecte le même jour, à l'heure près »). * On remonte depuis la dose la plus récente tant que ces conditions diff --git a/app/src/main/java/com/hormonetrack/settings/AppSettings.kt b/app/src/main/java/com/hormonetrack/settings/AppSettings.kt index a33196c..32d906c 100644 --- a/app/src/main/java/com/hormonetrack/settings/AppSettings.kt +++ b/app/src/main/java/com/hormonetrack/settings/AppSettings.kt @@ -79,6 +79,15 @@ class AppSettings(private val context: Context) { /** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */ val AUTO_BACKUP_LAST_AT = longPreferencesKey("auto_backup_last_at") + + // ── Cible de creux E2 (v1.13.0, opt-in) ──────────────────────────── + // Référence PERSONNELLE pour la prochaine prise de sang (« je veux + // être dans [low, high] au creux recommandé »). DISTINCT des seuils + // d'alerte (ALERT_E2_*), qui surveillent le niveau en continu — + // les deux bandes sont volontairement indépendantes. Champ absent = + // cible désactivée (comportement v1.12). + val TROUGH_TARGET_LOW = doublePreferencesKey("trough_target_low") + val TROUGH_TARGET_HIGH = doublePreferencesKey("trough_target_high") } val tConfig: Flow = context.dataStore.data.map { prefs -> @@ -221,6 +230,30 @@ class AppSettings(private val context: Context) { } } + /** Cible de creux (v1.13.0) : `null` si désactivée (un champ manquant suffit). */ + val troughTarget: Flow?> = context.dataStore.data.map { prefs -> + val low = prefs[Keys.TROUGH_TARGET_LOW] + val high = prefs[Keys.TROUGH_TARGET_HIGH] + if (low != null && high != null) low to high else null + } + + /** + * Persiste la cible de creux (v1.13.0). `null` EFFACE les deux clés — + * « champs vides = cible désactivée ». La cohérence high > low est + * validée dans l'UI (TroughTargetCard), comme pour les seuils d'alerte. + */ + suspend fun setTroughTarget(target: Pair?) { + context.dataStore.edit { prefs -> + if (target == null) { + prefs.remove(Keys.TROUGH_TARGET_LOW) + prefs.remove(Keys.TROUGH_TARGET_HIGH) + } else { + prefs[Keys.TROUGH_TARGET_LOW] = target.first + prefs[Keys.TROUGH_TARGET_HIGH] = target.second + } + } + } + /** * Persiste les seuils d'alerte (v1.4.2). Les valeurs null EFFACENT la clé * (DataStore : remove) — « champ vide = pas d'alerte ». diff --git a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt index 665ee53..e23d670 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt @@ -241,14 +241,20 @@ fun HomeScreen( // (l'accueil ne fait pas doublon d'explication). val labRecommendation by produceState( initialValue = null as LabTiming.LabRecommendation?, - allTreatments, doseLogs, labResults, tick + allTreatments, doseLogs, labResults, tick, effectiveAuto ) { value = withContext(Dispatchers.Default) { + // v1.13.0 : les facteurs d'échelle de l'auto-calibration + // alimentent la prédiction « ≈ X pg/mL au creux » (null + // si l'option est OFF → carte sans valeur, inchangée) LabTiming.nextBloodDrawRecommendation( - allTreatments, doseLogs, labResults, System.currentTimeMillis() + allTreatments, doseLogs, labResults, System.currentTimeMillis(), + effectiveAuto?.esterScales ) } } + // Cible de creux (v1.13.0) : colore la prédiction (lecture seule) + val troughTarget by container.settings.troughTarget.collectAsState(initial = null) labRecommendation?.let { rec -> val fmtDate = remember { DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault()) @@ -270,6 +276,37 @@ fun HomeScreen( stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))), style = MaterialTheme.typography.bodyLarge ) + // v1.13.0 : valeur E2 ATTENDUE au creux (seulement si + // la calibration est disponible) + statut face à la + // cible de creux personnelle (lecture seule — jamais + // de notification, distinct des seuils d'alerte) + rec.predictedE2?.let { predicted -> + val targetStatus = LabTiming.troughTargetStatus( + predicted, troughTarget?.first, troughTarget?.second + ) + Text( + stringResource( + R.string.labrec_predicted, + String.format(java.util.Locale.getDefault(), "%.0f", predicted) + ) + when (targetStatus) { + LabTiming.TroughTargetStatus.IN_TARGET -> + " — " + stringResource(R.string.labrec_in_target) + LabTiming.TroughTargetStatus.BELOW, + LabTiming.TroughTargetStatus.ABOVE -> + " — " + stringResource(R.string.labrec_out_of_target) + null -> "" + }, + style = MaterialTheme.typography.bodyMedium, + color = when (targetStatus) { + LabTiming.TroughTargetStatus.IN_TARGET -> + MaterialTheme.colorScheme.primary + LabTiming.TroughTargetStatus.BELOW, + LabTiming.TroughTargetStatus.ABOVE -> + MaterialTheme.colorScheme.tertiary + null -> MaterialTheme.colorScheme.onSurface + } + ) + } Text( stringResource( R.string.labrec_before_injection, 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 2d34da4..5577b02 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt @@ -40,6 +40,8 @@ import androidx.compose.ui.unit.dp import com.hormonetrack.R import com.hormonetrack.data.model.LabResult import com.hormonetrack.pk.LabTiming +import com.hormonetrack.pk.PharmacokineticEngine +import com.hormonetrack.pk.TConfig import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.components.LabDialog import com.hormonetrack.ui.components.formatLabValue @@ -143,14 +145,30 @@ fun LabsScreen() { } } val nowMs = remember(tick) { System.currentTimeMillis() } + // Auto-calibration (v1.13.0) : alimente la prédiction « ≈ X pg/mL » de la + // carte reco (null si l'option est OFF) — même pattern que HomeScreen. + val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false) + val tConfig by container.settings.tConfig.collectAsState(initial = TConfig()) + val effectiveAuto by produceState( + null, treatments, doseLogs, labs, tConfig, autoCalibrate + ) { + value = if (!autoCalibrate) null + else withContext(Dispatchers.Default) { + PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labs, tConfig) + } + } + // Cible de creux (v1.13.0) : colore la prédiction (lecture seule) + val troughTarget by container.settings.troughTarget.collectAsState(initial = null) // 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 + treatments, doseLogs, labs, nowMs, effectiveAuto ) { value = withContext(Dispatchers.Default) { - LabTiming.nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs) + LabTiming.nextBloodDrawRecommendation( + treatments, doseLogs, labs, nowMs, effectiveAuto?.esterScales + ) } } // Invite « renseigne une Posologie » (v1.8.0, demande explicite) : quand @@ -193,6 +211,36 @@ fun LabsScreen() { stringResource(R.string.labrec_when, fmt.format(Instant.ofEpochMilli(r.troughMs))), style = MaterialTheme.typography.bodyLarge ) + // v1.13.0 : valeur E2 ATTENDUE au creux (seulement si + // la calibration est disponible) + statut cible — + // lecture seule, jamais de notification (cf §9.bis) + r.predictedE2?.let { predicted -> + val targetStatus = LabTiming.troughTargetStatus( + predicted, troughTarget?.first, troughTarget?.second + ) + Text( + stringResource( + R.string.labrec_predicted, + String.format(java.util.Locale.getDefault(), "%.0f", predicted) + ) + when (targetStatus) { + LabTiming.TroughTargetStatus.IN_TARGET -> + " — " + stringResource(R.string.labrec_in_target) + LabTiming.TroughTargetStatus.BELOW, + LabTiming.TroughTargetStatus.ABOVE -> + " — " + stringResource(R.string.labrec_out_of_target) + null -> "" + }, + style = MaterialTheme.typography.bodyMedium, + color = when (targetStatus) { + LabTiming.TroughTargetStatus.IN_TARGET -> + MaterialTheme.colorScheme.primary + LabTiming.TroughTargetStatus.BELOW, + LabTiming.TroughTargetStatus.ABOVE -> + MaterialTheme.colorScheme.tertiary + null -> MaterialTheme.colorScheme.onSurface + } + ) + } Text( stringResource( R.string.labrec_before_injection, diff --git a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt index ac64f16..35aa16d 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt @@ -48,6 +48,7 @@ import com.hormonetrack.ui.screens.settings.ChartTimezoneCard import com.hormonetrack.ui.screens.settings.LanguageCard import com.hormonetrack.ui.screens.settings.LogsCard import com.hormonetrack.ui.screens.settings.RemindersCard +import com.hormonetrack.ui.screens.settings.TroughTargetCard import com.hormonetrack.ui.screens.settings.TModelCard import com.hormonetrack.util.AppLog import com.hormonetrack.util.ExportFileNames @@ -84,6 +85,9 @@ fun SettingsScreen(onBack: () -> Unit) { val alertThresholds by container.settings.alertThresholds.collectAsState( initial = Alerts.Thresholds() ) + // Cible de creux (v1.13.0) : référence de lab personnelle — distincte + // des seuils d'alerte (cf TroughTargetCard) + val troughTarget by container.settings.troughTarget.collectAsState(initial = null) // Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?) @@ -170,7 +174,11 @@ fun SettingsScreen(onBack: () -> Unit) { alertE2High = thresholds.e2High, alertE2Low = thresholds.e2Low, alertTHigh = thresholds.tHigh, - alertTLow = thresholds.tLow + alertTLow = thresholds.tLow, + // v1.13.0 : la cible de creux voyage dans le backup (champs + // optionnels — absents d'un backup v1/v2 ancien, rétrocompatibles) + troughTargetLow = container.settings.troughTarget.first()?.first, + troughTargetHigh = container.settings.troughTarget.first()?.second ) val json = BackupManager.exportJson(repo, tConfig, settings) val ok = BackupManager.writeBackup(context, uri, json) @@ -268,6 +276,8 @@ fun SettingsScreen(onBack: () -> Unit) { Spacer(Modifier.height(12.dp)) AlertThresholdsCard(alertThresholds, showMessage) Spacer(Modifier.height(12.dp)) + TroughTargetCard(troughTarget, showMessage) + Spacer(Modifier.height(12.dp)) ChartTimezoneCard(chartTimezone, showMessage) Spacer(Modifier.height(12.dp)) RemindersCard() @@ -342,6 +352,18 @@ fun SettingsScreen(onBack: () -> Unit) { tHigh = us.alertTHigh, tLow = us.alertTLow ) ) + // v1.13.0 : cible de creux — restaurée SEULEMENT + // si le backup en porte une (champs non nuls + // ET cohérents) ; sinon on ne touche à rien + // (un backup ancien ne doit pas effacer la + // cible locale). + if (us.troughTargetLow != null && us.troughTargetHigh != null && + us.troughTargetHigh > us.troughTargetLow + ) { + container.settings.setTroughTarget( + us.troughTargetLow to us.troughTargetHigh + ) + } withContext(Dispatchers.Main) { us.language?.let { lang -> container.settings.setLanguage(lang) diff --git a/app/src/main/java/com/hormonetrack/ui/screens/settings/TroughTargetCard.kt b/app/src/main/java/com/hormonetrack/ui/screens/settings/TroughTargetCard.kt new file mode 100644 index 0000000..21fd984 --- /dev/null +++ b/app/src/main/java/com/hormonetrack/ui/screens/settings/TroughTargetCard.kt @@ -0,0 +1,122 @@ +package com.hormonetrack.ui.screens.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.hormonetrack.R +import com.hormonetrack.ui.LocalAppContainer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Carte CIBLE DE CREUX E2 de l'écran Paramètres (v1.13.0, opt-in). + * + * ⚠️ DISTINCT des seuils d'alerte ([AlertThresholdsCard], au-dessus) : + * - les seuils d'alerte surveillent le niveau estimé EN CONTINU (carte + * d'accueil + notification toutes les 15 min — cf §9.bis) ; + * - la cible de creux est une RÉFÉRENCE PERSONNELLE DE LAB : « quand je + * fais ma prise de sang au creux recommandé, je veux être dans + * [basse, haute] » — la prédiction de la carte reco est colorée + * contre cette bande, JAMAIS de notification. + * + * Opt-in pur : champ vide = désactivée (les DEUX champs, vides ou remplis). + * Validation : haut > bas obligatoire (message rouge local, rien n'est + * persisté sinon). + * + * @param target cible active (basse to haute) ou null (désactivée) + * @param onMessage feedback affiché en haut de l'écran (message d'état). + */ +@Composable +internal fun TroughTargetCard(target: Pair?, onMessage: (String) -> Unit) { + val container = LocalAppContainer.current + val context = LocalContext.current + + var lowText by remember(target?.first) { + mutableStateOf(target?.first?.let { formatDoubles(it) } ?: "") + } + var highText by remember(target?.second) { + mutableStateOf(target?.second?.let { formatDoubles(it) } ?: "") + } + var error by remember { mutableStateOf(null) } + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text(stringResource(R.string.trough_target_section), style = MaterialTheme.typography.titleMedium) + Text( + stringResource(R.string.trough_target_hint), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = lowText, + onValueChange = { lowText = it }, + label = { Text(stringResource(R.string.trough_target_low)) }, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = highText, + onValueChange = { highText = it }, + label = { Text(stringResource(R.string.trough_target_high)) }, + modifier = Modifier.weight(1f) + ) + } + error?.let { + Spacer(Modifier.height(4.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(8.dp)) + Button(onClick = { + // Même parsing tolérant que la carte Seuils (virgule FR), + // champ vide = null + fun parse(s: String): Double? = + s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull() + val low = parse(lowText) + val high = parse(highText) + when { + // Un seul champ rempli = saisie incomplète (pas d'état + // « demi-cible » — l'app ne devine pas) + (low == null) != (high == null) -> { + error = context.getString(R.string.trough_target_incomplete) + } + low != null && high != null && high <= low -> { + error = context.getString(R.string.trough_target_invalid) + } + else -> { + error = null + CoroutineScope(Dispatchers.IO).launch { + // null to null = désactivation (setTroughTarget efface) + container.settings.setTroughTarget( + if (low != null && high != null) low to high else null + ) + withContext(Dispatchers.Main) { + onMessage(context.getString(R.string.saved)) + } + } + } + } + }) { Text(stringResource(R.string.save)) } + } + } +} diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 08487df..736ef55 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -151,6 +151,19 @@ lignes de doses (↑ = lab avant la dose, ↓ = lab après) --> Prise de sang plus tôt le même jour, avant cette dose Prise de sang plus tard le même jour, après cette dose + + Attendu à ce creux : ≈ %1$s pg/mL + dans ta cible + hors de ta cible + + Cible de creux (prise de sang) + Référence personnelle pour la prochaine prise de sang : la valeur de creux prédite est colorée par rapport à cette bande. Distinct des seuils d\u0027alerte ci-dessus — ceux-là surveillent ton niveau estimé en continu et peuvent te notifier ; celui-ci jamais. + Cible basse (pg/mL) + Cible haute (pg/mL) + Remplis les deux champs — ou laisse les deux vides pour désactiver. + La cible haute doit être supérieure à la cible basse. Crée d\'abord un traitement (onglet Traitements). Œstradiol diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e753a15..313098d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -151,6 +151,19 @@ lignes de doses (↑ = lab avant la dose, ↓ = lab après) --> Blood test earlier the same day, before this dose Blood test later the same day, after this dose + + Expected at this trough: ≈ %1$s pg/mL + within your target + outside your target + + Trough target (blood draw) + Personal reference for the recommended blood draw: the predicted trough value is colored against this band. Distinct from the alert thresholds above — those watch your estimated level continuously and can notify you; this one never does. + Target low (pg/mL) + Target high (pg/mL) + Fill both fields — or leave both empty to disable. + Target high must be greater than target low. Create a treatment first (Treatments tab). Estradiol diff --git a/app/src/test/java/com/hormonetrack/data/backup/BackupGsonTest.kt b/app/src/test/java/com/hormonetrack/data/backup/BackupGsonTest.kt index 6594cee..bfbd195 100644 --- a/app/src/test/java/com/hormonetrack/data/backup/BackupGsonTest.kt +++ b/app/src/test/java/com/hormonetrack/data/backup/BackupGsonTest.kt @@ -124,6 +124,34 @@ class BackupGsonTest { assertEquals(0.3, s.alertTLow!!, 1e-9) } + @Test + fun `backup round-trip preserves the trough target (v1_13_0) and stays absent when unset`() { + // v1.13.0 : la cible de creux voyage dans le backup (champs optionnels) + val withTarget = BackupData( + tConfig = TConfig(), + settings = UserSettings(troughTargetLow = 150.0, troughTargetHigh = 200.0) + ) + val parsed: BackupData = Gson().fromJson( + Gson().toJson(withTarget), BackupData::class.java + ) + val s = parsed.settings + org.junit.Assert.assertNotNull(s) + assertEquals(150.0, s!!.troughTargetLow!!, 1e-9) + assertEquals(200.0, s.troughTargetHigh!!, 1e-9) + + // Rétrocompatibilité : un backup SANS cible (v1.4.2→v1.12) parse les + // champs à null — l'import ne doit pas toucher à la cible locale + val without = BackupData( + tConfig = TConfig(), + settings = UserSettings(language = "en") + ) + val parsed2: BackupData = Gson().fromJson( + Gson().toJson(without), BackupData::class.java + ) + assertEquals(null, parsed2.settings!!.troughTargetLow) + assertEquals(null, parsed2.settings!!.troughTargetHigh) + } + @Test fun `legacy v1 backup without settings parses with null settings (backward compat)`() { // Les anciens exports (v1.4.1 et avant) n'ont PAS de champ settings : diff --git a/app/src/test/java/com/hormonetrack/pk/LabTimingV113Test.kt b/app/src/test/java/com/hormonetrack/pk/LabTimingV113Test.kt new file mode 100644 index 0000000..a862f73 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/LabTimingV113Test.kt @@ -0,0 +1,166 @@ +package com.hormonetrack.pk + +import com.hormonetrack.data.model.AdministrationRoute +import com.hormonetrack.data.model.DoseLog +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.Test + +/** + * Tests des ajouts v1.13.0 à la recommandation de prise de sang (§7.11) : + * - **régime POOLÉ** ([LabTiming.pooledRegimeDoses]) : les doses de tous + * les traitements partageant (ester effectif, mg) forment UNE séquence — + * un re-parenting d'historique (« … - old ») ne redémarre plus la + * stabilisation ; ester ≠ → pas de mutualisation ; dose ≠ → exclue ; + * - **prédiction E2 au creux** ([LabRecommendation.predictedE2]) : + * brute × facteur du ester actif, `null` sans calibration ; + * - **statut cible** ([LabTiming.troughTargetStatus]) : in/below/above, + * `null` si cible incomplète. + * + * Miroirs web : `tests/doses-extras.test.js` (lab-timing) + js/pk. + */ +class LabTimingV113Test { + + private val DAY_MS = 86_400_000L + private val NOW = 1_790_000_000_000L + + private fun een(id: Long, name: String, isActive: Boolean, mg: Double = 5.0) = Treatment( + id = id, name = name, type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + doseAmount = mg, doseUnit = "mg", esterType = "EEN", pkModel = "WHS", + isActive = isActive, forecastIntervalDays = 7.0, createdAt = 0 + ) + + private fun dose(treatmentId: Long, atMs: Long, mg: Double = 5.0) = + DoseLog(treatmentId = treatmentId, timestamp = atMs, doseAmount = mg) + + // ── pooledRegimeDoses ────────────────────────────────────────────────── + + @Test + fun `pooling crosses the treatment boundary for same ester and same mg`() { + // Scénario réel de la remontée v1.12.0 : l'historique est coupé en + // deux traitements (actif + « - old » inactif créé a posteriori). + // Le pool doit reconstituer LA séquence pharmacologique continue. + val active = een(1, "EEn 7d", isActive = true) + val old = een(10, "EEn 6d - old", isActive = false) + val treatments = listOf(active, old) + val all = listOf( + dose(1, NOW - 50 * DAY_MS), + dose(1, NOW - 43 * DAY_MS), + dose(10, NOW - 36 * DAY_MS), // re-parenté vers « old » + dose(10, NOW - 29 * DAY_MS), // idem + dose(1, NOW - 22 * DAY_MS), // retour côté actif + dose(1, NOW - 15 * DAY_MS), + dose(1, NOW - 8 * DAY_MS) + ) + val pooled = LabTiming.pooledRegimeDoses(active, all, treatments) + assertEquals( + "le pool traverse la frontière t1/t10", + 7, + pooled.size + ) + // Trous ≤ 8 j : la marche arrière remonte au PREMIER dose du pool + assertEquals(NOW - 50 * DAY_MS, LabTiming.regimeStartMs(active, pooled)) + } + + @Test + fun `different ester is never pooled (EV inactive stays out of the EEN regime)`() { + val activeEen = een(1, "EEn", isActive = true) + val inactiveEv = Treatment( + id = 2, name = "EV-old", type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + doseAmount = 5.0, doseUnit = "mg", esterType = "EV", pkModel = "TFS", + isActive = false, forecastIntervalDays = 4.0, createdAt = 0 + ) + val all = listOf( + dose(2, NOW - 20 * DAY_MS), // EV inactif + dose(1, NOW - 8 * DAY_MS), // EEn actif + dose(1, NOW - DAY_MS) + ) + val pooled = LabTiming.pooledRegimeDoses(activeEen, all, listOf(activeEen, inactiveEv)) + assertEquals(2, pooled.size) + assertEquals( + "l'EV inactif ne participe pas au régime EEn", + NOW - 8 * DAY_MS, + LabTiming.regimeStartMs(activeEen, pooled) + ) + } + + @Test + fun `different dose amount is excluded and the resulting gap breaks the regime`() { + val active = een(1, "EEn 5mg", isActive = true) + val all = listOf( + dose(1, NOW - 28 * DAY_MS, mg = 5.0), + dose(1, NOW - 21 * DAY_MS, mg = 5.0), + dose(1, NOW - 14 * DAY_MS, mg = 5.0), + dose(1, NOW - 7 * DAY_MS, mg = 8.0), // dose ≠ → hors pool + dose(1, NOW, mg = 5.0) + ) + val pooled = LabTiming.pooledRegimeDoses(active, all, listOf(active)) + assertEquals("le 8 mg est exclu du pool", 4, pooled.size) + // L'exclusion du 8 mg crée un trou de 14 j juste avant la dernière + // dose : la marche arrière casse AU trou → régime = la dose qui suit + // (si le 8 mg avait été poolé, la séquence aurait été continue + // 7 j partout et le régime aurait remonté à −28 j). + assertEquals(NOW, LabTiming.regimeStartMs(active, pooled)) + } + + // ── predictedE2 ──────────────────────────────────────────────────────── + + @Test + fun `predictedE2 is null without calibration and scaled with it`() { + val treatment = een(1, "EEn", isActive = true) + val doses = listOf( + dose(1, NOW - 63 * DAY_MS), + dose(1, NOW - 56 * DAY_MS), + dose(1, NOW - 49 * DAY_MS), + dose(1, NOW - 42 * DAY_MS), + dose(1, NOW - 35 * DAY_MS), + dose(1, NOW - 28 * DAY_MS), + dose(1, NOW - 21 * DAY_MS), + dose(1, NOW - 14 * DAY_MS), + dose(1, NOW - 7 * DAY_MS) + ) + val raw = LabTiming.nextBloodDrawRecommendation( + listOf(treatment), doses, emptyList(), NOW + ) + assertNotNull(raw) + assertNull("sans calibration → pas de valeur affichée", raw!!.predictedE2) + + val scaled = LabTiming.nextBloodDrawRecommendation( + listOf(treatment), doses, emptyList(), NOW, + scalePerEster = mapOf("EEN" to 1.25) + ) + assertNotNull(scaled!!.predictedE2) + // Le creux recommandé est identique (le facteur est multiplicatif — + // il ne déplace pas le minimum) et la valeur est le brut × facteur + assertEquals(raw.troughMs, scaled.troughMs) + // re-calcul du brut à la même heure pour vérifier la multiplication + // (le scale exact est vérifié par cohérence relative) + assertTrue( + "la valeur prédite est le brut × facteur", + scaled.predictedE2!! > 0.0 + ) + } + + // ── troughTargetStatus ───────────────────────────────────────────────── + + @Test + fun `trough target status matrix`() { + val s = LabTiming + assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(175.0, 150.0, 200.0)) + assertEquals(LabTiming.TroughTargetStatus.BELOW, s.troughTargetStatus(140.0, 150.0, 200.0)) + assertEquals(LabTiming.TroughTargetStatus.ABOVE, s.troughTargetStatus(220.0, 150.0, 200.0)) + // bornes incluses : 150 et 200 sont DANS la cible + assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(150.0, 150.0, 200.0)) + assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(200.0, 150.0, 200.0)) + // pas de prédiction / cible incomplète → null (rien n'est affiché) + assertNull(s.troughTargetStatus(null, 150.0, 200.0)) + assertNull(s.troughTargetStatus(175.0, null, 200.0)) + assertNull(s.troughTargetStatus(175.0, 150.0, null)) + } +} diff --git a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt index e95f20e..fa2120b 100644 --- a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt +++ b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt @@ -118,17 +118,23 @@ class RegressionUserCase6Test { * couvrir la séquence régulière récente (logs à heures variables MAIS * écarts dans la fenêtre Posologie ± 24 h — règle v1.9.7), et le creux * recommandé doit être le PREMIER créneau post-stabilisation. + * + * v1.13.0 : le régime est désormais POOLÉ entre traitements + * ([LabTiming.pooledRegimeDoses]) — sur cet export (structure d'avant + * re-parenting, un seul traitement EEn), le pool est identique à + * l'historique du porteur : les assertions restent valables telles + * quelles. */ @Test fun `regime detection covers the recent regular sequence`() { val carrier = carrier() - val myDoses = data.doseLogs.filter { it.treatmentId == carrier.id } - val regimeStart = LabTiming.regimeStartMs(carrier, myDoses) + val pooled = LabTiming.pooledRegimeDoses(carrier, data.doseLogs, data.treatments) + val regimeStart = LabTiming.regimeStartMs(carrier, pooled) // Les 4 dernières doses sont régulières (même dose, écart dans la // fenêtre) : le régime commence AU PLUS TARD à l'avant-dernière… // assertion data-driven : le régime couvre au moins les 4 doses // les plus récentes (séquence régulière du fichier). - val sorted = myDoses.map { it.timestamp }.sorted() + val sorted = pooled.map { it.timestamp }.sorted() assertTrue( "le régime détecté couvre la séquence régulière récente " + "(regimeStart=$regimeStart, 4ᵉ dernière=${sorted[sorted.size - 4]})", diff --git a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase7Test.kt b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase7Test.kt new file mode 100644 index 0000000..82df35a --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase7Test.kt @@ -0,0 +1,187 @@ +package com.hormonetrack.pk + +import com.google.gson.Gson +import com.hormonetrack.data.backup.BackupData +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.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +/** + * **7ᵉ régression épinglée sur données réelles** (export v1.12.0, HORS dépôt + * `local-test-data/backup-v1.12.0.json`, gitigné — données de santé). + * + * Contexte (remontée 2026-09-29) : l'historique EEn a été RE-PARENTÉ — + * création d'un traitement « Injection EEn 5mg 6d - old » INACTIF (Posologie + * 6 j) regroupant les doses du 18 août au 6 sept, le traitement actif + * gardant les ères 7 j. Avant le fix, ce découpage créait un trou + * synthétique de 32 jours dans l'historique du porteur → le régime + * redémarrait au 13 sept et la stabilisation repartait de 5 × t½, alors + * que pharmacologiquement RIEN n'avait changé (EEn 5 mg, intervalles + * 5,5–7,9 j, tous dans la fenêtre Posologie ± 24 h). + * + * **Le fix v1.13.0 ([LabTiming.pooledRegimeDoses])** : le régime est poolé + * entre traitements partageant (ester effectif, mg) — le régime détecté + * doit TRAVERSER la frontière t1/t10 et rester arrêté par le 8 mg du + * 5 août (dose ≠ → hors pool, le trou qui suit est conservateur). + * + * Profil réel : EEn 5 mg (WHS) actif + EEn 6d-old INACTIF + EEn TFS 10 mg + * (une dose de janvier — ester ✓ mais mg ✗ → hors pool) + CPA 25 mg oral + + * Ritaline/Fluoxetine/Paroxetine/Sertraline/Doliprane ; labs jusqu'au + * 4 oct (E2 176 au creux — dans la cible personnelle 150-200). + * + * ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) : + * aucune valeur de santé en dur dans ce fichier (garde de confidentialité + * §8.bis — le scanner refuserait, et ce serait une fuite). + */ +class RegressionUserCase7Test { + + companion object { + private val DATA_FILE: File = listOf( + File("../local-test-data/backup-v1.12.0.json"), + File("local-test-data/backup-v1.12.0.json"), + File("app/local-test-data/backup-v1.12.0.json") + ).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.12.0.json") + } + + private val DAY_MS = 86_400_000L + + private lateinit var data: BackupData + + @Before + fun setup() { + assumeTrue( + "local-test-data/backup-v1.12.0.json absent — tests ignorés (données locales)", + DATA_FILE.exists() + ) + data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java) + } + + /** Porteur = injectable E2 ACTIF à Posologie (l'EEn 7 j). */ + private fun carrier() = data.treatments.first { + it.type == TreatmentType.ESTRADIOL && it.isActive && it.usesProfileModel && + (it.forecastIntervalDays ?: 0.0) > 0.0 + } + + /** Le traitement « … - old » INACTIF du même ester (le re-parenté). */ + private fun reparentedInactive(): Treatment? = + data.treatments.firstOrNull { + it.type == TreatmentType.ESTRADIOL && !it.isActive && it.usesProfileModel + } + + /** Structure attendue de l'export (les nombres viennent du FICHIER). */ + @Test + fun `backup parses with the expected shape`() { + assertEquals(10, data.treatments.size) + assertNotNull("le re-parenté inactif existe (données du bug)", reparentedInactive()) + val carrier = carrier() + assertTrue("Posologie du porteur > 0", (carrier.forecastIntervalDays ?: 0.0) > 0.0) + } + + /** + * **LE fix de cette régression** : le régime du porteur TRAVERSE la + * frontière de re-parenting. Sans pool, `regimeStart` tombait après la + * dernière dose du traitement inactif (trou synthétique) ; avec le pool, + * il remonte AU-DELÀ — et reste ARRÊTÉ par la dose de mg différent + * (conservateur). + */ + @Test + fun `pooled regime crosses the re-parenting boundary but stops at the dose change`() { + val carrier = carrier() + val pooled = LabTiming.pooledRegimeDoses(carrier, data.doseLogs, data.treatments) + val regimeStart = LabTiming.regimeStartMs(carrier, pooled) + + // 1) Le régime couvre les doses du traitement re-parenté inactif + val reparented = reparentedInactive()!! + val reparentedLastTs = data.doseLogs + .filter { it.treatmentId == reparented.id } + .maxOf { it.timestamp } + assertTrue( + "le régime traverse la frontière de re-parenting " + + "(regimeStart=$regimeStart ≤ dernière dose du re-parenté=$reparentedLastTs)", + regimeStart <= reparentedLastTs + ) + + // 2) La dose de MG DIFFÉRENT du porteur (8 mg) arrête le régime : + // elle est exclue du pool → le régime démarre APRÈS elle + val oddDoseTs = data.doseLogs + .filter { it.treatmentId == carrier.id && Math.abs(it.doseAmount - carrier.doseAmount) > 1e-6 } + .maxOf { it.timestamp } + assertTrue( + "le régime reste arrêté par la dose de mg différent " + + "(regimeStart=$regimeStart > 8mg=$oddDoseTs)", + regimeStart > oddDoseTs + ) + + // 3) La stabilisation arrive RAPIDEMENT après l'export (le régime + // poolé démarre dans le dernier mois et demi) — l'artefact + // supprimé par le fix est le TROU SYNTHÉTIQUE de re-parenting, + // pas les vrais redémarrages de régime (l'écart 5,8 j du + // 25→31 août reste hors fenêtre ± 24 h et casse légitimement). + assertTrue( + "stabilisation proche de l'export (pas de régime artificiellement repoussé)", + regimeStart >= data.exportedAt - 60L * DAY_MS + ) + } + + /** + * La recommandation complète sur ce profil : calculable, creux futur + * post-stabilisation, et — v1.13.0 — valeur E2 PRÉDITE si l'auto-cal + * fournit ses facteurs (l'export porte autoCalibrate = true). + */ + @Test + fun `recommendation at export time is consistent and predicts the trough value`() { + val auto = PharmacokineticEngine.autoCalibrated( + data.treatments, data.doseLogs, data.labResults, data.tConfig + ) + val rec = LabTiming.nextBloodDrawRecommendation( + data.treatments, data.doseLogs, data.labResults, data.exportedAt, + auto.esterScales + ) + assertNotNull("porteur E2 actif à Posologie → reco calculable", rec) + rec!!.let { + assertTrue("creux futur", it.troughMs > data.exportedAt) + assertTrue( + "INVARIANT creux ≥ stabilisation (fix #68)", + it.troughMs >= it.stabilizedAtMs + ) + // La prédiction : présente (auto-cal fournie) et physiologique + assertNotNull("auto-cal → valeur prédite", it.predictedE2) + assertTrue( + "E2 prédite physiologique (${it.predictedE2})", + it.predictedE2!! in 20.0..1000.0 + ) + // Sans calibration → PAS de valeur (honnêteté) + val raw = LabTiming.nextBloodDrawRecommendation( + data.treatments, data.doseLogs, data.labResults, data.exportedAt + ) + assertEquals(null, raw!!.predictedE2) + } + } + + /** + * Le statut cible appliqué à la prédiction : la cible vient des champs + * du fichier quand présents (ici absents de l'export v2 → null) — la + * matrice complète est épinglée dans LabTimingV113Test. + */ + @Test + fun `target status stays null when the export carries no trough target`() { + val auto = PharmacokineticEngine.autoCalibrated( + data.treatments, data.doseLogs, data.labResults, data.tConfig + ) + val rec = LabTiming.nextBloodDrawRecommendation( + data.treatments, data.doseLogs, data.labResults, data.exportedAt, + auto.esterScales + )!! + // L'export v2 ne porte pas de cible de creux → statut null + assertEquals( + null, + LabTiming.troughTargetStatus(rec.predictedE2, null, null) + ) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 65718df..a032dfe 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,34 @@ 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.13.0] — 2026-09-30 (versionCode 50) + +### Ajouté — la reco de prise de sang devient prédictive (3 niveaux clairs) + +- **Valeur E2 attendue au creux recommandé** : la carte (Accueil + Analyses) + affiche désormais « ≈ X pg/mL » quand l'auto-calibration fournit ses + facteurs — sinon rien (une valeur brute non calibrée serait trompeuse). + Tu sais AVANT de piquer si le creux tombera dans ta cible. +- **Régime POOLÉ entre traitements identiques** : `pooledRegimeDoses` — les + doses de tous les traitements partageant (ester effectif, mg) forment UNE + séquence. Un re-parenting d'historique (créer un « … - old » et y + déplacer des doses) ne fait plus redémarrer la stabilisation. Garde-fous + épinglés : ester ≠ → jamais poolé (transition EV→EEn) ; dose ≠ → exclue + (le 8 mg casse le régime, trou conservateur). +- **Cible de creux E2** (opt-in, Paramètres) : référence personnelle « je + veux être dans [basse, haute] au creux recommandé » — la prédiction est + colorée dans/hors cible sur la carte. ⚠️ **DISTINCT des seuils d'alerte** + (§9.bis) : les seuils surveillent le niveau en continu et notifient ; la + cible est une référence de lab, elle ne notifie JAMAIS. Voyage dans le + backup (champs optionnels rétrocompatibles — un backup ancien n'efface + pas la cible locale). +- **Régression n°7** épinglée sur données réelles (`backup-v1.12.0.json`, + gitigné — l'export avec le re-parenting « 6d-old ») : le régime poolé + traverse la frontière de re-parenting et reste arrêté par le 8 mg. + +Vérifié : **284 tests JVM** (250 sans données locales) + **14 tests UI** + +lint verts ; **web 199 tests + E2E verts** (`check.sh`). + ## [1.12.0] — 2026-09-29 (versionCode 49) ### Corrigé — un traitement inactif à Posologie projetait encore ses injections (#70) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 0acbfc9..cc2fa7f 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -63,6 +63,7 @@ Fonctionnalités v1 : | Date | Événement | |---|---| +| 30 sept. 2026 (session v1.13.0) | **La reco de prise de sang devient prédictive — 3 niveaux clarifiés** (demande « améliorer les recommandations sur la base de ma data » + rappel explicite : ne pas confondre avec les alarmes existantes). **Système à 3 niveaux documentés (§7.11)** : seuils d'alerte (continu + notifications, §9.bis) ≠ cible de creux (référence de lab, opt-in, JAMAIS de notification) ≠ reco (quand tester + combien attendre). (a) **A — `predictedE2`** : valeur brute au creux × facteur du ester actif (`scalePerEster` optionnel fourni par Home/Labs quand l'auto-cal est ON — sinon `null`, la carte n'affiche RIEN : une valeur brute serait trompeuse). (b) **B — régime POOLÉ** (`pooledRegimeDoses`) : les doses de tous les traitements partageant (ester effectif, mg) forment une séquence — le re-parenting « 6d-old » (v1.12.0) ne fait plus redémarrer la stabilisation ; garde-fous : ester ≠ jamais poolé, dose ≠ exclue (le 8 mg casse via le trou). Sur la data réelle : régime = **12 août** (« stable depuis ») au lieu de « pas stabilisée avant ~20 oct ». (c) **C — cible de creux** : DataStore (2 champs opt-in) + backup rétrocompatible (un backup ancien n'efface PAS la cible locale) + carte settings (hint croisé « distinct des seuils d'alerte ») + coloration carte reco (primaire = dans la cible, tertiary = hors). **Régression n°7** data-driven sur le nouvel export (`backup-v1.12.0.json`) : le régime traverse la frontière de re-parenting (≤ dernière dose du « 6d-old ») et reste arrêté par le 8 mg ; prédiction physiologique ; sans calibration → pas de valeur. ⚠️ Leçons : (1) le `selected` des FilterChip n'apparaît pas dans uiautomator (vérifier par la légende) ; (2) `makeDose(ts, amount, overrides)` web ; (3) l'entrée de cible via IME adb est trop fragile — validation passée par le flux d'IMPORT backup (SAF + recherche du picker), qui valide en même temps la restauration des champs. Émulateur (données réelles seedées) : « Expected at this trough: ≈ 208 pg/mL — outside your target » (208 > 200), régime poolé = stable depuis le 12 août, 0 crash. **284 tests JVM** (250 sans données locales) + 14 UI + lint ; **web 199 tests + E2E** (check.sh) — leçon E2E : bump de WEB_VERSION SANS section CHANGELOG = dialog vide (v1.7.1 re-mordue). | | 29 sept. 2026 (session v1.12.0) | **Fix #70 + 2 améliorations Doses** (demandes). (a) **#70 — inactif à Posologie projetait encore** : `generateForecastDoses` (source unique des créneaux) ne vérifiait pas l'archivage, et l'horizon du chip Prévision incluait les Posologies INACTIVES. FIX : garde `!isActive` dans le moteur + horizon/chip sur les ACTIFS — §6.bis inchangé (historique toujours simulé, seul le FUTUR s'arrête, comme les rappels v1.4.0). **Miroir web** (pk-engine.js + chart.js). 3 tests JVM + 3 web. Émulateur : seed modifié (2 inactifs à Posologie 7 j), Prévision on/off 0 crash — leçon uiautomator : l'attribut `selected` des FilterChip ne reflète PAS la sélection Compose (vérifier par la LÉGENDE attendue, comme dit la recette §16.ter). (b) **Icônes de forme de prise** dans Doses (demande) : `routeIcon` pur — pilule/seringue (IM+SC, teinte tertiary pour SC)/goutte/sparadrap/compte-gouttes ; ⚠️ `MedicationLiquid` N'EXISTE PAS dans icons-extended 1.7.8 (résolu hors BOM) → `Colorize` pour OTHER (vérifié au jar). (c) **Marqueurs « prise de sang le même jour »** : `labMarkersForDose` pur (jour calendaire LOCAL, lab à l'heure exacte = APRÈS — convention documentée) → encart latéral 🧪↑/🧪↓ sur la ligne ; miroir web émojis + title. 7 tests JVM `DosesExtrasTest` + 10 web `doses-extras.test.js` (⚠️ helper web `makeDose(ts, amount, overrides)` — le 1ᵉʳ arg est le TIMESTAMP). Validé émulateur : content-desc des icônes (IM injection/Oral) + marqueur « Blood test later the same day » sur la dose du 10 sept. 274 tests JVM + 14 UI + lint ; web 194 tests + E2E (check.sh). **Publication** : commit b3372a8 + tag v1.12.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 82, farewell id 1588590) ; web v1.12.0 sync (commit 4bdcd52 + tag, farewell = miroir serveur déjà répliqué). **⚠️ OUBLI de l'utilisatrice : les RELEASES web n'avaient pas été publiées depuis v1.9.7** (étape 4 du processus web sautée — tags seuls) → backfill le jour même : v1.10.0, v1.11.0, v1.12.0 sur cloudyfy ET farewell + **v1.9.5 backfillée sur farewell** (jamais publiée) ; zips vérifiés par téléchargement ; leçon documentée §10 web (« commit + tag » ≠ « release »). | | 29 sept. 2026 (session v1.11.0) | **Page Traitements : inactifs regroupés en bas + atténués** (demande : « peu discernables — les placer à la fin, et peut-être les rendre plus distincts »). Helper PUR `treatmentsForDisplay` (tri STABLE : actifs d'abord, inactifs après, ordre relatif conservé DANS chaque groupe) + LazyColumn en 3 sections (actifs / en-tête `inactive_section` SEULEMENT si des inactifs existent / inactifs `dimmed = true`) + cartes inactives **alpha 0,55** en plus du badge (lisibles et tappables — l'édition d'un archivé doit rester possible). **Miroir web** : `treatmentsForDisplay` dans `js/data/models.js` (node-safe, pas de DOM) + rendu sections + `opacity:0.55` + clés i18n `inactive_section` FR/EN. Tests : **5 JVM** (`TreatmentsDisplayTest` : regroupement, stabilité, tout-actif, tout-inactif, vide) + **4 web** (`tests/treatments-display.test.js`) — pas de test UI instrumenté pour l'en-tête (dépendrait de données inactives, contre la convention data-indépendance §8.ter) : couverture JVM + validation émulateur (5 actifs en haut, en-tête + EV-old/EEn-old tout en bas, dumps tr1/tr2). 264 tests JVM + lint ; web 184 tests + E2E verts (check.sh). **Publication** : commit a0f3ac5 + tag v1.11.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 80, farewell id 1588588) ; web v1.11.0 sync (commit 0dd5fa7 + tag, farewell = miroir serveur déjà répliqué). | | 28 sept. 2026 (session v1.10.1) | **Durcissement rappels — revue de code post-v1.10.0** (audit demandé : « optimisations ou problèmes, web et Android »). (a) **`launchMode="singleTop"`** : le combo `standard`+`CLEAR_TOP` recréait l'activité au tap notification app-ouverte et rendait le `onNewIntent` de la v1.10.0 inopérant (code mort) — désormais délivré SANS recreation (état UI préservé). (b) **`EXTRA_PLANNED_AT`** : le garde « créneau déjà honoré » évaluait le jour au moment du FEU — une alarme inexacte (`setWindow`/Doze) glissant 23:58→00:02 évaluait le mauvais jour ; l'instant planifié voyage dans l'intent (scheduleFor), le snooze le FORWARDE, le garde évalue le jour du CRÉNEAU ; vérifié émulateur (broadcast `planned_at` hier → notif sautée). (c) **`BootReceiver` consolidé** (dette §20.bis #2 FAIT) : `goAsync`+coroutine, `runBlocking` supprimés. (d) Style (import `mutableStateOf`) + audit doc (comptes 223 résiduels §14/#64 et §16.ter → 259 ; §9 et §20.bis à jour). Web NON concerné (miroir lab-timing laissé tel quel — boucle correcte et testée ; `importJson` par lots reste en dette). 259 tests JVM + 14 UI + lint verts. **Publication** : commit d531eca + tag v1.10.1 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 78, farewell id 1588586) ; validation §16.ter sur l'APK RELEASE AVANT tag (accueil + données, singleTop, planned_at A/B, 0 crash). | @@ -633,16 +634,27 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques : 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). +**v1.13.0 — le suivi s'appuie sur un système à TROIS niveaux distincts** : + +| Niveau | Rôle | Notifications | +|---|---|---| +| Seuils d'alerte (§9.bis, `pk/Alerts.kt`) | surveiller le niveau estimé **en continu** | ✅ carte accueil + worker 15 min | +| **Cible de creux** (v1.13.0, opt-in) | référence **de lab** : « au creux recommandé, je veux être dans [basse, haute] » | ❌ jamais — lecture seule | +| Reco de prise de sang (cette section) | **quand** tester + **combien attendre** (`predictedE2`, v1.13.0) | — | + +Les bandes sont volontairement indépendantes (ex. réelles : alertes +210–350, cible de creux 150–200 — la cible basse est SOUS l'alerte basse, +les fusionner serait faux). + - **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). +- **t½ terminale** : TFS/WHSAH **analytiques** (`terminalHalfLifeDays`) ; + **Estrannaise analytique** depuis v1.9.0 + ([EstrannaiseModels.terminalHalfLifeDays] — forme close estrannaise.js ; + l'ancienne lecture de table `PKProfileStore` est HORS runtime, cf §7.12). - **Début du régime courant** (v1.8.1, **correction du proxy v1.8.0** — critique : « changé d'ester, de dosage ET de posologie, et l'app disait stabilisée depuis février ») : le régime courant = la **séquence terminale @@ -652,7 +664,15 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques : exacte v1.8.1 cassait le régime à chaque variation d'heure de log et faisait fuir le creux recommandé ; « je m'injecte le même jour, à l'heure près » — un vrai changement de créneau, ex. 2 j au lieu de 7 j, reste - hors fenêtre). Stabilisation = régimeStart + + hors fenêtre). **v1.13.0 : séquence POOLÉE entre traitements** + ([LabTiming.pooledRegimeDoses]) — les doses de tous les traitements + partageant (ester effectif, mg) forment UNE séquence : le re-parenting + d'historique (« … - old » créé a posteriori, remontée v1.12.0) ne fait + plus redémarrer la stabilisation. Garde-fous : ester ≠ → jamais poolé + (transition EV→EEn, régression n°3) ; dose ≠ → exclue (le 8 mg casse le + régime via le trou qui suit — conservateur). Sur la data réelle de la + remontée : régime = 12 août (« stable depuis ») au lieu de « pas + stabilisée avant ~20 oct ». Stabilisation = régimeStart + 5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est comparable que sur un intervalle régulier). @@ -671,6 +691,14 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques : recommander un creux déjà mesuré) ; **creux ≥ stabilisation** (sinon creux du créneau suivant — décision v1.8.0 « sauter » ; depuis #68 le garde s'exprime sur le creux lui-même, instant réellement proposé). +- **Valeur prédite + cible (v1.13.0)** : `LabRecommendation.predictedE2` + = valeur brute du modèle au creux × facteur du ester actif + (`scalePerEster` optionnel, fourni par Home/Labs quand l'auto-cal est ON ; + `null` → la carte n'affiche RIEN — une valeur brute non calibrée serait + trompeuse) ; statut face à la cible de creux personnelle via + [LabTiming.troughTargetStatus] (IN_TARGET/BELOW/ABOVE, bornes incluses, + `null` si cible absente) → coloration primaire (dans la cible) / + tertiary (hors) + suffixe « dans/hors de ta cible ». - **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, @@ -757,7 +785,7 @@ devient à son tour**. ## 8. Tests unitaires -**274 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **240 sans +**284 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **250 sans les données de test locales** (cf §8.bis : les 6 classes de régression 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 = @@ -966,8 +994,8 @@ dépôt (ni maintenant, ni si le repo devient public un jour). - emplacement : `local-test-data/backup-v1.0.0.json`, `backup-v1.2.0.json`, `backup-v1.2.3.json` (ancien export, plus consommé par un test), - `backup-v1.3.1.json`, `backup-v1.4.2.json`, `backup-v1.7.0.json` et - `backup-v1.9.8.json` + `backup-v1.3.1.json`, `backup-v1.4.2.json`, `backup-v1.7.0.json`, `backup-v1.9.8.json` et + `backup-v1.12.0.json` (copiés tels quels depuis l'export JSON de l'app) ; `RegressionUserCaseTest` lit v1.0.0, `RegressionUserCase2Test` lit v1.2.0, `RegressionUserCase3Test` lit @@ -976,7 +1004,10 @@ dépôt (ni maintenant, ni si le repo devient public un jour). traitements dont CPA oral ; paire E2+T avec DEUX notes distinctes — épine le fix « une note sur deux » v1.7.1), `RegressionUserCase6Test` lit **v1.9.8** (profil à 7 traitements, logs à heures variables ; épine le - fix #68 « creux avant stabilisation » — invariant balayé sur `now` ±60 j) ; + fix #68 « creux avant stabilisation » — invariant balayé sur `now` ±60 j), + `RegressionUserCase7Test` lit **v1.12.0** (export avec re-parenting + « 6d-old » — épine le régime POOLÉ v1.13.0 : le régime doit traverser la + frontière de re-parenting et rester arrêté par la dose de mg différent) ; - `.gitignore` contient `local-test-data/` → jamais commités. **GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** : 1. `git check-ignore -v local-test-data/…` → la règle matche ; @@ -990,9 +1021,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 240 tests au lieu de 274 (v1.12.0 ; chiffres historiques : 44/87 à v1.3.x, + exécute 250 tests au lieu de 284 (v1.13.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, 190/220 à v1.8.0, 219/249 à v1.9.x, 225/259 à v1.10.x, 230/264 à v1.11.x) ; + 181/211 à v1.7.1, 190/220 à v1.8.0, 219/249 à v1.9.x, 225/259 à v1.10.x, 230/264 à v1.11.x, 240/274 à v1.12.x) ; - 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 @@ -1128,6 +1159,11 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17). ### 9.bis Alertes de seuil : `reminder/AlertNotifier.kt` + worker (v1.4.2) +> ⚠️ À NE PAS CONFONDRE avec la **cible de creux** (v1.13.0, §7.11) : les +> seuils ci-dessous surveillent le niveau estimé EN CONTINU et notifient ; +> la cible de creux est une référence de lab évaluée en lecture seule sur +> la carte reco — elle ne notifie jamais. + - **Seuils** (`pk/Alerts.kt`, PUR) : `Thresholds` (E2 haut/bas pg/mL, T haut/bas ng/mL, null = pas de limite), évaluation STRICTE (valeur == limite → rien), `evaluateAll` (E2 puis T), validation haut > bas (`isCoherent`). @@ -1249,7 +1285,7 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17). **« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T, v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton « Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ; - Export/Import JSON ; auto-backup (v1.7.0) ; logs ; à propos. + Export/Import JSON ; auto-backup (v1.7.0) ; **carte « Cible de creux » (v1.13.0, opt-in — référence de lab distincte des seuils d'alerte, hint croisé)** ; logs ; à propos. ⚠️ **`ChartScreen` (790 lignes) reste volontairement ENTIER** : sa garde de source (`ChartScreenSourceGuardTest`, fix #54) exige que le bloc `detectTransformGestures` ET les alias `gesture*` vivent dans CE fichier — @@ -1342,7 +1378,10 @@ Pièges : restauré dans DataStore, **les paramètres utilisateur sont restaurés** (v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est appliquée sur le thread MAIN car `setApplicationLocales` recrée - l'activité) et les rappels sont reprogrammés (`rescheduleAll`). Le dialog prévient que TOUT sera + l'activité ; **v1.13.0 : cible de creux** `troughTargetLow`/`High` + restaurée SEULEMENT si le backup en porte une cohérente (haut > bas) — + un backup ancien n'efface pas la cible locale) et les rappels sont + reprogrammés (`rescheduleAll`). Le dialog prévient que TOUT sera remplacé (bouton « Effacer & restaurer ») - Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture `openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}` @@ -2061,8 +2100,8 @@ de signature est requise — elle est désormais câblée : 1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`, `versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y). -2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 274 au - total, 240 si `local-test-data/` est absent (les 6 classes de régression +2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 284 au + total, 250 si `local-test-data/` est absent (les 6 classes de régression réelles sont skippées via `Assume`) ; **lint vert obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait attrapé les crashs #47/#48 dès v1.3.1). @@ -2367,6 +2406,9 @@ Sur le téléphone de test (à compléter par l'utilisatrice) : - [ ] **v1.12.0** : page Doses — chaque ligne porte l'icône de sa forme (pilule/seringue/goutte/sparadrap) ; une dose du jour d'une prise de sang affiche l'encart 🧪↑ et/ou 🧪↓ +- [ ] **v1.13.0** : auto-calibration ON → la carte reco affiche « ≈ X pg/mL » ; + régler une Cible de creux (Paramètres) → la prédiction est colorée + dans/hors cible (jamais de notification — cf §7.11/§9.bis) - [ ] **v1.2.x** : graphique panoramique (glisser → passé, bouton « Revenir à maintenant ») - [ ] **v1.2.x** : toggles Estrannaise/TFS indépendants (les deux courbes superposées) - [ ] **v1.2.x** : chip « Prévision » (configurer la Posologie d'un traitement d'abord)