v1.5.0 : « Tracé labs » — courbe hybride ancrée sur les labs (M(t)×ρ(t))

Demande débattue avec l'utilisatrice (hésitation assumée, décision v1.5.0)
: une courbe biologiquement plausible MAIS passant par tous les labs.
- pk/LabTrajectoryModel.kt (pur) : M(t) = modèle PK brut (scaleFactor
  forcé 1) ; ρ_k = lab/M(t_k) interpolé LOG-LINÉAIREMENT entre labs
  consécutifs — passgage exact garanti, morphing d'amplitude continu.
  Garde anti-#61 (labIsSignificant : lab hors fenêtre d'action PAS un
  ancrage). E2 uniquement, fenêtre [1er ; dernier lab], < 2 labs → vide.
  Indépendante de la calibration (elle est SA propre référence — sinon
  double correction, épinglé). JAMAIS dans levelAt/alertes/rappels.
- UI : chip 'Tracé labs' (off par défaut) dans la rangée modèles,
  horizontalScroll CONFINÉ à la rangée (chip 4ᵉ coupé hors fenêtre —
  constat émulateur) ; couleur ChartLabTrajectory #C2185B dashed ;
  ChartSeries.showExtrema (tracé labs hors pics/creux) ; leçon #63 :
  clé 'LAB' exclue de la boucle légende (sinon légende TFS dupliquée).
- 11 tests LabTrajectoryModelTest (183 verts total, 161 sans données
  locales) + lint ; validé émulateur §16.ter (courbe passe exactement
  sur le lab 248 avec seed réel, 0 crash).
- Docs : §7.10 (maths + décisions), §2, §8, §16, §21, CHANGELOG [1.5.0],
  README (status + feature).
This commit is contained in:
Siphonight 2026-09-11 16:11:02 +02:00
parent e508607919
commit 04f6b8bd64
11 changed files with 623 additions and 15 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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<LevelPoint>,
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<Treatment>,
doseLogs: List<DoseLog>,
e2Labs: List<LabResult>,
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<Pair<Long, Double>>()
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<LevelPoint>()
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<Pair<Long, Double>>, 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
}
}

View File

@ -41,7 +41,14 @@ data class ChartSeries(
val points: List<LevelPoint>,
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)

View File

@ -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<Pair<String, List<LevelPoint>>>(),
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<Pair<String, List<LevelPoint>>>()
// ── 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,

View File

@ -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)

View File

@ -32,6 +32,7 @@
<string name="show_labs">Analyses</string>
<string name="show_forecast">Prévision</string>
<string name="show_extrema">Pics / creux</string>
<string name="show_lab_track">Tracé labs</string>
<string name="zoom_in">Zoom avant (fenêtre plus courte)</string>
<string name="zoom_out">Zoom arrière (fenêtre plus longue)</string>
<string name="hrt_since">Sous THS depuis le %1$s</string>
@ -72,6 +73,7 @@
<string name="agenda_hint">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.</string>
<string name="agenda_permission">L\'autorisation agenda est demandée à l\'activation.</string>
<string name="legend_extrema">▲▼ pics &amp; creux estimés (par courbe)</string>
<string name="legend_lab_track">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)</string>
<string name="legend_forecast_dose">— prochaine dose projetée (marqueur vertical)</string>
<string name="back_to_present">Revenir à maintenant</string>
<string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string>

View File

@ -32,6 +32,7 @@
<string name="show_labs">Labs</string>
<string name="show_forecast">Forecast</string>
<string name="show_extrema">Peaks / troughs</string>
<string name="show_lab_track">Lab track</string>
<string name="zoom_in">Zoom in (shorter window)</string>
<string name="zoom_out">Zoom out (longer window)</string>
<string name="hrt_since">Under HRT since %1$s</string>
@ -72,6 +73,7 @@
<string name="agenda_hint">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.</string>
<string name="agenda_permission">Calendar permission is requested when you enable this.</string>
<string name="legend_extrema">▲▼ estimated peaks &amp; troughs (per curve)</string>
<string name="legend_lab_track">Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab)</string>
<string name="legend_forecast_dose">— next projected dose (vertical marker)</string>
<string name="back_to_present">Back to now</string>
<string name="drag_hint">Drag the chart to the right to travel into the past.</string>

View File

@ -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<com.hormonetrack.data.model.DoseLog> =
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<LevelPoint>.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)
}
}

View File

@ -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é

View File

@ -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