v1.8.2 : maintenance — perf moteur, fraîcheur UI, code/doc nettoyés

Session d'audit complet (code + doc, 46 fichiers + 4 docs) et application
des constats sûrs.

PERF MOTEUR PK (résultat identique — ordre de sommation préservé, 223
tests verts inchangés) :
- e2At/computeCurve : cutoffHours pré-calculé par traitement (le chemin
  chaud du tracé labs recalculait ~430 k appels/refresh), Bateman
  paresseux (inutile pour les traitements à profil), doses pré-groupées
  par traitement (fin des scans complets).
- AppLog : purge sans recopie ; ChangelogHelper : Regex précompilées ;
  CurveChart : SimpleDateFormat hors boucle de dessin.

FIX FRAÎCHEUR (#65/#66) :
- Accueil : clés produceState complétées (allTreatments, tConfig) —
  éditer un scaleFactor/le modèle T actualise la courbe sans attendre le
  tick de 60 s.
- Graphiques : clé labResults (liste entière, plus .size) — éditer la
  valeur d'une analyse actualise les courbes.

MAINTENANCE :
- Code mort supprimé (Repository/DAO sans appelants, currentLevel,
  scheduler2, variable morte, Spacer no-op) ; imports morts/redondants
  nettoyés (9 fichiers) ; qualifications raccourcies.
- Médiane de calibration factorisée (4 copies → medianRounded) ;
  double-filtre computeTConfigCalibration supprimé (soussumé) ;
  formatDose dédupliqué ; en-têtes/KDoc obsolètes mis à jour.

DOC : ~35 corrections (compteurs actuels 223/193 partout — §16, §14,
§8.bis ; TOC + 4 ancres réparées ; inventaire §8 à jour + fusions
ChartZoom/WhsahEngine + ajout LabTiming/AutoBackupRetention/
ExportFileNames ; titres CHANGELOG doublés [1.4.9]/[1.4.8] supprimés ;
§19 backup = manuel + auto ; versions « v1.2.0 »/« Gradle 8.9 »/« API 34 »
corrigées ; ratios #61 complétés 383/391 ; §21 item v1.8.1 + chronologie ;
§14 #65/#66 ajoutés). Rétention Ratios (#14 #61) : vérifié via
ScaleFactorWhsahReproTest — les 5 valeurs 3,3/15,9/89,6/383/391 existent.

Validé émulateur §16.ter (release, seed réel v1.7.0) : accueil (encart
reco), Analyses (creux 27/09 + notes), Graphiques, 0 crash.
versionCode 37 / versionName 1.8.2. Web non concerné (aucun changement).
This commit is contained in:
Siphonight 2026-09-18 14:21:32 +02:00
parent 2ec9f63f93
commit f6be3d86a9
28 changed files with 404 additions and 362 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 > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > sang et aux consignes de ton endocrinologue.
- **Statut** : v1.8.1 — build Android ✅, **lint vert** ✅, **223 tests unitaires** ✅ (193 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.8.2 — build Android ✅, **lint vert** ✅, **223 tests unitaires** ✅ (193 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) - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
@ -64,11 +64,11 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
- **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées - **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées
**côte à côte** quand elles partagent la même date/heure, éditables (tap → choix de **côte à côte** quand elles partagent la même date/heure, éditables (tap → choix de
l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
- **Prochaine prise de sang suggérée (v1.8.0)** : la page Analyses recommande le - **Prochaine prise de sang suggérée (v1.8.0, étendu v1.8.1 aux pages Analyses et Accueil)** : la page Analyses recommande le
**creux estimé juste avant l'injection suivante** (moment le plus comparable), **creux estimé juste avant l'injection suivante** (moment le plus comparable),
**au premier creux où ton régime est stabilisé** (~5 demi-vies après ton dernier **au premier creux où ton régime est stabilisé** (~5 demi-vies après ton dernier
changement — dose, ester ou intervalle) — déduit des courbes et de la Posologie ; requiert un changement — dose, ester ou intervalle) — déduit des courbes et de la Posologie ; requiert un
injectable E2 actif à Posologie (une invite invite à la renseigner sinon) ; injectable E2 actif à Posologie (une invite propose de la renseigner sinon) ;
estimations, jamais un avis médical estimations, jamais un avis médical
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle), - **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
@ -128,7 +128,7 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) — Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
`Siphonight/HormoneTrack` sur les deux (privé), avec `Siphonight/HormoneTrack` sur les deux (privé), avec
**releases taguées** (`v1.1.0` → `v1.4.10`) et **deux APK par release** (depuis v1.2.5) **releases taguées** (`v1.1.0` → `v1.8.1`) et **deux APK par release** (depuis v1.2.5)
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) : (téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
`-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) : `-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) :

View File

@ -17,8 +17,8 @@ android {
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
// les APK publiés (Paramètres + titre du dialog « Nouveautés »). // les APK publiés (Paramètres + titre du dialog « Nouveautés »).
versionCode = 36 versionCode = 37
versionName = "1.8.1" versionName = "1.8.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -9,6 +9,7 @@ import com.hormonetrack.reminder.createNotificationChannel
import com.hormonetrack.util.AppLog import com.hormonetrack.util.AppLog
import com.hormonetrack.reminder.AlertNotifier import com.hormonetrack.reminder.AlertNotifier
import com.hormonetrack.settings.AppSettings import com.hormonetrack.settings.AppSettings
import com.hormonetrack.data.backup.AutoBackupScheduler
class HormoneTrackApp : Application() { class HormoneTrackApp : Application() {
@ -31,7 +32,7 @@ class HormoneTrackApp : Application() {
// le worker sort immédiatement si l'option est désactivée ; cf // le worker sort immédiatement si l'option est désactivée ; cf
// data/backup/AutoBackupWorker.kt). WorkManager survit aux reboots : // data/backup/AutoBackupWorker.kt). WorkManager survit aux reboots :
// BootReceiver n'a rien à faire. // BootReceiver n'a rien à faire.
com.hormonetrack.data.backup.AutoBackupScheduler.schedulePeriodic(this) AutoBackupScheduler.schedulePeriodic(this)
} }
} }

View File

@ -8,7 +8,6 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.hormonetrack.data.backup.UserSettings
import com.hormonetrack.util.AppLog import com.hormonetrack.util.AppLog
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -109,13 +108,15 @@ class AutoBackupWorker(
val ok = try { val ok = try {
// MÊME contenu que l'export manuel (format v2 avec paramètres) : // MÊME contenu que l'export manuel (format v2 avec paramètres) :
// un auto-backup est importable tel quel (rétrocompat assurée) // un auto-backup est importable tel quel (rétrocompat assurée)
// UNE seule collecte des seuils (même motif que l'export manuel)
val thresholds = settings.alertThresholds.first()
val userSettings = UserSettings( val userSettings = UserSettings(
language = settings.language.first(), language = settings.language.first(),
autoCalibrate = settings.autoCalibrate.first(), autoCalibrate = settings.autoCalibrate.first(),
alertE2High = settings.alertThresholds.first().e2High, alertE2High = thresholds.e2High,
alertE2Low = settings.alertThresholds.first().e2Low, alertE2Low = thresholds.e2Low,
alertTHigh = settings.alertThresholds.first().tHigh, alertTHigh = thresholds.tHigh,
alertTLow = settings.alertThresholds.first().tLow alertTLow = thresholds.tLow
) )
val json = BackupManager.exportJson(container.repository, settings.tConfig.first(), userSettings) val json = BackupManager.exportJson(container.repository, settings.tConfig.first(), userSettings)
val keep = settings.autoBackupKeep.first() val keep = settings.autoBackupKeep.first()

View File

@ -9,18 +9,10 @@ interface DoseLogDao {
@Query("SELECT * FROM dose_logs ORDER BY timestamp DESC") @Query("SELECT * FROM dose_logs ORDER BY timestamp DESC")
fun getAll(): Flow<List<DoseLog>> fun getAll(): Flow<List<DoseLog>>
@Query("SELECT * FROM dose_logs WHERE treatmentId = :treatmentId ORDER BY timestamp DESC")
fun getByTreatment(treatmentId: Long): Flow<List<DoseLog>>
@Query("SELECT * FROM dose_logs WHERE timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
fun getByTimeRange(start: Long, end: Long): Flow<List<DoseLog>>
@Query("SELECT * FROM dose_logs WHERE treatmentId = :treatmentId AND timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
fun getByTreatmentAndTimeRange(treatmentId: Long, start: Long, end: Long): Flow<List<DoseLog>>
@Query("SELECT * FROM dose_logs ORDER BY timestamp DESC LIMIT 1")
fun getLatest(): Flow<DoseLog?>
@Query("SELECT * FROM dose_logs ORDER BY timestamp ASC") @Query("SELECT * FROM dose_logs ORDER BY timestamp ASC")
suspend fun getAllOnce(): List<DoseLog> suspend fun getAllOnce(): List<DoseLog>

View File

@ -9,12 +9,8 @@ interface LabResultDao {
@Query("SELECT * FROM lab_results ORDER BY timestamp DESC") @Query("SELECT * FROM lab_results ORDER BY timestamp DESC")
fun getAll(): Flow<List<LabResult>> fun getAll(): Flow<List<LabResult>>
@Query("SELECT * FROM lab_results WHERE marker = :marker ORDER BY timestamp DESC")
fun getByMarker(marker: String): Flow<List<LabResult>>
@Query("SELECT * FROM lab_results WHERE timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
fun getByTimeRange(start: Long, end: Long): Flow<List<LabResult>>
@Query("SELECT * FROM lab_results ORDER BY timestamp ASC") @Query("SELECT * FROM lab_results ORDER BY timestamp ASC")
suspend fun getAllOnce(): List<LabResult> suspend fun getAllOnce(): List<LabResult>

View File

@ -15,9 +15,6 @@ interface TreatmentDao {
@Query("SELECT * FROM treatments WHERE id = :id") @Query("SELECT * FROM treatments WHERE id = :id")
suspend fun getById(id: Long): Treatment? suspend fun getById(id: Long): Treatment?
@Query("SELECT * FROM treatments WHERE isActive = 1 ORDER BY createdAt DESC")
suspend fun getActiveOnce(): List<Treatment>
@Query("SELECT * FROM treatments ORDER BY createdAt DESC") @Query("SELECT * FROM treatments ORDER BY createdAt DESC")
suspend fun getAllOnce(): List<Treatment> suspend fun getAllOnce(): List<Treatment>
@ -35,6 +32,4 @@ interface TreatmentDao {
@Delete @Delete
suspend fun delete(treatment: Treatment) suspend fun delete(treatment: Treatment)
@Query("UPDATE treatments SET isActive = :active WHERE id = :id")
suspend fun setActive(id: Long, active: Boolean)
} }

View File

@ -115,6 +115,9 @@ data class Treatment(
) { ) {
val isInjection: Boolean val isInjection: Boolean
get() = route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT get() = route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT
// (redondant avec PharmacokineticEngine.isInjectionRoute — volontaire :
// data.model ne doit PAS dépendre de pk (cycle de couches). Si la
// définition d'« injectable » change, changer les DEUX endroits.)
val usesProfileModel: Boolean val usesProfileModel: Boolean
get() = isInjection && esterType != Esters.NONE get() = isInjection && esterType != Esters.NONE

View File

@ -18,15 +18,6 @@ class HormoneRepository(
val allDoseLogs: Flow<List<DoseLog>> = doseLogDao.getAll() val allDoseLogs: Flow<List<DoseLog>> = doseLogDao.getAll()
val allLabResults: Flow<List<LabResult>> = labResultDao.getAll() val allLabResults: Flow<List<LabResult>> = labResultDao.getAll()
fun getDosesByTimeRange(start: Long, end: Long): Flow<List<DoseLog>> =
doseLogDao.getByTimeRange(start, end)
fun getDosesByTreatmentAndTimeRange(treatmentId: Long, start: Long, end: Long): Flow<List<DoseLog>> =
doseLogDao.getByTreatmentAndTimeRange(treatmentId, start, end)
fun getLabsByMarker(marker: String): Flow<List<LabResult>> =
labResultDao.getByMarker(marker)
suspend fun getTreatmentById(id: Long): Treatment? = treatmentDao.getById(id) suspend fun getTreatmentById(id: Long): Treatment? = treatmentDao.getById(id)
suspend fun allTreatmentsOnce(): List<Treatment> = treatmentDao.getAllOnce() suspend fun allTreatmentsOnce(): List<Treatment> = treatmentDao.getAllOnce()

View File

@ -120,9 +120,9 @@ object LabTiming {
// ── 2) t½ terminale de l'ester (analytique ou lue dans la table) ──── // ── 2) t½ terminale de l'ester (analytique ou lue dans la table) ────
val tHalfDays = when (carrier.pkModel) { val tHalfDays = when (carrier.pkModel) {
PKModels.TRANSFEM_SCIENCE -> PKModels.TRANSFEM_SCIENCE ->
com.hormonetrack.pk.TransfemScienceModels.model(carrier.esterType)?.terminalHalfLifeDays TransfemScienceModels.model(carrier.esterType)?.terminalHalfLifeDays
PKModels.WHSAH -> PKModels.WHSAH ->
com.hormonetrack.pk.WhsahModels.model(carrier.esterType)?.terminalHalfLifeDays WhsahModels.model(carrier.esterType)?.terminalHalfLifeDays
PKModels.ESTRANNAISE -> PKModels.ESTRANNAISE ->
PKProfileStore.terminalHalfLifeDays(carrier.esterType, carrier.pkModel) PKProfileStore.terminalHalfLifeDays(carrier.esterType, carrier.pkModel)
else -> null else -> null

View File

@ -2,7 +2,6 @@ package com.hormonetrack.pk
import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType import com.hormonetrack.data.model.TreatmentType
import kotlin.math.exp import kotlin.math.exp
@ -188,7 +187,7 @@ object LabTrajectoryModel {
var maxPredictionSoFar = 0.0 var maxPredictionSoFar = 0.0
for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) { for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) {
val predicted = PharmacokineticEngine.e2At( val predicted = PharmacokineticEngine.e2At(
unscaled, doseLogs, lab.timestamp, modelOverride, null unscaled, doseLogs, lab.timestamp, modelOverride, scalePerEster = null
) )
if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue
anchors.add(lab.timestamp to (lab.value / predicted)) anchors.add(lab.timestamp to (lab.value / predicted))
@ -221,7 +220,7 @@ object LabTrajectoryModel {
val points = mutableListOf<LevelPoint>() val points = mutableListOf<LevelPoint>()
var t = t0 var t = t0
while (t <= t1) { while (t <= t1) {
val m = PharmacokineticEngine.e2At(unscaled, doseLogs, t, modelOverride, null) val m = PharmacokineticEngine.e2At(unscaled, doseLogs, t, modelOverride, scalePerEster = null)
val rho = ratioAt(anchors, t) val rho = ratioAt(anchors, t)
points.add(LevelPoint(t, m * rho, 0.0)) // t=0 : pas de courbe T pour cette série points.add(LevelPoint(t, m * rho, 0.0)) // t=0 : pas de courbe T pour cette série
t += stepMs t += stepMs

View File

@ -2,6 +2,8 @@ package com.hormonetrack.pk
import android.content.Context import android.content.Context
import com.google.gson.JsonParser import com.google.gson.JsonParser
import com.hormonetrack.data.model.PKModels
import kotlin.math.ln
object PKProfileStore { object PKProfileStore {
@ -36,7 +38,7 @@ object PKProfileStore {
} }
fun profileKey(ester: String, model: String): String = fun profileKey(ester: String, model: String): String =
"${ester}_${if (model == com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE) "tfs" else "ese"}" "${ester}_${if (model == PKModels.TRANSFEM_SCIENCE) "tfs" else "ese"}"
/** /**
* Case-insensitive lookup. The ODS asset keys use the biological casing * Case-insensitive lookup. The ODS asset keys use the biological casing
@ -56,11 +58,6 @@ object PKProfileStore {
fun profileLength(ester: String, model: String): Int = fun profileLength(ester: String, model: String): Int =
lookup(profileKey(ester, model))?.size ?: 0 lookup(profileKey(ester, model))?.size ?: 0
/**
* Normalized response (pg/mL per mg injected) at dtHours after a 1 mg injection,
* linearly interpolated between hourly points; beyond the table, extrapolated
* with the terminal exponential slope.
*/
/** /**
* Demi-vie TERMINALE (JOURS) estimée depuis la table horaire (v1.8.0) : * Demi-vie TERMINALE (JOURS) estimée depuis la table horaire (v1.8.0) :
* même logique que l'extrapolation de [sample] (dernier point ≥ 1 % du * même logique que l'extrapolation de [sample] (dernier point ≥ 1 % du
@ -80,14 +77,14 @@ object PKProfileStore {
fun terminalHalfLifeDays(ester: String, model: String): Double? { fun terminalHalfLifeDays(ester: String, model: String): Double? {
if (!initialized) return null if (!initialized) return null
val suffix = when (model) { val suffix = when (model) {
com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE -> "tfs" PKModels.TRANSFEM_SCIENCE -> "tfs"
com.hormonetrack.data.model.PKModels.ESTRANNAISE -> "ese" PKModels.ESTRANNAISE -> "ese"
else -> return 0.0 else -> return 0.0
} }
val arr = lookup("${ester}_$suffix") ?: return null val arr = lookup("${ester}_$suffix") ?: return null
val (j, rate) = terminalDecayParameters(arr) ?: return null val (j, rate) = terminalDecayParameters(arr) ?: return null
if (rate >= 0.0) return null if (rate >= 0.0) return null
return kotlin.math.ln(2.0) / -rate / 24.0 // pente /h → t½ en jours return ln(2.0) / -rate / 24.0 // pente /h → t½ en jours
} }
/** /**
@ -117,11 +114,16 @@ object PKProfileStore {
return j to rate return j to rate
} }
/**
* Normalized response (pg/mL per mg injected) at dtHours after a 1 mg injection,
* linearly interpolated between hourly points; beyond the table, extrapolated
* with the terminal exponential slope.
*/
fun sample(ester: String, model: String, dtHours: Double): Double { fun sample(ester: String, model: String, dtHours: Double): Double {
if (!initialized || dtHours <= 0.0) return 0.0 if (!initialized || dtHours <= 0.0) return 0.0
val suffix = when (model) { val suffix = when (model) {
com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE -> "tfs" PKModels.TRANSFEM_SCIENCE -> "tfs"
com.hormonetrack.data.model.PKModels.ESTRANNAISE -> "ese" PKModels.ESTRANNAISE -> "ese"
else -> return 0.0 else -> return 0.0
} }
val arr = lookup("${ester}_$suffix") ?: return 0.0 val arr = lookup("${ester}_$suffix") ?: return 0.0

View File

@ -165,17 +165,32 @@ object PharmacokineticEngine {
*/ */
scalePerEster: Map<String, Double>? = null scalePerEster: Map<String, Double>? = null
): Double { ): Double {
// Pré-calculs PAR TRAITEMENT (v1.8.2 — le chemin chaud du tracé labs
// appelle e2At par point de grille : 720 pts × N traitements × D doses
// recalculaient cutoffHours et batemanParams à chaque itération) :
// - cutoff : déterministe par traitement → calculé UNE fois ;
// - Bateman (bisection 50 itérations + ln) : inutile pour les
// traitements à PROFIL (concentrationOfDose ne lit le paramètre
// que dans la branche Bateman, inatteignable quand
// usesProfileModel == true) → calcul paresseux.
// - doses pré-groupées par traitement (fini le scan complet de
// doseLogs par traitement) — l'ordre d'itération des doses d'UN
// traitement est préservé (groupBy préserve l'ordre relatif) →
// sommation flottante identique.
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
val cutoffByTreatment = HashMap<Long, Double>()
val batemanCache = HashMap<Long, BatemanParams>() val batemanCache = HashMap<Long, BatemanParams>()
var total = 0.0 var total = 0.0
for (treatment in treatments) { for (treatment in treatments) {
if (treatment.type != TreatmentType.ESTRADIOL) continue if (treatment.type != TreatmentType.ESTRADIOL) continue
if (!batemanCache.containsKey(treatment.id)) { cutoffByTreatment[treatment.id] = cutoffHours(treatment)
if (!treatment.usesProfileModel) {
batemanCache[treatment.id] = batemanParams(treatment) batemanCache[treatment.id] = batemanParams(treatment)
} }
for (dose in doseLogs) { for (dose in dosesByTreatment[treatment.id].orEmpty()) {
if (dose.treatmentId != treatment.id || dose.timestamp > tMs) continue if (dose.timestamp > tMs) continue
val dtH = (tMs - dose.timestamp) / 3_600_000.0 val dtH = (tMs - dose.timestamp) / 3_600_000.0
if (dtH > cutoffHours(treatment)) continue if (dtH > cutoffByTreatment.getValue(treatment.id)) continue
val c = concentrationOfDose( val c = concentrationOfDose(
treatment, dose, tMs, batemanCache[treatment.id], modelOverride treatment, dose, tMs, batemanCache[treatment.id], modelOverride
) )
@ -292,7 +307,15 @@ object PharmacokineticEngine {
val searchStart = maxOf(startMs, earliestDose) val searchStart = maxOf(startMs, earliestDose)
val batemanCache = HashMap<Long, BatemanParams>() val batemanCache = HashMap<Long, BatemanParams>()
relevantTreatments.forEach { batemanCache[it.id] = batemanParams(it) } relevantTreatments.forEach {
// Bateman paresseux (v1.8.2) : inutile pour les traitements à profil
if (!it.usesProfileModel) batemanCache[it.id] = batemanParams(it)
}
// v1.8.2 : cutoffs pré-calculés (déterministes par traitement) et
// doses pré-groupées — finis les scans complets de doseLogs à chaque
// point × traitement (l'ordre de sommation est préservé, cf e2At)
val cutoffByTreatment = relevantTreatments.associate { it.id to cutoffHours(it) }
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
// Doses triées pour suivre l'« ester actif » le long de la grille (pour le k de T) // Doses triées pour suivre l'« ester actif » le long de la grille (pour le k de T)
val sortedEstrogenDoses = doseLogs val sortedEstrogenDoses = doseLogs
@ -314,10 +337,10 @@ object PharmacokineticEngine {
} }
val k = activeEster?.let { tKPerEster?.get(it) } ?: tConfig.k val k = activeEster?.let { tKPerEster?.get(it) } ?: tConfig.k
for (treatment in relevantTreatments) { for (treatment in relevantTreatments) {
val cutoff = cutoffHours(treatment) val cutoff = cutoffByTreatment.getValue(treatment.id)
val p = batemanCache[treatment.id] val p = batemanCache[treatment.id]
for (dose in doseLogs) { for (dose in dosesByTreatment[treatment.id].orEmpty()) {
if (dose.treatmentId != treatment.id || dose.timestamp > t) continue if (dose.timestamp > t) continue
val dtH = (t - dose.timestamp) / 3_600_000.0 val dtH = (t - dose.timestamp) / 3_600_000.0
if (dtH > cutoff) continue if (dtH > cutoff) continue
val c = concentrationOfDose(treatment, dose, t, p, modelOverride) val c = concentrationOfDose(treatment, dose, t, p, modelOverride)
@ -449,10 +472,7 @@ object PharmacokineticEngine {
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
} }
return ratiosByEster.mapValues { (_, ratios) -> return ratiosByEster.mapValues { (_, ratios) ->
val sorted = ratios.sorted() medianRounded(ratios, 2)
val median = if (sorted.size % 2 == 1) sorted[sorted.size / 2]
else (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
(median * 100).toInt() / 100.0
} }
} }
@ -508,12 +528,7 @@ object PharmacokineticEngine {
} }
if (e2 > maxE2SoFar) maxE2SoFar = e2 if (e2 > maxE2SoFar) maxE2SoFar = e2
} }
return ksByEster.mapValues { (_, ks) -> return ksByEster.mapValues { (_, ks) -> medianRounded(ks, 3) }
val sorted = ks.sorted()
val median = if (sorted.size % 2 == 1) sorted[sorted.size / 2]
else (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
(median * 1000).toInt() / 1000.0
}
} }
/** /**
@ -602,6 +617,20 @@ object PharmacokineticEngine {
* jusqu'ici (les labs doivent être traités par timestamp CROISSANT) ; * jusqu'ici (les labs doivent être traités par timestamp CROISSANT) ;
* 0 = premier lab → seule la garde > 0,5 s'applique. * 0 = premier lab → seule la garde > 0,5 s'applique.
*/ */
/**
* MÉDIANE arrondie d'une liste de ratios (helper partagé par les 4
* pipelines de calibration — était dupliqué 4×). Liste triée en place.
* @param decimals nombre de décimales après arrondi (via troncature ×10^n)
*/
private fun medianRounded(values: MutableList<Double>, decimals: Int): Double {
values.sort()
val median = if (values.size % 2 == 1) values[values.size / 2]
else (values[values.size / 2 - 1] + values[values.size / 2]) / 2.0
val factor = Math.pow(10.0, decimals.toDouble())
return (median * factor).toInt() / factor
}
fun labIsSignificant(predicted: Double, maxSoFar: Double): Boolean = fun labIsSignificant(predicted: Double, maxSoFar: Double): Boolean =
predicted > 0.5 && predicted > 0.5 &&
(maxSoFar <= 0.0 || predicted >= LAB_MIN_PREDICTION_FRACTION * maxSoFar) (maxSoFar <= 0.0 || predicted >= LAB_MIN_PREDICTION_FRACTION * maxSoFar)
@ -636,13 +665,7 @@ object PharmacokineticEngine {
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
} }
if (ratios.isEmpty()) return null if (ratios.isEmpty()) return null
ratios.sort() return medianRounded(ratios, 2)
val median = if (ratios.size % 2 == 1) {
ratios[ratios.size / 2]
} else {
(ratios[ratios.size / 2 - 1] + ratios[ratios.size / 2]) / 2.0
}
return (median * 100).toInt() / 100.0
} }
fun computeTConfigCalibration( fun computeTConfigCalibration(
@ -651,8 +674,10 @@ object PharmacokineticEngine {
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
current: TConfig current: TConfig
): TConfig? { ): TConfig? {
// Le premier filtre « brut > floor » est soussumé par celui sur la
// valeur CONVERTIE (toute conversion est ≤ la valeur brute) : un seul
// filtre après conversion suffit — résultat strictement identique.
val usable = tLabs val usable = tLabs
.filter { it.value > current.floor + 0.02 }
.map { it.copy(value = convertTToNgMl(it.value, it.unit)) } .map { it.copy(value = convertTToNgMl(it.value, it.unit)) }
.filter { it.value > current.floor + 0.02 } .filter { it.value > current.floor + 0.02 }
if (usable.isEmpty()) return null if (usable.isEmpty()) return null
@ -665,13 +690,7 @@ object PharmacokineticEngine {
if (k > 1e-4 && k < 10.0) ks.add(k) if (k > 1e-4 && k < 10.0) ks.add(k)
} }
if (ks.isEmpty()) return null if (ks.isEmpty()) return null
ks.sort() return current.copy(k = medianRounded(ks, 3))
val median = if (ks.size % 2 == 1) {
ks[ks.size / 2]
} else {
(ks[ks.size / 2 - 1] + ks[ks.size / 2]) / 2.0
}
return current.copy(k = (median * 1000).toInt() / 1000.0)
} }
/** /**

View File

@ -20,7 +20,6 @@ import com.hormonetrack.R
import com.hormonetrack.data.AppDatabase import com.hormonetrack.data.AppDatabase
import com.hormonetrack.pk.Alerts import com.hormonetrack.pk.Alerts
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig
import com.hormonetrack.util.AppLog import com.hormonetrack.util.AppLog
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -33,7 +32,7 @@ import java.util.concurrent.TimeUnit
* Fonctionnement : * Fonctionnement :
* - `AlertWorker` (périodique, 15 min — minimum WorkManager) évalue le * - `AlertWorker` (périodique, 15 min — minimum WorkManager) évalue le
* niveau estimé actuel (`levelAt`, MÊME calcul que la carte accueil : * niveau estimé actuel (`levelAt`, MÊME calcul que la carte accueil :
* calibration par période d'ester si l'option est active) contre les * calibration par période d'ester si l'option est active) contre les
* seuils configurés ; * seuils configurés ;
* - **anti-spam** : l'état des alertes déjà notifiées est persisté en * - **anti-spam** : l'état des alertes déjà notifiées est persisté en
* DataStore (`Alerts.encodeState`) → pas de re-notification du même * DataStore (`Alerts.encodeState`) → pas de re-notification du même

View File

@ -5,6 +5,7 @@ import android.content.Context
import android.content.ContentValues import android.content.ContentValues
import android.provider.CalendarContract import android.provider.CalendarContract
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import com.hormonetrack.ui.components.formatDose
import java.util.Calendar import java.util.Calendar
import java.util.TimeZone import java.util.TimeZone
@ -188,6 +189,6 @@ object CalendarEvents {
private fun formatInterval(treatment: Treatment): String = private fun formatInterval(treatment: Treatment): String =
treatment.forecastIntervalDays?.let { formatDose(it) } ?: "?" treatment.forecastIntervalDays?.let { formatDose(it) } ?: "?"
private fun formatDose(d: Double): String = // formatDose est la fonction PUBLIQUE de ui/components/DoseDialog.kt
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.1f".format(d) // (dédupliquée v1.8.2 — la même règle d'affichage des doses partout)
} }

View File

@ -19,6 +19,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
object ReminderContract { object ReminderContract {
const val CHANNEL_ID = "hormonetrack_reminders" const val CHANNEL_ID = "hormonetrack_reminders"
@ -215,7 +216,7 @@ class ReminderReceiver : BroadcastReceiver() {
// n'existait qu'après un reboot/une édition. Le calcul passe par le moteur // n'existait qu'après un reboot/une édition. Le calcul passe par le moteur
// (grille Posologie si elle existe, sinon quotidien). // (grille Posologie si elle existe, sinon quotidien).
val pending = goAsync() val pending = goAsync()
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.IO).launch { CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try { try {
val db = AppDatabase.getInstance(context) val db = AppDatabase.getInstance(context)
db.treatmentDao().getById(treatmentId)?.let { tr -> db.treatmentDao().getById(treatmentId)?.let { tr ->
@ -238,10 +239,10 @@ class BootReceiver : BroadcastReceiver() {
// TOUS les traitements : les inactifs doivent voir leur alarme // TOUS les traitements : les inactifs doivent voir leur alarme
// annulée au boot (sinon une alarme périmée continue de sonner) ; // annulée au boot (sinon une alarme périmée continue de sonner) ;
// les doses alimentent la grille Posologie (fix #52) // les doses alimentent la grille Posologie (fix #52)
val treatments = kotlinx.coroutines.runBlocking { val treatments = runBlocking {
db.treatmentDao().getAllOnce() db.treatmentDao().getAllOnce()
} }
val doses = kotlinx.coroutines.runBlocking { val doses = runBlocking {
db.doseLogDao().getAllOnce() db.doseLogDao().getAllOnce()
} }
AlarmScheduler(context).rescheduleAll(treatments, doses) AlarmScheduler(context).rescheduleAll(treatments, doses)

View File

@ -1,5 +1,9 @@
package com.hormonetrack.settings package com.hormonetrack.settings
/** Précompilées (v1.8.2) : appelées à chaque ouverture du dialog « Nouveautés » */
private val VERSION_PATTERN = Regex("^\\d+(\\.\\d+)*$")
private val SECTION_PATTERN = Regex("## \\[(\\d+\\.\\d+\\.\\d+)\\][^\\n]*\\n(.*?)(?=\\n## \\[|\\Z)", RegexOption.DOT_MATCHES_ALL)
/** /**
* Utilitaires pour le dialog « Nouveautés » affiché après chaque mise à jour * Utilitaires pour le dialog « Nouveautés » affiché après chaque mise à jour
* (v1.3.0). Pur (aucune dépendance Android) → testable en JVM. * (v1.3.0). Pur (aucune dépendance Android) → testable en JVM.
@ -28,7 +32,7 @@ object ChangelogHelper {
private fun parse(version: String): List<Int>? { private fun parse(version: String): List<Int>? {
val cleaned = version.trim().removePrefix("v") val cleaned = version.trim().removePrefix("v")
if (!Regex("^\\d+(\\.\\d+)*$").matches(cleaned)) return null if (!VERSION_PATTERN.matches(cleaned)) return null
return cleaned.split(".").map { it.toInt() } return cleaned.split(".").map { it.toInt() }
} }
@ -43,7 +47,7 @@ object ChangelogHelper {
* @return le markdown concaténé des nouveautés ("" si rien de nouveau). * @return le markdown concaténé des nouveautés ("" si rien de nouveau).
*/ */
fun sectionsSince(markdown: String, lastSeenVersion: String?, currentVersion: String): String { fun sectionsSince(markdown: String, lastSeenVersion: String?, currentVersion: String): String {
val pattern = Regex("## \\[(\\d+\\.\\d+\\.\\d+)\\][^\\n]*\\n(.*?)(?=\\n## \\[|\\Z)", RegexOption.DOT_MATCHES_ALL) val pattern = SECTION_PATTERN
val out = StringBuilder() val out = StringBuilder()
for (match in pattern.findAll(markdown)) { for (match in pattern.findAll(markdown)) {
val version = match.groupValues[1] val version = match.groupValues[1]

View File

@ -17,8 +17,6 @@ import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.LevelPoint
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.ui.theme.ChartE2
import com.hormonetrack.ui.theme.ChartT
import com.hormonetrack.ui.theme.LabDot import com.hormonetrack.ui.theme.LabDot
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
@ -373,6 +371,12 @@ fun CurveChart(
(markerColor.blue * 255).toInt() (markerColor.blue * 255).toInt()
) )
} }
// SimpleDateFormat créé UNE fois (hors boucle) : le Canvas se
// redessine à chaque pan/zoom, une allocation par dose × frame
// était du gaspillage (la classe n'est de toute façon pas
// thread-safe — ici on reste dans le DrawScope UI)
val hourFmt = SimpleDateFormat("HH:mm", Locale.getDefault())
hourFmt.timeZone = zone
for (dose in forecastDoses) { for (dose in forecastDoses) {
if (dose.timestamp !in t0..t1) continue if (dose.timestamp !in t0..t1) continue
val x = xOf(dose.timestamp) val x = xOf(dose.timestamp)
@ -392,8 +396,6 @@ fun CurveChart(
} }
drawPath(path, markerColor) drawPath(path, markerColor)
// heure de la dose simulée DANS le fuseau de lecture // heure de la dose simulée DANS le fuseau de lecture
val hourFmt = SimpleDateFormat("HH:mm", Locale.getDefault())
hourFmt.timeZone = zone
drawContext.canvas.nativeCanvas.drawText( drawContext.canvas.nativeCanvas.drawText(
hourFmt.format(Date(dose.timestamp)), x, padTop + 3.dp.toPx(), paint hourFmt.format(Date(dose.timestamp)), x, padTop + 3.dp.toPx(), paint
) )
@ -516,7 +518,7 @@ fun CurveChart(
* estimée de l'extremum (v1.2.6) : valeur au-dessus d'un pic, en dessous d'un * estimée de l'extremum (v1.2.6) : valeur au-dessus d'un pic, en dessous d'un
* creux — dans la couleur de la série, unité implicite (celle de l'axe). * creux — dans la couleur de la série, unité implicite (celle de l'axe).
*/ */
private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: com.hormonetrack.pk.PharmacokineticEngine.Extremum, color: Color) { private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: PharmacokineticEngine.Extremum, color: Color) {
val r = 5.dp.toPx() val r = 5.dp.toPx()
val offset = 6.dp.toPx() val offset = 6.dp.toPx()
val path = Path() val path = Path()
@ -574,7 +576,8 @@ private fun DrawScope.drawAxisUnit(
drawContext.canvas.nativeCanvas.drawText(text, x, baseline, labelPaint(align)) drawContext.canvas.nativeCanvas.drawText(text, x, baseline, labelPaint(align))
} }
private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) { for (i in 0..rows) { private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) {
for (i in 0..rows) {
val y = padTop + h * i / rows val y = padTop + h * i / rows
drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f) drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f)
} }

View File

@ -42,7 +42,6 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.LevelPoint
import com.hormonetrack.pk.LabTrajectoryModel import com.hormonetrack.pk.LabTrajectoryModel
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
@ -62,6 +61,12 @@ import com.hormonetrack.ui.theme.TealTertiary
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.hormonetrack.ui.components.clampPanHours
import com.hormonetrack.ui.components.defaultModelToggles
import com.hormonetrack.ui.components.forecastExtensionHours
import com.hormonetrack.ui.components.forecastHorizonHours
import com.hormonetrack.ui.components.panDeltaHours
import com.hormonetrack.ui.components.stepForRange
private const val HOUR_MS = 3_600_000L private const val HOUR_MS = 3_600_000L
// Zoom (v1.2.9) : plage temporelle bornée 6 h → 300 j // Zoom (v1.2.9) : plage temporelle bornée 6 h → 300 j
@ -72,10 +77,12 @@ private const val MAX_RANGE_H = 24L * 300
* Écran Graphiques. * Écran Graphiques.
* *
* Fonctions : * Fonctions :
* - plages 24 h / 7 j / 30 j ; * - plages 6 h → 300 j (zoom v1.2.9, presets 24 h / 7 j / 30 j) ;
* - **panoramique** : glisser horizontalement déplace la fenêtre dans le passé ; * - **panoramique** : glisser horizontalement déplace la fenêtre dans le passé
* - **les deux modèles PK** (Estrannaise / Transfem Science) peuvent être affichés * (le futur avec la Prévision active) ;
* simultanément (override de modèle dans le moteur), avec toggle indépendant ; * - **les modèles PK** (Estrannaise / Transfem Science / WHSAH) peuvent être
* affichés simultanément (override de modèle dans le moteur), toggles
* indépendants ; + Tracé labs (v1.5.0) et sa prolongation (v1.6.0) ;
* - **prévision** : prolonge la courbe avec des doses projetées à partir de la * - **prévision** : prolonge la courbe avec des doses projetées à partir de la
* « fréquence » configurée sur chaque traitement ; * « fréquence » configurée sur chaque traitement ;
* - **auto-calibration** (option des Paramètres) : facteurs d'échelle et modèle T * - **auto-calibration** (option des Paramètres) : facteurs d'échelle et modèle T
@ -135,7 +142,7 @@ fun ChartScreen() {
var modelTogglesInitialized by remember { mutableStateOf(false) } var modelTogglesInitialized by remember { mutableStateOf(false) }
LaunchedEffect(treatments) { LaunchedEffect(treatments) {
if (!modelTogglesInitialized && treatments.isNotEmpty()) { if (!modelTogglesInitialized && treatments.isNotEmpty()) {
val toggles = com.hormonetrack.ui.components.defaultModelToggles( val toggles = defaultModelToggles(
treatments.filter { it.usesProfileModel }.map { it.pkModel }.toSet() treatments.filter { it.usesProfileModel }.map { it.pkModel }.toSet()
) )
showEse = toggles.getValue("ESE") showEse = toggles.getValue("ESE")
@ -194,7 +201,7 @@ fun ChartScreen() {
// Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné // Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné
// [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt). // [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt).
val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull() val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull()
val forecastHorizonHours = com.hormonetrack.ui.components.forecastHorizonHours(maxIntervalDays) val forecastHorizonHours = forecastHorizonHours(maxIntervalDays)
// ── Fenêtre temporelle (v1.4.1, extension v1.4.4, stabilisation v1.4.5) ── // ── Fenêtre temporelle (v1.4.1, extension v1.4.4, stabilisation v1.4.5) ──
// ⚠️ BOUCLE DE RECOMPOSITION (fix #56) : `nowMs` doit être MÉMOÏSÉ sur le // ⚠️ BOUCLE DE RECOMPOSITION (fix #56) : `nowMs` doit être MÉMOÏSÉ sur le
@ -220,7 +227,7 @@ fun ChartScreen() {
} }
} }
val forecastExtensionH = if (showForecast && forecastHorizonHours != null) { val forecastExtensionH = if (showForecast && forecastHorizonHours != null) {
com.hormonetrack.ui.components.forecastExtensionHours( forecastExtensionHours(
rangeHours, forecastHorizonHours, firstSlotGapH rangeHours, forecastHorizonHours, firstSlotGapH
) )
} else 0L } else 0L
@ -269,7 +276,7 @@ fun ChartScreen() {
val curves by produceState( val curves by produceState(
emptyList<Pair<String, List<LevelPoint>>>(), emptyList<Pair<String, List<LevelPoint>>>(),
autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast, autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast,
showLabTrack, showLabTrackExtend, labResults.size showLabTrack, showLabTrackExtend, labResults
) { ) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val list = mutableListOf<Pair<String, List<LevelPoint>>>() val list = mutableListOf<Pair<String, List<LevelPoint>>>()
@ -290,7 +297,7 @@ fun ChartScreen() {
// non vides seulement — la légende (plus bas) doit connaître // non vides seulement — la légende (plus bas) doit connaître
// "LABX" AVANT son else (leçon #63). // "LABX" AVANT son else (leçon #63).
if (showLabTrack) { if (showLabTrack) {
val step = com.hormonetrack.ui.components.stepForRange(rangeHours) val step = stepForRange(rangeHours)
val labCurve = LabTrajectoryModel.computeLabAnchoredCurve( val labCurve = LabTrajectoryModel.computeLabAnchoredCurve(
treatments, doseLogs, labResults, startMs, endMs, step, treatments, doseLogs, labResults, startMs, endMs, step,
extendBeyondLastLab = showLabTrackExtend extendBeyondLastLab = showLabTrackExtend
@ -319,39 +326,18 @@ fun ChartScreen() {
} else emptyList() } else emptyList()
val allDoses = doseLogs + forecastDoses val allDoses = doseLogs + forecastDoses
// Pas d'échantillonnage adaptatif au zoom (v1.2.9) // Pas d'échantillonnage adaptatif au zoom (v1.2.9)
val step = com.hormonetrack.ui.components.stepForRange(rangeHours) val step = stepForRange(rangeHours)
if (showEse) { // v1.8.2 : les 3 blocs copiés (ESE/TFS/WHS) sont factorisés —
val autoM = autoByModel["ESE"] // le modèle est la seule variable (même calcul, même calibration
// PAR MODÈLE via autoByModel[model], fix #60)
for ((model, enabled) in listOf("ESE" to showEse, "TFS" to showTfs, "WHS" to showWhs)) {
if (!enabled) continue
val autoM = autoByModel[model]
list.add( list.add(
"ESE" to PharmacokineticEngine.computeCurve( model to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs, effTreatments, allDoses, startMs, endMs,
stepMs = step, stepMs = step,
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "ESE", tConfig = autoM?.tConfig ?: effTConfig, modelOverride = model,
scalePerEster = autoM?.esterScales,
tKPerEster = autoM?.tKPerEster
)
)
}
if (showTfs) {
val autoM = autoByModel["TFS"]
list.add(
"TFS" to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs,
stepMs = step,
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "TFS",
scalePerEster = autoM?.esterScales,
tKPerEster = autoM?.tKPerEster
)
)
}
// v1.4.6 : 3ᵉ modèle WHSAH (même mécanique d'override)
if (showWhs) {
val autoM = autoByModel["WHS"]
list.add(
"WHS" to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs,
stepMs = step,
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "WHS",
scalePerEster = autoM?.esterScales, scalePerEster = autoM?.esterScales,
tKPerEster = autoM?.tKPerEster tKPerEster = autoM?.tKPerEster
) )
@ -550,12 +536,12 @@ fun ChartScreen() {
// v1.4.10 (fix #62) : cumul fractionnaire du pan // v1.4.10 (fix #62) : cumul fractionnaire du pan
// (l'ancienne troncature par delta tuait le pan // (l'ancienne troncature par delta tuait le pan
// sur la vue 24 h) // sur la vue 24 h)
val (hoursDragged, newResidual) = com.hormonetrack.ui.components.panDeltaHours( val (hoursDragged, newResidual) = panDeltaHours(
pan.x, chartWidthPx, rangeHours, panResidualHours pan.x, chartWidthPx, rangeHours, panResidualHours
) )
panResidualHours = newResidual panResidualHours = newResidual
if (hoursDragged != 0L) { if (hoursDragged != 0L) {
panHours = com.hormonetrack.ui.components.clampPanHours( panHours = clampPanHours(
panHours + hoursDragged, gestureFutureHorizon, gestureMaxPan panHours + hoursDragged, gestureFutureHorizon, gestureMaxPan
) )
} }
@ -573,7 +559,7 @@ fun ChartScreen() {
val tFocal = endAnchor - ((1.0 - f) * rangeHours * HOUR_MS).toLong() val tFocal = endAnchor - ((1.0 - f) * rangeHours * HOUR_MS).toLong()
val newEnd = tFocal + ((1.0 - f) * newRange * HOUR_MS).toLong() val newEnd = tFocal + ((1.0 - f) * newRange * HOUR_MS).toLong()
val newPan = ((nowMs - newEnd) / HOUR_MS).toLong() val newPan = ((nowMs - newEnd) / HOUR_MS).toLong()
panHours = com.hormonetrack.ui.components.clampPanHours( panHours = clampPanHours(
newPan, gestureFutureHorizon, gestureMaxPan newPan, gestureFutureHorizon, gestureMaxPan
) )
rangeHours = newRange rangeHours = newRange

View File

@ -5,10 +5,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@ -35,13 +33,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.DoseDialog import com.hormonetrack.ui.components.DoseDialog
import com.hormonetrack.util.HrtDuration import com.hormonetrack.util.HrtDuration

View File

@ -72,7 +72,7 @@ import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import kotlin.math.abs import kotlin.math.abs
private val HOUR_MS = 3_600_000L private const val HOUR_MS = 3_600_000L
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -128,7 +128,10 @@ fun HomeScreen(
} }
} }
val curve by produceState<List<LevelPoint>>(emptyList(), effectiveAuto, doseLogs, tick) { // v1.8.2 : allTreatments et tConfig ajoutés aux clés — éditer un
// scaleFactor ou le modèle T rafraîchit la courbe immédiatement
// (l'ancien trio de clés ratait ces changements jusqu'au tick de 60 s)
val curve by produceState<List<LevelPoint>>(emptyList(), allTreatments, tConfig, effectiveAuto, doseLogs, tick) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val end = System.currentTimeMillis() val end = System.currentTimeMillis()
value = PharmacokineticEngine.computeCurve( value = PharmacokineticEngine.computeCurve(
@ -463,8 +466,7 @@ private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) {
val fires = treatments val fires = treatments
.mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } } .mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } }
if (fires.isEmpty()) return if (fires.isEmpty()) return
val next = fires.minByOrNull { it.second }!!.second val (nextTreatment, next) = fires.minByOrNull { it.second }!!
val nextTreatment = fires.minByOrNull { it.second }!!.first
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp)) {
Text( Text(

View File

@ -1,7 +1,6 @@
package com.hormonetrack.ui.screens package com.hormonetrack.ui.screens
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize

View File

@ -144,13 +144,16 @@ fun SettingsScreen(onBack: () -> Unit) {
// v1.4.2 : le backup embarque aussi les PARAMÈTRES utilisateur // v1.4.2 : le backup embarque aussi les PARAMÈTRES utilisateur
// (langue, auto-calibration, seuils d'alerte) — collect des // (langue, auto-calibration, seuils d'alerte) — collect des
// DataStore Flows (first()) avant sérialisation. // DataStore Flows (first()) avant sérialisation.
// UNE seule collecte des seuils (les 4 champs viennent du même
// snapshot DataStore — 4 × .first() = 4 collectes redondantes)
val thresholds = container.settings.alertThresholds.first()
val settings = com.hormonetrack.data.backup.UserSettings( val settings = com.hormonetrack.data.backup.UserSettings(
language = container.settings.language.first(), language = container.settings.language.first(),
autoCalibrate = container.settings.autoCalibrate.first(), autoCalibrate = container.settings.autoCalibrate.first(),
alertE2High = container.settings.alertThresholds.first().e2High, alertE2High = thresholds.e2High,
alertE2Low = container.settings.alertThresholds.first().e2Low, alertE2Low = thresholds.e2Low,
alertTHigh = container.settings.alertThresholds.first().tHigh, alertTHigh = thresholds.tHigh,
alertTLow = container.settings.alertThresholds.first().tLow alertTLow = thresholds.tLow
) )
val json = BackupManager.exportJson(repo, tConfig, settings) val json = BackupManager.exportJson(repo, tConfig, settings)
val ok = BackupManager.writeBackup(context, uri, json) val ok = BackupManager.writeBackup(context, uri, json)

View File

@ -52,7 +52,6 @@ import com.hormonetrack.data.model.PKPresets
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType import com.hormonetrack.data.model.TreatmentType
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import com.hormonetrack.reminder.AlarmScheduler import com.hormonetrack.reminder.AlarmScheduler

View File

@ -60,9 +60,9 @@ object AppLog {
fun log(tag: String, message: String) = synchronized(lock) { fun log(tag: String, message: String) = synchronized(lock) {
val line = formatLine(System.currentTimeMillis(), tag, message) val line = formatLine(System.currentTimeMillis(), tag, message)
buffer.addLast(line) buffer.addLast(line)
val trimmed = trimLines(buffer.toList(), MAX_LINES) // Purge O(1) amortie (v1.8.2 : l'ancien trimLines recopiait TOUT le
buffer.clear() // buffer à chaque ligne loggée) — même résultat (MAX_LINES dernières)
buffer.addAll(trimmed) while (buffer.size > MAX_LINES) buffer.removeFirst()
try { try {
file?.appendText(line + "\n") file?.appendText(line + "\n")
} catch (_: Exception) { } catch (_: Exception) {

View File

@ -3,6 +3,43 @@
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
## [1.8.2] — 2026-09-17 (versionCode 37)
### Maintenance — performance du moteur + fraîcheur de l'interface
Aucune nouvelle fonctionnalité : session d'audit complet (code + doc), avec
les optimisations et corrections sûres qui en sont sorties.
### Performance du moteur PK
- `e2At`/`computeCurve` : `cutoffHours` et les paramètres Bateman
(bisection 50 itérations) ne sont plus recalculés à chaque (point ×
dose × traitement) — pré-calculés par traitement, et Bateman devient
**paresseux** (inutile pour les traitements à profil). Le chemin le plus
chaud (tracé labs : ~720 points × N × D appels de cutoff par refresh)
s'allège d'un facteur P × T. Doses pré-groupées par traitement (fin des
scans complets). **Résultat identique** (ordre de sommation préservé —
223 tests verts inchangés).
- `AppLog` : purge du buffer sans recopie à chaque ligne loggée.
- `ChangelogHelper` : Regex précompilées (dialog « Nouveautés »).
- `CurveChart` : SimpleDateFormat créé hors boucle de dessin.
### Corrigé — fraîcheur de l'interface
- **Accueil** : éditer un scaleFactor ou le modèle T rafraîchit la courbe
immédiatement (clés du producteur incomplètes — l'ancien trio attendait
le tick de 60 s).
- **Graphiques** : éditer la VALEUR d'une analyse rafraîchit les courbes
(la clé n'était que le NOMBRE d'analyses).
### Maintenance — code & documentation
- Code mort supprimé (Repository/DAO sans appelants, `currentLevel`,
variable et scheduler dupliqués), imports morts/redondants nettoyés,
médiane de calibration factorisée (4 copies → 1 helper),
qualifications raccourcies, en-têtes/KDoc obsolètes mis à jour.
- Documentation : ~35 corrections (compteurs de tests obsolètes, TOC et
ancres réparées, inventaire des suites de tests à jour et fusionné,
titres CHANGELOG doublés supprimés, versions « à jour v1.2.0 » /
« Gradle 8.9 » / « API 34 » corrigées, fautes).
## [1.8.1] — 2026-09-17 (versionCode 36) ## [1.8.1] — 2026-09-17 (versionCode 36)
### Corrigé — la stabilisation ignorait les changements récents (dose/ester/intervalle) ### Corrigé — la stabilisation ignorait les changements récents (dose/ester/intervalle)
@ -224,8 +261,6 @@ Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.1
## [1.4.9] — 2026-09-07 (versionCode 29) ## [1.4.9] — 2026-09-07 (versionCode 29)
## [1.4.9] — 2026-09-07 (versionCode 29)
### Corrigé ### Corrigé
- **La calibration tombait à ×2,21 (auto ET manuelle) sur un traitement - **La calibration tombait à ×2,21 (auto ET manuelle) sur un traitement
basculé sous un autre modèle** (#61, remontée : « le bouton Calibrer avec basculé sous un autre modèle** (#61, remontée : « le bouton Calibrer avec
@ -233,7 +268,7 @@ Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.1
diagnostic par reproduction sur données réelles — l'export contient une diagnostic par reproduction sur données réelles — l'export contient une
dose EEn de test logguée en **janvier** ; les labs de janvier-mars dose EEn de test logguée en **janvier** ; les labs de janvier-mars
tombaient 15–76 j après cette dose, quand la prédiction WHSAH est tombaient 15–76 j après cette dose, quand la prédiction WHSAH est
RÉSIDUELLE (184 → 1 pg/mL) → ratios aberrants (3,3 / 15,9 / 89,6 / 383) RÉSIDUELLE (184 → 1 pg/mL) → ratios aberrants (3,3 / 15,9 / 89,6 / 383 / 391)
→ la MÉDIANE tombait à ×2,21, **dans les deux pipelines** (manuel ET → la MÉDIANE tombait à ×2,21, **dans les deux pipelines** (manuel ET
auto — même défaut de frontière). FIX : **garde de significativité** auto — même défaut de frontière). FIX : **garde de significativité**
(`labIsSignificant`) — un lab ne calibre que si la prédiction reste (`labIsSignificant`) — un lab ne calibre que si la prédiction reste
@ -253,8 +288,6 @@ Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.1
## [1.4.8] — 2026-09-07 (versionCode 28) ## [1.4.8] — 2026-09-07 (versionCode 28)
## [1.4.8] — 2026-09-07 (versionCode 28)
### Corrigé ### Corrigé
- **La calibration automatique était fausse pour les modèles autres que le - **La calibration automatique était fausse pour les modèles autres que le
modèle stocké du traitement** (#60, remontée : « la calibration auto sur modèle stocké du traitement** (#60, remontée : « la calibration auto sur

View File

@ -11,23 +11,23 @@
1. [Contexte & objectifs](#1-contexte--objectifs) 1. [Contexte & objectifs](#1-contexte--objectifs)
2. [Historique du projet](#2-historique-du-projet) 2. [Historique du projet](#2-historique-du-projet)
3. [Stack & versions (épinglées)](#3-stack--versions-épinglées) 3. [Stack & versions (épinglées — à jour v1.8.1)](#3-stack--versions-épinglées--à-jour-v181)
4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine) 4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine)
5. [Architecture générale](#5-architecture-générale) 5. [Architecture générale](#5-architecture-générale)
6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre) 6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre)
7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique) + [7.8 Modèle TFS V3C](#78-modèle-tfs-v3c-v140--pktransfemsciencemodelskt) + [7.9 Modèle WHSAH](#79-modèle-whsah-v146--pkwhsahmodelskt) + [7.10 « Tracé labs »](#710--tracé-labs--v150--pklabtrajectorymodelkt) + [7.10.bis Prolongation](#710bis-prolongation-au-delà-du-dernier-lab-v160--pklabtrajectorymodelkt) 7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique) + [7.8 Modèle TFS V3C](#78-modèle-tfs-v3c-v140--pktransfemsciencemodelskt) + [7.9 Modèle WHSAH](#79-modèle-whsah-v146--pkwhsahmodelskt) + [7.10 « Tracé labs »](#710--tracé-labs--v150--pklabtrajectorymodelkt) + [7.10.bis Prolongation](#710bis-prolongation-au-delà-du-dernier-lab-v160--pklabtrajectorymodelkt) + [7.11 Prise de sang recommandée](#711-recommandation-de-prochaine-prise-de-sang-v180--pklabtimingkt)
8. [Tests unitaires](#8-tests-unitaires) + [8.bis Données de test hors dépôt](#8bis-données-de-test-réelles--hors-dépôt-local-test-data) 8. [Tests unitaires](#8-tests-unitaires) + [8.bis Données de test réelles : HORS dépôt](#8bis-données-de-test-réelles--hors-dépôt-local-test-data)
9. [Système de rappels](#9-système-de-rappels) + [9.bis Alertes de seuil](#9bis-alertes-de-seuil-reminderalertnotifierkt--worker-v142) 9. [Système de rappels](#9-système-de-rappels) + [9.bis Alertes de seuil](#9bis-alertes-de-seuil--reminderalertnotifierkt--worker-v142)
10. [UI & navigation](#10-ui--navigation) 10. [UI & navigation](#10-ui--navigation)
11. [Graphiques (CurveChart)](#11-graphiques-curvechart) 11. [Graphiques (CurveChart)](#11-graphiques-curvechart)
12. [i18n FR/EN](#12-i18n-fren) 12. [i18n FR/EN](#12-i18n-fren)
13. [Sauvegarde JSON](#13-sauvegarde-json) 13. [Sauvegarde JSON](#13-sauvegarde-json) + [13.bis Auto-backup quotidien](#13bis-sauvegarde-automatique-quotidienne-v170--databackupautobackupworkerkt)
14. [Bugs corrigés (historique complet — à ne pas réintroduire)](#14-bugs-corrigés) 14. [Bugs corrigés (historique complet — à ne pas réintroduire)](#14-bugs-corrigés)
15. [Comment régénérer l'asset pk_profiles.json](#15-comment-régénérer-lasset-pk_profilesjson) 15. [Comment régénérer l'asset pk_profiles.json](#15-comment-régénérer-lasset-pk_profilesjson)
16. [Workflow build / test / install](#16-workflow-build--test--install) + [16.bis Releases Gitea](#16bis-releases-gitea-avec-apk-téléchargeable) + [16.ter Test sur émulateur](#16ter-recette-test-manuel-sur-émulateur-reproductible--v134) 16. [Workflow build / test / install / git](#16-workflow-build--test--install--git) + [16.bis Releases Gitea](#16bis-releases-gitea-avec-apk-téléchargeable) + [16.ter Recette : test sur émulateur](#16ter-recette--test-manuel-sur-émulateur-reproductible--v134)
17. [Montre : Gadgetbridge & options](#17-montre--gadgetbridge--options) 17. [Montre : Gadgetbridge & options](#17-montre--gadgetbridge--options)
18. [Espace disque & coûts](#18-espace-disque--coûts) 18. [Espace disque & coûts](#18-espace-disque--coûts)
19. [Limites connues & choix volontaires](#19-limites-connues) 19. [Limites connues](#19-limites-connues)
20. [Idées d'évolution (Phase 2+)](#20-idées-dévolution) 20. [Idées d'évolution (Phase 2+)](#20-idées-dévolution)
21. [Checklist de test manuel](#21-checklist-de-test-manuel) 21. [Checklist de test manuel](#21-checklist-de-test-manuel)
@ -63,6 +63,7 @@ Fonctionnalités v1 :
| Date | Événement | | Date | Événement |
|---|---| |---|---|
| 17 sept. 2026 (session v1.8.2) | **Maintenance — audit complet code + doc** : (a) **perf moteur** : cutoffHours pré-calculé par traitement dans e2At/computeCurve (le chemin chaud du tracé labs recalculait ~430 k appels/refresh), Bateman paresseux (inutile pour les traitements à profil), doses pré-groupées par traitement — résultat identique (ordre de sommation préservé, 223 tests verts inchangés) ; (b) **2 fixes de fraîcheur** (#65 accueil : clés produceState sans allTreatments/tConfig — éditer un scaleFactor n'actualisait pas la courbe avant le tick ; #66 graphiques : clé `labResults.size` — éditer la valeur d'un lab ne recalculait pas) ; (c) **nettoyage** : ~9 imports morts, qualifications raccourcies, code mort (Repository/DAO, currentLevel, scheduler2), médiane factorisée (4 copies), SimpleDateFormat hors boucle, AppLog sans recopie, Regex précompilées, formatDose dédupliqué ; (d) **doc** : ~35 corrections (compteurs actuels 223/193 partout, TOC + ancres réparées, inventaire §8 à jour et fusionné, doublons CHANGELOG, §19 backup, fautes). Audit préalable par double exploration lecture-seule (46 fichiers + 4 docs). 223 verts + lint + émulateur smoke. Android seul (web non concerné — re-render complet, aucune release web). |
| 17 sept. 2026 (session v1.8.1) | **Fix stabilisation + suggestion sur l'Accueil** (critique v1.8.0 : « changé d'ester, de dosage ET de posologie, et l'app me disait stabilisée depuis février ») : le proxy « 1ʳᵉ dose du traitement » était aveugle aux changements récents. NOUVELLE règle `regimeStartMs` : le régime courant = la **séquence terminale de doses** à (ester effectif, dose mg, **écart inter-doses**) constants (l'écart comparé EXACTEMENT — 7 j ± 1 h casse le régime) ; tout changement récent réinitialise la stabilisation → la reco saute au premier creux post-stabilisation ; l'ester EFFECTIF de la dernière dose (override compris) alimente la carte. **+ Suggestion aussi sur l'Accueil** (demande) : carte compacte (creux daté + créneau, mention de stabilisation seulement si le régime n'était pas déjà stable). 3 nouveaux tests (223 verts / 193 sans données locales). Validé émulateur (release, seed réel) : la carte passe de « stabilisé depuis le 06/02 » à « EEN pas stabilisé avant le 29/09 — premier creux fiable », creux 27/09 22:43, 0 crash. Web v1.8.1 sync (3 tests miroirs + encart Accueil). | | 17 sept. 2026 (session v1.8.1) | **Fix stabilisation + suggestion sur l'Accueil** (critique v1.8.0 : « changé d'ester, de dosage ET de posologie, et l'app me disait stabilisée depuis février ») : le proxy « 1ʳᵉ dose du traitement » était aveugle aux changements récents. NOUVELLE règle `regimeStartMs` : le régime courant = la **séquence terminale de doses** à (ester effectif, dose mg, **écart inter-doses**) constants (l'écart comparé EXACTEMENT — 7 j ± 1 h casse le régime) ; tout changement récent réinitialise la stabilisation → la reco saute au premier creux post-stabilisation ; l'ester EFFECTIF de la dernière dose (override compris) alimente la carte. **+ Suggestion aussi sur l'Accueil** (demande) : carte compacte (creux daté + créneau, mention de stabilisation seulement si le régime n'était pas déjà stable). 3 nouveaux tests (223 verts / 193 sans données locales). Validé émulateur (release, seed réel) : la carte passe de « stabilisé depuis le 06/02 » à « EEN pas stabilisé avant le 29/09 — premier creux fiable », creux 27/09 22:43, 0 crash. Web v1.8.1 sync (3 tests miroirs + encart Accueil). |
| 17 sept. 2026 (session v1.8.0) | **Recommandation de prochaine prise de sang (page Analyses, Android + web sync)** (demande + choix validés) : carte « Prochaine prise de sang (suggestion) » — creux exact calculé sur la courbe prévisionnelle **juste avant l'injection suivante** (+ mention « ou la veille du créneau »), **saut au premier creux STABILISÉ** (5 × t½ terminale — pas de prise intermédiaire trompeuse), filtres honnêtes (injectable actif + Posologie requis, creux jamais déjà mesuré, horizon borné → carte cachée), **invite « renseigne une Posologie »** quand un injectable actif en est dépourvu (demande explicite). Moteur : `pk/LabTiming.kt` PUR (miroir web `js/pk/lab-timing.js`) ; `PKProfileStore.terminalHalfLifeDays` (refactor de la pente d'extrapolation de `sample()` — le modèle Estrannaise lit sa t½ dans la table, TFS/WHSAH analytiquement). UI : calcul `produceState` + tick minute, disclaimer, mutuellement exclusive avec l'invite. 11 nouveaux tests (220 verts / 190 sans données locales) + lint + check web (152). Validé émulateur §16.ter (release, seed réel v1.7.0) : « creux 20/09 10:37, injection EEN 20/09 11:20, EEN stabilisé depuis le 06/02 », 0 crash. | | 17 sept. 2026 (session v1.8.0) | **Recommandation de prochaine prise de sang (page Analyses, Android + web sync)** (demande + choix validés) : carte « Prochaine prise de sang (suggestion) » — creux exact calculé sur la courbe prévisionnelle **juste avant l'injection suivante** (+ mention « ou la veille du créneau »), **saut au premier creux STABILISÉ** (5 × t½ terminale — pas de prise intermédiaire trompeuse), filtres honnêtes (injectable actif + Posologie requis, creux jamais déjà mesuré, horizon borné → carte cachée), **invite « renseigne une Posologie »** quand un injectable actif en est dépourvu (demande explicite). Moteur : `pk/LabTiming.kt` PUR (miroir web `js/pk/lab-timing.js`) ; `PKProfileStore.terminalHalfLifeDays` (refactor de la pente d'extrapolation de `sample()` — le modèle Estrannaise lit sa t½ dans la table, TFS/WHSAH analytiquement). UI : calcul `produceState` + tick minute, disclaimer, mutuellement exclusive avec l'invite. 11 nouveaux tests (220 verts / 190 sans données locales) + lint + check web (152). Validé émulateur §16.ter (release, seed réel v1.7.0) : « creux 20/09 10:37, injection EEN 20/09 11:20, EEN stabilisé depuis le 06/02 », 0 crash. |
| 16 sept. 2026 (session v1.7.1) | **Fix « une seule note sur deux » dans les Analyses (Android + web)** : une prise E2+T avec des notes DISTINCTES (note clinique E2 + note « DHT » sur T — cas réel) n'en affichait qu'UNE (l'autre conservée mais perdue à l'affichage). Cause : affichage « première note non vide du groupe » — hypothèse historique « toutes identiques » (dialog commun) cassée par l'édition unitaire. FIX : `labNotesForDisplay` PUR des deux côtés (notes distinctes → préfixées du marqueur ; identiques → dédupliquées ; vides ignorées) + UI branchée. + **Nouvel export réel v1.7.0** (3 traitements dont CPA oral, 64 doses, 28 labs, notes doses ET labs) → `local-test-data/backup-v1.7.0.json` (gitigné) + **régression n°5** data-driven (7 tests : parsing, double-note épingle le fix, plausibilité moteur avec CPA, prévision 7 j). 11 nouveaux tests (211 verts / 181 sans données locales) + lint + check web. Validé émulateur §16.ter : seed v1.7.0 → écran Analyses affiche « E2 : … » ET « T : … » (dump), 0 crash. Web v1.7.1 sync (+ leçon : oubli d'entrée CHANGELOG = dialog « Nouveautés » vide, attrapé par l'E2E/§7). | | 16 sept. 2026 (session v1.7.1) | **Fix « une seule note sur deux » dans les Analyses (Android + web)** : une prise E2+T avec des notes DISTINCTES (note clinique E2 + note « DHT » sur T — cas réel) n'en affichait qu'UNE (l'autre conservée mais perdue à l'affichage). Cause : affichage « première note non vide du groupe » — hypothèse historique « toutes identiques » (dialog commun) cassée par l'édition unitaire. FIX : `labNotesForDisplay` PUR des deux côtés (notes distinctes → préfixées du marqueur ; identiques → dédupliquées ; vides ignorées) + UI branchée. + **Nouvel export réel v1.7.0** (3 traitements dont CPA oral, 64 doses, 28 labs, notes doses ET labs) → `local-test-data/backup-v1.7.0.json` (gitigné) + **régression n°5** data-driven (7 tests : parsing, double-note épingle le fix, plausibilité moteur avec CPA, prévision 7 j). 11 nouveaux tests (211 verts / 181 sans données locales) + lint + check web. Validé émulateur §16.ter : seed v1.7.0 → écran Analyses affiche « E2 : … » ET « T : … » (dump), 0 crash. Web v1.7.1 sync (+ leçon : oubli d'entrée CHANGELOG = dialog « Nouveautés » vide, attrapé par l'E2E/§7). |
@ -94,7 +95,7 @@ Fonctionnalités v1 :
| 7 sept. 2026 (session v1.4.3) | **Question pharmacocinétique → 1 bug + 1 confirmation** : « l'estimation ne remonte pas le lendemain de mon injection EEn » → (a) **comportement NORMAL** (contribution J+1 < 12 % du total, plateau d'équilibre — épinglé par la régression n°4 sur le 4ᵉ export réel) ; (b) **bug #53** : le delta « vs il y a 6 h » de l'accueil comparait en réalité il y a 24 h (`firstOrNull` sur la fenêtre 24 h) → helper `pointHoursBefore` + 4 tests. 137 verts. Publication v1.4.3. | | 7 sept. 2026 (session v1.4.3) | **Question pharmacocinétique → 1 bug + 1 confirmation** : « l'estimation ne remonte pas le lendemain de mon injection EEn » → (a) **comportement NORMAL** (contribution J+1 < 12 % du total, plateau d'équilibre — épinglé par la régression n°4 sur le 4ᵉ export réel) ; (b) **bug #53** : le delta « vs il y a 6 h » de l'accueil comparait en réalité il y a 24 h (`firstOrNull` sur la fenêtre 24 h) → helper `pointHoursBefore` + 4 tests. 137 verts. Publication v1.4.3. |
| 7 sept. 2026 (session v1.4.4) | **Prévision inaccessible depuis v1.4.1** (#54, remontée) : DEUX causes emboîtées — (a) captures FIGÉES dans `pointerInput(Unit)` (`futurePanHorizon` lu à 0 pour toujours → pan futur interdit) → `rememberUpdatedState` + **garde de source** (`ChartScreenSourceGuardTest`, le lint Compose ne voit pas ce pattern) ; (b) toggle sans effet visible (`endMs = now` → 0 dose générée) → extension contrôlée à droite (`forecastExtensionHours`, jusqu'au 1ᵉʳ créneau, sans déplacer le début). Validé émulateur : drag gauche → panHours < 0, courbe dessinée au-delà de « maintenant » (screencap). 140 verts. Publication v1.4.4. | | 7 sept. 2026 (session v1.4.4) | **Prévision inaccessible depuis v1.4.1** (#54, remontée) : DEUX causes emboîtées — (a) captures FIGÉES dans `pointerInput(Unit)` (`futurePanHorizon` lu à 0 pour toujours → pan futur interdit) → `rememberUpdatedState` + **garde de source** (`ChartScreenSourceGuardTest`, le lint Compose ne voit pas ce pattern) ; (b) toggle sans effet visible (`endMs = now` → 0 dose générée) → extension contrôlée à droite (`forecastExtensionHours`, jusqu'au 1ᵉʳ créneau, sans déplacer le début). Validé émulateur : drag gauche → panHours < 0, courbe dessinée au-delà de « maintenant » (screencap). 140 verts. Publication v1.4.4. |
| 7 sept. 2026 (session v1.4.5) | **« La prévision simule le 13 au lieu du 12 »** (#55) : diagnostic sur données réelles — GÉNÉRATION EXACTE (créneau = dernière dose loguée + 6 j ; subtilité : la dose avait été loguée le LENDENDE de l'injection réelle → dérive d'un jour par cycle, l'app suit les logs) ; illusion causée par l'absence de marqueur + labels X à minuit UTC. FIX : marqueurs de doses (prévisionnels pointillés + réels discrets), labels à minuit LOCAL, fuseau de graphique configurable (Paramètres). **+ #56** : boucle de recomposition (`nowMs` relu à chaque frame → endMs dérive → saturation main thread → taps perdus) → mémoïsé sur le tick. 145 verts. Publication v1.4.5. | | 7 sept. 2026 (session v1.4.5) | **« La prévision simule le 13 au lieu du 12 »** (#55) : diagnostic sur données réelles — GÉNÉRATION EXACTE (créneau = dernière dose loguée + 6 j ; subtilité : la dose avait été loguée le LENDENDE de l'injection réelle → dérive d'un jour par cycle, l'app suit les logs) ; illusion causée par l'absence de marqueur + labels X à minuit UTC. FIX : marqueurs de doses (prévisionnels pointillés + réels discrets), labels à minuit LOCAL, fuseau de graphique configurable (Paramètres). **+ #56** : boucle de recomposition (`nowMs` relu à chaque frame → endMs dérive → saturation main thread → taps perdus) → mémoïsé sur le tick. 145 verts. Publication v1.4.5. |
| 7 sept. 2026 (session v1.4.6) | **3ᵉ modèle PK « WHSAH »** (demande) : le fit « license-free » du WHSAH Collective publié dans **Mona** (l'app concurrente — son git montre qu'elle utilisait NOS paramètres TFS avant mars 2026) intégré au même niveau : dispatch parallèle (aucun remplacement), 6 presets, dropdown 3 choix, 3ᵉ toggle superposable (vert/violet), PEP non couvert. Comparaison chiffrée TFS vs WHSAF épinglée (EV pic 367 vs 295 ; EEn à J+1 ×3). **+ #57** : l'écran Graphiques défile désormais (rangée de toggles coupée sous le pli, trouvé en émulateur). 155 verts. Publication v1.4.6. | | 7 sept. 2026 (session v1.4.6) | **3ᵉ modèle PK « WHSAH »** (demande) : le fit « license-free » du WHSAH Collective publié dans **Mona** (l'app concurrente — son git montre qu'elle utilisait NOS paramètres TFS avant mars 2026) intégré au même niveau : dispatch parallèle (aucun remplacement), 6 presets, dropdown 3 choix, 3ᵉ toggle superposable (vert/violet), PEP non couvert. Comparaison chiffrée TFS vs WHSAH épinglée (EV pic 367 vs 295 ; EEn à J+1 ×3). **+ #57** : l'écran Graphiques défile désormais (rangée de toggles coupée sous le pli, trouvé en émulateur). 155 verts. Publication v1.4.6. |
| 7 sept. 2026 (session v1.4.7) | **Toggles alignés sur les traitements** (#58, demande) : au chargement, seuls les modèles utilisés par des traitements à PROFIL PK (`usesProfileModel`) sont ON (l'oral/AA polluait le set — vu en émulateur avec le CPA) ; garde-fou TFS si aucun profil ; une seule init. **Épinglé : la calibration s'applique à TOUTES les courbes** (par ESTER via doseEster, indépendant du modelOverride — test). 158 verts. Publication v1.4.7. | + **#59** : le chip de la carte de traitement affichait « Estrannaise » pour WHSAH (ternaire à 2 branches → `modelLabelRes` centralisé). | 7 sept. 2026 (session v1.4.7) | **Toggles alignés sur les traitements** (#58, demande) : au chargement, seuls les modèles utilisés par des traitements à PROFIL PK (`usesProfileModel`) sont ON (l'oral/AA polluait le set — vu en émulateur avec le CPA) ; garde-fou TFS si aucun profil ; une seule init. **Épinglé : la calibration s'applique à TOUTES les courbes** (par ESTER via doseEster, indépendant du modelOverride — test). 158 verts. Publication v1.4.7. | + **#59** : le chip de la carte de traitement affichait « Estrannaise » pour WHSAH (ternaire à 2 branches → `modelLabelRes` centralisé).
| 7 sept. 2026 (session v1.4.8) | **Calibration PAR MODÈLE PK** (#60, remontée : « ×2,21 sur WHSAH alors que l'idéal serait < 1 ») : un seul jeu de facteurs (calculé depuis le modèle stocké) était partagé par toutes les courbes → absurde dès qu'un traitement basculait de modèle. FIX : le graphique calcule une AutoCalibrated PAR modèle affiché (ESE/TFS/WHS — modelOverride propagé à computeEsterScaleFactors et computeTKPerEster) → chaque courbe calibrée passe par les labs. Home inchangé (modèle stocké). Test : chaque courbe calibrée ≈ lab (±2 %), échelles TFS ≠ WHS. 159 verts. Publication v1.4.8. | | 7 sept. 2026 (session v1.4.8) | **Calibration PAR MODÈLE PK** (#60, remontée : « ×2,21 sur WHSAH alors que l'idéal serait < 1 ») : un seul jeu de facteurs (calculé depuis le modèle stocké) était partagé par toutes les courbes → absurde dès qu'un traitement basculait de modèle. FIX : le graphique calcule une AutoCalibrated PAR modèle affiché (ESE/TFS/WHS — modelOverride propagé à computeEsterScaleFactors et computeTKPerEster) → chaque courbe calibrée passe par les labs. Home inchangé (modèle stocké). Test : chaque courbe calibrée ≈ lab (±2 %), échelles TFS ≠ WHS. 159 verts. Publication v1.4.8. |
| 7 sept. 2026 (session v1.4.9) | **Fix #61 — calibration tombant à ×2,21 (auto ET manuelle)** : reproduction par test sur données réelles — une dose EEn de test de JANVIER (10 mg) dans l'export, des labs de janvier-mars à 15–76 j → prédiction WHSAH résiduelle (184 → 1) → ratios aberrants (×3,3 → ×391) → médiane ×2,21, dans les DEUX pipelines. FIX : garde de significativité `labIsSignificant` (prédiction ≥ 15 % du max observé, labs par timestamp croissant) sur les 3 pipelines ; facteur WHSAH retombe à 0,55. + Hint UX (auto ON → facteur manuel ignoré à l'affichage) + nettoyage CHANGELOG (4 titres orphelins doublés [1.4.4–1.4.7]). 168 verts. Publication v1.4.9. | | 7 sept. 2026 (session v1.4.9) | **Fix #61 — calibration tombant à ×2,21 (auto ET manuelle)** : reproduction par test sur données réelles — une dose EEn de test de JANVIER (10 mg) dans l'export, des labs de janvier-mars à 15–76 j → prédiction WHSAH résiduelle (184 → 1) → ratios aberrants (×3,3 → ×391) → médiane ×2,21, dans les DEUX pipelines. FIX : garde de significativité `labIsSignificant` (prédiction ≥ 15 % du max observé, labs par timestamp croissant) sur les 3 pipelines ; facteur WHSAH retombe à 0,55. + Hint UX (auto ON → facteur manuel ignoré à l'affichage) + nettoyage CHANGELOG (4 titres orphelins doublés [1.4.4–1.4.7]). 168 verts. Publication v1.4.9. |
@ -109,7 +110,7 @@ Leçon importante de la session build : **les erreurs de compilation et les bugs
(bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant (bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant
et en testant** — aucun build n'avait été lancé avant la session 3. et en testant** — aucun build n'avait été lancé avant la session 3.
## 3. Stack & versions (épinglées — à jour v1.2.0) ## 3. Stack & versions (épinglées — à jour v1.8.1)
| Composant | Version | Où | | Composant | Version | Où |
|---|---|---| |---|---|---|
@ -167,11 +168,11 @@ Notes importantes (v1.2.0) :
- **SDK Android** : installé via `brew install --cask android-commandlinetools` - **SDK Android** : installé via `brew install --cask android-commandlinetools`
→ `/opt/homebrew/share/android-commandlinetools` (524 MB) → `/opt/homebrew/share/android-commandlinetools` (524 MB)
- licences acceptées : `yes | sdkmanager --licenses` - licences acceptées : `yes | sdkmanager --licenses`
- paquets : `platform-tools`, `platforms;android-34`, `build-tools;34.0.0` - paquets (état actuel, cf §18) : `platform-tools`, `platforms;android-34/36/37`, `build-tools;34.0.0` etc.
- **`local.properties`** à la racine (non commité) : `sdk.dir=/opt/homebrew/share/android-commandlinetools` - **`local.properties`** à la racine (non commité) : `sdk.dir=/opt/homebrew/share/android-commandlinetools`
- Gradle 8.9 téléchargé par le wrapper ; caches `~/.gradle` ≈ 1,5 GB - Gradle 9.7.1 téléchargé par le wrapper ; caches `~/.gradle` ≈ 1,5 GB
- Build validé : `./gradlew assembleDebug testDebugUnitTest` → **BUILD SUCCESSFUL**, - Build initial validé (5 sept. 2026) : `./gradlew assembleDebug testDebugUnitTest` →
APK debug 18 MB (`app/build/outputs/apk/debug/app-debug.apk`) **BUILD SUCCESSFUL** ; l'APK debug actuel pèse ≈ 20 Mo (`app/build/outputs/apk/debug/app-debug.apk`)
## 5. Architecture générale ## 5. Architecture générale
@ -371,7 +372,7 @@ timestamp croissant, les 3 pipelines : manuel, échelles par ester, k T).
Au-delà de la fenêtre d'action (prédiction résiduelle), le lab reflète Au-delà de la fenêtre d'action (prédiction résiduelle), le lab reflète
AUTRE CHOSE : une dose EEn de test logguée en janvier + des labs de AUTRE CHOSE : une dose EEn de test logguée en janvier + des labs de
janvier-mars produisaient des ratios ×3,3 → ×391 et la médiane tombait à janvier-mars produisaient des ratios ×3,3 → ×391 et la médiane tombait à
×2,21 (dans les DEUX pipelines). Result : facteur WHSAH réel = 0,55. ×2,21 (dans les DEUX pipelines). Résultat : facteur WHSAH réel = 0,55.
- chaque lab est **attribué à la période d'injection dans laquelle il tombe** = - chaque lab est **attribué à la période d'injection dans laquelle il tombe** =
dernière dose E2 ≤ lab (une prise de sang reflète d'abord l'injection qui précède) ; dernière dose E2 ≤ lab (une prise de sang reflète d'abord l'injection qui précède) ;
- ratio = lab ÷ prédiction **non calibrée** (toutes doses superposées, scaleFactor forcé 1) ; - ratio = lab ÷ prédiction **non calibrée** (toutes doses superposées, scaleFactor forcé 1) ;
@ -504,7 +505,7 @@ MAIS passant par tous les points ».
- **Indépendante de la calibration** : elle EST sa propre calibration - **Indépendante de la calibration** : elle EST sa propre calibration
continue (ratio brut, `scaleFactor` stocké ignoré ET `scalePerEster` continue (ratio brut, `scaleFactor` stocké ignoré ET `scalePerEster`
ignoré — l'inverse serait une DOUBLE correction, épinglé par test). ignoré — l'inverse serait une DOUBLE correction, épinglé par test).
effet : les modèles rapprochés au sein du même ancrage CONVERGENT Conséquence : les modèles rapprochés au sein du même ancrage CONVERGENT
(le mi-chemin ESE vs WHS colle à ~2 % — `modelOverride` garde une (le mi-chemin ESE vs WHS colle à ~2 % — `modelOverride` garde une
empreinte intermédiaire, testé avec le seuil du test moteur WHS). empreinte intermédiaire, testé avec le seuil du test moteur WHS).
- **Hors pics/creux** (chip "Pics / creux") : la série passe - **Hors pics/creux** (chip "Pics / creux") : la série passe
@ -660,7 +661,11 @@ Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'ex
médiane (0,5/0,9/1,4 → 0,9) ; calibration nulle sans labs ; **calibration T récupère un médiane (0,5/0,9/1,4 → 0,9) ; calibration nulle sans labs ; **calibration T récupère un
k planté (0,25)** ; grille horaire clampée à la 1ʳᵉ dose ; vide sans doses ; override k planté (0,25)** ; grille horaire clampée à la 1ʳᵉ dose ; vide sans doses ; override
d'ester par dose (EV≫EU à 45 h) ; prochain rappel dans le futur ; levelAt combiné d'ester par dose (EV≫EU à 45 h) ; prochain rappel dans le futur ; levelAt combiné
- **`BackupGsonTest`** (1) : round-trip JSON complet (enums, IDs, notes, TConfig) - **`AutoBackupRetentionTest`** (5) : v1.7.0 — rétention des auto-backups :
garde les N plus récents, ne touche JAMAIS exports manuels/logs/étrangers,
noms malformés jamais supprimés, keep clampé ≥ 1.
- **`BackupGsonTest`** (3) : round-trip JSON complet (enums, IDs, notes, TConfig),
rétrocompatibilité v1 (backup sans settings), import des paramètres utilisateur
- **`RegressionUserCaseTest`** (6) : **régression épinglée sur les données réelles - **`RegressionUserCaseTest`** (6) : **régression épinglée sur les données réelles
exportées** par l'utilisatrice (backup JSON v1.0.0 : 1 traitement EEn/ESE 5 mg, 1 dose, exportées** par l'utilisatrice (backup JSON v1.0.0 : 1 traitement EEn/ESE 5 mg, 1 dose,
4 labs dont T en ng/dL). Vérifie : parsing du JSON réel, courbes non vides et 4 labs dont T en ng/dL). Vérifie : parsing du JSON réel, courbes non vides et
@ -668,7 +673,7 @@ Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'ex
labs antérieurs à la 1ʳᵉ dose ignorés par la calibration SF, conversion ng/dL→ng/mL, labs antérieurs à la 1ʳᵉ dose ignorés par la calibration SF, conversion ng/dL→ng/mL,
calibration T avec labs en ng/dL. **En cas de nouveau bug remonté par l'utilisatrice : calibration T avec labs en ng/dL. **En cas de nouveau bug remonté par l'utilisatrice :
exporter le JSON, l'épingler ici, reproduire, corriger.** exporter le JSON, l'épingler ici, reproduire, corriger.**
- **`V120FeaturesTest`** (10) : v1.2.0→v1.2.3 — doses prévisionnelles (rythme 7 j depuis la - **`V120FeaturesTest`** (12) : v1.2.0→v1.2.7 — doses prévisionnelles (rythme 7 j depuis la
dernière dose réelle, liste exacte J+4/J+11/J+18/J+25 ; vide sans intervalle ou sans dernière dose réelle, liste exacte J+4/J+11/J+18/J+25 ; vide sans intervalle ou sans
doses ; ester override projeté), override de modèle (ESE ≠ TFS à 45 h pour EV ; doses ; ester override projeté), override de modèle (ESE ≠ TFS à 45 h pour EV ;
sans override = modèle du traitement), auto-calibration v1.2.1 (facteur **par ester** sans override = modèle du traitement), auto-calibration v1.2.1 (facteur **par ester**
@ -692,9 +697,144 @@ Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'ex
k T par ester, continuité de la courbe pendant la transition, niveau k T par ester, continuité de la courbe pendant la transition, niveau
actuel ~EEn équilibre. actuel ~EEn équilibre.
- **`LabsGroupingTest`** (9) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T
même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ;
ordre chronologique décroissant). v1.7.1 — **notes d'un groupe**
(`labNotesForDisplay`) : notes distinctes E2/T → DEUX lignes préfixées du
marqueur (fix « une seule note sur deux »), note identique → une seule
ligne (dédupliquée), note seule → brute (v1.0 préservé), vides/blanches
ignorées, aucune note → aucune ligne.
- **`ChangelogHelperTest`** (8) : v1.3.0 — comparaison SemVer **numérique**
(1.2.9 < 1.2.10 : la comparaison lexicographique serait fausse), extraction
de sections depuis la dernière vue, première installation (section courante
seule), plusieurs versions intermédiaires, versions non numériques.
- **`AppLogTest`** (4) : v1.3.1 — formatage des lignes horodatées, buffer
circulaire (600 → 500 dernières, bordures).
- **`HrtDurationTest`** (6) : v1.3.1 — jours depuis la 1re prise (0 si
futur/invalide), décomposition mois(30 j)/jours.
- **`ExportFileNamesTest`** (7) : v1.3.4/v1.7.0 — noms d'export (logs/backup),
règle anti-#48 (pattern horaire ⇒ LocalDateTime), auto-backup horodaté +
`parseAutoBackupTimestamp` (round-trip, non-auto-backups → null).
- **`CalendarRruleTest`** (3) : v1.3.0 — RRULE `FREQ=DAILY;INTERVAL=N`,
arrondi demi-supérieur des décimales (6,5 → 7), clamp ≥ 1 jour.
- **`ExtremaTest`** (6) : v1.2.3 — détection des pics/creux (`detectExtrema`) :
alternance stricte pic/creux en régime d'équilibre (4 doses hebdo → ≥ 4 extrema,
pic > creux voisin, valeurs dans les bornes), courbe monotone → vide, série plate →
vide, série trop courte → vide, **filtre d'amplitude** (sémantique zigzag : une
oscillation sous le seuil produit UN pivot, l'alternance complète apparaît quand le
seuil baisse), anti-corrélation E2/T (un pic d'E2 ≈ un creux de T).
- **`TransfemScienceModelsTest`** (5) : v1.4.0 — épingle le modèle TFS V3C sur
l'article : pics/t½ des Tableaux 9–10 (±1–2 %), AUC par mg (±3 %), état
d'équilibre EV 5 mg/7 j = Figure 11 (Cmax 384/Cmin 142/Cavg 269), esters
sans modèle → 0, dt ≤ 0 → 0. **Toute retouche de TransfemScienceModels.kt
passe par CE test** (c'est la fidélité au simulateur TFS qui est vérifiée).
- **`WhsahModelsTest`** (6) : v1.4.6 — fidélité au comportement **Mona**
(fit WHSAH) : pics EV 367,5 @ 1,69 j / EEn 187,9 @ 4,99 j (±2 %), t½ par
ester, linéarité par mg, PEP non couvert, `choicesForModel` ×3 modèles.
- **`WhsahEngineTest`** (6) : v1.4.6→v1.4.8 — dispatch WHSAH (override
depuis un traitement ESE, `pkModel = WHS`, coupure 10 × t½, PEP → 0) et
**3 courbes EV distinctes** (ESE/TFS/WHS — seuil 2 % : ESE et TFS sont
quasi identiques au pic par construction) ; + v1.4.8 **calibration PAR
MODÈLE** (fix #60) : chaque courbe calibrée avec la prédiction de SON
modèle passe par le lab (±2 %), échelles TFS ≠ WHS au même lab ; +
v1.4.7 **calibration universelle épinglée** (le facteur par ester
s'applique à TOUTES les courbes, indépendamment du modelOverride).
- **`ChartZoomTest`** (23) : v1.2.9 → v1.4.10 — pas d'échantillonnage
adaptatif, horizon + clamp de panoramique, `pointHoursBefore`,
extension de prévision, `xLabelTicks` (minuit LOCAL, fuseau CHOISI,
cas dégénérés), `defaultModelToggles` + **pan en cumul fractionnaire**
(`panDeltaHours`, fix #62 : les deltas de doigt < 1 h s'accumulent
jusqu'à franchir une heure entière — la vue 24 h ne bougeait jamais ;
deltas négatifs = troncature vers zéro, cas dégénérés).
- **`ScaleFactorWhsahReproTest`** (4) : v1.4.9 — épinglé sur le bug ×2,21
(#61) : facteur manuel WHSAH 0,55 (plus jamais 2,21), cohérence
auto↔manuel (±15 %), garde de significativité (séquence réelle 184→1 et
cas aux frontières).
- **`CalibrationPerModelTest`** (5) : v1.4.8 — **process calibration** :
les 3 modèles (ESE inclus) calibrés atterrissent sur le lab ; échelles
distinctes (ESE↔TFS ~2 %, WHS ~11 % — nuance documentée : la table ODS
dérive du même article) ; **délégation** `autoCalibrated(modelOverride)`
→ `computeEsterScaleFactors` (implémentation unique — Home et graphique
ne peuvent pas diverger) ; **k T par modèle** (k ∝ 1/E2 calibrée :
échelle ×2 → k ÷2 ; les k post-calibration convergent entre modèles —
même E2 cible) ; labs antérieurs à la 1ʳᵉ dose → échelles vides (par
modèle). Toute retouche du pipeline de calibration passe par CE test.
- **`ChartScreenSourceGuardTest`** (1) : v1.4.4 — **garde de SOURCE** contre
le bug #54 : vérifie que les vals de fenêtre du graphique sont exposées au
gestionnaire de gestes via `rememberUpdatedState` (alias `gesture*`) et que
la closure `detectTransformGestures` ne les lit jamais en direct (captures
figées). Le lint Compose ne détecte pas ce pattern — ce test est le filet.
- **`RegressionUserCase4Test`** (5) : v1.4.3/v1.4.5 — 4ᵉ régression épinglée sur
les données réelles (export v1.4.2, HORS dépôt) : parsing v2 avec
**settings** (seuils + auto-cal), scénario transition EV inactif → EEn
actif, **signature pharmacocinétique EEn** (contribution d'une dose à
J+1 < 12 % du total, plateau 48 h ≤ 25 % — c'est la réponse épinglée à
« pourquoi l'estimation ne remonte pas le lendemain »), pas de fausse
alerte à l'observation. **Tout nouvel export = une régression data-driven.**
- **`RegressionUserCase5Test`** (7) : v1.7.1 — 5ᵉ régression épinglée sur
les données réelles (export v1.7.0, HORS dépôt) : profil à 3 traitements
(CPA oral Bateman + EEn actif + EV inactif simulé, §6.bis), **le fix
« une note sur deux » épinglé sur la VRAIE paire** (dernière prise E2+T
avec deux notes distinctes → `labNotesForDisplay` en rend deux, préfixées ;
note identique → une seule), notes de DOSES préservées, plausibilité
moteur (niveau physiologique, courbe 30 j, auto-calibration couvre un
ester), prévision 7 j. **Tout nouvel export = une régression data-driven.**
- **`AlertsTest`** (12) : v1.4.2 — logique pure des seuils (`pk/Alerts.kt`) :
HIGH/LOW, comparaison STRICTE (valeur == limite → rien, éviter le
clignotement d'arrondi), seuil null → jamais d'alerte, cohérence haut > bas,
HIGH prime LOW en config incohérente (défensif), les deux marqueurs à la
fois (E2 puis T), **codec d'état** (`encodeState`/`parseState` — rétro-résistant
aux entrées malformées) et **décision de notification** (`shouldNotify` :
nouveau franchissement → notif, même état → pas de spam, retour à la
normale → pas de notif mais ré-arme).
- **`AlertsEngineTest`** (4) : v1.4.2 — câblage moteur → seuils : le niveau
évalué par la NOTIFICATION (`levelAt(scalePerEster)`) est celui de la
CARTE accueil (`computeCurve` dernier point, ±10 % de dérive horaire) ;
seuil à la moitié du niveau réel → HIGH, seuil au double → rien ; T bas
→ LOW en ng/mL ; l'état persisté ne contient que les marqueurs en alerte.
- **`TransfemScienceEngineTest`** (4) : v1.4.0 — câblage MOTEUR ↔ V3C :
EB (sans table ODS) routé en TFS → pic article 971 pg/mL @ 0,65 j ;
EB forcé en ESE (`modelOverride`) → 0 partout (comportement documenté :
presets EB/EC/ECS/PEP en TFS uniquement) ; PEP 32,5 mg → contribution
à 10 j ET 100 j (l'ancienne coupure 24 h du fallback table aurait donné 0)
et 0 au-delà de 10 × t½ (par design) ; `computeCurve(modelOverride=TFS)`
sur un traitement stocké ESE reproduit le pic V3C EV 295 — c'est le test
du dispatch `concentrationOfDose` + de `cutoffHours`.
- **`ReminderScheduleTest`** (8) : v1.4.0 — fix #52 : rappel sur la GRILLE
Posologie (dose il y a 2 j + intervalle 7 j → alarme dans 5 j à l'heure
choisie, PAS demain), heure déjà passée le jour du créneau → créneau
suivant, créneau manqué sauté (jamais dans le passé), fallback quotidien
sans Posologie ou sans doses, agrégat multi-traitements (le plus tôt
gagne), traitements inactifs/désactivés → null.
- **`LabTimingTest`** (11) : v1.8.0/v1.8.1 — recommandation de prochaine
prise de sang : creux juste avant le créneau, saut au premier creux
STABILISÉ (5 × t½ ; régime non stabilisé → creux suivant), prise récente →
creux suivant, sans Posologie/oral → null, invite Posologie, Estrannaise
lit sa t½ dans la table ; v1.8.1 — changements récents (dose/intervalle/
ester) réinitialisent la stabilisation.
- **`LabTrajectoryModelTest`** (19) : 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.
v1.6.0 — **prolongation** (§7.10.bis) : identité `M × ρ_last` exacte
au-delà du dernier lab (10/20/30 j), suture exacte au dernier ancre,
horizon = cutoff du traitement (dernier point ≤ cutoff < dernier point + pas),
dose EV loguée après le dernier lab → pic de prolongation (> ×1,3 le
niveau pré-injection — la forme suit dosage + ester), flag off =
v1.5.0 bit-compatible, garde #61 sur la prolongation (le lab tardif ne
prolonge ni n'ancre), gardes du helper d'horizon (pas de dose / modèle
éteint → null), `lastAnchorMs` (null si < 2 ancres).
### 8.bis Données de test réelles : HORS dépôt (`local-test-data/`) ### 8.bis Données de test réelles : HORS dépôt (`local-test-data/`)
Les deux classes de régression (`RegressionUserCase{,2}Test`) épinglent le comportement Les cinq classes de régression (`RegressionUserCase{,2,3,4,5}Test`) épinglent le comportement
du moteur sur les **exports réels** de l'utilisatrice. Ce sont des **données de santé du moteur sur les **exports réels** de l'utilisatrice. Ce sont des **données de santé
personnelles** : elles ne sont **pas versionnées**, pour ne rien divulguer dans le personnelles** : elles ne sont **pas versionnées**, pour ne rien divulguer dans le
dépôt (ni maintenant, ni si le repo devient public un jour). dépôt (ni maintenant, ni si le repo devient public un jour).
@ -748,138 +888,6 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
répliquées sur les deux Gitea, aucune fuite nouvelle par push) ; la dette répliquées sur les deux Gitea, aucune fuite nouvelle par push) ; la dette
(réécriture d'historique + re-tags + force-push des 2 remotes + re-publish des (réécriture d'historique + re-tags + force-push des 2 remotes + re-publish des
releases) reste ouverte et doit être re-décisionnée si le dépôt passe PUBLIC. releases) reste ouverte et doit être re-décisionnée si le dépôt passe PUBLIC.
- **`LabsGroupingTest`** (9) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T
même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ;
ordre chronologique décroissant). v1.7.1 — **notes d'un groupe**
(`labNotesForDisplay`) : notes distinctes E2/T → DEUX lignes préfixées du
marqueur (fix « une seule note sur deux »), note identique → une seule
ligne (dédupliquée), note seule → brute (v1.0 préservé), vides/blanches
ignorées, aucune note → aucune ligne.
- **`ChangelogHelperTest`** (8) : v1.3.0 — comparaison SemVer **numérique**
(1.2.9 < 1.2.10 : la comparaison lexicographique serait fausse), extraction
de sections depuis la dernière vue, première installation (section courante
seule), plusieurs versions intermédiaires, versions non numériques.
- **`AppLogTest`** (4) : v1.3.1 — formatage des lignes horodatées, buffer
circulaire (600 → 500 dernières, bordures).
- **`HrtDurationTest`** (6) : v1.3.1 — jours depuis la 1re prise (0 si
futur/invalide), décomposition mois(30 j)/jours.
- **`CalendarRruleTest`** (3) : v1.3.0 — RRULE `FREQ=DAILY;INTERVAL=N`,
arrondi demi-supérieur des décimales (6,5 → 7), clamp ≥ 1 jour.
- **`ExtremaTest`** (6) : v1.2.3 — détection des pics/creux (`detectExtrema`) :
alternance stricte pic/creux en régime d'équilibre (4 doses hebdo → ≥ 4 extrema,
pic > creux voisin, valeurs dans les bornes), courbe monotone → vide, série plate →
vide, série trop courte → vide, **filtre d'amplitude** (sémantique zigzag : une
oscillation sous le seuil produit UN pivot, l'alternance complète apparaît quand le
seuil baisse), anti-corrélation E2/T (un pic d'E2 ≈ un creux de T).
- **`TransfemScienceModelsTest`** (5) : v1.4.0 — épingle le modèle TFS V3C sur
l'article : pics/t½ des Tableaux 9–10 (±1–2 %), AUC par mg (±3 %), état
d'équilibre EV 5 mg/7 j = Figure 11 (Cmax 384/Cmin 142/Cavg 269), esters
sans modèle → 0, dt ≤ 0 → 0. **Toute retouche de TransfemScienceModels.kt
passe par CE test** (c'est la fidélité au simulateur TFS qui est vérifiée).
- **`WhsahModelsTest`** (6) : v1.4.6 — fidélité au comportement **Mona**
(fit WHSAH) : pics EV 367,5 @ 1,69 j / EEn 187,9 @ 4,99 j (±2 %), t½ par
ester, linéarité par mg, PEP non couvert, `choicesForModel` ×3 modèles.
- **`WhsahEngineTest`** (5) : v1.4.6/v1.4.7 — dispatch WHSAH (override depuis un
identiques au pic par construction) ; + **calibration universelle épinglée**
(le facteur par ester s'applique à TOUTES les courbes, indépendamment du
modelOverride — demande v1.4.7).
traitement ESE, `pkModel = WHS`, coupure 10 × t½, PEP → 0) et **3 courbes
EV distinctes** (ESE/TFS/WHS — seuil 2 % : ESE et TFS sont quasi
identiques au pic par construction).
- **`WhsahEngineTest`** (6) : + v1.4.8 — **calibration PAR MODÈLE** (fix
#60) : chaque courbe calibrée avec la prédiction de SON modèle passe par
le lab (±2 %) ; échelles TFS ≠ WHS au même lab.
- **`ChartZoomTest`** (19) : + v1.4.10 — **pan en cumul fractionnaire**
(`panDeltaHours`, fix #62 : les deltas de doigt < 1 h s'accumulent
jusqu'à franchir une heure entière — la vue 24 h ne bougeait jamais ;
deltas négatifs = troncature vers zéro, cas dégénérés).
- **`ScaleFactorWhsahReproTest`** (4) : v1.4.9 — épinglé sur le bug ×2,21
(#61) : facteur manuel WHSAH 0,55 (plus jamais 2,21), cohérence
auto↔manuel (±15 %), garde de significativité (séquence réelle 184→1 et
cas aux frontières).
- **`CalibrationPerModelTest`** (5) : v1.4.8 — **process calibration** :
les 3 modèles (ESE inclus) calibrés atterrissent sur le lab ; échelles
distinctes (ESE↔TFS ~2 %, WHS ~11 % — nuance documentée : la table ODS
dérive du même article) ; **délégation** `autoCalibrated(modelOverride)`
→ `computeEsterScaleFactors` (implémentation unique — Home et graphique
ne peuvent pas diverger) ; **k T par modèle** (k ∝ 1/E2 calibrée :
échelle ×2 → k ÷2 ; les k post-calibration convergent entre modèles —
même E2 cible) ; labs antérieurs à la 1ʳᵉ dose → échelles vides (par
modèle). Toute retouche du pipeline de calibration passe par CE test.
- **`ChartZoomTest`** (15) : v1.2.9 (pas d'échantillonnage adaptatif) /
v1.4.1 (horizon + clamp panoramique) / v1.4.5 (`pointHoursBefore`,
`xLabelTicks` minuit local) / v1.4.7 — **`defaultModelToggles`** (toggles
alignés sur les modèles utilisés par les traitements ; garde-fou TFS si
aucun profil),
horizon + clamp de panoramique, `pointHoursBefore`, extension de prévision,
**`xLabelTicks`** (minuit LOCAL, fuseau CHOISI America/New_York, heures
rondes, cas dégénérés).
- **`ChartScreenSourceGuardTest`** (1) : v1.4.4 — **garde de SOURCE** contre
le bug #54 : vérifie que les vals de fenêtre du graphique sont exposées au
gestionnaire de gestes via `rememberUpdatedState` (alias `gesture*`) et que
la closure `detectTransformGestures` ne les lit jamais en direct (captures
figées). Le lint Compose ne détecte pas ce pattern — ce test est le filet.
- **`RegressionUserCase4Test`** (4) : v1.4.3 — 4ᵉ régression épinglée sur
les données réelles (export v1.4.2, HORS dépôt) : parsing v2 avec
**settings** (seuils + auto-cal), scénario transition EV inactif → EEn
actif, **signature pharmacocinétique EEn** (contribution d'une dose à
J+1 < 12 % du total, plateau 48 h ≤ 25 % — c'est la réponse épinglée à
« pourquoi l'estimation ne remonte pas le lendemain »), pas de fausse
alerte à l'observation. **Tout nouvel export = une régression data-driven.**
- **`RegressionUserCase5Test`** (7) : v1.7.1 — 5ᵉ régression épinglée sur
les données réelles (export v1.7.0, HORS dépôt) : profil à 3 traitements
(CPA oral Bateman + EEn actif + EV inactif simulé, §6.bis), **le fix
« une note sur deux » épinglé sur la VRAIE paire** (dernière prise E2+T
avec deux notes distinctes → `labNotesForDisplay` en rend deux, préfixées ;
note identique → une seule), notes de DOSES préservées, plausibilité
moteur (niveau physiologique, courbe 30 j, auto-calibration couvre un
ester), prévision 7 j. **Tout nouvel export = une régression data-driven.**
- **`AlertsTest`** (12) : v1.4.2 — logique pure des seuils (`pk/Alerts.kt`) :
HIGH/LOW, comparaison STRICTE (valeur == limite → rien, éviter le
clignotement d'arrondi), seuil null → jamais d'alerte, cohérence haut > bas,
HIGH prime LOW en config incohérente (défensif), les deux marqueurs à la
fois (E2 puis T), **codec d'état** (`encodeState`/`parseState` — rétro-résistant
aux entrées malformées) et **décision de notification** (`shouldNotify` :
nouveau franchissement → notif, même état → pas de spam, retour à la
normale → pas de notif mais ré-arme).
- **`AlertsEngineTest`** (4) : v1.4.2 — câblage moteur → seuils : le niveau
évalué par la NOTIFICATION (`levelAt(scalePerEster)`) est celui de la
CARTE accueil (`computeCurve` dernier point, ±10 % de dérive horaire) ;
seuil à la moitié du niveau réel → HIGH, seuil au double → rien ; T bas
→ LOW en ng/mL ; l'état persisté ne contient que les marqueurs en alerte.
- **`TransfemScienceEngineTest`** (4) : v1.4.0 — câblage MOTEUR ↔ V3C :
EB (sans table ODS) routé en TFS → pic article 971 pg/mL @ 0,65 j ;
EB forcé en ESE (`modelOverride`) → 0 partout (comportement documenté :
presets EB/EC/ECS/PEP en TFS uniquement) ; PEP 32,5 mg → contribution
à 10 j ET 100 j (l'ancienne coupure 24 h du fallback table aurait donné 0)
et 0 au-delà de 10 × t½ (par design) ; `computeCurve(modelOverride=TFS)`
sur un traitement stocké ESE reproduit le pic V3C EV 295 — c'est le test
du dispatch `concentrationOfDose` + de `cutoffHours`.
- **`ReminderScheduleTest`** (8) : v1.4.0 — fix #52 : rappel sur la GRILLE
Posologie (dose il y a 2 j + intervalle 7 j → alarme dans 5 j à l'heure
choisie, PAS demain), heure déjà passée le jour du créneau → créneau
suivant, créneau manqué sauté (jamais dans le passé), fallback quotidien
sans Posologie ou sans doses, agrégat multi-traitements (le plus tôt
gagne), traitements inactifs/désactivés → null.
- **`LabTrajectoryModelTest`** (19) : 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.
v1.6.0 — **prolongation** (§7.10.bis) : identité `M × ρ_last` exacte
au-delà du dernier lab (10/20/30 j), suture exacte au dernier ancre,
horizon = cutoff du traitement (dernier point ≤ cutoff < dernier point + pas),
dose EV loguée après le dernier lab → pic de prolongation (> ×1,3 le
niveau pré-injection — la forme suit dosage + ester), flag off =
v1.5.0 bit-compatible, garde #61 sur la prolongation (le lab tardif ne
prolonge ni n'ancre), gardes du helper d'horizon (pas de dose / modèle
éteint → null), `lastAnchorMs` (null si < 2 ancres).
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis **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. la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
@ -1388,7 +1396,7 @@ aucun contexte, dans l'esprit de cette doc) :**
JSON marchait car `yyyyMMdd` est un pattern valide pour LocalDate. JSON marchait car `yyyyMMdd` est un pattern valide pour LocalDate.
Zones d'ombre emboîtées : v1.3.2 et v1.3.3 « corrigent » l'IO (cf #41/#45) Zones d'ombre emboîtées : v1.3.2 et v1.3.3 « corrigent » l'IO (cf #41/#45)
sans voir cette ligne. FIX : `util/ExportFileNames.kt` (pur, testé, sans voir cette ligne. FIX : `util/ExportFileNames.kt` (pur, testé,
documenté — couplage type↔pattern centralisé) ; `FileNamesTest` épinglant documenté — couplage type↔pattern centralisé) ; `ExportFileNamesTest` épinglant
les deux helpers (aurait attrapé le bug le jour même). Leçon : la les deux helpers (aurait attrapé le bug le jour même). Leçon : la
génération de nom de fichier ne doit JAMAIS vivre inline dans un onClick. génération de nom de fichier ne doit JAMAIS vivre inline dans un onClick.
49. **StringFormatMatches** (capture du lint, v1.3.4) : la notification de 49. **StringFormatMatches** (capture du lint, v1.3.4) : la notification de
@ -1602,7 +1610,7 @@ l'optimiseur.
le chip « Lab track » s'active MAIS la courbe n'est ni dessinée (0 pixel le chip « Lab track » s'active MAIS la courbe n'est ni dessinée (0 pixel
#C2185B au screencap) ni légendée ; « Prolonger » idem. En DEBUG, le même #C2185B au screencap) ni légendée ; « Prolonger » idem. En DEBUG, le même
chip sur les mêmes données fonctionne (légende + 7090 px rose après fix). chip sur les mêmes données fonctionne (légende + 7090 px rose après fix).
**Les 191 tests JVM sont verts** (ils ne passent JAMAIS par R8). **Les 191 tests JVM de l'époque sont verts** (223 aujourd'hui — ils ne passent JAMAIS par R8).
**Diagnostic** (méthode de bisection debug/release puis `mapping.txt`) : **Diagnostic** (méthode de bisection debug/release puis `mapping.txt`) :
`com.hormonetrack.pk.LabTrajectoryModel -> R8$$REMOVED$$CLASS$$…` — R8 full `com.hormonetrack.pk.LabTrajectoryModel -> R8$$REMOVED$$CLASS$$…` — R8 full
@ -1617,6 +1625,13 @@ proguard-rules.pro (garde commentée + renvoi au mapping). Validé émulateur
sur l'APK release re-buildé : ancrée (courbe + légende), prolongée (légende sur l'APK release re-buildé : ancrée (courbe + légende), prolongée (légende
+ avertissement), 0 crash. + avertissement), 0 crash.
**#65/#66 (v1.8.2) — courbes d'accueil/graphiques pas rafraîchies sur édition** : les
clés des `produceState` étaient incomplètes (Home : `allTreatments`/`tConfig` absentes ;
Chart : `labResults.size` au lieu de la liste) — éditer un scaleFactor/le modèle T/la
valeur d'un lab ne rafraîchissait pas les courbes avant le tick de 60 s. Leçon :
les clés d'un producteur d'état Compose doivent couvrir TOUTE entrée du calcul (pas
un proxy comme `.size`).
## 15. Comment régénérer l'asset pk_profiles.json ## 15. Comment régénérer l'asset pk_profiles.json
Si le `.ods` change (re-fits, nouveaux esters) : Si le `.ods` change (re-fits, nouveaux esters) :
@ -1641,7 +1656,7 @@ structure ci-dessus. **Toute restructuration du JSON impose de mettre à jour
```bash ```bash
cd ~/projects/HormoneTrack cd ~/projects/HormoneTrack
./gradlew assembleDebug testDebugUnitTest # build + 87 tests (44 sans données locales) ./gradlew assembleDebug testDebugUnitTest # build + 223 tests (193 sans données locales)
./gradlew assembleRelease # APK optimisé R8 (cf §16.bis) ./gradlew assembleRelease # APK optimisé R8 (cf §16.bis)
adb install -r app/build/outputs/apk/debug/app-debug.apk adb install -r app/build/outputs/apk/debug/app-debug.apk
``` ```
@ -1709,8 +1724,9 @@ Le script :
(`[1.2.3]`) — première version du script cherchait `[vX.Y.Z]` et tombait sur (`[1.2.3]`) — première version du script cherchait `[vX.Y.Z]` et tombait sur
le fallback « Voir docs/CHANGELOG.md » ; le fallback « Voir docs/CHANGELOG.md » ;
- crée la release si absente, sinon **met à jour le corps** (PATCH) ; - crée la release si absente, sinon **met à jour le corps** (PATCH) ;
- attache l'APK (remplace l'asset du même nom si présent) — appeler le script - attache l'APK (remplace l'asset du même nom si présent) — ⚠️ script
DEUX FOIS pour publier les deux APK (noms distincts) ; mono-APK : ne plus l'utiliser pour publier les APK, cf #43 et
publish-release.py (UNE invocation fait tout) ;
⚠️ vérification intégrée (`ensure_asset`) : le `?name=` de l'upload et/ou ⚠️ vérification intégrée (`ensure_asset`) : le `?name=` de l'upload et/ou
le PATCH peuvent être ignorés par les instances (assets au nom générique / le PATCH peuvent être ignorés par les instances (assets au nom générique /
APK disparu — v1.2.6, v1.2.10) → le script vérifie **nom ET taille** après APK disparu — v1.2.6, v1.2.10) → le script vérifie **nom ET taille** après
@ -1740,9 +1756,9 @@ vérifie pas en tests JVM).
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`, 1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y). `versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 191 au 2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 223 au
total, 168 si `local-test-data/` est absent (les 4 classes de régression total, 193 si `local-test-data/` est absent (les 5 classes de régression
réelles sont skippées via `Assume`, 6/6/6/5 tests) ; **lint vert réelles sont skippées via `Assume`, 6/6/6/5/7 tests) ; **lint vert
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet : attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet :
chaque commit = tests + lint ; release = + scan confidentialité + APK au chaque commit = tests + lint ; release = + scan confidentialité + APK au
@ -1756,7 +1772,7 @@ vérifie pas en tests JVM).
headless hrt36 + seed `scripts/seed-emulator.py` avec le dernier headless hrt36 + seed `scripts/seed-emulator.py` avec le dernier
backup de `local-test-data/` + APK release installé PAR-DESSUS) sur backup de `local-test-data/` + APK release installé PAR-DESSUS) sur
**l'APK RELEASE** (JAMAIS le debug seul : **R8 ne se vérifie qu'ici** — **l'APK RELEASE** (JAMAIS le debug seul : **R8 ne se vérifie qu'ici** —
les 191 tests JVM ne le voient jamais, cf #64). Parcours minimum : les 223 tests JVM ne le voient jamais, cf #64). Parcours minimum :
l'app démarre 0 crash → écran Graphiques → activer les chips de la l'app démarre 0 crash → écran Graphiques → activer les chips de la
release (rangée modèles défilable : `adb shell input swipe 900 716 150 release (rangée modèles défilable : `adb shell input swipe 900 716 150
716 400` puis tap aux bounds du `uiautomator dump`) → vérifier dans le 716 400` puis tap aux bounds du `uiautomator dump`) → vérifier dans le
@ -1775,7 +1791,7 @@ vérifie pas en tests JVM).
`Siphonight/HormoneTrack-web` — historique 100 % propre, séparé de la `Siphonight/HormoneTrack-web` — historique 100 % propre, séparé de la
dette de confidentialité de ce dépôt). À chaque release : porter la dette de confidentialité de ce dépôt). À chaque release : porter la
feature web si pas déjà fait, aligner `WEB_VERSION` sur la MÊME `X.Y.Z`, feature web si pas déjà fait, aligner `WEB_VERSION` sur la MÊME `X.Y.Z`,
`bash scripts/check.sh` vert (121 tests + E2E), commit + tag `vX.Y.Z` `bash scripts/check.sh` vert (155 tests + E2E), commit + tag `vX.Y.Z`
côté web, push des deux dépôts. Changelogs TOTALEMENT SÉPARÉS côté web, push des deux dépôts. Changelogs TOTALEMENT SÉPARÉS
(`web/docs/CHANGELOG.md` ≠ ce fichier). Process web complet : (`web/docs/CHANGELOG.md` ≠ ce fichier). Process web complet :
`../HormoneTrack-web/docs/DEVELOPPEMENT.md` §3 + §10. Publication `../HormoneTrack-web/docs/DEVELOPPEMENT.md` §3 + §10. Publication
@ -1874,8 +1890,7 @@ signal ; stack : `adb logcat -b crash -d` (grep FATAL). **0 crash attendu :
`adb shell input swipe` ne délivrent PAS les mouvements intermédiaires au `adb shell input swipe` ne délivrent PAS les mouvements intermédiaires au
chart (`detectTransformGestures` ne reçoit que down/up sans moves) → le chart (`detectTransformGestures` ne reçoit que down/up sans moves) → le
pan et le pinch sont INTESTABLES par adb sur cet environnement (bug #62 pan et le pinch sont INTESTABLES par adb sur cet environnement (bug #62
non détectable ici). Le pan/pinch se valide sur le Pixel 9 réel (l'appa- non détectable ici). Le pan/pinch se valide sur le Pixel 9 réel (l'appareil du test humain). Les TAPS, la navigation et les dialogs fonctionnent
rel du test humain). Les TAPS, la navigation et les dialogs fonctionnent
normalement par adb. normalement par adb.
**5. Flux SAF (export JSON/logs)** : sur android-31 ATD le **5. Flux SAF (export JSON/logs)** : sur android-31 ATD le
@ -1931,7 +1946,7 @@ Volontaires (v1) :
- DST : les rappels quotidiens peuvent glisser d'1 h après changement d'heure, jusqu'au - DST : les rappels quotidiens peuvent glisser d'1 h après changement d'heure, jusqu'au
prochain reschedule (boot/save) — mineur prochain reschedule (boot/save) — mineur
- Labs : marqueur libre — E2/T exacts requis pour calibration/charts - Labs : marqueur libre — E2/T exacts requis pour calibration/charts
- `allowBackup=false` → seul backup = export JSON manuel - `allowBackup=false` → backups = export JSON manuel + auto-backup quotidien (v1.7.0, dans le dossier choisi)
- **APK release (R8)** : la réflexion Gson est couverte par des `-keep` - **APK release (R8)** : la réflexion Gson est couverte par des `-keep`
explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur
téléphone** (export/import backup, graphiques) avant chaque publication ; téléphone** (export/import backup, graphiques) avant chaque publication ;
@ -1994,15 +2009,15 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] **v1.2.x** : édition d'une dose (tap ligne Doses) et d'un lab (tap ligne - [ ] **v1.2.x** : édition d'une dose (tap ligne Doses) et d'un lab (tap ligne
Analyses → sélecteur E2/T si paire) ; suppression par prise entière Analyses → sélecteur E2/T si paire) ; suppression par prise entière
- [ ] **v1.2.x** : prise de sang E2 + T en une entrée (champs optionnels) - [ ] **v1.2.x** : prise de sang E2 + T en une entrée (champs optionnels)
- [ ] **v1.2.9** : zoom du graphique (pinch 2 doigts + boutons − / + ; labels
X adaptatifs ; courbes lisses à fort zoom)
- [ ] **v1.2.7** : prévision après un OUBLI (pas de faux pic dans le passé)
et après un RETARD (prévision décalée, suivant la dernière prise)
- [ ] **v1.2.6** : import d'un backup avec l'app déjà remplie → ÉCRASER &
restaurer (message clair), tConfig restauré, rappels reprogrammés
- [ ] **v1.2.4** : passer un traitement à inactif → retiré de « Log rapide » et - [ ] **v1.2.4** : passer un traitement à inactif → retiré de « Log rapide » et
du dropdown des nouvelles doses, rappel annulé, **mais sa simulation reste du dropdown des nouvelles doses, rappel annulé, **mais sa simulation reste
sur le graphique** et la calibration couvre toujours ses périodes sur le graphique** et la calibration couvre toujours ses périodes
- [ ] **v1.2.6** : import d'un backup avec l'app déjà remplie → ÉCRASER &
restaurer (message clair), tConfig restauré, rappels reprogrammés
- [ ] **v1.2.7** : prévision après un OUBLI (pas de faux pic dans le passé)
et après un RETARD (prévision décalée, suivant la dernière prise)
- [ ] **v1.2.9** : zoom du graphique (pinch 2 doigts + boutons − / + ; labels
X adaptatifs ; courbes lisses à fort zoom)
- [ ] **v1.3.3** : **export des logs** → le gestionnaire de fichiers s'ouvre, - [ ] **v1.3.3** : **export des logs** → le gestionnaire de fichiers s'ouvre,
le .txt est écrit, un message « Logs de diagnostic exportés » s'affiche ; le .txt est écrit, un message « Logs de diagnostic exportés » s'affiche ;
cas d'échec (annulation du chooser = silencieux, échec d'écriture = cas d'échec (annulation du chooser = silencieux, échec d'écriture =
@ -2081,6 +2096,21 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] **v1.4.6** : **3ᵉ modèle WHSAH** : preset WHSAH → courbe verte - [ ] **v1.4.6** : **3ᵉ modèle WHSAH** : preset WHSAH → courbe verte
superposable aux 2 autres (montée plus rapide à J+1, t½ plus longues 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 que TFS) ; PEP non couvert ; 6 presets ; dropdown modèle à 3 choix
- [ ] **v1.4.7** : **toggles alignés sur les traitements** : au 1ᵉʳ chargement
du graphique, seuls les modèles des traitements à profil sont ON
(ex. EV TFS + EEn WHSAH → Estrannaise OFF) ; les autres activables au
tap ; et TOUTES les courbes affichées reflètent la calibration (test)
- [ ] **v1.4.8** : **calibration par modèle** : superposer TFS + WHSAH avec
l'auto-calibration ON → CHAQUE courbe colle aux labs (plus de facteur
partagé absurde type ×2,21 — fix #60)
- [ ] **v1.4.9** : **calibration ×2,21 corrigée** (#61) : le bouton
« Calibrer avec les analyses » et l'auto-calibration retombent sur un
facteur physiologique (< 1,2) même avec des doses de test anciennes ;
hint dans l'éditeur quand l'auto-calibration écrase le facteur manuel
- [ ] **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
le drag gauche avec prévision (futur)
- [ ] **v1.5.0** : **Tracé labs** : chip `Tracé labs` (rangée modèles, - [ ] **v1.5.0** : **Tracé labs** : chip `Tracé labs` (rangée modèles,
off par défaut) → la courbe rose foncé pointillé passe EXACTEMENT 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 sur chacun de tes labs E2 entre le 1er et le dernier LAB ; le reste
@ -2121,24 +2151,11 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
recommandé → la carte passe au creux SUIVANT ; enlever la Posologie recommandé → la carte passe au creux SUIVANT ; enlever la Posologie
→ carte remplacée par l'invite « renseigne une Posologie » ; → carte remplacée par l'invite « renseigne une Posologie » ;
traitement oral seul → aucune carte traitement oral seul → aucune carte
- [ ] **v1.4.10** : **pan sur la vue 24 h** : glisser horizontalement à - [ ] **v1.8.1** : **stabilisation corrigée + suggestion Accueil** : après
24 h doit déplacer la courbe (fix #62 : les deltas < 1 h s'accumulent changement de dose/ester/intervalle, la carte ne dit plus « stabilisé
— avant, le pan ne bougeait jamais sur 24 h) ; tester aussi à 7 j et depuis » l'ancienne période (régime = séquence terminale (ester, dose,
le drag gauche avec prévision (futur) écart) constants) ; une carte compacte de recommandation apparaît
aussi sur l'Accueil ; 0 crash
- [ ] **v1.4.9** : **calibration ×2,21 corrigée** (#61) : le bouton
« Calibrer avec les analyses » et l'auto-calibration retombent sur un
facteur physiologique (< 1,2) même avec des doses de test anciennes ;
hint dans l'éditeur quand l'auto-calibration écrase le facteur manuel
- [ ] **v1.4.8** : **calibration par modèle** : superposer TFS + WHSAH avec
l'auto-calibration ON → CHAQUE courbe colle aux labs (plus de facteur
partagé absurde type ×2,21 — fix #60)
- [ ] **v1.4.7** : **toggles alignés sur les traitements** : au 1ᵉʳ chargement
du graphique, seuls les modèles des traitements à profil sont ON
(ex. EV TFS + EEn WHSAH → Estrannaise OFF) ; les autres activables au
tap ; et TOUTES les courbes affichées reflètent la calibration (test)
--- ---
*Doc mise à jour le 16 sept. 2026 (v1.7.1) — build OK, lint vert, 211/211 tests verts (181 sans *Doc mise à jour le 16 sept. 2026 (v1.7.1) — build OK, lint vert, 211/211 tests verts (181 sans

View File

@ -122,7 +122,7 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
- **Supprimer** : la corbeille retire la prise entière (E2 + T ensemble) - **Supprimer** : la corbeille retire la prise entière (E2 + T ensemble)
- Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la - Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la
calibration (E2 en pg/mL ; T en ng/mL, ng/dL, ng/L, nmol/L) calibration (E2 en pg/mL ; T en ng/mL, ng/dL, ng/L, nmol/L)
- **Prochaine prise de sang (suggestion, v1.8.0)** : si ton traitement E2 - **Prochaine prise de sang (suggestion, v1.8.0 ; v1.8.1 : aussi sur l'Accueil)** : si ton traitement E2
injectable actif a une **Posologie**, la page te suggère quand faire la injectable actif a une **Posologie**, la page te suggère quand faire la
prochaine analyse — **au creux estimé, juste avant l'injection suivante** prochaine analyse — **au creux estimé, juste avant l'injection suivante**
(moment le plus comparable), au premier creux où ton ester est stabilisé (moment le plus comparable), au premier creux où ton ester est stabilisé
@ -248,7 +248,7 @@ Pics de référence (dose unique de 5 mg, IM) :
| Problème | Solution | | Problème | Solution |
|----------|----------| |----------|----------|
| « SDK not found » au sync | Android Studio → Settings → Languages & Frameworks → Android SDK → installer API 34 | | « SDK not found » au sync | Android Studio → Settings → Languages & Frameworks → Android SDK → vérifier/installer la plateforme 37 (le build AGP peut la télécharger automatiquement) |
| Pas de téléphone détecté | Réactive le Débogage USB, change de câble (données, pas charge seule) | | Pas de téléphone détecté | Réactive le Débogage USB, change de câble (données, pas charge seule) |
| Notif absente sur la montre | Huawei Santé → Notifications → autorise l'app ; redémarre la montre | | Notif absente sur la montre | Huawei Santé → Notifications → autorise l'app ; redémarre la montre |
| Rappels en retard | Paramètres → « Accorder les alarmes exactes » + désactive l'optimisation de batterie pour l'app | | Rappels en retard | Paramètres → « Accorder les alarmes exactes » + désactive l'optimisation de batterie pour l'app |