From 208ba56843f74e5c5c70662210e94e6c7a659ff1 Mon Sep 17 00:00:00 2001 From: Siphonight Date: Sun, 6 Sep 2026 22:28:18 +0200 Subject: [PATCH] =?UTF-8?q?v1.4.0=20:=20mod=C3=A8le=20Transfem=20Science?= =?UTF-8?q?=20reconstruit=20sur=20la=20m=C3=A9ta-analyse=20officielle=20(V?= =?UTF-8?q?3C=20forme=20close,=207=20esters,=20pics=20de=20l'article=20rep?= =?UTF-8?q?roduits=20=C3=A0=20~1=20%=20=E2=80=94=20TransfemScienceModels?= =?UTF-8?q?=20+=205=20tests)=20;=20fix=20#52=20:=20rappels=20sur=20la=20gr?= =?UTF-8?q?ille=20Posologie=20(nextReminderFireFor,=20re-programmation=20a?= =?UTF-8?q?pr=C3=A8s=20notif=20et=20'Pris',=20ReminderScheduleTest=20?= =?UTF-8?q?=C3=977)=20;=20nouveaux=20presets=20EB/EC/ECS/PEP=20;=20102=20t?= =?UTF-8?q?ests=20verts=20+=20lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 27 ++- app/build.gradle.kts | 4 +- .../com/hormonetrack/data/model/PKPresets.kt | 47 ++++- .../com/hormonetrack/data/model/Treatment.kt | 11 ++ .../hormonetrack/pk/PharmacokineticEngine.kt | 103 +++++++++-- .../hormonetrack/pk/TransfemScienceModels.kt | 103 +++++++++++ .../reminder/DoseActionReceiver.kt | 4 + .../hormonetrack/reminder/ReminderManager.kt | 74 +++++--- .../hormonetrack/ui/components/DoseDialog.kt | 17 +- .../com/hormonetrack/ui/screens/HomeScreen.kt | 24 ++- .../hormonetrack/ui/screens/SettingsScreen.kt | 5 +- .../ui/screens/TreatmentEditorScreen.kt | 6 +- app/src/main/res/values-fr/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../pk/PharmacokineticEngineTest.kt | 4 +- .../hormonetrack/pk/ReminderScheduleTest.kt | 142 ++++++++++++++++ .../pk/TransfemScienceModelsTest.kt | 160 ++++++++++++++++++ docs/CHANGELOG.md | 43 ++++- docs/DEVELOPPEMENT.md | 118 ++++++++++--- docs/GUIDE_INSTALLATION.md | 34 ++-- 20 files changed, 832 insertions(+), 102 deletions(-) create mode 100644 app/src/main/java/com/hormonetrack/pk/TransfemScienceModels.kt create mode 100644 app/src/test/java/com/hormonetrack/pk/ReminderScheduleTest.kt create mode 100644 app/src/test/java/com/hormonetrack/pk/TransfemScienceModelsTest.kt diff --git a/README.md b/README.md index 3d0713e..bd7f2b6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > sang et aux consignes de ton endocrinologue. -- **Statut** : v1.3.5 — build Android ✅, **lint vert** ✅, **90 tests unitaires** ✅ (72 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ +- **Statut** : v1.4.0 — build Android ✅, **lint vert** ✅, **102 tests unitaires** ✅ (84 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) @@ -28,8 +28,12 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** **panoramique** (glisser pour remonter dans le passé), **zoom** (pinch ou boutons − / +, 6 h → 300 j, échantillonnage adaptatif), **pics & creux** affichables avec leurs **valeurs estimées** (triangles ▲▼ aux extrema locaux, toggle) -- **Deux modèles PK au choix, superposables** (issus de la feuille `Estrogen.ods`) : - **Estrannaise (EstraNase)** et **Transfem Science** pour les injections EV / EU / EEn, +- **Deux modèles PK au choix, superposables** : **Estrannaise (EstraNase)** (tables + horaires du `.ods`) et **Transfem Science** — depuis v1.4.0, le modèle TFS est la + **méta-analyse officielle à 3 compartiments** (forme close exacte, params du + [simulateur TFS](https://transfemscience.org/misc/injectable-e2-simulator/), + [article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)), + avec les **7 esters** : EV, EU, EEn, EB, EC (huile), EC (suspension), PEP, affichés côte à côte avec toggles indépendants - **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel, patch et voie orale @@ -53,7 +57,9 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** **k calibré par période d'ester** (la suppression T diffère valerate vs enanthate), contre l'E2 déjà calibrée — unités T acceptées : ng/mL, ng/dL, ng/L, nmol/L - **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ; - les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) + les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ; + **les rappels suivent la Posologie** (v1.4.0) : un traitement injecté tous les + 7 jours ne sonne que le jour d'injection, pas tous les jours - **Nouveautés à chaque mise à jour** : dialog de changelog automatique (fermé = ne réapparaît pas avant la prochaine version) - **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise) @@ -73,7 +79,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1. git clone && cd HormoneTrack echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME ./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk -./gradlew testDebugUnitTest # 90 tests (72 sans les données locales) +./gradlew testDebugUnitTest # 102 tests (84 sans les données locales) ./gradlew lint # lint vert obligatoire avant release ``` @@ -85,7 +91,7 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) — `Siphonight/HormoneTrack` sur les deux (privé), avec -**releases taguées** (`v1.1.0` → `v1.3.5`) et **deux APK par release** (depuis v1.2.5) +**releases taguées** (`v1.1.0` → `v1.4.0`) et **deux APK par release** (depuis v1.2.5) (téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) : `-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) : @@ -101,8 +107,10 @@ taggué annoté. Voir [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md). ## Les modèles en bref Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée -(pg/mL par mg) issue des tables horaires d'Estrannaise / Transfem Science (8001 h) ; -les contributions se superposent. Pics de référence : +(pg/mL par mg) ; les contributions se superposent. **Estrannaise** = tables horaires +du `.ods` (8001 h) ; **Transfem Science** = méta-analyse à 3 compartiments (V3C, +forme close, [article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)). +Pics de référence (pg/mL par mg) : | Profil | Modèle | Pic (pg/mL/mg) | Tmax | |----------|------------------|----------------|--------| @@ -112,6 +120,9 @@ les contributions se superposent. Pics de référence : | EV | Transfem Science | 59,0 | ~51 h | | EU | Transfem Science | 10,1 | ~198 h | | EEn | Transfem Science | 32,0 | ~156 h | +| EB | Transfem Science | 194,2 | ~16 h | +| EC (huile) | Transfem Science | 31,1 | ~103 h | +| PEP | Transfem Science | 1,03 (dose ~6,5×) | ~18 j | La calibration (facteur d'échelle par traitement, calibré par tes labs) ajuste le modèle à ton corps, exactement comme la colonne « Scale factor » de la feuille d'origine. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dc27c1a..6e13f1a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // les APK publiés (Paramètres + titre du dialog « Nouveautés »). - versionCode = 19 - versionName = "1.3.5" + versionCode = 20 + versionName = "1.4.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/data/model/PKPresets.kt b/app/src/main/java/com/hormonetrack/data/model/PKPresets.kt index 9f59841..9f04375 100644 --- a/app/src/main/java/com/hormonetrack/data/model/PKPresets.kt +++ b/app/src/main/java/com/hormonetrack/data/model/PKPresets.kt @@ -53,7 +53,7 @@ object PKPresets { route = AdministrationRoute.INJECTION_IM, esterType = Esters.EV, pkModel = PKModels.TRANSFEM_SCIENCE, - absorptionHours = 51f, eliminationHalfLifeHours = 100f, bioavailabilityFraction = 1f, + absorptionHours = 50f, eliminationHalfLifeHours = 72f, bioavailabilityFraction = 1f, defaultDoseUnit = "mg", defaultDoseAmount = 4.0 ), PKPreset( @@ -62,7 +62,7 @@ object PKPresets { route = AdministrationRoute.INJECTION_IM, esterType = Esters.EU, pkModel = PKModels.TRANSFEM_SCIENCE, - absorptionHours = 198f, eliminationHalfLifeHours = 400f, bioavailabilityFraction = 1f, + absorptionHours = 204f, eliminationHalfLifeHours = 528f, bioavailabilityFraction = 1f, defaultDoseUnit = "mg", defaultDoseAmount = 10.0 ), PKPreset( @@ -71,8 +71,47 @@ object PKPresets { route = AdministrationRoute.INJECTION_SUBCUT, esterType = Esters.EEN, pkModel = PKModels.TRANSFEM_SCIENCE, - absorptionHours = 156f, eliminationHalfLifeHours = 150f, bioavailabilityFraction = 1f, - defaultDoseUnit = "mg", defaultDoseAmount = 10.0 + absorptionHours = 156f, eliminationHalfLifeHours = 110f, bioavailabilityFraction = 1f, + defaultDoseUnit = "mg", defaultDoseAmount = 5.0 + ), + // --- Nouveaux esters de la méta-analyse TFS (v1.4.0, modèle V3C) : + // EB / EC (huile) / EC suspension / PEP — PAS de tables Estrannaise + // pour ces clés : les presets sont créés en modèle TFS uniquement. + PKPreset( + nameRes = R.string.preset_eb_tfs, + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + esterType = Esters.EB, + pkModel = PKModels.TRANSFEM_SCIENCE, + absorptionHours = 16f, eliminationHalfLifeHours = 29f, bioavailabilityFraction = 1f, + defaultDoseUnit = "mg", defaultDoseAmount = 1.0 + ), + PKPreset( + nameRes = R.string.preset_ec_tfs, + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + esterType = Esters.EC, + pkModel = PKModels.TRANSFEM_SCIENCE, + absorptionHours = 103f, eliminationHalfLifeHours = 161f, bioavailabilityFraction = 1f, + defaultDoseUnit = "mg", defaultDoseAmount = 5.0 + ), + PKPreset( + nameRes = R.string.preset_ecs_tfs, + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + esterType = Esters.ECS, + pkModel = PKModels.TRANSFEM_SCIENCE, + absorptionHours = 30f, eliminationHalfLifeHours = 122f, bioavailabilityFraction = 1f, + defaultDoseUnit = "mg", defaultDoseAmount = 5.0 + ), + PKPreset( + nameRes = R.string.preset_pep_tfs, + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_IM, + esterType = Esters.PEP, + pkModel = PKModels.TRANSFEM_SCIENCE, + absorptionHours = 432f, eliminationHalfLifeHours = 682f, bioavailabilityFraction = 1f, + defaultDoseUnit = "mg", defaultDoseAmount = 32.5 ), // --- Transdermal / oral (Bateman fallback) --- PKPreset( diff --git a/app/src/main/java/com/hormonetrack/data/model/Treatment.kt b/app/src/main/java/com/hormonetrack/data/model/Treatment.kt index 5b4129a..d2465a3 100644 --- a/app/src/main/java/com/hormonetrack/data/model/Treatment.kt +++ b/app/src/main/java/com/hormonetrack/data/model/Treatment.kt @@ -8,6 +8,17 @@ object Esters { const val EV = "EV" const val EU = "EU" const val EEN = "EEN" + // Esters supplémentaires couverts par la méta-analyse Transfem Science + // (v1.4.0, modèle V3C — cf pk/TransfemScienceModels.kt). Ces clés n'ont + // PAS de tables Estrannaise (ODS) : un traitement ESE + EB/EC/ECS/PEP + // produirait 0 pg/mL (les presets les créent en modèle TFS uniquement). + const val EB = "EB" // benzoate + const val EC = "EC" // cypionate, solution huileuse + const val ECS = "ECS" // cypionate, suspension aqueuse (microcristaux) + const val PEP = "PEP" // polyestradiol phosphate + + /** Esters injectables proposables par ester (override par dose). */ + val INJECTABLE: List = listOf(EV, EU, EEN, EB, EC, ECS, PEP) } object PKModels { diff --git a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt index e150926..512a6b1 100644 --- a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt +++ b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt @@ -4,6 +4,7 @@ import com.hormonetrack.data.model.AdministrationRoute import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.Esters import com.hormonetrack.data.model.LabResult +import com.hormonetrack.data.model.PKModels import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.TreatmentType import kotlin.math.abs @@ -84,7 +85,15 @@ object PharmacokineticEngine { if (treatment.usesProfileModel) { val ester = doseEster(treatment, dose) if (ester != Esters.NONE) { - return PKProfileStore.sample(ester, modelOverride ?: treatment.pkModel, dtH) * mg + val model = modelOverride ?: treatment.pkModel + // v1.4.0 : le modèle Transfem Science utilise les paramètres + // V3C (3 compartiments) de la méta-analyse TFS, évalués en + // forme close — les tables ODS restent le fallback (esters + // sans modèle V3C, ou modèle Estrannaise). + if (model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester)) { + return TransfemScienceModels.sample(ester, dtH) * mg + } + return PKProfileStore.sample(ester, model, dtH) * mg } } @@ -102,7 +111,16 @@ object PharmacokineticEngine { private fun cutoffHours(treatment: Treatment): Double { val profileH = if (treatment.usesProfileModel) { - PKProfileStore.profileLength(treatment.esterType, treatment.pkModel).toDouble() + val model = treatment.pkModel + if (model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType)) { + // v1.4.0 : modèle V3C (forme close, pas de table bornée) → + // coupure à 10 demi-vies TERMINALES (~0,1 % de résidu), la + // t½ venant des paramètres de l'article (ex. EV 3 j → 30 j). + TransfemScienceModels.model(treatment.esterType)!! + .terminalHalfLifeDays * 24.0 * 10.0 + } else { + PKProfileStore.profileLength(treatment.esterType, model).toDouble() + } } else 0.0 val batemanH = if (treatment.usesProfileModel) 0.0 else 30.0 * treatment.eliminationHalfLifeHours.toDouble() @@ -561,22 +579,75 @@ object PharmacokineticEngine { return current.copy(k = (median * 1000).toInt() / 1000.0) } - fun nextReminderFireMs(treatments: List, nowMs: Long = System.currentTimeMillis()): Long? { - var next: Long? = null + /** + * Prochain déclenchement de rappel pour UN traitement (v1.4.0). + * + * ⚠️ FIX du bug « rappels quotidiens » (#52) : si le traitement a une + * **Posologie** (forecastIntervalDays) ET un historique de doses, le + * rappel suit la GRILLE des prises (dernière dose réelle + k × intervalle, + * créneaux passés sautés — même sémantique que [generateForecastDoses]) + * et sonne le jour du créneau à l'heure de rappel choisie : une injection + * tous les samedis à 18 h → rappel uniquement le samedi à 18 h. + * Sinon (gel/oral, ou aucune dose pour cadrer la grille) → comportement + * QUOTIDIEN historique (prochaine occurrence HH:mm). + */ + fun nextReminderFireFor(treatment: Treatment, doseLogs: List, nowMs: Long): Long? { + if (!treatment.reminderEnabled || !treatment.isActive) return null + val h = treatment.reminderHour ?: return null + val m = treatment.reminderMinute ?: return null + + val intervalDays = treatment.forecastIntervalDays + val myDoses = doseLogs.filter { it.treatmentId == treatment.id && it.timestamp <= nowMs } + if (intervalDays != null && intervalDays > 0.0 && myDoses.isNotEmpty()) { + val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong() + if (intervalMs > 0L) { + var slot = myDoses.maxByOrNull { it.timestamp }!!.timestamp + // Le premier créneau STRICTEMENT futur (un créneau passé = oubli : + // on avance au rythme configuré, cf generateForecastDoses). + while (slot <= nowMs) slot += intervalMs + val cal = java.util.Calendar.getInstance() + var fireMs = 0L + var candidate = slot + // L'alarme sonne le jour du créneau, à l'heure de rappel choisie. + while (true) { + cal.timeInMillis = candidate + cal.set(java.util.Calendar.HOUR_OF_DAY, h) + cal.set(java.util.Calendar.MINUTE, m) + cal.set(java.util.Calendar.SECOND, 0) + cal.set(java.util.Calendar.MILLISECOND, 0) + fireMs = cal.timeInMillis + if (fireMs > nowMs) return fireMs + // L'heure de rappel du jour du créneau est déjà passée → créneau suivant + candidate += intervalMs + } + } + } + + // Fallback QUOTIDIEN (comportement historique) : prochaine occurrence HH:mm val cal = java.util.Calendar.getInstance() + cal.timeInMillis = nowMs + cal.set(java.util.Calendar.HOUR_OF_DAY, h) + cal.set(java.util.Calendar.MINUTE, m) + cal.set(java.util.Calendar.SECOND, 0) + cal.set(java.util.Calendar.MILLISECOND, 0) + if (cal.timeInMillis <= nowMs) cal.add(java.util.Calendar.DAY_OF_MONTH, 1) + return cal.timeInMillis + } + + /** + * Prochain rappel parmi tous les traitements (min). [doseLogs] active la + * planification par Posologie (cf [nextReminderFireFor]) ; vide = fallback + * quotidien pur (compatibilité avec les appels historiques). + */ + fun nextReminderFireMs( + treatments: List, + doseLogs: List = emptyList(), + nowMs: Long = System.currentTimeMillis() + ): Long? { + var next: Long? = null for (tr in treatments) { - // Un traitement inactif ne génère plus de rappels - if (!tr.reminderEnabled || !tr.isActive) continue - val h = tr.reminderHour ?: continue - val m = tr.reminderMinute ?: continue - cal.timeInMillis = nowMs - cal.set(java.util.Calendar.HOUR_OF_DAY, h) - cal.set(java.util.Calendar.MINUTE, m) - cal.set(java.util.Calendar.SECOND, 0) - cal.set(java.util.Calendar.MILLISECOND, 0) - if (cal.timeInMillis <= nowMs) cal.add(java.util.Calendar.DAY_OF_MONTH, 1) - val fire = cal.timeInMillis - if (next == null || fire < next) next = fire + val fire = nextReminderFireFor(tr, doseLogs, nowMs) + if (fire != null && (next == null || fire < next)) next = fire } return next } diff --git a/app/src/main/java/com/hormonetrack/pk/TransfemScienceModels.kt b/app/src/main/java/com/hormonetrack/pk/TransfemScienceModels.kt new file mode 100644 index 0000000..1d59c19 --- /dev/null +++ b/app/src/main/java/com/hormonetrack/pk/TransfemScienceModels.kt @@ -0,0 +1,103 @@ +package com.hormonetrack.pk + +/** + * Modèles pharmacocinétiques « Transfem Science » — méta-analyse des esters + * d'estradiol injectables (transfemscience.org/articles/injectable-e2-meta-analysis/). + * + * SOURCE DES PARAMÈTRES (v1.4.0) : les coefficients du modèle à 3 compartiments + * publiés par le simulateur officiel de Transfem Science + * (https://transfemscience.org/misc/injectable-e2-simulator/ — les valeurs sont + * des DONNÉES SCIENTIFIQUES issues des études agrégées, Tableaux 9–10 de + * l'article). Avant v1.4.0, le modèle TFS de l'app utilisait des tables + * horaires dérivées du tableur de l'utilisatrice (ODS) ; désormais la courbe + * TFS est calculée EXACTEMENT comme le simulateur : + * + * Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + * + e^(−k3·t)/((k1−k3)(k2−k3)) + * + e^(−k2·t)·(k3−k1)/((k1−k2)(k1−k3)(k2−k3)) ] + * + * avec t en JOURS, Cp en pg/mL pour la dose de calibration fitDose (5 mg). + * La courbe est NORMALISÉE PAR MG : `sample()` retourne Cp(t) / fitDose — + * le moteur PK multiplie déjà par la dose réelle (dose_mg), exactement comme + * avec les tables ODS. Sanity checks épinglés par `TransfemScienceModelsTest` + * (Cmax/Tmax/t½ de l'article reproduits à ~1 %) : + * + * EV : Cmax 295 pg/mL @ 2,1 j, t½ 3,0 j | EEn : 160 @ 6,5 j, t½ 4,6 j + * EB : 971 @ 0,65 j, t½ 1,2 j | EC (huile) : 155 @ 4,3 j, t½ 6,7 j + * EC susp. : 241 @ 1,2 j, t½ 5,1 j | PEP : 34 pg/mL @ 32,5 mg @ 18 j, t½ 28,4 j + * + * Remarques de l'article conservées dans la doc de dev (§7) : l'undécylate + * (EU) a des données d'étude limitées (ajustement moins fiable) ; PEP a une + * biodisponibilité réduite (~15–20 % — d'où des doses ~6,5× plus élevées). + */ +object TransfemScienceModels { + + /** Modèle à 3 compartiments (V3C) d'un ester, tel que publié par TFS. */ + data class V3C( + /** Clé ester de l'app ("EV", "EB", …). */ + val ester: String, + /** Constante d'amplitude (pg/mL pour la dose de fit, unités TFS). */ + val D: Double, + /** Constantes de vitesse en j⁻¹ (k1 = absorption dépôt, k2/k3 phases). */ + val k1: Double, + val k2: Double, + val k3: Double, + /** Dose à laquelle les paramètres ont été ajustés (5 mg dans l'article). */ + val fitDoseMg: Double = 5.0 + ) { + /** Demi-vie TERMINALE (jours) = la phase la plus lente des trois. */ + val terminalHalfLifeDays: Double + get() = kotlin.math.ln(2.0) / minOf(k1, k2, k3) + } + + private val MODELS: Map = mapOf( + // estradiol valerate (solution huileuse) + "EV" to V3C("EV", D = 2596.05956, k1 = 2.38229125, k2 = 0.23345814, k3 = 1.37642769), + // estradiol enanthate (solution huileuse) + "EEN" to V3C("EEN", D = 333.874181, k1 = 0.42412968, k2 = 0.43452980, k3 = 0.15291485), + // estradiol undecylate — ⚠️ données d'étude limitées (cf article, §Limites) + "EU" to V3C("EU", D = 65.9493374, k1 = 0.29634323, k2 = 4799337.57, k3 = 0.03141554), + // estradiol benzoate (solution huileuse) + "EB" to V3C("EB", D = 1.7050e+08, k1 = 3.22397192, k2 = 0.58870148, k3 = 70721.4018), + // estradiol cypionate — SOLUTION HUILEUSE + "EC" to V3C("EC", D = 1920.89671, k1 = 0.10321089, k2 = 0.89854779, k3 = 0.89359759), + // estradiol cypionate — SUSPENSION AQUEUSE (microcristaux) ; clé dédiée + "ECS" to V3C("ECS", D = 1.5669e+08, k1 = 0.13586726, k2 = 2.51772731, k3 = 74768.1493), + // polyestradiol phosphate — biodispo réduite, doses ~6,5× (cf article) + "PEP" to V3C("PEP", D = 34.46836875, k1 = 0.02456035, k2 = 135643.711, k3 = 0.10582368) + ) + + fun hasModel(ester: String): Boolean = MODELS.containsKey(ester.uppercase()) + + /** Le modèle V3C de l'ester, ou null (lookup insensible à la casse). */ + fun model(ester: String): V3C? = MODELS[ester.uppercase()] + + /** + * Réponse normalisée (pg/mL PAR mg) à dtHours après une injection de 1 mg, + * modèle V3C de la méta-analyse TFS. dtHours ≤ 0 → 0 (rien avant la dose). + */ + fun sample(ester: String, dtHours: Double): Double { + if (dtHours <= 0.0) return 0.0 + val m = model(ester) ?: return 0.0 + val t = dtHours / 24.0 // la formule TFS travaille en jours + return rawConcentrationDays(m, t) / m.fitDoseMg + } + + /** + * Cp(t) en pg/mL pour la dose de fit (formule V3C exacte du simulateur). + * Le compartiment k2 peut être « ultra-rapide » (EU, PEP : k2 ~10⁵–10⁶ j⁻¹) : + * son terme décroît instantanément et n'affecte que t≈0 — double précision + * suffisante (vérifié par les tests de pics de l'article). + */ + private fun rawConcentrationDays(m: V3C, tDays: Double): Double { + val D = m.D + val k1 = m.k1 + val k2 = m.k2 + val k3 = m.k3 + return D * k1 * k2 * ( + kotlin.math.exp(-k1 * tDays) / ((k1 - k2) * (k1 - k3)) + + kotlin.math.exp(-k3 * tDays) / ((k1 - k3) * (k2 - k3)) + + kotlin.math.exp(-k2 * tDays) * (k3 - k1) / ((k1 - k2) * (k1 - k3) * (k2 - k3)) + ) + } +} diff --git a/app/src/main/java/com/hormonetrack/reminder/DoseActionReceiver.kt b/app/src/main/java/com/hormonetrack/reminder/DoseActionReceiver.kt index f17ee09..00a4489 100644 --- a/app/src/main/java/com/hormonetrack/reminder/DoseActionReceiver.kt +++ b/app/src/main/java/com/hormonetrack/reminder/DoseActionReceiver.kt @@ -31,6 +31,10 @@ class DoseActionReceiver : BroadcastReceiver() { val treatment = db.treatmentDao().getById(treatmentId) ?: return@launch val amount = if (dose > 0.0) dose else treatment.doseAmount db.doseLogDao().insert(DoseLog(treatmentId = treatmentId, doseAmount = amount)) + // ⚠️ v1.4.0 (fix #52) : la dose loguée AVANCE la grille — + // reprogrammer immédiatement le rappel au créneau suivant + // (Posologie si configurée, sinon demain à la même heure). + AlarmScheduler(context).scheduleFor(treatment, db.doseLogDao().getAllOnce()) } finally { pending.finish() } diff --git a/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt b/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt index bf60285..a980b8e 100644 --- a/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt +++ b/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt @@ -12,7 +12,13 @@ import androidx.core.app.NotificationCompat import com.hormonetrack.MainActivity import com.hormonetrack.R import com.hormonetrack.data.AppDatabase +import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.Treatment +import com.hormonetrack.pk.PharmacokineticEngine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import java.util.Calendar object ReminderContract { @@ -59,28 +65,23 @@ class AlarmScheduler(private val context: Context) { ) } - private fun nextOccurrence(hour: Int, minute: Int): Long { - val cal = Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, hour) - set(Calendar.MINUTE, minute) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - } - if (cal.timeInMillis <= System.currentTimeMillis()) { - cal.add(Calendar.DAY_OF_MONTH, 1) - } - return cal.timeInMillis - } - fun canScheduleExact(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms() - fun scheduleDaily(treatment: Treatment): Boolean { - val hour = treatment.reminderHour ?: return false - val minute = treatment.reminderMinute ?: return false - val triggerAt = nextOccurrence(hour, minute) + /** + * Programme la PROCHAINE alarme du traitement (v1.4.0, fix #52) : + * la date vient du MOTEUR (`nextReminderFireFor`) — unique source de + * vérité — donc suit la Posologie quand elle existe (rappel le jour du + * créneau uniquement), sinon quotidien. One-shot : l'alarme suivante est + * reprogrammée par ReminderReceiver (après notif) et DoseActionReceiver + * (après « Pris »). + */ + fun scheduleFor(treatment: Treatment, doseLogs: List): Boolean { + val fireAt = PharmacokineticEngine.nextReminderFireFor( + treatment, doseLogs, System.currentTimeMillis() + ) ?: return false val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount) - return scheduleAt(triggerAt, pi) + return scheduleAt(fireAt, pi) } fun scheduleSnooze(treatmentId: Long, name: String, dose: Double, delayMs: Long = 60 * 60_000L): Boolean { @@ -112,11 +113,15 @@ class AlarmScheduler(private val context: Context) { alarmManager.cancel(pi) } - fun rescheduleAll(treatments: List) { + /** + * Reprogramme/annule tous les rappels. [doseLogs] alimente la grille + * Posologie (fix #52) ; vide = fallback quotidien (compat. appelants). + */ + fun rescheduleAll(treatments: List, doseLogs: List = emptyList()) { treatments.forEach { tr -> // Un traitement inactif ne rappelle plus : son alarme est annulée if (tr.reminderEnabled && tr.isActive) { - scheduleDaily(tr) + scheduleFor(tr, doseLogs) } else { cancel(tr.id) } @@ -205,6 +210,22 @@ class ReminderReceiver : BroadcastReceiver() { val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.notify(treatmentId.toInt(), notification) + + // ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer + // ICI le déclenchement SUIVANT (v1.4.0, fix #52) — sinon le rappel suivant + // n'existait qu'après un reboot/une édition. Le calcul passe par le moteur + // (grille Posologie si elle existe, sinon quotidien). + val pending = goAsync() + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.IO).launch { + try { + val db = AppDatabase.getInstance(context) + db.treatmentDao().getById(treatmentId)?.let { tr -> + AlarmScheduler(context).scheduleFor(tr, db.doseLogDao().getAllOnce()) + } + } finally { + pending.finish() + } + } } } @@ -214,12 +235,17 @@ class BootReceiver : BroadcastReceiver() { val result = goAsync() Thread { try { + val db = AppDatabase.getInstance(context) + // TOUS les traitements : les inactifs doivent voir leur alarme + // annulée au boot (sinon une alarme périmée continue de sonner) ; + // les doses alimentent la grille Posologie (fix #52) val treatments = kotlinx.coroutines.runBlocking { - // TOUS les traitements : les inactifs doivent voir leur alarme - // annulée au boot (sinon une alarme périmée continue de sonner) - AppDatabase.getInstance(context).treatmentDao().getAllOnce() + db.treatmentDao().getAllOnce() } - AlarmScheduler(context).rescheduleAll(treatments) + val doses = kotlinx.coroutines.runBlocking { + db.doseLogDao().getAllOnce() + } + AlarmScheduler(context).rescheduleAll(treatments, doses) } finally { result.finish() } diff --git a/app/src/main/java/com/hormonetrack/ui/components/DoseDialog.kt b/app/src/main/java/com/hormonetrack/ui/components/DoseDialog.kt index ecbe967..76c553a 100644 --- a/app/src/main/java/com/hormonetrack/ui/components/DoseDialog.kt +++ b/app/src/main/java/com/hormonetrack/ui/components/DoseDialog.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp import com.hormonetrack.R import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.Esters +import com.hormonetrack.data.model.PKModels import com.hormonetrack.data.model.Treatment /** @@ -139,7 +140,17 @@ fun DoseDialog( text = { Text(stringResource(R.string.ester_default, selected.esterType)) }, onClick = { esterOverride = null; esterExpanded = false } ) - listOf(Esters.EV, Esters.EU, Esters.EEN).forEach { e -> + // v1.4.0 : la liste d'esters suit le MODÈLE du + // traitement — TFS = tous les esters de la + // méta-analyse (V3C, cf TransfemScienceModels), + // Estrannaise (ODS) = EV/EU/EEn seulement (les + // tables ne couvrent pas les autres esters). + val choices = if (selected.pkModel == PKModels.TRANSFEM_SCIENCE) { + Esters.INJECTABLE + } else { + listOf(Esters.EV, Esters.EU, Esters.EEN) + } + choices.forEach { e -> DropdownMenuItem( text = { Text(esterLabel(e)) }, onClick = { esterOverride = e; esterExpanded = false } @@ -175,6 +186,10 @@ private fun esterLabel(ester: String): String = when (ester) { Esters.EV -> "EV — valerate" Esters.EU -> "EU — undecylate" Esters.EEN -> "EEn — enanthate" + Esters.EB -> "EB — benzoate" + Esters.EC -> "EC — cypionate (oil)" + Esters.ECS -> "EC — cypionate (suspension)" + Esters.PEP -> "PEP — polyestradiol phosphate" else -> ester } diff --git a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt index f322056..cf531a8 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.hormonetrack.R +import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.Treatment import com.hormonetrack.pk.LevelPoint @@ -172,7 +173,7 @@ fun HomeScreen( NowLevelCard(curve) - NextDoseCard(activeTreatments) + NextDoseCard(activeTreatments, doseLogs) Card( modifier = Modifier.fillMaxWidth(), @@ -342,18 +343,15 @@ private fun NowLevelCard(curve: List) { } @Composable -private fun NextDoseCard(treatments: List) { - val next = PharmacokineticEngine.nextReminderFireMs(treatments) - if (next == null) return - val nextTreatment = treatments.filter { it.reminderEnabled } - .minByOrNull { tr -> - val h = tr.reminderHour ?: 23 - val m = tr.reminderMinute ?: 59 - val candidate = (h * 60L + m) * 60_000L - val nowMs = System.currentTimeMillis() - val nowOfDay = nowMs % (24 * HOUR_MS) - if (candidate >= nowOfDay) candidate else candidate + 24 * HOUR_MS - } +private fun NextDoseCard(treatments: List, doseLogs: List) { + // v1.4.0 (fix #52) : les dates de rappel passent par le moteur — la carte + // suit la grille Posologie (jour du créneau uniquement) comme les alarmes. + val nowMs = System.currentTimeMillis() + val fires = treatments + .mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } } + if (fires.isEmpty()) return + val next = fires.minByOrNull { it.second }!!.second + val nextTreatment = fires.minByOrNull { it.second }!!.first Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text( diff --git a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt index 8dad428..ac5abb8 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt @@ -443,7 +443,10 @@ fun SettingsScreen(onBack: () -> Unit) { } result?.let { container.settings.setTConfig(it.tConfig) - AlarmScheduler(context).rescheduleAll(repo.allTreatmentsOnce()) + // Doses + traitements : grille Posologie pour les rappels (#52) + AlarmScheduler(context).rescheduleAll( + repo.allTreatmentsOnce(), repo.allDoseLogsOnce() + ) } withContext(Dispatchers.Main) { message = if (result != null) { diff --git a/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt index e6e0efc..fb6743b 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt @@ -517,9 +517,11 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) { } val saved = tr.copy(id = savedId) // Rappel actif seulement si le traitement l'est aussi ; - // désactiver un traitement annule son alarme + // désactiver un traitement annule son alarme. Les doses + // alimentent la grille Posologie (fix #52) : le rappel + // sonne uniquement les jours de créneau. if (saved.reminderEnabled && saved.isActive) { - scheduler.scheduleDaily(saved) + scheduler.scheduleFor(saved, repo.allDoseLogsOnce()) } else { scheduler.cancel(savedId) } diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 67a12c1..f399dc4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -183,6 +183,10 @@ Injection EV — Transfem Science Injection EU — Transfem Science Injection EEn — Transfem Science + Injection EB — Transfem Science + Injection EC (huile) — Transfem Science + Injection EC (suspension aqueuse) — Transfem Science + Injection PEP — Transfem Science E2 gel transdermique E2 patch E2 orale diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dd570b5..df90c61 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -183,6 +183,10 @@ EV injection — Transfem Science EU injection — Transfem Science EEn injection — Transfem Science + EB injection — Transfem Science + EC injection (oil) — Transfem Science + EC injection (aqueous suspension) — Transfem Science + PEP injection — Transfem Science E2 transdermal gel E2 patch E2 oral diff --git a/app/src/test/java/com/hormonetrack/pk/PharmacokineticEngineTest.kt b/app/src/test/java/com/hormonetrack/pk/PharmacokineticEngineTest.kt index a01719b..45739e3 100644 --- a/app/src/test/java/com/hormonetrack/pk/PharmacokineticEngineTest.kt +++ b/app/src/test/java/com/hormonetrack/pk/PharmacokineticEngineTest.kt @@ -220,7 +220,9 @@ class PharmacokineticEngineTest { fun `next reminder fire time is in the future`() { val tr = evTreatment().copy(reminderEnabled = true, reminderHour = 8, reminderMinute = 30) val now = BASE - val next = PharmacokineticEngine.nextReminderFireMs(listOf(tr), now) + // v1.4.0 : signature (treatments, doseLogs, nowMs) — doseLogs vide = + // fallback quotidien (pas de Posologie sur ce traitement) + val next = PharmacokineticEngine.nextReminderFireMs(listOf(tr), emptyList(), now) assertTrue(next != null && next > now && next - now <= 24 * HOUR) } diff --git a/app/src/test/java/com/hormonetrack/pk/ReminderScheduleTest.kt b/app/src/test/java/com/hormonetrack/pk/ReminderScheduleTest.kt new file mode 100644 index 0000000..1a54a4a --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/ReminderScheduleTest.kt @@ -0,0 +1,142 @@ +package com.hormonetrack.pk + +import com.hormonetrack.data.model.AdministrationRoute +import com.hormonetrack.data.model.DoseLog +import com.hormonetrack.data.model.Esters +import com.hormonetrack.data.model.PKModels +import com.hormonetrack.data.model.Treatment +import com.hormonetrack.data.model.TreatmentType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Calendar + +/** + * Épingle la planification des rappels AVEC Posologie (v1.4.0, fix #52) : + * un traitement injecté tous les 7 jours avec un rappel à 18 h doit sonner + * UNIQUEMENT les jours de créneau (le samedi 18 h), pas tous les jours. + * + * Précédemment : `nextReminderFireMs` ignorait les doses et l'intervalle → + * l'alarme se ré-armait chaque jour à HH:mm, même hors jour d'injection. + */ +class ReminderScheduleTest { + + private companion object { + const val DAY_MS = 86_400_000L + const val HOUR_MS = 3_600_000L + + /** 18 h locales du jour de [ms] (comme le ferait l'alarme). */ + fun at(ms: Long, hour: Int, minute: Int): Long = + Calendar.getInstance().apply { + timeInMillis = ms + set(Calendar.HOUR_OF_DAY, hour) + set(Calendar.MINUTE, minute) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + + private fun injectionTreatment(intervalDays: Double?, hour: Int, minute: Int): Treatment = + Treatment( + id = 1L, + name = "EEn", + type = TreatmentType.ESTRADIOL, + route = AdministrationRoute.INJECTION_SUBCUT, + doseAmount = 5.0, + doseUnit = "mg", + isActive = true, + esterType = Esters.EEN, + pkModel = PKModels.TRANSFEM_SCIENCE, + forecastIntervalDays = intervalDays, + reminderHour = hour, + reminderMinute = minute, + reminderEnabled = true + ) + + @Test + fun `reminder follows the dosing grid (every 7 days at 18h, not daily)`() { + // now = un instant fixe ; dernière dose il y a 2 jours (le même jour + // de semaine que le créneau suivant, cf grille +7 j) ; rappel 18 h. + val now = System.currentTimeMillis() + val lastDose = now - 2 * DAY_MS + val doses = listOf(DoseLog(treatmentId = 1L, timestamp = lastDose, doseAmount = 5.0)) + val tr = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0) + + val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now) + val expected = at(lastDose + 7 * DAY_MS, 18, 0) + assertEquals(expected, fire) + // invariants : dans le futur, à ~5 jours (7 j − 2 j), PAS demain matin + assertTrue("fire doit être futur", fire!! > now) + assertTrue("fire doit être dans ~5 jours, pas demain", fire - now > 3 * DAY_MS) + } + + @Test + fun `reminder time already passed on slot day moves to next slot`() { + // Dose loguée à 20 h aujourd'hui, rappel prévu 18 h : l'occurrence + // du jour est déjà passée → le prochain rappel est le créneau suivant. + val now = at(System.currentTimeMillis(), 20, 0) + val doses = listOf(DoseLog(treatmentId = 1L, timestamp = now, doseAmount = 5.0)) + val tr = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0) + + val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now) + val expected = at(now + 7 * DAY_MS, 18, 0) + assertEquals(expected, fire) + } + + @Test + fun `missed slot is skipped without simulating the past`() { + // Dernière dose il y a 10 jours pour un intervalle de 7 j : le créneau + // théorique (il y a 3 jours) est PASSÉ → on avance au suivant (+7 j). + val now = System.currentTimeMillis() + val lastDose = now - 10 * DAY_MS + val doses = listOf(DoseLog(treatmentId = 1L, timestamp = lastDose, doseAmount = 5.0)) + val tr = injectionTreatment(intervalDays = 7.0, hour = 9, minute = 30) + + val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now) + val expected = at(lastDose + 14 * DAY_MS, 9, 30) + assertEquals(expected, fire) + assertTrue(fire!! > now) + } + + @Test + fun `no posology keeps the legacy daily behaviour`() { + val now = at(System.currentTimeMillis(), 10, 0) // 10 h < 18 h + val tr = injectionTreatment(intervalDays = null, hour = 18, minute = 0) + // Sans historique de doses non plus (gel/oral ou app neuve) + val fire = PharmacokineticEngine.nextReminderFireFor(tr, emptyList(), now) + assertEquals(at(now, 18, 0), fire) + } + + @Test + fun `posology without any logged dose falls back to daily`() { + // Aucune dose pour cadrer la grille : on retombe sur le quotidien + // (l'app neuve ne peut pas deviner le jour d'injection). + val now = at(System.currentTimeMillis(), 10, 0) + val tr = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0) + val fire = PharmacokineticEngine.nextReminderFireFor(tr, emptyList(), now) + assertEquals(at(now, 18, 0), fire) + } + + @Test + fun `aggregate helper picks the earliest fire across treatments`() { + val now = System.currentTimeMillis() + val trWeekly = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0) + val trDaily = injectionTreatment(intervalDays = null, hour = 8, minute = 0) + .copy(id = 2L) + val doses = listOf( + DoseLog(treatmentId = 1L, timestamp = now - 2 * DAY_MS, doseAmount = 5.0) + ) + val next = PharmacokineticEngine.nextReminderFireMs(listOf(trWeekly, trDaily), doses, now) + assertEquals(PharmacokineticEngine.nextReminderFireFor(trDaily, doses, now), next) + } + + @Test + fun `inactive or disabled treatments never schedule`() { + val now = System.currentTimeMillis() + val doses = listOf(DoseLog(treatmentId = 1L, timestamp = now - DAY_MS, doseAmount = 5.0)) + assertEquals(null, PharmacokineticEngine.nextReminderFireFor( + injectionTreatment(7.0, 18, 0).copy(isActive = false), doses, now)) + assertEquals(null, PharmacokineticEngine.nextReminderFireFor( + injectionTreatment(7.0, 18, 0).copy(reminderEnabled = false), doses, now)) + } +} diff --git a/app/src/test/java/com/hormonetrack/pk/TransfemScienceModelsTest.kt b/app/src/test/java/com/hormonetrack/pk/TransfemScienceModelsTest.kt new file mode 100644 index 0000000..5fb8f74 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/TransfemScienceModelsTest.kt @@ -0,0 +1,160 @@ +package com.hormonetrack.pk + +import com.hormonetrack.data.model.Esters +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +/** + * Épingle le modèle **Transfem Science V3C** (v1.4.0) sur les valeurs de la + * méta-analyse https://transfemscience.org/articles/injectable-e2-meta-analysis/ + * (Tableaux 9–10 : dose unique de 5 mg IM ; Figure 11 : état d'équilibre EV). + * + * Les paramètres (D/k1/k2/k3) viennent du simulateur officiel TFS + * (cf pk/TransfemScienceModels.kt pour la provenance exacte) : ce test + * garantit que l'app reproduit le simulateur à ~1 % près. + */ +class TransfemScienceModelsTest { + + companion object { + private const val DAY_MS = 86_400_000L + /** Pas d'intégration numérique pour l'AUC (jours). */ + private const val DT_DAYS = 0.01 + } + + @Before + fun warmup() { + // V3C est purement analytique : rien à charger (les tables ODS ne sont + // pas impliquées). Ce hook garde la même structure que les autres tests. + } + + /** Courbe pour une dose de 5 mg = sample × 5 (pg/mL). */ + private fun c5mg(ester: String, days: Double): Double = + TransfemScienceModels.sample(ester, days * 24.0) * 5.0 + + /** Max/t de la courbe 5 mg sur 0..60 j (pas 0,01 j). */ + private fun peak(ester: String): Pair { + var best = 0.0 to 0.0 + var t = 0.0 + while (t <= 60.0) { + val v = c5mg(ester, t) + if (v > best.first) best = v to t + t += DT_DAYS + } + return best + } + + @Test + fun `single-dose peaks match the article tables (5 mg IM)`() { + // (Cmax pg/mL, Tmax jours) — Tableaux 9/10 de la méta-analyse + val expected = mapOf( + "EV" to (295.0 to 2.1), + "EEN" to (160.0 to 6.5), + "EB" to (971.0 to 0.65), + "EC" to (155.0 to 4.3), + "ECS" to (241.0 to 1.2), + "EU" to null, // pas de valeurs tabulées fiables pour l'undécylate + // PEP : l'article donne Cmax=34 pg/mL à la dose ÉQUIVALENTE 32,5 mg + // (biodispo réduite) → à la dose de fit 5 mg : 34/6,5 ≈ 5,23 + "PEP" to (34.0 / 6.5 to 18.0) + ) + for ((ester, exp) in expected) { + val (cmax, tmax) = peak(ester) + if (exp == null) continue + val (cExp, tExp) = exp + assertTrue("$ester Cmax=$cmax attendu $cExp", cmax in cExp * 0.98..cExp * 1.02) + assertTrue("$ester Tmax=$tmax attendu $tExp", tmax in tExp * 0.9..tExp * 1.1) + } + } + + @Test + fun `terminal half-lives match the article tables`() { + // t½ terminale (la phase la plus lente) — Tableaux 9/10 + val expected = mapOf( + "EV" to 3.0, "EEN" to 4.6, "EB" to 1.2, + "EC" to 6.7, "ECS" to 5.1, "PEP" to 28.4 + ) + for ((ester, tHalfDays) in expected) { + val m = TransfemScienceModels.model(ester)!! + assertTrue( + "$ester t½=${m.terminalHalfLifeDays} attendu $tHalfDays", + m.terminalHalfLifeDays in tHalfDays * 0.95..tHalfDays * 1.05 + ) + } + } + + /** AUC numérique (pg·jour/mL par mg) = exposition totale normalisée. */ + private fun aucPerMg(ester: String, cutDays: Double = 400.0): Double { + var auc = 0.0 + var t = 0.0 + while (t < cutDays) { + auc += TransfemScienceModels.sample(ester, t * 24.0) * DT_DAYS + t += DT_DAYS + } + // Queue exponentielle au-delà de cutDays : valeur / λ terminale + val tail = TransfemScienceModels.sample(ester, cutDays * 24.0) * + TransfemScienceModels.model(ester)!!.terminalHalfLifeDays / kotlin.math.ln(2.0) + return auc + tail + } + + @Test + fun `AUC matches the article (bioavailability ~100 percent, PEP reduced)`() { + // Article (5 mg) : EV 1886, EEn 2183, EB 2410, EC 2150, ECS 2096, PEP 2117 @32,5 mg + val expectedPerMg = mapOf( + "EV" to 1886.0 / 5.0, + "EEN" to 2183.0 / 5.0, + "EC" to 2150.0 / 5.0, + "PEP" to 2117.0 / 32.5 // l'article donne l'AUC à la dose équivalente 32,5 mg + ) + for ((ester, exp) in expectedPerMg) { + val auc = aucPerMg(ester) + assertTrue("$ester AUC/mg=$auc attendu $exp", auc in exp * 0.97..exp * 1.03) + } + } + + @Test + fun `EV steady state at 5 mg per week matches figure 11 (Cmax 384, Cmin 142, Cavg 269)`() { + // Superposition de 40 doses hebdomadaires de 5 mg ; on analyse les + // 2 derniers cycles (équilibre atteint, t½ EV = 3 j). + val interval = 7.0 + val doses = 40 + fun steady(days: Double): Double { + var total = 0.0 + for (k in 0 until doses) { + val dt = days - k * interval + if (dt > 0) total += c5mg("EV", dt) + } + return total + } + // Fenêtre = dernier intervalle complet [ (doses-1)·7 , doses·7 ) + val start = (doses - 1) * interval + var cmax = 0.0 + var cmin = Double.MAX_VALUE + var sum = 0.0 + var n = 0 + var t = start + while (t < start + interval) { + val v = steady(t) + cmax = maxOf(cmax, v) + cmin = minOf(cmin, v) + sum += v + n++ + t += DT_DAYS + } + val cavg = sum / n + assertTrue("Cmax=$cmax", cmax in 384.0 * 0.95..384.0 * 1.05) + assertTrue("Cmin=$cmin", cmin in 142.0 * 0.9..142.0 * 1.1) + assertTrue("Cavg=$cavg", cavg in 269.0 * 0.95..269.0 * 1.05) + } + + @Test + fun `unknown esters have no V3C model and sample to zero`() { + assertTrue(!TransfemScienceModels.hasModel("NONE")) + assertEquals(0.0, TransfemScienceModels.sample("NONE", 10.0), 1e-12) + // avant la dose : 0 + assertEquals(0.0, TransfemScienceModels.sample(Esters.EV, -1.0), 1e-12) + assertEquals(0.0, TransfemScienceModels.sample(Esters.EV, 0.0), 1e-12) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9e446a2..e53fe7b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,48 @@ # Changelog — HormoneTrack Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). -Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.3.5`, …). +Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.0`, …). + +## [1.4.0] — 2026-09-06 (versionCode 20) + +### Ajouté +- **Modèle Transfem Science reconstruit sur la méta-analyse officielle** + (https://transfemscience.org/articles/injectable-e2-meta-analysis/) : + la courbe TFS n'est plus dérivée des tables horaires du tableur — elle + est désormais la **forme close exacte du modèle à 3 compartiments (V3C)** + publié par le simulateur TFS, avec les paramètres D/k1/k2/k3 des 7 esters + (EV, EEn, EU, EB, EC huile, EC suspension, PEP). PICS DE RÉFÉRENCE + REPRODUITS À ~1 % (épinglés par `TransfemScienceModelsTest`) : EV 295 + pg/mL @ 2,1 j ; EEn 160 @ 6,5 j ; EB 971 @ 0,65 j ; EC 155 @ 4,3 j ; + EC susp. 241 @ 1,2 j ; PEP 34 @ 32,5 mg @ 18 j ; AUC et état d'équilibre + EV 5 mg/7 j (Cmax 384 / Cmin 142 / Cavg 269) conformes à la Figure 11. + Avantages : plus de plancher d'arrondi 0,01 en queue de table, pas de + coupure à 8001 h (extrapolation exacte), coupure moteur à 10 demi-vies + terminales. +- **Nouveaux esters** : benzoate (EB), cypionate huile (EC), cypionate + suspension aqueuse (ECS) et polyestradiol phosphate (PEP) — 4 presets + TFS, override d'ester par dose étendu (la liste suit le modèle du + traitement : TFS = les 7 esters, Estrannaise = EV/EU/EEn faute de tables + ODS). PEP = biodisponibilité réduite, doses ~6,5× (cf article). +- **Rappels qui suivent la Posologie** (fix #52, Corrigé ci-dessous) : + la carte « Prochaine dose » de l'accueil passe par le même moteur que + les alarmes. +- **12 nouveaux tests** (102 au total / 84 sans les données locales) : + `TransfemScienceModelsTest` (pics, t½, AUC, équilibre de la Figure 11) + et `ReminderScheduleTest` (grille Posologie, créneau manqué, fallback + quotidien). + +### Corrigé +- **Les rappels se déclenchaient TOUS LES JOURS** sans tenir compte de + l'intervalle entre prises (#52, remontée utilisateur : « je m'injecte + tous les samedis à 18 h, je dois recevoir le rappel uniquement le + samedi à 18 h ») : le calcul du prochain déclenchement ignorait doses et + Posologie. Désormais, un traitement avec Posologie + historique sonne + le jour du créneau à l'heure choisie (dernière dose + k × intervalle, + créneaux passés sautés comme la prévision) ; sans Posologie ou sans + historique → comportement quotidien inchangé. De plus, l'alarme suivante + est reprogrammée **après chaque notification** et **après « Pris »** + (les alarmes sont one-shot : la chaîne n'existait qu'au boot/au save). ## [1.3.5] — 2026-09-06 (versionCode 19) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 8143f12..2fb57bd 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -81,7 +81,8 @@ Fonctionnalités v1 : | 6 sept. 2026 (session farewell) | Repo créé côté farewell → **push SSH (alias `farewell` : giteassh:2222) + 12 releases publiées avec APK vérifiés par téléchargement** ; piège lexicographique `v1.2.10 < v1.2.5` en comparaison de chaînes épinglé (§14 #38) ; les deux instances Gitea sont synchrones. | | 6 sept. 2026 (session v1.3.3) | **Audit de reprise de maintenance** (nouvelle session IA) : 3 bugs racines trouvés — permissions agenda ABSENTES du manifest (jamais déclarées, §14 #44), export logs plantait TOUJOURS (le fix v1.3.2 réimplémentait l'IO au lieu de réutiliser BackupManager, §14 #45), bump de version JAMAIS commité (tags v1.3.0–1.3.2 tous `versionCode 14 / "1.3.0"`, §14 #46). Corrigés + doc rafraîchie (DB v3, targetSdk 36, régression 3 = export v1.3.1, §19/§20 staleness) ; **téléphone de test documenté : Google Pixel 9 /e/OS** (AOSP ; SAF DocumentsUI standard). | | 6 sept. 2026 (session v1.3.4) | **Deux crashs v1.3.3 remontés (Doses + export logs)** → reproduits sur ÉMULATEUR avec les vraies données (recette §16.ter, désormais standard) : (a) Doses = string `hrt_duration` 3 placeholders vs 2 args (**né en v1.3.1**, §14 #47) ; (b) export logs = `LocalDate.format("…-HHmm")` levant `UnsupportedTemporalTypeException` au TAP (§14 #48) — les fixes IO v1.3.2/v1.3.3 étaient à côté du vrai problème. Lint mis en filet bloquant (aurait attrapé les deux) + #49 notif ; helper `ExportFileNames` + 3 tests (90 verts) ; fixes VÉRIFIÉS sur émulateur avec l'APK release + données réelles (flux SAF complet : SAVE → fichier + message). Publication v1.3.4. | -| 6 sept. 2026 (session v1.3.5) | **Premier diagnostic à distance RÉUSSI via les logs exportés** : les AppLog de la v1.3.4 fournis par l'utilisatrice prouvent export logs `ok=true` (fix #48 ✓ au téléphone), permission agenda accordée (fix manifest ✓) et révèlent le bug racine EXPLICITE : `ensureCalendar` plantait car l'URI sync-adapter n'embarquait pas ACCOUNT_NAME/ACCOUNT_TYPE (§14 #50). En validant sur émulateur, DEUX bugs découverts : delete d'event sans account (#50 bis — « supprimé » logué mais l'event restait) et `buildTreatment` sans `calendarEventId` (#51 — événements orphelins). Cycle ON→save→OFF→save validé end-to-end (`content query`). Publication v1.3.5. | +| 6 sept. 2026 (session v1.3.5) | **Premier diagnostic à distance RÉUSSI via les logs exportés** : les AppLog de la v1.3.4 prouvent export logs `ok=true` (fix #48 ✓ au téléphone), permission agenda accordée (fix manifest ✓) et révèlent le bug racine EXPLICITE : `ensureCalendar` plantait car l'URI sync-adapter n'embarquait pas ACCOUNT_NAME/ACCOUNT_TYPE (§14 #50). En validant sur émulateur, DEUX bugs découverts : delete d'event sans account (#50 bis — « supprimé » logué mais l'event restait) et `buildTreatment` sans `calendarEventId` (#51 — événements orphelins). Cycle ON→save→OFF→save validé end-to-end (`content query`). Publication v1.3.5. | +| 6 sept. 2026 (session v1.4.0) | **Modèle TFS reconstruit sur la méta-analyse officielle** (params V3C du simulateur transfemscience.github.io/injectable-e2-simulator, extraits de `ester-data.js`/`calc-curve.js`) : forme close 3 compartiments, 7 esters (EB/EC/ECS/PEP ajoutés), pics de l'article reproduits à ~1 % (AUC + Figure 11 épinglés). **+ Fix #52** (remontée) : rappels quotidiens même hors jour d'injection → grille Posologie (moteur = source unique), re-programmation après notif et « Pris ». 12 nouveaux tests (102 verts), lint vert. Publication v1.4.0. | | 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. | | 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. | | 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. | @@ -366,15 +367,50 @@ garde k ∈ (1e-4, 10), **médiane** ; labs normalisés via `convertTToNgMl`. ### 7.7 API du moteur `levelAt / currentLevel / computeCurve(start, end, step=1h, tConfig) / e2At / -testosteroneAt / computeScaleFactor / computeTConfigCalibration / nextReminderFireMs / +testosteroneAt / computeScaleFactor / computeTConfigCalibration / +nextReminderFireMs(treatments, doseLogs, now) / nextReminderFireFor / batemanParams / concentrationOfDose / computeKa / doseEster / isInjectionRoute`. Type de retour : `LevelPoint(timestamp, e2, t)`. + +### 7.8 Modèle TFS V3C (v1.4.0) — `pk/TransfemScienceModels.kt` + +Le modèle « Transfem Science » n'est PLUS dérivé des tables ODS : c'est la +**forme close exacte** du modèle à 3 compartiments de la méta-analyse +(https://transfemscience.org/articles/injectable-e2-meta-analysis/), avec les +paramètres D/k1/k2/k3 publiés par le simulateur officiel +(transfemscience.github.io/injectable-e2-simulator — fichiers `ester-data.js` +/ `calc-curve.js`, valeurs = données scientifiques des études) : + +``` +Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + e^(−k3·t)/((k1−k3)(k2−k3)) + + e^(−k2·t)·(k3−k1)/((k1−k2)(k1−k3)(k2−k3)) ] (t en JOURS) +``` + +- `sample(ester, dtHours)` = `Cp(t_jours) / fitDose(5 mg)` → pg/mL PAR mg ; + le moteur multiplie par la dose réelle, comme pour les tables ODS. +- Branchement dans `concentrationOfDose` : si `model == TFS` et que l'ester a + un V3C (EV, EEn, EU, EB, EC, ECS, PEP) → `TransfemScienceModels.sample` ; + sinon fallback tables ODS (Estrannaise, ou ester sans modèle). +- **Coupure moteur** (`cutoffHours`) : 10 demi-vies TERMINALES (t½ = ln2 / + min(k1,k2,k3)) — ex. EV 30 j, PEP 284 j — au lieu de la longueur de table. +- Compartiment k2 « ultra-rapide » (EU/PEP : k2 ~10⁵–10⁶ j⁻¹) : son terme + décroît instantanément, double précision suffisante (testé). +- Sanity checks épinglés (±1–3 %) : pics/t½ des Tableaux 9–10, AUC par mg + (EV 377,2 / EEn 436,6 / EC 430 / PEP 65,1 pg·j/mL/mg), état d'équilibre + EV 5 mg/7 j (Cmax 384 / Cmin 142 / Cavg 269, Figure 11 de l'article). +- ⚠️ EU (undécylate) : ajustement sur données d'étude LIMITÉES (cf article + §Limites) — précision moindre, conservé car c'est le meilleur disponible. +- Les valeurs des pics par mg restent cohérentes avec l'ancienne table ODS + (la table en DÉRIVAIT) : EV 59 pg/mL/mg @ 51 h, EEn 31,97 @ 156 h — le + changement est la FORME (queues exactes, pas d'arrondi 2 décimales). + ## 8. Tests unitaires -**87 tests JVM, tous verts** → **90 depuis v1.3.4** (`./gradlew testDebugUnitTest`) — -**72 sans les données de test locales** (cf §8.bis : les 3 classes de régression, -6 tests chacune, sont skippées via `Assume`). Dépendance : JUnit 4.13.2. +**102 tests JVM, tous verts** → **v1.4.0** (`./gradlew testDebugUnitTest`) — +**84 sans les données de test locales** (cf §8.bis : les 3 classes de +régression, 6 tests chacune, sont skippées via `Assume`). Dépendance : +JUnit 4.13.2. Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = `app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`). @@ -478,6 +514,17 @@ dépôt (ni maintenant, ni si le repo devient public un jour). vide, série trop courte → vide, **filtre d'amplitude** (sémantique zigzag : une oscillation sous le seuil produit UN pivot, l'alternance complète apparaît quand le seuil baisse), anti-corrélation E2/T (un pic d'E2 ≈ un creux de T). +- **`TransfemScienceModelsTest`** (5) : v1.4.0 — épingle le modèle TFS V3C sur + l'article : pics/t½ des Tableaux 9–10 (±1–2 %), AUC par mg (±3 %), état + d'équilibre EV 5 mg/7 j = Figure 11 (Cmax 384/Cmin 142/Cavg 269), esters + sans modèle → 0, dt ≤ 0 → 0. **Toute retouche de TransfemScienceModels.kt + passe par CE test** (c'est la fidélité au simulateur TFS qui est vérifiée). +- **`ReminderScheduleTest`** (7) : v1.4.0 — fix #52 : rappel sur la GRILLE + Posologie (dose il y a 2 j + intervalle 7 j → alarme dans 5 j à l'heure + choisie, PAS demain), heure déjà passée le jour du créneau → créneau + suivant, créneau manqué sauté (jamais dans le passé), fallback quotidien + sans Posologie ou sans doses, agrégat multi-traitements (le plus tôt + gagne), traitements inactifs/désactivés → null. **Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu. @@ -486,23 +533,33 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod ## 9. Système de rappels -`reminder/ReminderManager.kt` (+ `DoseActionReceiver.kt`). +`reminder/ReminderManager.kt` (+ `DoseActionReceiver.kt`, `ReminderReceiver`). - `ReminderContract` : constantes + **fabrique unique `reminderIntent()`** pour schedule ET cancel (même action = même PendingIntent — cf bug §14.3) - `AlarmScheduler` : - - quotidien : `setExactAndAllowWhileIdle` si `canScheduleExact()` (API≥31 : - `alarmManager.canScheduleExactAlarms()`), sinon `setWindow` ±10 min + - **`scheduleFor(treatment, doseLogs)` (v1.4.0, source de vérité = moteur)** : + la date du prochain déclenchement vient de + `PharmacokineticEngine.nextReminderFireFor` — **grille Posologie** si le + traitement a `forecastIntervalDays` + des doses (rappel UNIQUEMENT le jour + du créneau, à l'heure choisie — fix #52), sinon quotidien (comportement + historique, gel/oral ou app sans historique) + - alarmes **one-shot** (`setExactAndAllowWhileIdle`) : la suivante est + reprogrammée par `ReminderReceiver` (après notif) et `DoseActionReceiver` + (après « Pris ») — avant v1.4.0 la chaîne ne se recousait qu'au boot/au save - permission **SCHEDULE_EXACT_ALARM** : bouton d'octroi dans Paramètres + éditeur (`Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM`) - - `scheduleDaily` (prochaine occurrence HH:mm), `scheduleSnooze` (+1 h), `rescheduleAll` + - `scheduleSnooze` (+1 h), `rescheduleAll(treatments, doseLogs)` - `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity (`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes - PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()` + PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()` ; **puis + reprogramme le prochain déclenchement** (goAsync + IO) - `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert - DoseLog (dose = extra ou standard) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif -- `BootReceiver` : `goAsync()` + thread + **`runBlocking`** + one-shot `getActiveOnce()` - (jamais un Flow en runBlocking !) → reschedule + DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance + la grille) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif +- `BootReceiver` : `goAsync()` + thread + **`runBlocking`** + one-shots + `getAllOnce()` (traitements) + `doseLogDao.getAllOnce()` → `rescheduleAll` + (jamais un Flow en runBlocking !) **Événements d'agenda (v1.3.0, `reminder/CalendarEvents.kt`)** : switch dans l'éditeur (sous Rappels) → insertion dans un **calendrier local dédié @@ -922,6 +979,19 @@ l'exemple) :** un save doit repartir des champs NON ÉDITABLES (voir `loadedCreatedAt`, déjà protégé, et désormais `loadedCalendarEventId`). +**Session v1.4.0 (modèle TFS sur la méta-analyse + fix #52) :** + +52. **Rappels quotidiens même hors jour d'injection** (remontée utilisateur : + « je m'injecte tous les samedis à 18 h → je dois recevoir le rappel + uniquement le samedi à 18 h ») : `nextReminderFireMs` ignorait doses et + Posologie → l'alarme se ré-armait chaque jour à HH:mm. FIX : le prochain + déclenchement suit la GRILLE (dernière dose + k × intervalle, créneaux + passés sautés — même sémantique que la prévision) à l'heure de rappel + choisie ; fallback quotidien sans Posologie/sans doses ; **et** la + chaîne one-shot est recousue après notif ET après « Pris » (avant : + seulement au boot/save). Leçon : toute logique de scheduling doit vivre + au MOTEUR (testable JVM), pas dans les composants Android. + **Leçons** : (a) ne jamais croire un build « probablement bon » sans l'avoir lancé ; (b) les tests sémantiques attrapent ce que la compilation ne voit pas ; (c) se méfier des constantes stdlib « de mémoire » (`ln2`), des mélanges Float/Double, et des APIs M3 @@ -1325,14 +1395,24 @@ Sur le téléphone de test (à compléter par l'utilisatrice) : RÉELLEMENT** de l'app d'agenda (bugs #50 / #50 bis / #51 : en v1.3.4 ni création ni suppression ne fonctionnaient, et les re-saves orphelinaient les événements — cf §14) +- [ ] **v1.4.0** : **rappel = jour du créneau uniquement** (fix #52) : un + traitement avec Posologie 7 j + rappel 18 h ne doit sonner que le jour + d'injection à 18 h (pas les autres jours) ; la carte « Prochaine + dose » de l'accueil montre le même créneau ; après « Pris » dans la + notification, le rappel suivant se cale sur le créneau d'après +- [ ] **v1.4.0** : **courbes TFS V3C** : superposer Estrannaise/TFS sur un + traitement EV — les deux doivent être proches aux pics (58–61 + pg/mL/mg) et diverger en queues (V3C = queues exactes) ; vérifier un + preset EB/EC/PEP (nouvelles courbes) ; PEP à 32,5 mg → niveaux + comparables à un EEn à 5 mg --- -*Doc mise à jour le 6 sept. 2026 (v1.3.5) — build OK, lint vert, 90/90 tests verts (72 sans +*Doc mise à jour le 6 sept. 2026 (v1.4.0) — build OK, lint vert, 102/102 tests verts (84 sans les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée de santé dans le dépôt ni l'historique. Fil des corrections : v1.3.3 = reprise de maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs -reproduits sur émulateur avec les données réelles (#47 stringResource arity, #48 -LocalDate+pattern horaire), lint filet bloquant ; v1.3.5 = premier diagnostic à distance -RéUSSI via les logs exportés de l'utilisatrice (#50 CalendarProvider sync-adapter account, -log agenda véridique). Dette connue : fragments de labs dans l'historique git -(v1.1.0→v1.2.3) — cf §8.bis.* +reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint +filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51 +agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité +~1 % épinglée) + rappels sur la grille Posologie (#52). Dette connue : fragments de +labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.* diff --git a/docs/GUIDE_INSTALLATION.md b/docs/GUIDE_INSTALLATION.md index e574c2b..1fd8cd6 100644 --- a/docs/GUIDE_INSTALLATION.md +++ b/docs/GUIDE_INSTALLATION.md @@ -75,6 +75,12 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei 3. Test : programme un rappel 2 min à l'avance → la notif doit apparaître au poignet avec les boutons **« Pris »** et **« Reporter 1 h »** +> **Quand le rappel sonne-t-il ?** (v1.4.0) : si le traitement a une **Posologie** +> (intervalle en jours) et un historique de prises, le rappel sonne **uniquement le +> jour du créneau** à l'heure choisie (ex. : injection tous les samedis, rappel 18 h +> → notification le samedi à 18 h, pas les autres jours). Après « Pris », le rappel +> suivant se cale sur le créneau d'après. Sans Posologie (gel, oral) → rappel quotidien. + > Sur la GT 3, on ne peut pas installer d'app au poignet facilement (Lite Wearable, sideload > via DevEco Assistant). La v1 utilise la montre comme **écran de notifications**, ce qui est > fiable et sans maintenance. Une mini-app au poignet reste possible en Phase 2 si tu veux. @@ -125,19 +131,27 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei ## 7. Les modèles mathématiques -Les profils viennent de ta feuille `Estrogen.ods` (table « Models ») : +- **Estrannaise** : tables horaires issues de ta feuille `Estrogen.ods` (table « Models ») +- **Transfem Science** (v1.4.0) : la méta-analyse officielle à 3 compartiments + ([article](https://transfemscience.org/articles/injectable-e2-meta-analysis/), + [simulateur](https://transfemscience.org/misc/injectable-e2-simulator/)) — + courbe calculée en forme close, 7 esters disponibles (EV, EU, EEn, EB, EC, + EC suspension, PEP) -| Profil | Source | Pic (pg/mL par mg) | Tmax (h) | -|-----------|-------------------|--------------------|----------| -| EV(ese) | Estrannaise | 61,1 | ~45 h | -| EU(ese) | Estrannaise | 3,4 | ~55 h (plateau long) | -| EEn(ese) | Estrannaise | 31,4 | ~152 h | -| EV(tfs) | Transfem Science | 59,0 | ~51 h | -| EU(tfs) | Transfem Science | 10,1 | ~198 h | -| EEn(tfs) | Transfem Science | 32,0 | ~156 h | +Pics de référence (dose unique de 5 mg, IM) : + +| Profil | Source | Cmax (pg/mL) | Tmax | t½ terminale | +|--------|--------|--------------|------|---------------| +| EV | Estrannaise | — tables ODS | ~45 h | — | +| EEn | Estrannaise | — tables ODS | ~152 h | — | +| EV | Transfem Science | 295 | 2,1 j | 3,0 j | +| EEn | Transfem Science | 160 | 6,5 j | 4,6 j | +| EB | Transfem Science | 971 | 0,65 j | 1,2 j | +| EC (huile) | Transfem Science | 155 | 4,3 j | 6,7 j | +| EC (susp.) | Transfem Science | 241 | 1,2 j | 5,1 j | +| PEP | Transfem Science | 34 (à 32,5 mg) | 18 j | 28,4 j | - **Superposition** : chaque injection contribue `dose_mg × profil(dt)` ; les courbes s'additionnent -- **Interpolation** linéaire entre les heures ; au-delà de 8000 h, extrapolation avec la pente terminale - **Calibration** : facteur d'échelle par traitement (médiane des ratios lab/prédiction) - **Courbe T** : dérivée de l'E2 estimé (modèle empirique, calibrable) — indicative seulement - Unités T acceptées : ng/mL, **ng/dL**, ng/L, nmol/L (conversion automatique)