v1.3.1 : en-tête « temps sous THS » sur Doses, logs de diagnostic exportables, fix toggle agenda (callback async)

- DosesScreen : carte « Sous THS depuis le … » (HrtDuration, mois = 30 j estimés)
- AppLog : journal horodaté buffer circulaire 500 lignes persisté
  filesDir/debug-log.txt ; export SAF + effacement dans Paramètres ;
  alimenté par agenda/permissions/import/export/erreurs
- FIX toggle agenda (bug remonté) : le résultat de permission arrive
  ASYNCHRONE — le test synchrone post-launch() était toujours faux → le
  callback active le switch + journalisation
- AppLogTest (4) + HrtDurationTest (5) → 87 tests verts
- versionCode 15, versionName 1.3.1
This commit is contained in:
Siphonight 2026-09-06 10:16:49 +02:00
parent d456cfa093
commit 00b6425a6f
14 changed files with 318 additions and 18 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 > à 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.3.0 — build Android ✅, **78 tests unitaires** ✅ (44 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.3.1 — build Android ✅, **87 tests unitaires** ✅ (44 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)
@ -56,6 +56,8 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health)
- **Nouveautés à chaque mise à jour** : dialog de changelog automatique - **Nouveautés à chaque mise à jour** : dialog de changelog automatique
(fermé = ne réapparaît pas avant la prochaine version) (fermé = ne réapparaît pas avant la prochaine version)
- **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise)
- **Logs de diagnostic** exportables (Paramètres) — utile pour le support
- **Événements d'agenda** : les rappels de prises peuvent créer un événement - **É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 récurrent (posologie) dans un calendrier « HormoneTrack » de ton téléphone
- **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T) - **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T)

View File

@ -6,6 +6,7 @@ import com.hormonetrack.data.AppDatabase
import com.hormonetrack.data.repository.HormoneRepository import com.hormonetrack.data.repository.HormoneRepository
import com.hormonetrack.pk.PKProfileStore import com.hormonetrack.pk.PKProfileStore
import com.hormonetrack.reminder.createNotificationChannel import com.hormonetrack.reminder.createNotificationChannel
import com.hormonetrack.util.AppLog
import com.hormonetrack.settings.AppSettings import com.hormonetrack.settings.AppSettings
class HormoneTrackApp : Application() { class HormoneTrackApp : Application() {
@ -18,6 +19,7 @@ class HormoneTrackApp : Application() {
container = AppContainer(this) container = AppContainer(this)
PKProfileStore.init(this) PKProfileStore.init(this)
createNotificationChannel(this) createNotificationChannel(this)
AppLog.init(this)
} }
} }

View File

@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -43,6 +44,7 @@ import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.DoseDialog import com.hormonetrack.ui.components.DoseDialog
import com.hormonetrack.util.HrtDuration
import com.hormonetrack.ui.components.formatDose import com.hormonetrack.ui.components.formatDose
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -100,6 +102,36 @@ fun DosesScreen() {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.nav_doses)) } title = { Text(stringResource(R.string.nav_doses)) }
) )
// En-tête « temps sous THS » : de la 1re prise enregistrée à maintenant
val earliest = remember(doseLogs) { doseLogs.minOfOrNull { it.timestamp } }
earliest?.let { first ->
Card(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Column(Modifier.padding(12.dp)) {
val dateStr = DateTimeFormatter.ofPattern("d MMMM yyyy")
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochMilli(first))
Text(
stringResource(R.string.hrt_since, dateStr),
style = MaterialTheme.typography.titleMedium
)
val totalDays = HrtDuration.daysSince(first, System.currentTimeMillis())
if (totalDays > 0) {
val (months, days) = HrtDuration.monthsAndDays(totalDays)
Text(
if (months > 0) stringResource(R.string.hrt_duration, months, days)
else stringResource(R.string.hrt_duration_days_only, days),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
if (doseLogs.isEmpty()) { if (doseLogs.isEmpty()) {
Text( Text(
stringResource(R.string.no_data), stringResource(R.string.no_data),

View File

@ -35,6 +35,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -46,6 +47,7 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.backup.BackupManager import com.hormonetrack.data.backup.BackupManager
import com.hormonetrack.util.AppLog
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig import com.hormonetrack.pk.TConfig
import com.hormonetrack.reminder.AlarmScheduler import com.hormonetrack.reminder.AlarmScheduler
@ -75,6 +77,21 @@ fun SettingsScreen(onBack: () -> Unit) {
var tKText by remember(tConfig.k) { mutableStateOf(formatDoubles(tConfig.k)) } var tKText by remember(tConfig.k) { mutableStateOf(formatDoubles(tConfig.k)) }
var showImportConfirm by remember { mutableStateOf(false) } var showImportConfirm by remember { mutableStateOf(false) }
var logLineCount by remember { mutableIntStateOf(AppLog.count()) }
val logExportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("text/plain")
) { uri ->
uri ?: return@rememberLauncherForActivityResult
CoroutineScope(Dispatchers.IO).launch {
try {
context.contentResolver.openOutputStream(uri, "wt")?.use { os ->
os.write(AppLog.all().toByteArray(Charsets.UTF_8))
os.flush()
}
withContext(Dispatchers.Main) { logLineCount = AppLog.count() }
} catch (_: Exception) { }
}
}
var importJson by remember { mutableStateOf<String?>(null) } var importJson by remember { mutableStateOf<String?>(null) }
var message by remember { mutableStateOf<String?>(null) } var message by remember { mutableStateOf<String?>(null) }
@ -322,6 +339,30 @@ fun SettingsScreen(onBack: () -> Unit) {
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
// --- Logs de diagnostic (v1.3.1) ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.logs_section), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.logs_count, logLineCount),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {
val date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmm"))
logExportLauncher.launch("hormonetrack-logs-$date.txt")
}) { Text(stringResource(R.string.logs_export)) }
TextButton(onClick = {
AppLog.clear()
logLineCount = AppLog.count()
}) { Text(stringResource(R.string.logs_clear)) }
}
}
}
Spacer(Modifier.height(12.dp))
// --- About : version installée + lien releases Gitea --- // --- About : version installée + lien releases Gitea ---
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {

View File

@ -56,6 +56,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import com.hormonetrack.reminder.AlarmScheduler import com.hormonetrack.reminder.AlarmScheduler
import com.hormonetrack.reminder.CalendarEvents import com.hormonetrack.reminder.CalendarEvents
import com.hormonetrack.util.AppLog
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.formatDose import com.hormonetrack.ui.components.formatDose
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -97,7 +98,13 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
var calendarPermissionGranted by remember { mutableStateOf(false) } var calendarPermissionGranted by remember { mutableStateOf(false) }
val calendarPermissionLauncher = rememberLauncherForActivityResult( val calendarPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() ActivityResultContracts.RequestMultiplePermissions()
) { grants -> calendarPermissionGranted = grants.values.all { it } } ) { grants ->
// ⚠️ FIX v1.3.1 : le résultat arrive ASYNCHRONE — c'est ICI (et pas
// dans le onCheckedChange synchrone) que le switch doit s'activer.
calendarPermissionGranted = grants.values.all { it }
AppLog.log("agenda", "permission agenda : accordée=$calendarPermissionGranted")
if (calendarPermissionGranted) calendarEnabled = true
}
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
var showPresetMenu by remember { mutableStateOf(false) } var showPresetMenu by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) } var showTimePicker by remember { mutableStateOf(false) }
@ -427,17 +434,18 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
Switch( Switch(
checked = calendarEnabled, checked = calendarEnabled,
onCheckedChange = { want -> onCheckedChange = { want ->
if (want && !calendarPermissionGranted) { if (want) {
calendarPermissionLauncher.launch( if (calendarPermissionGranted) {
arrayOf( calendarEnabled = true
android.Manifest.permission.WRITE_CALENDAR, } else {
android.Manifest.permission.READ_CALENDAR // le callback activera le switch si accordée
calendarPermissionLauncher.launch(
arrayOf(
android.Manifest.permission.WRITE_CALENDAR,
android.Manifest.permission.READ_CALENDAR
)
) )
) }
// activé seulement si la permission est accordée (callback)
if (calendarPermissionGranted) calendarEnabled = true
} else if (want) {
calendarEnabled = true
} else { } else {
calendarEnabled = false calendarEnabled = false
} }
@ -511,8 +519,11 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
if (eventToStore != saved.calendarEventId) { if (eventToStore != saved.calendarEventId) {
repo.updateTreatment(saved.copy(calendarEventId = eventToStore)) repo.updateTreatment(saved.copy(calendarEventId = eventToStore))
} }
} catch (_: SecurityException) { AppLog.log("agenda",
// permission agenda révoquée entre-temps → ignorer "traitement #${saved.id} : événement agenda " +
if (eventToStore != null) "créé/mis à jour (id=$eventToStore)" else "supprimé")
} catch (e: Exception) {
AppLog.log("agenda", "ERREUR événement agenda : ${e.message}")
} }
withContext(Dispatchers.Main) { onDone() } withContext(Dispatchers.Main) { onDone() }
} }

View File

@ -0,0 +1,68 @@
package com.hormonetrack.util
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Journal de diagnostic de l'app (v1.3.1) : ligne horodatée par événement
* notable (agenda, rappels, import/export, erreurs), persisté dans
* `filesDir/debug-log.txt`, buffer circulaire de MAX_LINES.
*
* Exportable depuis Paramètres (« Logs de diagnostic ») pour le debug à
* distance — un utilisateur peut joindre le fichier à un rapport de bug.
*
* Les parties PURES (formatLine, trimLines) sont testables en JVM sans
* Android ; l'objet lui-même ne fait que l'IO fichier mince.
*/
object AppLog {
const val MAX_LINES = 500
private val buffer = ArrayDeque<String>()
private var file: File? = null
private val lock = Any()
/** À appeler une fois dans Application.onCreate. */
fun init(context: android.content.Context) {
synchronized(lock) {
val f = File(context.filesDir, "debug-log.txt")
file = f
if (f.exists()) {
buffer.clear()
buffer.addAll(f.readLines().takeLast(MAX_LINES))
}
}
log("app", "démarrage (v init) — ${buffer.size} lignes de logs existantes")
}
fun log(tag: String, message: String) = synchronized(lock) {
val line = formatLine(System.currentTimeMillis(), tag, message)
buffer.addLast(line)
val trimmed = trimLines(buffer.toList(), MAX_LINES)
buffer.clear()
buffer.addAll(trimmed)
file?.appendText(line + "\n")
}
fun all(): String = synchronized(lock) { buffer.joinToString("\n") }
fun count(): Int = synchronized(lock) { buffer.size }
fun clear() = synchronized(lock) {
buffer.clear()
file?.writeText("")
log("app", "logs effacés")
}
/** Ligne horodatée : « 2026-09-06 12:34:56 [tag] message » (locale). */
fun formatLine(tsMs: Long, tag: String, message: String): String {
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
return "${fmt.format(Date(tsMs))} [$tag] $message"
}
/** Buffer circulaire : garde les MAX dernières lignes. */
fun trimLines(existing: List<String>, max: Int): List<String> =
if (existing.size <= max) existing else existing.takeLast(max)
}

View File

@ -0,0 +1,15 @@
package com.hormonetrack.util
/** Estimation du temps passé sous THS (v1.3.1). Mois = 30 jours (estimation). */
object HrtDuration {
const val DAY_MS = 86_400_000L
/** Jours entiers écoulés depuis la première prise (0 si futur/invalide). */
fun daysSince(earliestMs: Long, nowMs: Long): Long =
if (nowMs <= earliestMs) 0L else (nowMs - earliestMs) / DAY_MS
/** Décomposition mois(30 j) / jours restants. */
fun monthsAndDays(totalDays: Long): Pair<Long, Long> =
Pair(totalDays / 30, totalDays % 30)
}

View File

@ -33,6 +33,13 @@
<string name="show_extrema">Pics / creux</string> <string name="show_extrema">Pics / creux</string>
<string name="zoom_in">Zoom avant (fenêtre plus courte)</string> <string name="zoom_in">Zoom avant (fenêtre plus courte)</string>
<string name="zoom_out">Zoom arrière (fenêtre plus longue)</string> <string name="zoom_out">Zoom arrière (fenêtre plus longue)</string>
<string name="hrt_since">Sous THS depuis le %1$s</string>
<string name="hrt_duration">%1$d mois et %2$d jours (%3$d jours au total)</string>
<string name="hrt_duration_days_only">%1$d jours</string>
<string name="logs_section">Logs de diagnostic</string>
<string name="logs_count">%1$d lignes (rappels, agenda, import/export, erreurs)</string>
<string name="logs_export">Exporter</string>
<string name="logs_clear">Effacer</string>
<string name="changelog_title">Nouveautés de la version %1$s</string> <string name="changelog_title">Nouveautés de la version %1$s</string>
<string name="settings_version">Version installée : %1$s</string> <string name="settings_version">Version installée : %1$s</string>
<string name="releases_link">Dernières versions sur Gitea (télécharger les APK)</string> <string name="releases_link">Dernières versions sur Gitea (télécharger les APK)</string>

View File

@ -33,6 +33,13 @@
<string name="show_extrema">Peaks / troughs</string> <string name="show_extrema">Peaks / troughs</string>
<string name="zoom_in">Zoom in (shorter window)</string> <string name="zoom_in">Zoom in (shorter window)</string>
<string name="zoom_out">Zoom out (longer window)</string> <string name="zoom_out">Zoom out (longer window)</string>
<string name="hrt_since">Under HRT since %1$s</string>
<string name="hrt_duration">%1$d months and %2$d days (%3$d days total)</string>
<string name="hrt_duration_days_only">%1$d days</string>
<string name="logs_section">Diagnostic logs</string>
<string name="logs_count">%1$d lines (reminders, calendar, import/export, errors)</string>
<string name="logs_export">Export</string>
<string name="logs_clear">Clear</string>
<string name="changelog_title">What\'s new in %1$s</string> <string name="changelog_title">What\'s new in %1$s</string>
<string name="settings_version">Installed version: %1$s</string> <string name="settings_version">Installed version: %1$s</string>
<string name="releases_link">Latest releases on Gitea (download APKs)</string> <string name="releases_link">Latest releases on Gitea (download APKs)</string>

View File

@ -0,0 +1,37 @@
package com.hormonetrack.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** Formatage et buffer circulaire des logs de diagnostic (v1.3.1). */
class AppLogTest {
@Test
fun `ligne horodatée au format attendu`() {
val line = AppLog.formatLine(1_788_748_800_000L, "agenda", "événement créé")
assertTrue(line.contains("[agenda]"))
assertTrue(line.contains("événement créé"))
assertTrue(Regex("^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} \\[agenda\\] événement créé$").matches(line))
}
@Test
fun `trim garde les MAX dernières lignes`() {
val existing = (1..600).map { "ligne $it" }
val trimmed = AppLog.trimLines(existing, 500)
assertEquals(500, trimmed.size)
assertEquals("ligne 101", trimmed.first())
assertEquals("ligne 600", trimmed.last())
}
@Test
fun `trim ne fait rien si sous la limite`() {
val existing = (1..100).map { "ligne $it" }
assertEquals(100, AppLog.trimLines(existing, 500).size)
}
@Test
fun `trim liste vide`() {
assertEquals(0, AppLog.trimLines(emptyList(), 500).size)
}
}

View File

@ -0,0 +1,38 @@
package com.hormonetrack.util
import org.junit.Assert.assertEquals
import org.junit.Test
/** Estimation du temps sous THS : 1re prise → maintenant, mois = 30 j. */
class HrtDurationTest {
private val day = HrtDuration.DAY_MS
@Test
fun `même jour - zéro jour`() {
assertEquals(0L, HrtDuration.daysSince(1000L, 1000L))
assertEquals(0L, HrtDuration.daysSince(2000L, 1000L))
}
@Test
fun `un jour exact`() {
assertEquals(1L, HrtDuration.daysSince(1000L, 1000L + day))
}
@Test
fun `365 jours - un an`() {
assertEquals(365L, HrtDuration.daysSince(0L, 365 * day))
}
@Test
fun `décomposition mois et jours - 400 jours = 13 mois et 10 jours`() {
val (months, days) = HrtDuration.monthsAndDays(400L)
assertEquals(13L, months)
assertEquals(10L, days)
}
@Test
fun `décomposition - zéro jour`() {
assertEquals(Pair(0L, 0L), HrtDuration.monthsAndDays(0L))
}
}

View File

@ -1,7 +1,27 @@
# 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.3.0`, …). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.3.1`, …).
## [1.3.1] — 2026-09-06 (versionCode 15)
### Ajouté
- **En-tête « temps sous THS »** sur la page Doses : depuis la 1re prise
enregistrée, en jours totaux et décomposition mois/jours (mois = 30 j,
estimation documentée).
- **Logs de diagnostic** (v1.3.1) : journal horodaté des événements notables
(agenda, rappels, import/export, erreurs), buffer circulaire de 500 lignes,
persisté dans `filesDir/debug-log.txt` ; export (SAF) et effacement depuis
Paramètres. Objectif : debug à distance via les logs joints aux retours.
- 9 nouveaux tests (87 au total) : AppLog (formatage, buffer circulaire) et
HrtDuration (jours, décomposition mois/jours).
### Corrigé
- **Toggle de l'agenda ne s'activait pas** (bug remonté) : le résultat de la
permission arrive ASYNCHRONE — le test synchrone juste après `launch()`
était toujours faux → le switch rebondissait sans s'activer et sans
feedback. FIX : le callback active le switch ; feedback visible (hint) et
journalisation (AppLog).
## [1.3.0] — 2026-09-06 (versionCode 14) ## [1.3.0] — 2026-09-06 (versionCode 14)

View File

@ -76,6 +76,7 @@ Fonctionnalités v1 :
| 5 sept. 2026 (session v1.2.1) | Remontées UX/utilisateur : calibration **par période d'ester** (labs EV → doses EV, labs EEn → doses EEn — corrige les courbes gonflées), lab T "pg/mL" neutralisé, **édition des labs**, tap accueil → Graphiques + mini-légende, auto-cal fusionnée (E2+T, un seul switch) ; 2ᵉ régression épinglée sur le nouvel export ; **44 tests verts**, APK v1.2.1. | | 5 sept. 2026 (session v1.2.1) | Remontées UX/utilisateur : calibration **par période d'ester** (labs EV → doses EV, labs EEn → doses EEn — corrige les courbes gonflées), lab T "pg/mL" neutralisé, **édition des labs**, tap accueil → Graphiques + mini-légende, auto-cal fusionnée (E2+T, un seul switch) ; 2ᵉ régression épinglée sur le nouvel export ; **44 tests verts**, APK v1.2.1. |
| 5 sept. 2026 (session v1.2.2) | Toggle T masque aussi les labs T ; **prise de sang E2 + T en une entrée** (chacune optionnelle) affichée **côte à côte** ; sélecteur d'édition par marqueur ; suppression par prise ; 4 tests de regroupement ; **48 tests verts**, APK v1.2.2. | | 5 sept. 2026 (session v1.2.2) | Toggle T masque aussi les labs T ; **prise de sang E2 + T en une entrée** (chacune optionnelle) affichée **côte à côte** ; sélecteur d'édition par marqueur ; suppression par prise ; 4 tests de regroupement ; **48 tests verts**, APK v1.2.2. |
| 5 sept. 2026 (session v1.2.6) | Valeurs estimées affichées sur les pics/creux (drawExtremum) ; **import JSON en mode écrasement** (bug : conflit d'IDs si données existantes) avec restauration du tConfig et reprogrammation des rappels ; docs ; 62 tests verts, APK v1.2.6 + releases. | | 5 sept. 2026 (session v1.2.6) | Valeurs estimées affichées sur les pics/creux (drawExtremum) ; **import JSON en mode écrasement** (bug : conflit d'IDs si données existantes) avec restauration du tConfig et reprogrammation des rappels ; docs ; 62 tests verts, APK v1.2.6 + releases. |
| 6 sept. 2026 (session v1.3.1) | En-tête « temps sous THS » sur Doses ; **logs de diagnostic** (AppLog : buffer 500 lignes, persisté, exportable de Paramètres) ; **fix toggle agenda** (callback async) ; **87 tests verts**, APK v1.3.1 + releases. |
| 6 sept. 2026 (session farewell) | Repo créé côté farewell → **push SSH (alias `farewell` : giteassh:2222) + 12 releases publiées avec APK vérifiés par téléchargement** ; piège lexicographique `v1.2.10 < v1.2.5` en comparaison de chaînes épinglé (§14 #38) ; les deux instances Gitea sont synchrones. | | 6 sept. 2026 (session farewell) | Repo créé côté farewell → **push SSH (alias `farewell` : giteassh:2222) + 12 releases publiées avec APK vérifiés par téléchargement** ; piège lexicographique `v1.2.10 < v1.2.5` en comparaison de chaînes épinglé (§14 #38) ; les deux instances Gitea sont synchrones. |
| 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. |
@ -348,7 +349,7 @@ Type de retour : `LevelPoint(timestamp, e2, t)`.
## 8. Tests unitaires ## 8. Tests unitaires
**78 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — 44 sans les **87 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — 44 sans les
données de test locales (cf §8.bis). Dépendance : JUnit 4.13.2. données de test locales (cf §8.bis). 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 =
`app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`). `app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
@ -424,6 +425,10 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
(1.2.9 < 1.2.10 : la comparaison lexicographique serait fausse), extraction (1.2.9 < 1.2.10 : la comparaison lexicographique serait fausse), extraction
de sections depuis la dernière vue, première installation (section courante de sections depuis la dernière vue, première installation (section courante
seule), plusieurs versions intermédiaires, versions non numériques. 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
futur/invalide), décomposition mois(30 j)/jours.
- **`CalendarRruleTest`** (3) : v1.3.0 — RRULE `FREQ=DAILY;INTERVAL=N`, - **`CalendarRruleTest`** (3) : v1.3.0 — RRULE `FREQ=DAILY;INTERVAL=N`,
arrondi demi-supérieur des décimales (6,5 → 7), clamp ≥ 1 jour. arrondi demi-supérieur des décimales (6,5 → 7), clamp ≥ 1 jour.
- **`ExtremaTest`** (6) : v1.2.3 — détection des pics/creux (`detectExtrema`) : - **`ExtremaTest`** (6) : v1.2.3 — détection des pics/creux (`detectExtrema`) :
@ -493,7 +498,9 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
0,02 ng/mL) 0,02 ng/mL)
- `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même - `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition** avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition** ;
**en-tête « temps sous THS »** (v1.3.1) : carte depuis la 1re prise, jours
totaux + décomposition mois/jours (`HrtDuration`, mois = 30 j estimés)
- `LabsScreen` (v1.2.2) : **prise de sang E2 + T en une entrée** (LabDialog create : - `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 deux sections optionnelles → 1 ou 2 LabResult au même timestamp) ; affichage
**groupé par timestamp** (`groupLabsForDisplay` : E2 avant T, autres ensuite, tri **groupé par timestamp** (`groupLabsForDisplay` : E2 avant T, autres ensuite, tri
@ -509,6 +516,11 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
switch « Simuler les doses à venir » + intervalle en jours)** ; carte Rappel (switch + switch « Simuler les doses à venir » + intervalle en jours)** ; carte Rappel (switch +
TimePicker **centré** + avertissement alarmes exactes) ; switch actif ; save → TimePicker **centré** + avertissement alarmes exactes) ; switch actif ; save →
insert/update + schedule/cancel ; delete avec confirmation ; `createdAt` préservé insert/update + schedule/cancel ; delete avec confirmation ; `createdAt` préservé
- **Logs de diagnostic (v1.3.1, `util/AppLog.kt`)** : journal horodaté
(buffer circulaire 500 lignes, persisté `filesDir/debug-log.txt`),
alimenté par l'agenda (permissions, upsert/delete), l'import/export et les
erreurs ; **export (SAF) et effacement** dans Paramètres — permet le debug
à distance (l'utilisateur joint les logs à son retour).
- **Dialog « Nouveautés » (v1.3.0)** : au démarrage, si `BuildConfig.VERSION_NAME` - **Dialog « Nouveautés » (v1.3.0)** : au démarrage, si `BuildConfig.VERSION_NAME`
est plus récente que la dernière vue (DataStore `changelog_seen_version`), est plus récente que la dernière vue (DataStore `changelog_seen_version`),
un AlertDialog affiche les sections CHANGELOG concernées (extraites par un AlertDialog affiche les sections CHANGELOG concernées (extraites par
@ -694,6 +706,11 @@ Historique complet — **à ne pas réintroduire** (utile pour diff/revert) :
PATCH de renommage peuvent être ignorés (asset au nom générique, APK PATCH de renommage peuvent être ignorés (asset au nom générique, APK
release disparu de la release). → `ensure_asset` dans le script : release disparu de la release). → `ensure_asset` dans le script :
upload + vérification nom/taille + retry PATCH + échec bruyant. upload + vérification nom/taille + retry PATCH + échec bruyant.
39. **Toggle de l'agenda inopérant** (v1.3.1, remontée) : le résultat de la
permission arrive ASYNCHRONE — le test synchrone juste après `launch()`
était toujours faux → le switch rebondissait sans s'activer, sans
feedback. → le CALLBACK active le switch ; journalisation AppLog ;
leçon : ne jamais lire un état de permission juste après launch().
38. **Comparaison lexicographique de tags/versions** (v1.3.0, publication 38. **Comparaison lexicographique de tags/versions** (v1.3.0, publication
farewell) : `tag >= "v1.2.5"` en comparaison de CHAÎNES fait `v1.2.10 < farewell) : `tag >= "v1.2.5"` en comparaison de CHAÎNES fait `v1.2.10 <
v1.2.5` ("1" < "5") → la boucle de publication a sauté v1.2.10. → v1.2.5` ("1" < "5") → la boucle de publication a sauté v1.2.10. →
@ -1007,4 +1024,4 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
sur le graphique** et la calibration couvre toujours ses périodes sur le graphique** et la calibration couvre toujours ses périodes
--- ---
*Doc mise à jour le 6 sept. 2026 (v1.3.0) — build OK, 62/62 tests verts (44 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.* *Doc mise à jour le 6 sept. 2026 (v1.3.1) — build OK, 62/62 tests verts (44 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.*

View File

@ -86,6 +86,9 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
passe-le **inactif** (switch « Actif ») : il disparaît de la saisie et de tes passe-le **inactif** (switch « Actif ») : il disparaît de la saisie et de tes
rappels, mais **son historique reste simulé et calibré** sur les graphiques — rappels, mais **son historique reste simulé et calibré** sur les graphiques —
parfait pour une transition valerate → enanthate parfait pour une transition valerate → enanthate
- **Agenda** : active « Événement d'agenda récurrent » → un événement se
répétant tous les N jours (Posologie) apparaît dans le calendrier
« HormoneTrack » de ton téléphone
- Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods` - 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) - Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo)
2. **Doses → +** → logue tes injections passées 2. **Doses → +** → logue tes injections passées