v1.13.0 : reco prédictive — valeur E2 au creux, régime poolé entre traitements identiques, cible de creux personnelle
- predictedE2 : valeur brute au creux × facteur du ester actif — fournie par Home/Labs quand l'auto-calibration est ON (sinon null : la carte n'affiche rien, une valeur brute serait trompeuse). Miroir web. - Régime POOLÉ (pooledRegimeDoses) : les doses de tous les traitements partageant (ester effectif, mg) forment UNE séquence — le re-parenting d'historique (« 6d-old », v1.12.0) ne fait plus redémarrer la stabilisation. Garde-fous : ester ≠ jamais poolé (régression n°3) ; dose ≠ exclue (le 8 mg casse via le trou). Régression n°7 data-driven sur le nouvel export (backup-v1.12.0.json). - Cible de creux E2 (opt-in) : DataStore + carte settings (hint croisé « distinct des seuils d'alerte — jamais de notification ») + coloration carte reco (primaire/hors cible tertiary) + backup rétrocompatible (un backup ancien n'efface pas la cible locale). Système à 3 niveaux documenté §7.11. Miroir web complet. 284 tests JVM (250 sans données locales) + 14 UI + lint verts ; web 199 tests + E2E verts (check.sh). Validé émulateur sur données réelles : « Expected at this trough: ≈ 208 pg/mL — outside your target », régime poolé = stable depuis le 12 août, 0 crash.
This commit is contained in:
parent
4eb8519d9d
commit
688d196698
11
README.en.md
11
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
|
> for informational purposes — they are not measurements. Always rely on your
|
||||||
> blood tests and on your endocrinologist's guidance.
|
> blood tests and on your endocrinologist's guidance.
|
||||||
|
|
||||||
- **Status**: v1.12.0 — Android build ✅, **lint clean** ✅, **274 unit tests** ✅
|
- **Status**: v1.13.0 — Android build ✅, **lint clean** ✅, **284 unit tests** ✅
|
||||||
(240 without the local test data; 6 regressions pinned on real data
|
(250 without the local test data; 7 regressions pinned on real data
|
||||||
**not committed to the repo**), **14 Compose UI tests** ✅ (emulator),
|
**not committed to the repo**), **14 Compose UI tests** ✅ (emulator),
|
||||||
smartwatch integration = notifications ✅, **private Gitea repo + releases
|
smartwatch integration = notifications ✅, **private Gitea repo + releases
|
||||||
with APK** ✅
|
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) —
|
stabilized** (~5 half-lives after your last change — dose, ester or interval) —
|
||||||
derived from the curves and the Dosage interval; requires an active injectable
|
derived from the curves and the Dosage interval; requires an active injectable
|
||||||
E2 with a Dosage (a prompt suggests setting one otherwise); estimates, never
|
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),
|
- **Calibration**: per-treatment scale factor = median(lab ÷ model prediction),
|
||||||
computed automatically (the `.ods` "Scale factor", automated) — or **permanent
|
computed automatically (the `.ods` "Scale factor", automated) — or **permanent
|
||||||
automatic calibration** (option, off by default) which calibrates **each ester
|
automatic calibration** (option, off by default) which calibrates **each ester
|
||||||
@ -134,7 +137,7 @@ Gradle 9.7.1.
|
|||||||
git clone <repo> && cd HormoneTrack
|
git clone <repo> && cd HormoneTrack
|
||||||
echo "sdk.dir=/path/to/android-sdk" > local.properties # or ANDROID_HOME
|
echo "sdk.dir=/path/to/android-sdk" > local.properties # or ANDROID_HOME
|
||||||
./gradlew assembleDebug # APK: app/build/outputs/apk/debug/app-debug.apk
|
./gradlew assembleDebug # APK: app/build/outputs/apk/debug/app-debug.apk
|
||||||
./gradlew testDebugUnitTest # 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 connectedDebugAndroidTest # 14 UI tests (emulator/device required)
|
||||||
./gradlew lint # clean lint required before a release
|
./gradlew lint # clean lint required before a release
|
||||||
```
|
```
|
||||||
|
|||||||
@ -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
|
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
|
||||||
> sang et aux consignes de ton endocrinologue.
|
> sang et aux consignes de ton endocrinologue.
|
||||||
|
|
||||||
- **Statut** : v1.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)
|
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
||||||
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
|
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
|
||||||
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
|
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
|
||||||
@ -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
|
**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
|
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) ;
|
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),
|
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
|
||||||
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
|
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
|
||||||
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
|
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
|
||||||
@ -131,7 +134,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
|
|||||||
git clone <repo> && cd HormoneTrack
|
git clone <repo> && cd HormoneTrack
|
||||||
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
||||||
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
||||||
./gradlew testDebugUnitTest # 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 connectedDebugAndroidTest # 14 tests UI (émulateur/appareil requis)
|
||||||
./gradlew lint # lint vert obligatoire avant release
|
./gradlew lint # lint vert obligatoire avant release
|
||||||
```
|
```
|
||||||
|
|||||||
@ -33,8 +33,8 @@ android {
|
|||||||
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
||||||
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
||||||
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
||||||
versionCode = 49
|
versionCode = 50
|
||||||
versionName = "1.12.0"
|
versionName = "1.13.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
@ -36,7 +36,12 @@ data class UserSettings(
|
|||||||
val alertE2High: Double? = null,
|
val alertE2High: Double? = null,
|
||||||
val alertE2Low: Double? = null,
|
val alertE2Low: Double? = null,
|
||||||
val alertTHigh: 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(
|
data class BackupData(
|
||||||
|
|||||||
@ -77,6 +77,37 @@ object LabTiming {
|
|||||||
/** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */
|
/** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */
|
||||||
const val MIN_HORIZON_HOURS = 6L
|
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. */
|
/** Résultat : tout ce que l'UI affiche sur la carte de recommandation. */
|
||||||
data class LabRecommendation(
|
data class LabRecommendation(
|
||||||
/** Date/heure du CREUX exact (minimum prévisionnel entre 2 injections). */
|
/** 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
|
* arrive entre maintenant et le creux recommandé (texte « c'est le
|
||||||
* premier creux fiable »).
|
* 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<Treatment>,
|
treatments: List<Treatment>,
|
||||||
doseLogs: List<DoseLog>,
|
doseLogs: List<DoseLog>,
|
||||||
labs: List<LabResult>,
|
labs: List<LabResult>,
|
||||||
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<String, Double>? = null
|
||||||
): LabRecommendation? {
|
): LabRecommendation? {
|
||||||
// ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ─────
|
// ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ─────
|
||||||
val carrier = treatments.firstOrNull {
|
val carrier = treatments.firstOrNull {
|
||||||
@ -138,13 +184,14 @@ object LabTiming {
|
|||||||
// ── 3) Début du régime courant + date de stabilisation ──────────────
|
// ── 3) Début du régime courant + date de stabilisation ──────────────
|
||||||
val myDoses = doseLogs.filter { it.treatmentId == carrier.id }
|
val myDoses = doseLogs.filter { it.treatmentId == carrier.id }
|
||||||
if (myDoses.isEmpty()) return null
|
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 lastDose = myDoses.maxByOrNull { it.timestamp }!!
|
||||||
val activeEster = PharmacokineticEngine.doseEster(carrier, lastDose)
|
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 ──
|
// ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ──
|
||||||
val intervalDays = carrier.forecastIntervalDays!!
|
val intervalDays = carrier.forecastIntervalDays!!
|
||||||
@ -199,9 +246,18 @@ object LabTiming {
|
|||||||
injectionMs = slot.timestamp,
|
injectionMs = slot.timestamp,
|
||||||
ester = activeEster,
|
ester = activeEster,
|
||||||
terminalHalfLifeDays = tHalfDays,
|
terminalHalfLifeDays = tHalfDays,
|
||||||
regimeStartMs = regimeStartMs,
|
regimeStartMs = regimeStart,
|
||||||
stabilizedAtMs = stabilizedAtMs,
|
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
|
windowStart = slot.timestamp
|
||||||
@ -212,6 +268,52 @@ object LabTiming {
|
|||||||
/** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */
|
/** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */
|
||||||
private const val DOSE_EPS = 1e-6
|
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<DoseLog>,
|
||||||
|
allTreatments: List<Treatment>
|
||||||
|
): List<DoseLog> {
|
||||||
|
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
|
* 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,
|
* 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
|
* 12:00, repoussant la stabilisation de 5 × t½ à CHAQUE injection : le
|
||||||
* creux recommandé fuyait vers l'avant sans jamais se fixer.
|
* creux recommandé fuyait vers l'avant sans jamais se fixer.
|
||||||
*
|
*
|
||||||
* **RÈGLE** : le régime courant = la **séquence terminale de doses** où
|
* **RÈGLE** : le régime courant = la **séquence terminale de doses**
|
||||||
* (a) l'**ester effectif** et la **dose (mg)** sont identiques à celles
|
* (v1.13.0 : **poolée** entre traitements — cf [pooledRegimeDoses] — le
|
||||||
* de la dose la plus récente, ET (b) chaque **écart entre doses
|
* 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**
|
* consécutives reste dans l'INTERVALLE DE POSOLOGIE ± 24 h**
|
||||||
* ([GAP_TOLERANCE_MS] — « je m'injecte le même jour, à l'heure près »).
|
* ([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
|
* On remonte depuis la dose la plus récente tant que ces conditions
|
||||||
|
|||||||
@ -79,6 +79,15 @@ class AppSettings(private val context: Context) {
|
|||||||
|
|
||||||
/** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */
|
/** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */
|
||||||
val AUTO_BACKUP_LAST_AT = longPreferencesKey("auto_backup_last_at")
|
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<TConfig> = context.dataStore.data.map { prefs ->
|
val tConfig: Flow<TConfig> = 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<Pair<Double, Double>?> = 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<Double, Double>?) {
|
||||||
|
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é
|
* Persiste les seuils d'alerte (v1.4.2). Les valeurs null EFFACENT la clé
|
||||||
* (DataStore : remove) — « champ vide = pas d'alerte ».
|
* (DataStore : remove) — « champ vide = pas d'alerte ».
|
||||||
|
|||||||
@ -241,14 +241,20 @@ fun HomeScreen(
|
|||||||
// (l'accueil ne fait pas doublon d'explication).
|
// (l'accueil ne fait pas doublon d'explication).
|
||||||
val labRecommendation by produceState(
|
val labRecommendation by produceState(
|
||||||
initialValue = null as LabTiming.LabRecommendation?,
|
initialValue = null as LabTiming.LabRecommendation?,
|
||||||
allTreatments, doseLogs, labResults, tick
|
allTreatments, doseLogs, labResults, tick, effectiveAuto
|
||||||
) {
|
) {
|
||||||
value = withContext(Dispatchers.Default) {
|
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(
|
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 ->
|
labRecommendation?.let { rec ->
|
||||||
val fmtDate = remember {
|
val fmtDate = remember {
|
||||||
DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault())
|
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))),
|
stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))),
|
||||||
style = MaterialTheme.typography.bodyLarge
|
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(
|
Text(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.labrec_before_injection,
|
R.string.labrec_before_injection,
|
||||||
|
|||||||
@ -40,6 +40,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
import com.hormonetrack.data.model.LabResult
|
import com.hormonetrack.data.model.LabResult
|
||||||
import com.hormonetrack.pk.LabTiming
|
import com.hormonetrack.pk.LabTiming
|
||||||
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
|
import com.hormonetrack.pk.TConfig
|
||||||
import com.hormonetrack.ui.LocalAppContainer
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
import com.hormonetrack.ui.components.LabDialog
|
import com.hormonetrack.ui.components.LabDialog
|
||||||
import com.hormonetrack.ui.components.formatLabValue
|
import com.hormonetrack.ui.components.formatLabValue
|
||||||
@ -143,14 +145,30 @@ fun LabsScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val nowMs = remember(tick) { System.currentTimeMillis() }
|
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<PharmacokineticEngine.AutoCalibrated?>(
|
||||||
|
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
|
// Calcul HORS UI thread (produceState) — c'est un calcul PK complet
|
||||||
// (courbe prévisionnelle), même discipline que Home/Chart.
|
// (courbe prévisionnelle), même discipline que Home/Chart.
|
||||||
val recommendation by produceState(
|
val recommendation by produceState(
|
||||||
initialValue = null as LabTiming.LabRecommendation?,
|
initialValue = null as LabTiming.LabRecommendation?,
|
||||||
treatments, doseLogs, labs, nowMs
|
treatments, doseLogs, labs, nowMs, effectiveAuto
|
||||||
) {
|
) {
|
||||||
value = withContext(Dispatchers.Default) {
|
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
|
// 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))),
|
stringResource(R.string.labrec_when, fmt.format(Instant.ofEpochMilli(r.troughMs))),
|
||||||
style = MaterialTheme.typography.bodyLarge
|
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(
|
Text(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.labrec_before_injection,
|
R.string.labrec_before_injection,
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import com.hormonetrack.ui.screens.settings.ChartTimezoneCard
|
|||||||
import com.hormonetrack.ui.screens.settings.LanguageCard
|
import com.hormonetrack.ui.screens.settings.LanguageCard
|
||||||
import com.hormonetrack.ui.screens.settings.LogsCard
|
import com.hormonetrack.ui.screens.settings.LogsCard
|
||||||
import com.hormonetrack.ui.screens.settings.RemindersCard
|
import com.hormonetrack.ui.screens.settings.RemindersCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.TroughTargetCard
|
||||||
import com.hormonetrack.ui.screens.settings.TModelCard
|
import com.hormonetrack.ui.screens.settings.TModelCard
|
||||||
import com.hormonetrack.util.AppLog
|
import com.hormonetrack.util.AppLog
|
||||||
import com.hormonetrack.util.ExportFileNames
|
import com.hormonetrack.util.ExportFileNames
|
||||||
@ -84,6 +85,9 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
val alertThresholds by container.settings.alertThresholds.collectAsState(
|
val alertThresholds by container.settings.alertThresholds.collectAsState(
|
||||||
initial = Alerts.Thresholds()
|
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
|
// 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?)
|
val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?)
|
||||||
|
|
||||||
@ -170,7 +174,11 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
alertE2High = thresholds.e2High,
|
alertE2High = thresholds.e2High,
|
||||||
alertE2Low = thresholds.e2Low,
|
alertE2Low = thresholds.e2Low,
|
||||||
alertTHigh = thresholds.tHigh,
|
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 json = BackupManager.exportJson(repo, tConfig, settings)
|
||||||
val ok = BackupManager.writeBackup(context, uri, json)
|
val ok = BackupManager.writeBackup(context, uri, json)
|
||||||
@ -268,6 +276,8 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
AlertThresholdsCard(alertThresholds, showMessage)
|
AlertThresholdsCard(alertThresholds, showMessage)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
TroughTargetCard(troughTarget, showMessage)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
ChartTimezoneCard(chartTimezone, showMessage)
|
ChartTimezoneCard(chartTimezone, showMessage)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
RemindersCard()
|
RemindersCard()
|
||||||
@ -342,6 +352,18 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
tHigh = us.alertTHigh, tLow = us.alertTLow
|
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) {
|
withContext(Dispatchers.Main) {
|
||||||
us.language?.let { lang ->
|
us.language?.let { lang ->
|
||||||
container.settings.setLanguage(lang)
|
container.settings.setLanguage(lang)
|
||||||
|
|||||||
@ -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<Double, Double>?, 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<String?>(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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -151,6 +151,19 @@
|
|||||||
lignes de doses (↑ = lab avant la dose, ↓ = lab après) -->
|
lignes de doses (↑ = lab avant la dose, ↓ = lab après) -->
|
||||||
<string name="lab_before_dose">Prise de sang plus tôt le même jour, avant cette dose</string>
|
<string name="lab_before_dose">Prise de sang plus tôt le même jour, avant cette dose</string>
|
||||||
<string name="lab_after_dose">Prise de sang plus tard le même jour, après cette dose</string>
|
<string name="lab_after_dose">Prise de sang plus tard le même jour, après cette dose</string>
|
||||||
|
<!-- v1.13.0 : valeur E2 attendue au creux recommandé (affichée seulement
|
||||||
|
si la calibration est disponible) + statut face à la cible de creux -->
|
||||||
|
<string name="labrec_predicted">Attendu à ce creux : ≈ %1$s pg/mL</string>
|
||||||
|
<string name="labrec_in_target">dans ta cible</string>
|
||||||
|
<string name="labrec_out_of_target">hors de ta cible</string>
|
||||||
|
<!-- v1.13.0 : carte Cible de creux (opt-in) — DISTINCT des seuils
|
||||||
|
d'alerte (surveillance continue + notifications) -->
|
||||||
|
<string name="trough_target_section">Cible de creux (prise de sang)</string>
|
||||||
|
<string name="trough_target_hint">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.</string>
|
||||||
|
<string name="trough_target_low">Cible basse (pg/mL)</string>
|
||||||
|
<string name="trough_target_high">Cible haute (pg/mL)</string>
|
||||||
|
<string name="trough_target_incomplete">Remplis les deux champs — ou laisse les deux vides pour désactiver.</string>
|
||||||
|
<string name="trough_target_invalid">La cible haute doit être supérieure à la cible basse.</string>
|
||||||
<string name="no_treatment_hint">Crée d\'abord un traitement (onglet Traitements).</string>
|
<string name="no_treatment_hint">Crée d\'abord un traitement (onglet Traitements).</string>
|
||||||
|
|
||||||
<string name="type_estradiol">Œstradiol</string>
|
<string name="type_estradiol">Œstradiol</string>
|
||||||
|
|||||||
@ -151,6 +151,19 @@
|
|||||||
lignes de doses (↑ = lab avant la dose, ↓ = lab après) -->
|
lignes de doses (↑ = lab avant la dose, ↓ = lab après) -->
|
||||||
<string name="lab_before_dose">Blood test earlier the same day, before this dose</string>
|
<string name="lab_before_dose">Blood test earlier the same day, before this dose</string>
|
||||||
<string name="lab_after_dose">Blood test later the same day, after this dose</string>
|
<string name="lab_after_dose">Blood test later the same day, after this dose</string>
|
||||||
|
<!-- v1.13.0 : valeur E2 attendue au creux recommandé (affichée seulement
|
||||||
|
si la calibration est disponible) + statut face à la cible de creux -->
|
||||||
|
<string name="labrec_predicted">Expected at this trough: ≈ %1$s pg/mL</string>
|
||||||
|
<string name="labrec_in_target">within your target</string>
|
||||||
|
<string name="labrec_out_of_target">outside your target</string>
|
||||||
|
<!-- v1.13.0 : carte Cible de creux (opt-in) — DISTINCT des seuils
|
||||||
|
d'alerte (surveillance continue + notifications) -->
|
||||||
|
<string name="trough_target_section">Trough target (blood draw)</string>
|
||||||
|
<string name="trough_target_hint">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.</string>
|
||||||
|
<string name="trough_target_low">Target low (pg/mL)</string>
|
||||||
|
<string name="trough_target_high">Target high (pg/mL)</string>
|
||||||
|
<string name="trough_target_incomplete">Fill both fields — or leave both empty to disable.</string>
|
||||||
|
<string name="trough_target_invalid">Target high must be greater than target low.</string>
|
||||||
<string name="no_treatment_hint">Create a treatment first (Treatments tab).</string>
|
<string name="no_treatment_hint">Create a treatment first (Treatments tab).</string>
|
||||||
|
|
||||||
<string name="type_estradiol">Estradiol</string>
|
<string name="type_estradiol">Estradiol</string>
|
||||||
|
|||||||
@ -124,6 +124,34 @@ class BackupGsonTest {
|
|||||||
assertEquals(0.3, s.alertTLow!!, 1e-9)
|
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
|
@Test
|
||||||
fun `legacy v1 backup without settings parses with null settings (backward compat)`() {
|
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 :
|
// Les anciens exports (v1.4.1 et avant) n'ont PAS de champ settings :
|
||||||
|
|||||||
166
app/src/test/java/com/hormonetrack/pk/LabTimingV113Test.kt
Normal file
166
app/src/test/java/com/hormonetrack/pk/LabTimingV113Test.kt
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -118,17 +118,23 @@ class RegressionUserCase6Test {
|
|||||||
* couvrir la séquence régulière récente (logs à heures variables MAIS
|
* 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
|
* é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.
|
* 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
|
@Test
|
||||||
fun `regime detection covers the recent regular sequence`() {
|
fun `regime detection covers the recent regular sequence`() {
|
||||||
val carrier = carrier()
|
val carrier = carrier()
|
||||||
val myDoses = data.doseLogs.filter { it.treatmentId == carrier.id }
|
val pooled = LabTiming.pooledRegimeDoses(carrier, data.doseLogs, data.treatments)
|
||||||
val regimeStart = LabTiming.regimeStartMs(carrier, myDoses)
|
val regimeStart = LabTiming.regimeStartMs(carrier, pooled)
|
||||||
// Les 4 dernières doses sont régulières (même dose, écart dans la
|
// 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…
|
// 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
|
// assertion data-driven : le régime couvre au moins les 4 doses
|
||||||
// les plus récentes (séquence régulière du fichier).
|
// 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(
|
assertTrue(
|
||||||
"le régime détecté couvre la séquence régulière récente " +
|
"le régime détecté couvre la séquence régulière récente " +
|
||||||
"(regimeStart=$regimeStart, 4ᵉ dernière=${sorted[sorted.size - 4]})",
|
"(regimeStart=$regimeStart, 4ᵉ dernière=${sorted[sorted.size - 4]})",
|
||||||
|
|||||||
187
app/src/test/java/com/hormonetrack/pk/RegressionUserCase7Test.kt
Normal file
187
app/src/test/java/com/hormonetrack/pk/RegressionUserCase7Test.kt
Normal file
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,34 @@
|
|||||||
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
|
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
|
||||||
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
|
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
|
||||||
|
|
||||||
|
## [1.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)
|
## [1.12.0] — 2026-09-29 (versionCode 49)
|
||||||
|
|
||||||
### Corrigé — un traitement inactif à Posologie projetait encore ses injections (#70)
|
### Corrigé — un traitement inactif à Posologie projetait encore ses injections (#70)
|
||||||
|
|||||||
@ -63,6 +63,7 @@ Fonctionnalités v1 :
|
|||||||
|
|
||||||
| Date | Événement |
|
| 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.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é). |
|
| 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). |
|
| 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½
|
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).
|
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`) +
|
- **Traitement porteur** : ESTRADIOL + injectable (`usesProfileModel`) +
|
||||||
**actif** + Posologie > 0. Sinon `null` → carte cachée ; et si un
|
**actif** + Posologie > 0. Sinon `null` → carte cachée ; et si un
|
||||||
injectable E2 actif est SANS Posologie, l'UI affiche l'**invite**
|
injectable E2 actif est SANS Posologie, l'UI affiche l'**invite**
|
||||||
([LabTiming.shouldSuggestPosology], demande v1.8.0 : rendre découvrable le
|
([LabTiming.shouldSuggestPosology], demande v1.8.0 : rendre découvrable le
|
||||||
lien Posologie → recommandation). Les deux encarts sont exclusifs.
|
lien Posologie → recommandation). Les deux encarts sont exclusifs.
|
||||||
- **t½ terminale** : TFS/WHSAH analytiques (`terminalHalfLifeDays`) ;
|
- **t½ terminale** : TFS/WHSAH **analytiques** (`terminalHalfLifeDays`) ;
|
||||||
**Estrannaise** : lue dans la table par
|
**Estrannaise analytique** depuis v1.9.0
|
||||||
[PKProfileStore.terminalHalfLifeDays] (refactor v1.8.0 : la pente
|
([EstrannaiseModels.terminalHalfLifeDays] — forme close estrannaise.js ;
|
||||||
log-linéaire utilisée par l'extrapolation de `sample()` — dernier point
|
l'ancienne lecture de table `PKProfileStore` est HORS runtime, cf §7.12).
|
||||||
≥ 1 % du pic, 48 h précédentes — est partagée).
|
|
||||||
- **Début du régime courant** (v1.8.1, **correction du proxy v1.8.0** —
|
- **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
|
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
|
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
|
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
|
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
|
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
|
5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la
|
||||||
carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est
|
carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est
|
||||||
comparable que sur un intervalle régulier).
|
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
|
recommander un creux déjà mesuré) ; **creux ≥ stabilisation** (sinon
|
||||||
creux du créneau suivant — décision v1.8.0 « sauter » ; depuis #68 le
|
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é).
|
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
|
- **UI** : LabsScreen — calcul `produceState` (Dispatchers.Default) + tick
|
||||||
minute ; carte avec creux exact (« At the estimated trough »), créneau
|
minute ; carte avec creux exact (« At the estimated trough »), créneau
|
||||||
associé (+ mention « ou simplement la veille »), statut de stabilisation,
|
associé (+ mention « ou simplement la veille »), statut de stabilisation,
|
||||||
@ -757,7 +785,7 @@ devient à son tour**.
|
|||||||
|
|
||||||
## 8. Tests unitaires
|
## 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
|
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.
|
skippées via `Assume`). Dépendance : JUnit 4.13.2.
|
||||||
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
|
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
|
||||||
@ -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`,
|
- 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.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.3.1.json`, `backup-v1.4.2.json`, `backup-v1.7.0.json`, `backup-v1.9.8.json` et
|
||||||
`backup-v1.9.8.json`
|
`backup-v1.12.0.json`
|
||||||
(copiés tels quels depuis l'export JSON de l'app) ;
|
(copiés tels quels depuis l'export JSON de l'app) ;
|
||||||
`RegressionUserCaseTest` lit v1.0.0,
|
`RegressionUserCaseTest` lit v1.0.0,
|
||||||
`RegressionUserCase2Test` lit v1.2.0, `RegressionUserCase3Test` lit
|
`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 —
|
traitements dont CPA oral ; paire E2+T avec DEUX notes distinctes —
|
||||||
épine le fix « une note sur deux » v1.7.1), `RegressionUserCase6Test` lit
|
épine le fix « une note sur deux » v1.7.1), `RegressionUserCase6Test` lit
|
||||||
**v1.9.8** (profil à 7 traitements, logs à heures variables ; épine le
|
**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.
|
- `.gitignore` contient `local-test-data/` → jamais commités.
|
||||||
**GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** :
|
**GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** :
|
||||||
1. `git check-ignore -v local-test-data/…` → la règle matche ;
|
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).
|
dans le code (le scanner refuserait, et ce serait une fuite).
|
||||||
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le
|
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le
|
||||||
fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI
|
fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI
|
||||||
exécute 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,
|
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
|
- le workdir des tests Gradle est le dossier du module (`app/`) → les tests
|
||||||
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
|
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
|
||||||
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous
|
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous
|
||||||
@ -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)
|
### 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
|
- **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),
|
ng/mL, null = pas de limite), évaluation STRICTE (valeur == limite → rien),
|
||||||
`evaluateAll` (E2 puis T), validation haut > bas (`isCoherent`).
|
`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,
|
**« 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
|
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 ;
|
« 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
|
⚠️ **`ChartScreen` (790 lignes) reste volontairement ENTIER** : sa garde de
|
||||||
source (`ChartScreenSourceGuardTest`, fix #54) exige que le bloc
|
source (`ChartScreenSourceGuardTest`, fix #54) exige que le bloc
|
||||||
`detectTransformGestures` ET les alias `gesture*` vivent dans CE fichier —
|
`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**
|
restauré dans DataStore, **les paramètres utilisateur sont restaurés**
|
||||||
(v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est
|
(v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est
|
||||||
appliquée sur le thread MAIN car `setApplicationLocales` recrée
|
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 »)
|
remplacé (bouton « Effacer & restaurer »)
|
||||||
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
|
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
|
||||||
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}`
|
`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`,
|
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
|
||||||
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
|
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
|
||||||
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 274 au
|
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 284 au
|
||||||
total, 240 si `local-test-data/` est absent (les 6 classes de régression
|
total, 250 si `local-test-data/` est absent (les 6 classes de régression
|
||||||
réelles sont skippées via `Assume`) ; **lint vert
|
réelles sont skippées via `Assume`) ; **lint vert
|
||||||
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
|
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
|
||||||
attrapé les crashs #47/#48 dès v1.3.1).
|
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
|
- [ ] **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
|
(pilule/seringue/goutte/sparadrap) ; une dose du jour d'une prise de
|
||||||
sang affiche l'encart 🧪↑ et/ou 🧪↓
|
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** : graphique panoramique (glisser → passé, bouton « Revenir à maintenant »)
|
||||||
- [ ] **v1.2.x** : toggles Estrannaise/TFS indépendants (les deux courbes superposées)
|
- [ ] **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)
|
- [ ] **v1.2.x** : chip « Prévision » (configurer la Posologie d'un traitement d'abord)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user