diff --git a/README.md b/README.md index 0d81572..6e440e9 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.9.6 — build Android ✅, **lint vert** ✅, **247 tests unitaires** ✅ (217 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ +- **Statut** : v1.9.7 — build Android ✅, **lint vert** ✅, **249 tests unitaires** ✅ (219 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) @@ -123,7 +123,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 # 247 tests (217 sans les données locales) +./gradlew testDebugUnitTest # 249 tests (219 sans les données locales) ./gradlew lint # lint vert obligatoire avant release ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 217e12b..e8dd080 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 = 43 - versionName = "1.9.6" + versionCode = 44 + versionName = "1.9.7" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/pk/LabTiming.kt b/app/src/main/java/com/hormonetrack/pk/LabTiming.kt index f158bb4..52ca5d1 100644 --- a/app/src/main/java/com/hormonetrack/pk/LabTiming.kt +++ b/app/src/main/java/com/hormonetrack/pk/LabTiming.kt @@ -194,6 +194,15 @@ object LabTiming { /** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */ private const val DOSE_EPS = 1e-6 + /** + * 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'heure près ». Un log à 12:30 au lieu de 12:00 ne doit pas + * repousser la stabilisation de 5 × t½ (remontée v1.9.7) ; un vrai + * changement de créneau (2 j au lieu de 7 j) reste hors fenêtre. + */ + private val GAP_TOLERANCE_MS = 24L * 3_600_000L + /** * DÉBUT DU RÉGIME COURANT (v1.8.1 — **correction du proxy v1.8.0**). * @@ -202,25 +211,33 @@ object LabTiming { * depuis février" » — l'ancien proxy (1ʳᵉ dose du traitement) ne voyait * PAS les changements récents de dose ni d'intervalle. * - * **NOUVELLE RÈGLE** : le régime courant = la **séquence terminale de - * doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques - * à celles de la dose la plus récente, ET (b) **l'écart entre doses - * consécutives est constant** (= l'écart des deux doses les plus - * récentes). On remonte depuis la dose la plus récente tant que ces - * conditions tiennent ; le régime commence à la dose la plus ancienne - * de la séquence. + * **Critique remontée** (v1.9.7) : « la suggestion de prochaine prise de + * sang change tout le temps à chaque injection si l'injection n'est pas + * faite pile à la même heure » — la comparaison EXACTE de v1.8.1 (écart + * identique au ms près) cassait le régime pour un log à 12:30 au lieu de + * 12:00, repoussant la stabilisation de 5 × t½ à CHAQUE injection : le + * creux recommandé fuyait vers l'avant sans jamais se fixer. + * + * **RÈGLE** : le régime courant = la **séquence terminale de doses** où + * (a) l'**ester effectif** et la **dose (mg)** sont identiques à celles + * de la dose la plus récente, ET (b) chaque **écart entre doses + * consécutives reste dans l'INTERVALLE DE POSOLOGIE ± 24 h** + * ([GAP_TOLERANCE_MS] — « je m'injecte le même jour, à l'heure près »). + * On remonte depuis la dose la plus récente tant que ces conditions + * tiennent ; le régime commence à la dose la plus ancienne de la + * séquence. * * Pourquoi chaque composante : * - **ester effectif** (override par dose compris) : un switch EV → EEn * réinitialise la stabilisation ; * - **doseAmount** : 3 mg → 5 mg change le niveau d'équilibre ; - * - **écart inter-doses constant** : 7 j → 2 j → 9 j (posologie - * modifiée/irrégulière) change le creux — conservateur : une série - * d'intervalles chaotiques maintient la carte « non stabilisée », ce - * qui est pharmacocinétiquement vrai (le trough n'est comparable que - * sur un intervalle régulier). La comparaison est EXACTE (arrondi au - * ms) : un écart 7 j → 7 j ± 1 h casse le régime, ce qui reflète un - * vrai changement de créneau. + * - **écart ≈ intervalle de Posologie ± 24 h** : le trough n'est + * comparable que sur un intervalle régulier (pharmacocinétique), mais + * l'heure de log RÉELLE d'une humaine varie — un écart 7 j ± 23 h EST + * le même régime ; 7 j + 25 h (ou un vrai passage à 2 j = 48 h, hors + * fenêtre) casse le régime. La fenêtre se réfère à l'INTERVALLE + * THÉORIQUE (Posologie), pas à l'écart précédent : des logs à 6,8 j + * puis 7,2 j ne se déstabilisent plus en cascade. * * @param carrier traitement porteur (injectable E2 actif à Posologie) * @param myDoses doses du porteur (ordre indifférent) @@ -231,18 +248,23 @@ object LabTiming { var idx = sorted.size - 1 var regimeStart = sorted[idx].timestamp var current = sorted[idx] - // Écart EXIGÉ pour remonter : celui des deux doses les plus récentes - // (inconnu si une seule dose — la séquence s'arrête là de toute façon) - var gapExpected = -1L + // Fenêtre d'écart = INTERVALLE DE POSOLOGIE ± tolérance (v1.9.7 — + // remplace la comparaison exacte v1.8.1 qui cassait le régime à + // chaque variation d'heure de log). La Posologie est un prérequis de + // la carte : absente → on s'arrête à la dernière dose (dégradé). + val intervalDays = carrier.forecastIntervalDays ?: return regimeStart + val intervalMs = (intervalDays * 24.0 * 3_600_000.0).toLong() + val gapMin = intervalMs - GAP_TOLERANCE_MS + val gapMax = intervalMs + GAP_TOLERANCE_MS while (idx > 0) { val prev = sorted[idx - 1] val gap = current.timestamp - prev.timestamp val sameMarker = PharmacokineticEngine.doseEster(carrier, current) == PharmacokineticEngine.doseEster(carrier, prev) && Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS - // Remonter seulement si même (ester, dose) ET même écart - val sameGap = gapExpected < 0L || gap == gapExpected + // Remonter seulement si même (ester, dose) ET écart dans la + // fenêtre (gap > 0 : un doublon de log n'est jamais un régime) + val sameGap = gap > 0 && gap in gapMin..gapMax if (!sameMarker || !sameGap) break - gapExpected = gap regimeStart = prev.timestamp current = prev idx-- diff --git a/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt b/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt index c9b2cff..e68a5ac 100644 --- a/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt +++ b/app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt @@ -175,16 +175,55 @@ class LabTimingTest { @Test fun `recent interval change resets stabilization`() { - // 9 semaines à 7 j, puis une dose 2 j après la première des deux plus - // récentes : l'écart terminal (NOW−9 j → NOW−7 j = 2 j) devient - // l'écart EXIGÉ du régime → la dose à NOW−9 j fonde le nouveau régime - // (la dose NOW−14 j avait un écart de 5 j ≠ 2 j → hors régime) + // 9 semaines à 7 j, puis une dose 2 j après la précédente : l'écart + // de 2 j (48 h) est HORS de la fenêtre Posologie ± 24 h (v1.9.7, + // [6 j ; 8 j] pour 7 j) → le régime s'arrête à la dose NOW−7 j (un + // vrai changement de créneau reste un changement — la tolérance ne + // rattrape pas un écart de 2 j). val doses = weeklyDoses(9) + DoseLog( id = 99, treatmentId = 1, timestamp = NOW - 9 * DAY_MS, doseAmount = 5.0 ) val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!! - // régimeStart = NOW−9 j → stabilisation ≈ NOW+13,7 j → 1ᵉʳ créneau : +14 j - assertEquals(NOW + 14 * DAY_MS, rec.injectionMs) + // régimeStart = NOW−7 j → stabilisation ≈ NOW+15,7 j → 1ᵉʳ créneau : +21 j + assertEquals(NOW + 21 * DAY_MS, rec.injectionMs) + assertEquals(false, rec.wasAlreadyStabilized) + } + + @Test + fun `logging time jitter does NOT reset stabilization (v1_9_7)`() { + // REMONTÉE v1.9.7 : « la suggestion change tout le temps à chaque + // injection si l'injection n'est pas faite pile à la même heure » — + // l'ancienne comparaison EXACTE cassait le régime pour un log à + // 12:30 au lieu de 12:00. Désormais : chaque écart reste dans + // Posologie ± 24 h → le régime remonte toute la séquence (63 j) → + // déjà stabilisée → 1ᵉʳ creux du créneau, wasAlreadyStabilized=true. + val doses = listOf( + DoseLog(id = 91, treatmentId = 1, timestamp = NOW - 63 * DAY_MS + 2 * HOUR_MS, doseAmount = 5.0), + DoseLog(id = 92, treatmentId = 1, timestamp = NOW - 56 * DAY_MS - 3 * HOUR_MS, doseAmount = 5.0), + DoseLog(id = 93, treatmentId = 1, timestamp = NOW - 49 * DAY_MS + 5 * HOUR_MS, doseAmount = 5.0), + DoseLog(id = 94, treatmentId = 1, timestamp = NOW - 42 * DAY_MS - 1 * HOUR_MS, doseAmount = 5.0), + DoseLog(id = 95, treatmentId = 1, timestamp = NOW - 35 * DAY_MS + 23 * HOUR_MS, doseAmount = 5.0), + DoseLog(id = 96, treatmentId = 1, timestamp = NOW - 28 * DAY_MS - 23 * HOUR_MS, doseAmount = 5.0), + DoseLog(id = 97, treatmentId = 1, timestamp = NOW - 21 * DAY_MS + 1 * HOUR_MS, doseAmount = 5.0), + DoseLog(id = 98, treatmentId = 1, timestamp = NOW - 14 * DAY_MS - 30 * 60_000L, doseAmount = 5.0), + DoseLog(id = 99, treatmentId = 1, timestamp = NOW - 7 * DAY_MS + 45 * 60_000L, doseAmount = 5.0), + ) + val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!! + // Le créneau se cale sur la dernière dose RÉELLE (+45 min) — voulu : + // le creux suit l'heure réelle, la tolérance protège la STABILISATION. + assertEquals(NOW + 7 * DAY_MS + 45 * 60_000L, rec.injectionMs) + assertTrue("les écarts ± 23 h sont le MÊME régime", rec.wasAlreadyStabilized) + } + + @Test + fun `gap beyond 24h tolerance resets stabilization (v1_9_7)`() { + // 8 écarts réguliers, puis un retard de 25 h sur le 7 j (8 j + 1 h) : + // hors fenêtre Posologie ± 24 h → le régime ne remonte PAS avant la + // dose en retard → PAS stabilisée (5 × t½ ≈ 22,7 j > 1,4 j). + val doses = weeklyDoses(9).mapIndexed { idx, d -> + if (idx == 7) d.copy(timestamp = NOW - 14 * DAY_MS + 25 * HOUR_MS) else d + } + val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!! assertEquals(false, rec.wasAlreadyStabilized) } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 593f7bc..59e9330 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,29 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …). +## [1.9.7] — 2026-09-20 (versionCode 44) + +### Corrigé — la suggestion de prise de sang « change tout le temps » à chaque injection + +- **Remontée** : « la suggestion de prochain test sanguin change tout le + temps à chaque injection si l'injection n'est pas faite pile à la même + heure ou si elle est faite 30 minutes plus tard que la précédente ». +- **Cause** : la règle de stabilisation v1.8.1 comparait l'écart inter-doses + **EXACTEMENT** à l'écart précédent (au ms près) — un log à 12:30 au lieu + de 12:00 cassait le régime, repoussant la stabilisation de 5 × t½ à + CHAQUE injection : le creux recommandé fuyait vers l'avant sans jamais + se fixer. +- **Fix (tolérance)** : chaque écart doit rester dans **l'intervalle de + Posologie ± 24 h** (`GAP_TOLERANCE_MS` — « je m'injecte le même jour, à + l'heure près »). La fenêtre se réfère à l'INTERVALLE THÉORIQUE, pas à + l'écart précédent : des logs à 6,8 j puis 7,2 j ne se déstabilisent plus + en cascade. Un vrai changement de créneau (2 j au lieu de 7 j) reste + hors fenêtre et réinitialise, comme avant. Le créneau affiché se cale + toujours sur la dernière dose réelle (voulu). +- Tests : le test v1.8.1 « interval change » ré-épinglé (NOW+21 j) + 2 + nouveaux (flou d'heure ± 23 h → PAS de reset ; écart 25 h → reset). + 249 tests verts + lint. Web miroir v1.9.7 (12 tests lab-timing). + ## [1.9.6] — 2026-09-20 (versionCode 43) ### Modifié — la carte de l'Accueil liste TOUTES les prochaines prises diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index cacfcfb..ba27bb3 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -63,6 +63,7 @@ Fonctionnalités v1 : | Date | Événement | |---|---| +| 20 sept. 2026 (session v1.9.7) | **Fix « la suggestion de prise de sang change tout le temps »** (remontée + données réelles : logs d'injection à heures variables) : la règle de stabilisation v1.8.1 comparait l'écart inter-doses EXACTEMENT à l'écart précédent — un log 30 min plus tard cassait le régime et repoussait la stabilisation de 5 × t½ à CHAQUE injection (creux fuyant). FIX : l'écart doit rester dans **l'intervalle de Posologie ± 24 h** (`GAP_TOLERANCE_MS`, fenêtre vs l'interval THÉORIQUE — des logs 6,8 j puis 7,2 j ne se déstabilisent plus en cascade ; un vrai passage à 2 j reste hors fenêtre). Le créneau affiché suit toujours la dernière dose réelle (voulu). Tests : « interval change » ré-épinglé + 2 tests de tolérance. 249 verts + lint. Web miroir v1.9.7 (12 verts lab-timing). §7.11 mis à jour. | | 20 sept. 2026 (session v1.9.6) | **Carte Accueil « Prochaines doses » — toutes les prises, triées** (remontée : le rappel quotidien CPA masquait le rappel hebdomadaire EEn — « je ne le vois quasiment pas ») : la carte liste UNE ligne par traitement TRIÉES par prochaine prise (1ʳᵉ en avant, suivantes en style secondaire), titre pluriel dès 2 lignes (`next_doses` FR/EN) ; helper PUR `nextDoseLine` (locale/zone paramétrables, template jours localisé `next_dose_days`) — 3 tests `NextDoseLineTest`. Notifications inchangées (une alarme par traitement). Web miroir v1.9.6 : helper pur `nextDoseLineText` (format.js) + même carte + 3 tests miroirs (misc) — 247 verts Android / 178 web. | | 20 sept. 2026 (session v1.9.5) | **Revue de code post-v1.9.4 — audit lecture-seule des deux dépôts puis corrections** (demande : « les logiques, choix techniques, commentaires, optimisations ») : (a) **#67** : ticks X alignés sur le fuseau APPAREIL pendant que les labels étaient formatés dans le fuseau CHOISI (zone manquante à `xLabelTicks`, contredisant #55) + pas fixe `+24 h` dérivant d'1 h aux transitions DST → zone passée + avancement Calendar, test DST (Europe/Paris 29/03) ; (b) `EstrannaiseCloud.compute` : `nowMs` mort supprimé (KDoc mensonger) ; (c) **perf** : `E2Context`/`prepareE2Context` — le tracé labs (720 pts) reconstruisait les maps moteur par point, diluant v1.8.2 (résultat bit-identique) ; Paints texte + `detectExtrema` + formatters mémoïsés dans le Canvas ; (d) **règles ré-appliquées** : `modelLabelRes` (anti-#59), `PKModels.*` partout (fini les literals), `scheduler2` supprimé ; (e) **doc rattrapée** : 3 commentaires périmés (ODS/lookup/t½-table), §7.2/§7.3 (asset historique, cutoff 10×t½). **Web miroir v1.9.5** : garde `hasModel` ESE dans `cutoffHours` (un backup importé ESE+ester non couvert levait TypeError → écran vide), portage des optimisations v1.8.2 (contexte préparé, cutoffs par traitement, Bateman paresseux), min/max en une passe (RangeError possible sur ~230 k pts avec le Nuage), init MCMC non bloquante (data-ready ⇒ asset chargé), `pk_profiles.json` exclu du zip (export-ignore, −548 Ko livrés), E2E version paramétrée (`data-version` — anti-bug v1.9.3), nettoyages (import mort lab-timing, JSDoc « troncature », dialogs ESE=3, COLORS accueil, JSDoc orpheline). 244 verts Android + lint · 175 verts web + E2E ✓. | | 19 sept. 2026 (session v1.9.4) | **Presets du modèle ESE étendus à ses 6 esters injectables** (remontée : « depuis l'ajout du modèle source ESE, les presets n'ont pas été mis à jour ») : 3 presets ajoutés (EC huile 5 mg IM, EB 1 mg IM, EUCS suspension cristalline 10 mg SC — 25 entrées), champs Bateman informatifs = t½ des fits ; **éditeur** : la liste des esters suit `Esters.choicesForModel` (parité DoseDialog/web) et rebascule sur EV si l'ester sort de la couverture au changement de modèle ; **web** : 2 trous du portage v1.9.0 corrigés (`Esters.EUCS` manquant dans pk-engine.js — preset référençant `undefined` — et `choicesForModel` resté sur la couverture ODS à 3 esters) + `tests/presets.test.js` (6 tests, miroir `PKPresetsTest`). 243 verts / 213 sans données locales + lint + check web (175). Doc : doublon §7.12 supprimé. Web v1.9.4 sync. | @@ -635,9 +636,12 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques : 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 de doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques - à la dose la plus récente, ET (b) **l'écart entre doses consécutives est - constant** (= l'écart des deux doses les plus récentes, comparé - exactement — 7 j ± 1 h casse le régime). Stabilisation = régimeStart + + à la dose la plus récente, ET (b) **l'écart entre doses consécutives + reste dans l'intervalle de Posologie ± 24 h** (v1.9.7 — la comparaison + exacte v1.8.1 cassait le régime à chaque variation d'heure de log et + faisait fuir le creux recommandé ; « je m'injecte le même jour, à l'heure + près » — un vrai changement de créneau, ex. 2 j au lieu de 7 j, reste + hors fenêtre). Stabilisation = régimeStart + 5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est comparable que sur un intervalle régulier). @@ -653,7 +657,8 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques : minute ; carte avec creux exact (« At the estimated trough »), créneau associé (+ mention « ou simplement la veille »), statut de stabilisation, disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée. -- 11 tests `LabTimingTest` (8 + 3 v1.8.1 régime récent) + 1 test régression n°5. +- Tests `LabTimingTest` (v1.9.7 : 2 tests de tolérance ± 24 h ajoutés, + le test v1.8.1 « interval change » ré-épinglé) + 1 test régression n°5. ### 7.12 Modèle Estrannaise ANALYTIQUE + nuage MCMC (v1.9.0) — `pk/EstrannaiseModels.kt`, `pk/EstrannaiseCloud.kt`