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

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

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

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

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

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

Validé émulateur §16.ter (release, seed réel v1.7.0) : accueil (encart
reco), Analyses (creux 27/09 + notes), Graphiques, 0 crash.
versionCode 37 / versionName 1.8.2. Web non concerné (aucun changement).
This commit is contained in:
Siphonight 2026-09-18 14:21:32 +02:00
parent 2ec9f63f93
commit d2955a6e21
28 changed files with 404 additions and 362 deletions

View File

@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> 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) :

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Treatment>
@Query("SELECT * FROM treatments ORDER BY createdAt DESC")
suspend fun getAllOnce(): List<Treatment>
@ -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)
}

View File

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

View File

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

View File

@ -120,9 +120,9 @@ object LabTiming {
// ── 2) t½ terminale de l'ester (analytique ou lue dans la table) ────
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

View File

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

View File

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

View File

@ -165,17 +165,32 @@ object PharmacokineticEngine {
*/
scalePerEster: Map<String, Double>? = 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<Long, Double>()
val batemanCache = HashMap<Long, BatemanParams>()
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<Long, BatemanParams>()
relevantTreatments.forEach { batemanCache[it.id] = batemanParams(it) }
relevantTreatments.forEach {
// Bateman paresseux (v1.8.2) : inutile pour les traitements à profil
if (!it.usesProfileModel) batemanCache[it.id] = batemanParams(it)
}
// v1.8.2 : cutoffs pré-calculés (déterministes par traitement) et
// doses pré-groupées — finis les scans complets de doseLogs à chaque
// point × traitement (l'ordre de sommation est préservé, cf e2At)
val cutoffByTreatment = relevantTreatments.associate { it.id to cutoffHours(it) }
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
// Doses triées pour suivre l'« ester actif » le long de la grille (pour le k de T)
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<Double>, decimals: Int): Double {
values.sort()
val median = if (values.size % 2 == 1) values[values.size / 2]
else (values[values.size / 2 - 1] + values[values.size / 2]) / 2.0
val factor = Math.pow(10.0, decimals.toDouble())
return (median * factor).toInt() / factor
}
fun labIsSignificant(predicted: Double, maxSoFar: Double): Boolean =
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<DoseLog>,
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))
}
/**

View File

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

View File

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

View File

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

View File

@ -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<Int>? {
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]

View File

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

View File

@ -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<Pair<String, List<LevelPoint>>>(),
autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast,
showLabTrack, showLabTrackExtend, labResults.size
showLabTrack, showLabTrackExtend, labResults
) {
withContext(Dispatchers.Default) {
val list = mutableListOf<Pair<String, List<LevelPoint>>>()
@ -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

View File

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

View File

@ -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<List<LevelPoint>>(emptyList(), effectiveAuto, doseLogs, tick) {
// v1.8.2 : allTreatments et tConfig ajoutés aux clés — éditer un
// scaleFactor ou le modèle T rafraîchit la courbe immédiatement
// (l'ancien trio de clés ratait ces changements jusqu'au tick de 60 s)
val curve by produceState<List<LevelPoint>>(emptyList(), allTreatments, tConfig, effectiveAuto, doseLogs, tick) {
withContext(Dispatchers.Default) {
val end = System.currentTimeMillis()
value = PharmacokineticEngine.computeCurve(
@ -463,8 +466,7 @@ private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) {
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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -122,7 +122,7 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
- **Supprimer** : la corbeille retire la prise entière (E2 + T ensemble)
- 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 |