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:
parent
045ab5d060
commit
15a7e6614c
@ -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 <repo> && 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) :
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<TConfig> = 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<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?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (encoded.isNullOrEmpty()) prefs.remove(Keys.ALERT_NOTIFIED_STATE)
|
||||
|
||||
@ -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<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. */
|
||||
fun niceCeil(value: Double): Double {
|
||||
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
|
||||
* (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<LabResult>,
|
||||
tLabs: List<LabResult>,
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<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")
|
||||
val curves by produceState(
|
||||
emptyList<Pair<String, List<LevelPoint>>>(),
|
||||
@ -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),
|
||||
|
||||
@ -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<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 ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
|
||||
@ -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_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="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_count">%1$d lignes (rappels, agenda, import/export, erreurs)</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_permission">L\'autorisation agenda est demandée à l\'activation.</string>
|
||||
<string name="legend_extrema">▲▼ pics & 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="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>
|
||||
|
||||
@ -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_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="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_count">%1$d lines (reminders, calendar, import/export, errors)</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_permission">Calendar permission is requested when you enable this.</string>
|
||||
<string name="legend_extrema">▲▼ estimated peaks & 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="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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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.*
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user