feat(stats): add global training dashboard

This commit is contained in:
fy59 2026-09-11 19:04:00 +02:00
parent b2249b10b1
commit 766e6498a7
29 changed files with 4005 additions and 112 deletions

View file

@ -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<StatisticsPoint>)
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<StatisticsPerformanceWeek>,
/** Comparable, context-owned values retained for exercise drill-downs. */
val performance: List<StatisticsSeries>,
val body: List<StatisticsSeries>,
val frequency: List<StatisticsFrequency>,
val sessionsLast7Days: Int,
val sessionsLast30Days: Int,
)

View file

@ -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<String, MutableList<PointRow>>()
val names = linkedMapOf<String, String>()
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<StatisticsSeries>()
data class EventCount(var working: Int = 0, var maxima: Int = 0)
val eventCounts = sortedMapOf<String, EventCount>()
performanceRows.forEach { (context, unsorted) ->
val rows = unsorted.sortedWith(
compareBy<PointRow> { 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<StatisticsSeries>()
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<Triple<TrainlogTimestampKey, String, StatisticsPoint>>()
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<Triple<TrainlogTimestampKey, String, StatisticsPoint>> { 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<String, FrequencyCount>()
val sessionTimes = mutableListOf<Pair<TrainlogTimestampKey, String>>()
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<String>()
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<StatisticsSeries> {
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(),

View file

@ -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<TrainlogTimestampKey> {
/* 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. */

View file

@ -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

View file

@ -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<StatisticsFrequency>,
today: LocalDate = LocalDate.now(),
): List<DashboardWeek> {
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<StatisticsDetail?>(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<DashboardWeek>) {
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<StatisticsSeries>, 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<StatisticsSeries>, 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), {}) }
}
}

View file

@ -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() })

View file

@ -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<Any?>(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<Any?>(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<Any?>(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<Any?>(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"
}
}

View file

@ -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))
}
}

View file

@ -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<StatisticsSeries> = emptyList(),
frequency: List<StatisticsFrequency> = 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,
)
}

View file

@ -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
```

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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;

View file

@ -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 <stddef.h>\n#include <string.h>\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")

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -10,6 +10,7 @@
#include "trainlog/id.h"
#include "trainlog/session_generation.h"
#include "timestamp.h"
#include "database_internal.h"
#include <math.h>
#include <limits.h>
@ -20,11 +21,6 @@
#include <sqlite3.h>
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;";

View file

@ -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 <sqlite3.h>
struct TrainlogDatabase {
sqlite3 *connection;
unsigned int read_snapshot_depth;
};
#endif

View file

@ -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;
}

View file

@ -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));

View file

@ -7,6 +7,7 @@
typedef struct TrainlogTimestampKey {
int64_t utc_second;
int64_t local_day;
const char *fraction;
size_t fraction_length;
} TrainlogTimestampKey;

File diff suppressed because it is too large Load diff

View file

@ -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(

View file

@ -4,6 +4,7 @@
*/
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@ -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");

View file

@ -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(

File diff suppressed because it is too large Load diff