From 15a7e6614c50b0c3af62fd67af0556b5f7a35dac Mon Sep 17 00:00:00 2001 From: Siphonight Date: Mon, 7 Sep 2026 11:27:38 +0200 Subject: [PATCH] =?UTF-8?q?v1.4.5=20:=20fix=20#55=20=E2=80=94=20projection?= =?UTF-8?q?=20paraissant=20d=C3=A9cal=C3=A9e=20d'un=20jour=20(g=C3=A9n?= =?UTF-8?q?=C3=A9ration=20exacte=20confirm=C3=A9e=20par=20r=C3=A9gression?= =?UTF-8?q?=20#4=20;=20illusion=20=3D=20absence=20de=20marqueurs=20+=20lab?= =?UTF-8?q?els=20=C3=A0=20minuit=20UTC)=20=E2=86=92=20marqueurs=20de=20dos?= =?UTF-8?q?es=20(pr=C3=A9visionnels=20pointill=C3=A9s=20+=20r=C3=A9els=20d?= =?UTF-8?q?iscrets)=20+=20xLabelTicks=20=C3=A0=20minuit=20LOCAL=20dans=20l?= =?UTF-8?q?e=20fuseau=20CHOISI=20(Param=C3=A8tres)=20;=20fix=20#56=20?= =?UTF-8?q?=E2=80=94=20boucle=20de=20recomposition=20(nowMs=20relu=20?= =?UTF-8?q?=C3=A0=20chaque=20frame=20=E2=86=92=20endMs=20d=C3=A9rive=20?= =?UTF-8?q?=E2=86=92=20main=20thread=20satur=C3=A9,=20taps=20perdus)=20?= =?UTF-8?q?=E2=86=92=20nowMs=20m=C3=A9mo=C3=AFs=C3=A9=20sur=20le=20tick=20?= =?UTF-8?q?;=206=20tests=20(145=20verts)=20+=20lint=20;=20valid=C3=A9=20?= =?UTF-8?q?=C3=A9mulateur=20(chips=20r=C3=A9actifs,=20marqueur=20vertical?= =?UTF-8?q?=20rendu)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- app/build.gradle.kts | 4 +- .../com/hormonetrack/settings/AppSettings.kt | 21 +++ .../hormonetrack/ui/components/CurveChart.kt | 142 +++++++++++++++++- .../hormonetrack/ui/screens/ChartScreen.kt | 72 +++++++-- .../hormonetrack/ui/screens/SettingsScreen.kt | 64 ++++++++ app/src/main/res/values-fr/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + .../pk/RegressionUserCase4Test.kt | 37 +++++ .../java/com/hormonetrack/ui/ChartZoomTest.kt | 68 +++++++++ docs/CHANGELOG.md | 38 ++++- docs/DEVELOPPEMENT.md | 36 ++++- docs/GUIDE_INSTALLATION.md | 7 + 13 files changed, 478 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index ac49201..0a67152 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > sang et aux consignes de ton endocrinologue. -- **Statut** : v1.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) - **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) @@ -25,6 +25,8 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** ## Fonctionnalités - **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 − / +, 6 h → 300 j, échantillonnage adaptatif), **pics & creux** affichables avec 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 && cd HormoneTrack echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME ./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 ``` @@ -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) — `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)) : `-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) : diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e4fd41d..e7fbb5c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // les APK publiés (Paramètres + titre du dialog « Nouveautés »). - versionCode = 24 - versionName = "1.4.4" + versionCode = 25 + versionName = "1.4.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hormonetrack/settings/AppSettings.kt b/app/src/main/java/com/hormonetrack/settings/AppSettings.kt index caeeaf6..a086cd2 100644 --- a/app/src/main/java/com/hormonetrack/settings/AppSettings.kt +++ b/app/src/main/java/com/hormonetrack/settings/AppSettings.kt @@ -46,6 +46,12 @@ class AppSettings(private val context: Context) { * re-notify pas le même franchissement toutes les 15 min. */ 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 = context.dataStore.data.map { prefs -> @@ -115,6 +121,21 @@ class AppSettings(private val context: Context) { 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 = 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?) { context.dataStore.edit { prefs -> if (encoded.isNullOrEmpty()) prefs.remove(Keys.ALERT_NOTIFIED_STATE) diff --git a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt index b950177..d3df750 100644 --- a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt +++ b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.unit.dp +import com.hormonetrack.data.model.DoseLog import com.hormonetrack.data.model.LabResult import com.hormonetrack.pk.LevelPoint 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.LabDot import java.text.SimpleDateFormat +import java.util.Calendar import java.util.Date import java.util.Locale -import kotlin.math.ceil /** * Style d'une courbe dessinée sur le graphique. @@ -51,6 +52,52 @@ data class ChartOptions( 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 { + 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() + 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. */ fun niceCeil(value: Double): Double { if (value <= 0) return 100.0 @@ -166,7 +213,10 @@ fun pointHoursBefore(points: List, hours: Double): LevelPoint? { * - les labs E2 sont des points orange ronds, les labs T des carrés orange * (converties en ng/mL via [PharmacokineticEngine.convertTToNgMl] — les labs * 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 fun CurveChart( @@ -174,11 +224,22 @@ fun CurveChart( e2Labs: List, tLabs: List, options: ChartOptions, - modifier: Modifier + modifier: Modifier, + /** Doses PRÉVISIONNELLES (jamais persistées) à marquer sur le graphique. */ + forecastDoses: List = emptyList(), + /** Doses RÉELLES enregistrées : marqueurs discrets (triangles bas) pour + * la lisibilité des prises passées (v1.4.5). */ + realDoses: List = 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 labelColor = MaterialTheme.colorScheme.onSurfaceVariant 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) { val allPoints = series.flatMap { it.points } @@ -221,7 +282,47 @@ fun CurveChart( if (options.showT && series.any { it.tStyle != null }) { 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 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 » if (options.nowMs in t0..t1) { drawLine( @@ -398,7 +519,8 @@ private fun DrawScope.drawRightYLabels(color: Color, tMax: Double, xRight: Float private fun DrawScope.drawXLabels( 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 spanH = (t1 - t0) / 3_600_000.0 @@ -410,13 +532,17 @@ private fun DrawScope.drawXLabels( spanH <= 25 * 7 -> 24 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()) else SimpleDateFormat("dd/MM", Locale.getDefault()) - var tick = ceil(t0 / (stepHours * 3_600_000.0)).toLong() * stepHours * 3_600_000L - while (tick <= t1) { + fmt.timeZone = zone + // 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()) drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint) - tick += stepHours * 3_600_000L } } diff --git a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt index ae59af2..c6fd48c 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt @@ -88,6 +88,15 @@ fun ChartScreen() { val labResults by repo.allLabResults.collectAsState(initial = emptyList()) val tConfig by container.settings.tConfig.collectAsState(initial = TConfig()) 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 var rangeHours by remember { mutableLongStateOf(24L) } @@ -128,24 +137,30 @@ fun ChartScreen() { val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull() val forecastHorizonHours = com.hormonetrack.ui.components.forecastHorizonHours(maxIntervalDays) - // ── Fenêtre temporelle (v1.4.1, extension v1.4.4 — fix #54) ────────────── - // endMs = now − panHours + extension (panHours > 0 = passé, < 0 = futur - // via prévision). Le drag vers la gauche parcourt la projection (borné - // par l'horizon) ; ⚠️ ACTIVER la prévision ne déplace JAMAIS le début de - // la fenêtre (pas de saut, v1.4.1) mais l'ÉTEND à droite d'après - // [forecastExtensionHours] : jusqu'au premier créneau projeté (+12 h de - // contexte), sinon 1/3 de la plage — sinon `generateForecastDoses` - // (borné à endMs) ne générait AUCUNE dose et le chip semblait inerte. - val nowMs = System.currentTimeMillis() + // ── 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 + // tick minute — relu frais à chaque recomposition, il faisait dériver + // endMs → les keys du producer (endMs) changeaient à chaque frame → + // re-calcul perpétuel → main thread saturé (« Skipped 52 frames ») → les + // taps sur les chips étaient perdus. Les vals dérivées de nowMs sont + // donc stables entre recompositions et ne bougent qu'au tick (60 s). + val nowMs = remember(tick) { System.currentTimeMillis() } val earliestDoseMs = doseLogs.minOfOrNull { it.timestamp } ?: nowMs val maxPanHours = (((nowMs - earliestDoseMs) / HOUR_MS) + rangeHours / HOUR_MS).coerceAtLeast(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 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( rangeHours, forecastHorizonHours, firstSlotGapH ) @@ -166,6 +181,25 @@ fun ChartScreen() { val gestureMaxPan by rememberUpdatedState(maxPanHours) 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(), + 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") val curves by produceState( emptyList>>(), @@ -394,6 +428,9 @@ fun ChartScreen() { showExtrema = showExtrema, nowMs = nowMs ), + forecastDoses = if (showForecast) forecastDoses else emptyList(), + realDoses = doseLogs, + zone = chartZone, modifier = Modifier .fillMaxWidth() .height(320.dp) @@ -446,6 +483,13 @@ fun ChartScreen() { color = LabDot ) } + if (showForecast && forecastDoses.isNotEmpty()) { + Text( + stringResource(R.string.legend_forecast_dose), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary + ) + } if (showExtrema) { Text( stringResource(R.string.legend_extrema), diff --git a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt index abd5924..6ad680d 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt @@ -76,6 +76,8 @@ fun SettingsScreen(onBack: () -> Unit) { val alertThresholds by container.settings.alertThresholds.collectAsState( 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 tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) } @@ -438,6 +440,68 @@ fun SettingsScreen(onBack: () -> Unit) { } 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(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 --- Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 852ceb5..3c907d5 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -51,6 +51,12 @@ ▲ %1$s estimé ≈ %2$s %3$s — au-dessus de ta limite haute (%4$s) ▼ %1$s estimé ≈ %2$s %3$s — sous ta limite basse (%4$s) Basé sur l\'estimation du modèle, pas une mesure. Vérifie avec tes prises de sang et ton endocrinologue. + Fuseau horaire du graphique + Sert à aligner les jours du graphique (minuit) et à lire les doses. Vide = fuseau du téléphone (automatique). Exemple : Europe/Paris. + ID de fuseau (ex. Europe/Paris) + Fuseau du téléphone + ID de fuseau inconnu (ex. Europe/Paris, America/New_York). + — prises enregistrées (marqueurs bas) Logs de diagnostic %1$d lignes (rappels, agenda, import/export, erreurs) Exporter @@ -66,6 +72,7 @@ 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. L\'autorisation agenda est demandée à l\'activation. ▲▼ pics & creux estimés (par courbe) + — prochaine dose projetée (marqueur vertical) Revenir à maintenant Fais glisser le graphique vers la droite pour remonter dans le passé. — E2 · Estrannaise (pg/mL, axe gauche) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c408cb1..43624ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,6 +51,12 @@ ▲ %1$s estimated ≈ %2$s %3$s — above your high limit (%4$s) ▼ %1$s estimated ≈ %2$s %3$s — below your low limit (%4$s) Based on the model estimate, not a measurement. Check with your blood tests and your endocrinologist. + Chart timezone + Used to align the chart days (midnight) and read doses. Empty = the phone\'s timezone (auto). Example: Europe/Paris. + Timezone ID (e.g. Europe/Paris) + Use phone timezone + Unknown timezone ID (e.g. Europe/Paris, America/New_York). + — logged doses (bottom markers) Diagnostic logs %1$d lines (reminders, calendar, import/export, errors) Export @@ -66,6 +72,7 @@ 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. Calendar permission is requested when you enable this. ▲▼ estimated peaks & troughs (per curve) + — next projected dose (vertical marker) Back to now Drag the chart to the right to travel into the past. — E2 · Estrannaise (pg/mL, left axis) diff --git a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase4Test.kt b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase4Test.kt index 4d67b6e..acb17cc 100644 --- a/app/src/test/java/com/hormonetrack/pk/RegressionUserCase4Test.kt +++ b/app/src/test/java/com/hormonetrack/pk/RegressionUserCase4Test.kt @@ -151,4 +151,41 @@ class RegressionUserCase4Test { assertTrue("alertes inattendues à l'observation : ${alerts}", alerts.isEmpty()) 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) + } + } } diff --git a/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt b/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt index ba8079f..cdebdf5 100644 --- a/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt +++ b/app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt @@ -5,6 +5,7 @@ import com.hormonetrack.ui.components.forecastHorizonHours import com.hormonetrack.ui.components.stepForRange import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test /** @@ -150,4 +151,71 @@ class ChartZoomTest { assertEquals(12L, com.hormonetrack.ui.components.forecastExtensionHours( 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()) + } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 23b485b..cb4edd5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,43 @@ # Changelog — HormoneTrack 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) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 90543f5..4e3a32a 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -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. | | 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. | | 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.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 -**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, 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 = @@ -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 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). +- **`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 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 @@ -1077,6 +1082,27 @@ l'exemple) :** **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)`** (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 @@ -1263,8 +1289,8 @@ vérifie pas en tests JVM). 1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`, `versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y). -2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 140 au - total, 118 si `local-test-data/` est absent (les 3 classes de régression +2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 145 au + 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 obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait 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 --- -*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 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 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 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.* diff --git a/docs/GUIDE_INSTALLATION.md b/docs/GUIDE_INSTALLATION.md index 262dfc5..0f4d784 100644 --- a/docs/GUIDE_INSTALLATION.md +++ b/docs/GUIDE_INSTALLATION.md @@ -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) ; « Revenir à maintenant » pour revenir. Si tu désactives le chip alors que tu es 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 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