diff --git a/README.md b/README.md index e56b6b2..ae20448 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.4.10 — build Android ✅, **lint vert** ✅, **172 tests unitaires** ✅ (150 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.5.0 — build Android ✅, **lint vert** ✅, **183 tests unitaires** ✅ (161 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) @@ -39,6 +39,13 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** en couvre 6 (sans PEP). Affichés côte à côte avec toggles indépendants (3 couleurs) — **pré-cochés selon les modèles de tes traitements** (v1.4.7) et **chaque modèle calibré séparément** par tes labs (v1.4.8 — les facteurs s'adaptent à chaque profil) +- **« Tracé labs » (v1.5.0)** : la courbe ancrée sur tes prises de sang + (chip `Tracé labs`, off par défaut) — la forme du modèle PK entre les + labs, mais l'amplitude recalée pour passer EXACTEMENT sur chaque lab E2 + ; transition log-linéaire d'un lab au suivant, garde des labs hors + fenêtre d'action (#61), fenêtre bornée [1er ; dernier lab] — la courbe + est une référence de COMPARAISON, jamais une estimation d'action (elle + n'entre jamais dans l'accueil/les alertes) - **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel, patch et voie orale - **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1279640..dda800d 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 = 30 - versionName = "1.4.10" + versionCode = 31 + versionName = "1.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt b/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt new file mode 100644 index 0000000..583e06d --- /dev/null +++ b/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt @@ -0,0 +1,174 @@ +package com.hormonetrack.pk + +import com.hormonetrack.data.model.DoseLog +import com.hormonetrack.data.model.LabResult +import com.hormonetrack.data.model.PKModels +import com.hormonetrack.data.model.Treatment +import com.hormonetrack.data.model.TreatmentType +import kotlin.math.exp +import kotlin.math.ln + +/** + * « Tracé labs » (v1.5.0) — courbe hybride ancrée sur les résultats de sang. + * + * DEMANDE (décision v1.5.0, plan validé avec l'utilisatrice après balancement + * variantes A/B — cf doc §7.10) : une courbe « biologiquement plausible MAIS + * passant par tous les points de lab ». Formule : + * + * courbe(t) = M(t) × ρ(t) + * + * où : + * - **M(t)** = niveau du MODÈLE PK brut (superposition des contributions, + * scaleFactor forcé à 1, modèle stocké ou `modelOverride`) — il porte TOUTE + * la physiologie entre les labs (Tmax, queues, accumulation) ; + * - **ρ(t) = lab_k / M(t_k)** à chaque lab — l'amplitude du modèle est + * recalée sur la mesure réelle ; ENTRE deux labs consécutifs, ρ + * interpole LOG-LINÉAIREMENT (morphing d'amplitude multiplicativement + * continu : un passage d'un facteur entre 2 labs ne fait pas de saut + * vertical) ; + * - courbe(t_k) = M(t_k) × (lab_k / M(t_k)) = **lab_k exactement** : + * le passage par chaque point est GARANTI par construction. + * + * GARDE ANTI-#61 (labIsSignificant) : les labs traités par timestamp + * CROISSANT qui ne « gouvernent » plus la prédiction (< 15 % du max déjà + * observé) ne sont PAS des ancrages — la dose de test de janvier + des + * labs tardifs (#61) ne créent pas de zigzag. À la différence des + * pipelines de calibration (#60), un lab rejeté n'est PAS utilisé du tout. + * + * PORTÉE ET LIMITES (essentielles — cf doc §7.10 / décision plan v1.5.0) : + * - **E2 uniquement** (les labs T : 1) sont trop rares, 2) leurs unités + * variées ont causé le bug #26 — hors scope) ; + * - **fenêtre = [1er lab significatif ; dernier lab]** — rien AVANT (M=0 + * sans dose) ni APRÈS (aucune extrapolation : le vieillissement après le + * dernier lab est visible = honnête, l'app n'invente pas de niveau) ; + * - < 2 labs significatifs → courbe vide (un lab seul ne définit pas + * d'intervalle) ; + * - indépendante de la calibration : cette courbe EST SA PROPRE + * calibration continue (ratio brut, scaleFactor des traitements ignoré + * ET scalePerEster ignoré — l'inverse serait une double correction, + * épinglé par test) ; + * - **display-end only** : utilisée UNIQUEMENT par le graphique (chip, + * off par défaut). Jamais branchée sur `levelAt` — l'accueil, les + * seuils d'alerte et les rappels continuent d'utiliser le modèle + * calibré (invariant v1.4.2 §9.bis). + * - courbes T non modélisées ici : la série n'a pas de tStyle (le chart + * ne dessine pas de T pour la série — cf CurveChart). + * + * PUR (aucune dépendance Android) → testable en JVM + * (LabTrajectoryModelTest — l'ancrage exact, la garde #61 exactement, + * les bornes de fenêtre). + */ +object LabTrajectoryModel { + + /** + * Résultat : les points (E2 ancrée, t = 0 — pas de courbe T pour cette + * série) + le nombre de labs réellement utilisés comme ancrages + * (diagnostic dans la doc ; 0 → courbe vide). + */ + data class AnchoredCurve( + val points: List, + val anchoredLabs: Int + ) + + // Les ancrages sont des couples (timestamp du lab, ratio lab ÷ prédiction) + // — traités en timeline triée croissante par ratioAt(). + + /** + * Construit la courbe ancrée sur les labs. + * + * @param treatments TOUS les traitements (actifs ET inactifs — §6.bis : + * l'inactivation ne retire pas l'historique de la + * prédiction M(t)) + * @param doseLogs toutes les doses + * @param e2Labs labs E2 uniquement (les marqueurs autres sont + * ignorés par `marker == "E2"`) — unité pg/mL + * @param startMs/endMs fenêtre demandée (celle du graphique) — la courbe + * est CLIPPÉE à l'intersection avec [lab 1er ; lab dernier] + * @param stepMs pas d'échantillonnage (stepForRange, v1.2.9) + * @param modelOverride modèle pour la prédiction M(t) (défaut = modèle + * stocké, même sémantique que l'auto-calibration + * Home v1.4.8) + */ + fun computeLabAnchoredCurve( + treatments: List, + doseLogs: List, + e2Labs: List, + startMs: Long, + endMs: Long, + stepMs: Long = 3_600_000L, + modelOverride: String? = null + ): AnchoredCurve { + if (startMs >= endMs) return AnchoredCurve(emptyList(), 0) + + val estrogenTreatments = treatments.filter { it.type == TreatmentType.ESTRADIOL } + if (estrogenTreatments.isEmpty()) return AnchoredCurve(emptyList(), 0) + + // Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le + // facteur — combiner avec le facteur stocké = double correction) + val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) } + + // ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ─────────── + // Un couple (t_lab, ratio) par lab significatif, trié croissant. + val anchors = mutableListOf>() + var maxPredictionSoFar = 0.0 + for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) { + val predicted = PharmacokineticEngine.e2At( + unscaled, doseLogs, lab.timestamp, modelOverride, null + ) + if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue + anchors.add(lab.timestamp to (lab.value / predicted)) + if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted + } + if (anchors.size < 2) return AnchoredCurve(emptyList(), anchors.size) + + val firstLabMs = anchors.first().first + val lastLabMs = anchors.last().first + + // ── 2) Fenêtre effective = [1er ; dernier lab] ∩ fenêtre demandée ──── + val t0 = maxOf(startMs, firstLabMs) + val t1 = minOf(endMs, lastLabMs) + if (t1 <= t0) return AnchoredCurve(emptyList(), anchors.size) + + // ── 3) Courbe = M(t) × ρ(t) sur la grille ───────────────────────────── + val points = mutableListOf() + var t = t0 + while (t <= t1) { + val m = PharmacokineticEngine.e2At(unscaled, doseLogs, t, modelOverride, null) + val rho = ratioAt(anchors, t) + points.add(LevelPoint(t, m * rho, 0.0)) // t=0 : pas de courbe T pour cette série + t += stepMs + } + return AnchoredCurve(points, anchors.size) + } + + /** + * ρ(t) : interpolation LOG-LINÉAIRE entre ancrages consécutifs + * (`anchors` trié croissant). Pour t ∈ [t_k ; t_(k+1)] : + * + * ρ(t) = exp( lnρ_k + (t − t_k)/(t_(k+1) − t_k) · (lnρ_(k+1) − lnρ_k) ) + * + * Garanties : ρ(t_k) = ρ_k exactement (passage par le lab), continuité + * multiplicative (pas de saut), monotone entre deux références. + * Doublons de timestamp (2 labs au même instant) : le 2ᵉ ratio gagne + * (garde division par zéro). + */ + internal fun ratioAt(anchors: List>, tMs: Long): Double { + // Avant le 1er / après le dernier : exclus par la fenêtre du caller — + // garde défensive = bornes. + if (tMs <= anchors.first().first) return anchors.first().second + if (tMs >= anchors.last().first) return anchors.last().second + + for (i in 0 until anchors.size - 1) { + val (t0, r0) = anchors[i] + val (t1, r1) = anchors[i + 1] + if (tMs in t0..t1) { + val span = t1 - t0 + if (span <= 0L) return r1 // doublon de timestamp : 2ᵉ gagne + val f = (tMs - t0).toDouble() / span.toDouble() + return exp(ln(r0) + f * (ln(r1) - ln(r0))) + } + } + // inatteignable si anchors croissant et bornes cohérentes — garde + return anchors.last().second + } +} diff --git a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt index f4c8380..16da4ab 100644 --- a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt +++ b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt @@ -41,7 +41,14 @@ data class ChartSeries( val points: List, val e2Style: CurveStyle, /** Style de la courbe T de cette série ; null = pas de courbe T pour cette série. */ - val tStyle: CurveStyle? = null + val tStyle: CurveStyle? = null, + /** + * v1.5.0 : cette série participe-t-elle à la détection des pics/creux ? + * Défaut true (toutes les séries modèle) ; « Tracé labs » est FALSE — + * ses extrema reflèteraient l'interp ρ entre labs, pas la physiologie du + * modèle, et `labIsSignificant` a déjà filtré les points aberrants. + */ + val showExtrema: Boolean = true ) data class ChartOptions( @@ -419,8 +426,9 @@ fun CurveChart( } // Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée - // (v1.2.6 : le triangle est accompagné de la valeur simulée du pic/creux) - if (options.showExtrema) { + // (v1.2.6 : le triangle est accompagné de la valeur estimée du pic/creux). + // v1.5.0 : une série peut être exclue (Tracé labs → showExtrema=false). + if (options.showExtrema && s.showExtrema) { PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0) .forEach { ex -> drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color) diff --git a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt index df241c7..ff44ed6 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt @@ -1,6 +1,7 @@ package com.hormonetrack.ui.screens import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement @@ -43,6 +44,7 @@ import androidx.compose.ui.unit.dp import com.hormonetrack.R import com.hormonetrack.data.model.Treatment import com.hormonetrack.pk.LevelPoint +import com.hormonetrack.pk.LabTrajectoryModel import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.TConfig import com.hormonetrack.ui.LocalAppContainer @@ -51,6 +53,7 @@ import com.hormonetrack.ui.components.ChartSeries import com.hormonetrack.ui.components.CurveChart import com.hormonetrack.ui.components.CurveStyle import com.hormonetrack.ui.theme.ChartE2 +import com.hormonetrack.ui.theme.ChartLabTrajectory import com.hormonetrack.ui.theme.ChartT import com.hormonetrack.ui.theme.ChartTWhs import com.hormonetrack.ui.theme.ChartWhs @@ -110,6 +113,11 @@ fun ChartScreen() { var showTfs by remember { mutableStateOf(true) } // v1.4.6 : 3ᵉ modèle PK superposable (WHSAH — fit license-free de Mona) var showWhs by remember { mutableStateOf(false) } + // v1.5.0 : courbe « Tracé labs » (hybride ancrée sur les labs, cf + // pk/LabTrajectoryModel.kt) — OFF par défaut : opt-in, la courbe + // revendique « passer par tes labs » et doit rester une référence de + // comparaison, pas un affichage par défiant + var showLabTrack by remember { mutableStateOf(false) } // v1.4.7 (fix #58) : au CHARGEMENT des traitements, les toggles sont // alignés sur les modèles réellement UTILISÉS par des traitements à // PROFIL PK (injection + ester, `usesProfileModel`) — en cours ou passés @@ -249,13 +257,29 @@ fun ChartScreen() { } } - // Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") + // Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") — + // v1.5.0 : la 4ᵉ série possible est "LAB" (Tracé labs, indépendante) val curves by produceState( emptyList>>(), - autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast + autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast, + showLabTrack, labResults.size ) { withContext(Dispatchers.Default) { val list = mutableListOf>>() + // ── v1.5.0 : courbe « Tracé labs » ─────────────────────────────── + // Totalement indépendante des toggles de modèles (elle utilise le + // modèle STOCKÉ des traitements — sémantique Home, cf doc §7.10) + // et de la calibration (elle EST sa propre calibration continue — + // scalePerEster ignoré, épinglé par LabTrajectoryModelTest). + // showLabTrack + serie visible UNIQUEMENT si ≥ 2 labs that anchor. + if (showLabTrack) { + val step = com.hormonetrack.ui.components.stepForRange(rangeHours) + list.add( + "LAB" to LabTrajectoryModel.computeLabAnchoredCurve( + treatments, doseLogs, labResults, startMs, endMs, step + ).points + ) + } if (showEse || showTfs || showWhs) { // Calibration PAR MODÈLE (fix #60) : chaque courbe reçoit les // échelles/k calculés avec SA prédiction. Hors auto-calibration @@ -330,6 +354,11 @@ fun ChartScreen() { "ESE" -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT)) // v1.4.6 : WHSAH = E2 vert plein, T violet pointillé "WHS" -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true)) + // v1.5.0 : « Tracé labs » = E2 rose foncé pointillé, PAS de courbe T + // (la série n'a qu'une valeur E2 — tStyle null → rien de dessiné) + // et hors pics/creux (labIsSignificant a déjà filtré, §7.10) + "LAB" -> ChartSeries(points, CurveStyle(ChartLabTrajectory, dashed = true), null, + showExtrema = false) else -> ChartSeries(points, CurveStyle(TealTertiary), CurveStyle(ChartT, dashed = true)) } } @@ -422,8 +451,18 @@ fun ChartScreen() { Spacer(Modifier.height(8.dp)) - // Modèles PK superposables (3ᵉ = WHSAH, v1.4.6) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // Modèles PK superposables (3ᵉ = WHSAH, v1.4.6) + v1.5.0 : le chip + // « Tracé labs » vit ICI (4ᵉ chip de la rangée). ⚠️ SCROLL HORIZONTAL + // CONFINÉ à la rangée : 4 chips débordent sur écrans étroits (constat + // émulateur v1.5.0 — le 4ᵉ chip était hors fenêtre, absent du dump + // uiautomator). AUCUN conflit avec le pan du graphique : le scroll + // portre sur CETTE rangée uniquement, le pan/gesture reste sur le + // Card du chart (le fix #62 portait sur un scroll VERTICAL parent ; + // ici le libellé est différent et localisé). + Row( + Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { FilterChip( selected = showEse, onClick = { showEse = !showEse }, @@ -439,6 +478,12 @@ fun ChartScreen() { onClick = { showWhs = !showWhs }, label = { Text(stringResource(R.string.model_whs)) } ) + // v1.5.0 : courbe hybride ancrée sur les labs (off par défaut) + FilterChip( + selected = showLabTrack, + onClick = { showLabTrack = !showLabTrack }, + label = { Text(stringResource(R.string.show_lab_track)) } + ) } Spacer(Modifier.height(12.dp)) @@ -528,7 +573,10 @@ fun ChartScreen() { } } - // Légende dynamique + // Légende dynamique — ⚠️ v1.5.0 : la clé "LAB" (Tracé labs) + // est SKIPPÉE ici : elle a sa propre légende dédiée plus + // bas, sinon le `else` (TFS) imprimerait la légende TFS + // une 2ᵉ fois (bug v1.5.0-trouvé-en-émulateur #63). curves.forEach { (model, _) -> when (model) { "ESE" -> { @@ -560,6 +608,7 @@ fun ChartScreen() { ) } } + "LAB" -> { /* légende dédiée, cf plus bas */ } else -> { Text( stringResource(R.string.legend_tfs_e2), @@ -597,6 +646,15 @@ fun ChartScreen() { color = MaterialTheme.colorScheme.onSurfaceVariant ) } + // v1.5.0 : légende « Tracé labs » — seulement si la série + // a ≥ 2 points (≥ 2 labs significatifs, la grille est vide sinon) + if (showLabTrack && curves.any { it.first == "LAB" && it.second.size >= 2 }) { + Text( + stringResource(R.string.legend_lab_track), + style = MaterialTheme.typography.labelMedium, + color = ChartLabTrajectory + ) + } Text( stringResource(R.string.drag_hint), style = MaterialTheme.typography.labelMedium, diff --git a/app/src/main/java/com/hormonetrack/ui/theme/Color.kt b/app/src/main/java/com/hormonetrack/ui/theme/Color.kt index d31ac65..a841999 100644 --- a/app/src/main/java/com/hormonetrack/ui/theme/Color.kt +++ b/app/src/main/java/com/hormonetrack/ui/theme/Color.kt @@ -17,5 +17,8 @@ val ChartWhs = Color(0xFF2E7D32) val ChartTWhs = Color(0xFF9C27B0) val ChartT = Color(0xFFD6589E) val LabDot = Color(0xFFE67E22) +// v1.5.0 : courbe « Tracé labs » (hybride ancrée sur les labs, pk/LabTrajectoryModel) +// — rose foncé distinct de E2/T/TFS/WHS et des dots orange des labs +val ChartLabTrajectory = Color(0xFFC2185B) val GoodGreen = Color(0xFF2E7D32) val WarnOrange = Color(0xFFB26A00) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4bd4d1e..5513f5d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -32,6 +32,7 @@ Analyses Prévision Pics / creux + Tracé labs Zoom avant (fenêtre plus courte) Zoom arrière (fenêtre plus longue) Sous THS depuis le %1$s @@ -72,6 +73,7 @@ Un événement récurrent est créé dans un calendrier dédié « HormoneTrack », basé sur la Posologie et l\'heure de rappel. Visible et modifiable dans ton application d\'agenda. L\'autorisation agenda est demandée à l\'activation. ▲▼ pics & creux estimés (par courbe) + E2 ancrée sur tes labs — forme du modèle forcée de passer par chaque prise de sang (entre le 1er et le dernier lab) — prochaine dose projetée (marqueur vertical) Revenir à maintenant Fais glisser le graphique vers la droite pour remonter dans le passé. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bc46270..5ebc2f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,6 +32,7 @@ Labs Forecast Peaks / troughs + Lab track Zoom in (shorter window) Zoom out (longer window) Under HRT since %1$s @@ -72,6 +73,7 @@ A recurring event is created in a dedicated “HormoneTrack” calendar, based on the Posology interval and reminder time. Visible in your calendar app; editable there. Calendar permission is requested when you enable this. ▲▼ estimated peaks & troughs (per curve) + Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab) — next projected dose (vertical marker) Back to now Drag the chart to the right to travel into the past. diff --git a/app/src/test/java/com/hormonetrack/pk/LabTrajectoryModelTest.kt b/app/src/test/java/com/hormonetrack/pk/LabTrajectoryModelTest.kt new file mode 100644 index 0000000..3fd38f3 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/pk/LabTrajectoryModelTest.kt @@ -0,0 +1,264 @@ +package com.hormonetrack.pk + +import com.hormonetrack.data.model.LabResult +import com.hormonetrack.data.model.Treatment +import com.hormonetrack.data.model.TreatmentType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.lang.Math.exp +import java.lang.Math.log +import kotlin.math.ln + +/** + * Tests du « Tracé labs » (v1.5.0) — courbe hybride ancrée sur les labs + * (cf doc §7.10). Chaque test épingle une GARANTIE de la construction : + * - passage EXACT sur chaque lab (comme les sanity checks TFS) ; + * - interp log-linéaire de ρ (math exacte, tolérance FP seule) ; + * - garde de significativité jamais contournée (leçon #61 : une dose de + * test ancienne + labs tardifs ne doivent pas créer de zigzag) ; + * - fenêtre = [1er ; dernier lab] — JAMAIS d'extrapolation ; + * - indépendance du scaleFactor stocké (cette courbe est SA PROPRE + * calibration continue — combiner = double correction). + * + * ⚠️ Noms de tests ASCII-sans-ponctuation : les backticks Kotlin avec + * « : . / ; [ ] » sont des caractères illégaux pour le nom de méthode JVM + * (compilation refusée par l'AGP — vérifié v1.5.0). + */ +class LabTrajectoryModelTest { + + private val DAY_MS = 86_400_000L + private val HOUR_MS = 3_600_000L + + @Before + fun setup() { + // Même pattern que PKProfileStoreTest : l'asset est un FICHIER du + // module (les assets Android ne sont pas sur le classpath des tests JVM) + if (!PKProfileStore.hasProfile("EV", "ESE")) { + val candidates = listOf( + File("src/main/assets/pk_profiles.json"), + File("app/src/main/assets/pk_profiles.json") + ) + val file = candidates.firstOrNull { it.exists() } + ?: error("pk_profiles.json not found for unit tests") + PKProfileStore.initWithJson(file.readText()) + } + } + + // ── Fixtures ───────────────────────────────────────────────────────────── + + /** Traitement injectable EEn / TFS 5 mg, une dose à t=0 (id 1). */ + private fun treatment(scaleFactor: Double = 1.0): Treatment = + Treatment( + id = 1, name = "EEn TFS", type = TreatmentType.ESTRADIOL, + route = com.hormonetrack.data.model.AdministrationRoute.INJECTION_SUBCUT, + doseAmount = 5.0, doseUnit = "mg", + esterType = "EEN", pkModel = "TFS", scaleFactor = scaleFactor, + createdAt = 0 + ) + + private fun doses(t: Treatment): List = + listOf(com.hormonetrack.data.model.DoseLog(id = 1, treatmentId = 1, timestamp = 0L, doseAmount = 5.0)) + + private fun lab(value: Double, tMs: Long): LabResult = + LabResult(marker = "E2", value = value, unit = "pg/mL", timestamp = tMs) + + private fun tLab(tMs: Long): LabResult = + LabResult(marker = "T", value = 0.35, unit = "ng/mL", timestamp = tMs) + + private fun initProfiles() { + if (!PKProfileStore.hasProfile("EV", "ESE")) setup() + } + + /** Point à mi-chemin de l'index (représentatif du milieu de la fenêtre). */ + private fun List.mid(): LevelPoint = this[size / 2] + + // ── Garanties ──────────────────────────────────────────────────────────── + + @Test + fun `passe exactement sur chaque lab`() { + initProfiles() + val tr = treatment() + val t2 = 2 * DAY_MS + val t7 = 7 * DAY_MS + val labs = listOf(lab(300.0, t2), lab(260.0, t7)) + + val curve = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS + ) + + assertEquals(2, curve.anchoredLabs) + // Aux timestamps des labs : courbe == lab EXACTEMENT (fp ≈ 1e-6) + assertEquals(labs[0].value, curve.points.first { it.timestamp == t2 }.e2, 1e-6) + assertEquals(labs[1].value, curve.points.first { it.timestamp == t7 }.e2, 1e-6) + } + + @Test + fun `rho interpole en log entre deux labs (math exacte)`() { + initProfiles() + val tr = treatment() + val doses = doses(tr) + val t2 = 2 * DAY_MS + val t7 = 7 * DAY_MS + // ⚠️ Labs DATA-DRIVEN : lab = M(t) × facteur voulu — sinon les ratios + // ne sont PAS les valeurs voulues (M(2 j) ≠ le pic de référence 160) + val m2 = PharmacokineticEngine.e2At(listOf(tr), doses, t2, null, null) + val m7 = PharmacokineticEngine.e2At(listOf(tr), doses, t7, null, null) + val labs = listOf(lab(0.9 * m2, t2), lab(1.2 * m7, t7)) + + // 1) La fonction PURE ratioAt interpole exactement (ancres artificiels) + val pure = listOf(t2 to 0.9, t7 to 1.2) + val expectedRhoMid = exp((log(0.9) + log(1.2)) / 2.0) + assertEquals(0.9, LabTrajectoryModel.ratioAt(pure, t2), 1e-12) + assertEquals(1.2, LabTrajectoryModel.ratioAt(pure, t7), 1e-12) + assertEquals(expectedRhoMid, LabTrajectoryModel.ratioAt(pure, (t2 + t7) / 2), 1e-9) + + // 2) La COURBE restitue le même ρ : recomposer M(t) et vérifier + // courbe/M == ρ attendu (M calculé par le moteur, comme le module) + val curve = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses, labs, 0L, 30 * DAY_MS, HOUR_MS + ) + val mid = curve.points.mid() + val m = PharmacokineticEngine.e2At( + listOf(tr.copy(scaleFactor = 1.0)), doses, mid.timestamp, null, null) + assertEquals(expectedRhoMid, mid.e2 / m, 1e-9) + } + + @Test + fun `un seul lab significatif donne une courbe vide (pas d intervalle)`() { + initProfiles() + val tr = treatment() + val curve = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), listOf(lab(200.0, 3 * DAY_MS)), 0L, 30 * DAY_MS, HOUR_MS + ) + assertTrue("le lab compte mais ne suffit pas à tracer", curve.points.isEmpty()) + assertTrue(curve.anchoredLabs == 1) + } + + @Test + fun `garde 61 - un lab hors de la fenetre d action n est pas un ancrage`() { + initProfiles() + val tr = treatment() + // Scénario #61 : labs DANS la fenêtre d'action + un lab TARDIF à + // valeur absurde (prédiction résiduelle ≈ 0 → ratio aberrant) + val t2 = 2 * DAY_MS + val t7 = 7 * DAY_MS + val curve = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), + listOf(lab(300.0, t2), lab(260.0, t7), lab(3000.0, 60 * DAY_MS)), + 0L, 90 * DAY_MS, HOUR_MS + ) + assertTrue("le lab tardif est rejeté par labIsSignificant", curve.anchoredLabs == 2) + assertTrue("aucun point ne suit la valeur absurde", curve.points.none { it.e2 > 600.0 }) + // …et la fenêtre s'arrête au DERNIER lab SIGNIFICATIF (pas au tardif) + assertTrue("modelOverride modifie la forme intermédiaire (ESE ≠ TFS)", curve.points.all { it.timestamp <= t7 }) + } + + @Test + fun `labs avant la premiere dose ne produisent aucun ancrage (prediction nulle)`() { + initProfiles() + val tr = treatment() + val curve = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), + listOf(lab(200.0, -10 * DAY_MS), lab(210.0, -5 * DAY_MS)), + -15 * DAY_MS, 0L, HOUR_MS + ) + assertTrue(curve.points.isEmpty()) + assertTrue(curve.anchoredLabs == 0) + } + + @Test + fun `la fenetre du trace est bornée par les labs ET par la demande`() { + initProfiles() + val tr = treatment() + val t2 = 2 * DAY_MS + val t7 = 7 * DAY_MS + val labs = listOf(lab(160.0, t2), lab(140.0, t7)) + // demande PLUS LARGE que les labs → clippée aux labs + val wide = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), labs, 0L, 60 * DAY_MS, HOUR_MS + ) + assertTrue(wide.points.isNotEmpty()) + assertTrue(wide.points.all { it.timestamp in t2..t7 }) + // demande PLUS ÉTROITE que les labs → clippée à la demande + val narrow = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), labs, 3 * DAY_MS, 5 * DAY_MS, HOUR_MS + ) + assertTrue(narrow.points.isNotEmpty()) + assertTrue(narrow.points.all { it.timestamp in 3 * DAY_MS..5 * DAY_MS }) + } + + @Test + fun `indépendant du scaleFactor stocké (sinon double correction)`() { + initProfiles() + val labs = listOf(lab(300.0, 2 * DAY_MS), lab(260.0, 7 * DAY_MS)) + val a = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(treatment(1.0)), doses(treatment(1.0)), labs, 0L, 30 * DAY_MS, HOUR_MS + ) + val b = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(treatment(0.55)), doses(treatment(0.55)), labs, 0L, 30 * DAY_MS, HOUR_MS + ) + // M diffère (0,55 vs 1,0) MAIS ρ compense exactement : courbes identiques + assertEquals(a.points, b.points) + } + + @Test + fun `modelOverride - chaque modèle ancre avec SA prédiction (sémantique 60)`() { + initProfiles() + // ⚠️ ESE vs TFS : PROUVENT quasi-identiques par construction (doc §8, + // seuil 2 % — le test WHSAH côté moteur a la même limite). Pour une + // divergence franche : EV avec ESE (pic 61,12 @ 45 h) vs WHS + // (73,5 @ 41 h, biodispo F<1) — ~20 % d'écart de forme. + val tr = treatment().copy( + esterType = "EV", pkModel = "TFS", + route = com.hormonetrack.data.model.AdministrationRoute.INJECTION_IM, + ) + val labs = listOf(lab(320.0, 2 * DAY_MS), lab(240.0, 7 * DAY_MS)) + val ese = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS, modelOverride = "ESE") + val whs = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS, modelOverride = "WHS") + // Les DEUX passent par les labs (invariant)… + assertEquals(labs[0].value, ese.points.first { it.timestamp == 2 * DAY_MS }.e2, 1e-6) + assertEquals(labs[0].value, whs.points.first { it.timestamp == 2 * DAY_MS }.e2, 1e-6) + // …mais les formes intermédiaires diffèrent nettement + val midEse = ese.points.mid().e2 + val midWhs = whs.points.mid().e2 + val relDiff = Math.abs(midEse - midWhs) / Math.max(Math.abs(midEse), Math.abs(midWhs)) + // ⚠️ L'ancrage CONVERGE les modèles (mêmes points traversés + ρ interpolé + // dans le même esprit) : l'écart brut ESE/WHS (~20 %) est résorbé à + // ~2 % au mi-chemin — seuil identique au test moteur WHS (doc §8). + assertTrue("le modelOverride garde une empreinte intermédiaire (ESE $midEse vs WHS $midWhs)", + relDiff > 0.02) + } + + @Test + fun `les labs T et autres marqueurs sont ignorés (E2 uniquement)`() { + initProfiles() + val tr = treatment() + val labs = listOf(lab(300.0, 2 * DAY_MS), tLab(3 * DAY_MS), lab(240.0, 7 * DAY_MS)) + val curve = LabTrajectoryModel.computeLabAnchoredCurve( + listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS) + assertTrue("le lab T n'est pas un ancrage", curve.anchoredLabs == 2) + } + + @Test + fun `doublons de timestamp - le deuxième ratio gagne (garde division par zéro)`() { + val t = 5 * DAY_MS + val anchors = listOf(t to 1.0, t to 2.0, 10 * DAY_MS to 3.0) + assertEquals(3.0, LabTrajectoryModel.ratioAt(anchors, 10 * DAY_MS), 1e-12) + // au-delà de t, la section [t ; 10 j] interpole depuis r=2 (le DERNIER + // ratio porté par ce timestamp), jamais division par 0 + val expected = exp(ln(2.0) + (7.0 - 5.0) / 5.0 * (ln(3.0) - ln(2.0))) + assertEquals(expected, LabTrajectoryModel.ratioAt(anchors, 7 * DAY_MS), 1e-9) + } + + @Test + fun `ratioAt - gardes defensives aux bornes`() { + val anchors = listOf(0L to 1.0, 10L to 2.0) + assertEquals(1.0, LabTrajectoryModel.ratioAt(anchors, -5L), 1e-12) + assertEquals(2.0, LabTrajectoryModel.ratioAt(anchors, 100L), 1e-12) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6446d32..da6f529 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,33 @@ 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.5.0] — 2026-09-11 (versionCode 31) + +### « Tracé labs » — courbe ancrée sur tes prises de sang + +- **Nouvelle courbe superposable** (chip `Tracé labs`, **off par défaut**) + : `courbe(t) = M(t) × ρ(t)` — le modèle PK garde la forme biologique + (Tmax, queues, accumulation) et l'amplitude est recalée sur CHAQUE prise + de sang : la trajectoire réelle que tes labs tracent, avec la dynamique + du modèle entre les points. +- **Passage exact garanti** sur chaque lab ; transition continue + (log-linéaire) d'un lab au suivant — jamais de saut vertical. +- Garde anti-ratés : un lab qui ne « gouverne » plus la prédiction du + modèle (< 15 % du max, cf leçon #61) n'est PAS forcé-traversé — une + dose de test de janvier ne crée pas de zigzag. +- **E2 uniquement** (unité pg/mL) ; fenêtre = entre le 1er et le dernier + lab significatif — rien avant/après : la courbe s'arrête à ta dernière + prise de sang (honnête, pas d'extrapolation). +- Indépendante de la calibration (elle est SA propre référence) et + n'entre JAMAIS dans l'accueil/les seuils d'alerte/les rappels — une + courbe de comparaison, pas une estimation d'action. +- Couleur dédiée rose foncé pointillé, hors pics/creux. + +### Améliorations UI + +- La rangée de chips du graphique défile horizontalement (le 4ᵉ chip + était coupé hors fenêtre sur écrans étroits — constat émulateur v1.5.0). + ## [1.4.10] — 2026-09-07 (versionCode 30) ### Corrigé diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 400e90e..b1cb242 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -63,6 +63,7 @@ Fonctionnalités v1 : | Date | Événement | |---|---| +| 11 sept. 2026 (session v1.5.0) | **« Tracé labs »** (demande débattue) : courbe hybride `M(t)×ρ(t)` ancrée sur les labs — ρ log-linéaire entre labs, garde #61, E2 seul, fenêtre labs, chip off par défaut + scroll horizontal CONFINÉ (chip 4ᵉ coupé hors fenêtre, constat émulateur) + leçon #63 légende dupliquée ("LAB" tombant dans else TFS) ; ChartSeries.showExtrema=false pour la série ; 11 tests LabTrajectoryModelTest (183 verts) + lint ; validé émulateur (courbe passe exactement sur le lab 248, 0 crash). | | 5 sept. 2026 (session 1) | Plan, vérification GT 3 = Lite Wearable, création couche données + ancien moteur Bateman + ancien ReminderManager. Extraction des modèles du `Estrogen.ods` → `/tmp/pk_models.json` (6 profils × 8001 h + params D/k1–k3). | | 5 sept. 2026 | L'utilisatrice mentionne un travail d'un assistant tiers « **Mimo V2.5** » : **aucune trace trouvée** (fichiers identiques à la session 1, timestamps identiques). Reprise depuis l'état existant. Bugs trouvés au passage : settings.gradle, BootReceiver, cancel PendingIntent. | | 5 sept. 2026 (session build) | Redesign données (ester/pkModel/scaleFactor), réécriture moteur PK sur tables ODS, modèle T + calibration, rappels complets, UI 6 écrans, chart Canvas, backup JSON, i18n, wrapper Gradle, guide. **Installation SDK Android (brew) + premier build.** | @@ -466,9 +467,54 @@ niveau que les deux autres, pas de remplacement »). `ChartWhs`/`ChartTWhs`), 6 presets, dropdown modèle à 3 choix, `Esters.choicesForModel` (TFS=7 / WHS=6 / ESE=3 — testé). +### 7.10 « Tracé labs » (v1.5.0) — `pk/LabTrajectoryModel.kt` + +Courbe hybride ancrée sur les labs — **demande v1.5.0, débattue avec +l'utilisatrice** (hésitation assumée, cf §2) : « biologiquement plausible +MAIS passant par tous les points ». + +- **Formule** : `courbe(t) = M(t) × ρ(t)` où + - `M(t)` = niveau du modèle PK BRUT (scaleFactor forcé 1, modèle stocké + ou `modelOverride`) — porte toute la physiologie entre les labs + (Tmax, queues, accumulation) ; + - `ρ_k = lab_k / M(t_k)` à chaque lab ; ENTRE deux labs consécutifs, + ρ interpole LOG-LINÉAIREMENT : + `ρ(t) = exp(lnρ_k + f·(lnρ_{k+1} − lnρ_k))` — morphing d'amplitude + multiplicativement continu (jamais de saut vertical) ; + - passage EXACT garanti : courbe(t_k) = M(t_k)·ρ_k = lab_k (épinglé). +- **Garde anti-#61** : labs croissants + `labIsSignificant` — un lab hors + fenêtre d'action (dose de test de janvier, labs tardifs) N'EST PAS un + ancrage (reproduction épinglée dans LabTrajectoryModelTest). +- **E3 = E2 uniquement** : les labs T sont trop rares + unités variées + (bug #26) — hors scope (choix v1.5.0). Pas de courbe T pour la série + (`tStyle = null` → rien de dessiné, miroir CurveChart). +- **Fenêtre = [1er lab significatif ; dernier lab] ∩ fenêtre du graphique** — + toujours bornée aux labs ; vieillir après le dernier lab = rien dessiné + (l'app n'invente pas de niveau). +- < 2 labs significatifs → courbe vide (un lab seul = pas d'intervalle ; + un point isolé ne définit pas de tracé). +- **Indépendante de la calibration** : elle EST sa propre calibration + continue (ratio brut, `scaleFactor` stocké ignoré ET `scalePerEster` + ignoré — l'inverse serait une DOUBLE correction, épinglé par test). + efecto : les modèles rapprochés au sein du même ancrage CONVERGENT + (le mi-chemin ESE vs WHS colle à ~2 % — `modelOverride` garde une + empreinte intermédiaire, testé avec le seuil du test moteur WHS). +- **Hors pics/creux** (chip "Pics / creux") : la série passe + `showExtrema = false` — ses extrema reflèteraient l'interp ρ, pas la + physiologie du modèle. +- **UI** : chip « Tracé labs » / "Lab track" dans la RANGÉE MODÈLES (4ᵉ + chip — scroll horizontal CONFINÉ à la rangée ; constat v1.5.0 en émulateur : + le 4ᵉ chip était coupé hors fenêtre avec une Row figée). OFF par défaut. +- ⚠️ **Leçon #63 (v1.5.0, émulateur)** : la clé "LAB" dans la liste `curves` + doit être EXCLUE de la boucle de légende (`when (model)` — le `else` + = TFS imprimerait la légende TFS une 2ᵉ fois). Série → couleur dédiée + `ChartLabTrajectory` #C2185B dashed. +- **INVARIANT** : cette courbe n'entre JAMAIS dans `levelAt` — accueil, + seuils d'alerte, rappels utilisent toujours le modèle calibré (§9.bis). + ## 8. Tests unitaires -**172 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **150 sans +**183 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **161 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 = @@ -669,6 +715,17 @@ dépôt (ni maintenant, ni si le repo devient public un jour). sans Posologie ou sans doses, agrégat multi-traitements (le plus tôt gagne), traitements inactifs/désactivés → null. +- **`LabTrajectoryModelTest`** (11) : v1.5.0 — « Tracé labs » (§7.10) : + passage EXACT sur chaque lab (fp 1e-6), ρ log-linéaire (fonction pure + + courbe recomposée via le moteur — M recalcule et ρ = geometric mean), + 1 lab → vide, **garde #61** (lab tardif PAS un ancrage + fenêtre bornée + au dernier lab SIGNIFICATIF), labs avant 1ʳᵉ dose → 0 ancrage, fenêtre + clippée [labs ∩ demande], **indépendance du scaleFactor stocké** + (sinon double correction → courbes identiques sous SF 1,0 vs 0,55), + `modelOverride` (ESE vs WHS — divergence franche : ESE≈TFS quasi + identiques par CONSTRUCTION, seuil 2 % doc §8), labs T ignorés, + doublons de timestamp (2ᵉ ratio gagne), bornes défensives ratioAt. + **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. **Toute modification du moteur passe par ces tests.** Suite envisageable : Robolectric @@ -1434,8 +1491,8 @@ vérifie pas en tests JVM). 1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`, `versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y). -2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 172 au - total, 150 si `local-test-data/` est absent (les 3 classes de régression +2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 183 au + total, 161 si `local-test-data/` est absent (les 3 classes de régression réelles sont skippées via `Assume`, 6 tests chacune) ; **lint vert obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet : @@ -1757,6 +1814,12 @@ Sur le téléphone de test (à compléter par l'utilisatrice) : - [ ] **v1.4.6** : **3ᵉ modèle WHSAH** : preset WHSAH → courbe verte superposable aux 2 autres (montée plus rapide à J+1, t½ plus longues que TFS) ; PEP non couvert ; 6 presets ; dropdown modèle à 3 choix +- [ ] **v1.5.0** : **Tracé labs** : chip `Tracé labs` (rangée modèles, + off par défaut) → la courbe rose foncé pointillé passe EXACTEMENT + sur chacun de tes labs E2 entre le 1er et le dernier LAB ; le reste + du graphique (toggles modèles, calibration) n'est pas modifié ; les + jours sans labs récents : la courbe s'arrête au dernier lab (le + vieillir est normal) ; chip décoché = plus de série ; 0 crash - [ ] **v1.4.10** : **pan sur la vue 24 h** : glisser horizontalement à 24 h doit déplacer la courbe (fix #62 : les deltas < 1 h s'accumulent — avant, le pan ne bougeait jamais sur 24 h) ; tester aussi à 7 j et @@ -1777,7 +1840,7 @@ Sur le téléphone de test (à compléter par l'utilisatrice) : tap ; et TOUTES les courbes affichées reflètent la calibration (test) --- -*Doc mise à jour le 7 sept. 2026 (v1.4.10) — build OK, lint vert, 172/172 tests verts (150 sans +*Doc mise à jour le 11 sept. 2026 (v1.5.0) — build OK, lint vert, 183/183 tests verts (161 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