feat(stats): add global training dashboard
This commit is contained in:
parent
b2249b10b1
commit
766e6498a7
29 changed files with 4005 additions and 112 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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), {}) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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() })
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
88
docs/tui.md
88
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;
|
||||
|
|
|
|||
63
tools/generate_exercise_name_catalog.py
Normal file
63
tools/generate_exercise_name_catalog.py
Normal 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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
17
tui/include/trainlog/exercise_name_catalog.h
Normal file
17
tui/include/trainlog/exercise_name_catalog.h
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;";
|
||||
|
||||
|
|
|
|||
14
tui/src/database_internal.h
Normal file
14
tui/src/database_internal.h
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
typedef struct TrainlogTimestampKey {
|
||||
int64_t utc_second;
|
||||
int64_t local_day;
|
||||
const char *fraction;
|
||||
size_t fraction_length;
|
||||
} TrainlogTimestampKey;
|
||||
|
|
|
|||
1538
tui/src/tui.c
1538
tui/src/tui.c
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue