v1.4.1 : prévision étendue et sans saut — activer le chip ne déplace plus la fenêtre (endMs = now − panHours signé, panHours < 0 = futur borné par l'horizon 12×Posologie [30 j ; 1 an], clamp unique clampPanHours) ; delta en jours sur la carte Prochaine dose (> 24 h : '5 j 2 h · sam. 6 18:00') ; 5 tests (112 verts) + lint

This commit is contained in:
Siphonight 2026-09-06 23:00:42 +02:00
parent c1c2386ab2
commit 77ba90bb1e
13 changed files with 221 additions and 43 deletions

View File

@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue.
- **Statut** : v1.4.0 — build Android ✅, **lint vert** ✅, **107 tests unitaires** ✅ (89 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.1 — build Android ✅, **lint vert** ✅, **112 tests unitaires** ✅ (94 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)
@ -38,7 +38,9 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
- **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel,
patch et voie orale
- **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
traitement → projection des doses à venir sur le graphique (jamais sauvegardées)
traitement → projection des doses à venir sur le graphique (jamais sauvegardées) ;
**l'activation ne déplace pas le graphique** et la projection se parcourt en tirant
vers la gauche, jusqu'à **1 an** (v1.4.1)
- **Log des doses** avec date/heure exacte, dose en mg, **ester par injection**
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
**intervalle en jours entre dosages** affiché
@ -62,7 +64,8 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
7 jours ne sonne que le jour d'injection, pas tous les jours
- **Nouveautés à chaque mise à jour** : dialog de changelog automatique
(fermé = ne réapparaît pas avant la prochaine version)
- **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise)
- **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise) ;
**prochaine dose en jours** sur l'accueil quand elle est à plus de 24 h (v1.4.1)
- **Logs de diagnostic** exportables (Paramètres) — utile pour le support
- **Événements d'agenda** : les rappels de prises peuvent créer un événement
récurrent (posologie) dans un calendrier « HormoneTrack » de ton téléphone
@ -79,7 +82,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 # 107 tests (89 sans les données locales)
./gradlew testDebugUnitTest # 112 tests (94 sans les données locales)
./gradlew lint # lint vert obligatoire avant release
```
@ -91,7 +94,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.0`) et **deux APK par release** (depuis v1.2.5)
**releases taguées** (`v1.1.0` → `v1.4.1`) et **deux APK par release** (depuis v1.2.5)
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
`-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) :

View File

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

View File

@ -77,6 +77,29 @@ fun stepForRange(rangeHours: Long): Long = when {
else -> 3_600_000L // 1 h
}
/**
* Horizon de PRÉVISION en heures (v1.4.1) : quantité de doses projetées
* générées au-delà de « maintenant » quand le chip Prévision est actif.
* 12 × la plus grande Posologie, borné [30 j ; 365 j] — « scroller à
* l'infini » s'arrête à 1 an (compromis calcul : 365 j / 6 j ≈ 61 doses
* projetées superposées, coût O(points × doses) maîtrisé).
* @return null si aucun traitement n'a de Posologie (chip désactivé).
*/
fun forecastHorizonHours(maxIntervalDays: Double?): Long? {
if (maxIntervalDays == null || maxIntervalDays <= 0.0) return null
return (maxIntervalDays * 24.0 * 12.0).toLong().coerceIn(24L * 30, 24L * 365)
}
/**
* Bornes du panoramique (v1.4.1) : la fenêtre peut reculer dans le PASSÉ
* jusqu'à [0 ; maxPastHours] et — quand la prévision est active — AVANCER
* dans le FUTUR jusqu'à [-futureHorizonHours ; …] (panHours négatif =
* fenêtre décalée vers le futur). Unique point de clamp partagé par le
* drag et le zoom pour éviter les divergences.
*/
fun clampPanHours(panHours: Long, futureHorizonHours: Long, maxPastHours: Long): Long =
panHours.coerceIn(-futureHorizonHours.coerceAtLeast(0L), maxPastHours.coerceAtLeast(0L))
/**
* Graphique Canvas pur, multi-séries :
* - axe gauche = E2 (pg/mL), axe droit = T (ng/mL) ;

View File

@ -122,16 +122,23 @@ fun ChartScreen() {
}
}
// Horizon de prévision : 2 × le plus grand intervalle configuré (borné 7–30 jours)
// Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné
// [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt).
val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull()
val forecastHorizonHours = maxIntervalDays?.let { (it * 24 * 2).toLong().coerceIn(DAY_MS / HOUR_MS * 7, DAY_MS / HOUR_MS * 30) }
val forecastHorizonHours = com.hormonetrack.ui.components.forecastHorizonHours(maxIntervalDays)
// Fenêtre temporelle courante : endMs recule quand on tire vers la droite
// ── Fenêtre temporelle (v1.4.1) ──────────────────────────────────────────
// endMs = now − panHours (panHours > 0 = passé, < 0 = futur via prévision).
// ⚠️ ACTIVER la prévision ne déplace PLUS la fenêtre (ancien bug UX : le
// graphique sautait « tout à droite » dans le futur) : la courbe projetée
// s'étend au-delà de la fenêtre courante et on la parcourt en tirant vers
// la gauche (panHours négatif), borné par l'horizon. La fenêtre reste où
// l'utilisateur l'avait laissée.
val nowMs = System.currentTimeMillis()
val earliestDoseMs = doseLogs.minOfOrNull { it.timestamp } ?: nowMs
val maxPanHours = (((nowMs - earliestDoseMs) / HOUR_MS) + rangeHours / HOUR_MS).coerceAtLeast(0L)
val endMs = nowMs - panHours * HOUR_MS +
(if (showForecast) (forecastHorizonHours ?: 0L) * HOUR_MS else 0L)
val futurePanHorizon = if (showForecast) (forecastHorizonHours ?: 0L) else 0L
val endMs = nowMs - panHours * HOUR_MS
val startMs = endMs - rangeHours * HOUR_MS
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS")
@ -266,7 +273,13 @@ fun ChartScreen() {
)
FilterChip(
selected = showForecast,
onClick = { showForecast = !showForecast },
onClick = {
showForecast = !showForecast
// v1.4.1 : désactiver la prévision pendant qu'on est dans le
// futur → revenir à maintenant (sinon fenêtre vide au-delà
// de « maintenant » sans doses projetées).
if (!showForecast) panHours = maxOf(panHours, 0L)
},
enabled = forecastHorizonHours != null,
label = { Text(stringResource(R.string.show_forecast)) }
)
@ -303,13 +316,16 @@ fun ChartScreen() {
// Pan (1 doigt) + pinch-zoom (2 doigts, v1.2.9) — un seul
// handler pour éviter les conflits de consommation
detectTransformGestures { centroid, pan, zoom, _ ->
val forecastMs =
(if (showForecast) (forecastHorizonHours ?: 0L) * HOUR_MS else 0L)
if (chartWidthPx > 0) {
// Pan horizontal : glisser à droite = remonter le temps
// Pan horizontal : glisser à DROITE = remonter le
// temps (panHours croît) ; glisser à GAUCHE =
// avancer dans le FUTUR (panHours décroît, borné
// par l'horizon de prévision — v1.4.1)
val hoursDragged = (pan.x / chartWidthPx * rangeHours).toLong()
if (hoursDragged != 0L) {
panHours = (panHours + hoursDragged).coerceIn(0L, maxPanHours)
panHours = com.hormonetrack.ui.components.clampPanHours(
panHours + hoursDragged, futurePanHorizon, maxPanHours
)
}
// Zoom autour du point focal : le temps sous le doigt
// reste fixe pendant le pinch
@ -318,11 +334,13 @@ fun ChartScreen() {
val newRange = (rangeHours / zoom).toLong()
.coerceIn(MIN_RANGE_H, MAX_RANGE_H)
if (newRange != rangeHours) {
val endAnchor = nowMs + forecastMs - panHours * HOUR_MS
val endAnchor = nowMs - panHours * HOUR_MS
val tFocal = endAnchor - ((1.0 - f) * rangeHours * HOUR_MS).toLong()
val newEnd = tFocal + ((1.0 - f) * newRange * HOUR_MS).toLong()
val newPan = ((nowMs + forecastMs - newEnd) / HOUR_MS).toLong()
panHours = newPan.coerceIn(0L, maxPanHours)
val newPan = ((nowMs - newEnd) / HOUR_MS).toLong()
panHours = com.hormonetrack.ui.components.clampPanHours(
newPan, futurePanHorizon, maxPanHours
)
rangeHours = newRange
}
}
@ -354,8 +372,10 @@ fun ChartScreen() {
)
Spacer(Modifier.height(8.dp))
// Retour au présent dès qu'on a voyagé dans le passé
if (panHours > 0L) {
// Retour au présent dès qu'on a voyagé (passé OU futur —
// v1.4.1 : le futur est atteint en tirant vers la gauche
// avec la prévision active)
if (panHours != 0L) {
TextButton(onClick = { panHours = 0L }) {
Text(stringResource(R.string.back_to_present))
}

View File

@ -362,17 +362,31 @@ private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) {
val deltaMs = next - System.currentTimeMillis()
val h = deltaMs / HOUR_MS
val m = (deltaMs % HOUR_MS) / 60_000L
val timeStr = DateTimeFormatter.ofPattern("HH:mm")
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochMilli(next))
Text(
if (h > 0) {
String.format(Locale.getDefault(), "%dh%02d · %s (%s)", h, m, timeStr, nextTreatment?.name ?: "")
} else {
String.format(Locale.getDefault(), "%d min · %s (%s)", m, timeStr, nextTreatment?.name ?: "")
},
style = MaterialTheme.typography.titleMedium
)
val name = nextTreatment?.name ?: ""
// v1.4.1 : au-delà de 24 h, afficher en JOURS (les heures
// deviennent peu lisibles) + le jour de la semaine dans la date.
if (h >= 24) {
val (days, remH) = com.hormonetrack.util.HrtDuration.daysAndHours(h)
val dayTimeStr = DateTimeFormatter.ofPattern("EEE d HH:mm")
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochMilli(next))
Text(
stringResource(R.string.next_dose_days, days, remH, dayTimeStr, name),
style = MaterialTheme.typography.titleMedium
)
} else {
val timeStr = DateTimeFormatter.ofPattern("HH:mm")
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochMilli(next))
Text(
if (h > 0) {
String.format(Locale.getDefault(), "%dh%02d · %s (%s)", h, m, timeStr, name)
} else {
String.format(Locale.getDefault(), "%d min · %s (%s)", m, timeStr, name)
},
style = MaterialTheme.typography.titleMedium
)
}
}
}
}

View File

@ -12,4 +12,12 @@ object HrtDuration {
/** Décomposition mois(30 j) / jours restants. */
fun monthsAndDays(totalDays: Long): Pair<Long, Long> =
Pair(totalDays / 30, totalDays % 30)
/**
* Conversion d'un delta EN HEURES en (jours, heures restantes) — v1.4.1,
* carte « Prochaine dose » de l'accueil : au-delà de 24 h, les heures
* parlent moins que les jours (ex. 120 h → 5 j 0 h).
*/
fun daysAndHours(totalHours: Long): Pair<Long, Long> =
Pair(totalHours / 24, totalHours % 24)
}

View File

@ -17,6 +17,7 @@
<string name="no_data">Pas encore de données — crée un traitement et logue une dose.</string>
<string name="logged_today">Log rapide</string>
<string name="next_dose">Prochaine dose</string>
<string name="next_dose_days">%1$d j %2$d h · %3$s (%4$s)</string>
<string name="delta_6h">%1$s %2$s pg/mL vs il y a 6 h</string>
<string name="home_chart_title">Dernières 24 h (estimation)</string>
<string name="home_legend_e2">— E2 estimé (pg/mL, gauche)</string>

View File

@ -17,6 +17,7 @@
<string name="no_data">No data yet — add a treatment and log a dose.</string>
<string name="logged_today">Quick log</string>
<string name="next_dose">Next dose</string>
<string name="next_dose_days">%1$d d %2$d h · %3$s (%4$s)</string>
<string name="delta_6h">%1$s %2$s pg/mL vs 6 h ago</string>
<string name="home_chart_title">Last 24 hours (estimate)</string>
<string name="home_legend_e2">— E2 estimate (pg/mL, left)</string>

View File

@ -1,12 +1,18 @@
package com.hormonetrack.ui
import com.hormonetrack.ui.components.clampPanHours
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.Test
/**
* Pas d'échantillonnage adaptatif au zoom (v1.2.9) : plus la fenêtre est
* courte, plus le pas est fin — sinon les courbes paraissent anguleuses.
*
* + v1.4.1 : horizon de prévision (12 × Posologie, borné 30–365 j) et bornes
* du panoramique (panHours négatif = futur ; clamp unique drag/zoom).
*/
class ChartZoomTest {
@ -28,4 +34,46 @@ class ChartZoomTest {
assertEquals(3_600_000L, stepForRange(24L * 30))
assertEquals(3_600_000L, stepForRange(24L * 300))
}
@Test
fun `forecast horizon is null without posology`() {
assertNull(forecastHorizonHours(null))
assertNull(forecastHorizonHours(0.0))
assertNull(forecastHorizonHours(-2.0))
}
@Test
fun `forecast horizon is 12x the largest interval bounded 30d-365d`() {
// Posologie 6 j → 72 j d'horizon (12 × 6)
assertEquals(24L * 72, forecastHorizonHours(6.0))
// petits intervalles → plancher 30 j (sinon horizon trop court)
assertEquals(24L * 30, forecastHorizonHours(1.0))
// grands intervalles : 30 j × 12 = 360 j (sous le plafond)…
assertEquals(24L * 360, forecastHorizonHours(30.0))
// …et plafond 1 an au-delà de ~30,4 j (l'« infini » s'arrête là)
assertEquals(24L * 365, forecastHorizonHours(31.0))
assertEquals(24L * 365, forecastHorizonHours(90.0))
}
@Test
fun `pan clamps to the past only when forecast is off`() {
// Prévision OFF : futur interdit (horizon 0) → un drag négatif ramène à 0
assertEquals(0L, clampPanHours(panHours = -48L, futureHorizonHours = 0L, maxPastHours = 100L))
assertEquals(50L, clampPanHours(50L, 0L, 100L))
// bornes passées respectées
assertEquals(100L, clampPanHours(500L, 0L, 100L))
}
@Test
fun `pan can go into the future up to the forecast horizon`() {
// Prévision ON (horizon 72 j) : panHours négatif = fenêtre décalée
// vers le futur — c'est ainsi qu'on « scrolle à droite » sans saut
// à l'activation du chip.
assertEquals(-24L * 72, clampPanHours(-24L * 500, 24L * 72, 100L))
assertEquals(-5L, clampPanHours(-5L, 24L * 72, 100L))
// le passé reste borné par maxPast
assertEquals(100L, clampPanHours(1000L, 24L * 72, 100L))
// bornes dégénérées (jamais négatives)
assertEquals(0L, clampPanHours(-10L, -5L, -3L))
}
}

View File

@ -35,4 +35,16 @@ class HrtDurationTest {
fun `décomposition - zéro jour`() {
assertEquals(Pair(0L, 0L), HrtDuration.monthsAndDays(0L))
}
@Test
fun `heures en jours et heures restantes - carte prochaine dose v1_4_1`() {
// 120 h = 5 j 0 h
assertEquals(Pair(5L, 0L), HrtDuration.daysAndHours(120L))
// 51 h = 2 j 3 h
assertEquals(Pair(2L, 3L), HrtDuration.daysAndHours(51L))
// < 24 h → 0 j (l'écran utilise alors le format heures/minutes)
assertEquals(Pair(0L, 23L), HrtDuration.daysAndHours(23L))
// zéro
assertEquals(Pair(0L, 0L), HrtDuration.daysAndHours(0L))
}
}

View File

@ -1,7 +1,30 @@
# 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.0`, …).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.1`, …).
## [1.4.1] — 2026-09-06 (versionCode 21)
### Ajouté
- **Prévision étendue ET sans saut temporel** (remontées utilisateur) :
- activer le chip **Prévision** ne déplace PLUS le graphique « tout à
droite » dans le futur — la fenêtre reste exactement où elle était ;
la courbe projetée s'étend au-delà et on la parcourt en **tirant vers
la gauche** (« scroller à l'infini » = jusqu'à 1 an de projection) ;
« Revenir à maintenant » ramène depuis le passé **ou** le futur ;
- l'horizon passe de « 2 × Posologie, borné 7–30 j » à **12 × Posologie,
borné 30 j – 1 an** (`forecastHorizonHours`, épinglé par `ChartZoomTest`).
- désactiver la prévision pendant qu'on est dans le futur → retour
automatique à « maintenant » (sinon fenêtre sans données).
- **Delta en JOURS sur la carte « Prochaine dose »** (remontée : les heures
deviennent illisibles au-delà de 24 h) : à partir d'un jour, la carte
affiche « 5 j 2 h · sam. 6 18:00 (traitement) » avec le jour de la
semaine ; en dessous, format heures/minutes inchangé
(`HrtDuration.daysAndHours` + string `next_dose_days` FR/EN).
- **5 nouveaux tests** (112 au total / 94 sans les données locales) :
bornes d'horizon et clamp de panoramique (`ChartZoomTest` ×4 — le clamp
unique drag/zoom est centralisé dans `clampPanHours`),
conversion heures→jours (`HrtDurationTest` ×1).
## [1.4.0] — 2026-09-06 (versionCode 20)

View File

@ -83,6 +83,7 @@ Fonctionnalités v1 :
| 6 sept. 2026 (session v1.3.4) | **Deux crashs v1.3.3 remontés (Doses + export logs)** → reproduits sur ÉMULATEUR avec les vraies données (recette §16.ter, désormais standard) : (a) Doses = string `hrt_duration` 3 placeholders vs 2 args (**né en v1.3.1**, §14 #47) ; (b) export logs = `LocalDate.format("…-HHmm")` levant `UnsupportedTemporalTypeException` au TAP (§14 #48) — les fixes IO v1.3.2/v1.3.3 étaient à côté du vrai problème. Lint mis en filet bloquant (aurait attrapé les deux) + #49 notif ; helper `ExportFileNames` + 3 tests (90 verts) ; fixes VÉRIFIÉS sur émulateur avec l'APK release + données réelles (flux SAF complet : SAVE → fichier + message). Publication v1.3.4. |
| 6 sept. 2026 (session v1.3.5) | **Premier diagnostic à distance RÉUSSI via les logs exportés** : les AppLog de la v1.3.4 prouvent export logs `ok=true` (fix #48 ✓ au téléphone), permission agenda accordée (fix manifest ✓) et révèlent le bug racine EXPLICITE : `ensureCalendar` plantait car l'URI sync-adapter n'embarquait pas ACCOUNT_NAME/ACCOUNT_TYPE (§14 #50). En validant sur émulateur, DEUX bugs découverts : delete d'event sans account (#50 bis — « supprimé » logué mais l'event restait) et `buildTreatment` sans `calendarEventId` (#51 — événements orphelins). Cycle ON→save→OFF→save validé end-to-end (`content query`). Publication v1.3.5. |
| 6 sept. 2026 (session v1.4.0) | **Modèle TFS reconstruit sur la méta-analyse officielle** (params V3C du simulateur transfemscience.github.io/injectable-e2-simulator, extraits de `ester-data.js`/`calc-curve.js`) : forme close 3 compartiments, 7 esters (EB/EC/ECS/PEP ajoutés), pics de l'article reproduits à ~1 % (AUC + Figure 11 épinglés). **+ Fix #52** (remontée) : rappels quotidiens même hors jour d'injection → grille Posologie (moteur = source unique), re-programmation après notif et « Pris ». 12 nouveaux tests (102 verts), lint vert. Publication v1.4.0. |
| 6 sept. 2026 (session v1.4.1) | **UX graphique et carte « Prochaine dose »** (remontées) : (a) activer la Prévision ne saute PLUS dans le futur — la fenêtre reste en place, la projection s'étend au-delà et se parcourt en tirant vers la gauche (panHours NÉGATIF = futur, clamp unique `clampPanHours`, horizon 12× Posologie borné 30 j–1 an) ; (b) delta en JOURS sur la carte « Prochaine dose » au-delà de 24 h (`HrtDuration.daysAndHours` + `next_dose_days` FR/EN). 5 tests (112 verts). Publication v1.4.1. |
| 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. |
@ -407,7 +408,7 @@ Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + e^(−k3·t)/((k1−k
## 8. Tests unitaires
**107 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **89 sans
**112 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **94 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 =
@ -503,7 +504,7 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
seule), plusieurs versions intermédiaires, versions non numériques.
- **`AppLogTest`** (4) : v1.3.1 — formatage des lignes horodatées, buffer
circulaire (600 → 500 dernières, bordures).
- **`HrtDurationTest`** (5) : v1.3.1 — jours depuis la 1re prise (0 si
- **`HrtDurationTest`** (6) : v1.3.1 — jours depuis la 1re prise (0 si
futur/invalide), décomposition mois(30 j)/jours.
- **`CalendarRruleTest`** (3) : v1.3.0 — RRULE `FREQ=DAILY;INTERVAL=N`,
arrondi demi-supérieur des décimales (6,5 → 7), clamp ≥ 1 jour.
@ -526,6 +527,7 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
et 0 au-delà de 10 × t½ (par design) ; `computeCurve(modelOverride=TFS)`
sur un traitement stocké ESE reproduit le pic V3C EV 295 — c'est le test
du dispatch `concentrationOfDose` + de `cutoffHours`.
- **`ChartZoomTest`** (7) : v1.2.9 (pas d'échantillonnage) + v1.4.1 — **horizon de prévision** 12 × Posologie borné 30 j–1 an (`forecastHorizonHours`, null sans Posologie) et **clamp du panoramique** (`clampPanHours` : panHours négatif = futur, borné par l'horizon ; prévision OFF → futur interdit) — la logique de fenêtre du graphique est centralisée et testée.
- **`ReminderScheduleTest`** (8) : v1.4.0 — fix #52 : rappel sur la GRILLE
Posologie (dose il y a 2 j + intervalle 7 j → alarme dans 5 j à l'heure
choisie, PAS demain), heure déjà passée le jour du créneau → créneau
@ -590,11 +592,20 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
- `HormoneTrackRoot` : NavigationBar 5 tabs (home/chart/doses/labs/treatments) + routes
`settings`, `treatment_edit/{id}` (-1 = nouveau) ; barre masquée sur ces 2 routes
- `HomeScreen` : bandeau gradient (TransSky→TransPink, discret), carte **niveau actuel**
(E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h), carte prochaine dose, chips de log rapide
(E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h), carte prochaine dose (**v1.4.1 :
delta en JOURS au-delà de 24 h — « 5 j 2 h · sam. 6 18:00 » via
`HrtDuration.daysAndHours` + `next_dose_days` ; en dessous, h/min**), chips de log rapide
(+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`) **cliquable → écran
Graphiques** (v1.2.1) avec mini-légende E2/T ; données auto-calibrées si l'option est
active (`scalePerEster = effectiveAuto?.esterScales`) ; rafraîchissement `tick` 60 s
- `ChartScreen` (v1.2, le plus riche) : plages 24 h/7 j/30 j + **boutons zoom − / +**
- `ChartScreen` (v1.2, le plus riche) : **fenêtre temporelle v1.4.1** —
`endMs = now − panHours` avec `panHours` signé : > 0 = passé (tirer vers la
droite), **< 0 = futur** (tirer vers la gauche, uniquement avec la
prévision active, borné par `forecastHorizonHours` = 12 × Posologie borné
30 j–1 an). **Activer le chip Prévision ne déplace PAS la fenêtre** (la
projection s'étend au-delà et se parcourt au drag) ; désactiver pendant un
voyage futur → retour à maintenant ; clamp centralisé dans
`clampPanHours` (testé). Plages 24 h/7 j/30 j + **boutons zoom − / +**
(v1.2.9) ; **panoramique**
(`detectHorizontalDragGestures` — tirer vers la droite remonte dans le passé,
`panHours` borné à [0, âge de la 1ʳᵉ dose + plage], bouton « Revenir à maintenant ») ;
@ -1147,8 +1158,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` — 107 au
total, 89 si `local-test-data/` est absent (les 3 classes de régression
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 112 au
total, 94 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 :
@ -1418,14 +1429,22 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
pg/mL/mg) et diverger en queues (V3C = queues exactes) ; vérifier un
preset EB/EC/PEP (nouvelles courbes) ; PEP à 32,5 mg → niveaux
comparables à un EEn à 5 mg
- [ ] **v1.4.1** : **prévision sans saut** : ouvrir Graphiques → activer le
chip Prévision → le graphique NE BOUGE PAS (avant v1.4.1 il sautait
« tout à droite ») ; tirer vers la GAUCHE → la courbe projetée défile
vers le futur (jusqu'à 1 an selon la Posologie) ; « Revenir à
maintenant » ramène ; désactiver le chip dans le futur → retour auto
- [ ] **v1.4.1** : **carte « Prochaine dose » en jours** : avec un créneau
à > 24 h, l'accueil affiche « 5 j 2 h · sam. 6 18:00 (traitement) »
(et non « 122h22 ») ; à < 24 h, format heures/minutes inchangé
---
*Doc mise à jour le 6 sept. 2026 (v1.4.0) — build OK, lint vert, 107/107 tests verts (89 sans
*Doc mise à jour le 6 sept. 2026 (v1.4.1) — build OK, lint vert, 112/112 tests verts (94 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). 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. Dette connue : fragments de
labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.*

View File

@ -101,6 +101,8 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
- Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods`
- Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo)
2. **Doses → +** → logue tes injections passées
- L'accueil affiche la **prochaine dose en jours** quand elle est à plus de 24 h
(ex. « 5 j 2 h · sam. 6 18:00 »), en heures/minutes sinon
- Astuce mise à jour : l'app affiche les **nouveautés** de chaque version au
démarrage (fermable) ; la **version installée** et le **lien des releases**
sont dans Paramètres (date/heure exactes, dose en mg)
@ -164,7 +166,11 @@ Pics de référence (dose unique de 5 mg, IM) :
simultanément (Estrannaise = bleu, Transfem Science = turquoise) pour comparer
- **Prévision** : configure la **Posologie** (intervalle en jours) dans un traitement
(section « Fréquence ») puis active le chip « Prévision » → les doses à venir sont
simulées et dessinées après la ligne « maintenant » (jamais sauvegardées)
simulées et dessinées après la ligne « maintenant » (jamais sauvegardées).
**Activer le chip ne déplace pas le graphique** : tire vers la GAUCHE 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
- **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