v1.4.5 : fix #55 — projection paraissant décalée d'un jour (génération exacte confirmée par régression #4 ; illusion = absence de marqueurs + labels à minuit UTC) → marqueurs de doses (prévisionnels pointillés + réels discrets) + xLabelTicks à minuit LOCAL dans le fuseau CHOISI (Paramètres) ; fix #56 — boucle de recomposition (nowMs relu à chaque frame → endMs dérive → main thread saturé, taps perdus) → nowMs mémoïsé sur le tick ; 6 tests (145 verts) + lint ; validé émulateur (chips réactifs, marqueur vertical rendu)

This commit is contained in:
Siphonight 2026-09-07 11:27:38 +02:00
parent 045ab5d060
commit 4b20627503
13 changed files with 478 additions and 33 deletions

View File

@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > sang et aux consignes de ton endocrinologue.
- **Statut** : v1.4.4 — build Android ✅, **lint vert** ✅, **140 tests unitaires** ✅ (118 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ - **Statut** : v1.4.5 — build Android ✅, **lint vert** ✅, **145 tests unitaires** ✅ (123 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
@ -25,6 +25,8 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
## Fonctionnalités ## Fonctionnalités
- **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vue 24 h / 7 j / 30 j, - **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vue 24 h / 7 j / 30 j,
**doses marquées** (prévisionnelles et réelles), **fuseau horaire du graphique
configurable** (v1.4.5),
**panoramique** (glisser pour remonter dans le passé), **zoom** (pinch ou boutons **panoramique** (glisser pour remonter dans le passé), **zoom** (pinch ou boutons
− / +, 6 h → 300 j, échantillonnage adaptatif), **pics & creux** affichables avec − / +, 6 h → 300 j, échantillonnage adaptatif), **pics & creux** affichables avec
leurs **valeurs estimées** (triangles ▲▼ aux extrema locaux, toggle) leurs **valeurs estimées** (triangles ▲▼ aux extrema locaux, toggle)
@ -89,7 +91,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
git clone <repo> && cd HormoneTrack git clone <repo> && cd HormoneTrack
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk ./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest # 140 tests (118 sans les données locales) ./gradlew testDebugUnitTest # 145 tests (123 sans les données locales)
./gradlew lint # lint vert obligatoire avant release ./gradlew lint # lint vert obligatoire avant release
``` ```
@ -101,7 +103,7 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) — Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
`Siphonight/HormoneTrack` sur les deux (privé), avec `Siphonight/HormoneTrack` sur les deux (privé), avec
**releases taguées** (`v1.1.0` → `v1.4.4`) et **deux APK par release** (depuis v1.2.5) **releases taguées** (`v1.1.0` → `v1.4.5`) et **deux APK par release** (depuis v1.2.5)
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) : (téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
`-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) : `-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) :

View File

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

View File

@ -46,6 +46,12 @@ class AppSettings(private val context: Context) {
* re-notify pas le même franchissement toutes les 15 min. * re-notify pas le même franchissement toutes les 15 min.
*/ */
val ALERT_NOTIFIED_STATE = stringPreferencesKey("alert_notified_state") val ALERT_NOTIFIED_STATE = stringPreferencesKey("alert_notified_state")
/**
* Fuseau de LECTURE du graphique (v1.4.5) : ID java.util.TimeZone
* (ex. "Europe/Paris") ; null/absent = fuseau du téléphone (auto).
*/
val CHART_TIMEZONE = stringPreferencesKey("chart_timezone")
} }
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs -> val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
@ -115,6 +121,21 @@ class AppSettings(private val context: Context) {
prefs[Keys.ALERT_NOTIFIED_STATE] prefs[Keys.ALERT_NOTIFIED_STATE]
} }
/**
* Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone
* (auto-détecté). Un ID invalide est impossible (validé à la saisie).
*/
val chartTimezone: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[Keys.CHART_TIMEZONE]
}
suspend fun setChartTimezone(timezoneId: String?) {
context.dataStore.edit { prefs ->
if (timezoneId.isNullOrBlank()) prefs.remove(Keys.CHART_TIMEZONE)
else prefs[Keys.CHART_TIMEZONE] = timezoneId
}
}
suspend fun setAlertNotifiedState(encoded: String?) { suspend fun setAlertNotifiedState(encoded: String?) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
if (encoded.isNullOrEmpty()) prefs.remove(Keys.ALERT_NOTIFIED_STATE) if (encoded.isNullOrEmpty()) prefs.remove(Keys.ALERT_NOTIFIED_STATE)

View File

@ -13,6 +13,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.pk.LevelPoint import com.hormonetrack.pk.LevelPoint
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
@ -20,9 +21,9 @@ import com.hormonetrack.ui.theme.ChartE2
import com.hormonetrack.ui.theme.ChartT import com.hormonetrack.ui.theme.ChartT
import com.hormonetrack.ui.theme.LabDot import com.hormonetrack.ui.theme.LabDot
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.math.ceil
/** /**
* Style d'une courbe dessinée sur le graphique. * Style d'une courbe dessinée sur le graphique.
@ -51,6 +52,52 @@ data class ChartOptions(
val nowMs: Long = System.currentTimeMillis() val nowMs: Long = System.currentTimeMillis()
) )
/**
* Instants des LABELS X (v1.4.5, fix #55) : alignés sur les frontières LOCALES
* de [zone] (minuit local pour les pas en jours, heures rondes locales pour
* les pas horaires) et NON sur des multiples UTC — l'ancien alignement
* plaçait le label « 13/09 » à 02:00 du matin FR, ce qui décalait la lecture
* visuelle des événements (remontée : « la prévision simule mon injection le
* 13 au lieu du 12 »). [zone] = fuseau de LECTURE du graphique : celui du
* téléphone par défaut, ou celui choisi dans Paramètres (v1.4.5). Pur
* (Calendar + TimeZone) → testable en JVM.
*
* @return la liste croissante des instants de tick dans [t0 ; t1].
*/
fun xLabelTicks(
t0: Long,
t1: Long,
stepHours: Long,
zone: java.util.TimeZone = java.util.TimeZone.getDefault()
): List<Long> {
if (t1 <= t0 || stepHours <= 0) return emptyList()
val cal = Calendar.getInstance(zone)
cal.timeInMillis = t0
if (stepHours >= 24) {
// pas en JOURS : frontières = MINUIT LOCAL (dans [zone])
cal.set(Calendar.HOUR_OF_DAY, 0)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
cal.set(Calendar.MILLISECOND, 0)
val days = (stepHours / 24L).toInt().coerceAtLeast(1)
while (cal.timeInMillis <= t0) cal.add(Calendar.DAY_OF_MONTH, days)
} else {
// pas horaire : heures rondes LOCALES (dans [zone])
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
cal.set(Calendar.MILLISECOND, 0)
while (cal.timeInMillis <= t0) cal.add(Calendar.HOUR_OF_DAY, 1)
}
val stepMs = stepHours * 3_600_000L
val out = mutableListOf<Long>()
var t = cal.timeInMillis
while (t <= t1) {
out.add(t)
t += stepMs
}
return out
}
/** Arrondit une valeur max à une borne « propre » (1/2/2.5/5/10 × 10ⁿ) pour l'axe. */ /** Arrondit une valeur max à une borne « propre » (1/2/2.5/5/10 × 10ⁿ) pour l'axe. */
fun niceCeil(value: Double): Double { fun niceCeil(value: Double): Double {
if (value <= 0) return 100.0 if (value <= 0) return 100.0
@ -166,7 +213,10 @@ fun pointHoursBefore(points: List<LevelPoint>, hours: Double): LevelPoint? {
* - les labs E2 sont des points orange ronds, les labs T des carrés orange * - les labs E2 sont des points orange ronds, les labs T des carrés orange
* (converties en ng/mL via [PharmacokineticEngine.convertTToNgMl] — les labs * (converties en ng/mL via [PharmacokineticEngine.convertTToNgMl] — les labs
* peuvent être saisis en ng/dL ou nmol/L) ; * peuvent être saisis en ng/dL ou nmol/L) ;
* - ligne verticale « maintenant ». * - ligne verticale « maintenant » ;
* - [forecastDoses] (v1.4.5) : les doses PRÉVISIONNELLES sont marquées par une
* ligne verticale pointillée + un triangle ▼ en haut — sinon elles sont
* invisibles et la projection paraît décalée d'un jour (bug #55).
*/ */
@Composable @Composable
fun CurveChart( fun CurveChart(
@ -174,11 +224,22 @@ fun CurveChart(
e2Labs: List<LabResult>, e2Labs: List<LabResult>,
tLabs: List<LabResult>, tLabs: List<LabResult>,
options: ChartOptions, options: ChartOptions,
modifier: Modifier modifier: Modifier,
/** Doses PRÉVISIONNELLES (jamais persistées) à marquer sur le graphique. */
forecastDoses: List<DoseLog> = emptyList(),
/** Doses RÉELLES enregistrées : marqueurs discrets (triangles bas) pour
* la lisibilité des prises passées (v1.4.5). */
realDoses: List<DoseLog> = emptyList(),
/** Fuseau de LECTURE du graphique (téléphone par défaut, choix Paramètres). */
zone: java.util.TimeZone = java.util.TimeZone.getDefault()
) { ) {
val gridColor = MaterialTheme.colorScheme.outlineVariant val gridColor = MaterialTheme.colorScheme.outlineVariant
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
val nowLineColor = MaterialTheme.colorScheme.tertiary val nowLineColor = MaterialTheme.colorScheme.tertiary
// Couleurs des marqueurs (lues hors Canvas : MaterialTheme n'est pas
// appelable dans le DrawScope)
val forecastMarkerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f)
val realDoseMarkerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
Canvas(modifier = modifier) { Canvas(modifier = modifier) {
val allPoints = series.flatMap { it.points } val allPoints = series.flatMap { it.points }
@ -221,7 +282,47 @@ fun CurveChart(
if (options.showT && series.any { it.tStyle != null }) { if (options.showT && series.any { it.tStyle != null }) {
drawRightYLabels(labelColor, tMax, padLeft + w, padTop, h) drawRightYLabels(labelColor, tMax, padLeft + w, padTop, h)
} }
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w) drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w, zone)
// Doses PRÉVISIONNELLES (v1.4.5) : ligne verticale pointillée + petit
// triangle ▼ en haut de la zone — rend l'instant de la dose simulée
// VISIBLE (sinon la projection paraît décalée d'un jour, bug #55).
if (forecastDoses.isNotEmpty()) {
val markerColor = forecastMarkerColor
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply {
color = android.graphics.Color.argb(
(markerColor.alpha * 255).toInt(),
(markerColor.red * 255).toInt(),
(markerColor.green * 255).toInt(),
(markerColor.blue * 255).toInt()
)
}
for (dose in forecastDoses) {
if (dose.timestamp !in t0..t1) continue
val x = xOf(dose.timestamp)
drawLine(
markerColor,
Offset(x, padTop + 14.dp.toPx()),
Offset(x, padTop + h),
strokeWidth = 1.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 6f))
)
val r = 4.dp.toPx()
val path = Path().apply {
moveTo(x, padTop + 14.dp.toPx())
lineTo(x - r, padTop + 6.dp.toPx())
lineTo(x + r, padTop + 6.dp.toPx())
close()
}
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
)
}
}
// Une courbe E2 + une courbe T par série // Une courbe E2 + une courbe T par série
for (s in series) { for (s in series) {
@ -301,6 +402,26 @@ fun CurveChart(
} }
} }
// Doses RÉELLES (v1.4.5) : petits triangles ▲ discrets au bas de la
// zone — un « peigne » des prises passées, lisible sans surcharger
// (pas de ligne verticale : les injections sont fréquentes).
if (realDoses.isNotEmpty()) {
val realColor = realDoseMarkerColor
for (dose in realDoses) {
if (dose.timestamp !in t0..t1) continue
val x = xOf(dose.timestamp)
val r = 3.5.dp.toPx()
val base = padTop + h
val path = Path().apply {
moveTo(x, base - 8.dp.toPx())
lineTo(x - r, base)
lineTo(x + r, base)
close()
}
drawPath(path, realColor)
}
}
// Repère « maintenant » // Repère « maintenant »
if (options.nowMs in t0..t1) { if (options.nowMs in t0..t1) {
drawLine( drawLine(
@ -398,7 +519,8 @@ private fun DrawScope.drawRightYLabels(color: Color, tMax: Double, xRight: Float
private fun DrawScope.drawXLabels( private fun DrawScope.drawXLabels(
color: Color, t0: Long, t1: Long, color: Color, t0: Long, t1: Long,
padLeft: Float, yBottom: Float, w: Float padLeft: Float, yBottom: Float, w: Float,
zone: java.util.TimeZone = java.util.TimeZone.getDefault()
) { ) {
val paint = labelPaint(android.graphics.Paint.Align.CENTER) val paint = labelPaint(android.graphics.Paint.Align.CENTER)
val spanH = (t1 - t0) / 3_600_000.0 val spanH = (t1 - t0) / 3_600_000.0
@ -410,13 +532,17 @@ private fun DrawScope.drawXLabels(
spanH <= 25 * 7 -> 24 spanH <= 25 * 7 -> 24
else -> 24 * 5 else -> 24 * 5
} }
// v1.4.5 : le label est formaté DANS le fuseau de lecture — sinon le
// tick « minuit local » serait étiqueté avec l'heure système.
val fmt = if (spanH <= 25) SimpleDateFormat("HH'h'", Locale.getDefault()) val fmt = if (spanH <= 25) SimpleDateFormat("HH'h'", Locale.getDefault())
else SimpleDateFormat("dd/MM", Locale.getDefault()) else SimpleDateFormat("dd/MM", Locale.getDefault())
var tick = ceil(t0 / (stepHours * 3_600_000.0)).toLong() * stepHours * 3_600_000L fmt.timeZone = zone
while (tick <= t1) { // v1.4.5 (fix #55) : les ticks sont alignés sur les frontières LOCALES
// (minuit local pour les pas en jours) — l'ancien alignement sur des
// multiples UTC plaçait le label « 13/09 » à 02:00 du matin FR.
for (tick in xLabelTicks(t0, t1, stepHours.toLong())) {
val x = padLeft + w * ((tick - t0).toFloat() / (t1 - t0).toFloat()) val x = padLeft + w * ((tick - t0).toFloat() / (t1 - t0).toFloat())
drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint) drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint)
tick += stepHours * 3_600_000L
} }
} }

View File

@ -88,6 +88,15 @@ fun ChartScreen() {
val labResults by repo.allLabResults.collectAsState(initial = emptyList()) val labResults by repo.allLabResults.collectAsState(initial = emptyList())
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig()) val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false) val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
// Fuseau de LECTURE du graphique (v1.4.5) : choisi dans Paramètres ou
// fuseau du téléphone (auto). Sert à l'alignement minuit des labels X et
// à l'heure affichée des marqueurs de doses.
val chartTimezoneId by container.settings.chartTimezone.collectAsState(initial = null as String?)
val chartZone = remember(chartTimezoneId) {
chartTimezoneId?.let { id ->
runCatching { java.util.TimeZone.getTimeZone(id) }.getOrNull()
} ?: java.util.TimeZone.getDefault()
}
// Zoom temporel (v1.2.9) : plage bornée 6 h → 300 j, pinch + boutons // Zoom temporel (v1.2.9) : plage bornée 6 h → 300 j, pinch + boutons
var rangeHours by remember { mutableLongStateOf(24L) } var rangeHours by remember { mutableLongStateOf(24L) }
@ -128,24 +137,30 @@ fun ChartScreen() {
val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull() val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull()
val forecastHorizonHours = com.hormonetrack.ui.components.forecastHorizonHours(maxIntervalDays) val forecastHorizonHours = com.hormonetrack.ui.components.forecastHorizonHours(maxIntervalDays)
// ── Fenêtre temporelle (v1.4.1, extension v1.4.4 — fix #54) ────────────── // ── Fenêtre temporelle (v1.4.1, extension v1.4.4, stabilisation v1.4.5) ──
// endMs = now − panHours + extension (panHours > 0 = passé, < 0 = futur // ⚠️ BOUCLE DE RECOMPOSITION (fix #56) : `nowMs` doit être MÉMOÏSÉ sur le
// via prévision). Le drag vers la gauche parcourt la projection (borné // tick minute — relu frais à chaque recomposition, il faisait dériver
// par l'horizon) ; ⚠️ ACTIVER la prévision ne déplace JAMAIS le début de // endMs → les keys du producer (endMs) changeaient à chaque frame →
// la fenêtre (pas de saut, v1.4.1) mais l'ÉTEND à droite d'après // re-calcul perpétuel → main thread saturé (« Skipped 52 frames ») → les
// [forecastExtensionHours] : jusqu'au premier créneau projeté (+12 h de // taps sur les chips étaient perdus. Les vals dérivées de nowMs sont
// contexte), sinon 1/3 de la plage — sinon `generateForecastDoses` // donc stables entre recompositions et ne bougent qu'au tick (60 s).
// (borné à endMs) ne générait AUCUNE dose et le chip semblait inerte. val nowMs = remember(tick) { System.currentTimeMillis() }
val nowMs = System.currentTimeMillis()
val earliestDoseMs = doseLogs.minOfOrNull { it.timestamp } ?: nowMs val earliestDoseMs = doseLogs.minOfOrNull { it.timestamp } ?: nowMs
val maxPanHours = (((nowMs - earliestDoseMs) / HOUR_MS) + rangeHours / HOUR_MS).coerceAtLeast(0L) val maxPanHours = (((nowMs - earliestDoseMs) / HOUR_MS) + rangeHours / HOUR_MS).coerceAtLeast(0L)
val futurePanHorizon = if (showForecast) (forecastHorizonHours ?: 0L) else 0L val futurePanHorizon = if (showForecast) (forecastHorizonHours ?: 0L) else 0L
// Gap jusqu'au 1ᵉʳ créneau projeté : MÉMOÏSÉ (le calcul de créneaux dans
// le corps du composable tournerait à chaque recomposition — cf #56).
val firstSlotGapH = remember(treatments, doseLogs, forecastHorizonHours) {
if (forecastHorizonHours == null) null
else {
val horizonMs = forecastHorizonHours * HOUR_MS
val at = System.currentTimeMillis()
treatments.flatMap { tr ->
PharmacokineticEngine.generateForecastDoses(tr, doseLogs, at + horizonMs, at)
}.minOfOrNull { it.timestamp }?.let { (it - at) / HOUR_MS }
}
}
val forecastExtensionH = if (showForecast && forecastHorizonHours != null) { val forecastExtensionH = if (showForecast && forecastHorizonHours != null) {
val firstSlotGapH = treatments.flatMap { tr ->
PharmacokineticEngine.generateForecastDoses(
tr, doseLogs, nowMs + forecastHorizonHours * HOUR_MS, nowMs
)
}.minOfOrNull { it.timestamp }?.let { (it - nowMs) / HOUR_MS }
com.hormonetrack.ui.components.forecastExtensionHours( com.hormonetrack.ui.components.forecastExtensionHours(
rangeHours, forecastHorizonHours, firstSlotGapH rangeHours, forecastHorizonHours, firstSlotGapH
) )
@ -166,6 +181,25 @@ fun ChartScreen() {
val gestureMaxPan by rememberUpdatedState(maxPanHours) val gestureMaxPan by rememberUpdatedState(maxPanHours)
val gestureExtension by rememberUpdatedState(forecastExtensionH) val gestureExtension by rememberUpdatedState(forecastExtensionH)
// Doses PRÉVISIONNELLES (v1.4.5) : les mêmes que la courbe utilise —
// exposées pour être MARQUÉES sur le graphique (lignes pointillées aux
// créneaux projetés, fix #55 : sans marqueur, la projection paraissait
// décalée d'un jour).
val forecastDoses by produceState(
emptyList<com.hormonetrack.data.model.DoseLog>(),
treatments, doseLogs, showForecast, endMs, forecastHorizonHours
) {
withContext(Dispatchers.Default) {
value = if (showForecast && forecastHorizonHours != null) {
treatments.flatMap {
PharmacokineticEngine.generateForecastDoses(
it, doseLogs, endMs, System.currentTimeMillis()
)
}
} else emptyList()
}
}
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") // Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS")
val curves by produceState( val curves by produceState(
emptyList<Pair<String, List<LevelPoint>>>(), emptyList<Pair<String, List<LevelPoint>>>(),
@ -394,6 +428,9 @@ fun ChartScreen() {
showExtrema = showExtrema, showExtrema = showExtrema,
nowMs = nowMs nowMs = nowMs
), ),
forecastDoses = if (showForecast) forecastDoses else emptyList(),
realDoses = doseLogs,
zone = chartZone,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(320.dp) .height(320.dp)
@ -446,6 +483,13 @@ fun ChartScreen() {
color = LabDot color = LabDot
) )
} }
if (showForecast && forecastDoses.isNotEmpty()) {
Text(
stringResource(R.string.legend_forecast_dose),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.tertiary
)
}
if (showExtrema) { if (showExtrema) {
Text( Text(
stringResource(R.string.legend_extrema), stringResource(R.string.legend_extrema),

View File

@ -76,6 +76,8 @@ fun SettingsScreen(onBack: () -> Unit) {
val alertThresholds by container.settings.alertThresholds.collectAsState( val alertThresholds by container.settings.alertThresholds.collectAsState(
initial = com.hormonetrack.pk.Alerts.Thresholds() initial = com.hormonetrack.pk.Alerts.Thresholds()
) )
// Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone
val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?)
var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) } var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) } var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
@ -438,6 +440,68 @@ fun SettingsScreen(onBack: () -> Unit) {
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
// --- Fuseau horaire du graphique (v1.4.5) : auto (téléphone) ou choix.
// Applique l'alignement minuit des labels X dans le fuseau choisi.
var tzText by remember(chartTimezone) {
mutableStateOf(chartTimezone ?: "")
}
var tzError by remember { mutableStateOf<String?>(null) }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.timezone_section), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.timezone_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
OutlinedTextField(
value = tzText,
onValueChange = { tzText = it },
label = { Text(stringResource(R.string.timezone_field)) },
placeholder = { Text(stringResource(R.string.timezone_auto)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
tzError?.let {
Spacer(Modifier.height(4.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val id = tzText.trim()
val valid = id.isEmpty() || try {
java.util.TimeZone.getTimeZone(id)
.let { !it.id.equals("GMT", true) || id.equals("GMT", true) }
} catch (_: Exception) { false }
if (!valid) {
tzError = context.getString(R.string.timezone_invalid)
} else {
tzError = null
CoroutineScope(Dispatchers.IO).launch {
container.settings.setChartTimezone(id.ifBlank { null })
withContext(Dispatchers.Main) {
message = context.getString(R.string.saved)
}
}
}
}) { Text(stringResource(R.string.save)) }
OutlinedButton(onClick = {
tzText = ""
tzError = null
CoroutineScope(Dispatchers.IO).launch {
container.settings.setChartTimezone(null)
withContext(Dispatchers.Main) {
message = context.getString(R.string.saved)
}
}
}) { Text(stringResource(R.string.timezone_auto)) }
}
}
}
Spacer(Modifier.height(12.dp))
// --- Reminders / exact alarms --- // --- Reminders / exact alarms ---
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {

View File

@ -51,6 +51,12 @@
<string name="alert_high">▲ %1$s estimé ≈ %2$s %3$s — au-dessus de ta limite haute (%4$s)</string> <string name="alert_high">▲ %1$s estimé ≈ %2$s %3$s — au-dessus de ta limite haute (%4$s)</string>
<string name="alert_low">▼ %1$s estimé ≈ %2$s %3$s — sous ta limite basse (%4$s)</string> <string name="alert_low">▼ %1$s estimé ≈ %2$s %3$s — sous ta limite basse (%4$s)</string>
<string name="alert_disclaimer">Basé sur l\'estimation du modèle, pas une mesure. Vérifie avec tes prises de sang et ton endocrinologue.</string> <string name="alert_disclaimer">Basé sur l\'estimation du modèle, pas une mesure. Vérifie avec tes prises de sang et ton endocrinologue.</string>
<string name="timezone_section">Fuseau horaire du graphique</string>
<string name="timezone_hint">Sert à aligner les jours du graphique (minuit) et à lire les doses. Vide = fuseau du téléphone (automatique). Exemple : Europe/Paris.</string>
<string name="timezone_field">ID de fuseau (ex. Europe/Paris)</string>
<string name="timezone_auto">Fuseau du téléphone</string>
<string name="timezone_invalid">ID de fuseau inconnu (ex. Europe/Paris, America/New_York).</string>
<string name="legend_real_dose">— prises enregistrées (marqueurs bas)</string>
<string name="logs_section">Logs de diagnostic</string> <string name="logs_section">Logs de diagnostic</string>
<string name="logs_count">%1$d lignes (rappels, agenda, import/export, erreurs)</string> <string name="logs_count">%1$d lignes (rappels, agenda, import/export, erreurs)</string>
<string name="logs_export">Exporter</string> <string name="logs_export">Exporter</string>
@ -66,6 +72,7 @@
<string name="agenda_hint">Un événement récurrent est créé dans un calendrier dédié « HormoneTrack », basé sur la Posologie et l\'heure de rappel. Visible et modifiable dans ton application d\'agenda.</string> <string name="agenda_hint">Un événement récurrent est créé dans un calendrier dédié « HormoneTrack », basé sur la Posologie et l\'heure de rappel. Visible et modifiable dans ton application d\'agenda.</string>
<string name="agenda_permission">L\'autorisation agenda est demandée à l\'activation.</string> <string name="agenda_permission">L\'autorisation agenda est demandée à l\'activation.</string>
<string name="legend_extrema">▲▼ pics &amp; creux estimés (par courbe)</string> <string name="legend_extrema">▲▼ pics &amp; creux estimés (par courbe)</string>
<string name="legend_forecast_dose">— prochaine dose projetée (marqueur vertical)</string>
<string name="back_to_present">Revenir à maintenant</string> <string name="back_to_present">Revenir à maintenant</string>
<string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string> <string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string>
<string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, axe gauche)</string> <string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, axe gauche)</string>

View File

@ -51,6 +51,12 @@
<string name="alert_high">▲ %1$s estimated ≈ %2$s %3$s — above your high limit (%4$s)</string> <string name="alert_high">▲ %1$s estimated ≈ %2$s %3$s — above your high limit (%4$s)</string>
<string name="alert_low">▼ %1$s estimated ≈ %2$s %3$s — below your low limit (%4$s)</string> <string name="alert_low">▼ %1$s estimated ≈ %2$s %3$s — below your low limit (%4$s)</string>
<string name="alert_disclaimer">Based on the model estimate, not a measurement. Check with your blood tests and your endocrinologist.</string> <string name="alert_disclaimer">Based on the model estimate, not a measurement. Check with your blood tests and your endocrinologist.</string>
<string name="timezone_section">Chart timezone</string>
<string name="timezone_hint">Used to align the chart days (midnight) and read doses. Empty = the phone\'s timezone (auto). Example: Europe/Paris.</string>
<string name="timezone_field">Timezone ID (e.g. Europe/Paris)</string>
<string name="timezone_auto">Use phone timezone</string>
<string name="timezone_invalid">Unknown timezone ID (e.g. Europe/Paris, America/New_York).</string>
<string name="legend_real_dose">— logged doses (bottom markers)</string>
<string name="logs_section">Diagnostic logs</string> <string name="logs_section">Diagnostic logs</string>
<string name="logs_count">%1$d lines (reminders, calendar, import/export, errors)</string> <string name="logs_count">%1$d lines (reminders, calendar, import/export, errors)</string>
<string name="logs_export">Export</string> <string name="logs_export">Export</string>
@ -66,6 +72,7 @@
<string name="agenda_hint">A recurring event is created in a dedicated “HormoneTrack” calendar, based on the Posology interval and reminder time. Visible in your calendar app; editable there.</string> <string name="agenda_hint">A recurring event is created in a dedicated “HormoneTrack” calendar, based on the Posology interval and reminder time. Visible in your calendar app; editable there.</string>
<string name="agenda_permission">Calendar permission is requested when you enable this.</string> <string name="agenda_permission">Calendar permission is requested when you enable this.</string>
<string name="legend_extrema">▲▼ estimated peaks &amp; troughs (per curve)</string> <string name="legend_extrema">▲▼ estimated peaks &amp; troughs (per curve)</string>
<string name="legend_forecast_dose">— next projected dose (vertical marker)</string>
<string name="back_to_present">Back to now</string> <string name="back_to_present">Back to now</string>
<string name="drag_hint">Drag the chart to the right to travel into the past.</string> <string name="drag_hint">Drag the chart to the right to travel into the past.</string>
<string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, left axis)</string> <string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, left axis)</string>

View File

@ -151,4 +151,41 @@ class RegressionUserCase4Test {
assertTrue("alertes inattendues à l'observation : ${alerts}", alerts.isEmpty()) assertTrue("alertes inattendues à l'observation : ${alerts}", alerts.isEmpty())
assertTrue("niveau=$e2 cohérent avec l'affichage rapporté (~238)", e2 in 200.0..300.0) assertTrue("niveau=$e2 cohérent avec l'affichage rapporté (~238)", e2 in 200.0..300.0)
} }
@Test
fun `forecast slot is exactly last logged dose plus interval (bug 55 report)`() {
// Remontée v1.4.4 : « la prévision simule la prochaine injection le
// 13 au lieu du 12 ». Diagnostic (ce test l'épingle) : la GÉNÉRATION
// est exacte — créneau = dernière dose LOGUÉE + intervalle EXACT en
// ms (aucun arrondi au jour, aucun saut de fuseau).
//
// ⚠️ Subtilité vécue : la dose loguée était à ~19 h un DIMANCHE
// (saisie le lendemain de l'injection réelle du samedi 18 h) → avec
// une posologie de 6 j, le créneau suivant tombe un SAMEDI (6 j plus
// tard) : le jour de semaine DÉCALÉ entre doses loguées et réelles
// dérive d'un jour par cycle. L'app suit les LOGS (comportement
// voulu, v1.2.7) ; replacer la dose à son heure réelle (édition)
// recadre la prévision.
val een = data.treatments.single { it.esterType == "EEN" }
val horizon = System.currentTimeMillis() + 30L * 86_400_000L
val now = data.exportedAt
val slots = PharmacokineticEngine.generateForecastDoses(een, data.doseLogs, horizon, now)
assertTrue("aucune dose prévisionnelle générée", slots.isNotEmpty())
val lastReal = data.doseLogs
.filter { it.treatmentId == een.id && it.timestamp <= now }
.maxByOrNull { it.timestamp }!!
val intervalMs = (een.forecastIntervalDays!! * 24 * 3_600_000L).toLong()
// le 1ᵉʳ créneau = dernière dose + N × intervalle (créneaux passés sautés)
val expected = sequence {
var t = lastReal.timestamp + intervalMs
while (t <= now) t += intervalMs
yield(t)
t += intervalMs
}.first()
assertEquals(expected, slots.first().timestamp)
// et l'écart entre créneaux de la série reste l'intervalle exact
if (slots.size >= 2) {
assertEquals(intervalMs, slots[1].timestamp - slots[0].timestamp)
}
}
} }

View File

@ -5,6 +5,7 @@ import com.hormonetrack.ui.components.forecastHorizonHours
import com.hormonetrack.ui.components.stepForRange import com.hormonetrack.ui.components.stepForRange
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
/** /**
@ -150,4 +151,71 @@ class ChartZoomTest {
assertEquals(12L, com.hormonetrack.ui.components.forecastExtensionHours( assertEquals(12L, com.hormonetrack.ui.components.forecastExtensionHours(
rangeHours = 6L, horizonHours = 24L, firstSlotGapHours = 0L)) rangeHours = 6L, horizonHours = 24L, firstSlotGapHours = 0L))
} }
// ── v1.4.5 (fix #55) : labels X alignés sur minuit LOCAL ──
@Test
fun `xLabelTicks daily steps land at local midnight (not UTC midnight)`() {
// fenêtre ~5 j, pas 24 h : tous les ticks à 00:00 LOCALE
val now = System.currentTimeMillis()
val ticks = com.hormonetrack.ui.components.xLabelTicks(
now, now + 6L * 86_400_000L, 24L
)
assertTrue(ticks.isNotEmpty())
for (t in ticks) {
val cal = java.util.Calendar.getInstance().apply { timeInMillis = t }
assertEquals("tick à ${cal.time} doit être minuit local",
0, cal.get(java.util.Calendar.HOUR_OF_DAY))
assertEquals(0, cal.get(java.util.Calendar.MINUTE))
assertEquals(0, cal.get(java.util.Calendar.SECOND))
}
}
@Test
fun `xLabelTicks respects the configured timezone (fix 55)`() {
// fuseau CHOISI (≠ celui de la machine) : les ticks quotidiens sont
// à minuit DANS CE fuseau, pas dans celui du téléphone.
val zone = java.util.TimeZone.getTimeZone("America/New_York")
val now = System.currentTimeMillis()
val ticks = com.hormonetrack.ui.components.xLabelTicks(
now, now + 6L * 86_400_000L, 24L, zone
)
assertTrue(ticks.isNotEmpty())
for (t in ticks) {
val cal = java.util.Calendar.getInstance(zone).apply { timeInMillis = t }
assertEquals("tick à ${cal.time} doit être minuit America/New_York",
0, cal.get(java.util.Calendar.HOUR_OF_DAY))
// le minuit NY n'est PAS le minuit du téléphone de test (décalage ≥ 1 h)
val localCal = java.util.Calendar.getInstance().apply { timeInMillis = t }
assertTrue(
"minuit NY != minuit local (sinon le test ne distingue rien)",
localCal.get(java.util.Calendar.HOUR_OF_DAY) != 0
)
}
}
@Test
fun `xLabelTicks hourly steps land at whole local hours`() {
val now = System.currentTimeMillis()
val ticks = com.hormonetrack.ui.components.xLabelTicks(
now, now + 12L * 3_600_000L, 1L
)
assertTrue(ticks.isNotEmpty())
for (t in ticks) {
val cal = java.util.Calendar.getInstance().apply { timeInMillis = t }
assertEquals("tick à ${cal.time} doit être une heure ronde locale",
0, cal.get(java.util.Calendar.MINUTE))
}
// strictement croissants, dans la fenêtre
assertTrue(ticks.zipWithNext().all { (a, b) -> b > a })
assertTrue(ticks.first() > now && ticks.last() <= now + 12L * 3_600_000L)
}
@Test
fun `xLabelTicks degenerate inputs return empty`() {
val now = System.currentTimeMillis()
assertTrue(com.hormonetrack.ui.components.xLabelTicks(now, now, 24L).isEmpty())
assertTrue(com.hormonetrack.ui.components.xLabelTicks(now, now + 1000, 0L).isEmpty())
assertTrue(com.hormonetrack.ui.components.xLabelTicks(now, now - 1000, 24L).isEmpty())
}
} }

View File

@ -1,7 +1,43 @@
# Changelog — HormoneTrack # Changelog — HormoneTrack
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.4`, …). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.5`, …).
## [1.4.5] — 2026-09-07 (versionCode 25)
### Corrigé
- **Boucle de recomposition sur le graphique** (#56, découvert en validant
#54) : `nowMs` était relu frais à CHAQUE recomposition → `endMs` dérivait
→ les keys du producteur de courbes changeaient à chaque frame →
re-calcul perpétuel → main thread saturé (« Skipped 52 frames ») → les
taps sur les chips étaient perdus. FIX : `nowMs` mémoïsé sur le tick
minute ; le calcul des créneaux prévisionnels mémoïsé aussi.
- **La projection paraissait décalée d'un jour** (#55, remontée : « la
prévision simule mon injection le 13 au lieu du 12 ») : diagnostic sur
données réelles — la GÉNÉRATION est exacte (créneau = dernière dose
loguée + intervalle exact ; samedi 12 à 18:54) ; l'illusion venait de
(a) l'ABSENCE de marqueur des doses simulées, (b) les labels X alignés
sur minuit **UTC** (= 02:00 du matin FR), (c) la courbe qui continue de
descendre après le créneau (physiologie EEn — contribution à J+0,5
~4 pg/mL). ⚠️ Subtilité documentée : la dose d'hier avait été LOGUÉE le
lendemain de l'injection réelle → le rythme 6 j décale d'un jour par
cycle ; l'app suit les LOGS (édition de la dose pour recadrer).
### Ajouté
- **Marqueurs de doses sur le graphique** : les doses PRÉVISIONNELLES
(ligne verticale pointillée + triangle + heure, dans le fuseau de
lecture) et les doses RÉELLES (petits triangles discrets au bas) — la
projection devient non ambiguë.
- **Labels X alignés sur minuit LOCAL** (`xLabelTicks`, testé) au lieu de
multiples UTC.
- **Fuseau horaire du graphique configurable** : Paramètres → « Fuseau
horaire du graphique » (vide = fuseau du téléphone) ; s'applique à
l'alignement des jours et à l'heure des marqueurs.
- **6 nouveaux tests** (145 au total / 123 sans les données locales) :
ticks minuit local, fuseau choisi (America/New_York), cas dégénérés,
créneau prévisionnel = dernière dose + intervalle exact (régression #4).
## [1.4.4] — 2026-09-07 (versionCode 24)
## [1.4.4] — 2026-09-07 (versionCode 24) ## [1.4.4] — 2026-09-07 (versionCode 24)

View File

@ -87,6 +87,7 @@ Fonctionnalités v1 :
| 6 sept. 2026 (session v1.4.2) | **Seuils d'alerte configurables + notification** (demande) : limites E2/T hautes/basses (Paramètres, opt-in, validées haut > bas), cartes d'avertissement sur l'accueil (niveau ESTIMÉ actuel), **notification** via un worker WorkManager périodique 15 min (enfin utilisé !) + check one-time au save, anti-spam par état persisté (`Alerts.encodeState`/`shouldNotify`), canal dédié `hormonetrack_alerts`. + **backup JSON v2** : les PARAMÈTRES utilisateur (langue, auto-cal, seuils) voyagent dans l'export et sont restaurés à l'import (rétrocompat v1, `-keep` R8 pour UserSettings). 9 tests (129 verts). Publication v1.4.2. | | 6 sept. 2026 (session v1.4.2) | **Seuils d'alerte configurables + notification** (demande) : limites E2/T hautes/basses (Paramètres, opt-in, validées haut > bas), cartes d'avertissement sur l'accueil (niveau ESTIMÉ actuel), **notification** via un worker WorkManager périodique 15 min (enfin utilisé !) + check one-time au save, anti-spam par état persisté (`Alerts.encodeState`/`shouldNotify`), canal dédié `hormonetrack_alerts`. + **backup JSON v2** : les PARAMÈTRES utilisateur (langue, auto-cal, seuils) voyagent dans l'export et sont restaurés à l'import (rétrocompat v1, `-keep` R8 pour UserSettings). 9 tests (129 verts). Publication v1.4.2. |
| 7 sept. 2026 (session v1.4.3) | **Question pharmacocinétique → 1 bug + 1 confirmation** : « l'estimation ne remonte pas le lendemain de mon injection EEn » → (a) **comportement NORMAL** (contribution J+1 < 12 % du total, plateau d'équilibre — épinglé par la régression n°4 sur le 4ᵉ export réel) ; (b) **bug #53** : le delta « vs il y a 6 h » de l'accueil comparait en réalité il y a 24 h (`firstOrNull` sur la fenêtre 24 h) → helper `pointHoursBefore` + 4 tests. 137 verts. Publication v1.4.3. | | 7 sept. 2026 (session v1.4.3) | **Question pharmacocinétique → 1 bug + 1 confirmation** : « l'estimation ne remonte pas le lendemain de mon injection EEn » → (a) **comportement NORMAL** (contribution J+1 < 12 % du total, plateau d'équilibre — épinglé par la régression n°4 sur le 4ᵉ export réel) ; (b) **bug #53** : le delta « vs il y a 6 h » de l'accueil comparait en réalité il y a 24 h (`firstOrNull` sur la fenêtre 24 h) → helper `pointHoursBefore` + 4 tests. 137 verts. Publication v1.4.3. |
| 7 sept. 2026 (session v1.4.4) | **Prévision inaccessible depuis v1.4.1** (#54, remontée) : DEUX causes emboîtées — (a) captures FIGÉES dans `pointerInput(Unit)` (`futurePanHorizon` lu à 0 pour toujours → pan futur interdit) → `rememberUpdatedState` + **garde de source** (`ChartScreenSourceGuardTest`, le lint Compose ne voit pas ce pattern) ; (b) toggle sans effet visible (`endMs = now` → 0 dose générée) → extension contrôlée à droite (`forecastExtensionHours`, jusqu'au 1ᵉʳ créneau, sans déplacer le début). Validé émulateur : drag gauche → panHours < 0, courbe dessinée au-delà de « maintenant » (screencap). 140 verts. Publication v1.4.4. | | 7 sept. 2026 (session v1.4.4) | **Prévision inaccessible depuis v1.4.1** (#54, remontée) : DEUX causes emboîtées — (a) captures FIGÉES dans `pointerInput(Unit)` (`futurePanHorizon` lu à 0 pour toujours → pan futur interdit) → `rememberUpdatedState` + **garde de source** (`ChartScreenSourceGuardTest`, le lint Compose ne voit pas ce pattern) ; (b) toggle sans effet visible (`endMs = now` → 0 dose générée) → extension contrôlée à droite (`forecastExtensionHours`, jusqu'au 1ᵉʳ créneau, sans déplacer le début). Validé émulateur : drag gauche → panHours < 0, courbe dessinée au-delà de « maintenant » (screencap). 140 verts. Publication v1.4.4. |
| 7 sept. 2026 (session v1.4.5) | **« La prévision simule le 13 au lieu du 12 »** (#55) : diagnostic sur données réelles — GÉNÉRATION EXACTE (créneau = dernière dose loguée + 6 j ; subtilité : la dose avait été loguée le LENDENDE de l'injection réelle → dérive d'un jour par cycle, l'app suit les logs) ; illusion causée par l'absence de marqueur + labels X à minuit UTC. FIX : marqueurs de doses (prévisionnels pointillés + réels discrets), labels à minuit LOCAL, fuseau de graphique configurable (Paramètres). **+ #56** : boucle de recomposition (`nowMs` relu à chaque frame → endMs dérive → saturation main thread → taps perdus) → mémoïsé sur le tick. 145 verts. Publication v1.4.5. |
| 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. | | 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. |
| 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. | | 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. |
| 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. | | 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. |
@ -411,7 +412,7 @@ Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + e^(−k3·t)/((k1−k
## 8. Tests unitaires ## 8. Tests unitaires
**140 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **118 sans **145 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **123 sans
les données de test locales** (cf §8.bis : les 3 classes de régression, les données de test locales** (cf §8.bis : les 3 classes de régression,
6 tests chacune, sont skippées via `Assume`). Dépendance : JUnit 4.13.2. 6 tests chacune, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
@ -534,6 +535,10 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
d'équilibre EV 5 mg/7 j = Figure 11 (Cmax 384/Cmin 142/Cavg 269), esters 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 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). passe par CE test** (c'est la fidélité au simulateur TFS qui est vérifiée).
- **`ChartZoomTest`** (13) : v1.2.9/v1.4.1/v1.4.5 — pas d'échantillonnage,
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 - **`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 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 gestionnaire de gestes via `rememberUpdatedState` (alias `gesture*`) et que
@ -1077,6 +1082,27 @@ l'exemple) :**
**Session v1.4.0 (modèle TFS sur la méta-analyse + fix #52) :** **Session v1.4.0 (modèle TFS sur la méta-analyse + fix #52) :**
55. **« La prévision simule le 13 au lieu du 12 »** (v1.4.5, remontée) :
diagnostic sur les données réelles (régression #4) : la GÉNÉRATION est
exacte (créneau = dernière dose LOGUÉE + intervalle en ms exact) —
l'illusion venait de trois défauts d'AFFICHAGE : aucun marqueur des
doses simulées, labels X alignés sur minuit UTC (= 02:00 FR), courbe
encore descendante au créneau (physiologie EEn). FIX : marqueurs
(prévisionnels pointillés + réels discrets), `xLabelTicks` à minuit
LOCAL dans le fuseau CHOISI (Paramètres, null = téléphone).
⚠️ Subtilité : une dose loguée le LENDENDE de l'injection réelle décale
le rythme d'un jour par cycle — l'app suit les LOGS ; recadrer = éditer
la dose à son instant réel.
56. **Boucle de recomposition saturant le main thread** (v1.4.5, découvert
en validant #55 : les chips ne répondaient plus, « Skipped 52 frames »
en continu) : `nowMs = System.currentTimeMillis()` relu frais à CHAQUE
recomposition → endMs dérive → les keys du producer (endMs) changent à
chaque frame → re-calcul perpétuel. FIX : nowMs mémoïsé sur le tick
minute (`remember(tick)`) ; le calcul des créneaux prévisionnels
mémoïsé aussi. Leçon : JAMAIS de `System.currentTimeMillis()` nu dans
un corps de composable dont il dérive des keys de producer — le mémoïser
sur un tick.
54. **Prévision inaccessible : captures figées dans `pointerInput(Unit)`** 54. **Prévision inaccessible : captures figées dans `pointerInput(Unit)`**
(v1.4.4, remontée « le toggle activé ne génère pas les prévisions, je ne (v1.4.4, remontée « le toggle activé ne génère pas les prévisions, je ne
peux même pas dragger vers la gauche ») : la closure du gestionnaire de peux même pas dragger vers la gauche ») : la closure du gestionnaire de
@ -1263,8 +1289,8 @@ vérifie pas en tests JVM).
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`, 1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y). `versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 140 au 2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 145 au
total, 118 si `local-test-data/` est absent (les 3 classes de régression total, 123 si `local-test-data/` est absent (les 3 classes de régression
réelles sont skippées via `Assume`, 6 tests chacune) ; **lint vert réelles sont skippées via `Assume`, 6 tests chacune) ; **lint vert
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet : attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet :
@ -1558,12 +1584,12 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
la courbe 24 h du graphique la courbe 24 h du graphique
--- ---
*Doc mise à jour le 7 sept. 2026 (v1.4.3) — build OK, lint vert, 137/137 tests verts (115 sans *Doc mise à jour le 7 sept. 2026 (v1.4.5) — build OK, lint vert, 145/145 tests verts (123 sans
les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée
de santé dans le dépôt ni l'historique. Fil des corrections : v1.3.3 = reprise de de santé dans le dépôt ni l'historique. Fil des corrections : v1.3.3 = reprise de
maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs
reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint
filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51 filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51
agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité
~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres ; v1.4.3 = fix du delta 6 h (#53) + régression n°4 (plateau EEn épinglé). Dette connue : fragments de ~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres ; v1.4.3 = fix du delta 6 h (#53) + régression n°4 (plateau EEn épinglé) ; v1.4.4 = prévision réparée (captures figées #54 + extension de fenêtre) ; v1.4.5 = marqueurs de doses + minuit local + fuseau configurable (#55) + boucle de recomposition tuée (#56). Dette connue : fragments de
labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.* labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.*

View File

@ -183,6 +183,13 @@ Pics de référence (dose unique de 5 mg, IM) :
pour parcourir la projection dans le futur (jusqu'à 1 an selon la Posologie) ; pour parcourir la projection dans le futur (jusqu'à 1 an selon la Posologie) ;
« Revenir à maintenant » pour revenir. Si tu désactives le chip alors que tu es « Revenir à maintenant » pour revenir. Si tu désactives le chip alors que tu es
dans le futur, l'app revient automatiquement à maintenant dans le futur, l'app revient automatiquement à maintenant
- **Marqueurs de doses** (v1.4.5) : les doses projetées sont marquées par une
ligne verticale pointillée avec leur heure ; tes prises enregistrées par un
petit triangle discret au bas du graphique — plus d'ambiguïté sur « où » la
prochaine injection est simulée
- **Fuseau horaire du graphique** (v1.4.5) : Paramètres → « Fuseau horaire du
graphique » — les jours s'alignent sur minuit du fuseau choisi (vide = celui
du téléphone)
- **Pics / creux** (chip sur le graphique) : triangles ▲▼ aux extrema estimés de - **Pics / creux** (chip sur le graphique) : triangles ▲▼ aux extrema estimés de
chaque courbe (E2 et T, les deux modèles) — pratique pour visualiser d'un coup chaque courbe (E2 et T, les deux modèles) — pratique pour visualiser d'un coup
d'œil les hauts et les bas entre deux injections d'œil les hauts et les bas entre deux injections