From 766e6498a75e24b826e3bf39bd0d57a9ed56d2ac Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 11 Sep 2026 19:04:00 +0200 Subject: [PATCH] feat(stats): add global training dashboard --- .../trainlog/data/StatisticsModels.kt | 35 + .../trainlog/data/TrainlogRepository.kt | 179 ++ .../trainlog/data/TrainlogTimestamp.kt | 19 +- .../labfytools/trainlog/ui/SectionScreens.kt | 11 - .../trainlog/ui/StatisticsScreen.kt | 292 ++++ .../com/labfytools/trainlog/ui/TrainlogApp.kt | 2 +- .../trainlog/data/StatisticsRepositoryTest.kt | 334 ++++ .../trainlog/data/TrainlogTimestampTest.kt | 10 + .../ui/StatisticsScreenPresentationTest.kt | 107 ++ docs/android.md | 34 +- docs/current_state.md | 17 +- docs/reviews/stats_v1_design.md | 17 +- docs/roadmap.md | 24 +- docs/tests.md | 24 +- docs/tui.md | 88 +- tools/generate_exercise_name_catalog.py | 63 + tui/include/trainlog/database.h | 3 + tui/include/trainlog/exercise_name_catalog.h | 17 + tui/meson.build | 8 + tui/src/database.c | 21 +- tui/src/database_internal.h | 14 + tui/src/measured_max.c | 15 +- tui/src/timestamp.c | 3 +- tui/src/timestamp.h | 1 + tui/src/tui.c | 1538 ++++++++++++++++- tui/tests/test_exercise_performance.c | 32 +- tui/tests/test_max_results.c | 69 +- tui/tests/test_session_type_schema.c | 25 + tui/tests/test_tui_workflows.c | 1115 +++++++++++- 29 files changed, 4005 insertions(+), 112 deletions(-) create mode 100644 android/app/src/main/java/com/labfytools/trainlog/data/StatisticsModels.kt create mode 100644 android/app/src/main/java/com/labfytools/trainlog/ui/StatisticsScreen.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/StatisticsRepositoryTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/ui/StatisticsScreenPresentationTest.kt create mode 100644 tools/generate_exercise_name_catalog.py create mode 100644 tui/include/trainlog/exercise_name_catalog.h create mode 100644 tui/src/database_internal.h diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/StatisticsModels.kt b/android/app/src/main/java/com/labfytools/trainlog/data/StatisticsModels.kt new file mode 100644 index 0000000..6358366 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/StatisticsModels.kt @@ -0,0 +1,35 @@ +package com.labfytools.trainlog.data + +/** STATS_V1 is a read-only projection: none of these values are persisted as facts. */ +enum class StatisticsPeriod(val days: Long?, val label: String) { + DAYS_7(7, "7 j"), DAYS_30(30, "30 j"), DAYS_90(90, "90 j"), YEAR(365, "1 an"), ALL(null, "Tout"), +} + +data class StatisticsPoint(val timestamp: String, val value: Double, val label: String = "") +data class StatisticsSeries(val id: String, val label: String, val unit: String, val points: List) +data class StatisticsFrequency(val week: String, val all: Int, val maxima: Int) +data class StatisticsPerformanceWeek( + val week: String, + val workingImprovements: Int, + val maxImprovements: Int, +) +data class StatisticsSummary( + val sessions: Int, + val performedSets: Int, + val distinctExercises: Int, + val explicitMaxima: Int, +) +data class StatisticsOverview( + val period: StatisticsPeriod, + /** True when malformed legacy rows were conservatively omitted. */ + val hasInvalidData: Boolean, + val summary: StatisticsSummary, + /** Weekly event counts for the landing graph; never summed kilograms. */ + val performanceEvents: List, + /** Comparable, context-owned values retained for exercise drill-downs. */ + val performance: List, + val body: List, + val frequency: List, + val sessionsLast7Days: Int, + val sessionsLast30Days: Int, +) diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index ccf5d33..54e7dc6 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -32,10 +32,28 @@ import org.json.JSONArray import org.json.JSONObject import java.text.Normalizer import java.io.StringReader +import java.time.LocalDate import java.time.OffsetDateTime +import java.time.temporal.WeekFields import java.util.Locale import java.util.UUID +/* WHY: ended_at is absent on valid imported/history rows and present lifecycle + * metadata cannot prove actual work. CONTRACT: observable history requires at + * least one occurrence-owned performed set, continuous activity, or explicit + * MAX. INVARIANT: plans and targets alone never satisfy this predicate. */ +private const val ACTUAL_SESSION_PREDICATE = """ + EXISTS (SELECT 1 FROM session_exercises AS actual_se + JOIN performed_sets AS ps ON ps.session_exercise_row_id=actual_se.id + WHERE actual_se.session_row_id=s.id) + OR EXISTS (SELECT 1 FROM session_exercises AS actual_se + JOIN continuous_activity AS ca ON ca.session_exercise_row_id=actual_se.id + WHERE actual_se.session_row_id=s.id) + OR EXISTS (SELECT 1 FROM session_exercises AS actual_se + JOIN max_results AS mr ON mr.session_exercise_row_id=actual_se.id + WHERE actual_se.session_row_id=s.id) +""" + sealed interface CreateExerciseResult { data class Created( val exercise: ExerciseProfile, @@ -291,6 +309,166 @@ class TrainlogRepository( database.close() } + /** + * WHY: dashboard comparisons must remain conservative. CONTRACT: rows are + * actual observable-history facts only; external working loads are grouped + * by canonical exercise and equipment, assistance is deliberately omitted + * from kg trends, and missing metrics create gaps rather than zeroes. + * Merges repoint occurrences transactionally, so the stored exercise row + * is already the canonical identity at this read boundary. + */ + fun loadStatistics(period: StatisticsPeriod): StatisticsOverview = + loadStatistics(period, checkNotNull(TrainlogTimestamp.parse(OffsetDateTime.now().toString()))) + + /** Visible to repository tests so exact inclusive boundaries do not depend on wall time. */ + internal fun loadStatistics(period: StatisticsPeriod, now: TrainlogTimestampKey): StatisticsOverview { + val db = database.readableDatabase + val cutoff = period.days?.let { TrainlogTimestampKey(now.utcSecond - it * 86400L, now.fraction) } + var hasInvalidData = false + fun timestamp(value: String): TrainlogTimestampKey? = + TrainlogTimestamp.parse(value).also { if (it == null) hasInvalidData = true } + fun included(key: TrainlogTimestampKey): Boolean = key <= now && (cutoff == null || key >= cutoff) + fun query(sql: String, block: (android.database.Cursor) -> Unit) = + db.rawQuery(sql, null).use { cursor -> while (cursor.moveToNext()) block(cursor) } + fun week(key: TrainlogTimestampKey): String { + val date = LocalDate.of(key.localYear, key.localMonth, key.localDay) + val fields = WeekFields.ISO + return "%04d-W%02d".format( + Locale.ROOT, date.get(fields.weekBasedYear()), date.get(fields.weekOfWeekBasedYear()), + ) + } + data class PointRow( + val key: TrainlogTimestampKey, + val stableId: String, + val order: Int, + val point: StatisticsPoint, + val dose: String, + ) + val performanceRows = linkedMapOf>() + val names = linkedMapOf() + query("""SELECT s.started_at,e.exercise_id,e.name,eq.equipment_id,eq.display_name,ps.weight_kg,se.entry_id,ps.position,ps.reps,ps.duration_seconds + FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id JOIN exercises e ON e.id=se.exercise_row_id + JOIN performed_sets ps ON ps.session_exercise_row_id=se.id JOIN equipment eq ON eq.id=se.equipment_row_id + WHERE se.load_mode='external' AND eq.load_semantics='external' AND eq.equipment_id <> '' AND ps.weight_kg IS NOT NULL + AND (ps.reps>0 OR ps.duration_seconds>0)""".trimIndent()) { c -> + val time = timestamp(c.getString(0)) ?: return@query + val value = c.getDouble(5) + if (value.isFinite() && value >= 0.0) { + val dose = if (!c.isNull(8)) "reps:${c.getInt(8)}" else "duration:${c.getInt(9)}" + val key = "work:${c.getString(1)}:${c.getString(3)}:$dose" + val exerciseName = canonicalExerciseNames[c.getString(1)] ?: c.getString(2) + names[key] = "$exerciseName · ${c.getString(4)} · ${dose.substringAfter(':')} ${dose.substringBefore(':')} — charges réalisées" + performanceRows.getOrPut(key) { mutableListOf() } += + PointRow(time, c.getString(6), c.getInt(7), StatisticsPoint(c.getString(0), value), dose) + } + } + query("""SELECT s.started_at,e.exercise_id,e.name,eq.equipment_id,eq.display_name,mr.max_weight_kg,se.entry_id + FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id JOIN exercises e ON e.id=se.exercise_row_id + JOIN max_results mr ON mr.session_exercise_row_id=se.id JOIN equipment eq ON eq.id=se.equipment_row_id + WHERE se.load_mode!='assistance' AND eq.load_semantics='external' AND eq.equipment_id <> ''""".trimIndent()) { c -> + val time = timestamp(c.getString(0)) ?: return@query + val value = c.getDouble(5) + if (value.isFinite() && value > 0.0) { + val key = "max:${c.getString(1)}:${c.getString(3)}" + val exerciseName = canonicalExerciseNames[c.getString(1)] ?: c.getString(2) + names[key] = "$exerciseName · ${c.getString(4)} — MAX explicite" + performanceRows.getOrPut(key) { mutableListOf() } += + PointRow(time, c.getString(6), 0, StatisticsPoint(c.getString(0), value), "max") + } + } + val performance = mutableListOf() + data class EventCount(var working: Int = 0, var maxima: Int = 0) + val eventCounts = sortedMapOf() + performanceRows.forEach { (context, unsorted) -> + val rows = unsorted.sortedWith( + compareBy { it.key }.thenBy { it.stableId }.thenBy { it.order }, + ) + val visible = rows.filter { included(it.key) } + if (visible.isNotEmpty()) performance += StatisticsSeries( + context, names.getValue(context), "kg", visible.map { it.point }, + ) + /* CONTRACT: an improvement is a strictly later record above the + * prior best in one exact context. Stable IDs only order display; + * equal canonical instants never establish or create an event. */ + rows.groupBy { it.dose }.values.forEach { comparable -> + comparable.forEachIndexed { index, row -> + val priorBest = comparable.asSequence().take(index) + .filter { it.key < row.key }.maxOfOrNull { it.point.value } + if (priorBest != null && row.point.value > priorBest && included(row.key)) { + eventCounts.getOrPut(week(row.key)) { EventCount() }.also { + if (context.startsWith("max:")) it.maxima++ else it.working++ + } + } + } + } + } + val body = mutableListOf() + val bodyMetrics = listOf("body_weight_kg" to "Poids" to "kg", "neck_cm" to "Cou" to "cm", "shoulders_cm" to "Épaules" to "cm", "chest_cm" to "Poitrine" to "cm", "waist_cm" to "Tour de taille" to "cm", "hips_cm" to "Hanches" to "cm", "left_arm_cm" to "Bras gauche" to "cm", "right_arm_cm" to "Bras droit" to "cm", "left_forearm_cm" to "Avant-bras gauche" to "cm", "right_forearm_cm" to "Avant-bras droit" to "cm", "left_thigh_cm" to "Cuisse gauche" to "cm", "right_thigh_cm" to "Cuisse droite" to "cm", "left_calf_cm" to "Mollet gauche" to "cm", "right_calf_cm" to "Mollet droit" to "cm") + bodyMetrics.forEach { triple -> + val column = triple.first.first; val label = triple.first.second; val unit = triple.second + val points = mutableListOf>() + db.rawQuery("SELECT observed_at,observation_id,$column FROM body_observations WHERE $column IS NOT NULL", null).use { c -> while (c.moveToNext()) { + val time = timestamp(c.getString(0)) ?: continue + if (included(time)) points += Triple(time, c.getString(1), StatisticsPoint(c.getString(0), c.getDouble(2))) + } } + points.sortWith(compareBy> { it.first }.thenBy { it.second }) + if (points.isNotEmpty()) body += StatisticsSeries( + column, label, unit, points.takeLast(64).map { it.third }, + ) + } + data class FrequencyCount(var all: Int = 0, var maxima: Int = 0) + val frequency = sortedMapOf() + val sessionTimes = mutableListOf>() + query("SELECT started_at,session_type FROM sessions AS s WHERE $ACTUAL_SESSION_PREDICATE") { c -> + val time = timestamp(c.getString(0)) ?: return@query + sessionTimes += time to c.getString(1) + if (included(time)) { + /* Calendar grouping follows the timestamp's represented local + * date. Rolling windows and ordering continue to use its exact + * canonical instant key above. */ + frequency.getOrPut(week(time)) { FrequencyCount() }.also { it.all++; if (c.getString(1) == "max_test") it.maxima++ } + } + } + val freq = frequency.map { StatisticsFrequency(it.key, it.value.all, it.value.maxima) } + fun count(days: Long): Int { + val boundary = TrainlogTimestampKey(now.utcSecond - days * 86400L, now.fraction) + return sessionTimes.count { it.first in boundary..now } + } + var performedSetCount = 0 + var explicitMaxCount = 0 + val actualExercises = mutableSetOf() + query("""SELECT s.started_at,e.exercise_id, + (SELECT COUNT(*) FROM performed_sets ps WHERE ps.session_exercise_row_id=se.id), + (SELECT COUNT(*) FROM max_results mr WHERE mr.session_exercise_row_id=se.id), + EXISTS(SELECT 1 FROM continuous_activity ca WHERE ca.session_exercise_row_id=se.id) + FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id + JOIN exercises e ON e.id=se.exercise_row_id + WHERE EXISTS(SELECT 1 FROM performed_sets ps WHERE ps.session_exercise_row_id=se.id) + OR EXISTS(SELECT 1 FROM max_results mr WHERE mr.session_exercise_row_id=se.id) + OR EXISTS(SELECT 1 FROM continuous_activity ca WHERE ca.session_exercise_row_id=se.id)""".trimIndent()) { c -> + val time = timestamp(c.getString(0)) ?: return@query + if (included(time)) { + actualExercises += c.getString(1) + performedSetCount += c.getInt(2) + explicitMaxCount += c.getInt(3) + } + } + val selectedSessions = sessionTimes.count { included(it.first) } + return StatisticsOverview( + period = period, + hasInvalidData = hasInvalidData, + summary = StatisticsSummary(selectedSessions, performedSetCount, actualExercises.size, explicitMaxCount), + performanceEvents = eventCounts.map { StatisticsPerformanceWeek(it.key, it.value.working, it.value.maxima) }, + performance = performance, + body = body.sortedWith(compareByDescending { + checkNotNull(TrainlogTimestamp.parse(it.points.last().timestamp)) + }.thenBy { if (it.id == "body_weight_kg") 0 else 1 }.thenBy { it.id }).take(6), + frequency = freq, + sessionsLast7Days = count(7), + sessionsLast30Days = count(30), + ) + } + fun getExerciseKnowledge(exerciseId: String): ExerciseKnowledge? = trainingKnowledge.getExerciseKnowledge(exerciseId) @@ -1379,6 +1557,7 @@ class TrainlogRepository( FROM sessions AS s LEFT JOIN session_exercises AS se ON se.session_row_id = s.id + WHERE $ACTUAL_SESSION_PREDICATE GROUP BY s.id ORDER BY s.started_at DESC, s.id DESC; """.trimIndent(), diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogTimestamp.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogTimestamp.kt index 7de40c6..1d7674e 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogTimestamp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogTimestamp.kt @@ -3,7 +3,18 @@ package com.labfytools.trainlog.data internal data class TrainlogTimestampKey( val utcSecond: Long, val fraction: String, + val localYear: Int = 1, + val localMonth: Int = 1, + val localDay: Int = 1, ) : Comparable { + /* Calendar metadata is deliberately excluded: key identity remains the + * same canonical-instant representation used before local grouping was + * added. */ + override fun equals(other: Any?): Boolean = + other is TrainlogTimestampKey && utcSecond == other.utcSecond && fraction == other.fraction + + override fun hashCode(): Int = 31 * utcSecond.hashCode() + fraction.hashCode() + override fun compareTo(other: TrainlogTimestampKey): Int { utcSecond.compareTo(other.utcSecond).takeIf { it != 0 }?.let { return it } val count = maxOf(fraction.length, other.fraction.length) @@ -64,7 +75,13 @@ internal object TrainlogTimestamp { } val localSecond = dayNumber(year, month, day) * 86400L + hour * 3600L + minute * 60L + second - return TrainlogTimestampKey(localSecond - offsetSeconds, fraction) + return TrainlogTimestampKey( + localSecond - offsetSeconds, + fraction, + year, + month, + day, + ) } /** Bytewise identity order shared with C's unsigned memcmp semantics. */ diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt index 0204d2e..214fce2 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt @@ -36,17 +36,6 @@ fun SessionsHub( } } -@Composable -fun StatisticsHub(onBody: () -> Unit, onMaxima: () -> Unit) { - TrainlogScreen("Statistiques") { - TrainlogFrame("Données existantes") { - TrainlogAction("Mensurations", "Ajouter et consulter les relevés corporels locaux.", onBody) - TrainlogAction("Derniers MAX", "Consulter les derniers résultats MAX explicites par exercice.", onMaxima) - } - TrainlogInfo("Les tendances et analyses supplémentaires restent sur le PC canonique.") - } -} - @Composable fun LatestMaximaScreen(repository: TrainlogRepository) { val colors = LocalTrainlogColors.current diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/StatisticsScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/StatisticsScreen.kt new file mode 100644 index 0000000..b9b0d31 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/StatisticsScreen.kt @@ -0,0 +1,292 @@ +package com.labfytools.trainlog.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.labfytools.trainlog.data.StatisticsFrequency +import com.labfytools.trainlog.data.StatisticsOverview +import com.labfytools.trainlog.data.StatisticsPeriod +import com.labfytools.trainlog.data.StatisticsPoint +import com.labfytools.trainlog.data.StatisticsSeries +import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.ui.theme.LocalTrainlogColors +import com.labfytools.trainlog.ui.theme.TrainlogTypography +import java.time.LocalDate +import java.time.temporal.WeekFields +import java.util.Locale +import kotlin.math.max + +private enum class StatisticsDetail { PROGRESS, BODY, FREQUENCY, DISTRIBUTION, SUMMARY } + +internal data class DashboardWeek(val label: String, val sessions: Int, val maxima: Int) + +internal fun sixWeekFrequency( + frequency: List, + today: LocalDate = LocalDate.now(), +): List { + val fields = WeekFields.ISO + val byWeek = frequency.associateBy { it.week } + val currentMonday = today.with(fields.dayOfWeek(), 1) + return (5L downTo 0L).map { offset -> + val date = currentMonday.minusWeeks(offset) + val key = "%04d-W%02d".format( + Locale.ROOT, + date.get(fields.weekBasedYear()), + date.get(fields.weekOfWeekBasedYear()), + ) + byWeek[key]?.let { DashboardWeek(key, it.all, it.maxima) } + ?: DashboardWeek(key, 0, 0) + } +} + +@Composable +fun StatisticsDashboard(repository: TrainlogRepository) { + var period by remember { mutableStateOf(StatisticsPeriod.DAYS_30) } + var detail by remember { mutableStateOf(null) } + val overview = remember(period) { repository.loadStatistics(period) } + if (detail != null) return StatisticsDetailScreen(detail!!, overview) { detail = null } + TrainlogScreen("Statistiques") { + TrainlogChoiceChips(StatisticsPeriod.entries.map { it.name to it.label }, period.name) { + period = StatisticsPeriod.valueOf(it) + } + if (overview.hasInvalidData) TrainlogInfo( + "Certaines données historiques invalides ont été ignorées; les statistiques valides restent disponibles.", + ) + SummaryGrid(overview) + PerformanceCard(overview) { detail = StatisticsDetail.PROGRESS } + MeasurementsCard(overview) { detail = StatisticsDetail.BODY } + FrequencyCard(overview) { detail = StatisticsDetail.FREQUENCY } + TrainlogFrame("EXPLORER") { + TrainlogAction("Répartition", "Zones et utilisation des exercices, uniquement quand elles sont classifiées.", { detail = StatisticsDetail.DISTRIBUTION }) + TrainlogAction("Résumé global", "Indicateurs prudents dérivés des données disponibles.", { detail = StatisticsDetail.SUMMARY }) + } + } +} + +@Composable +private fun SummaryGrid(overview: StatisticsOverview) { + TrainlogFrame("RÉSUMÉ · ${overview.period.label}") { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Metric("Séances", overview.summary.sessions.toString(), Modifier.weight(1f)) + Metric("Séries", overview.summary.performedSets.toString(), Modifier.weight(1f)) + } + Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Metric("Exercices", overview.summary.distinctExercises.toString(), Modifier.weight(1f)) + Metric("MAX explicites", overview.summary.explicitMaxima.toString(), Modifier.weight(1f)) + } + } +} + +@Composable +private fun Metric(label: String, value: String, modifier: Modifier) { + val colors = LocalTrainlogColors.current + Column(modifier.background(colors.surface, RoundedCornerShape(10.dp)).padding(horizontal = 12.dp, vertical = 8.dp)) { + Text(label, style = TrainlogTypography.small.copy(color = colors.muted)) + Text(value, style = TrainlogTypography.value.copy(color = colors.text)) + } +} + +@Composable +internal fun PerformanceCard(overview: StatisticsOverview, click: () -> Unit) { + val colors = LocalTrainlogColors.current + val work = overview.performanceEvents.sumOf { it.workingImprovements } + val maxima = overview.performanceEvents.sumOf { it.maxImprovements } + TrainlogFrame("PROGRESSION", active = true) { + Column(Modifier.fillMaxWidth().clickable(onClick = click).padding(vertical = 2.dp)) { + PerformanceTimeline(overview) + Text( + if (work + maxima == 0) "Données insuffisantes pour établir une progression comparable." + else "$work progression(s) travail · $maxima record(s) MAX", + style = TrainlogTypography.small.copy(color = if (work + maxima == 0) colors.muted else colors.text), + modifier = Modifier.padding(top = 6.dp), + ) + Text( + "${overview.summary.explicitMaxima} MAX explicite(s) · ouvrir le détail", + style = TrainlogTypography.small.copy(color = colors.notice), + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +@Composable +private fun PerformanceTimeline(overview: StatisticsOverview) { + val colors = LocalTrainlogColors.current + val recent = overview.performanceEvents.takeLast(6).map { it.workingImprovements + it.maxImprovements } + val values = List(6 - recent.size) { 0 } + recent + val description = if (values.all { it == 0 }) + "Progression sur six semaines : aucun événement comparable, six points vides" + else "Progression sur six semaines : ${values.joinToString()} événements" + Canvas(Modifier.fillMaxWidth().height(54.dp).semantics { contentDescription = description }) { + val step = size.width / values.size + val peak = max(1, values.max()) + values.forEachIndexed { index, value -> + val center = Offset(step * (index + .5f), size.height / 2f) + if (value == 0) drawCircle(colors.muted, radius = 3.dp.toPx(), center = center) + else drawCircle(colors.info, radius = (4 + 4f * value / peak).dp.toPx(), center = center) + } + } +} + +@Composable +internal fun MeasurementsCard(overview: StatisticsOverview, click: () -> Unit) { + val colors = LocalTrainlogColors.current + TrainlogFrame("MENSURATIONS", active = overview.body.isNotEmpty()) { + Column(Modifier.fillMaxWidth().clickable(onClick = click)) { + if (overview.body.isEmpty()) { + Text("Aucune mensuration dans cette période.", style = TrainlogTypography.normal.copy(color = colors.muted)) + } else overview.body.chunked(2).forEachIndexed { rowIndex, row -> + Row( + Modifier.fillMaxWidth().padding(top = if (rowIndex == 0) 0.dp else 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + row.forEach { series -> MeasurementTile(series, Modifier.weight(1f)) } + if (row.size == 1) Box(Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun MeasurementTile(series: StatisticsSeries, modifier: Modifier) { + val colors = LocalTrainlogColors.current + val latest = series.points.last().value + val delta = series.points.takeIf { it.size >= 2 }?.let { latest - it[it.lastIndex - 1].value } + Column(modifier.background(colors.surface, RoundedCornerShape(10.dp)).padding(10.dp)) { + Text(series.label, style = TrainlogTypography.small.copy(color = colors.muted)) + Text("$latest ${series.unit}", style = TrainlogTypography.section.copy(color = colors.text)) + if (delta == null) Text("1 relevé · tendance indisponible", style = TrainlogTypography.small.copy(color = colors.muted)) + else { + Text("Δ ${if (delta >= 0) "+" else ""}$delta", style = TrainlogTypography.small.copy(color = colors.info)) + LineChart(listOf(series), Modifier.height(42.dp)) + } + } +} + +@Composable +internal fun FrequencyCard(overview: StatisticsOverview, today: LocalDate = LocalDate.now(), click: () -> Unit) { + val colors = LocalTrainlogColors.current + val weeks = sixWeekFrequency(overview.frequency, today) + val current = weeks.last() + TrainlogFrame("FRÉQUENCE", active = overview.frequency.isNotEmpty()) { + Column(Modifier.fillMaxWidth().clickable(onClick = click)) { + FrequencyBars(weeks) + Text( + "Semaine actuelle · ${current.sessions} séance(s) · ${current.maxima} MAX inclus", + style = TrainlogTypography.small.copy(color = colors.accent, fontWeight = FontWeight.Bold), + modifier = Modifier.padding(top = 6.dp), + ) + Text( + "Les MAX signalent des séances; ils ne sont jamais additionnés une seconde fois.", + style = TrainlogTypography.small.copy(color = colors.muted), + modifier = Modifier.padding(top = 2.dp), + ) + } + } +} + +@Composable +private fun FrequencyBars(weeks: List) { + val colors = LocalTrainlogColors.current + val description = "Fréquence sur six semaines : " + weeks.mapIndexed { index, week -> + "${if (index == weeks.lastIndex) "actuelle" else week.label} ${week.sessions} séances dont ${week.maxima} MAX" + }.joinToString("; ") + Canvas(Modifier.fillMaxWidth().height(116.dp).semantics { contentDescription = description }) { + val peak = max(1, weeks.maxOf { it.sessions }) + val slot = size.width / weeks.size + val barWidth = slot * .44f + weeks.forEachIndexed { index, week -> + val barHeight = if (week.sessions == 0) 3.dp.toPx() else size.height * .82f * week.sessions / peak + val left = slot * index + (slot - barWidth) / 2f + val top = size.height - barHeight + drawRoundRect( + color = if (index == weeks.lastIndex) colors.accent else colors.info, + topLeft = Offset(left, top), size = Size(barWidth, barHeight), cornerRadius = CornerRadius(5.dp.toPx()), + ) + /* MAX is a marker within the session bar, never stacked onto it. */ + if (week.maxima > 0) drawCircle( + colors.notice, radius = 4.dp.toPx(), center = Offset(left + barWidth / 2f, top + 7.dp.toPx()), + ) + } + } +} + +@Composable +private fun DashboardCard(title: String, subtitle: String, series: List, click: () -> Unit) { + val colors = LocalTrainlogColors.current + TrainlogFrame(title, active = series.isNotEmpty()) { + Column(Modifier.fillMaxWidth().clickable(onClick = click).padding(vertical = 4.dp)) { + Text(subtitle, style = TrainlogTypography.small.copy(color = colors.muted)) + if (series.isEmpty()) Text("Pas encore assez de données pour une courbe.", style = TrainlogTypography.normal.copy(color = colors.muted), modifier = Modifier.padding(top = 12.dp)) + else { + LineChart(series.take(3)) + Text("${series.size} série(s) comparable(s) · ouvrir le détail", style = TrainlogTypography.small.copy(color = colors.accent), modifier = Modifier.padding(top = 6.dp)) + } + } + } +} + +@Composable +private fun LineChart(series: List, modifier: Modifier = Modifier.height(108.dp)) { + val colors = LocalTrainlogColors.current + Canvas(modifier.fillMaxWidth().padding(top = 6.dp)) { + val palette = listOf(colors.lavender, colors.graph, colors.success) + series.forEachIndexed { seriesIndex, item -> + val values = item.points.map { it.value } + if (values.size < 2) return@forEachIndexed + val minimum = values.min() + val span = (values.max() - minimum).takeIf { it > 0.0 } ?: 1.0 + val path = androidx.compose.ui.graphics.Path() + values.forEachIndexed { index, value -> + val point = Offset( + size.width * index / (values.size - 1), + size.height - (size.height * ((value - minimum) / span)).toFloat(), + ) + if (index == 0) path.moveTo(point.x, point.y) else path.lineTo(point.x, point.y) + } + drawPath(path, palette[seriesIndex % palette.size], style = Stroke(3.dp.toPx(), cap = StrokeCap.Round)) + } + } +} + +@Composable +private fun StatisticsDetailScreen(detail: StatisticsDetail, overview: StatisticsOverview, back: () -> Unit) { + val (title, series, description) = when (detail) { + StatisticsDetail.PROGRESS -> Triple("Progression", overview.performance, "Chaque ligne de détail conserve un exercice canonique, un équipement et un mode comparables; le dashboard ne somme jamais des kg.") + StatisticsDetail.BODY -> Triple("Mensurations", overview.body, "Aucune interpolation : seuls les relevés réellement enregistrés sont affichés.") + StatisticsDetail.FREQUENCY -> Triple("Fréquence", listOf(StatisticsSeries("f", "Séances", "séances", overview.frequency.map { x -> StatisticsPoint(x.week, x.all.toDouble(), x.week) })), "Nombre de séances avec travail réel par semaine; MAX est un marqueur, pas une seconde séance.") + StatisticsDetail.DISTRIBUTION -> Triple("Répartition", emptyList(), "La répartition détaillée arrive lorsque les occurrences classifiées sont disponibles dans cette vue.") + StatisticsDetail.SUMMARY -> Triple("Résumé global", emptyList(), "${overview.summary.sessions} séances · ${overview.summary.performedSets} séries · ${overview.summary.distinctExercises} exercices · ${overview.summary.explicitMaxima} MAX explicites") + } + TrainlogScreen(title) { + TrainlogAction("← Retour au dashboard", "", back) + TrainlogInfo(description) + if (series.isEmpty()) TrainlogInfo("Aucune donnée comparable dans cette période.") + else series.forEach { DashboardCard(it.label, "${it.points.size} point(s) · ${it.unit}", listOf(it), {}) } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index 6aa6004..7f62b38 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -99,7 +99,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) }) is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exporter.exportMobileBundle(); catalogRevision++; back() } - AppRoute.Statistics -> StatisticsHub({ open(AppRoute.BodyMeasurements) }, { open(AppRoute.LatestMaxima) }) + AppRoute.Statistics -> StatisticsDashboard(repository) AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exporter.exportMobileBundle() }, { back() }) AppRoute.LatestMaxima -> LatestMaximaScreen(repository) AppRoute.Sync -> SyncScreen(inbox, requestOutbox, { exporter.exportMobileBundle(); catalogRevision++ }, { back() }) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/StatisticsRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/StatisticsRepositoryTest.kt new file mode 100644 index 0000000..373f182 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/StatisticsRepositoryTest.kt @@ -0,0 +1,334 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class StatisticsRepositoryTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val databaseName = "statistics-${UUID.randomUUID()}.db" + private val repository = TrainlogRepository(context, databaseName) + + @After fun close() { repository.close(); context.deleteDatabase(databaseName) } + + @Test fun exactTimeWindowsAndConservativeSeriesSemantics() { + repository.listEquipment() + SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL("PRAGMA foreign_keys=ON") + db.execSQL( + "INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) VALUES(?, 'Presse canonique', 'presse canonique', 'sets', 'reps', 0)", + arrayOf(CANONICAL_ID), + ) + /* INVARIANT: statistics see the flattened post-merge identity, never + * resurrect the retired creator ID as a second series. */ + db.execSQL( + "INSERT INTO exercise_aliases(source_exercise_id,canonical_exercise_id) VALUES(?,?)", + arrayOf(RETIRED_ID, CANONICAL_ID), + ) + + session(db, "outside", "2026-09-05T02:59:59.99999999999999999999+15:00") + session(db, "boundary", "2026-09-05T02:30:00+14:30") + session(db, "fraction-low", "2026-09-06t10:00:00.12345678901234567890z") + session(db, "fraction-high", "2026-09-06T12:00:00.12345678901234567891+02:00") + session(db, "equipment", "2026-09-07T10:00:00Z") + session(db, "assistance", "2026-09-08T10:00:00Z") + session(db, "target-only", "2026-09-09T10:00:00Z") + session(db, "maximum", "2026-09-10T10:00:00Z", "max_test") + + occurrence(db, "outside", "out", "leg_press", "external", 10.0) + occurrence(db, "boundary", "boundary", "leg_press", "external", 20.0) + occurrence(db, "fraction-low", "low", "leg_press", "external", 30.0) + occurrence(db, "fraction-high", "high", "leg_press", "external", 40.0) + occurrence(db, "equipment", "other-equipment", "plate_loaded_leg_press", "external", 50.0) + occurrence(db, "assistance", "assist", "assisted_dip_chin_machine", "assistance", 60.0, maximum = 160.0) + occurrence(db, "target-only", "target", "leg_press", "external", null, target = 999.0) + occurrence(db, "maximum", "max", "leg_press", "external", null, maximum = 100.0) + + db.execSQL("INSERT INTO body_observations(observation_id,observed_at,body_weight_kg) VALUES('before-body','2026-09-04T11:59:59Z',70.0)") + db.execSQL("INSERT INTO body_observations(observation_id,observed_at,body_weight_kg) VALUES('body-weight','2026-09-05t14:00:00+02:00',71.0)") + db.execSQL("INSERT INTO body_observations(observation_id,observed_at,waist_cm) VALUES('body-waist','2026-09-06T10:00:00Z',80.0)") + } + + val now = TrainlogTimestamp.parse("2026-09-11T12:00:00Z")!! + val overview = repository.loadStatistics(StatisticsPeriod.DAYS_7, now) + assertEquals(StatisticsPeriod.DAYS_7, overview.period) + assertEquals(StatisticsSummary(6, 5, 1, 2), overview.summary) + assertEquals(6, overview.sessionsLast7Days) + assertEquals(7, overview.sessionsLast30Days) + + val work = overview.performance.filter { it.id.startsWith("work:") } + val maxima = overview.performance.filter { it.id.startsWith("max:") } + assertEquals(2, work.size) + assertEquals(1, maxima.size) + assertEquals(listOf(20.0, 30.0, 40.0), work.single { ":leg_press:reps:5" in it.id }.points.map { it.value }) + assertEquals(listOf(50.0), work.single { ":plate_loaded_leg_press:reps:5" in it.id }.points.map { it.value }) + assertEquals(listOf(100.0), maxima.single().points.map { it.value }) + assertTrue(overview.performance.all { CANONICAL_ID in it.id }) + assertFalse(overview.performance.any { RETIRED_ID in it.id || it.points.any { point -> point.value == 60.0 || point.value == 999.0 || point.value == 160.0 } }) + + assertEquals(listOf(71.0), overview.body.single { it.id == "body_weight_kg" }.points.map { it.value }) + assertEquals(listOf(80.0), overview.body.single { it.id == "waist_cm" }.points.map { it.value }) + assertEquals(6, overview.frequency.sumOf { it.all }) + assertEquals(1, overview.frequency.sumOf { it.maxima }) + /* Three later leg-press loads beat the prior best at the same five-rep + * dose. The one-point other-equipment class and lone MAX are not + * falsely classified; no kg values are summed into this graph. */ + assertEquals(3, overview.performanceEvents.sumOf { it.workingImprovements }) + assertEquals(0, overview.performanceEvents.sumOf { it.maxImprovements }) + } + + @Test fun nullEndedObservableHistoryRequiresActualOccurrenceOwnedFacts() { + repository.listEquipment() + SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL( + "INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) VALUES(?, 'Presse réelle', 'presse reelle', 'sets', 'reps', 0)", + arrayOf(CANONICAL_ID), + ) + db.execSQL( + "INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) VALUES(?, 'Marche réelle', 'marche reelle', 'continuous', 'duration', 1)", + arrayOf(CONTINUOUS_ID), + ) + + session(db, "A", "2026-09-08T08:00:00Z") + session(db, "B", "2026-09-09T08:00:00Z", "max_test") + session(db, "C", "2026-09-10T08:00:00Z") + session(db, "D", "2026-09-11T08:00:00Z") + occurrence(db, "A", "actual-set", "leg_press", "external", 42.5) + /* SQLite accepts this non-finite REAL under the historic affinity + * boundary; it is an actual row for frequency, never a graph point. */ + db.execSQL( + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,1,5,1e999 FROM session_exercises WHERE entry_id='actual-set'", + ) + occurrence(db, "B", "actual-max", "leg_press", "external", null, maximum = 120.0) + db.execSQL( + """INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,entry_id,load_mode) + SELECT s.id,e.id,0,'continuous','duration',1,'actual-continuous','none' FROM sessions s JOIN exercises e ON e.exercise_id=? WHERE s.session_id='C'""".trimIndent(), + arrayOf(CONTINUOUS_ID), + ) + db.execSQL( + "INSERT INTO continuous_activity(session_exercise_row_id,duration_seconds,distance_km) SELECT id,1800,2.5 FROM session_exercises WHERE entry_id='actual-continuous'", + ) + occurrence(db, "D", "plan-only", "leg_press", "external", null, target = 999.0) + db.execSQL( + "INSERT INTO body_observations(observation_id,observed_at,body_weight_kg) VALUES('body-real','2026-09-10T09:00:00Z',72.0)", + ) + } + + assertEquals(setOf("A", "B", "C"), repository.listSessions().map { it.sessionId }.toSet()) + val overview = repository.loadStatistics( + StatisticsPeriod.ALL, + TrainlogTimestamp.parse("2026-09-11T12:00:00Z")!!, + ) + assertEquals(3, overview.sessionsLast7Days) + assertEquals(StatisticsSummary(3, 2, 2, 1), overview.summary) + assertEquals(3, overview.frequency.sumOf { it.all }) + assertEquals(1, overview.frequency.sumOf { it.maxima }) + assertEquals(listOf(42.5), overview.performance.single { it.id.startsWith("work:") }.points.map { it.value }) + assertEquals(listOf(120.0), overview.performance.single { it.id.startsWith("max:") }.points.map { it.value }) + assertEquals(listOf(72.0), overview.body.single { it.id == "body_weight_kg" }.points.map { it.value }) + assertTrue(overview.performanceEvents.isEmpty()) + } + + @Test fun maximumStatisticsRequireResolvableExternalEquipment() { + repository.listEquipment() + SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL( + "INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) VALUES(?, 'Presse MAX', 'presse max', 'sets', 'reps', 0)", + arrayOf(CANONICAL_ID), + ) + session(db, "external-first", "2026-09-08T08:00:00Z", "max_test") + session(db, "external-improved", "2026-09-09T08:00:00Z", "max_test") + session(db, "missing-equipment-first", "2026-09-08T09:00:00Z", "max_test") + session(db, "missing-equipment-improved", "2026-09-09T09:00:00Z", "max_test") + occurrence(db, "external-first", "external-first", "leg_press", "external", null, maximum = 100.0) + occurrence(db, "external-improved", "external-improved", "leg_press", "external", null, maximum = 110.0) + occurrenceWithoutEquipment(db, "missing-equipment-first", "missing-equipment-first", 120.0) + occurrenceWithoutEquipment(db, "missing-equipment-improved", "missing-equipment-improved", 130.0) + } + + val overview = repository.loadStatistics( + StatisticsPeriod.ALL, + TrainlogTimestamp.parse("2026-09-11T12:00:00Z")!!, + ) + + val maxima = overview.performance.filter { it.id.startsWith("max:") } + assertEquals(1, maxima.size) + assertEquals(listOf(100.0, 110.0), maxima.single().points.map { it.value }) + assertFalse(overview.performance.any { it.id == "max:$CANONICAL_ID:" }) + assertEquals(1, overview.performanceEvents.sumOf { it.maxImprovements }) + } + + @Test fun emptyExternalEquipmentDoesNotCreateComparableStatistics() { + repository.listEquipment() + SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL( + "INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) VALUES(?, 'Presse équipement vide', 'presse equipement vide', 'sets', 'reps', 0)", + arrayOf(CANONICAL_ID), + ) + db.execSQL( + "INSERT INTO equipment(equipment_id,label_name,display_name,equipment_type,load_semantics) VALUES('', '', 'Équipement vide', 'custom_machine', 'external')", + ) + session(db, "empty-work-first", "2026-09-08T08:00:00Z") + session(db, "empty-work-improved", "2026-09-09T08:00:00Z") + session(db, "empty-max-first", "2026-09-08T09:00:00Z", "max_test") + session(db, "empty-max-improved", "2026-09-09T09:00:00Z", "max_test") + occurrence(db, "empty-work-first", "empty-work-first", "", "external", 100.0) + occurrence(db, "empty-work-improved", "empty-work-improved", "", "external", 110.0) + occurrence(db, "empty-max-first", "empty-max-first", "", "external", null, maximum = 120.0) + occurrence(db, "empty-max-improved", "empty-max-improved", "", "external", null, maximum = 130.0) + } + + val overview = repository.loadStatistics( + StatisticsPeriod.ALL, + TrainlogTimestamp.parse("2026-09-11T12:00:00Z")!!, + ) + + assertTrue(overview.performance.isEmpty()) + assertTrue(overview.performanceEvents.isEmpty()) + } + + @Test fun frequencyUsesEachTimestampRepresentedLocalIsoWeek() { + repository.listEquipment() + SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL( + "INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) VALUES(?, 'Presse semaine', 'presse semaine', 'sets', 'reps', 0)", + arrayOf(CANONICAL_ID), + ) + /* The first two rows are the same instant on opposite sides of a + * local Monday boundary. The final row is locally Sunday even + * though its canonical instant is already Monday. */ + session(db, "local-monday", "2025-12-29T00:30:00+02:00") + session(db, "utc-sunday", "2025-12-28T22:30:00Z") + session(db, "iso-year-monday", "2024-12-30T00:30:00+14:00") + session(db, "local-sunday", "2026-01-04T23:30:00-02:00") + occurrence(db, "local-monday", "week-a", "leg_press", "external", 10.0) + occurrence(db, "utc-sunday", "week-b", "leg_press", "external", 20.0) + occurrence(db, "iso-year-monday", "week-c", "leg_press", "external", 30.0) + occurrence(db, "local-sunday", "week-d", "leg_press", "external", 40.0) + } + + val overview = repository.loadStatistics( + StatisticsPeriod.ALL, + TrainlogTimestamp.parse("2026-01-05T12:00:00Z")!!, + ) + + assertEquals( + listOf( + StatisticsFrequency("2025-W01", 1, 0), + StatisticsFrequency("2025-W52", 1, 0), + StatisticsFrequency("2026-W01", 2, 0), + ), + overview.frequency, + ) + } + + @Test fun exactDoseStrictLaterAndMalformedLegacyRowsRemainConservative() { + repository.listEquipment() + SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL( + "INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) VALUES(?, 'Presse parité', 'presse parite', 'sets', 'reps', 0)", + arrayOf(CANONICAL_ID), + ) + session(db, "A", "2026-09-08T08:00:00Z") + session(db, "B", "2026-09-09T08:00:00Z") + occurrence(db, "A", "A", "leg_press", "external", 100.0, reps = 5) + db.execSQL( + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,1,10,80.0 FROM session_exercises WHERE entry_id='A'", + ) + occurrence(db, "B", "B", "leg_press", "external", 90.0, reps = 10) + + /* Equal instants use IDs only for presentation order. Neither the + * working nor MAX pair may manufacture a later observation. */ + session(db, "tie-work-a", "2026-09-10T08:00:00Z") + session(db, "tie-work-b", "2026-09-10T08:00:00+00:00") + occurrence(db, "tie-work-a", "tie-work-a", "leg_press", "external", 50.0, reps = 12) + occurrence(db, "tie-work-b", "tie-work-b", "leg_press", "external", 60.0, reps = 12) + session(db, "tie-max-a", "2026-09-10T09:00:00Z", "max_test") + session(db, "tie-max-b", "2026-09-10T10:00:00+01:00", "max_test") + occurrence(db, "tie-max-a", "tie-max-a", "leg_press", "none", null, maximum = 100.0) + occurrence(db, "tie-max-b", "tie-max-b", "leg_press", "none", null, maximum = 110.0) + + session(db, "invalid", "legacy-not-a-timestamp") + occurrence(db, "invalid", "invalid", "leg_press", "external", 999.0, reps = 10) + db.execSQL("INSERT INTO body_observations(observation_id,observed_at,body_weight_kg) VALUES('valid-body','2026-09-09T12:00:00Z',71.0)") + db.execSQL("INSERT INTO body_observations(observation_id,observed_at,body_weight_kg) VALUES('invalid-body','bad-body-time',999.0)") + } + + val overview = repository.loadStatistics( + StatisticsPeriod.ALL, + TrainlogTimestamp.parse("2026-09-11T12:00:00Z")!!, + ) + assertTrue(overview.hasInvalidData) + assertEquals(1, overview.performanceEvents.sumOf { it.workingImprovements }) + assertEquals(0, overview.performanceEvents.sumOf { it.maxImprovements }) + assertEquals(listOf(80.0, 90.0), overview.performance + .single { it.id.endsWith(":reps:10") }.points.map { it.value }) + assertEquals(listOf(71.0), overview.body.single().points.map { it.value }) + assertFalse(overview.performance.any { series -> series.points.any { it.value == 999.0 } }) + } + + private fun session(db: SQLiteDatabase, id: String, timestamp: String, type: String = "training") { + db.execSQL("INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?)", arrayOf(id, timestamp, type)) + } + + private fun occurrence( + db: SQLiteDatabase, sessionId: String, entryId: String, equipmentId: String, + loadMode: String, weight: Double?, target: Double? = null, maximum: Double? = null, + reps: Int = 5, + ) { + db.execSQL( + """INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,equipment_row_id,entry_id,load_mode,target_sets,target_weight_kg) + SELECT s.id,e.id,0,'sets','reps',0,eq.id,?,?,1,? FROM sessions s JOIN exercises e ON e.exercise_id=? JOIN equipment eq ON eq.equipment_id=? WHERE s.session_id=?""".trimIndent(), + arrayOf(entryId, loadMode, target, CANONICAL_ID, equipmentId, sessionId), + ) + if (weight != null) db.execSQL( + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,0,?,? FROM session_exercises WHERE entry_id=?", + arrayOf(reps, weight, entryId), + ) + if (maximum != null) db.execSQL( + "INSERT INTO max_results(session_exercise_row_id,max_weight_kg) SELECT id,? FROM session_exercises WHERE entry_id=?", + arrayOf(maximum, entryId), + ) + } + + private fun occurrenceWithoutEquipment(db: SQLiteDatabase, sessionId: String, entryId: String, maximum: Double) { + db.execSQL( + """INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,entry_id,load_mode) + SELECT s.id,e.id,0,'sets','reps',0,?,'external' FROM sessions s JOIN exercises e ON e.exercise_id=? WHERE s.session_id=?""".trimIndent(), + arrayOf(entryId, CANONICAL_ID, sessionId), + ) + db.execSQL( + "INSERT INTO max_results(session_exercise_row_id,max_weight_kg) SELECT id,? FROM session_exercises WHERE entry_id=?", + arrayOf(maximum, entryId), + ) + } + + companion object { + private const val CANONICAL_ID = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde" + private const val CONTINUOUS_ID = "ex_77777777-7777-4777-8777-777777777777" + private const val RETIRED_ID = "ex_00000000-0000-4000-8000-000000000099" + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogTimestampTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogTimestampTest.kt index eeb150b..aaaabc4 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogTimestampTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogTimestampTest.kt @@ -30,4 +30,14 @@ class TrainlogTimestampTest { "2026-09-05T18:34:12+24:00", ).forEach { assertNull(it, TrainlogTimestamp.parse(it)) } } + + @Test fun preservesRepresentedLocalCalendarDateAcrossEquivalentInstants() { + val march = TrainlogTimestamp.parse("2026-03-01T00:30:00+02:00")!! + val february = TrainlogTimestamp.parse("2026-02-28T22:30:00Z")!! + + assertEquals(0, march.compareTo(february)) + assertEquals(march, february) + assertEquals(listOf(2026, 3, 1), listOf(march.localYear, march.localMonth, march.localDay)) + assertEquals(listOf(2026, 2, 28), listOf(february.localYear, february.localMonth, february.localDay)) + } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/StatisticsScreenPresentationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/StatisticsScreenPresentationTest.kt new file mode 100644 index 0000000..e1b9c4a --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/StatisticsScreenPresentationTest.kt @@ -0,0 +1,107 @@ +package com.labfytools.trainlog.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import com.labfytools.trainlog.data.StatisticsFrequency +import com.labfytools.trainlog.data.StatisticsOverview +import com.labfytools.trainlog.data.StatisticsPeriod +import com.labfytools.trainlog.data.StatisticsPoint +import com.labfytools.trainlog.data.StatisticsSeries +import com.labfytools.trainlog.data.StatisticsSummary +import com.labfytools.trainlog.ui.theme.TrainlogTheme +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class StatisticsScreenPresentationTest { + @Suppress("DEPRECATION") + @get:Rule val compose = createComposeRule() + + @Test fun frequencyPresentationPadsSixIsoWeeksWithoutAddingMaxima() { + val weeks = sixWeekFrequency( + listOf( + StatisticsFrequency("2026-W36", 2, 1), + StatisticsFrequency("2026-W37", 3, 1), + ), + LocalDate.of(2026, 9, 11), + ) + + assertEquals(6, weeks.size) + assertEquals(DashboardWeek("2026-W32", 0, 0), weeks.first()) + assertEquals(DashboardWeek("2026-W37", 3, 1), weeks.last()) + assertEquals(5, weeks.sumOf { it.sessions }) + } + + @Test fun emptyProgressAndSingleMeasurementRemainExplicitAndNonSynthetic() { + val overview = overview( + body = listOf( + StatisticsSeries( + "body_weight_kg", "Poids", "kg", + listOf(StatisticsPoint("2026-09-11T08:00:00Z", 82.5)), + ), + ), + ) + compose.setContent { + TrainlogTheme { + Column { + PerformanceCard(overview) {} + MeasurementsCard(overview) {} + } + } + } + + compose.onNodeWithContentDescription( + "Progression sur six semaines : aucun événement comparable, six points vides", + ).fetchSemanticsNode() + compose.onNodeWithText("Données insuffisantes pour établir une progression comparable.").fetchSemanticsNode() + compose.onNodeWithText("2 MAX explicite(s) · ouvrir le détail").fetchSemanticsNode() + compose.onNodeWithText("82.5 kg").fetchSemanticsNode() + compose.onNodeWithText("1 relevé · tendance indisponible").fetchSemanticsNode() + assertTrue(compose.onAllNodesWithText("Δ", substring = true).fetchSemanticsNodes().isEmpty()) + } + + @Test fun frequencyChartExposesSixBucketsAndCurrentWeekSummary() { + val overview = overview( + frequency = listOf( + StatisticsFrequency("2026-W36", 2, 1), + StatisticsFrequency("2026-W37", 3, 1), + ), + ) + compose.setContent { + TrainlogTheme { + FrequencyCard(overview, LocalDate.of(2026, 9, 11)) {} + } + } + + compose.onNodeWithContentDescription("Fréquence sur six semaines", substring = true).fetchSemanticsNode() + compose.onNodeWithText("Semaine actuelle · 3 séance(s) · 1 MAX inclus").fetchSemanticsNode() + compose.onNodeWithText( + "Les MAX signalent des séances; ils ne sont jamais additionnés une seconde fois.", + ).fetchSemanticsNode() + } + + private fun overview( + body: List = emptyList(), + frequency: List = emptyList(), + ) = StatisticsOverview( + period = StatisticsPeriod.DAYS_30, + hasInvalidData = false, + summary = StatisticsSummary(5, 8, 3, 2), + performanceEvents = emptyList(), + performance = emptyList(), + body = body, + frequency = frequency, + sessionsLast7Days = 3, + sessionsLast30Days = 5, + ) +} diff --git a/docs/android.md b/docs/android.md index 8b8ecba..ca5f76e 100644 --- a/docs/android.md +++ b/docs/android.md @@ -40,6 +40,38 @@ canonical route and exposes selected semantics; it is not maintained as a second navigation state. The drawer itself scrolls when vertical space is limited. +## STATS_V1 + +The Statistics landing page defaults to 30 days and is a Material 3 dashboard using the existing +Catppuccin Mocha/Lavender semantic theme. It filters a read-only projection by +7 days, 30 days, 90 days, one year, or all history, then presents performance, +body-measurement, and weekly-frequency cards plus detail routes. + +The landing performance chart contains weekly classified event counts, never +summed or averaged kilograms. Every actual performed set is an observation. A +working event is strictly later in canonical time and greater than the prior +best for the same canonical exercise, nonempty resolved equipment, external +equipment semantics, external-load mode and exact performed reps/duration dose. +Explicit MAX uses a separate strictly-later-record-above-prior-MAX rule in the +same exercise/resolved-external-equipment context. Equal-instant IDs only order +presentation and never create events. A one-point class +cannot create an event; context-owned raw series remain available in the +exercise drill-down. Assistance, plans and missing actual loads are omitted. + +The selected-period summary reports actual sessions, performed sets, distinct +actual exercises and explicit MAX records. Body curves use only recorded +observations (no fabricated interpolation), are bounded and ordered by the +most recently observed metric; one point has no delta or graph. Observable session +history and frequency include a stable session ID exactly once when one of its +occurrences owns a persisted performed set, continuous activity, or explicit +MAX result; `ended_at` is not an inclusion gate. Planned targets and empty +occurrences do not count. MAX sessions are labelled separately. Calendar buckets use the local date +written in each persisted timestamp (including its literal offset), with Monday +as the first day of the week; rolling windows and chronology still compare the +exact represented instant and never use the device timezone. +Malformed legacy timestamps are omitted only from the statistics projection; +the dashboard remains available and reports that invalid history was ignored. + The section roots are: ```text @@ -47,7 +79,7 @@ Accueil durable-draft resume, generation/manual capture, concise local Séances current session, programme a session, manual entry, completed sessions Exercices catalogue, detail, create, and contextual edit Équipements catalogue, detail, and custom-equipment creation -Statistiques measurements and local latest explicit MAX +Statistiques STATS_V1 dashboard, conservative graphs and detailed local drill-downs Synchronisation existing request, result, and diagnostic workflow Paramètres exchange-folder authorization and explicit PC-catalog refresh ``` diff --git a/docs/current_state.md b/docs/current_state.md index e232d51..af24a1f 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -494,4 +494,19 @@ as if it were a real measurement. V2. Manual `%MAX` remains available. `exercise-names-v1.json` supplies movement-oriented canonical metadata for mapped stable IDs; stale peer labels cannot overwrite those names, while custom exercise naming remains editable. -STATS_V1 is prepared only in `reviews/stats_v1_design.md`. +`STATS_V1=IMPLEMENTED`: Android exposes a Material 3 Catppuccin Mocha/Lavender +statistics landing dashboard with selected-period fact counts, weekly +conservative working/MAX improvement-event counts, bounded measurements, weekly +session frequency, and context-owned drill-down series. The Notcurses Statistics +route loads a bounded, transactionally consistent controller snapshot outside +rendering and presents the same global facts at wide and compact widths. Working +events compare every set only within exact exercise, resolved external equipment +and performed-dose context; equal canonical instants never create an event. A +session contributes once when it owns a +performed set, continuous activity, or explicit MAX; `ended_at` does not gate +persisted history, and plans/empty occurrences contribute nothing. Exact parsed +instants, rather than SQLite text ordering, choose and order points. Its >=100-column multi-region and 72x20 +compact layouts retain the same three detail actions. +Malformed legacy timestamps are skipped before sorting with a nonfatal dashboard +warning on both platforms. +No statistics schema or exchange-format field was added. diff --git a/docs/reviews/stats_v1_design.md b/docs/reviews/stats_v1_design.md index 9e4a03a..b782531 100644 --- a/docs/reviews/stats_v1_design.md +++ b/docs/reviews/stats_v1_design.md @@ -1,12 +1,13 @@ # STATS_V1 preparation -STATS_V1 is not implemented and needs no schema change. Queries read completed -sessions only: `sessions.ended_at IS NOT NULL`, `session_exercises`, +STATS_V1 needs no schema change. Observable history reads `session_exercises`, `performed_sets`, `continuous_activity`, `max_results`, `exercise_body_zones`, -and `body_observations`. Drafts and planned targets are never frequency or -volume facts. +and `body_observations`. A stable session ID contributes once when it owns a +performed set, continuous activity, or explicit MAX, whether or not `ended_at` +is present. Drafts, planned targets, and empty occurrences are never frequency +or volume facts. -Proposed query boundary: date-windowed session count; completed exercise +Implemented query boundary: date-windowed session count; completed exercise occurrence count; performed-set count; continuous duration; per-exercise working-weight observations; explicit MAX chronology; body-observation series; and BODY ZONE/movement-pattern projections through the read-only knowledge @@ -14,7 +15,11 @@ catalog. Windows use local calendar week/month derived from RFC3339 timestamps; missing actual sets, duration, equipment, or load are omitted from that metric, never coerced to zero. -Comparisons group first by `exercise_id`, then equipment context and load mode. +Comparisons group first by canonical `exercise_id`, then equipment context and +load mode. Working classification additionally requires the exact performed +reps/duration dose and a later value above the prior best. Explicit MAX has its +own later-record-above-prior-MAX rule. Classes with fewer than two comparable +points produce no event, and the landing graph plots weekly event counts only. External resistance on different equipment IDs is not comparable; assistance is not external resistance. A trend may label equipment-specific series but must not blend them. Body-zone frequency counts completed occurrences/sets through diff --git a/docs/roadmap.md b/docs/roadmap.md index 5c4e88a..9e4f433 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -445,5 +445,25 @@ cool-down, transition/setup time, and explicit candidate-scarcity handling. ## STATS_V1 -Prepared only: see `reviews/stats_v1_design.md`. Implementation remains a -future derived-query tranche with no new summary schema in this checkpoint. +Implemented as a derived-query tranche with no summary schema: the Android +landing dashboard provides 7/30/90-day, year and all-history filters; +the performance landing graph counts weekly improvements from every performed +set under strict canonical exercise/nonempty resolved external-equipment/ +performed-dose comparison, with explicit MAX classified separately. Only a +strictly later canonical instant can improve; equal-instant IDs order display +only. Raw values remain drill-down data and are never globally aggregated +as kilograms. Body charts show only actually recorded measures; weekly frequency +uses MAX as a marker on the once-counted session. The Notcurses +landing route gives the same visual dashboard hierarchy and routes to the +existing detailed persisted views. Its controller-owned snapshot is loaded +outside rendering; wide terminals show performance, body and frequency regions +while 80x24 and 72x20 retain all three factual summaries. The selected-period +summary reports sessions, performed sets, distinct actual exercises and explicit +MAX records; body selects the latest actually observed metric, and weekly frequency counts each stable session +ID exactly once when it owns at least one persisted performed set, continuous +activity, or explicit MAX result. Planned targets and empty occurrences do not +count; `ended_at` is presentation/lifecycle metadata, not the history gate. +Exact parsed instants govern boundaries and ordering. Assistance, missing values +and incomparable equipment contexts are never coerced into progress values. +Malformed legacy timestamps are skipped by the projection with a visible warning +while valid statistics remain available. diff --git a/docs/tests.md b/docs/tests.md index 1597128..981b5e3 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -115,7 +115,7 @@ app_shell ``` The current desktop suite, including APP_SHELL_V1 production-transition -coverage, is 47/47. The following generator-specific checkpoint counts remain +coverage, is 48/48. The following generator-specific checkpoint counts remain historical evidence: ```text @@ -248,10 +248,16 @@ bounded deterministic read-only equipment page reader, including pagination, invalid arguments, offsets and corrupt values. This coverage replaces former nested-screen-loop workflow claims. +The Mensurations workflow regression separates the selected observation's +profile from its metric history: singleton circumference bars without a trend, +independent kg/cm rendering, missing-field omission, a real two-date Unicode +series, historical selection updates, availability-filtered metric navigation, +and bounded 120x35, 100x30, 80x24 and 72x20 geometry without ASCII chart glyphs. + The APP_SHELL PTY validation exercises six TUI sizes—72x20, 80x24, 100x25, 100x30, 120x31 and 120x35—plus help, search clear/close, F6/F7, compact focus, resize/overlay restoration, clean exit, and navigation with no temporary -database write. Normal and ASan/UBSan Meson suites each passed 47/47; the +database write. Normal and ASan/UBSan Meson suites each passed 48/48; the current normal and sanitizer real-PTY runs each passed 100/100 checks. The sanitizer run used the upstream-prescribed Notcurses compatibility setting `ASAN_OPTIONS=use_sigaltstack=0:detect_leaks=1:halt_on_error=1` and reported @@ -415,8 +421,18 @@ Coverage proves: - the real TUI `/` path resolves through the registered action, focuses search, filters live, then follows clear-before-close Escape semantics; - action stable identifiers are unique, preventing footer/F7 duplication. +- Statistics-dashboard rendering covers global summary/event facts, primary-zone + and unclassified active-catalogue projection without merged-alias double + counts, zero/one-point without-graph and multi-point Unicode detail-chart + states; readable selected-period frequency with non-duplicating MAX markers; + daily/multi-day period projection and independent catalogue bars with + right-side counts; exact-dose per-set parity, equal-instant + working/MAX non-events, malformed-timestamp availability, distinct wide versus + stacked compact geometry, and Unicode blocks used only as chart geometry; + and footer F6/F7 single-occurrence output at 120x35, 100x30, 80x24, and + 72x20. -Historical validation checkpoint (the current desktop suite is 47/47): +Historical validation checkpoint (the current desktop suite is 48/48): ```text 39/39 Meson tests PASS @@ -441,7 +457,7 @@ Coverage includes: - missing required circumference handling; - invalid estimation-profile rejection. -Historical validation checkpoint (the current desktop suite is 47/47): +Historical validation checkpoint (the current desktop suite is 48/48): ```text 39/39 Meson tests PASS diff --git a/docs/tui.md b/docs/tui.md index 29e28c8..3dfbc30 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -22,6 +22,29 @@ top-level Historique entry. Existing mensurations, body graphs/analysis, exercise performance and explicit MAX views are reached through `Statistiques`; this relocation adds no statistic or analytical interpretation. +`STATS_V1` makes the Statistics root a data-fed Unicode dashboard rather than a +plain list. Before rendering, its controller takes one bounded read snapshot: +the newest actual-history working-performance series is identified by exact +exercise, nonempty resolved external equipment, tracking, load and performed +dose context; the latest recorded body metric +shows its real trend; and the selected period shows actual-work session counts. +An explicit MAX is reported separately and is never folded into the working +series. Assistance, targets, empty sessions and unavailable measurements do +not become progress facts. Timestamp selection and ordering use Trainlog's +exact instant comparator, including offsets and arbitrary fractional seconds. +Presentation buckets use the local date written in each persisted timestamp; +they never reinterpret history in the current machine timezone. +Malformed legacy timestamps are skipped before statistics sorting and produce a +nonfatal ignored-data warning; valid dashboard facts remain available. The same +four panels and detail actions remain visible at 72x20; terminals of at least +100 columns place performance and measurements side by side with frequency and +catalogue distribution below, rather than reusing the compact vertical stack. +Trend presentation is deliberately sparse: no observation presents an honest +unavailable state, one observation states that the trend is unavailable and +draws no graph, and two or more observations draw only their observed trend. +Dashboard exercise labels resolve the canonical display name from the stable +`exercise_id` when one is mapped; the resolved equipment label remains +secondary context rather than becoming the exercise identity. The geometry policy is exact: ```text @@ -55,6 +78,9 @@ q quit from the application shell The root aliases and contextual actions are built from one action registry: the footer and F7 therefore advertise the same enabled actions that dispatch. +After contextual and shell actions are collected, their stable action identities +are deduplicated before footer layout, so `F6 Navigation` and `F7 Actions` each +appear once. The intentional APP_SHELL_V1 shortcut changes are the replacement of the old top-level Historique/Corps aliases by `2/F2 Séances effectuées` and `5/F5 Mensurations`, F6 Navigation and F7 Actions, as approved in design @@ -272,13 +298,26 @@ visibly distinct; `i` opens the resolved equipment detail. `Statistiques → Mensurations` (direct alias `5/F5`) provides: -- newest-first body observations; +- a current-profile snapshot of the selected newest-first body observation; +- `↑`/`↓` historical-observation selection and `Entrée` detail access; +- weight, when present, on a separate `kg` line; +- only present circumferences as exact `cm` values with thin horizontal bars + scaled to the largest circumference in that observation; +- `←`/`→` selection among only metrics that exist in recorded observations, + with weight selected by default when available; +- an evolution section that reports no data for zero points, reports + `1 relevé · tendance indisponible` without a graph for one point, and draws a + Unicode time series from real dated values for two or more points; - detail and correction; - body trend visualization; - normalized multi-metric overlay; -- left/right metric separation; - no invented zero values for missing measurements. +The profile and evolution sections are deliberately separate: kilograms never +share a circumference scale, missing measurements are omitted, and the profile +bars make no progress or trend claim. The sections stack responsively down to +the supported 72x20 terminal. + Editing preserves observation identity, timestamp, and optional session link. ## 9. Statistics and dashboard @@ -286,6 +325,51 @@ Editing preserves observation identity, timestamp, and optional session link. The rolling 12-month normalized body graph has moved from the dashboard to `Statistiques → Mensurations`; its data rules are unchanged. +The landing dashboard itself is a read-only projection over persisted facts. +Rendering consumes the route-owned snapshot and performs no SQLite access. +Its performance plot is a selected-period bucket count of improvement events, +never a sum of +kilograms across exercises or equipment. Every performed set is a working +observation. Working and explicit-MAX improvements are separate and require a +strictly earlier canonical-instant comparable point; equal-instant stable IDs +only order presentation. Raw series remain in the +per-exercise drill-down. The 30-day default switches with `1` through `5` to 7, +30, 90, 365 days or all history. Progression and frequency share a deterministic +projection of that selection: seven daily buckets for 7 days, six 5-day buckets +for 30 days, six 15-day buckets for 90 days, twelve 31-day presentation buckets +for one year, and at most twelve equal day spans covering represented history +for Tout. Canonical rolling membership is unchanged; local persisted dates only +place included facts into a display bucket, with boundary dates clamped into the +first or current bucket. The summary reports actual sessions, performed sets, +distinct actual exercises as `Exercices pratiqués`, and explicit MAX records. +The body summary +selects the most recently observed metric; one point has no delta or graph. +Observable session history and the +frequency histogram include a stable session ID exactly once when an occurrence +owns a persisted performed set, continuous activity, or explicit MAX result. +`ended_at` is presentation/lifecycle metadata, not the history gate; planned +targets and empty occurrences do not count. A capacity warning is shown when a +bounded source reaches its local limit. +Malformed persisted timestamps are excluded before sorting, reported as ignored +legacy data, and do not make the remaining dashboard unavailable. + +`Répartition du catalogue` is a separate global catalogue projection. It +counts every active canonical exercise exactly once by its persisted **primary** +BODY ZONE; secondary zones do not inflate the result, merged source IDs are not +catalogue rows, and an exercise with no primary zone is shown as `Non classés`. +Buckets are ordered by descending count then label. Each landing-chart row owns +an independent Unicode horizontal bar between its left label and right count; +the bounded leading buckets remain at compact sizes without colliding with the +frequency chart. Enter opens the existing global exercise catalogue, never an +arbitrary exercise detail. + +The dashboard rendering regression covers global event/summary facts, primary +zone/unclassified catalogue projection, and sparse or multi-point measurements +at 120x35, 100x30, 80x24, and the 72x20 minimum. Unicode blocks/line/point +glyphs provide chart geometry; text totals remain the accessible factual +equivalent at every supported width. Detail charts show no graph for zero or +one actual point, and no ASCII placeholder trace for two or more points. + Rules include: - fixed calendar month slots; diff --git a/tools/generate_exercise_name_catalog.py b/tools/generate_exercise_name_catalog.py new file mode 100644 index 0000000..a8229d2 --- /dev/null +++ b/tools/generate_exercise_name_catalog.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Validate exercise-names-v1 and generate its bounded C lookup table.""" + +import json +import re +import sys + + +source, output = sys.argv[1:] +with open(source, encoding="utf-8") as handle: + root = json.load(handle) + +if set(root) != {"format", "version", "names"} or \ + root.get("format") != "trainlog-exercise-names-v1" or \ + root.get("version") != 1 or isinstance(root.get("version"), bool): + raise SystemExit("unsupported exercise-name catalogue") + +names = root.get("names") +if not isinstance(names, list) or not names: + raise SystemExit("names must be a non-empty list") + +seen_ids = set() +for item in names: + if set(item) != {"exercise_id", "display_name", "normalized_name", + "previous_display_names"}: + raise SystemExit("invalid exercise-name entry shape") + exercise_id = item["exercise_id"] + display_name = item["display_name"] + if not isinstance(exercise_id, str) or re.fullmatch( + r"ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + exercise_id, + ) is None or exercise_id in seen_ids: + raise SystemExit("duplicate or invalid exercise_id") + if not isinstance(display_name, str) or not display_name.strip() or \ + display_name != display_name.strip(): + raise SystemExit("invalid exercise display_name") + seen_ids.add(exercise_id) + + +def c(value): + return json.dumps(value, ensure_ascii=False) + + +with open(output, "w", encoding="utf-8") as generated: + generated.write('#include "trainlog/exercise_name_catalog.h"\n') + generated.write('#include \n#include \n\n') + generated.write("typedef struct TrainlogExerciseNameEntry {\n" + " const char *exercise_id;\n" + " const char *display_name;\n" + "} TrainlogExerciseNameEntry;\n\n") + generated.write("static const TrainlogExerciseNameEntry entries[] = {\n") + for item in names: + generated.write(" {%s, %s},\n" % ( + c(item["exercise_id"]), c(item["display_name"]))) + generated.write("};\n\n") + generated.write("const char *trainlog_exercise_name_catalog_lookup(const char *exercise_id) {\n" + " size_t index;\n" + " if (exercise_id == NULL) return NULL;\n" + " for (index = 0U; index < sizeof(entries) / sizeof(entries[0]); ++index)\n" + " if (strcmp(entries[index].exercise_id, exercise_id) == 0)\n" + " return entries[index].display_name;\n" + " return NULL;\n" + "}\n") diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 8a0b2df..b0009f5 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -292,6 +292,9 @@ TrainlogStatus trainlog_database_session_count( size_t *output_count ); +/* Observable history only: a returned session owns at least one persisted + * performed set, continuous activity, or explicit MAX result. ended_at is + * presentation metadata and is not an actual-work gate. */ TrainlogStatus trainlog_database_list_sessions( TrainlogDatabase *database, TrainlogSessionSummary *output, diff --git a/tui/include/trainlog/exercise_name_catalog.h b/tui/include/trainlog/exercise_name_catalog.h new file mode 100644 index 0000000..85c777e --- /dev/null +++ b/tui/include/trainlog/exercise_name_catalog.h @@ -0,0 +1,17 @@ +#ifndef TRAINLOG_EXERCISE_NAME_CATALOG_H +#define TRAINLOG_EXERCISE_NAME_CATALOG_H + +/** + * @file exercise_name_catalog.h + * @brief Shared presentation names keyed by stable exercise identity. + */ + +/** + * Return the current shared display name for a known stable exercise ID. + * + * The returned pointer has static storage duration. Unknown IDs return NULL so + * callers can preserve the user-owned database label for custom exercises. + */ +const char *trainlog_exercise_name_catalog_lookup(const char *exercise_id); + +#endif diff --git a/tui/meson.build b/tui/meson.build index eeab6a9..4892a30 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -30,6 +30,13 @@ body_zone_catalog_generated = custom_target( command: [find_program('python3'), meson.project_source_root() / 'tools/generate_body_zone_catalog.py', '@INPUT@', '@OUTPUT@'], ) +exercise_name_catalog_generated = custom_target( + 'exercise_name_catalog_generated', + input: meson.project_source_root() / 'catalog/exercise-names-v1.json', + output: 'exercise_name_catalog_generated.c', + command: [find_program('python3'), meson.project_source_root() / 'tools/generate_exercise_name_catalog.py', '@INPUT@', '@OUTPUT@'], +) + training_knowledge_generated = custom_target( 'training_knowledge_generated', input: [ @@ -96,6 +103,7 @@ trainlog_core_sources = files( ) trainlog_core_sources += equipment_catalog_generated trainlog_core_sources += body_zone_catalog_generated +trainlog_core_sources += exercise_name_catalog_generated trainlog_core_sources += training_knowledge_generated trainlog_core_sources += session_generation_policy_generated diff --git a/tui/src/database.c b/tui/src/database.c index 0e890fb..081dcd3 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -10,6 +10,7 @@ #include "trainlog/id.h" #include "trainlog/session_generation.h" #include "timestamp.h" +#include "database_internal.h" #include #include @@ -20,11 +21,6 @@ #include -struct TrainlogDatabase { - sqlite3 *connection; - unsigned int read_snapshot_depth; -}; - TrainlogStatus trainlog_database_read_snapshot_begin(TrainlogDatabase *database) { char sql[64]; @@ -3727,6 +3723,12 @@ TrainlogStatus trainlog_database_list_sessions( size_t *output_count ) { + /* WHY: imported and historically persisted actual work can legitimately + * have no ended_at, while a planned/empty occurrence is not observed work. + * CONTRACT: observable session history contains a session exactly once + * when it owns a performed set, continuous activity, or explicit MAX. + * INVARIANT: this read-only predicate never infers work from targets and + * never mutates or backfills the persisted lifecycle timestamps. */ static const char *const SQL = "SELECT " "s.session_id, " @@ -3737,6 +3739,15 @@ TrainlogStatus trainlog_database_list_sessions( "FROM sessions AS s " "LEFT JOIN session_exercises AS se " "ON se.session_row_id = s.id " + "WHERE EXISTS (SELECT 1 FROM session_exercises AS actual_se " + "JOIN performed_sets AS ps ON ps.session_exercise_row_id = actual_se.id " + "WHERE actual_se.session_row_id = s.id) " + "OR EXISTS (SELECT 1 FROM session_exercises AS actual_se " + "JOIN continuous_activity AS ca ON ca.session_exercise_row_id = actual_se.id " + "WHERE actual_se.session_row_id = s.id) " + "OR EXISTS (SELECT 1 FROM session_exercises AS actual_se " + "JOIN max_results AS mr ON mr.session_exercise_row_id = actual_se.id " + "WHERE actual_se.session_row_id = s.id) " "GROUP BY s.id " "ORDER BY s.started_at DESC, s.id DESC;"; diff --git a/tui/src/database_internal.h b/tui/src/database_internal.h new file mode 100644 index 0000000..a8504ac --- /dev/null +++ b/tui/src/database_internal.h @@ -0,0 +1,14 @@ +#ifndef TRAINLOG_DATABASE_INTERNAL_H +#define TRAINLOG_DATABASE_INTERNAL_H + +/* Source-private SQLite handle layout. Public callers retain the opaque + * TrainlogDatabase declaration from database.h; STATS uses this only for its + * file-local read-model query and exports no new database symbol or ABI. */ +#include + +struct TrainlogDatabase { + sqlite3 *connection; + unsigned int read_snapshot_depth; +}; + +#endif diff --git a/tui/src/measured_max.c b/tui/src/measured_max.c index 4bad824..080e98e 100644 --- a/tui/src/measured_max.c +++ b/tui/src/measured_max.c @@ -112,6 +112,19 @@ TrainlogStatus trainlog_measured_max_summarize( const TrainlogExercisePerformancePoint *point = &points[index]; + /* INVARIANT: the database read model emits work immediately before + * the MAX fact when one occurrence owns both. Summarization consumes + * that ABI-safe pair as one test whose measured result is the MAX. */ + if (point->has_explicit_max == 0 && index + 1U < count && + points[index + 1U].has_explicit_max != 0 && + strcmp(point->session_id, points[index + 1U].session_id) == 0 && + strcmp(point->started_at, points[index + 1U].started_at) == 0 && + strcmp(point->equipment_id, points[index + 1U].equipment_id) == 0 && + point->tracking_mode == points[index + 1U].tracking_mode && + point->load_mode == points[index + 1U].load_mode) { + continue; + } + if ( point->session_type != TRAINLOG_SESSION_MAX_TEST @@ -121,7 +134,7 @@ TrainlogStatus trainlog_measured_max_summarize( ++output->test_count; - if (point->has_performance == 0) { + if (point->has_performance == 0 && point->has_explicit_max == 0) { continue; } diff --git a/tui/src/timestamp.c b/tui/src/timestamp.c index 7fdfd6d..b48d0cc 100644 --- a/tui/src/timestamp.c +++ b/tui/src/timestamp.c @@ -83,7 +83,8 @@ bool trainlog_timestamp_parse( offset_sign = value[zone] == '+' ? 1 : -1; } - local_second = day_number(year, month, day) * INT64_C(86400) + + output->local_day = day_number(year, month, day); + local_second = output->local_day * INT64_C(86400) + (int64_t)hour * INT64_C(3600) + (int64_t)minute * INT64_C(60) + second; output->utc_second = local_second - offset_sign * ((int64_t)offset_hour * INT64_C(3600) + (int64_t)offset_minute * INT64_C(60)); diff --git a/tui/src/timestamp.h b/tui/src/timestamp.h index ea438c9..27b5f39 100644 --- a/tui/src/timestamp.h +++ b/tui/src/timestamp.h @@ -7,6 +7,7 @@ typedef struct TrainlogTimestampKey { int64_t utc_second; + int64_t local_day; const char *fraction; size_t fraction_length; } TrainlogTimestampKey; diff --git a/tui/src/tui.c b/tui/src/tui.c index 68e1b4b..dfa40d7 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -22,6 +23,7 @@ #include #include +#include #include "trainlog/terminal.h" #include "trainlog/app_shell.h" @@ -32,6 +34,7 @@ #include "trainlog/catalog.h" #include "trainlog/duration.h" #include "trainlog/equipment_catalog.h" +#include "trainlog/exercise_name_catalog.h" #include "trainlog/id.h" #include "trainlog/measured_max.h" #include "trainlog/mtp.h" @@ -45,6 +48,8 @@ #include "trainlog/timeutil.h" #include "trainlog/training_knowledge.h" #include "trainlog/usb.h" +#include "timestamp.h" +#include "database_internal.h" #define MAX_EXERCISES 128U #define MAX_SESSION_EXERCISES 32U @@ -56,6 +61,9 @@ #define MAX_BODY_METRIC_POINTS 256U #define EXERCISE_GRAPH_POINTS 12U +#define DASHBOARD_BUCKET_COUNT 12U +#define DASHBOARD_FACT_CAPACITY 4096U +#define DASHBOARD_ZONE_BUCKET_CAPACITY (MAX_BODY_ZONES + 1U) /* TRAINLOG_TUI_V02_POLISH */ /* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */ @@ -610,13 +618,13 @@ typedef struct TrainlogGlobalBodySeries { size_t count; double baseline; double latest_percent; - char symbol; + uint32_t symbol; TrainlogColorRole role; } TrainlogGlobalBodySeries; -static const char GLOBAL_BODY_SYMBOLS[] = { - 'P', 'N', 'E', 'C', 'T', 'H', 'A', - 'B', 'F', 'G', 'Q', 'R', 'M', 'D' +static const uint32_t GLOBAL_BODY_SYMBOLS[] = { + 0x25cfU, 0x25c6U, 0x25b2U, 0x25a0U, 0x25cbU, 0x25bcU, 0x25c7U, + 0x25efU, 0x25c8U, 0x25b3U, 0x25a1U, 0x25bdU, 0x25ceU, 0x25c9U }; static const TrainlogColorRole GLOBAL_BODY_ROLES[] = { @@ -2556,6 +2564,87 @@ typedef struct TrainlogSyncController { TrainlogAppSyncRun run; } TrainlogSyncController; +typedef struct TrainlogDashboardWeek { + int64_t start_day; + int64_t span_days; + char label[12]; + size_t sessions; + size_t maxima; + size_t working_improvements; + size_t max_improvements; +} TrainlogDashboardWeek; + +typedef struct TrainlogDashboardZoneBucket { + char label[TRAINLOG_NAME_MAX + 1U]; + size_t count; +} TrainlogDashboardZoneBucket; + +typedef enum TrainlogStatisticsPeriod { + TRAINLOG_STATS_30_DAYS = 0, + TRAINLOG_STATS_7_DAYS, + TRAINLOG_STATS_90_DAYS, + TRAINLOG_STATS_YEAR, + TRAINLOG_STATS_ALL +} TrainlogStatisticsPeriod; + +typedef struct TrainlogDashboardSnapshot { + bool error; + bool partial; + bool invalid_data; + bool has_performance; + TrainlogExercise exercise; + TrainlogLoadMode performance_load_mode; + TrainlogTrackingMode performance_tracking_mode; + int performance_dose; + char performance_equipment_id[TRAINLOG_ID_MAX + 1U]; + char performance_equipment_label[TRAINLOG_NAME_MAX + 1U]; + TrainlogExercisePerformancePoint performance[MAX_SESSIONS]; + size_t performance_count; + bool has_explicit_max; + TrainlogExercise max_exercise; + double max_weight_kg; + char max_timestamp[TRAINLOG_TIMESTAMP_MAX + 1U]; + bool has_body; + size_t body_metric; + TrainlogBodyMetricPoint body[MAX_BODY_METRIC_POINTS]; + size_t body_count; + TrainlogDashboardWeek weeks[DASHBOARD_BUCKET_COUNT]; + size_t week_count; + size_t completed_session_count; + size_t performed_set_count; + size_t distinct_exercise_count; + size_t explicit_max_count; + /* WHY: this is a catalogue dashboard, not a history-derived muscle + * claim. CONTRACT: every active canonical exercise contributes once to + * its persisted primary zone, or to the explicit unclassified bucket. + * INVARIANT: aliases and secondary zones never affect these counts. */ + TrainlogDashboardZoneBucket zones[DASHBOARD_ZONE_BUCKET_CAPACITY]; + size_t zone_count; + TrainlogStatisticsPeriod period; +} TrainlogDashboardSnapshot; + +typedef enum TrainlogDashboardFactKind { + TRAINLOG_DASHBOARD_WORK, + TRAINLOG_DASHBOARD_MAX, + TRAINLOG_DASHBOARD_CONTINUOUS +} TrainlogDashboardFactKind; + +typedef struct TrainlogDashboardFact { + TrainlogDashboardFactKind kind; + TrainlogTimestampKey time; + char session_id[TRAINLOG_ID_MAX + 1U]; + char entry_id[TRAINLOG_ID_MAX + 1U]; + char exercise_id[TRAINLOG_ID_MAX + 1U]; + char started_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + char equipment_id[TRAINLOG_ID_MAX + 1U]; + TrainlogTrackingMode tracking_mode; + TrainlogLoadMode load_mode; + size_t set_position; + int dose; + bool has_weight; + double weight_kg; +} TrainlogDashboardFact; + typedef struct TrainlogAppContext { TrainlogDatabase *database; TrainlogTerminal *terminal; @@ -2566,6 +2655,7 @@ typedef struct TrainlogAppContext { TrainlogBodyController body_controller; TrainlogProfileController profile_controller; TrainlogSyncController sync_controller; + TrainlogDashboardSnapshot dashboard; TrainlogFocusTarget focus; TrainlogActionModel actions; TrainlogOverlayStack overlays; @@ -2634,6 +2724,8 @@ static void app_shell_open_route(TrainlogAppContext *app, TrainlogAppRoute route); static void app_shell_load_exercise_detail_metadata(TrainlogAppContext *app); static void app_shell_refresh_list(TrainlogAppContext *app); +static void app_shell_load_body_global(TrainlogAppContext *app); +static void app_shell_select_available_body_metric(TrainlogAppContext *app); static void body_metric_slot(TrainlogBodyObservationInput *input, size_t field, bool **present, double **value) @@ -2732,8 +2824,13 @@ static bool body_controller_persist(TrainlogAppContext *app) (void)snprintf(controller->message, sizeof(controller->message), "%s", controller->editing ? "Relevé corrigé; identité, date et séance conservées." : "Relevé corporel enregistré."); - if (app->navigation.current.route == TRAINLOG_ROUTE_BODY) + if (app->navigation.current.route == TRAINLOG_ROUTE_BODY) { app_shell_refresh_list(app); + /* INVARIANT: a successful edit refreshes both halves of the body + * dashboard from the same committed database state. */ + app_shell_load_body_global(app); + app_shell_select_available_body_metric(app); + } if (controller->editing && app->navigation.current.route == TRAINLOG_ROUTE_BODY_DETAIL) (void)trainlog_database_get_body_observation(app->database, @@ -3419,6 +3516,36 @@ static void app_shell_load_body_global(TrainlogAppContext *app) sizeof(app->body_global_dates[0]), global_date_compare); } +static void app_shell_select_available_body_metric(TrainlogAppContext *app) +{ + size_t metric; + /* CONTRACT: the profile dashboard offers only measurements backed by at + * least one real observation. Weight is the deterministic first choice; + * otherwise BODY_METRICS catalogue order is the stable fallback. */ + if (app->body_metric_selected < 14U && + app->body_global_series[app->body_metric_selected].count > 0U) return; + app->body_metric_selected = 0U; + for (metric = 0U; metric < 14U; ++metric) { + if (app->body_global_series[metric].count > 0U) { + app->body_metric_selected = metric; + return; + } + } +} + +static void app_shell_move_available_body_metric(TrainlogAppContext *app, + int direction) +{ + size_t step; + for (step = 0U; step < 14U; ++step) { + app->body_metric_selected = direction < 0 + ? (app->body_metric_selected + 13U) % 14U + : (app->body_metric_selected + 1U) % 14U; + if (app->body_global_series[app->body_metric_selected].count > 0U) + return; + } +} + static void app_shell_load_body_analytics(TrainlogAppContext *app) { size_t count = 0U; @@ -3430,6 +3557,673 @@ static void app_shell_load_body_analytics(TrainlogAppContext *app) app->body_has_profile = body_analytics_profile_load(&app->body_profile); } +static bool dashboard_copy_sql_text(sqlite3_stmt *statement, int column, + char *output, size_t capacity) +{ + const unsigned char *value; + int bytes; + if (sqlite3_column_type(statement, column) != SQLITE_TEXT) return false; + value = sqlite3_column_text(statement, column); + bytes = sqlite3_column_bytes(statement, column); + if (value == NULL || bytes < 0 || (size_t)bytes >= capacity) return false; + (void)memcpy(output, value, (size_t)bytes); + output[bytes] = '\0'; + return true; +} + +/* WHY: the global dashboard compares occurrence-owned facts, while the public + * performance reader deliberately collapses each session to one representative. + * CONTRACT: this private projection emits every performed set, every explicit + * MAX exactly once, and every continuous occurrence exactly once. INVARIANT: + * malformed legacy timestamps are omitted before sorting and reported through + * invalid_data; they can never reach a comparator with uninitialized keys. */ +static TrainlogStatus dashboard_database_list_facts(TrainlogDatabase *database, + TrainlogDashboardFact *output, size_t capacity, size_t *output_count, + bool *truncated, bool *invalid_data) +{ + static const char *const SQL = + "SELECT 0,s.started_at,s.session_id,se.entry_id,e.exercise_id," + "COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,ps.position," + "CASE WHEN e.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END," + "ps.weight_kg FROM performed_sets ps " + "JOIN session_exercises se ON se.id=ps.session_exercise_row_id " + "JOIN sessions s ON s.id=se.session_row_id " + "JOIN exercises e ON e.id=se.exercise_row_id " + "UNION ALL " + "SELECT 1,s.started_at,s.session_id,se.entry_id,e.exercise_id," + "COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,0,0," + "mr.max_weight_kg FROM max_results mr " + "JOIN session_exercises se ON se.id=mr.session_exercise_row_id " + "JOIN sessions s ON s.id=se.session_row_id " + "JOIN exercises e ON e.id=se.exercise_row_id " + "UNION ALL " + "SELECT 2,s.started_at,s.session_id,se.entry_id,e.exercise_id," + "COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,0," + "ca.duration_seconds,NULL FROM continuous_activity ca " + "JOIN session_exercises se ON se.id=ca.session_exercise_row_id " + "JOIN sessions s ON s.id=se.session_row_id " + "JOIN exercises e ON e.id=se.exercise_row_id;"; + sqlite3_stmt *statement = NULL; + size_t count = 0U; + int rc; + if (output_count != NULL) *output_count = 0U; + if (truncated != NULL) *truncated = false; + if (invalid_data != NULL) *invalid_data = false; + if (database == NULL || database->connection == NULL || output_count == NULL || + truncated == NULL || invalid_data == NULL || + (capacity > 0U && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT; + rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL); + if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR; + while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { + TrainlogDashboardFact fact; + const unsigned char *tracking; + const unsigned char *load; + int kind; + (void)memset(&fact, 0, sizeof(fact)); + if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || + !dashboard_copy_sql_text(statement, 1, fact.started_at, + sizeof(fact.started_at)) || + !dashboard_copy_sql_text(statement, 2, fact.session_id, + sizeof(fact.session_id)) || + !dashboard_copy_sql_text(statement, 3, fact.entry_id, + sizeof(fact.entry_id)) || + !dashboard_copy_sql_text(statement, 4, fact.exercise_id, + sizeof(fact.exercise_id)) || + !dashboard_copy_sql_text(statement, 5, fact.equipment_id, + sizeof(fact.equipment_id)) || + sqlite3_column_type(statement, 6) != SQLITE_TEXT || + sqlite3_column_type(statement, 7) != SQLITE_TEXT || + !trainlog_timestamp_parse(fact.started_at, strlen(fact.started_at), + &fact.time)) { + *invalid_data = true; + continue; + } + kind = sqlite3_column_int(statement, 0); + if (kind < 0 || kind > 2) { + *invalid_data = true; + continue; + } + fact.kind = (TrainlogDashboardFactKind)kind; + tracking = sqlite3_column_text(statement, 6); + load = sqlite3_column_text(statement, 7); + if (strcmp((const char *)tracking, "reps") == 0) + fact.tracking_mode = TRAINLOG_TRACKING_REPS; + else if (strcmp((const char *)tracking, "duration") == 0) + fact.tracking_mode = TRAINLOG_TRACKING_DURATION; + else { *invalid_data = true; continue; } + if (strcmp((const char *)load, "none") == 0) + fact.load_mode = TRAINLOG_LOAD_NONE; + else if (strcmp((const char *)load, "external") == 0) + fact.load_mode = TRAINLOG_LOAD_EXTERNAL; + else if (strcmp((const char *)load, "assistance") == 0) + fact.load_mode = TRAINLOG_LOAD_ASSISTANCE; + else { *invalid_data = true; continue; } + if (fact.kind == TRAINLOG_DASHBOARD_WORK) { + sqlite3_int64 position; + if (sqlite3_column_type(statement, 8) != SQLITE_INTEGER || + sqlite3_column_type(statement, 9) != SQLITE_INTEGER) { + *invalid_data = true; continue; + } + position = sqlite3_column_int64(statement, 8); + if (position < 0 || (uint64_t)position > (uint64_t)SIZE_MAX) { + *invalid_data = true; continue; + } + fact.set_position = (size_t)position; + fact.dose = sqlite3_column_int(statement, 9); + } else if (fact.kind == TRAINLOG_DASHBOARD_CONTINUOUS) { + if (sqlite3_column_type(statement, 9) != SQLITE_INTEGER) { + *invalid_data = true; continue; + } + fact.dose = sqlite3_column_int(statement, 9); + } + if (fact.kind != TRAINLOG_DASHBOARD_CONTINUOUS) { + int type = sqlite3_column_type(statement, 10); + if (type == SQLITE_NULL && fact.kind == TRAINLOG_DASHBOARD_WORK) { + fact.has_weight = false; + } else if ((type != SQLITE_FLOAT && type != SQLITE_INTEGER) || + !isfinite(sqlite3_column_double(statement, 10))) { + *invalid_data = true; continue; + } else { + fact.has_weight = true; + fact.weight_kg = sqlite3_column_double(statement, 10); + } + if (fact.kind == TRAINLOG_DASHBOARD_MAX && fact.weight_kg <= 0.0) { + *invalid_data = true; continue; + } + } + if (count == capacity) { *truncated = true; continue; } + output[count++] = fact; + } + if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + *output_count = count; + return TRAINLOG_STATUS_OK; +} + +static int dashboard_fact_time_compare(const void *left, const void *right) +{ + const TrainlogDashboardFact *a = left; + const TrainlogDashboardFact *b = right; + int result = trainlog_timestamp_compare(&a->time, &b->time); + if (result != 0) return result; + result = strcmp(a->session_id, b->session_id); + if (result != 0) return result; + result = strcmp(a->entry_id, b->entry_id); + if (result != 0) return result; + if (a->kind != b->kind) return a->kind < b->kind ? -1 : 1; + return a->set_position == b->set_position ? 0 : + (a->set_position < b->set_position ? -1 : 1); +} + +static int dashboard_performance_time_compare(const void *left, const void *right) +{ + const TrainlogExercisePerformancePoint *a = left; + const TrainlogExercisePerformancePoint *b = right; + TrainlogTimestampKey a_key; + TrainlogTimestampKey b_key; + (void)trainlog_timestamp_parse(a->started_at, strlen(a->started_at), &a_key); + (void)trainlog_timestamp_parse(b->started_at, strlen(b->started_at), &b_key); + { + int result = trainlog_timestamp_compare(&a_key, &b_key); + return result != 0 ? result : strcmp(a->session_id, b->session_id); + } +} + +static int dashboard_body_time_compare(const void *left, const void *right) +{ + const TrainlogBodyMetricPoint *a = left; + const TrainlogBodyMetricPoint *b = right; + TrainlogTimestampKey a_key; + TrainlogTimestampKey b_key; + (void)trainlog_timestamp_parse(a->observed_at, strlen(a->observed_at), &a_key); + (void)trainlog_timestamp_parse(b->observed_at, strlen(b->observed_at), &b_key); + return trainlog_timestamp_compare(&a_key, &b_key); +} + +static int64_t dashboard_floor_div(int64_t value, int64_t divisor) +{ + return value >= 0 ? value / divisor : -((-value + divisor - 1) / divisor); +} + +static int64_t dashboard_period_days(TrainlogStatisticsPeriod period) +{ + switch (period) { + case TRAINLOG_STATS_7_DAYS: return 7; + case TRAINLOG_STATS_30_DAYS: return 30; + case TRAINLOG_STATS_90_DAYS: return 90; + case TRAINLOG_STATS_YEAR: return 365; + case TRAINLOG_STATS_ALL: return 0; + } + return 30; +} + +static bool dashboard_in_period(const TrainlogTimestampKey *key, + TrainlogStatisticsPeriod period) +{ + const int64_t days = dashboard_period_days(period); + /* Unix time and Trainlog's proleptic day zero differ by 719162 days. */ + const int64_t now = (int64_t)time(NULL) + INT64_C(719162) * INT64_C(86400); + return key->utc_second <= now && + (days == 0 || key->utc_second >= now - days * INT64_C(86400)); +} + +/* WHY: the selected period must change both dashboard charts, while the + * canonical rolling membership test above remains the sole inclusion rule. + * CONTRACT: 7/30/90/365 days use respectively daily, five-day, fifteen-day + * and roughly monthly (31-day) presentation buckets; Tout uses at most twelve + * equal day spans covering represented history. INVARIANT: every included + * local date is clamped into exactly one leading-to-current bucket. */ +static void dashboard_prepare_period_buckets(TrainlogDashboardSnapshot *dashboard, + int64_t current_day, int64_t earliest_day) +{ + size_t count; + int64_t span; + size_t index; + switch (dashboard->period) { + case TRAINLOG_STATS_7_DAYS: count = 7U; span = 1; break; + case TRAINLOG_STATS_30_DAYS: count = 6U; span = 5; break; + case TRAINLOG_STATS_90_DAYS: count = 6U; span = 15; break; + case TRAINLOG_STATS_YEAR: count = 12U; span = 31; break; + case TRAINLOG_STATS_ALL: { + int64_t represented = current_day >= earliest_day + ? current_day - earliest_day + 1 : 1; + count = represented < (int64_t)DASHBOARD_BUCKET_COUNT + ? (size_t)represented : DASHBOARD_BUCKET_COUNT; + if (count == 0U) count = 1U; + span = (represented + (int64_t)count - 1) / (int64_t)count; + break; + } + } + dashboard->week_count = count; + for (index = 0U; index < count; ++index) { + int64_t distance = (int64_t)(count - 1U - index) * span; + dashboard->weeks[index].start_day = current_day - distance - span + 1; + dashboard->weeks[index].span_days = span; + if (index + 1U == count) + (void)snprintf(dashboard->weeks[index].label, + sizeof(dashboard->weeks[index].label), "%s", "actuel"); + else if (dashboard->period == TRAINLOG_STATS_7_DAYS) + (void)snprintf(dashboard->weeks[index].label, + sizeof(dashboard->weeks[index].label), "J−%zu", count - 1U - index); + else if (dashboard->period == TRAINLOG_STATS_YEAR) + (void)snprintf(dashboard->weeks[index].label, + sizeof(dashboard->weeks[index].label), "M−%zu", count - 1U - index); + else + (void)snprintf(dashboard->weeks[index].label, + sizeof(dashboard->weeks[index].label), "P−%zu", count - 1U - index); + } +} + +static size_t dashboard_period_bucket(const TrainlogDashboardSnapshot *dashboard, + int64_t local_day) +{ + int64_t first; + int64_t span; + size_t slot; + if (dashboard->week_count == 0U) return 0U; + first = dashboard->weeks[0].start_day; + span = dashboard->weeks[0].span_days; + if (local_day <= first) return 0U; + slot = (size_t)((local_day - first) / span); + return slot < dashboard->week_count ? slot : dashboard->week_count - 1U; +} + +static const char *dashboard_period_bucket_summary(TrainlogStatisticsPeriod period) +{ + switch (period) { + case TRAINLOG_STATS_7_DAYS: return "7 jours · quotidien"; + case TRAINLOG_STATS_30_DAYS: return "30 jours · pas de 5 j"; + case TRAINLOG_STATS_90_DAYS: return "90 jours · pas de 15 j"; + case TRAINLOG_STATS_YEAR: return "1 an · pas de 31 j"; + case TRAINLOG_STATS_ALL: return "Tout · 12 périodes max."; + } + return "Période"; +} + +static bool dashboard_fact_external_context_qualifies(TrainlogAppContext *app, + const TrainlogDashboardFact *fact) +{ + TrainlogResolvedEquipment equipment; + if (fact->equipment_id[0] == '\0' || + trainlog_database_resolve_equipment(app->database, fact->equipment_id, + &equipment) != TRAINLOG_STATUS_OK) return false; + return strcmp(equipment.load_semantics, "external") == 0; +} + +static TrainlogExercise *dashboard_find_exercise(TrainlogExercise *exercises, + size_t count, const char *exercise_id) +{ + size_t index; + for (index = 0U; index < count; ++index) + if (strcmp(exercises[index].exercise_id, exercise_id) == 0) + return &exercises[index]; + return NULL; +} + +static int dashboard_zone_bucket_compare(const void *left, const void *right) +{ + const TrainlogDashboardZoneBucket *a = left; + const TrainlogDashboardZoneBucket *b = right; + if (a->count != b->count) return a->count > b->count ? -1 : 1; + return strcmp(a->label, b->label); +} + +/* WHY: a body-zone dashboard must describe the current catalogue rather than + * infer classifications from history or commercial names. CONTRACT: one + * canonical exercise is counted once, using only its persisted primary body + * zone. INVARIANT: missing primary relations remain visible as "Non classés"; + * secondary relations and aliases can neither create nor duplicate a bucket. */ +static bool dashboard_load_zone_distribution(TrainlogAppContext *app, + const TrainlogExercise *exercises, size_t exercise_count) +{ + TrainlogDashboardSnapshot *dashboard = &app->dashboard; + size_t exercise_index; + for (exercise_index = 0U; exercise_index < exercise_count; ++exercise_index) { + TrainlogExerciseBodyZone relations[MAX_BODY_ZONES]; + const char *label = "Non classés"; + size_t relation_count = 0U; + size_t relation_index; + size_t bucket; + if (trainlog_database_list_exercise_body_zones(app->database, + exercises[exercise_index].exercise_id, relations, MAX_BODY_ZONES, + &relation_count) != TRAINLOG_STATUS_OK) return false; + for (relation_index = 0U; relation_index < relation_count; ++relation_index) { + if (relations[relation_index].role == TRAINLOG_BODY_ZONE_PRIMARY) { + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup( + relations[relation_index].zone_id); + if (zone != NULL && !zone->is_group) label = zone->display_name; + break; + } + } + for (bucket = 0U; bucket < dashboard->zone_count; ++bucket) + if (strcmp(dashboard->zones[bucket].label, label) == 0) break; + if (bucket == dashboard->zone_count) { + if (bucket == DASHBOARD_ZONE_BUCKET_CAPACITY) return false; + (void)snprintf(dashboard->zones[bucket].label, + sizeof(dashboard->zones[bucket].label), "%s", label); + ++dashboard->zone_count; + } + ++dashboard->zones[bucket].count; + } + qsort(dashboard->zones, dashboard->zone_count, sizeof(dashboard->zones[0]), + dashboard_zone_bucket_compare); + return true; +} + +/* WHY: the landing page must remain useful without rendering-time SQL. + * CONTRACT: this bounded controller snapshot contains only actual persisted + * facts. One working-performance series has an exact exercise/equipment/load + * identity; explicit MAX is separate, and assistance/planned targets never + * enter it. All chronology is decided by Trainlog's timestamp parser, not + * SQLite text ordering. */ +static void app_shell_load_dashboard(TrainlogAppContext *app) +{ + TrainlogDashboardSnapshot *dashboard = &app->dashboard; + TrainlogStatisticsPeriod period = dashboard->period; + TrainlogExercise *exercises = NULL; + TrainlogDashboardFact *facts = NULL; + TrainlogBodyMetricPoint body[MAX_BODY_METRIC_POINTS]; + TrainlogSessionSummary sessions[MAX_SESSIONS]; + TrainlogTimestampKey newest_performance; + char newest_body[TRAINLOG_TIMESTAMP_MAX + 1U] = ""; + size_t exercise_count = 0U; + size_t fact_count = 0U; + size_t session_count = 0U; + size_t exercise_index; + int64_t current_day; + int64_t earliest_day = INT64_MAX; + time_t now; + bool has_newest_performance = false; + bool facts_truncated = false; + bool facts_invalid = false; + + (void)memset(dashboard, 0, sizeof(*dashboard)); + dashboard->period = period; + facts = calloc(DASHBOARD_FACT_CAPACITY, sizeof(*facts)); + if (facts == NULL) { + dashboard->error = true; + return; + } + if (trainlog_database_read_snapshot_begin(app->database) != TRAINLOG_STATUS_OK) { + dashboard->error = true; + free(facts); + return; + } + if (trainlog_database_list_sessions(app->database, sessions, MAX_SESSIONS, + &session_count) != TRAINLOG_STATUS_OK) { + dashboard->error = true; + goto finish; + } + dashboard->partial = session_count == MAX_SESSIONS; + /* CONTRACT: frequency is one count per actual session; a MAX marker is an + * attribute of that count and never a second session. Rolling membership + * uses canonical instants, while buckets use represented local dates. + * Presentation always ends at the canonical current day, so the bucket + * containing that day remains explicit even when it is zero. */ + now = time(NULL); + if (now == (time_t)-1) { + dashboard->error = true; + goto finish; + } + current_day = dashboard_floor_div((int64_t)now + + INT64_C(719162) * INT64_C(86400), INT64_C(86400)); + for (exercise_index = 0U; exercise_index < session_count; ++exercise_index) { + TrainlogTimestampKey key; + if (!trainlog_timestamp_parse(sessions[exercise_index].started_at, + strlen(sessions[exercise_index].started_at), &key)) { + dashboard->invalid_data = true; + continue; + } + if (!dashboard_in_period(&key, dashboard->period)) continue; + ++dashboard->completed_session_count; + if (key.local_day < earliest_day) earliest_day = key.local_day; + } + dashboard_prepare_period_buckets(dashboard, current_day, + earliest_day == INT64_MAX ? current_day : earliest_day); + for (exercise_index = 0U; exercise_index < session_count; ++exercise_index) { + TrainlogTimestampKey key; + size_t slot; + if (!trainlog_timestamp_parse(sessions[exercise_index].started_at, + strlen(sessions[exercise_index].started_at), &key) || + !dashboard_in_period(&key, dashboard->period)) continue; + slot = dashboard_period_bucket(dashboard, key.local_day); + ++dashboard->weeks[slot].sessions; + if (sessions[exercise_index].session_type == TRAINLOG_SESSION_MAX_TEST) + ++dashboard->weeks[slot].maxima; + } + if (trainlog_database_list_exercises(app->database, NULL, 0U, + &exercise_count) != TRAINLOG_STATUS_OK || + exercise_count > SIZE_MAX / sizeof(*exercises)) { + dashboard->error = true; + goto finish; + } + if (exercise_count > 0U) { + exercises = calloc(exercise_count, sizeof(*exercises)); + if (exercises == NULL || trainlog_database_list_exercises(app->database, + exercises, exercise_count, &exercise_count) != TRAINLOG_STATUS_OK) { + dashboard->error = true; + goto finish; + } + } + if (!dashboard_load_zone_distribution(app, exercises, exercise_count)) { + dashboard->error = true; + goto finish; + } + if (dashboard_database_list_facts(app->database, facts, + DASHBOARD_FACT_CAPACITY, &fact_count, &facts_truncated, + &facts_invalid) != TRAINLOG_STATUS_OK) { + dashboard->error = true; + goto finish; + } + dashboard->partial = dashboard->partial || facts_truncated; + dashboard->invalid_data = dashboard->invalid_data || facts_invalid; + qsort(facts, fact_count, sizeof(facts[0]), dashboard_fact_time_compare); + for (exercise_index = 0U; exercise_index < fact_count; ++exercise_index) { + TrainlogDashboardFact *fact = &facts[exercise_index]; + TrainlogExercise *exercise; + bool external; + bool comparable_work; + bool comparable_max; + size_t prior; + bool has_prior = false; + double prior_best = 0.0; + exercise = dashboard_find_exercise(exercises, exercise_count, + fact->exercise_id); + if (!dashboard_in_period(&fact->time, dashboard->period)) continue; + if (fact->kind == TRAINLOG_DASHBOARD_WORK) + ++dashboard->performed_set_count; + else if (fact->kind == TRAINLOG_DASHBOARD_MAX) + ++dashboard->explicit_max_count; + { + size_t seen; + for (seen = 0U; seen < exercise_index; ++seen) + if (dashboard_in_period(&facts[seen].time, dashboard->period) && + strcmp(facts[seen].exercise_id, fact->exercise_id) == 0) + break; + if (seen == exercise_index) ++dashboard->distinct_exercise_count; + } + external = dashboard_fact_external_context_qualifies(app, fact); + comparable_work = fact->kind == TRAINLOG_DASHBOARD_WORK && external && + fact->load_mode == TRAINLOG_LOAD_EXTERNAL && fact->dose > 0 && + fact->has_weight && isfinite(fact->weight_kg) && + fact->weight_kg >= 0.0; + comparable_max = fact->kind == TRAINLOG_DASHBOARD_MAX && external && + fact->load_mode != TRAINLOG_LOAD_ASSISTANCE && fact->weight_kg > 0.0; + + /* CONTRACT: Android and TUI compare the same actual observations: + * exact exercise + nonempty resolved external equipment + external + * load semantics + exact performed dose. Only facts at a strictly + * earlier canonical instant establish the prior best; bytewise IDs + * order equal-instant presentation but never manufacture an event. */ + if (comparable_work || comparable_max) { + for (prior = 0U; prior < exercise_index; ++prior) { + TrainlogDashboardFact *old = &facts[prior]; + bool same = old->kind == fact->kind && + strcmp(old->exercise_id, fact->exercise_id) == 0 && + strcmp(old->equipment_id, fact->equipment_id) == 0 && + old->tracking_mode == fact->tracking_mode && + old->load_mode == fact->load_mode && + (fact->kind != TRAINLOG_DASHBOARD_WORK || + old->dose == fact->dose); + if (!same || trainlog_timestamp_compare(&old->time, + &fact->time) >= 0) continue; + if (!has_prior || old->weight_kg > prior_best) + prior_best = old->weight_kg; + has_prior = true; + } + if (has_prior && fact->weight_kg > prior_best && + dashboard->week_count > 0U) { + size_t slot = dashboard_period_bucket(dashboard, + fact->time.local_day); + if (comparable_work) + ++dashboard->weeks[slot].working_improvements; + else ++dashboard->weeks[slot].max_improvements; + } + } + if (comparable_max && exercise != NULL) { + TrainlogTimestampKey current_max; + int order = 1; + if (dashboard->has_explicit_max && + trainlog_timestamp_parse(dashboard->max_timestamp, + strlen(dashboard->max_timestamp), ¤t_max)) + order = trainlog_timestamp_compare(&fact->time, ¤t_max); + if (!dashboard->has_explicit_max || order > 0 || + (order == 0 && strcmp(fact->exercise_id, + dashboard->max_exercise.exercise_id) < 0)) { + dashboard->has_explicit_max = true; + dashboard->max_exercise = *exercise; + dashboard->max_weight_kg = fact->weight_kg; + (void)snprintf(dashboard->max_timestamp, + sizeof(dashboard->max_timestamp), "%s", fact->started_at); + } + } + if (comparable_work && exercise != NULL) { + int order = has_newest_performance ? + trainlog_timestamp_compare(&fact->time, &newest_performance) : 1; + if (!has_newest_performance || order > 0 || + (order == 0 && strcmp(fact->exercise_id, + dashboard->exercise.exercise_id) < 0)) { + dashboard->has_performance = true; + dashboard->exercise = *exercise; + dashboard->performance_load_mode = fact->load_mode; + dashboard->performance_tracking_mode = fact->tracking_mode; + dashboard->performance_dose = fact->dose; + (void)snprintf(dashboard->performance_equipment_id, + sizeof(dashboard->performance_equipment_id), "%s", + fact->equipment_id); + newest_performance = fact->time; + has_newest_performance = true; + } + } + } + if (dashboard->has_performance) { + TrainlogResolvedEquipment equipment; + size_t index; + if (trainlog_database_resolve_equipment(app->database, + dashboard->performance_equipment_id, &equipment) == + TRAINLOG_STATUS_OK) + (void)snprintf(dashboard->performance_equipment_label, + sizeof(dashboard->performance_equipment_label), "%s", + equipment.display_name); + for (index = 0U; index < fact_count && + dashboard->performance_count < MAX_SESSIONS; ++index) { + TrainlogDashboardFact *fact = &facts[index]; + TrainlogExercisePerformancePoint *point; + if (fact->kind != TRAINLOG_DASHBOARD_WORK || fact->dose <= 0 || + fact->load_mode != dashboard->performance_load_mode || + fact->tracking_mode != dashboard->performance_tracking_mode || + fact->dose != dashboard->performance_dose || + strcmp(fact->exercise_id, dashboard->exercise.exercise_id) != 0 || + strcmp(fact->equipment_id, + dashboard->performance_equipment_id) != 0 || + !dashboard_in_period(&fact->time, dashboard->period)) continue; + point = &dashboard->performance[dashboard->performance_count++]; + (void)memset(point, 0, sizeof(*point)); + (void)snprintf(point->session_id, sizeof(point->session_id), "%s", + fact->session_id); + (void)snprintf(point->started_at, sizeof(point->started_at), "%s", + fact->started_at); + point->tracking_mode = fact->tracking_mode; + point->load_mode = fact->load_mode; + point->actual_set_count = 1U; + point->has_performance = 1; + point->metric_value = fact->dose; + point->has_weight = 1; + point->weight_kg = fact->weight_kg; + (void)snprintf(point->equipment_id, sizeof(point->equipment_id), "%s", + fact->equipment_id); + } + if (dashboard->performance_count == MAX_SESSIONS) + dashboard->partial = true; + qsort(dashboard->performance, dashboard->performance_count, + sizeof(dashboard->performance[0]), dashboard_performance_time_compare); + } + + for (exercise_index = 0U; exercise_index < 14U; ++exercise_index) { + size_t count = 0U; + size_t point_index; + if (trainlog_database_list_body_metric_points(app->database, + BODY_METRICS[exercise_index].metric, body, + MAX_BODY_METRIC_POINTS, &count) != TRAINLOG_STATUS_OK) { + dashboard->error = true; + continue; + } + if (count == MAX_BODY_METRIC_POINTS) dashboard->partial = true; + for (point_index = 0U; point_index < count; ++point_index) { + TrainlogTimestampKey candidate; + TrainlogTimestampKey current; + int order = 1; + if (!trainlog_timestamp_parse(body[point_index].observed_at, + strlen(body[point_index].observed_at), &candidate)) { + dashboard->invalid_data = true; + continue; + } + if (!dashboard_in_period(&candidate, dashboard->period)) continue; + if (newest_body[0] != '\0') { + (void)trainlog_timestamp_parse(newest_body, strlen(newest_body), ¤t); + order = trainlog_timestamp_compare(&candidate, ¤t); + } + if (newest_body[0] == '\0' || order > 0 || + (order == 0 && exercise_index < dashboard->body_metric)) { + dashboard->has_body = true; + dashboard->body_metric = exercise_index; + (void)snprintf(newest_body, sizeof(newest_body), "%s", + body[point_index].observed_at); + } + } + } + if (dashboard->has_body) { + size_t count = 0U; + size_t point_index; + if (trainlog_database_list_body_metric_points(app->database, + BODY_METRICS[dashboard->body_metric].metric, body, + MAX_BODY_METRIC_POINTS, &count) != TRAINLOG_STATUS_OK) { + dashboard->error = true; + goto finish; + } + if (count == MAX_BODY_METRIC_POINTS) + dashboard->partial = true; + for (point_index = 0U; point_index < count; ++point_index) { + TrainlogTimestampKey key; + if (!trainlog_timestamp_parse(body[point_index].observed_at, + strlen(body[point_index].observed_at), &key)) { + dashboard->invalid_data = true; + continue; + } + if (dashboard_in_period(&key, dashboard->period)) + dashboard->body[dashboard->body_count++] = body[point_index]; + } + qsort(dashboard->body, dashboard->body_count, sizeof(dashboard->body[0]), + dashboard_body_time_compare); + } + +finish: + if (trainlog_database_read_snapshot_end(app->database, !dashboard->error) != + TRAINLOG_STATUS_OK) dashboard->error = true; + free(exercises); + free(facts); +} + static void sync_controller_load_history(TrainlogSyncController *sync) { char selected_id[TRAINLOG_ID_MAX + 1U] = ""; @@ -4343,6 +5137,10 @@ static void app_shell_actions(TrainlogAppContext *app) TRAINLOG_INTENT_NONE, route); app_shell_add_action(app, "body.global", 'o', "o Vue globale", 6U, TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "body.profile.metric.previous", TRAINLOG_KEY_LEFT, + "← Mesure précédente", 7U, TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "body.profile.metric.next", TRAINLOG_KEY_RIGHT, + "→ Mesure suivante", 8U, TRAINLOG_INTENT_NONE, route); } if (route == TRAINLOG_ROUTE_BODY_DETAIL) app_shell_add_action(app, "body.detail.edit", 'e', "e Modifier", 1U, @@ -4426,8 +5224,9 @@ static void app_shell_actions(TrainlogAppContext *app) static void app_shell_render_footer(TrainlogAppContext *app) { const TrainlogOverlay *overlay = trainlog_overlays_top(&app->overlays); - const TrainlogAction *first; - const TrainlogAction *second; + const TrainlogAction *contextual[2] = {NULL, NULL}; + size_t position; + size_t contextual_count = 0U; trainlog_surface_erase(app->footer); trainlog_surface_set_role(app->footer, TRAINLOG_COLOR_DEFAULT, TRAINLOG_RGB_MANTLE, TRAINLOG_TEXT_NORMAL); @@ -4443,10 +5242,24 @@ static void app_shell_render_footer(TrainlogAppContext *app) trainlog_surface_printf(app->footer, 1, 2, "Échap fermer F7 Actions ? Aide"); return; } - first = trainlog_actions_at_priority(&app->actions, 0U); - second = trainlog_actions_at_priority(&app->actions, 1U); - if (first != NULL) trainlog_surface_printf(app->footer, 0, 2, "%s", first->label); - if (second != NULL) trainlog_surface_printf(app->footer, 0, 24, "%s", second->label); + /* INVARIANT: stable action identity is deduplicated after every screen, + * focus and shell-wide contributor has been aggregated, before layout. + * Reserved global actions remain in F7 but are rendered once in row two. */ + for (position = 0U; position < app->actions.count && contextual_count < 2U; + ++position) { + const TrainlogAction *candidate = trainlog_actions_at_priority( + &app->actions, position); + if (candidate == NULL || + strcmp(candidate->identifier, "navigation") == 0 || + strcmp(candidate->identifier, "actions") == 0 || + strcmp(candidate->identifier, "help") == 0 || + strcmp(candidate->identifier, "back") == 0) continue; + contextual[contextual_count++] = candidate; + } + if (contextual[0] != NULL) + trainlog_surface_printf(app->footer, 0, 2, "%s", contextual[0]->label); + if (contextual[1] != NULL) + trainlog_surface_printf(app->footer, 0, 24, "%s", contextual[1]->label); trainlog_surface_printf(app->footer, 1, 2, "F6 Navigation F7 Actions ? Aide %s", app->navigation.current.route == TRAINLOG_ROUTE_HOME ? "q Quitter" : "Échap Retour"); @@ -4865,8 +5678,28 @@ static int app_shell_graph_height(const TrainlogAppContext *app, int top) return available; } +/* WHY: a statistical line must show the relationship between two observations, + * rather than fill horizontal space with decorative ASCII. CONTRACT: callers + * pass distinct x coordinates inside their plot; this helper writes only + * Unicode line glyphs between them. INVARIANT: each interpolated cell stays in + * the caller-owned chart rectangle. */ +static void app_shell_draw_chart_segment(TrainlogSurface *surface, int x1, int y1, + int x2, int y2) +{ + int x; + if (surface == NULL || x2 <= x1) return; + for (x = x1 + 1; x < x2; ++x) { + int y = y1 + ((y2 - y1) * (x - x1)) / (x2 - x1); + int delta = y2 - y1; + uint32_t glyph = delta == 0 ? 0x2500U : + (delta < 0 ? 0x2571U : 0x2572U); + trainlog_surface_draw(surface, y, x, glyph); + } +} + static void app_shell_draw_performance_graph( TrainlogAppContext *app, + const TrainlogExercisePerformancePoint *context, TrainlogLoadMode mode, TrainlogTrackingMode tracking_mode, int top, @@ -4891,12 +5724,22 @@ static void app_shell_draw_performance_graph( const TrainlogExercisePerformancePoint *point = &app->performance_points[index]; if (point->has_performance != 0 && point->load_mode == mode && + (context == NULL || strcmp(point->equipment_id, + context->equipment_id) == 0) && (!measured_max || point->session_type == TRAINLOG_SESSION_MAX_TEST)) indices[selected_count++] = index; } if (selected_count == 0U) { trainlog_surface_printf(app->content, top, 2, - "Aucune performance réussie pour ce mode."); + "Aucun point exploitable pour ce contexte."); + return; + } + if (selected_count == 1U) { + char value[128]; + exercise_format_performance(&app->performance_points[indices[0]], value, + sizeof(value)); + trainlog_surface_printf(app->content, top, 2, + "%s · 1 point · tendance indisponible", value); return; } minimum = exercise_graph_value(&app->performance_points[indices[0]]); @@ -4907,7 +5750,6 @@ static void app_shell_draw_performance_graph( if (value < minimum) minimum = value; if (value > maximum) maximum = value; } - if (maximum == minimum) { minimum -= 1.0; maximum += 1.0; } trainlog_surface_set_role(app->content, mode == TRAINLOG_LOAD_ASSISTANCE ? TRAINLOG_COLOR_WARNING : TRAINLOG_COLOR_DEFAULT, @@ -4921,35 +5763,31 @@ static void app_shell_draw_performance_graph( : tracking_mode == TRAINLOG_TRACKING_DURATION ? (measured_max ? "Durée MAX mesurée" : "Meilleure durée") : (measured_max ? "Répétitions MAX mesurées" : "Meilleures répétitions")); - trainlog_surface_printf(app->content, top, 2, "%.1f", maximum); - trainlog_surface_printf(app->content, top + height - 1, 2, "%.1f", minimum); + trainlog_surface_printf(app->content, top, 2, "haut %.1f", maximum); + trainlog_surface_printf(app->content, top + height - 1, 2, "bas %.1f", minimum); trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); { int previous_x = -1; int previous_y = -1; + for (int axis_row = top; axis_row < top + height; ++axis_row) + trainlog_surface_draw(app->content, axis_row, left - 2, 0x2502U); size_t order; for (order = selected_count; order > 0U; --order) { size_t chronological = selected_count - order; const TrainlogExercisePerformancePoint *point = &app->performance_points[indices[order - 1U]]; - double ratio = (exercise_graph_value(point) - minimum) / - (maximum - minimum); - int x = selected_count == 1U ? left + width / 2 : left + + int x = left + (int)((chronological * (size_t)(width - 1)) / (selected_count - 1U)); - int y = top + height - 1 - - (int)(ratio * (double)(height - 1)); + int y = normalized_graph_row(exercise_graph_value(point), minimum, + maximum, top, height); if (previous_x >= 0 && x > previous_x) { - int line_x; - for (line_x = previous_x + 1; line_x < x; ++line_x) { - int line_y = previous_y + ((y - previous_y) * - (line_x - previous_x)) / (x - previous_x); - trainlog_surface_draw(app->content, line_y, line_x, '.'); - } + app_shell_draw_chart_segment(app->content, previous_x, previous_y, + x, y); } trainlog_surface_draw(app->content, y, x, - chronological + 1U == selected_count ? 'O' : '*'); + chronological + 1U == selected_count ? 0x25cfU : 0x25cbU); previous_x = x; previous_y = y; } @@ -4985,6 +5823,11 @@ static void app_shell_draw_body_points(TrainlogAppContext *app, "Aucune donnée pour cette mesure."); return; } + if (count == 1U) { + trainlog_surface_printf(app->content, top, 2, + "%.2f %s · 1 relevé · tendance indisponible", points[0].value, unit); + return; + } if (height < 3 || width < 10 || top + height >= app->layout.content.height) return; start = count > (size_t)width ? count - (size_t)width : 0U; @@ -4994,25 +5837,159 @@ static void app_shell_draw_body_points(TrainlogAppContext *app, if (points[index].value < minimum) minimum = points[index].value; if (points[index].value > maximum) maximum = points[index].value; } - if (maximum == minimum) { minimum -= 1.0; maximum += 1.0; } - trainlog_surface_printf(app->content, top, 2, "%.1f", maximum); - trainlog_surface_printf(app->content, top + height - 1, 2, "%.1f", minimum); + trainlog_surface_printf(app->content, top, 2, "haut %.1f", maximum); + trainlog_surface_printf(app->content, top + height - 1, 2, "bas %.1f", minimum); trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); - for (index = start; index < count; ++index) { - double ratio = (points[index].value - minimum) / (maximum - minimum); - int x = left + (int)(index - start); - int y = top + height - 1 - (int)(ratio * (double)(height - 1)); - trainlog_surface_draw(app->content, y, x, '*'); + for (int axis_row = top; axis_row < top + height; ++axis_row) + trainlog_surface_draw(app->content, axis_row, left - 2, 0x2502U); + { + int previous_x = -1; + int previous_y = -1; + size_t visible = count - start; + for (index = start; index < count; ++index) { + int x = left + (int)(((index - start) * (size_t)(width - 1)) / + (visible - 1U)); + int y = normalized_graph_row(points[index].value, minimum, maximum, + top, height); + if (previous_x >= 0) + app_shell_draw_chart_segment(app->content, previous_x, previous_y, + x, y); + trainlog_surface_draw(app->content, y, x, + index + 1U == count ? 0x25cfU : 0x25cbU); + previous_x = x; + previous_y = y; + } } trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); - trainlog_surface_printf(app->content, top + height, left, - "%zu point(s) · %s", count, unit); + { + char oldest[11]; + char newest[11]; + exercise_short_date(points[start].observed_at, oldest); + exercise_short_date(points[count - 1U].observed_at, newest); + trainlog_surface_printf(app->content, top + height, left, + "%s → %s · %zu relevés · %s", oldest, newest, count, unit); + } trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); } +static void app_shell_render_body_profile(TrainlogAppContext *app) +{ + const TrainlogBodyObservationRecord *record; + const bool *present[13]; + const double *values[13]; + double maximum = 0.0; + size_t circumference_count = 0U; + size_t shown = 0U; + size_t index; + int evolution_top; + int profile_row = 6; + int bar_left = 23; + int bar_capacity = app->layout.content.width - bar_left - 10; + TrainlogGlobalBodySeries *series; + + if (app->body_snapshot_error) { + trainlog_surface_printf(app->content, 3, 2, + "Impossible de lire les mensurations."); + return; + } + if (app->loaded_count == 0U) { + trainlog_surface_printf(app->content, 3, 2, + "Aucun relevé corporel. a pour ajouter un relevé."); + trainlog_surface_printf(app->content, 5, 2, + "ÉVOLUTION DANS LE TEMPS · aucune métrique disponible"); + return; + } + if (app->list.selected_index >= app->loaded_count) + app->list.selected_index = 0U; + record = &app->body_records[app->list.selected_index]; + { + const bool *const flags[13] = {&record->has_neck, + &record->has_shoulders, &record->has_chest, &record->has_waist, + &record->has_hips, &record->has_left_arm, &record->has_right_arm, + &record->has_left_forearm, &record->has_right_forearm, + &record->has_left_thigh, &record->has_right_thigh, + &record->has_left_calf, &record->has_right_calf}; + const double *const measurements[13] = {&record->neck_cm, + &record->shoulders_cm, &record->chest_cm, &record->waist_cm, + &record->hips_cm, &record->left_arm_cm, &record->right_arm_cm, + &record->left_forearm_cm, &record->right_forearm_cm, + &record->left_thigh_cm, &record->right_thigh_cm, + &record->left_calf_cm, &record->right_calf_cm}; + (void)memcpy(present, flags, sizeof(present)); + (void)memcpy(values, measurements, sizeof(values)); + } + for (index = 0U; index < 13U; ++index) { + if (!*present[index]) continue; + ++circumference_count; + if (*values[index] > maximum) maximum = *values[index]; + } + + /* WHY: weight and circumference have unrelated units and must never share + * a visual scale. INVARIANT: absent circumference fields produce no row; + * every rendered bar encodes the selected observation's actual centimetres. */ + trainlog_surface_printf(app->content, 3, 2, + "PROFIL ACTUEL · relevé %zu/%zu · %s", app->list.selected_index + 1U, + app->loaded_count, record->observed_at); + if (record->has_body_weight) + trainlog_surface_printf(app->content, 4, 2, "Poids : %.2f kg", + record->body_weight_kg); + else trainlog_surface_printf(app->content, 4, 2, + "Poids : non renseigné"); + trainlog_surface_printf(app->content, 5, 2, + "PROFIL CIRCONFÉRENCES · échelle au plus grand cm présent"); + + evolution_top = app->layout.content.height >= 26 + ? 20 : app->layout.content.height - 6; + if (evolution_top < 10) evolution_top = 10; + if (bar_capacity < 1) bar_capacity = 1; + for (index = 0U; index < 13U && profile_row < evolution_top - 1; ++index) { + int length; + int column; + if (!*present[index]) continue; + length = maximum > 0.0 + ? (int)((*values[index] / maximum) * (double)bar_capacity) : 1; + if (length < 1) length = 1; + if (length > bar_capacity) length = bar_capacity; + trainlog_surface_printf(app->content, profile_row, 2, "%-19s", + BODY_METRICS[index + 1U].label); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + for (column = 0; column < length; ++column) + trainlog_surface_draw(app->content, profile_row, + bar_left + column, 0x2586U); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->content, profile_row, + bar_left + length + 1, "%.1f cm", *values[index]); + ++profile_row; + ++shown; + } + if (circumference_count == 0U) + trainlog_surface_printf(app->content, profile_row, 2, + "Aucune circonférence dans ce relevé."); + else if (shown < circumference_count) + trainlog_surface_printf(app->content, evolution_top - 1, 2, + "… %zu autre(s) circonférence(s) · ↑↓ change le relevé", + circumference_count - shown); + + series = &app->body_global_series[app->body_metric_selected]; + if (series->count == 0U) { + trainlog_surface_printf(app->content, evolution_top, 2, + "ÉVOLUTION DANS LE TEMPS · aucune métrique disponible"); + return; + } + trainlog_surface_printf(app->content, evolution_top, 2, + "ÉVOLUTION DANS LE TEMPS · ←/→ %s", + BODY_METRICS[app->body_metric_selected].label); + app_shell_draw_body_points(app, series->points, series->count, + evolution_top + 1, + app->layout.content.height - evolution_top - 2, + BODY_METRICS[app->body_metric_selected].unit); +} + static void app_shell_render_body_controller(TrainlogAppContext *app) { TrainlogBodyController *controller = &app->body_controller; @@ -5112,6 +6089,381 @@ static void app_shell_surface_segment(TrainlogSurface *surface, int x1, int y1, } } +static void app_shell_dashboard_sparkline(TrainlogAppContext *app, + const double *values, size_t count, int left, int top, int width, int height) +{ + double minimum; + double maximum; + size_t start; + size_t index; + int previous_x = -1; + int previous_y = -1; + /* INVARIANT: a line encodes change between actual observations. A single + * point has no trend and must never acquire a synthetic axis or baseline. */ + if (count < 2U || width < 4 || height < 2) return; + start = count > (size_t)width ? count - (size_t)width : 0U; + minimum = values[start]; + maximum = values[start]; + for (index = start + 1U; index < count; ++index) { + if (values[index] < minimum) minimum = values[index]; + if (values[index] > maximum) maximum = values[index]; + } + if (minimum == maximum) { minimum -= 1.0; maximum += 1.0; } + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + for (index = start; index < count; ++index) { + size_t relative = index - start; + size_t visible = count - start; + int x = visible == 1U ? left + width / 2 : left + + (int)((relative * (size_t)(width - 1)) / (visible - 1U)); + int y = top + height - 1 - (int)(((values[index] - minimum) / + (maximum - minimum)) * (double)(height - 1)); + if (previous_x >= 0) + app_shell_surface_segment(app->content, previous_x, previous_y, + x, y, 0x2584U); + trainlog_surface_draw(app->content, y, x, + index + 1U == count ? 0x2588U : 0x2584U); + previous_x = x; + previous_y = y; + } + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); +} + +static void app_shell_dashboard_performance_events(TrainlogAppContext *app, + const TrainlogDashboardSnapshot *dashboard, int row, int column) +{ + size_t index; + size_t working = 0U; + size_t maxima = 0U; + for (index = 0U; index < dashboard->week_count; ++index) { + working += dashboard->weeks[index].working_improvements; + maxima += dashboard->weeks[index].max_improvements; + } + /* Text totals are the accessible equivalent of the Unicode plot. Empty + * buckets remain dots: a low block would falsely suggest an event. */ + trainlog_surface_printf(app->content, row, column, + "%s · travail %zu · MAX %zu%s", + dashboard_period_bucket_summary(dashboard->period), working, maxima, + working + maxima == 0U ? " · données insuffisantes" : ""); + for (index = 0U; index < dashboard->week_count && + column + 2 + (int)index * 3 < app->layout.content.width; ++index) { + size_t level = dashboard->weeks[index].working_improvements + + dashboard->weeks[index].max_improvements; + trainlog_surface_set_role(app->content, + dashboard->weeks[index].max_improvements > 0U ? + TRAINLOG_COLOR_WARNING : TRAINLOG_COLOR_GRAPH, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + trainlog_surface_draw(app->content, row + 1, column + 2 + (int)index * 3, + level > 0U ? 0x2588U : 0x00b7U); + } + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); +} + +static void app_shell_dashboard_wide_performance_events(TrainlogAppContext *app, + const TrainlogDashboardSnapshot *dashboard, int row, int column) +{ + size_t index; + size_t working = 0U; + size_t maxima = 0U; + for (index = 0U; index < dashboard->week_count; ++index) { + working += dashboard->weeks[index].working_improvements; + maxima += dashboard->weeks[index].max_improvements; + } + trainlog_surface_printf(app->content, row, column, + "%s · travail %zu · MAX %zu", + dashboard_period_bucket_summary(dashboard->period), working, maxima); + for (index = 0U; index < dashboard->week_count; ++index) { + size_t level = dashboard->weeks[index].working_improvements + + dashboard->weeks[index].max_improvements; + trainlog_surface_set_role(app->content, + dashboard->weeks[index].max_improvements > 0U ? + TRAINLOG_COLOR_WARNING : TRAINLOG_COLOR_GRAPH, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + trainlog_surface_draw(app->content, row + 1, + column + (int)index * 3, level > 0U ? 0x2588U : 0x00b7U); + } + if (working + maxima == 0U) + trainlog_surface_printf(app->content, row + 2, column, + "Données insuffisantes."); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); +} + +static void app_shell_dashboard_decimal_comma(double value, char *output, + size_t output_size) +{ + char *separator; + if (output == NULL || output_size == 0U) return; + (void)snprintf(output, output_size, "%.2f", value); + separator = strchr(output, '.'); + if (separator != NULL) *separator = ','; +} + +/* WHY: the landing page needs a genuinely global catalogue view, while the + * exercise list remains the destination for inspection. CONTRACT: bars encode + * active canonical-exercise counts prepared in the dashboard snapshot only. + * INVARIANT: zero counts never receive invented bars, and a compact viewport + * deterministically retains the count/label-sorted leading buckets. */ +static void app_shell_dashboard_zone_chart(TrainlogAppContext *app, + const TrainlogDashboardSnapshot *dashboard, int top, int left, int width, + int rows) +{ + size_t index; + size_t bucket_rows; + size_t maximum = 0U; + bool separated_rows; + int row_stride; + int label_width; + int bar_width; + if (width < 20 || rows < 1) return; + if (dashboard->zone_count == 0U) { + trainlog_surface_printf(app->content, top, left, + "Aucun exercice actif à répartir."); + return; + } + for (index = 0U; index < dashboard->zone_count; ++index) + if (dashboard->zones[index].count > maximum) maximum = dashboard->zones[index].count; + label_width = width >= 64 ? 20 : width >= 48 ? 16 : 12; + bar_width = width - label_width - 6; + if (bar_width < 4) return; + /* Spacing is a wide-layout enhancement, never a reason to hide catalogue + * buckets which already fit. Thin bars remain independently legible on + * adjacent rows when the available chart height cannot hold every gap. */ + separated_rows = app->layout.columns >= 100 && + dashboard->zone_count <= (size_t)((rows + 1) / 2); + row_stride = separated_rows ? 2 : 1; + bucket_rows = separated_rows ? (size_t)((rows + 1) / 2) : (size_t)rows; + if (dashboard->zone_count < bucket_rows) bucket_rows = dashboard->zone_count; + if (dashboard->zone_count > bucket_rows) { + size_t summary_row = separated_rows + ? bucket_rows * (size_t)row_stride - 1U : bucket_rows; + if (summary_row >= (size_t)rows && bucket_rows > 1U) --bucket_rows; + } + for (index = 0U; index < bucket_rows; ++index) { + int row = top + (int)index * row_stride; + int blocks = maximum == 0U ? 0 : (int)((dashboard->zones[index].count * + (size_t)bar_width + maximum - 1U) / maximum); + int block; + /* CONTRACT: label, bar and right-edge count own separate columns; + * independently scaled partial-height rows can never form the former + * solid staircase. Wide layouts leave a blank row between buckets. */ + trainlog_surface_printf(app->content, row, left, + "%-*.*s", label_width, label_width, dashboard->zones[index].label); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + for (block = 0; block < blocks; ++block) + trainlog_surface_draw(app->content, row, + left + label_width + 1 + block, 0x2586U); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->content, row, + left + label_width + 2 + bar_width, "%zu", + dashboard->zones[index].count); + } + if (dashboard->zone_count > bucket_rows) { + size_t summary_row = separated_rows + ? bucket_rows * (size_t)row_stride - 1U : bucket_rows; + if (summary_row >= (size_t)rows) return; + trainlog_surface_printf(app->content, + top + (int)summary_row, left, + "+ %zu zones dans le catalogue", dashboard->zone_count - bucket_rows); + } +} + +static void app_shell_dashboard_frequency_chart(TrainlogAppContext *app, + const TrainlogDashboardSnapshot *dashboard, int top, int left, int width, + int height) +{ + size_t index; + size_t maximum = 0U; + int baseline; + if (width < 12 || height < 1) return; + if (dashboard->week_count == 0U) { + trainlog_surface_printf(app->content, top, left, + "Aucune séance dans cette période."); + return; + } + for (index = 0U; index < dashboard->week_count; ++index) + if (dashboard->weeks[index].sessions > maximum) + maximum = dashboard->weeks[index].sessions; + trainlog_surface_printf(app->content, top, left, + "%s · actuel %zu séance%s · MAX %zu", + dashboard_period_bucket_summary(dashboard->period), + dashboard->weeks[dashboard->week_count - 1U].sessions, + dashboard->weeks[dashboard->week_count - 1U].sessions == 1U ? "" : "s", + dashboard->weeks[dashboard->week_count - 1U].maxima); + baseline = top + height; + for (index = 0U; index < dashboard->week_count; ++index) { + const char *label = dashboard->weeks[index].label; + int label_margin = 3; + int x = dashboard->week_count > 1U + ? left + label_margin + (int)((index * (size_t)(width - 2 * label_margin - 1)) / + (dashboard->week_count - 1U)) + : left + width / 2; + int level = maximum == 0U ? 0 : (int)((dashboard->weeks[index].sessions * + (size_t)height + maximum - 1U) / maximum); + int bar_row; + trainlog_surface_set_role(app->content, + dashboard->weeks[index].maxima > 0U ? TRAINLOG_COLOR_WARNING : + (index + 1U == dashboard->week_count ? TRAINLOG_COLOR_ACCENT : + TRAINLOG_COLOR_GRAPH), TRAINLOG_RGB_BASE, + index + 1U == dashboard->week_count ? TRAINLOG_TEXT_BOLD : + TRAINLOG_TEXT_NORMAL); + if (level == 0) trainlog_surface_draw(app->content, baseline, x, 0x00b7U); + else for (bar_row = 0; bar_row < level; ++bar_row) + trainlog_surface_draw(app->content, baseline - bar_row, x, 0x2588U); + /* Each period bucket owns one x coordinate: the count, optional MAX + * marker, bar and label all remain vertically aligned at that point. */ + trainlog_surface_printf(app->content, baseline - (level > 0 ? level - 1 : 0), + x, "%zu", dashboard->weeks[index].sessions); + if (dashboard->weeks[index].maxima > 0U) { + /* WHY: the minimum dashboard has one bar row; lifting its marker + * would overwrite the frequency summary. CONTRACT: compact MAX + * markers sit beside the count, taller charts keep them above it. */ + int marker_row = height == 1 ? baseline : + baseline - (level > 0 ? level : 0); + int marker_column = height == 1 ? x - 2 : x; + trainlog_surface_draw(app->content, marker_row, marker_column, + 0x25c6U); + } + trainlog_surface_printf(app->content, baseline + 1, + x - (index + 1U == dashboard->week_count ? 3 : 1), "%s", label); + } + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); +} + +static void app_shell_render_dashboard(TrainlogAppContext *app) +{ + const TrainlogDashboardSnapshot *dashboard = &app->dashboard; + double body_values[MAX_BODY_METRIC_POINTS]; + size_t index; + for (index = 0U; index < dashboard->body_count; ++index) + body_values[index] = dashboard->body[index].value; + trainlog_surface_printf(app->content, 2, 2, + "Période %s %s %s %s %s", + dashboard->period == TRAINLOG_STATS_7_DAYS ? "[7j]" : "7j", + dashboard->period == TRAINLOG_STATS_30_DAYS ? "[30j]" : "30j", + dashboard->period == TRAINLOG_STATS_90_DAYS ? "[90j]" : "90j", + dashboard->period == TRAINLOG_STATS_YEAR ? "[1an]" : "1an", + dashboard->period == TRAINLOG_STATS_ALL ? "[Tout]" : "Tout"); + trainlog_surface_printf(app->content, 3, 2, + "Séances %zu Séries %zu Exercices pratiqués %zu MAX %zu", + dashboard->completed_session_count, dashboard->performed_set_count, + dashboard->distinct_exercise_count, dashboard->explicit_max_count); + if (dashboard->error) + trainlog_surface_printf(app->content, 4, 2, + "Certaines données n’ont pas pu être lues."); + else if (dashboard->invalid_data) + trainlog_surface_printf(app->content, 4, 2, + "Données historiques invalides ignorées."); + else if (dashboard->partial) + trainlog_surface_printf(app->content, 4, 2, + "Vue bornée : historique récent partiel."); + if (app->layout.columns >= 100 && app->layout.content.width >= 70) { + int right = app->layout.content.width / 2 + 2; + int body_width = app->layout.content.width - right - 2; + int frequency_top = 10; + int chart_height = app->layout.content.height - frequency_top - 11; + int zones_top; + int zone_rows; + if (chart_height > 5) chart_height = 5; + if (chart_height < 2) chart_height = 2; + trainlog_surface_printf(app->content, 5, 2, "%s Progression globale", + app->content_selected == 0U ? "▶" : " "); + app_shell_dashboard_wide_performance_events(app, dashboard, 6, 4); + trainlog_surface_printf(app->content, 5, right, "%s Mensurations", + app->content_selected == 1U ? "▶" : " "); + if (dashboard->has_body && dashboard->body_count > 0U) { + double latest = dashboard->body[dashboard->body_count - 1U].value; + char latest_text[32]; + app_shell_dashboard_decimal_comma(latest, latest_text, + sizeof(latest_text)); + if (dashboard->body_count == 1U) + trainlog_surface_printf(app->content, 6, right, + "%s · %s %s · 1 relevé", + BODY_METRICS[dashboard->body_metric].label, latest_text, + BODY_METRICS[dashboard->body_metric].unit); + else { + double delta = latest - dashboard->body[ + dashboard->body_count - 2U].value; + char delta_text[32]; + app_shell_dashboard_decimal_comma(fabs(delta), delta_text, + sizeof(delta_text)); + trainlog_surface_printf(app->content, 6, right, + "%s · %s %s · Δ %c%s", + BODY_METRICS[dashboard->body_metric].label, latest_text, + BODY_METRICS[dashboard->body_metric].unit, + delta >= 0.0 ? '+' : '-', delta_text); + app_shell_dashboard_sparkline(app, body_values, + dashboard->body_count, right, 7, body_width, 2); + } + } else trainlog_surface_printf(app->content, 6, right, + "Aucune mensuration."); + trainlog_surface_printf(app->content, 10, 2, "%s Fréquence", + app->content_selected == 2U ? "▶" : " "); + app_shell_dashboard_frequency_chart(app, dashboard, 11, 4, + app->layout.content.width - 8, chart_height); + /* INVARIANT: frequency owns summary, bars, MAX markers and labels; + * distribution begins strictly after its final label row. */ + zones_top = 14 + chart_height; + zone_rows = app->layout.content.height - zones_top - 2; + trainlog_surface_printf(app->content, zones_top - 1, 2, + "%s Répartition du catalogue", + app->content_selected == 3U ? "▶" : " "); + app_shell_dashboard_zone_chart(app, dashboard, zones_top, 4, + app->layout.content.width - 8, zone_rows); + if (app->layout.content.height > zones_top + 4) + trainlog_surface_printf(app->content, + app->layout.content.height - 2, 2, + "↑↓ région · Entrée détail · chiffres période"); + return; + } + /* Compact planes retain each dashboard region. The smaller form omits a + * decorative body sparkline, not data: a real two-point chart belongs in + * the detail view, while the zone bars and frequency bars stay visible. */ + trainlog_surface_printf(app->content, 5, 2, "%s Progression globale", + app->content_selected == 0U ? "▶" : " "); + app_shell_dashboard_performance_events(app, dashboard, 5, 25); + trainlog_surface_printf(app->content, 7, 2, "%s Mensurations", + app->content_selected == 1U ? "▶" : " "); + if (dashboard->has_body && dashboard->body_count > 0U) { + double latest = dashboard->body[dashboard->body_count - 1U].value; + char latest_text[32]; + app_shell_dashboard_decimal_comma(latest, latest_text, sizeof(latest_text)); + if (dashboard->body_count == 1U) + trainlog_surface_printf(app->content, 8, 4, + "%s · %s %s · 1 relevé, tendance indisponible", + BODY_METRICS[dashboard->body_metric].label, latest_text, + BODY_METRICS[dashboard->body_metric].unit); + else { + double delta = latest - dashboard->body[dashboard->body_count - 2U].value; + char delta_text[32]; + app_shell_dashboard_decimal_comma(fabs(delta), delta_text, + sizeof(delta_text)); + trainlog_surface_printf(app->content, 8, 4, + "%s · %s %s · Δ %c%s", + BODY_METRICS[dashboard->body_metric].label, latest_text, + BODY_METRICS[dashboard->body_metric].unit, + delta >= 0.0 ? '+' : '-', delta_text); + } + } else trainlog_surface_printf(app->content, 8, 4, + "Aucune mensuration saisie."); + trainlog_surface_printf(app->content, 10, 2, "%s Fréquence", + app->content_selected == 2U ? "▶" : " "); + app_shell_dashboard_frequency_chart(app, dashboard, 11, 4, + app->layout.content.width - 8, + 1); + trainlog_surface_printf(app->content, 14, 2, "%s Répartition du catalogue", + app->content_selected == 3U ? "▶" : " "); + if (app->layout.content.height >= 16) + app_shell_dashboard_zone_chart(app, dashboard, 15, 4, + app->layout.content.width - 8, + app->layout.content.height - 15); +} + static void app_shell_render_body_global(TrainlogAppContext *app) { size_t metric; @@ -5164,11 +6516,9 @@ static void app_shell_render_body_global(TrainlogAppContext *app) y = normalized_graph_row(value, minimum, maximum, graph_top, graph_height); if (previous_x >= 0) - app_shell_surface_segment(app->content, previous_x, - previous_y, x, y, - (uint32_t)(unsigned char)series->symbol); - else trainlog_surface_draw(app->content, y, x, - (uint32_t)(unsigned char)series->symbol); + app_shell_draw_chart_segment(app->content, previous_x, + previous_y, x, y); + trainlog_surface_draw(app->content, y, x, series->symbol); previous_x = x; previous_y = y; } @@ -5182,11 +6532,13 @@ static void app_shell_render_body_global(TrainlogAppContext *app) app->layout.content.height; ++metric) { int row = graph_top + graph_height + 2 + (int)(metric % 7U); int column = metric < 7U ? 2 : app->layout.content.width / 2; - trainlog_surface_printf(app->content, row, column, "%c [%c] %-15s %+.1f%%", + trainlog_surface_printf(app->content, row, column, "%c [%c] %-15s %+.1f%%", app->body_global_selected == metric ? '>' : ' ', app->body_global_enabled[metric] ? 'x' : ' ', BODY_METRICS[metric].label, app->body_global_series[metric].latest_percent); + trainlog_surface_draw(app->content, row, column + 2, + app->body_global_series[metric].symbol); } if (app->body_global_partial) trainlog_surface_printf(app->content, app->layout.content.height - 1, 2, @@ -5286,10 +6638,9 @@ static void app_shell_render_body_trends(TrainlogAppContext *app) (DASHBOARD_MONTH_COUNT - 1U)); y = normalized_graph_row(percent, minimum, maximum, top, height); if (previous_x >= 0 && previous_month + 1L == (long)month) - app_shell_surface_segment(app->content, previous_x, previous_y, - x, y, (uint32_t)(unsigned char)GLOBAL_BODY_SYMBOLS[metric]); - else trainlog_surface_draw(app->content, y, x, - (uint32_t)(unsigned char)GLOBAL_BODY_SYMBOLS[metric]); + app_shell_draw_chart_segment(app->content, previous_x, previous_y, + x, y); + trainlog_surface_draw(app->content, y, x, GLOBAL_BODY_SYMBOLS[metric]); previous_x = x; previous_y = y; previous_month = (long)month; } } @@ -5501,18 +6852,7 @@ static void app_shell_render_content(TrainlogAppContext *app) trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); } else if (route == TRAINLOG_ROUTE_STATS) { - static const char *const rows[] = {"Par exercice", "Mensurations", "Capacités / MAX"}; - size_t index; - for (index = 0U; index < 3U; ++index) { - trainlog_surface_set_role(app->content, - app->content_selected == index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT, - TRAINLOG_RGB_BASE, - app->content_selected == index ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL); - trainlog_surface_printf(app->content, 4 + (int)index * 2, 2, - "%s %s", app->content_selected == index ? "▶ [SÉLECTION]" : " ", rows[index]); - } - trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, - TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + app_shell_render_dashboard(app); } else if (route == TRAINLOG_ROUTE_SESSION_DETAIL) { int row = 3; if (app->session_detail_error) trainlog_surface_printf(app->content, row, 2, @@ -5690,7 +7030,9 @@ static void app_shell_render_content(TrainlogAppContext *app) const TrainlogExercisePerformancePoint *latest = NULL; const TrainlogExercisePerformancePoint *best = NULL; TrainlogLoadMode mode = TRAINLOG_LOAD_NONE; + TrainlogResolvedEquipment equipment; size_t index; + size_t context_points = 0U; int row = 4; char latest_text[128]; char best_text[128]; @@ -5705,12 +7047,31 @@ static void app_shell_render_content(TrainlogAppContext *app) for (index = 0U; latest != NULL && index < app->performance_count; ++index) if (app->performance_points[index].has_performance != 0 && app->performance_points[index].load_mode == mode && + strcmp(app->performance_points[index].equipment_id, + latest->equipment_id) == 0 && exercise_point_better(&app->performance_points[index], best)) best = &app->performance_points[index]; exercise_format_performance(latest, latest_text, sizeof(latest_text)); exercise_format_performance(best, best_text, sizeof(best_text)); - trainlog_surface_printf(app->content, row++, 2, "%s", + trainlog_surface_printf(app->content, row++, 2, "Performance — %s", app->exercise_detail.name); + if (latest != NULL) { + const char *equipment_label = "sans équipement"; + if (latest->equipment_id[0] != '\0' && + trainlog_database_resolve_equipment(app->database, + latest->equipment_id, &equipment) == TRAINLOG_STATUS_OK) + equipment_label = equipment.display_name; + for (index = 0U; index < app->performance_count; ++index) + if (app->performance_points[index].has_performance != 0 && + app->performance_points[index].load_mode == mode && + strcmp(app->performance_points[index].equipment_id, + latest->equipment_id) == 0) + ++context_points; + trainlog_surface_printf(app->content, row++, 2, + "Contexte : %s · %s · %zu point%s", + exercise_load_mode_label(mode), equipment_label, context_points, + context_points == 1U ? "" : "s"); + } trainlog_surface_printf(app->content, row++, 2, "Séances enregistrées : %zu", app->performance_count); trainlog_surface_printf(app->content, row++, 2, "Dernier meilleur set : %s", @@ -5720,20 +7081,26 @@ static void app_shell_render_content(TrainlogAppContext *app) if (latest != NULL) { int graph_top = row + 1; int graph_height = app_shell_graph_height(app, graph_top); - app_shell_draw_performance_graph(app, mode, + app_shell_draw_performance_graph(app, latest, mode, app->exercise_detail.tracking_mode, graph_top, graph_height, false); if (graph_height >= 3) row = graph_top + graph_height + 2; else row += 2; } else row += 2; + if (row < app->layout.content.height) + trainlog_surface_printf(app->content, row++, 2, + "Points réels du contexte :"); for (index = app->content_scroll; index < app->performance_count && row < app->layout.content.height; ++index) { char date[11]; char summary[128]; + if (app->performance_points[index].has_performance == 0 || + app->performance_points[index].load_mode != mode || latest == NULL || + strcmp(app->performance_points[index].equipment_id, + latest->equipment_id) != 0) continue; exercise_short_date(app->performance_points[index].started_at, date); exercise_format_performance(&app->performance_points[index], summary, sizeof(summary)); - trainlog_surface_printf(app->content, row++, 2, "%s %-13s %s", date, - exercise_load_mode_label(app->performance_points[index].load_mode), summary); + trainlog_surface_printf(app->content, row++, 2, "%s %s", date, summary); } } } else if (route == TRAINLOG_ROUTE_EXERCISE_MAX) { @@ -5776,11 +7143,14 @@ static void app_shell_render_content(TrainlogAppContext *app) { int graph_top = row + 1; int graph_height = app_shell_graph_height(app, graph_top); - app_shell_draw_performance_graph(app, summary->current.load_mode, + app_shell_draw_performance_graph(app, &summary->current, + summary->current.load_mode, app->exercise_detail.tracking_mode, graph_top, graph_height, true); } } + } else if (route == TRAINLOG_ROUTE_BODY) { + app_shell_render_body_profile(app); } else if (route == TRAINLOG_ROUTE_BODY_DETAIL) { app_shell_render_body_detail(app); } else if (route == TRAINLOG_ROUTE_BODY_METRIC) { @@ -6134,9 +7504,12 @@ static void app_shell_open_route(TrainlogAppContext *app, TrainlogAppRoute route } else if (route == TRAINLOG_ROUTE_SESSION_GENERATOR && session->generated_preview && session->phase != TRAINLOG_SESSION_GENERATOR_WARNING) session->phase = TRAINLOG_SESSION_GENERATOR_PREVIEW; - if (route == TRAINLOG_ROUTE_BODY_METRIC) + if (route == TRAINLOG_ROUTE_STATS) + app_shell_load_dashboard(app); + else if (route == TRAINLOG_ROUTE_BODY_METRIC) app_shell_load_body_metric(app); - else if (route == TRAINLOG_ROUTE_BODY_TRENDS || + else if (route == TRAINLOG_ROUTE_BODY || + route == TRAINLOG_ROUTE_BODY_TRENDS || route == TRAINLOG_ROUTE_BODY_GLOBAL) app_shell_load_body_global(app); else if (route == TRAINLOG_ROUTE_BODY_ANALYTICS) @@ -6151,6 +7524,8 @@ static void app_shell_open_route(TrainlogAppContext *app, TrainlogAppRoute route app->search = app->saved_searches[route]; app_shell_refresh_list(app); } + if (route == TRAINLOG_ROUTE_BODY) + app_shell_select_available_body_metric(app); } static bool app_shell_back(TrainlogAppContext *app) @@ -6167,6 +7542,12 @@ static bool app_shell_back(TrainlogAppContext *app) app->search = app->saved_searches[app->navigation.current.route]; app_shell_refresh_list(app); } + if (app->navigation.current.route == TRAINLOG_ROUTE_STATS) + app_shell_load_dashboard(app); + else if (app->navigation.current.route == TRAINLOG_ROUTE_BODY) { + app_shell_load_body_global(app); + app_shell_select_available_body_metric(app); + } return true; } @@ -6214,8 +7595,9 @@ static void app_shell_primary(TrainlogAppContext *app) app_shell_open_route(app, targets[app->content_selected % 3U]); } else if (route == TRAINLOG_ROUTE_STATS) { static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_STATS_EXERCISE, - TRAINLOG_ROUTE_BODY, TRAINLOG_ROUTE_MAX}; - app_shell_open_route(app, targets[app->content_selected % 3U]); + TRAINLOG_ROUTE_BODY, TRAINLOG_ROUTE_SESSIONS_COMPLETED, + TRAINLOG_ROUTE_EXERCISES}; + app_shell_open_route(app, targets[app->content_selected % 4U]); } else if (route == TRAINLOG_ROUTE_SESSION_MANUAL || route == TRAINLOG_ROUTE_SESSION_CURRENT || route == TRAINLOG_ROUTE_SESSION_GENERATOR) { @@ -7385,6 +8767,18 @@ static void app_shell_dispatch(TrainlogAppContext *app, int key) app->focus = TRAINLOG_FOCUS_SEARCH; return; } + if (app->navigation.current.route == TRAINLOG_ROUTE_STATS && + key >= '1' && key <= '5') { + static const TrainlogStatisticsPeriod periods[] = { + TRAINLOG_STATS_7_DAYS, TRAINLOG_STATS_30_DAYS, + TRAINLOG_STATS_90_DAYS, TRAINLOG_STATS_YEAR, TRAINLOG_STATS_ALL + }; + /* Compact keyboard contract: changing period reloads only the + * controller-owned read snapshot; rendering remains SQL-free. */ + app->dashboard.period = periods[(size_t)(key - '1')]; + app_shell_load_dashboard(app); + return; + } if (app_shell_dispatch_exercise_controller(app, key)) return; if (app_shell_dispatch_equipment_controller(app, key)) return; if (app_shell_dispatch_body_controller(app, key)) return; @@ -7394,7 +8788,7 @@ static void app_shell_dispatch(TrainlogAppContext *app, int key) if ((app->navigation.current.route == TRAINLOG_ROUTE_SESSIONS || app->navigation.current.route == TRAINLOG_ROUTE_STATS) && (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) { - size_t count = 3U; + size_t count = app->navigation.current.route == TRAINLOG_ROUTE_STATS ? 4U : 3U; if (key == TRAINLOG_KEY_UP && app->content_selected > 0U) --app->content_selected; if (key == TRAINLOG_KEY_DOWN && app->content_selected + 1U < count) ++app->content_selected; return; @@ -7460,6 +8854,12 @@ static void app_shell_dispatch(TrainlogAppContext *app, int key) if (key == 'o' || key == 'O') { app_shell_open_route(app, TRAINLOG_ROUTE_BODY_GLOBAL); return; } + if (key == TRAINLOG_KEY_LEFT) { + app_shell_move_available_body_metric(app, -1); return; + } + if (key == TRAINLOG_KEY_RIGHT) { + app_shell_move_available_body_metric(app, 1); return; + } } if (app->navigation.current.route == TRAINLOG_ROUTE_BODY_DETAIL) { if (key == 'e' || key == 'E') { diff --git a/tui/tests/test_exercise_performance.c b/tui/tests/test_exercise_performance.c index 1faa8e7..ce356b8 100644 --- a/tui/tests/test_exercise_performance.c +++ b/tui/tests/test_exercise_performance.c @@ -60,7 +60,9 @@ static bool test_performance_semantics(void) TrainlogSetInput assistance_sets[2]; TrainlogSetInput bodyweight_sets[2]; - TrainlogSessionExerciseInput exercises[3]; + TrainlogSessionExerciseInput exercises[4]; + TrainlogSetInput second_external_set; + TrainlogCustomEquipment custom_equipment; TrainlogSessionInput session; TrainlogExercisePerformancePoint points[8]; @@ -83,6 +85,20 @@ static bool test_performance_semantics(void) ) == TRAINLOG_STATUS_OK ); + (void)memset(&custom_equipment, 0, sizeof(custom_equipment)); + (void)snprintf(custom_equipment.equipment_id, + sizeof(custom_equipment.equipment_id), "%s", "stats_test_cable"); + (void)snprintf(custom_equipment.display_name, + sizeof(custom_equipment.display_name), "%s", "Stats Test Cable"); + (void)snprintf(custom_equipment.label_name, + sizeof(custom_equipment.label_name), "%s", "STATS TEST CABLE"); + (void)snprintf(custom_equipment.equipment_type, + sizeof(custom_equipment.equipment_type), "%s", "cable"); + (void)snprintf(custom_equipment.load_semantics, + sizeof(custom_equipment.load_semantics), "%s", "external"); + CHECK(trainlog_database_create_custom_equipment(database, + &custom_equipment) == TRAINLOG_STATUS_OK); + CHECK( trainlog_database_insert_exercise( database, @@ -167,6 +183,15 @@ static bool test_performance_semantics(void) 2U ); + (void)memset(&second_external_set, 0, sizeof(second_external_set)); + second_external_set.reps = 12; + second_external_set.has_weight = true; + second_external_set.weight_kg = 50.0; + bind_exercise(&exercises[3], "ex_external", TRAINLOG_LOAD_EXTERNAL, + 50.0, &second_external_set, 1U); + (void)snprintf(exercises[3].equipment_id, + sizeof(exercises[3].equipment_id), "%s", "stats_test_cable"); + (void)memset(&session, 0, sizeof(session)); (void)snprintf( @@ -191,7 +216,7 @@ static bool test_performance_semantics(void) ); session.exercises = exercises; - session.exercise_count = 3U; + session.exercise_count = 4U; CHECK( trainlog_database_insert_session( @@ -210,11 +235,14 @@ static bool test_performance_semantics(void) ) == TRAINLOG_STATUS_OK ); + /* PUBLIC CONTRACT: the reader retains one representative per session; + * multiple same-exercise occurrences do not change cardinality. */ CHECK(count == 1U); CHECK(points[0].has_performance != 0); CHECK(points[0].weight_kg > 89.99); CHECK(points[0].weight_kg < 90.01); CHECK(points[0].metric_value == 5); + CHECK(points[0].actual_set_count == 3U); CHECK( trainlog_database_list_exercise_performance( diff --git a/tui/tests/test_max_results.c b/tui/tests/test_max_results.c index 22b8dc3..0461b8e 100644 --- a/tui/tests/test_max_results.c +++ b/tui/tests/test_max_results.c @@ -4,6 +4,7 @@ */ #include +#include #include #include #include @@ -13,6 +14,21 @@ #include "trainlog/database.h" +/* ABI CONTRACT: these offsets and total size are the published LP64 layout; + * performance read-model fixes must use existing fields or private storage. */ +_Static_assert(offsetof(TrainlogExercisePerformancePoint, actual_set_count) == 184U, + "performance point ABI: actual_set_count"); +_Static_assert(offsetof(TrainlogExercisePerformancePoint, has_performance) == 192U, + "performance point ABI: has_performance"); +_Static_assert(offsetof(TrainlogExercisePerformancePoint, has_explicit_max) == 196U, + "performance point ABI: has_explicit_max"); +_Static_assert(offsetof(TrainlogExercisePerformancePoint, weight_kg) == 208U, + "performance point ABI: weight_kg"); +_Static_assert(offsetof(TrainlogExercisePerformancePoint, equipment_id) == 216U, + "performance point ABI: equipment_id"); +_Static_assert(sizeof(TrainlogExercisePerformancePoint) == 352U, + "performance point ABI: size"); + #define CHECK(condition) \ do { \ if (!(condition)) { \ @@ -207,10 +223,61 @@ static bool test_v8_migration_refuses_to_guess_multiple_attempts(void) return true; } +static bool test_public_performance_retains_session_representative_contract(void) +{ + char path[] = "/tmp/trainlog-performance-max-XXXXXX"; + int fd = mkstemp(path); + sqlite3 *raw = NULL; + TrainlogDatabase *database = NULL; + TrainlogSessionExerciseInput entry; + TrainlogSessionInput session; + TrainlogExercisePerformancePoint points[2]; + size_t count = 0U; + + CHECK(fd >= 0 && close(fd) == 0); + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + CHECK(add_exercise(database, "ex_mixed_fact", "Mixed Fact")); + max_input(&entry, "sxe_mixed_fact", "ex_mixed_fact", 100.0); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", + "se_mixed_fact"); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", + "2031-02-04T08:15:00+01:00"); + session.session_type = TRAINLOG_SESSION_MAX_TEST; + session.exercises = &entry; + session.exercise_count = 1U; + CHECK(trainlog_database_insert_session(database, &session) == + TRAINLOG_STATUS_OK); + trainlog_database_close(database); + + /* The writer deliberately rejects mixed input. This controlled persisted + * fixture proves the read model independently preserves both compatible + * facts if an imported/historical occurrence owns them. */ + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) " + "SELECT id,0,5,80.0 FROM session_exercises WHERE entry_id='sxe_mixed_fact';", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); + + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_list_exercise_performance(database, "ex_mixed_fact", + points, 2U, &count) == TRAINLOG_STATUS_OK); + /* PUBLIC CONTRACT: mixed persisted facts still produce one session point; + * STATS preserves both through its private fact projection. */ + CHECK(count == 1U); + CHECK(points[0].actual_set_count == 0U); + CHECK(points[0].has_explicit_max != 0 && points[0].weight_kg == 100.0); + trainlog_database_close(database); + CHECK(unlink(path) == 0); + return true; +} + int main(void) { if (!test_explicit_max_round_trip_and_identity() || - !test_v8_migration_refuses_to_guess_multiple_attempts()) { + !test_v8_migration_refuses_to_guess_multiple_attempts() || + !test_public_performance_retains_session_representative_contract()) { return 1; } (void)printf("max results: PASS\n"); diff --git a/tui/tests/test_session_type_schema.c b/tui/tests/test_session_type_schema.c index f8bd84d..d3de215 100644 --- a/tui/tests/test_session_type_schema.c +++ b/tui/tests/test_session_type_schema.c @@ -32,6 +32,9 @@ static bool test_session_type_roundtrip(void) TrainlogDatabase *database = NULL; TrainlogSessionInput training; TrainlogSessionInput max_test; + TrainlogSessionExerciseInput training_occurrence; + TrainlogSessionExerciseInput max_occurrence; + TrainlogSetInput set = {8, 0, false, 0.0}; TrainlogSessionSummary sessions[4]; size_t count = 0U; @@ -41,8 +44,19 @@ static bool test_session_type_roundtrip(void) &database ) == TRAINLOG_STATUS_OK ); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_33333333-3333-4333-8333-333333333333", "Presse test", + "presse test", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); (void)memset(&training, 0, sizeof(training)); + (void)memset(&training_occurrence, 0, sizeof(training_occurrence)); + (void)snprintf(training_occurrence.exercise_id, + sizeof(training_occurrence.exercise_id), "%s", + "ex_33333333-3333-4333-8333-333333333333"); + training_occurrence.recording_mode = TRAINLOG_RECORDING_SETS; + training_occurrence.sets = &set; + training_occurrence.set_count = 1U; (void)snprintf( training.session_id, @@ -62,6 +76,8 @@ static bool test_session_type_roundtrip(void) training.session_type == TRAINLOG_SESSION_TRAINING ); + training.exercises = &training_occurrence; + training.exercise_count = 1U; CHECK( trainlog_database_insert_session( @@ -71,6 +87,13 @@ static bool test_session_type_roundtrip(void) ); (void)memset(&max_test, 0, sizeof(max_test)); + (void)memset(&max_occurrence, 0, sizeof(max_occurrence)); + (void)snprintf(max_occurrence.exercise_id, + sizeof(max_occurrence.exercise_id), "%s", + "ex_33333333-3333-4333-8333-333333333333"); + max_occurrence.recording_mode = TRAINLOG_RECORDING_SETS; + max_occurrence.has_max_weight = true; + max_occurrence.max_weight_kg = 100.0; (void)snprintf( max_test.session_id, @@ -88,6 +111,8 @@ static bool test_session_type_roundtrip(void) max_test.session_type = TRAINLOG_SESSION_MAX_TEST; + max_test.exercises = &max_occurrence; + max_test.exercise_count = 1U; CHECK( trainlog_database_insert_session( diff --git a/tui/tests/test_tui_workflows.c b/tui/tests/test_tui_workflows.c index 7a59fa1..fe25f27 100644 --- a/tui/tests/test_tui_workflows.c +++ b/tui/tests/test_tui_workflows.c @@ -22,6 +22,30 @@ struct TrainlogTerminal { bool coordinate_overflow; bool text_overflow; size_t surface_draw_count; + size_t dashboard_dot_count; + size_t dashboard_block_count; + int maximum_draw_row; + int maximum_draw_column; + int stats_progression_row; + int stats_progression_column; + int stats_measurements_row; + int stats_measurements_column; + int stats_frequency_row; + int stats_frequency_column; + int stats_frequency_bottom_row; + int stats_distribution_row; + struct { + int row; + int column; + char text[32]; + } printed[256]; + size_t printed_count; + struct { + int row; + int column; + uint32_t codepoint; + } drawn[512]; + size_t drawn_count; }; struct TrainlogPanel { int unused; }; @@ -31,6 +55,48 @@ struct TrainlogSurface { TrainlogTerminal *terminal; }; (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \ __FILE__, __LINE__, #condition); return false; } } while (0) +static size_t text_occurrences(const char *text, const char *needle) +{ + size_t count = 0U; + size_t length = strlen(needle); + const char *cursor = text; + if (length == 0U) return 0U; + while ((cursor = strstr(cursor, needle)) != NULL) { + ++count; + cursor += length; + } + return count; +} + +static int rendered_text_column(const TrainlogTerminal *terminal, const char *text) +{ + size_t index; + for (index = 0U; index < terminal->printed_count; ++index) + if (strcmp(terminal->printed[index].text, text) == 0) + return terminal->printed[index].column; + return -1; +} + +static int rendered_draw_column(const TrainlogTerminal *terminal, uint32_t codepoint, + size_t occurrence) +{ + size_t index; + for (index = 0U; index < terminal->drawn_count; ++index) + if (terminal->drawn[index].codepoint == codepoint && occurrence-- == 0U) + return terminal->drawn[index].column; + return -1; +} + +static bool rendered_has_draw_column(const TrainlogTerminal *terminal, + uint32_t codepoint, int column) +{ + size_t index; + for (index = 0U; index < terminal->drawn_count; ++index) + if (terminal->drawn[index].codepoint == codepoint && + terminal->drawn[index].column == column) return true; + return false; +} + TrainlogTerminal *trainlog_terminal_create(void) { return NULL; } void trainlog_terminal_destroy(TrainlogTerminal *terminal) { (void)terminal; } int trainlog_terminal_rows(const TrainlogTerminal *terminal) { return terminal->rows; } @@ -133,20 +199,69 @@ void trainlog_surface_printf(TrainlogSurface *surface, int row, int column, if (surface == NULL || surface->terminal == NULL) return; va_start(arguments, format); if (surface->terminal->output_used < sizeof(surface->terminal->output)) { + char *start = surface->terminal->output + surface->terminal->output_used; int written = vsnprintf(surface->terminal->output + surface->terminal->output_used, sizeof(surface->terminal->output) - surface->terminal->output_used, format, arguments); + if (strstr(start, "Progression globale") != NULL) { + surface->terminal->stats_progression_row = row; + surface->terminal->stats_progression_column = column; + } + if (strstr(start, "Mensurations") != NULL) { + surface->terminal->stats_measurements_row = row; + surface->terminal->stats_measurements_column = column; + } + if (strstr(start, "Fréquence") != NULL) { + surface->terminal->stats_frequency_row = row; + surface->terminal->stats_frequency_column = column; + } + if (strstr(start, "Répartition du catalogue") != NULL) + surface->terminal->stats_distribution_row = row; + if ((strncmp(start, "J−", strlen("J−")) == 0 || + strncmp(start, "P−", strlen("P−")) == 0 || + strncmp(start, "M−", strlen("M−")) == 0 || + strcmp(start, "actuel") == 0) && + row > surface->terminal->stats_frequency_bottom_row) + surface->terminal->stats_frequency_bottom_row = row; if (written > 0 && (size_t)written < sizeof(surface->terminal->output) - - surface->terminal->output_used) surface->terminal->output_used += (size_t)written; + surface->terminal->output_used) { + size_t copy = (size_t)written < sizeof(surface->terminal->printed[0].text) - 1U + ? (size_t)written : sizeof(surface->terminal->printed[0].text) - 1U; + if (surface->terminal->printed_count < + sizeof(surface->terminal->printed) / sizeof(surface->terminal->printed[0])) { + size_t slot = surface->terminal->printed_count++; + surface->terminal->printed[slot].row = row; + surface->terminal->printed[slot].column = column; + (void)memcpy(surface->terminal->printed[slot].text, start, copy); + surface->terminal->printed[slot].text[copy] = '\0'; + } + surface->terminal->output_used += (size_t)written; + } } va_end(arguments); (void)row; (void)column; } void trainlog_surface_draw(TrainlogSurface *surface, int row, int column, uint32_t codepoint) { - if (surface != NULL && surface->terminal != NULL) + if (surface != NULL && surface->terminal != NULL) { + if (surface->terminal->drawn_count < + sizeof(surface->terminal->drawn) / sizeof(surface->terminal->drawn[0])) { + size_t slot = surface->terminal->drawn_count++; + surface->terminal->drawn[slot].row = row; + surface->terminal->drawn[slot].column = column; + surface->terminal->drawn[slot].codepoint = codepoint; + } ++surface->terminal->surface_draw_count; - (void)row; (void)column; (void)codepoint; + if (codepoint == 0x00b7U) ++surface->terminal->dashboard_dot_count; + if (codepoint == 0x2588U) ++surface->terminal->dashboard_block_count; + if (row > surface->terminal->maximum_draw_row) + surface->terminal->maximum_draw_row = row; + if (column > surface->terminal->maximum_draw_column) + surface->terminal->maximum_draw_column = column; + if (row < 0 || row >= surface->terminal->rows || column < 0 || + column >= surface->terminal->columns) + surface->terminal->coordinate_overflow = true; + } } void trainlog_surface_move_top(TrainlogSurface *surface) { (void)surface; } @@ -1040,6 +1155,646 @@ static bool test_body_graphs_draw_inside_minimum_content_surface(void) return true; } +static bool dashboard_insert_completed_set_dose(TrainlogDatabase *database, + const char *session_id, const char *started_at, const char *ended_at, + TrainlogLoadMode load_mode, const char *equipment_id, double weight, int dose) +{ + TrainlogSetInput set = {dose, 0, true, weight}; + TrainlogSessionExerciseInput occurrence; + TrainlogSessionInput session; + (void)memset(&occurrence, 0, sizeof(occurrence)); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", + "ex_33333333-3333-4333-8333-333333333333"); + (void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s", + equipment_id); + occurrence.recording_mode = TRAINLOG_RECORDING_SETS; + occurrence.load_mode = load_mode; + occurrence.rest_seconds = 60; + occurrence.target_sets = 2; + occurrence.target_reps = 10; + occurrence.target_has_weight = true; + occurrence.target_weight_kg = 999.0; + occurrence.sets = &set; + occurrence.set_count = 1U; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at); + (void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", ended_at); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = &occurrence; + session.exercise_count = 1U; + return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK; +} + +static bool dashboard_insert_completed_set(TrainlogDatabase *database, + const char *session_id, const char *started_at, const char *ended_at, + TrainlogLoadMode load_mode, const char *equipment_id, double weight) +{ + return dashboard_insert_completed_set_dose(database, session_id, started_at, + ended_at, load_mode, equipment_id, weight, 8); +} + +static bool dashboard_insert_explicit_max_at(TrainlogDatabase *database, + const char *session_id, const char *started_at, const char *entry_id, + double weight) +{ + TrainlogSessionExerciseInput occurrence; + TrainlogSessionInput session; + (void)memset(&occurrence, 0, sizeof(occurrence)); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", + "ex_33333333-3333-4333-8333-333333333333"); + (void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s", + "leg_press"); + (void)snprintf(occurrence.entry_id, sizeof(occurrence.entry_id), "%s", entry_id); + occurrence.recording_mode = TRAINLOG_RECORDING_SETS; + occurrence.load_mode = TRAINLOG_LOAD_NONE; + occurrence.has_max_weight = true; + occurrence.max_weight_kg = weight; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at); + session.session_type = TRAINLOG_SESSION_MAX_TEST; + session.exercises = &occurrence; + session.exercise_count = 1U; + return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK; +} + +static bool dashboard_insert_explicit_max(TrainlogDatabase *database) +{ + return dashboard_insert_explicit_max_at(database, + "se_55555555-5555-4555-8555-555555555555", "2026-09-11T11:00:00Z", + "sxe_55555555-5555-4555-8555-555555555555", 120.0); +} + +static bool dashboard_insert_continuous_actual(TrainlogDatabase *database) +{ + TrainlogSessionExerciseInput occurrence; + TrainlogSessionInput session; + (void)memset(&occurrence, 0, sizeof(occurrence)); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", + "ex_77777777-7777-4777-8777-777777777777"); + occurrence.recording_mode = TRAINLOG_RECORDING_CONTINUOUS; + occurrence.continuous_duration_seconds = 1800; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", + "se_77777777-7777-4777-8777-777777777777"); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", + "2026-09-08T08:00:00Z"); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = &occurrence; + session.exercise_count = 1U; + return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK; +} + +static bool dashboard_insert_plan_only(TrainlogDatabase *database) +{ + TrainlogSessionExerciseInput occurrence; + TrainlogSessionInput session; + (void)memset(&occurrence, 0, sizeof(occurrence)); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", + "ex_33333333-3333-4333-8333-333333333333"); + (void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s", + "leg_press"); + occurrence.recording_mode = TRAINLOG_RECORDING_SETS; + occurrence.load_mode = TRAINLOG_LOAD_EXTERNAL; + occurrence.target_sets = 3; + occurrence.target_reps = 10; + occurrence.target_has_weight = true; + occurrence.target_weight_kg = 999.0; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", + "se_88888888-8888-4888-8888-888888888888"); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", + "2026-09-07T08:00:00Z"); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = &occurrence; + session.exercise_count = 1U; + return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK; +} + +static void dashboard_prepare_render(TrainlogAppContext *app, + TrainlogTerminal *terminal, TrainlogSurface *surface, int columns, int rows, + int content_width, int content_height) +{ + (void)memset(terminal, 0, sizeof(*terminal)); + surface->terminal = terminal; + app->terminal = terminal; + app->content = surface; + terminal->columns = columns; + terminal->rows = rows; + app->layout.columns = columns; + app->layout.rows = rows; + app->layout.content.width = content_width; + app->layout.content.height = content_height; +} + +static bool dashboard_utc_timestamp_at(time_t value, char output[TRAINLOG_TIMESTAMP_MAX + 1U]) +{ + struct tm utc; + return gmtime_r(&value, &utc) != NULL && strftime(output, + TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U; +} + +static bool test_stats_dashboard_sparse_series_names_frequency_and_footer(void) +{ + TrainlogAppContext app; + TrainlogTerminal terminal; + TrainlogSurface surface; + size_t index; + (void)memset(&app, 0, sizeof(app)); + app.dashboard.has_body = true; + app.dashboard.body_metric = 0U; + app.dashboard.body_count = 1U; + app.dashboard.body[0].value = 83.70; + app.dashboard.completed_session_count = 4U; + app.dashboard.performed_set_count = 9U; + app.dashboard.distinct_exercise_count = 3U; + app.dashboard.explicit_max_count = 1U; + dashboard_prepare_period_buckets(&app.dashboard, 1000, 971); + app.dashboard.weeks[5].sessions = 4U; + app.dashboard.weeks[5].maxima = 1U; + app.dashboard.weeks[5].working_improvements = 2U; + app.dashboard.weeks[5].max_improvements = 1U; + + /* CONTRACT: the selector changes the shared projection used by progress + * and frequency; it is not a label over an invariant six-week chart. */ + { + static const TrainlogStatisticsPeriod periods[] = { + TRAINLOG_STATS_7_DAYS, TRAINLOG_STATS_30_DAYS, + TRAINLOG_STATS_90_DAYS, TRAINLOG_STATS_YEAR, TRAINLOG_STATS_ALL + }; + static const size_t counts[] = {7U, 6U, 6U, 12U, 12U}; + static const int64_t spans[] = {1, 5, 15, 31, 84}; + size_t period_index; + for (period_index = 0U; period_index < 5U; ++period_index) { + app.dashboard.period = periods[period_index]; + dashboard_prepare_period_buckets(&app.dashboard, 1000, 0); + CHECK(app.dashboard.week_count == counts[period_index]); + CHECK(app.dashboard.weeks[0].span_days == spans[period_index]); + CHECK(strcmp(app.dashboard.weeks[ + app.dashboard.week_count - 1U].label, "actuel") == 0); + } + app.dashboard.period = TRAINLOG_STATS_30_DAYS; + dashboard_prepare_period_buckets(&app.dashboard, 1000, 971); + app.dashboard.weeks[5].sessions = 4U; + app.dashboard.weeks[5].maxima = 1U; + app.dashboard.weeks[5].working_improvements = 2U; + app.dashboard.weeks[5].max_improvements = 1U; + } + + /* A lone measurement is text-only; event blocks are factual period-bucket + * counts and the accessible totals expose their meaning. */ + dashboard_prepare_render(&app, &terminal, &surface, 120, 35, 96, 29); + app_shell_render_dashboard(&app); + CHECK(strstr(terminal.output, "Période 7j [30j] 90j 1an Tout") != NULL); + CHECK(strstr(terminal.output, "1:7j") == NULL); + CHECK(strstr(terminal.output, + "Séances 4 Séries 9 Exercices pratiqués 3 MAX 1") != NULL); + CHECK(terminal.stats_distribution_row > terminal.stats_frequency_bottom_row); + CHECK(strstr(terminal.output, "travail 2 · MAX 1") != NULL); + CHECK(strstr(terminal.output, "83,70 kg · 1 relevé") != NULL); + CHECK(terminal.dashboard_block_count > 0U); + + /* Frequency geometry has one shared x coordinate per period bucket. + * The end buckets also prove a wide chart uses its useful span. */ + (void)memset(&app.dashboard.weeks, 0, sizeof(app.dashboard.weeks)); + dashboard_prepare_period_buckets(&app.dashboard, 1000, 971); + for (index = 0U; index < 6U; ++index) { + app.dashboard.weeks[index].sessions = 11U + index; + app.dashboard.weeks[index].maxima = 1U; + } + dashboard_prepare_render(&app, &terminal, &surface, 120, 35, 96, 29); + app_shell_dashboard_frequency_chart(&app, &app.dashboard, 11, 4, 88, 7); + for (index = 0U; index < 6U; ++index) { + char count[8]; + const char *label = app.dashboard.weeks[index].label; + int label_column; + int x; + (void)snprintf(count, sizeof(count), "%zu", 11U + index); + label_column = rendered_text_column(&terminal, label); + x = label_column + (index + 1U == 6U ? 3 : 1); + CHECK(rendered_text_column(&terminal, count) == x); + CHECK(rendered_draw_column(&terminal, 0x25c6U, index) == x); + CHECK(rendered_has_draw_column(&terminal, 0x2588U, x)); + } + CHECK(rendered_text_column(&terminal, "actuel") - + rendered_text_column(&terminal, "P−5") >= 75); + + /* One actual measurement is a value, not a fabricated line. */ + (void)memset(&terminal, 0, sizeof(terminal)); + surface.terminal = &terminal; + app_shell_dashboard_sparkline(&app, &app.dashboard.body[0].value, 1U, + 4, 4, 24, 2); + CHECK(terminal.surface_draw_count == 0U); + + app.dashboard.body[1] = app.dashboard.body[0]; + app.dashboard.body[1].value = 82.90; + app.dashboard.body_count = 2U; + { + static const int columns[] = {100, 80, 72}; + static const int rows[] = {30, 24, 20}; + static const int widths[] = {76, 80, 72}; + static const int heights[] = {24, 20, 16}; + size_t geometry; + for (geometry = 0U; geometry < 3U; ++geometry) { + dashboard_prepare_render(&app, &terminal, &surface, + columns[geometry], rows[geometry], widths[geometry], + heights[geometry]); + app_shell_render_dashboard(&app); + CHECK(strstr(terminal.output, "Progression globale") != NULL); + CHECK(strstr(terminal.output, "Mensurations") != NULL); + CHECK(strstr(terminal.output, "Fréquence") != NULL); + CHECK(strchr(terminal.output, '#') == NULL); + CHECK(strchr(terminal.output, '*') == NULL); + CHECK(terminal.dashboard_block_count > 0U); + CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); + CHECK(terminal.maximum_draw_row < heights[geometry]); + CHECK(terminal.stats_distribution_row > + terminal.stats_frequency_bottom_row); + if (columns[geometry] >= 100) { + CHECK(terminal.stats_progression_row == + terminal.stats_measurements_row); + CHECK(terminal.stats_measurements_column > + terminal.stats_progression_column); + CHECK(terminal.stats_frequency_row > + terminal.stats_progression_row); + } else { + CHECK(terminal.stats_progression_column == + terminal.stats_measurements_column); + CHECK(terminal.stats_measurements_row > + terminal.stats_progression_row); + } + } + } + + /* No classified performance events still occupies the selected-period + * timeline, with dots and an explicit explanation. */ + (void)memset(&app.dashboard.weeks, 0, sizeof(app.dashboard.weeks)); + dashboard_prepare_period_buckets(&app.dashboard, 1000, 971); + app.dashboard.body_count = 1U; + dashboard_prepare_render(&app, &terminal, &surface, 72, 20, 72, 16); + app_shell_render_dashboard(&app); + CHECK(strstr(terminal.output, "données insuffisantes") != NULL); + CHECK(terminal.dashboard_dot_count >= 6U); + CHECK(terminal.dashboard_block_count == 0U); + CHECK(!terminal.coordinate_overflow && terminal.maximum_draw_row < 16); + + /* A non-zero frequency bucket alone must produce a bar, independently of + * performance and measurement marks. */ + app.dashboard.weeks[5].sessions = 3U; + app.dashboard.weeks[5].maxima = 1U; + dashboard_prepare_render(&app, &terminal, &surface, 72, 20, 72, 16); + app_shell_dashboard_frequency_chart(&app, &app.dashboard, 3, 4, 64, 3); + CHECK(terminal.dashboard_block_count > 0U); + CHECK(terminal.maximum_draw_row < 16); + + CHECK(strcmp(trainlog_exercise_name_catalog_lookup( + "ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef"), + "Adduction de hanche assise") == 0); + + (void)memset(&app, 0, sizeof(app)); + trainlog_navigation_init(&app.navigation); + app.navigation.current.route = TRAINLOG_ROUTE_STATS; + app_shell_actions(&app); + CHECK(trainlog_actions_find_key(&app.actions, TRAINLOG_KEY_F7) != NULL); + dashboard_prepare_render(&app, &terminal, &surface, 80, 24, 80, 20); + app.footer = &surface; + app_shell_render_footer(&app); + CHECK(text_occurrences(terminal.output, "F6 Navigation") == 1U); + CHECK(text_occurrences(terminal.output, "F7 Actions") == 1U); + return true; +} + +static bool test_stats_dashboard_uses_real_exact_time_snapshots_responsively(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + TrainlogTerminal terminal; + TrainlogSurface surface; + TrainlogBodyObservationInput body; + size_t sessions = 0U; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_33333333-3333-4333-8333-333333333333", "Presse test", + "presse test", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_77777777-7777-4777-8777-777777777777", "Marche test", + "marche test", TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_CONTINUOUS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + /* Raw text says 23:00 is later, but +15:00 makes it the older instant. */ + CHECK(dashboard_insert_completed_set(database, + "se_11111111-1111-4111-8111-111111111111", + "2026-09-10T23:00:00+15:00", "2026-09-10T23:30:00+15:00", + TRAINLOG_LOAD_EXTERNAL, "leg_press", 10.0)); + CHECK(dashboard_insert_completed_set(database, + "se_22222222-2222-4222-8222-222222222222", + "2026-09-10t09:00:00z", "2026-09-10t09:30:00z", + TRAINLOG_LOAD_EXTERNAL, "leg_press", 20.0)); + CHECK(dashboard_insert_completed_set(database, + "se_44444444-4444-4444-8444-444444444444", + "2026-09-11T10:00:00+00:00", "2026-09-11T10:30:00+00:00", + TRAINLOG_LOAD_ASSISTANCE, "assisted_dip_chin_machine", 5.0)); + /* A: ended_at is lifecycle metadata, not the gate for actual history. */ + CHECK(dashboard_insert_completed_set(database, + "se_66666666-6666-4666-8666-666666666666", + "2026-09-09T10:00:00Z", "", TRAINLOG_LOAD_EXTERNAL, + "leg_press", 30.0)); + /* B: the explicit MAX helper also persists ended_at NULL. */ + CHECK(dashboard_insert_explicit_max(database)); + /* C is actual continuous history; D has only targets and must disappear. */ + CHECK(dashboard_insert_continuous_actual(database)); + CHECK(dashboard_insert_plan_only(database)); + (void)memset(&body, 0, sizeof(body)); + (void)snprintf(body.observation_id, sizeof(body.observation_id), "%s", + "bo_11111111-1111-4111-8111-111111111111"); + (void)snprintf(body.observed_at, sizeof(body.observed_at), "%s", + "2026-09-10T23:00:00+15:00"); + body.has_body_weight = true; + body.body_weight_kg = 80.0; + CHECK(trainlog_database_insert_body_observation(database, &body) == + TRAINLOG_STATUS_OK); + (void)memset(&body, 0, sizeof(body)); + (void)snprintf(body.observation_id, sizeof(body.observation_id), "%s", + "bo_33333333-3333-4333-8333-333333333333"); + (void)snprintf(body.observed_at, sizeof(body.observed_at), "%s", + "2026-09-11T12:00:00Z"); + body.has_waist = true; + body.waist_cm = 85.0; + CHECK(trainlog_database_insert_body_observation(database, &body) == + TRAINLOG_STATUS_OK); + (void)memset(&body, 0, sizeof(body)); + (void)snprintf(body.observation_id, sizeof(body.observation_id), "%s", + "bo_22222222-2222-4222-8222-222222222222"); + (void)snprintf(body.observed_at, sizeof(body.observed_at), "%s", + "2026-09-10t09:00:00z"); + body.has_body_weight = true; + body.body_weight_kg = 81.0; + CHECK(trainlog_database_insert_body_observation(database, &body) == + TRAINLOG_STATUS_OK); + + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app_shell_load_dashboard(&app); + CHECK(!app.dashboard.error); + CHECK(app.dashboard.has_performance && app.dashboard.performance_count == 3U); + CHECK(app.dashboard.performance[2].weight_kg == 20.0); + CHECK(app.dashboard.has_explicit_max && app.dashboard.max_weight_kg == 120.0); + CHECK(app.dashboard.has_body && app.dashboard.body_count == 1U); + CHECK(app.dashboard.body_metric == 4U && app.dashboard.body[0].value == 85.0); + for (size_t index = 0U; index < app.dashboard.week_count; ++index) + sessions += app.dashboard.weeks[index].sessions; + CHECK(sessions == 6U); + CHECK(app.dashboard.completed_session_count == 6U); + CHECK(app.dashboard.performed_set_count == 4U); + CHECK(app.dashboard.distinct_exercise_count == 2U); + CHECK(app.dashboard.explicit_max_count == 1U); + + (void)memset(&terminal, 0, sizeof(terminal)); + surface.terminal = &terminal; + app.terminal = &terminal; + app.content = &surface; + terminal.columns = 120; + terminal.rows = 35; + app.layout.columns = 120; + app.layout.content.width = 90; + app.layout.content.height = 29; + app_shell_render_dashboard(&app); + CHECK(strstr(terminal.output, "Séances 6") != NULL); + CHECK(strstr(terminal.output, "Mensurations") != NULL); + CHECK(strstr(terminal.output, "Fréquence") != NULL); + CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); + + (void)memset(&terminal, 0, sizeof(terminal)); + surface.terminal = &terminal; + terminal.columns = 100; + terminal.rows = 30; + app.layout.columns = 100; + app.layout.content.width = 76; + app.layout.content.height = 24; + app_shell_render_dashboard(&app); + CHECK(strstr(terminal.output, "Progression globale") != NULL); + CHECK(strstr(terminal.output, "Mensurations") != NULL); + CHECK(strstr(terminal.output, "Fréquence") != NULL); + CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); + + (void)memset(&terminal, 0, sizeof(terminal)); + surface.terminal = &terminal; + terminal.columns = 80; + terminal.rows = 24; + app.layout.columns = 80; + app.layout.content.width = 80; + app.layout.content.height = 20; + app_shell_render_dashboard(&app); + CHECK(strstr(terminal.output, "Progression") != NULL); + CHECK(strstr(terminal.output, "Mensurations") != NULL); + CHECK(strstr(terminal.output, "Fréquence") != NULL); + CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); + + (void)memset(&terminal, 0, sizeof(terminal)); + surface.terminal = &terminal; + terminal.columns = 72; + terminal.rows = 20; + app.layout.columns = 72; + app.layout.content.width = 72; + app.layout.content.height = 16; + app_shell_render_dashboard(&app); + CHECK(strstr(terminal.output, "Progression") != NULL); + CHECK(strstr(terminal.output, "Mensurations") != NULL); + CHECK(strstr(terminal.output, "Fréquence") != NULL); + CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); + trainlog_navigation_init(&app.navigation); + app.navigation.current.route = TRAINLOG_ROUTE_STATS; + app.layout.usable = true; + app.focus = TRAINLOG_FOCUS_CONTENT; + app_shell_dispatch(&app, '1'); + CHECK(app.dashboard.period == TRAINLOG_STATS_7_DAYS); + app.content_selected = 2U; + app_shell_primary(&app); + CHECK(app.navigation.current.route == TRAINLOG_ROUTE_SESSIONS_COMPLETED); + trainlog_database_close(database); + return true; +} + +static bool test_stats_dashboard_uses_represented_local_weeks(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + TrainlogTimestampKey march; + TrainlogTimestampKey february; + CHECK(trainlog_timestamp_parse("2026-03-01T00:30:00+02:00", 25U, &march)); + CHECK(trainlog_timestamp_parse("2026-02-28T22:30:00Z", 20U, &february)); + CHECK(trainlog_timestamp_compare(&march, &february) == 0); + CHECK(march.local_day == february.local_day + 1); + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_33333333-3333-4333-8333-333333333333", "Presse test", + "presse test", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(dashboard_insert_completed_set(database, + "se_70000000-0000-4000-8000-000000000001", + "2025-12-29T00:30:00+02:00", "2025-12-29T01:00:00+02:00", + TRAINLOG_LOAD_EXTERNAL, "leg_press", 10.0)); + CHECK(dashboard_insert_completed_set(database, + "se_70000000-0000-4000-8000-000000000002", + "2025-12-28T22:30:00Z", "2025-12-28T23:00:00Z", + TRAINLOG_LOAD_EXTERNAL, "leg_press", 20.0)); + CHECK(dashboard_insert_completed_set(database, + "se_70000000-0000-4000-8000-000000000003", + "2026-01-01T00:30:00+14:00", "2026-01-01T01:00:00+14:00", + TRAINLOG_LOAD_EXTERNAL, "leg_press", 30.0)); + CHECK(dashboard_insert_completed_set(database, + "se_70000000-0000-4000-8000-000000000004", + "2026-01-04T23:30:00-02:00", "2026-01-04T23:45:00-02:00", + TRAINLOG_LOAD_EXTERNAL, "leg_press", 40.0)); + + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app.dashboard.period = TRAINLOG_STATS_ALL; + app_shell_load_dashboard(&app); + CHECK(!app.dashboard.error); + CHECK(app.dashboard.week_count >= 1U && + app.dashboard.week_count <= DASHBOARD_BUCKET_COUNT); + /* Tout projects the complete represented history instead of silently + * retaining the former six-week viewport. */ + { + size_t projected = 0U; + for (size_t index = 0U; index < app.dashboard.week_count; ++index) + projected += app.dashboard.weeks[index].sessions; + CHECK(projected == 4U); + } + CHECK(app.dashboard.completed_session_count == 4U); + trainlog_database_close(database); + return true; +} + +static bool test_stats_dashboard_current_week_remains_zero_after_past_activity(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + TrainlogTerminal terminal; + TrainlogSurface surface; + char started_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + char ended_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + time_t now = time(NULL); + + CHECK(now != (time_t)-1); + CHECK(dashboard_utc_timestamp_at(now - 8 * 24 * 60 * 60, started_at)); + CHECK(dashboard_utc_timestamp_at(now - 8 * 24 * 60 * 60 + 3600, ended_at)); + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_33333333-3333-4333-8333-333333333333", "Presse test", + "presse test", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(dashboard_insert_completed_set(database, + "se_70000000-0000-4000-8000-000000000005", started_at, ended_at, + TRAINLOG_LOAD_EXTERNAL, "leg_press", 20.0)); + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app.dashboard.period = TRAINLOG_STATS_ALL; + app_shell_load_dashboard(&app); + CHECK(!app.dashboard.error); + CHECK(app.dashboard.week_count >= 1U && + app.dashboard.week_count <= DASHBOARD_BUCKET_COUNT); + CHECK(app.dashboard.weeks[app.dashboard.week_count - 1U].sessions == 0U); + dashboard_prepare_render(&app, &terminal, &surface, 80, 24, 80, 20); + app_shell_dashboard_frequency_chart(&app, &app.dashboard, 3, 4, 64, 3); + CHECK(terminal.dashboard_block_count > 0U); + CHECK(strstr(terminal.output, "actuel 0 séances") != NULL); + CHECK(strstr(terminal.output, "P−") != NULL); + CHECK(strstr(terminal.output, "actuel") != NULL); + trainlog_database_close(database); + return true; +} + +static bool test_stats_dashboard_exact_dose_strict_later_and_invalid_rows(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + TrainlogTerminal terminal; + TrainlogSurface surface; + size_t working = 0U; + size_t maxima = 0U; + size_t index; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_33333333-3333-4333-8333-333333333333", "Presse parité", + "presse parite", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(dashboard_insert_completed_set_dose(database, + "se_90000000-0000-4000-8000-000000000001", "2026-09-08T08:00:00Z", + "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 100.0, 5)); + CHECK(sqlite3_exec(database->connection, + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) " + "SELECT id,1,10,80.0 FROM session_exercises WHERE " + "session_row_id=(SELECT id FROM sessions WHERE " + "session_id='se_90000000-0000-4000-8000-000000000001');", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(dashboard_insert_completed_set_dose(database, + "se_90000000-0000-4000-8000-000000000002", "2026-09-09T08:00:00Z", + "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 90.0, 10)); + CHECK(dashboard_insert_completed_set_dose(database, + "se_90000000-0000-4000-8000-000000000003", "2026-09-10T08:00:00Z", + "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 50.0, 12)); + CHECK(dashboard_insert_completed_set_dose(database, + "se_90000000-0000-4000-8000-000000000004", + "2026-09-10T09:00:00+01:00", "", TRAINLOG_LOAD_EXTERNAL, + "leg_press", 60.0, 12)); + CHECK(dashboard_insert_explicit_max_at(database, + "se_90000000-0000-4000-8000-000000000005", "2026-09-10T10:00:00Z", + "sxe_90000000-0000-4000-8000-000000000005", 100.0)); + CHECK(dashboard_insert_explicit_max_at(database, + "se_90000000-0000-4000-8000-000000000006", + "2026-09-10T11:00:00+01:00", + "sxe_90000000-0000-4000-8000-000000000006", 110.0)); + /* INVARIANT: one occurrence may own independent work and MAX facts. */ + CHECK(sqlite3_exec(database->connection, + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) " + "SELECT id,0,15,70.0 FROM session_exercises WHERE " + "entry_id='sxe_90000000-0000-4000-8000-000000000005';", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(dashboard_insert_completed_set_dose(database, + "se_90000000-0000-4000-8000-000000000007", "2026-09-11T08:00:00Z", + "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 999.0, 10)); + CHECK(sqlite3_exec(database->connection, + "UPDATE sessions SET started_at='legacy-invalid' WHERE " + "session_id='se_90000000-0000-4000-8000-000000000007';", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(dashboard_insert_completed_set_dose(database, + "se_90000000-0000-4000-8000-000000000008", "2026-09-11T09:00:00Z", + "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 1.0, 20)); + CHECK(sqlite3_exec(database->connection, + "UPDATE performed_sets SET weight_kg=NULL WHERE session_exercise_row_id=" + "(SELECT se.id FROM session_exercises se JOIN sessions s ON " + "s.id=se.session_row_id WHERE " + "s.session_id='se_90000000-0000-4000-8000-000000000008');", + NULL, NULL, NULL) == SQLITE_OK); + + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app.dashboard.period = TRAINLOG_STATS_ALL; + app_shell_load_dashboard(&app); + CHECK(!app.dashboard.error && app.dashboard.invalid_data); + CHECK(app.dashboard.completed_session_count == 7U); + CHECK(app.dashboard.performed_set_count == 7U); + CHECK(app.dashboard.explicit_max_count == 2U); + CHECK(app.dashboard.distinct_exercise_count == 1U); + for (index = 0U; index < app.dashboard.week_count; ++index) { + working += app.dashboard.weeks[index].working_improvements; + maxima += app.dashboard.weeks[index].max_improvements; + } + CHECK(working == 1U && maxima == 0U); + dashboard_prepare_render(&app, &terminal, &surface, 100, 30, 76, 24); + app_shell_render_dashboard(&app); + CHECK(strstr(terminal.output, "Données historiques invalides ignorées.") != NULL); + CHECK(strstr(terminal.output, "Progression globale") != NULL); + CHECK(strstr(terminal.output, "Mensurations") != NULL); + CHECK(strstr(terminal.output, "Fréquence") != NULL); + trainlog_database_close(database); + return true; +} + static bool test_exercise_merge_action_search_confirm_selects_target(void) { TrainlogDatabase *database = NULL; @@ -1101,6 +1856,351 @@ static bool test_exercise_merge_action_search_confirm_selects_target(void) return true; } +static bool test_stats_detail_charts_and_global_zone_projection(void) +{ + TrainlogAppContext app; + TrainlogTerminal terminal; + TrainlogSurface surface; + TrainlogBodyMetricPoint body[2]; + TrainlogDatabase *database = NULL; + size_t index; + bool chest = false; + bool back = false; + bool unclassified = false; + const char *const chest_zone[] = {NULL}; + const char *const back_zone[] = {NULL}; + (void)memset(&app, 0, sizeof(app)); + dashboard_prepare_render(&app, &terminal, &surface, 72, 20, 72, 16); + + /* The sparse-state contract is deliberately exercised through the exact + * detail painters: empty and singleton series have prose only, while two + * real observations acquire Unicode points/segments rather than ASCII. */ + app_shell_draw_performance_graph(&app, NULL, TRAINLOG_LOAD_EXTERNAL, + TRAINLOG_TRACKING_REPS, 5, 4, false); + CHECK(strstr(terminal.output, "Aucun point exploitable") != NULL); + CHECK(terminal.surface_draw_count == 0U); + (void)memset(&terminal, 0, sizeof(terminal)); surface.terminal = &terminal; + app.performance_count = 1U; + app.performance_points[0].has_performance = 1; + app.performance_points[0].load_mode = TRAINLOG_LOAD_EXTERNAL; + app.performance_points[0].weight_kg = 40.0; + app.performance_points[0].metric_value = 8; + (void)snprintf(app.performance_points[0].started_at, + sizeof(app.performance_points[0].started_at), "%s", "2026-09-01T08:00:00Z"); + app_shell_draw_performance_graph(&app, NULL, TRAINLOG_LOAD_EXTERNAL, + TRAINLOG_TRACKING_REPS, 5, 4, false); + CHECK(strstr(terminal.output, "1 point") != NULL); + CHECK(strstr(terminal.output, "tendance indisponible") != NULL); + CHECK(terminal.surface_draw_count == 0U); + app.performance_count = 2U; + app.performance_points[1] = app.performance_points[0]; + app.performance_points[1].weight_kg = 45.0; + (void)snprintf(app.performance_points[1].started_at, + sizeof(app.performance_points[1].started_at), "%s", "2026-09-02T08:00:00Z"); + (void)memset(&terminal, 0, sizeof(terminal)); surface.terminal = &terminal; + app_shell_draw_performance_graph(&app, NULL, TRAINLOG_LOAD_EXTERNAL, + TRAINLOG_TRACKING_REPS, 5, 4, false); + CHECK(rendered_draw_column(&terminal, 0x25cfU, 0U) >= 0); + for (index = 0U; index < terminal.drawn_count; ++index) + CHECK(terminal.drawn[index].codepoint != (uint32_t)'*' && + terminal.drawn[index].codepoint != (uint32_t)'.' && + terminal.drawn[index].codepoint != (uint32_t)'O'); + + (void)memset(body, 0, sizeof(body)); + (void)memset(&terminal, 0, sizeof(terminal)); surface.terminal = &terminal; + app_shell_draw_body_points(&app, body, 0U, 5, 4, "kg"); + CHECK(strstr(terminal.output, "Aucune donnée") != NULL); + body[0].value = 80.0; + (void)snprintf(body[0].observed_at, sizeof(body[0].observed_at), "%s", + "2026-09-01T08:00:00Z"); + (void)memset(&terminal, 0, sizeof(terminal)); surface.terminal = &terminal; + app_shell_draw_body_points(&app, body, 1U, 5, 4, "kg"); + CHECK(strstr(terminal.output, "1 relevé") != NULL); + CHECK(terminal.surface_draw_count == 0U); + body[1] = body[0]; body[1].value = 79.0; + (void)snprintf(body[1].observed_at, sizeof(body[1].observed_at), "%s", + "2026-09-02T08:00:00Z"); + (void)memset(&terminal, 0, sizeof(terminal)); surface.terminal = &terminal; + app_shell_draw_body_points(&app, body, 2U, 5, 4, "kg"); + CHECK(rendered_draw_column(&terminal, 0x25cfU, 0U) >= 0); + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise(database, + "ex_10000000-0000-4000-8000-000000000001", "Source", "source", + TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise(database, + "ex_10000000-0000-4000-8000-000000000002", "Canonique", "canonique", + TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise(database, + "ex_10000000-0000-4000-8000-000000000003", "Dos test", "dos test", + TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise(database, + "ex_10000000-0000-4000-8000-000000000004", "Sans zone", "sans zone", + TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_replace_exercise_body_zones(database, + "ex_10000000-0000-4000-8000-000000000001", "chest", chest_zone, 0U) == + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_replace_exercise_body_zones(database, + "ex_10000000-0000-4000-8000-000000000002", "chest", chest_zone, 0U) == + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_replace_exercise_body_zones(database, + "ex_10000000-0000-4000-8000-000000000003", "back", back_zone, 0U) == + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_merge_exercises(database, + "ex_10000000-0000-4000-8000-000000000001", + "ex_10000000-0000-4000-8000-000000000002") == TRAINLOG_STATUS_OK); + (void)memset(&app, 0, sizeof(app)); app.database = database; + app.dashboard.period = TRAINLOG_STATS_ALL; + app_shell_load_dashboard(&app); + CHECK(!app.dashboard.error && app.dashboard.zone_count == 3U); + for (index = 0U; index < app.dashboard.zone_count; ++index) { + chest = chest || (strcmp(app.dashboard.zones[index].label, "Pectoraux") == 0 && + app.dashboard.zones[index].count == 1U); + back = back || (strcmp(app.dashboard.zones[index].label, "Dos") == 0 && + app.dashboard.zones[index].count == 1U); + unclassified = unclassified || + (strcmp(app.dashboard.zones[index].label, "Non classés") == 0 && + app.dashboard.zones[index].count == 1U); + } + CHECK(chest && back && unclassified); + + /* Regression: uneven adjacent bars used to appear as one solid staircase. + * Exercise the real dashboard allocation with the reported distribution, + * and inspect cells rather than accepting matching label text. */ + { + static const size_t counts[] = {8U, 6U, 5U, 2U, 2U, 2U, 1U, 1U}; + static const int columns[] = {120, 100, 80, 72}; + static const int rows[] = {35, 30, 24, 20}; + static const int widths[] = {96, 76, 80, 72}; + static const int heights[] = {29, 24, 20, 16}; + static const int chart_tops[] = {19, 17, 15, 15}; + static const int chart_rows[] = {8, 5, 5, 1}; + static const size_t visible[] = {8U, 4U, 4U, 1U}; + size_t geometry; + app.dashboard.zone_count = sizeof(counts) / sizeof(counts[0]); + for (index = 0U; index < app.dashboard.zone_count; ++index) { + (void)snprintf(app.dashboard.zones[index].label, + sizeof(app.dashboard.zones[index].label), "Zone %zu", index + 1U); + app.dashboard.zones[index].count = counts[index]; + } + for (geometry = 0U; geometry < 4U; ++geometry) { + int chart_width = widths[geometry] - 8; + int label_width = chart_width >= 64 ? 20 : + chart_width >= 48 ? 16 : 12; + int bar_width = chart_width - label_width - 6; + int bar_left = 4 + label_width + 1; + int count_column = 4 + label_width + 2 + bar_width; + int stride = columns[geometry] >= 100 && + sizeof(counts) / sizeof(counts[0]) <= + (size_t)((chart_rows[geometry] + 1) / 2) ? 2 : 1; + size_t bucket; + dashboard_prepare_render(&app, &terminal, &surface, + columns[geometry], rows[geometry], widths[geometry], + heights[geometry]); + app_shell_render_dashboard(&app); + CHECK(strstr(terminal.output, "Répartition du catalogue") != NULL); + CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); + CHECK(terminal.stats_distribution_row > + terminal.stats_frequency_bottom_row); + for (bucket = 0U; bucket < visible[geometry]; ++bucket) { + int expected_row = chart_tops[geometry] + (int)bucket * stride; + int expected_blocks = (int)((counts[bucket] * + (size_t)bar_width + counts[0] - 1U) / counts[0]); + size_t drawn; + size_t printed; + size_t blocks = 0U; + bool found_count = false; + char count_text[8]; + (void)snprintf(count_text, sizeof(count_text), "%zu", counts[bucket]); + for (printed = 0U; printed < terminal.printed_count; ++printed) + if (terminal.printed[printed].row == expected_row && + terminal.printed[printed].column == count_column && + strcmp(terminal.printed[printed].text, count_text) == 0) + found_count = true; + for (drawn = 0U; drawn < terminal.drawn_count; ++drawn) { + const int drawn_row = terminal.drawn[drawn].row; + const int drawn_column = terminal.drawn[drawn].column; + const uint32_t glyph = terminal.drawn[drawn].codepoint; + if (drawn_row < chart_tops[geometry] || + drawn_row >= chart_tops[geometry] + chart_rows[geometry]) + continue; + CHECK(glyph != (uint32_t)'#' && glyph != (uint32_t)'*' && + glyph != (uint32_t)'+' && glyph != 0x2588U); + if (drawn_row == expected_row && glyph == 0x2586U) { + CHECK(drawn_column >= bar_left && + drawn_column < bar_left + expected_blocks); + ++blocks; + } + } + CHECK(found_count && blocks == (size_t)expected_blocks); + } + { + size_t drawn; + for (drawn = 0U; drawn < terminal.drawn_count; ++drawn) { + int relative_row = terminal.drawn[drawn].row - + chart_tops[geometry]; + if (relative_row < 0 || relative_row >= chart_rows[geometry] || + terminal.drawn[drawn].codepoint != 0x2586U) continue; + CHECK(relative_row % stride == 0); + CHECK((size_t)(relative_row / stride) < visible[geometry]); + } + } + if (stride == 2) { + size_t gap; + for (gap = 0U; gap + 1U < visible[geometry]; ++gap) { + size_t drawn; + int gap_row = chart_tops[geometry] + (int)gap * 2 + 1; + for (drawn = 0U; drawn < terminal.drawn_count; ++drawn) + CHECK(terminal.drawn[drawn].row != gap_row || + terminal.drawn[drawn].codepoint != 0x2586U); + } + } + } + /* A tall wide chart has room for all eight categories plus seven + * gaps. Verify that the enhancement really leaves those gap cells + * empty instead of reconnecting the old staircase. */ + dashboard_prepare_render(&app, &terminal, &surface, 120, 35, 96, 29); + app_shell_dashboard_zone_chart(&app, &app.dashboard, 0, 4, 88, 15); + for (index = 0U; index < 8U; ++index) { + size_t drawn; + for (drawn = 0U; drawn < terminal.drawn_count; ++drawn) + if (terminal.drawn[drawn].row == (int)index * 2) + CHECK(terminal.drawn[drawn].codepoint == 0x2586U); + if (index + 1U < 8U) + for (drawn = 0U; drawn < terminal.drawn_count; ++drawn) + CHECK(terminal.drawn[drawn].row != (int)index * 2 + 1 || + terminal.drawn[drawn].codepoint != 0x2586U); + } + } + for (index = 0U; index < 4U; ++index) { + static const int columns[] = {120, 100, 80, 72}; + static const int rows[] = {35, 30, 24, 20}; + static const int widths[] = {96, 76, 80, 72}; + static const int heights[] = {29, 24, 20, 16}; + dashboard_prepare_render(&app, &terminal, &surface, columns[index], rows[index], + widths[index], heights[index]); + app_shell_render_dashboard(&app); + CHECK(strstr(terminal.output, "Répartition du catalogue") != NULL); + CHECK(rendered_draw_column(&terminal, 0x2586U, 0U) >= 0); + CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); + CHECK(terminal.stats_distribution_row > + terminal.stats_frequency_bottom_row); + } + trainlog_navigation_init(&app.navigation); + app.navigation.current.route = TRAINLOG_ROUTE_STATS; + app.content_selected = 3U; + app_shell_primary(&app); + CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EXERCISES); + trainlog_database_close(database); + return true; +} + +static bool test_body_profile_snapshot_and_evolution_are_separate(void) +{ + TrainlogAppContext app; + TrainlogTerminal terminal; + TrainlogSurface surface; + const char *ids[2]; + size_t geometry; + size_t drawn; + static const int columns[] = {120, 100, 80, 72}; + static const int rows[] = {35, 30, 24, 20}; + static const int widths[] = {96, 76, 80, 72}; + static const int heights[] = {29, 24, 20, 16}; + (void)memset(&app, 0, sizeof(app)); + app.navigation.current.route = TRAINLOG_ROUTE_BODY; + app.focus = TRAINLOG_FOCUS_CONTENT; + app.layout.usable = true; + app.loaded_count = 2U; + (void)snprintf(app.body_records[0].observation_id, + sizeof(app.body_records[0].observation_id), "%s", "bo_latest"); + (void)snprintf(app.body_records[0].observed_at, + sizeof(app.body_records[0].observed_at), "%s", "2026-09-10T08:00:00Z"); + app.body_records[0].has_body_weight = true; + app.body_records[0].body_weight_kg = 80.0; + app.body_records[0].has_chest = true; + app.body_records[0].chest_cm = 100.0; + app.body_records[0].has_waist = true; + app.body_records[0].waist_cm = 85.0; + app.body_records[1] = app.body_records[0]; + (void)snprintf(app.body_records[1].observation_id, + sizeof(app.body_records[1].observation_id), "%s", "bo_older"); + (void)snprintf(app.body_records[1].observed_at, + sizeof(app.body_records[1].observed_at), "%s", "2026-08-10T08:00:00Z"); + app.body_records[1].body_weight_kg = 78.0; + app.body_records[1].chest_cm = 90.0; + app.body_records[1].waist_cm = 75.0; + ids[0] = app.body_records[0].observation_id; + ids[1] = app.body_records[1].observation_id; + trainlog_list_init(&app.list); + trainlog_list_set_items(&app.list, ids, 2U, 8U, true, false); + + app.body_metric_selected = 0U; + app.body_global_series[0].count = 1U; + app.body_global_series[0].points[0].value = 80.0; + (void)snprintf(app.body_global_series[0].points[0].observed_at, + sizeof(app.body_global_series[0].points[0].observed_at), "%s", + "2026-09-10T08:00:00Z"); + app.body_global_series[4].count = 1U; + app.body_global_series[4].points[0].value = 85.0; + (void)snprintf(app.body_global_series[4].points[0].observed_at, + sizeof(app.body_global_series[4].points[0].observed_at), "%s", + "2026-09-10T08:00:00Z"); + + dashboard_prepare_render(&app, &terminal, &surface, 72, 20, 72, 16); + app_shell_render_body_profile(&app); + CHECK(strstr(terminal.output, "Poids : 80.00 kg") != NULL); + CHECK(strstr(terminal.output, "Poitrine") != NULL); + CHECK(strstr(terminal.output, "100.0 cm") != NULL); + CHECK(strstr(terminal.output, "Tour de taille") != NULL); + CHECK(strstr(terminal.output, "85.0 cm") != NULL); + CHECK(strstr(terminal.output, "Épaules") == NULL); + CHECK(strstr(terminal.output, "1 relevé · tendance indisponible") != NULL); + CHECK(rendered_draw_column(&terminal, 0x2586U, 0U) >= 0); + for (drawn = 0U; drawn < terminal.drawn_count; ++drawn) { + CHECK(terminal.drawn[drawn].row != 4); + CHECK(terminal.drawn[drawn].codepoint != (uint32_t)'*' && + terminal.drawn[drawn].codepoint != (uint32_t)'.' && + terminal.drawn[drawn].codepoint != (uint32_t)'O'); + } + + app.body_global_series[0].count = 2U; + app.body_global_series[0].points[1].value = 80.0; + (void)snprintf(app.body_global_series[0].points[1].observed_at, + sizeof(app.body_global_series[0].points[1].observed_at), "%s", + "2026-09-10T08:00:00Z"); + app.body_global_series[0].points[0].value = 78.0; + (void)snprintf(app.body_global_series[0].points[0].observed_at, + sizeof(app.body_global_series[0].points[0].observed_at), "%s", + "2026-08-10T08:00:00Z"); + dashboard_prepare_render(&app, &terminal, &surface, 80, 24, 80, 20); + app_shell_render_body_profile(&app); + CHECK(strstr(terminal.output, "2026-08-10") != NULL); + CHECK(strstr(terminal.output, "2026-09-10") != NULL); + CHECK(rendered_draw_column(&terminal, 0x25cfU, 0U) >= 0); + + app_shell_dispatch(&app, TRAINLOG_KEY_DOWN); + CHECK(app.list.selected_index == 1U); + dashboard_prepare_render(&app, &terminal, &surface, 80, 24, 80, 20); + app_shell_render_body_profile(&app); + CHECK(strstr(terminal.output, "Poids : 78.00 kg") != NULL); + CHECK(strstr(terminal.output, "90.0 cm") != NULL); + CHECK(strstr(terminal.output, "100.0 cm") == NULL); + + app.body_metric_selected = 0U; + app_shell_move_available_body_metric(&app, 1); + CHECK(app.body_metric_selected == 4U); + for (geometry = 0U; geometry < 4U; ++geometry) { + dashboard_prepare_render(&app, &terminal, &surface, columns[geometry], + rows[geometry], widths[geometry], heights[geometry]); + app_shell_render_body_profile(&app); + CHECK(strstr(terminal.output, "PROFIL ACTUEL") != NULL); + CHECK(strstr(terminal.output, "ÉVOLUTION DANS LE TEMPS") != NULL); + CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); + } + return true; +} + int main(void) { if (!test_knowledge_scrolls_long_lists_at_minimum_terminal() || @@ -1121,7 +2221,14 @@ int main(void) !test_shell_sync_history_neutralizes_legacy_directions() || !test_equipment_collector_finds_match_after_first_pages() || !test_exercise_merge_action_search_confirm_selects_target() || - !test_body_graphs_draw_inside_minimum_content_surface()) return 1; + !test_stats_dashboard_sparse_series_names_frequency_and_footer() || + !test_stats_dashboard_uses_real_exact_time_snapshots_responsively() || + !test_stats_dashboard_uses_represented_local_weeks() || + !test_stats_dashboard_current_week_remains_zero_after_past_activity() || + !test_stats_dashboard_exact_dose_strict_later_and_invalid_rows() || + !test_body_graphs_draw_inside_minimum_content_surface() || + !test_body_profile_snapshot_and_evolution_are_separate() || + !test_stats_detail_charts_and_global_zone_projection()) return 1; (void)printf("PASS tui_workflows\n"); return 0; }