diff --git a/README.md b/README.md index 4d1b0ea..93e0923 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > sang et aux consignes de ton endocrinologue. -- **Statut** : v1.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) - **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) @@ -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 **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 -- **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), **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 - 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 - **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle), 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) — `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)) : `-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) : diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6fcc863..542662e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // les APK publiés (Paramètres + titre du dialog « Nouveautés »). - versionCode = 36 - versionName = "1.8.1" + versionCode = 37 + versionName = "1.8.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/HormoneTrackApp.kt b/app/src/main/java/com/hormonetrack/HormoneTrackApp.kt index 0e21451..31da8c2 100644 --- a/app/src/main/java/com/hormonetrack/HormoneTrackApp.kt +++ b/app/src/main/java/com/hormonetrack/HormoneTrackApp.kt @@ -9,6 +9,7 @@ import com.hormonetrack.reminder.createNotificationChannel import com.hormonetrack.util.AppLog import com.hormonetrack.reminder.AlertNotifier import com.hormonetrack.settings.AppSettings +import com.hormonetrack.data.backup.AutoBackupScheduler class HormoneTrackApp : Application() { @@ -31,7 +32,7 @@ class HormoneTrackApp : Application() { // le worker sort immédiatement si l'option est désactivée ; cf // data/backup/AutoBackupWorker.kt). WorkManager survit aux reboots : // BootReceiver n'a rien à faire. - com.hormonetrack.data.backup.AutoBackupScheduler.schedulePeriodic(this) + AutoBackupScheduler.schedulePeriodic(this) } } diff --git a/app/src/main/java/com/hormonetrack/data/backup/AutoBackupWorker.kt b/app/src/main/java/com/hormonetrack/data/backup/AutoBackupWorker.kt index 48bed7e..7421d2d 100644 --- a/app/src/main/java/com/hormonetrack/data/backup/AutoBackupWorker.kt +++ b/app/src/main/java/com/hormonetrack/data/backup/AutoBackupWorker.kt @@ -8,7 +8,6 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters -import com.hormonetrack.data.backup.UserSettings import com.hormonetrack.util.AppLog import kotlinx.coroutines.flow.first import java.util.concurrent.TimeUnit @@ -109,13 +108,15 @@ class AutoBackupWorker( val ok = try { // MÊME contenu que l'export manuel (format v2 avec paramètres) : // 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( language = settings.language.first(), autoCalibrate = settings.autoCalibrate.first(), - alertE2High = settings.alertThresholds.first().e2High, - alertE2Low = settings.alertThresholds.first().e2Low, - alertTHigh = settings.alertThresholds.first().tHigh, - alertTLow = settings.alertThresholds.first().tLow + alertE2High = thresholds.e2High, + alertE2Low = thresholds.e2Low, + alertTHigh = thresholds.tHigh, + alertTLow = thresholds.tLow ) val json = BackupManager.exportJson(container.repository, settings.tConfig.first(), userSettings) val keep = settings.autoBackupKeep.first() diff --git a/app/src/main/java/com/hormonetrack/data/dao/DoseLogDao.kt b/app/src/main/java/com/hormonetrack/data/dao/DoseLogDao.kt index a25ddac..ad40cd2 100644 --- a/app/src/main/java/com/hormonetrack/data/dao/DoseLogDao.kt +++ b/app/src/main/java/com/hormonetrack/data/dao/DoseLogDao.kt @@ -9,18 +9,10 @@ interface DoseLogDao { @Query("SELECT * FROM dose_logs ORDER BY timestamp DESC") fun getAll(): Flow> - @Query("SELECT * FROM dose_logs WHERE treatmentId = :treatmentId ORDER BY timestamp DESC") - fun getByTreatment(treatmentId: Long): Flow> - - @Query("SELECT * FROM dose_logs WHERE timestamp BETWEEN :start AND :end ORDER BY timestamp ASC") - fun getByTimeRange(start: Long, end: Long): Flow> - - @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> - - @Query("SELECT * FROM dose_logs ORDER BY timestamp DESC LIMIT 1") - fun getLatest(): Flow - + + + + @Query("SELECT * FROM dose_logs ORDER BY timestamp ASC") suspend fun getAllOnce(): List diff --git a/app/src/main/java/com/hormonetrack/data/dao/LabResultDao.kt b/app/src/main/java/com/hormonetrack/data/dao/LabResultDao.kt index 4814ca4..002f573 100644 --- a/app/src/main/java/com/hormonetrack/data/dao/LabResultDao.kt +++ b/app/src/main/java/com/hormonetrack/data/dao/LabResultDao.kt @@ -9,12 +9,8 @@ interface LabResultDao { @Query("SELECT * FROM lab_results ORDER BY timestamp DESC") fun getAll(): Flow> - @Query("SELECT * FROM lab_results WHERE marker = :marker ORDER BY timestamp DESC") - fun getByMarker(marker: String): Flow> - - @Query("SELECT * FROM lab_results WHERE timestamp BETWEEN :start AND :end ORDER BY timestamp ASC") - fun getByTimeRange(start: Long, end: Long): Flow> - + + @Query("SELECT * FROM lab_results ORDER BY timestamp ASC") suspend fun getAllOnce(): List diff --git a/app/src/main/java/com/hormonetrack/data/dao/TreatmentDao.kt b/app/src/main/java/com/hormonetrack/data/dao/TreatmentDao.kt index 90b0de5..d23f5a0 100644 --- a/app/src/main/java/com/hormonetrack/data/dao/TreatmentDao.kt +++ b/app/src/main/java/com/hormonetrack/data/dao/TreatmentDao.kt @@ -15,9 +15,6 @@ interface TreatmentDao { @Query("SELECT * FROM treatments WHERE id = :id") suspend fun getById(id: Long): Treatment? - @Query("SELECT * FROM treatments WHERE isActive = 1 ORDER BY createdAt DESC") - suspend fun getActiveOnce(): List - @Query("SELECT * FROM treatments ORDER BY createdAt DESC") suspend fun getAllOnce(): List @@ -35,6 +32,4 @@ interface TreatmentDao { @Delete suspend fun delete(treatment: Treatment) - @Query("UPDATE treatments SET isActive = :active WHERE id = :id") - suspend fun setActive(id: Long, active: Boolean) } diff --git a/app/src/main/java/com/hormonetrack/data/model/Treatment.kt b/app/src/main/java/com/hormonetrack/data/model/Treatment.kt index ef6cf8a..04d8886 100644 --- a/app/src/main/java/com/hormonetrack/data/model/Treatment.kt +++ b/app/src/main/java/com/hormonetrack/data/model/Treatment.kt @@ -115,6 +115,9 @@ data class Treatment( ) { val isInjection: Boolean 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 get() = isInjection && esterType != Esters.NONE diff --git a/app/src/main/java/com/hormonetrack/data/repository/HormoneRepository.kt b/app/src/main/java/com/hormonetrack/data/repository/HormoneRepository.kt index 5403447..837f9f0 100644 --- a/app/src/main/java/com/hormonetrack/data/repository/HormoneRepository.kt +++ b/app/src/main/java/com/hormonetrack/data/repository/HormoneRepository.kt @@ -18,15 +18,6 @@ class HormoneRepository( val allDoseLogs: Flow> = doseLogDao.getAll() val allLabResults: Flow> = labResultDao.getAll() - fun getDosesByTimeRange(start: Long, end: Long): Flow> = - doseLogDao.getByTimeRange(start, end) - - fun getDosesByTreatmentAndTimeRange(treatmentId: Long, start: Long, end: Long): Flow> = - doseLogDao.getByTreatmentAndTimeRange(treatmentId, start, end) - - fun getLabsByMarker(marker: String): Flow> = - labResultDao.getByMarker(marker) - suspend fun getTreatmentById(id: Long): Treatment? = treatmentDao.getById(id) suspend fun allTreatmentsOnce(): List = treatmentDao.getAllOnce() diff --git a/app/src/main/java/com/hormonetrack/pk/LabTiming.kt b/app/src/main/java/com/hormonetrack/pk/LabTiming.kt index 4155d8b..0242186 100644 --- a/app/src/main/java/com/hormonetrack/pk/LabTiming.kt +++ b/app/src/main/java/com/hormonetrack/pk/LabTiming.kt @@ -120,9 +120,9 @@ object LabTiming { // ── 2) t½ terminale de l'ester (analytique ou lue dans la table) ──── val tHalfDays = when (carrier.pkModel) { PKModels.TRANSFEM_SCIENCE -> - com.hormonetrack.pk.TransfemScienceModels.model(carrier.esterType)?.terminalHalfLifeDays + TransfemScienceModels.model(carrier.esterType)?.terminalHalfLifeDays PKModels.WHSAH -> - com.hormonetrack.pk.WhsahModels.model(carrier.esterType)?.terminalHalfLifeDays + WhsahModels.model(carrier.esterType)?.terminalHalfLifeDays PKModels.ESTRANNAISE -> PKProfileStore.terminalHalfLifeDays(carrier.esterType, carrier.pkModel) else -> null diff --git a/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt b/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt index b863024..48126c4 100644 --- a/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt +++ b/app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt @@ -2,7 +2,6 @@ package com.hormonetrack.pk import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.LabResult -import com.hormonetrack.data.model.PKModels import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.TreatmentType import kotlin.math.exp @@ -188,7 +187,7 @@ object LabTrajectoryModel { var maxPredictionSoFar = 0.0 for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) { val predicted = PharmacokineticEngine.e2At( - unscaled, doseLogs, lab.timestamp, modelOverride, null + unscaled, doseLogs, lab.timestamp, modelOverride, scalePerEster = null ) if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue anchors.add(lab.timestamp to (lab.value / predicted)) @@ -221,7 +220,7 @@ object LabTrajectoryModel { val points = mutableListOf() var t = t0 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) points.add(LevelPoint(t, m * rho, 0.0)) // t=0 : pas de courbe T pour cette série t += stepMs diff --git a/app/src/main/java/com/hormonetrack/pk/PKProfileStore.kt b/app/src/main/java/com/hormonetrack/pk/PKProfileStore.kt index 6c4bd47..9f40c1d 100644 --- a/app/src/main/java/com/hormonetrack/pk/PKProfileStore.kt +++ b/app/src/main/java/com/hormonetrack/pk/PKProfileStore.kt @@ -2,6 +2,8 @@ package com.hormonetrack.pk import android.content.Context import com.google.gson.JsonParser +import com.hormonetrack.data.model.PKModels +import kotlin.math.ln object PKProfileStore { @@ -36,7 +38,7 @@ object PKProfileStore { } 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 @@ -56,11 +58,6 @@ object PKProfileStore { fun profileLength(ester: String, model: String): Int = 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) : * 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? { if (!initialized) return null val suffix = when (model) { - com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE -> "tfs" - com.hormonetrack.data.model.PKModels.ESTRANNAISE -> "ese" + PKModels.TRANSFEM_SCIENCE -> "tfs" + PKModels.ESTRANNAISE -> "ese" else -> return 0.0 } val arr = lookup("${ester}_$suffix") ?: return null val (j, rate) = terminalDecayParameters(arr) ?: 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 } + /** + * 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 { if (!initialized || dtHours <= 0.0) return 0.0 val suffix = when (model) { - com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE -> "tfs" - com.hormonetrack.data.model.PKModels.ESTRANNAISE -> "ese" + PKModels.TRANSFEM_SCIENCE -> "tfs" + PKModels.ESTRANNAISE -> "ese" else -> return 0.0 } val arr = lookup("${ester}_$suffix") ?: return 0.0 diff --git a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt index e778cc4..388612b 100644 --- a/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt +++ b/app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt @@ -165,17 +165,32 @@ object PharmacokineticEngine { */ scalePerEster: Map? = null ): 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() val batemanCache = HashMap() var total = 0.0 for (treatment in treatments) { if (treatment.type != TreatmentType.ESTRADIOL) continue - if (!batemanCache.containsKey(treatment.id)) { + cutoffByTreatment[treatment.id] = cutoffHours(treatment) + if (!treatment.usesProfileModel) { batemanCache[treatment.id] = batemanParams(treatment) } - for (dose in doseLogs) { - if (dose.treatmentId != treatment.id || dose.timestamp > tMs) continue + for (dose in dosesByTreatment[treatment.id].orEmpty()) { + if (dose.timestamp > tMs) continue val dtH = (tMs - dose.timestamp) / 3_600_000.0 - if (dtH > cutoffHours(treatment)) continue + if (dtH > cutoffByTreatment.getValue(treatment.id)) continue val c = concentrationOfDose( treatment, dose, tMs, batemanCache[treatment.id], modelOverride ) @@ -292,7 +307,15 @@ object PharmacokineticEngine { val searchStart = maxOf(startMs, earliestDose) val batemanCache = HashMap() - 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) val sortedEstrogenDoses = doseLogs @@ -314,10 +337,10 @@ object PharmacokineticEngine { } val k = activeEster?.let { tKPerEster?.get(it) } ?: tConfig.k for (treatment in relevantTreatments) { - val cutoff = cutoffHours(treatment) + val cutoff = cutoffByTreatment.getValue(treatment.id) val p = batemanCache[treatment.id] - for (dose in doseLogs) { - if (dose.treatmentId != treatment.id || dose.timestamp > t) continue + for (dose in dosesByTreatment[treatment.id].orEmpty()) { + if (dose.timestamp > t) continue val dtH = (t - dose.timestamp) / 3_600_000.0 if (dtH > cutoff) continue val c = concentrationOfDose(treatment, dose, t, p, modelOverride) @@ -449,10 +472,7 @@ object PharmacokineticEngine { if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted } return ratiosByEster.mapValues { (_, ratios) -> - val sorted = ratios.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 * 100).toInt() / 100.0 + medianRounded(ratios, 2) } } @@ -508,12 +528,7 @@ object PharmacokineticEngine { } if (e2 > maxE2SoFar) maxE2SoFar = e2 } - return ksByEster.mapValues { (_, ks) -> - 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 - } + return ksByEster.mapValues { (_, ks) -> medianRounded(ks, 3) } } /** @@ -602,6 +617,20 @@ object PharmacokineticEngine { * jusqu'ici (les labs doivent être traités par timestamp CROISSANT) ; * 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, 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 = predicted > 0.5 && (maxSoFar <= 0.0 || predicted >= LAB_MIN_PREDICTION_FRACTION * maxSoFar) @@ -636,13 +665,7 @@ object PharmacokineticEngine { if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted } if (ratios.isEmpty()) return null - ratios.sort() - 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 + return medianRounded(ratios, 2) } fun computeTConfigCalibration( @@ -651,8 +674,10 @@ object PharmacokineticEngine { doseLogs: List, current: 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 - .filter { it.value > current.floor + 0.02 } .map { it.copy(value = convertTToNgMl(it.value, it.unit)) } .filter { it.value > current.floor + 0.02 } if (usable.isEmpty()) return null @@ -665,13 +690,7 @@ object PharmacokineticEngine { if (k > 1e-4 && k < 10.0) ks.add(k) } if (ks.isEmpty()) return null - ks.sort() - 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) + return current.copy(k = medianRounded(ks, 3)) } /** diff --git a/app/src/main/java/com/hormonetrack/reminder/AlertNotifier.kt b/app/src/main/java/com/hormonetrack/reminder/AlertNotifier.kt index e67e3fd..deb40e4 100644 --- a/app/src/main/java/com/hormonetrack/reminder/AlertNotifier.kt +++ b/app/src/main/java/com/hormonetrack/reminder/AlertNotifier.kt @@ -20,7 +20,6 @@ import com.hormonetrack.R import com.hormonetrack.data.AppDatabase import com.hormonetrack.pk.Alerts import com.hormonetrack.pk.PharmacokineticEngine -import com.hormonetrack.pk.TConfig import com.hormonetrack.util.AppLog import kotlinx.coroutines.flow.first import java.util.concurrent.TimeUnit @@ -33,7 +32,7 @@ import java.util.concurrent.TimeUnit * Fonctionnement : * - `AlertWorker` (périodique, 15 min — minimum WorkManager) évalue le * 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 ; * - **anti-spam** : l'état des alertes déjà notifiées est persisté en * DataStore (`Alerts.encodeState`) → pas de re-notification du même diff --git a/app/src/main/java/com/hormonetrack/reminder/CalendarEvents.kt b/app/src/main/java/com/hormonetrack/reminder/CalendarEvents.kt index 5d89096..065d78a 100644 --- a/app/src/main/java/com/hormonetrack/reminder/CalendarEvents.kt +++ b/app/src/main/java/com/hormonetrack/reminder/CalendarEvents.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.ContentValues import android.provider.CalendarContract import com.hormonetrack.data.model.Treatment +import com.hormonetrack.ui.components.formatDose import java.util.Calendar import java.util.TimeZone @@ -188,6 +189,6 @@ object CalendarEvents { private fun formatInterval(treatment: Treatment): String = treatment.forecastIntervalDays?.let { formatDose(it) } ?: "?" - private fun formatDose(d: Double): String = - if (d == d.toLong().toDouble()) d.toLong().toString() else "%.1f".format(d) + // formatDose est la fonction PUBLIQUE de ui/components/DoseDialog.kt + // (dédupliquée v1.8.2 — la même règle d'affichage des doses partout) } diff --git a/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt b/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt index 57269db..e1d723b 100644 --- a/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt +++ b/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking object ReminderContract { 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 // (grille Posologie si elle existe, sinon quotidien). val pending = goAsync() - kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.IO).launch { + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { try { val db = AppDatabase.getInstance(context) db.treatmentDao().getById(treatmentId)?.let { tr -> @@ -238,10 +239,10 @@ class BootReceiver : BroadcastReceiver() { // TOUS les traitements : les inactifs doivent voir leur alarme // annulée au boot (sinon une alarme périmée continue de sonner) ; // les doses alimentent la grille Posologie (fix #52) - val treatments = kotlinx.coroutines.runBlocking { + val treatments = runBlocking { db.treatmentDao().getAllOnce() } - val doses = kotlinx.coroutines.runBlocking { + val doses = runBlocking { db.doseLogDao().getAllOnce() } AlarmScheduler(context).rescheduleAll(treatments, doses) diff --git a/app/src/main/java/com/hormonetrack/settings/ChangelogHelper.kt b/app/src/main/java/com/hormonetrack/settings/ChangelogHelper.kt index d457de8..02cf470 100644 --- a/app/src/main/java/com/hormonetrack/settings/ChangelogHelper.kt +++ b/app/src/main/java/com/hormonetrack/settings/ChangelogHelper.kt @@ -1,5 +1,9 @@ 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 * (v1.3.0). Pur (aucune dépendance Android) → testable en JVM. @@ -28,7 +32,7 @@ object ChangelogHelper { private fun parse(version: String): List? { 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() } } @@ -43,7 +47,7 @@ object ChangelogHelper { * @return le markdown concaténé des nouveautés ("" si rien de nouveau). */ 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() for (match in pattern.findAll(markdown)) { val version = match.groupValues[1] diff --git a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt index 89ae032..29a922a 100644 --- a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt +++ b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt @@ -17,8 +17,6 @@ import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.LabResult import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.PharmacokineticEngine -import com.hormonetrack.ui.theme.ChartE2 -import com.hormonetrack.ui.theme.ChartT import com.hormonetrack.ui.theme.LabDot import java.text.SimpleDateFormat import java.util.Calendar @@ -373,6 +371,12 @@ fun CurveChart( (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) { if (dose.timestamp !in t0..t1) continue val x = xOf(dose.timestamp) @@ -392,8 +396,6 @@ fun CurveChart( } drawPath(path, markerColor) // 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( 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 * 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 offset = 6.dp.toPx() val path = Path() @@ -574,7 +576,8 @@ private fun DrawScope.drawAxisUnit( 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 drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f) } diff --git a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt index 90eb4bf..8463de2 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt @@ -42,7 +42,6 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.hormonetrack.R -import com.hormonetrack.data.model.Treatment import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.LabTrajectoryModel import com.hormonetrack.pk.PharmacokineticEngine @@ -62,6 +61,12 @@ import com.hormonetrack.ui.theme.TealTertiary import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay 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 // 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. * * Fonctions : - * - plages 24 h / 7 j / 30 j ; - * - **panoramique** : glisser horizontalement déplace la fenêtre dans le passé ; - * - **les deux modèles PK** (Estrannaise / Transfem Science) peuvent être affichés - * simultanément (override de modèle dans le moteur), avec toggle indépendant ; + * - 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é + * (le futur avec la Prévision active) ; + * - **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 * « fréquence » configurée sur chaque traitement ; * - **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) } LaunchedEffect(treatments) { if (!modelTogglesInitialized && treatments.isNotEmpty()) { - val toggles = com.hormonetrack.ui.components.defaultModelToggles( + val toggles = defaultModelToggles( treatments.filter { it.usesProfileModel }.map { it.pkModel }.toSet() ) showEse = toggles.getValue("ESE") @@ -194,7 +201,7 @@ fun ChartScreen() { // Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné // [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt). 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) ── // ⚠️ 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) { - com.hormonetrack.ui.components.forecastExtensionHours( + forecastExtensionHours( rangeHours, forecastHorizonHours, firstSlotGapH ) } else 0L @@ -269,7 +276,7 @@ fun ChartScreen() { val curves by produceState( emptyList>>(), autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast, - showLabTrack, showLabTrackExtend, labResults.size + showLabTrack, showLabTrackExtend, labResults ) { withContext(Dispatchers.Default) { val list = mutableListOf>>() @@ -290,7 +297,7 @@ fun ChartScreen() { // non vides seulement — la légende (plus bas) doit connaître // "LABX" AVANT son else (leçon #63). if (showLabTrack) { - val step = com.hormonetrack.ui.components.stepForRange(rangeHours) + val step = stepForRange(rangeHours) val labCurve = LabTrajectoryModel.computeLabAnchoredCurve( treatments, doseLogs, labResults, startMs, endMs, step, extendBeyondLastLab = showLabTrackExtend @@ -319,39 +326,18 @@ fun ChartScreen() { } else emptyList() val allDoses = doseLogs + forecastDoses // Pas d'échantillonnage adaptatif au zoom (v1.2.9) - val step = com.hormonetrack.ui.components.stepForRange(rangeHours) - if (showEse) { - val autoM = autoByModel["ESE"] + val step = stepForRange(rangeHours) + // v1.8.2 : les 3 blocs copiés (ESE/TFS/WHS) sont factorisés — + // 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( - "ESE" to PharmacokineticEngine.computeCurve( + model to PharmacokineticEngine.computeCurve( effTreatments, allDoses, startMs, endMs, stepMs = step, - tConfig = autoM?.tConfig ?: effTConfig, modelOverride = "ESE", - 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", + tConfig = autoM?.tConfig ?: effTConfig, modelOverride = model, scalePerEster = autoM?.esterScales, tKPerEster = autoM?.tKPerEster ) @@ -550,12 +536,12 @@ fun ChartScreen() { // v1.4.10 (fix #62) : cumul fractionnaire du pan // (l'ancienne troncature par delta tuait le pan // sur la vue 24 h) - val (hoursDragged, newResidual) = com.hormonetrack.ui.components.panDeltaHours( + val (hoursDragged, newResidual) = panDeltaHours( pan.x, chartWidthPx, rangeHours, panResidualHours ) panResidualHours = newResidual if (hoursDragged != 0L) { - panHours = com.hormonetrack.ui.components.clampPanHours( + panHours = clampPanHours( panHours + hoursDragged, gestureFutureHorizon, gestureMaxPan ) } @@ -573,7 +559,7 @@ fun ChartScreen() { val tFocal = endAnchor - ((1.0 - f) * rangeHours * HOUR_MS).toLong() val newEnd = tFocal + ((1.0 - f) * newRange * HOUR_MS).toLong() val newPan = ((nowMs - newEnd) / HOUR_MS).toLong() - panHours = com.hormonetrack.ui.components.clampPanHours( + panHours = clampPanHours( newPan, gestureFutureHorizon, gestureMaxPan ) rangeHours = newRange diff --git a/app/src/main/java/com/hormonetrack/ui/screens/DosesScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/DosesScreen.kt index a09807e..aef92c3 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/DosesScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/DosesScreen.kt @@ -5,10 +5,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -35,13 +33,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.hormonetrack.R import com.hormonetrack.data.model.DoseLog -import com.hormonetrack.data.model.Treatment import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.components.DoseDialog import com.hormonetrack.util.HrtDuration diff --git a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt index d0fa69c..ce14829 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt @@ -72,7 +72,7 @@ import java.time.format.DateTimeFormatter import java.util.Locale import kotlin.math.abs -private val HOUR_MS = 3_600_000L +private const val HOUR_MS = 3_600_000L @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -128,7 +128,10 @@ fun HomeScreen( } } - val curve by produceState>(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>(emptyList(), allTreatments, tConfig, effectiveAuto, doseLogs, tick) { withContext(Dispatchers.Default) { val end = System.currentTimeMillis() value = PharmacokineticEngine.computeCurve( @@ -463,8 +466,7 @@ private fun NextDoseCard(treatments: List, doseLogs: List) { val fires = treatments .mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } } if (fires.isEmpty()) return - val next = fires.minByOrNull { it.second }!!.second - val nextTreatment = fires.minByOrNull { it.second }!!.first + val (nextTreatment, next) = fires.minByOrNull { it.second }!! Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text( diff --git a/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt index ade8e87..2d34da4 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt @@ -1,7 +1,6 @@ package com.hormonetrack.ui.screens import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize diff --git a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt index 6a8d385..3db93b9 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt @@ -144,13 +144,16 @@ fun SettingsScreen(onBack: () -> Unit) { // v1.4.2 : le backup embarque aussi les PARAMÈTRES utilisateur // (langue, auto-calibration, seuils d'alerte) — collect des // 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( language = container.settings.language.first(), autoCalibrate = container.settings.autoCalibrate.first(), - alertE2High = container.settings.alertThresholds.first().e2High, - alertE2Low = container.settings.alertThresholds.first().e2Low, - alertTHigh = container.settings.alertThresholds.first().tHigh, - alertTLow = container.settings.alertThresholds.first().tLow + alertE2High = thresholds.e2High, + alertE2Low = thresholds.e2Low, + alertTHigh = thresholds.tHigh, + alertTLow = thresholds.tLow ) val json = BackupManager.exportJson(repo, tConfig, settings) val ok = BackupManager.writeBackup(context, uri, json) diff --git a/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt index b489196..40a5866 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/TreatmentEditorScreen.kt @@ -52,7 +52,6 @@ import com.hormonetrack.data.model.PKPresets import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.TreatmentType import com.hormonetrack.pk.PharmacokineticEngine -import android.app.Activity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import com.hormonetrack.reminder.AlarmScheduler diff --git a/app/src/main/java/com/hormonetrack/util/AppLog.kt b/app/src/main/java/com/hormonetrack/util/AppLog.kt index 934bddd..edd423f 100644 --- a/app/src/main/java/com/hormonetrack/util/AppLog.kt +++ b/app/src/main/java/com/hormonetrack/util/AppLog.kt @@ -60,9 +60,9 @@ object AppLog { fun log(tag: String, message: String) = synchronized(lock) { val line = formatLine(System.currentTimeMillis(), tag, message) buffer.addLast(line) - val trimmed = trimLines(buffer.toList(), MAX_LINES) - buffer.clear() - buffer.addAll(trimmed) + // Purge O(1) amortie (v1.8.2 : l'ancien trimLines recopiait TOUT le + // buffer à chaque ligne loggée) — même résultat (MAX_LINES dernières) + while (buffer.size > MAX_LINES) buffer.removeFirst() try { file?.appendText(line + "\n") } catch (_: Exception) { diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 804bc35..c3c6f4b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,43 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …). +## [1.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) ### 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) - ### Corrigé - **La calibration tombait à ×2,21 (auto ET manuelle) sur un traitement 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 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 - 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 auto — même défaut de frontière). FIX : **garde de significativité** (`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) - ### Corrigé - **La calibration automatique était fausse pour les modèles autres que le modèle stocké du traitement** (#60, remontée : « la calibration auto sur diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index e18a469..b0cda0a 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -11,23 +11,23 @@ 1. [Contexte & objectifs](#1-contexte--objectifs) 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) 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) -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) -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) -9. [Système de rappels](#9-système-de-rappels) + [9.bis Alertes de seuil](#9bis-alertes-de-seuil-reminderalertnotifierkt--worker-v142) +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 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) 10. [UI & navigation](#10-ui--navigation) 11. [Graphiques (CurveChart)](#11-graphiques-curvechart) 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) 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) 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) 21. [Checklist de test manuel](#21-checklist-de-test-manuel) @@ -63,6 +63,7 @@ Fonctionnalités v1 : | 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.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). | @@ -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.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.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.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. | @@ -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 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ù | |---|---|---| @@ -167,11 +168,11 @@ Notes importantes (v1.2.0) : - **SDK Android** : installé via `brew install --cask android-commandlinetools` → `/opt/homebrew/share/android-commandlinetools` (524 MB) - 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` -- Gradle 8.9 téléchargé par le wrapper ; caches `~/.gradle` ≈ 1,5 GB -- Build validé : `./gradlew assembleDebug testDebugUnitTest` → **BUILD SUCCESSFUL**, - APK debug 18 MB (`app/build/outputs/apk/debug/app-debug.apk`) +- Gradle 9.7.1 téléchargé par le wrapper ; caches `~/.gradle` ≈ 1,5 GB +- Build initial validé (5 sept. 2026) : `./gradlew assembleDebug testDebugUnitTest` → + **BUILD SUCCESSFUL** ; l'APK debug actuel pèse ≈ 20 Mo (`app/build/outputs/apk/debug/app-debug.apk`) ## 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 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 à -×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** = 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) ; @@ -504,7 +505,7 @@ MAIS passant par tous les points ». - **Indépendante de la calibration** : elle EST sa propre calibration continue (ratio brut, `scaleFactor` stocké ignoré ET `scalePerEster` ignoré — l'inverse serait une DOUBLE correction, épinglé par test). - 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 empreinte intermédiaire, testé avec le seuil du test moteur WHS). - **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 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é -- **`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 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 @@ -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, calibration T avec labs en ng/dL. **En cas de nouveau bug remonté par l'utilisatrice : 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 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** @@ -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 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/`) -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é 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). @@ -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éé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. -- **`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 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. 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é, - 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 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 @@ -1602,7 +1610,7 @@ l'optimiseur. 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 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`) : `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 + 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 Si le `.ods` change (re-fits, nouveaux esters) : @@ -1641,7 +1656,7 @@ structure ci-dessus. **Toute restructuration du JSON impose de mettre à jour ```bash 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) 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 le fallback « Voir docs/CHANGELOG.md » ; - 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 - DEUX FOIS pour publier les deux APK (noms distincts) ; +- attache l'APK (remplace l'asset du même nom si présent) — ⚠️ script + 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 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 @@ -1740,9 +1756,9 @@ vérifie pas en tests JVM). 1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`, `versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y). -2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 191 au - total, 168 si `local-test-data/` est absent (les 4 classes de régression - réelles sont skippées via `Assume`, 6/6/6/5 tests) ; **lint vert +2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 223 au + 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/7 tests) ; **lint vert obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet : 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 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** — - 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 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 @@ -1775,7 +1791,7 @@ vérifie pas en tests JVM). `Siphonight/HormoneTrack-web` — historique 100 % propre, séparé de 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`, - `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 (`web/docs/CHANGELOG.md` ≠ ce fichier). Process web complet : `../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 chart (`detectTransformGestures` ne reçoit que down/up sans moves) → le 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- -rel du test humain). Les TAPS, la navigation et les dialogs fonctionnent +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 normalement par adb. **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 prochain reschedule (boot/save) — mineur - 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` explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur 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 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.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 du dropdown des nouvelles doses, rappel annulé, **mais sa simulation reste 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, le .txt est écrit, un message « Logs de diagnostic exportés » s'affiche ; 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 superposable aux 2 autres (montée plus rapide à J+1, t½ plus longues que TFS) ; PEP non couvert ; 6 presets ; dropdown modèle à 3 choix +- [ ] **v1.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, 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 @@ -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 → carte remplacée par l'invite « renseigne une Posologie » ; traitement oral seul → aucune carte -- [ ] **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.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) +- [ ] **v1.8.1** : **stabilisation corrigée + suggestion Accueil** : après + changement de dose/ester/intervalle, la carte ne dit plus « stabilisé + depuis » l'ancienne période (régime = séquence terminale (ester, dose, + écart) constants) ; une carte compacte de recommandation apparaît + aussi sur l'Accueil ; 0 crash --- *Doc mise à jour le 16 sept. 2026 (v1.7.1) — build OK, lint vert, 211/211 tests verts (181 sans diff --git a/docs/GUIDE_INSTALLATION.md b/docs/GUIDE_INSTALLATION.md index f4d1b9e..48f0a8a 100644 --- a/docs/GUIDE_INSTALLATION.md +++ b/docs/GUIDE_INSTALLATION.md @@ -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) - 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) - - **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 prochaine analyse — **au creux estimé, juste avant l'injection suivante** (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 | |----------|----------| -| « 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) | | 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 |