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
4b20627503
@ -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) :
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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 & creux estimés (par courbe)</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="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>
|
||||||
|
|||||||
@ -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 & troughs (per curve)</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="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>
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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.*
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user