v1.2.2 : prise de sang E2 + T en une entrée, affichage côte à côte, toggle T masque aussi les labs T

- LabDialog : mode création avec deux sections (E2 et T, chacune optionnelle,
  au moins une valeur requise) → insère 1 ou 2 LabResult au même timestamp ;
  mode édition inchangé (un seul marqueur, pré-rempli)
- LabsScreen : regroupement par timestamp (groupLabsForDisplay : E2 avant T,
  autres ensuite, tri desc) → affichage « E2 306 pg/mL · T 45 ng/dL » côte à
  côte ; tap sur une paire → sélecteur « Quelle entrée modifier ? » puis
  édition pré-remplie ; suppression = la prise entière (confirm nommant les
  valeurs) ; formatLabValue préserve les décimales
- ChartScreen : le toggle T masque désormais aussi les labs T (avant : seuls
  les points restaient visibles)
- LabsGroupingTest : 4 tests de regroupement (48 tests verts au total)
- versionCode 5, versionName 1.2.2 ; docs mises à jour
This commit is contained in:
Siphonight 2026-09-05 18:33:19 +02:00
parent 571e346b73
commit 4731e4c159
11 changed files with 356 additions and 120 deletions

View File

@ -9,7 +9,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.2.1 — build Android ✅, **44 tests unitaires** ✅ (2 régressions épinglées sur données réelles), intégration montre = notifications ✅, **repo git avec releases taguées** ✅
- **Statut** : v1.2.2 — build Android ✅, **48 tests unitaires** ✅ (2 régressions épinglées sur données réelles), intégration montre = notifications ✅, **repo git avec releases taguées** ✅
- **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)
@ -29,8 +29,9 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
- **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é
- **Analyses de sang** (E2, T, PRL…) tracées sur les courbes comme points de calibration,
**éditables** (tap sur une ligne dans Analyses) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
- **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées
**côte à côte** quand elles partagent la même date/heure, éditables (tap → choix de
l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester

View File

@ -12,8 +12,8 @@ android {
applicationId = "com.hormonetrack"
minSdk = 26
targetSdk = 36
versionCode = 4
versionName = "1.2.1"
versionCode = 5
versionName = "1.2.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@ -2,6 +2,7 @@ package com.hormonetrack.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
@ -9,6 +10,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -23,20 +25,25 @@ import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult
/** Valeur de labo saisie pour un marqueur (valeur + unité). */
data class LabValue(val value: Double, val unit: String)
/**
* Dialog create-OU-edit pour les résultats de labo (même mécanique que DoseDialog).
* Dialog de saisie de prise de sang.
*
* existing == null → création ;
* existing != null → édition : tout est pré-rempli (marqueur, valeur, unité,
* date/heure, notes) et la confirmation appelle onConfirmEdit avec la copie mise à
* jour (id et timestamp préservés sauf modification explicite).
* - **Création** (existing == null) : E2 et T **dans la même entrée**, chacune
* optionnelle (au moins une valeur requise), date/heure et notes partagées →
* insère 1 ou 2 LabResult avec le MÊME timestamp (l'écran Analyses les affiche
* alors côte à côte).
* - **Édition** (existing != null) : un seul marqueur, tout pré-rempli →
* onConfirmEdit avec la copie mise à jour.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabDialog(
existing: LabResult? = null,
onDismiss: () -> Unit,
onConfirmCreate: (marker: String, value: Double, unit: String, timestampMs: Long, notes: String) -> Unit,
onConfirmCreate: (e2: LabValue?, t: LabValue?, timestampMs: Long, notes: String) -> Unit,
onConfirmEdit: (LabResult) -> Unit
) {
val isEdit = existing != null
@ -50,6 +57,15 @@ fun LabDialog(
}
var unit by remember(existing) { mutableStateOf(existing?.unit ?: "pg/mL") }
var unitExpanded by remember { mutableStateOf(false) }
// Mode création : deux sections (E2 + T), chacune optionnelle
var e2Text by remember { mutableStateOf("") }
var e2Unit by remember { mutableStateOf("pg/mL") }
var e2UnitExpanded by remember { mutableStateOf(false) }
var tText by remember { mutableStateOf("") }
var tUnit by remember { mutableStateOf("ng/mL") }
var tUnitExpanded by remember { mutableStateOf(false) }
var dateTime by remember(existing) {
mutableStateOf(
existing?.let { localDateTimeFromMs(it.timestamp) } ?: java.time.LocalDateTime.now()
@ -60,9 +76,17 @@ fun LabDialog(
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
val v = valueText.replace(',', '.').toDoubleOrNull() ?: return@TextButton
TextButton(
enabled = if (isEdit) {
valueText.replace(',', '.').toDoubleOrNull() != null
} else {
e2Text.replace(',', '.').toDoubleOrNull() != null ||
tText.replace(',', '.').toDoubleOrNull() != null
},
onClick = {
if (isEdit && existing != null) {
val v = valueText.replace(',', '.').toDoubleOrNull()
?: return@TextButton
onConfirmEdit(
existing.copy(
marker = marker.trim().uppercase(),
@ -73,9 +97,17 @@ fun LabDialog(
)
)
} else {
onConfirmCreate(marker.trim().uppercase(), v, unit.trim(), msFromLocalDateTime(dateTime), notes)
val e2Val = e2Text.replace(',', '.').toDoubleOrNull()
val tVal = tText.replace(',', '.').toDoubleOrNull()
onConfirmCreate(
e2Val?.let { LabValue(it, e2Unit.trim()) },
tVal?.let { LabValue(it, tUnit.trim()) },
msFromLocalDateTime(dateTime),
notes
)
}
}) { Text(stringResource(R.string.save)) }
}
) { Text(stringResource(R.string.save)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
title = {
@ -83,6 +115,8 @@ fun LabDialog(
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (isEdit) {
// Édition : un seul marqueur (l'entrée existante)
ExposedDropdownMenuBox(expanded = markerExpanded, onExpandedChange = { markerExpanded = it }) {
OutlinedTextField(
value = marker,
@ -117,6 +151,52 @@ fun LabDialog(
}
}
}
} else {
// Création : E2 et T ensemble, chacune optionnelle
OutlinedTextField(
value = e2Text,
onValueChange = { e2Text = it },
label = { Text(stringResource(R.string.lab_e2_value)) },
supportingText = { Text(stringResource(R.string.lab_optional)) },
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenuBox(expanded = e2UnitExpanded, onExpandedChange = { e2UnitExpanded = it }) {
OutlinedTextField(
value = e2Unit,
onValueChange = { e2Unit = it },
label = { Text(stringResource(R.string.lab_e2_unit)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = e2UnitExpanded) },
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(expanded = e2UnitExpanded, onDismissRequest = { e2UnitExpanded = false }) {
unitSuggestions.forEach { s ->
DropdownMenuItem(text = { Text(s) }, onClick = { e2Unit = s; e2UnitExpanded = false })
}
}
}
HorizontalDivider()
OutlinedTextField(
value = tText,
onValueChange = { tText = it },
label = { Text(stringResource(R.string.lab_t_value)) },
supportingText = { Text(stringResource(R.string.lab_optional)) },
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenuBox(expanded = tUnitExpanded, onExpandedChange = { tUnitExpanded = it }) {
OutlinedTextField(
value = tUnit,
onValueChange = { tUnit = it },
label = { Text(stringResource(R.string.lab_t_unit)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = tUnitExpanded) },
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(expanded = tUnitExpanded, onDismissRequest = { tUnitExpanded = false }) {
unitSuggestions.forEach { s ->
DropdownMenuItem(text = { Text(s) }, onClick = { tUnit = s; tUnitExpanded = false })
}
}
}
}
DateTimeField(
label = stringResource(R.string.lab_date),
value = dateTime,

View File

@ -168,7 +168,10 @@ fun ChartScreen() {
allPoints.minOf { it.timestamp } to allPoints.maxOf { it.timestamp }
} else 0L to 0L
val e2Labs = labResults.filter { it.marker.equals("E2", true) && it.timestamp in a..b }
val tLabs = labResults.filter { it.marker.equals("T", true) && it.timestamp in a..b }
// Le toggle T masque la courbe T ET les labs T (cohérence d'affichage)
val tLabs = if (showT) {
labResults.filter { it.marker.equals("T", true) && it.timestamp in a..b }
} else emptyList()
// Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ;
// T Estrannaise = rose plein, T TFS = rose pointillé.

View File

@ -3,7 +3,6 @@ package com.hormonetrack.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -36,6 +35,7 @@ import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.LabDialog
import com.hormonetrack.ui.components.formatLabValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -43,6 +43,24 @@ import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Regroupe les labs par timestamp (une « prise de sang » = E2 + T prises au même
* moment). Ordre d'affichage dans un groupe : E2 avant T, autres marqueurs ensuite.
* Retour : liste (timestamp, labs du groupe) triée par date décroissante.
*/
fun groupLabsForDisplay(labs: List<LabResult>): List<Pair<Long, List<LabResult>>> =
labs.groupBy { it.timestamp }
.map { (ts, group) ->
ts to group.sortedBy { lab ->
when (lab.marker.uppercase()) {
"E2" -> 0
"T" -> 1
else -> 2
}
}
}
.sortedByDescending { it.first }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabsScreen() {
@ -52,11 +70,12 @@ fun LabsScreen() {
var showLabDialog by remember { mutableStateOf(false) }
var toEdit by remember { mutableStateOf<LabResult?>(null) }
var toDelete by remember { mutableStateOf<LabResult?>(null) }
// Groupe en cours de suppression (une prise de sang entière)
var toDeleteGroup by remember { mutableStateOf<List<LabResult>?>(null) }
// Choix du marqueur à éditer quand le groupe contient E2 + T
var chooserGroup by remember { mutableStateOf<List<LabResult>?>(null) }
val grouped = remember(labs) {
labs.sortedBy { it.marker.lowercase() }.groupBy { it.marker.uppercase() }
}
val grouped = remember(labs) { groupLabsForDisplay(labs) }
val fmt = remember {
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault())
}
@ -72,37 +91,34 @@ fun LabsScreen() {
TopAppBar(
title = { Text(stringResource(R.string.nav_labs)) }
)
if (labs.isEmpty()) {
if (grouped.isEmpty()) {
Text(
stringResource(R.string.no_data),
modifier = Modifier.padding(16.dp)
)
} else {
LazyColumn(Modifier.fillMaxSize()) {
grouped.forEach { (marker, results) ->
item(key = "header_$marker") {
Text(
marker,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
items(grouped, key = { it.first }) { (ts, group) ->
// Affichage côte à côte : « E2 306 pg/mL · T 45 ng/dL »
val headline = group.joinToString(" · ") { lab ->
"${lab.marker.uppercase()} ${formatLabValue(lab.value)} ${lab.unit}"
}
items(results, key = { it.id }) { lab ->
ListItem(
modifier = Modifier.clickable { toEdit = lab },
headlineContent = {
Text("${lab.value} ${lab.unit}")
modifier = Modifier.clickable {
if (group.size > 1) chooserGroup = group else toEdit = group.first()
},
headlineContent = { Text(headline) },
supportingContent = {
Column {
Text(fmt.format(Instant.ofEpochMilli(lab.timestamp)))
lab.notes?.takeIf { it.isNotBlank() }?.let {
Text(fmt.format(Instant.ofEpochMilli(ts)))
// Notes du groupe (toutes identiques en pratique)
group.firstNotNullOfOrNull { it.notes }?.takeIf { it.isNotBlank() }?.let {
Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
},
trailingContent = {
IconButton(onClick = { toDelete = lab }) {
IconButton(onClick = { toDeleteGroup = group }) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
}
}
@ -112,52 +128,91 @@ fun LabsScreen() {
}
}
}
}
if (showLabDialog || toEdit != null) {
if (showLabDialog) {
LabDialog(
existing = toEdit,
onDismiss = {
showLabDialog = false
toEdit = null
},
onConfirmCreate = { marker, value, unit, ts, notes ->
existing = null,
onDismiss = { showLabDialog = false },
onConfirmCreate = { e2, t, ts, notes ->
showLabDialog = false
CoroutineScope(Dispatchers.IO).launch {
e2?.let {
repo.insertLabResult(
LabResult(
marker = marker,
value = value,
unit = unit,
timestamp = ts,
notes = notes.ifBlank { null }
)
LabResult(marker = "E2", value = it.value, unit = it.unit, timestamp = ts, notes = notes.ifBlank { null })
)
}
t?.let {
repo.insertLabResult(
LabResult(marker = "T", value = it.value, unit = it.unit, timestamp = ts, notes = notes.ifBlank { null })
)
}
}
},
onConfirmEdit = { }
)
}
// Édition d'UNE entrée du groupe (choix E2 / T si paire)
toEdit?.let { lab ->
LabDialog(
existing = lab,
onDismiss = { toEdit = null },
onConfirmCreate = { _, _, _, _ -> },
onConfirmEdit = { updated ->
toEdit = null
CoroutineScope(Dispatchers.IO).launch {
repo.updateLabResult(updated)
CoroutineScope(Dispatchers.IO).launch { repo.updateLabResult(updated) }
}
)
}
// Sélecteur quand la prise contient E2 + T
chooserGroup?.let { group ->
AlertDialog(
onDismissRequest = { chooserGroup = null },
confirmButton = {},
dismissButton = {
TextButton(onClick = { chooserGroup = null }) { Text(stringResource(R.string.cancel)) }
},
title = { Text(stringResource(R.string.edit_which_lab)) },
text = {
Column {
group.forEach { lab ->
TextButton(onClick = {
chooserGroup = null
toEdit = lab
}) {
Text("${lab.marker.uppercase()} · ${formatLabValue(lab.value)} ${lab.unit}")
}
}
}
}
)
}
toDelete?.let { lab ->
// Suppression : la prise de sang ENTIÈRE (les labs sont pris ensemble)
toDeleteGroup?.let { group ->
AlertDialog(
onDismissRequest = { toDelete = null },
onDismissRequest = { toDeleteGroup = null },
confirmButton = {
TextButton(onClick = {
toDelete = null
CoroutineScope(Dispatchers.IO).launch { repo.deleteLabResult(lab) }
toDeleteGroup = null
CoroutineScope(Dispatchers.IO).launch {
group.forEach { repo.deleteLabResult(it) }
}
}) { Text(stringResource(R.string.delete)) }
},
dismissButton = {
TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) }
TextButton(onClick = { toDeleteGroup = null }) { Text(stringResource(R.string.cancel)) }
},
title = { Text(stringResource(R.string.delete)) },
text = { Text(stringResource(R.string.confirm_delete)) }
text = {
Text(
stringResource(
R.string.confirm_delete_blood_draw,
group.joinToString(" · ") { "${it.marker.uppercase()} ${formatLabValue(it.value)}" }
)
)
}
)
}
}

View File

@ -51,8 +51,15 @@
<string name="confirm_delete_treatment">Supprimer ce traitement ? Son historique de doses sera aussi supprimé.</string>
<!-- Labs -->
<string name="add_lab">Ajouter un résultat</string>
<string name="add_lab">Ajouter une prise de sang</string>
<string name="edit_lab">Modifier le résultat</string>
<string name="lab_e2_value">E2 — valeur œstradiol</string>
<string name="lab_e2_unit">Unité E2</string>
<string name="lab_t_value">T — valeur testostérone</string>
<string name="lab_t_unit">Unité T</string>
<string name="lab_optional">optionnel</string>
<string name="edit_which_lab">Quelle entrée veux-tu modifier ?</string>
<string name="confirm_delete_blood_draw">Supprimer cette prise de sang (%1$s) ?</string>
<string name="lab_marker">Marqueur (ex : E2, T)</string>
<string name="lab_value">Valeur</string>
<string name="lab_unit">Unité (ex : pg/mL, ng/mL)</string>

View File

@ -51,8 +51,15 @@
<string name="confirm_delete_treatment">Delete this treatment? Its dose history will also be deleted.</string>
<!-- Labs -->
<string name="add_lab">Add lab result</string>
<string name="add_lab">Add a blood test</string>
<string name="edit_lab">Edit lab result</string>
<string name="lab_e2_value">E2 — estradiol value</string>
<string name="lab_e2_unit">E2 unit</string>
<string name="lab_t_value">T — testosterone value</string>
<string name="lab_t_unit">T unit</string>
<string name="lab_optional">optional</string>
<string name="edit_which_lab">Which entry do you want to edit?</string>
<string name="confirm_delete_blood_draw">Delete this blood draw (%1$s)?</string>
<string name="lab_marker">Marker (e.g. E2, T)</string>
<string name="lab_value">Value</string>
<string name="lab_unit">Unit (e.g. pg/mL, ng/mL)</string>

View File

@ -0,0 +1,56 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.ui.screens.groupLabsForDisplay
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Regroupement de l'écran Analyses (v1.2.2) : une prise de sang (E2 + T au même
* timestamp) s'affiche côte à côte ; E2 avant T ; tri chronologique décroissant.
*/
class LabsGroupingTest {
private fun lab(marker: String, ts: Long, value: Double) =
LabResult(marker = marker, value = value, unit = if (marker == "E2") "pg/mL" else "ng/mL", timestamp = ts)
@Test
fun `E2 and T at the same timestamp are grouped into one blood draw`() {
val ts = 1_788_621_894_346L
val labs = listOf(lab("E2", ts, 306.0), lab("T", ts, 45.0))
val groups = groupLabsForDisplay(labs)
assertEquals(1, groups.size)
assertEquals(ts, groups[0].first)
assertEquals(listOf("E2", "T"), groups[0].second.map { it.marker.uppercase() })
}
@Test
fun `different timestamps stay separate rows`() {
val labs = listOf(
lab("E2", 1000L, 306.0),
lab("T", 2000L, 45.0)
)
val groups = groupLabsForDisplay(labs)
assertEquals(2, groups.size)
// Tri décroissant : le plus récent (2000) d'abord
assertEquals(2000L, groups[0].first)
assertEquals(1000L, groups[1].first)
}
@Test
fun `labs entered alone also group correctly and E2 sorts before T`() {
val ts = 5000L
val labs = listOf(lab("T", ts, 45.0), lab("E2", ts, 306.0))
val groups = groupLabsForDisplay(labs)
assertEquals(1, groups.size)
assertEquals(listOf("E2", "T"), groups[0].second.map { it.marker.uppercase() })
}
@Test
fun `other markers sort after E2 and T`() {
val ts = 6000L
val labs = listOf(lab("PRL", ts, 12.0), lab("T", ts, 45.0), lab("E2", ts, 306.0))
val groups = groupLabsForDisplay(labs)
assertEquals(listOf("E2", "T", "PRL"), groups[0].second.map { it.marker.uppercase() })
}
}

View File

@ -1,7 +1,22 @@
# 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.2.1`, …).
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.2.2`, …).
## [1.2.2] — 2026-09-05 (versionCode 5)
### Ajouté
- **Prise de sang E2 + T en UNE entrée** : le dialog d'ajout propose les deux
marqueurs (chacun **optionnel** — on peut ne saisir que E2 ou que T), avec
date/heure et notes partagées ; les entrées sont insérées au même timestamp et
s'affichent **côte à côte** dans l'écran Analyses (« E2 306 pg/mL · T 44 ng/dL »).
- **Tap sur une paire → sélecteur** (« Quelle entrée veux-tu modifier ? » E2 / T)
puis édition pré-remplie de l'entrée choisie.
- **Suppression par prise de sang** : la corbeille supprime la prise entière
(confirmation listant les valeurs).
- **Toggle T du graphique** : masque désormais aussi les **labs T** (avant : seule
la courbe T disparaissait, les points restaient).
- 4 tests de regroupement (48 au total).
## [1.2.1] — 2026-09-05 (versionCode 4)

View File

@ -297,7 +297,7 @@ Type de retour : `LevelPoint(timestamp, e2, t)`.
## 8. Tests unitaires
**44 tests JVM, tous verts** (`./gradlew testDebugUnitTest`). Dépendance : JUnit 4.13.2.
**48 tests JVM, tous verts** (`./gradlew testDebugUnitTest`). Dépendance : JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
`app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
@ -334,6 +334,9 @@ Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'ex
non calibré ≈ 367 = les « 375 » rapportés), facteur unique EEN plausible, lab T en
unité aberrante neutralisé, prévision 6 j exacte, auto-cal cohérente.
**Tout nouvel export utilisateur = un nouveau test de régression.**
- **`LabsGroupingTest`** (4) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T
même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ;
ordre chronologique décroissant).
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
@ -379,13 +382,18 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
superposées (E2 ESE bleu plein, E2 TFS turquoise, T ESE rose plein, T TFS rose
pointillé) ; **chip Prévision** (doses projetées via `generateForecastDoses`, horizon
= 2× le plus grand intervalle configuré, borné 7–30 j) ; **auto-calibration** branchée
sur les Paramètres ; légende dynamique ; labs T normalisés en ng/mL
sur les Paramètres ; légende dynamique ; labs T normalisés en ng/mL ; **toggle T =
masque aussi les labs T** (v1.2.2)
- `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition**
- `LabsScreen` : groupée par marqueur, FAB → `LabDialog` (création), **tap sur la ligne →
édition** (v1.2.1, même mécanique que les doses), suppression avec confirmation ;
suggestions d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/L
- `LabsScreen` (v1.2.2) : **prise de sang E2 + T en une entrée** (LabDialog create :
deux sections optionnelles → 1 ou 2 LabResult au même timestamp) ; affichage
**groupé par timestamp** (`groupLabsForDisplay` : E2 avant T, autres ensuite, tri
desc) → « E2 306 pg/mL · T 44 ng/dL » côte à côte ; tap → sélecteur E2/T si paire,
édition unitaire pré-remplie ; suppression = **la prise entière** (confirm nommant
les valeurs — choix de design : les labs sont prélevés ensemble) ; suggestions
d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/L
- `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰,
badge inactif), FAB → éditeur
- `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ;
@ -632,4 +640,4 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2)
---
*Doc mise à jour le 5 sept. 2026 (v1.2.1) — build OK, 44/44 tests verts, repo git avec tags v1.1.0/v1.2.0/v1.2.1.*
*Doc mise à jour le 5 sept. 2026 (v1.2.2) — build OK, 48/48 tests verts, repo git avec tags v1.1.0→v1.2.2.*

View File

@ -65,10 +65,14 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
- **Modifier une dose existante** : appuie simplement sur sa ligne dans l'écran Doses
(traitement, dose, date/heure, notes et ester tout ça éditable) — pas besoin de
supprimer/recréer
3. **Analyses → +** → entre tes prises de sang (E2 en pg/mL, T en ng/mL ou ng/dL)
- **Modifier un résultat** : appuie simplement sur sa ligne (même principe que les doses)
3. **Analyses → +** → une prise de sang complète en une entrée : **E2 et/ou T**
(chacune optionnelle), date/heure commune, notes — les deux s'affichent côte à côte
dans la liste
- **Modifier** : appuie sur la ligne ; si la prise contient E2 **et** T, un sélecteur
te demande laquelle modifier
- **Supprimer** : la corbeille retire la prise entière (E2 + T ensemble)
- Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la
calibration (ng/mL, ng/dL, ng/L, nmol/L)
calibration (E2 en pg/mL ; T en ng/mL, ng/dL, ng/L, nmol/L)
4. **Calibration** (dans l'édition d'un traitement E2) → « Calibrer avec les analyses »
→ calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods`
5. **Paramètres** :