feat(stats): add global dashboard and body focus home

This commit is contained in:
fy59 2026-09-11 20:24:32 +02:00
parent 766e6498a7
commit 42b12b8e1e
12 changed files with 1234 additions and 2 deletions

View file

@ -79,6 +79,7 @@ class DraftUiTestActivity : ComponentActivity() {
?.warning,
latestSession = null,
latestMaximum = null,
bodyZoneOverview = repository.getBodyZoneHomeOverview(),
onSession = {
when (
val result =
@ -95,6 +96,8 @@ class DraftUiTestActivity : ComponentActivity() {
}
}
},
onOpenExercises = {},
onOpenStatistics = {},
onBody = {},
onOpenLatestSession = {},
onSync = {},

View file

@ -0,0 +1,35 @@
package com.labfytools.trainlog.data
enum class BodyZoneHomeState(val label: String) {
PRIORITIZE("À privilégier"),
LITTLE_RECENT_WORK("Peu travaillé récemment"),
RECENT_WORK("Travaillé récemment"),
HIGH_RECENT_EXPOSURE("Forte exposition récente"),
INSUFFICIENT_DATA("Données insuffisantes"),
UNSUPPORTED("Aucun exercice résolu disponible"),
}
data class BodyZoneHomeStatus(
val zoneId: String,
val displayName: String,
val lastPrimaryExposure: String?,
val lastSecondaryExposure: String?,
val primaryExposureAgeSeconds: Long?,
val secondaryExposureAgeSeconds: Long?,
val primaryWork7Days: Int,
val secondaryWork7Days: Int,
val primaryWork30Days: Int,
val secondaryWork30Days: Int,
/** Distinct completed sessions with qualifying primary or secondary work in 30 days. */
val recentSessionCount: Int,
val availableExerciseCount: Int,
val state: BodyZoneHomeState,
val reasons: List<String>,
)
data class BodyZoneHomeOverview(
val zones: List<BodyZoneHomeStatus>,
val recommendations: List<BodyZoneHomeStatus>,
val hasTrainingHistory: Boolean,
val hasInvalidHistoryTimestamp: Boolean,
)

View file

@ -469,6 +469,181 @@ class TrainlogRepository(
)
}
fun getBodyZoneHomeOverview(): BodyZoneHomeOverview = getBodyZoneHomeOverview(
checkNotNull(TrainlogTimestamp.parse(OffsetDateTime.now().toString())),
)
/**
* WHY: Home guidance must describe recorded exposure, never infer recovery,
* fatigue, readiness, or a session prescription.
* CONTRACT: only completed occurrence-owned performed sets, continuous
* activity, and explicit MAX results qualify. Windows are inclusive rolling
* instants parsed by [TrainlogTimestamp]; SQL date and lexical comparisons
* are deliberately forbidden. Parent BODY ZONES and draft/target data never
* enter this read model.
*/
internal fun getBodyZoneHomeOverview(now: TrainlogTimestampKey): BodyZoneHomeOverview {
val db = database.readableDatabase
val focusZoneIds = setOf("chest", "back", "shoulders", "arms", "core", "glutes", "thighs", "calves")
val childZones = bodyZones.zones.filter { it.zoneId in focusZoneIds }
check(childZones.size == focusZoneIds.size && childZones.none { it.kind == BodyZoneKind.GROUP }) {
"BODY_FOCUS_HOME_V1 doit référencer exactement huit zones anatomiques enfant"
}
val childIds = childZones.mapTo(mutableSetOf()) { it.zoneId }
val canonicalByAlias = mutableMapOf<String, String>()
db.rawQuery(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases", null,
).use { cursor ->
while (cursor.moveToNext()) canonicalByAlias[cursor.getString(0)] = cursor.getString(1)
}
fun canonical(id: String): String {
var current = id
val seen = mutableSetOf<String>()
while (seen.add(current)) current = canonicalByAlias[current] ?: return current
return current
}
val relations = mutableMapOf<String, Pair<String?, List<String>>>()
db.rawQuery(
"""SELECT e.exercise_id,ebz.zone_id,ebz.role
FROM exercises e JOIN exercise_body_zones ebz ON ebz.exercise_row_id=e.id
ORDER BY e.exercise_id,ebz.role,ebz.zone_id""".trimIndent(), null,
).use { cursor ->
while (cursor.moveToNext()) {
val id = canonical(cursor.getString(0))
val zone = cursor.getString(1).takeIf { it in childIds } ?: continue
val old = relations[id] ?: (null to emptyList())
relations[id] = if (cursor.getString(2) == "primary") zone to old.second
else old.first to (old.second + zone).distinct()
}
}
/* There is no soft-active flag in schema v12: a present exercise row
* which is not a retired alias source is the active canonical row. A
* direct child-zone relation is the minimum resolved classification
* needed by the existing Catalogue filter. */
val availability = childIds.associateWith { mutableSetOf<String>() }
db.rawQuery("SELECT exercise_id FROM exercises ORDER BY exercise_id", null).use { cursor ->
while (cursor.moveToNext()) {
val stored = cursor.getString(0)
val id = canonical(stored)
if (stored != id) continue
val zones = relations[id] ?: continue
zones.first?.let { availability.getValue(it).add(id) }
zones.second.forEach { availability.getValue(it).add(id) }
}
}
data class MutableExposure(
var lastPrimary: Pair<TrainlogTimestampKey, String>? = null,
var lastSecondary: Pair<TrainlogTimestampKey, String>? = null,
var primary7: Int = 0,
var secondary7: Int = 0,
var primary30: Int = 0,
var secondary30: Int = 0,
val sessions30: MutableSet<String> = mutableSetOf(),
)
val exposure = childIds.associateWith { MutableExposure() }
val cutoff7 = TrainlogTimestampKey(now.utcSecond - 7L * 86400L, now.fraction)
val cutoff30 = TrainlogTimestampKey(now.utcSecond - 30L * 86400L, now.fraction)
var hasActualHistory = false
var invalidTimestamp = false
db.rawQuery(
"""SELECT s.session_id,s.started_at,e.exercise_id,
(SELECT COUNT(*) FROM performed_sets ps WHERE ps.session_exercise_row_id=se.id),
EXISTS(SELECT 1 FROM continuous_activity ca WHERE ca.session_exercise_row_id=se.id),
EXISTS(SELECT 1 FROM max_results mr WHERE mr.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 continuous_activity ca WHERE ca.session_exercise_row_id=se.id)
OR EXISTS(SELECT 1 FROM max_results mr WHERE mr.session_exercise_row_id=se.id)
ORDER BY s.session_id,se.entry_id""".trimIndent(), null,
).use { cursor ->
while (cursor.moveToNext()) {
hasActualHistory = true
val timestampText = cursor.getString(1)
val time = TrainlogTimestamp.parse(timestampText)
if (time == null) { invalidTimestamp = true; continue }
if (time > now) continue
val zones = relations[canonical(cursor.getString(2))] ?: continue
val work = cursor.getInt(3) + cursor.getInt(4) + cursor.getInt(5)
fun add(zoneId: String, primary: Boolean) {
val item = exposure.getValue(zoneId)
if (primary) {
if (item.lastPrimary == null || time > item.lastPrimary!!.first) item.lastPrimary = time to timestampText
if (time >= cutoff7) item.primary7 += work
if (time >= cutoff30) item.primary30 += work
} else {
if (item.lastSecondary == null || time > item.lastSecondary!!.first) item.lastSecondary = time to timestampText
if (time >= cutoff7) item.secondary7 += work
if (time >= cutoff30) item.secondary30 += work
}
if (time >= cutoff30) item.sessions30 += cursor.getString(0)
}
zones.first?.let { add(it, true) }
zones.second.forEach { add(it, false) }
}
}
val supported = childZones.filter { availability.getValue(it.zoneId).isNotEmpty() }
/* FROZEN V1 rank: absent primary first; then longest elapsed primary
* exposure; then lower primary work at 7d and 30d; then weaker
* secondary modifiers (7d, 30d, longest elapsed); finally stable ID. */
val ranked = supported.sortedWith { left, right ->
val a = exposure.getValue(left.zoneId)
val b = exposure.getValue(right.zoneId)
compareValues(a.lastPrimary != null, b.lastPrimary != null).takeIf { it != 0 }
?: compareValues(a.lastPrimary?.first, b.lastPrimary?.first).takeIf { it != 0 }
?: compareValues(a.primary7, b.primary7).takeIf { it != 0 }
?: compareValues(a.primary30, b.primary30).takeIf { it != 0 }
?: compareValues(a.secondary7, b.secondary7).takeIf { it != 0 }
?: compareValues(a.secondary30, b.secondary30).takeIf { it != 0 }
?: compareValues(a.lastSecondary?.first, b.lastSecondary?.first).takeIf { it != 0 }
?: left.zoneId.compareTo(right.zoneId)
}.take(3).map { it.zoneId }.toSet()
val statuses = childZones.map { zone ->
val item = exposure.getValue(zone.zoneId)
val available = availability.getValue(zone.zoneId).size
val state = when {
available == 0 -> BodyZoneHomeState.UNSUPPORTED
invalidTimestamp -> BodyZoneHomeState.INSUFFICIENT_DATA
zone.zoneId in ranked -> BodyZoneHomeState.PRIORITIZE
item.primary7 >= 4 -> BodyZoneHomeState.HIGH_RECENT_EXPOSURE
item.primary7 > 0 -> BodyZoneHomeState.RECENT_WORK
else -> BodyZoneHomeState.LITTLE_RECENT_WORK
}
val reasons = when (state) {
BodyZoneHomeState.UNSUPPORTED -> listOf(BodyZoneHomeState.UNSUPPORTED.label)
BodyZoneHomeState.INSUFFICIENT_DATA -> listOf("Au moins un horodatage historique est invalide.")
BodyZoneHomeState.PRIORITIZE -> listOf(
if (item.lastPrimary == null) "Aucune exposition primaire enregistrée."
else "Exposition primaire moins récente ou moins représentée.",
"Exposition secondaire comptée comme modificateur plus faible.",
)
BodyZoneHomeState.LITTLE_RECENT_WORK -> listOf("Aucun travail primaire enregistré sur les 7 derniers jours.")
BodyZoneHomeState.RECENT_WORK -> listOf("Travail primaire enregistré sur les 7 derniers jours.")
BodyZoneHomeState.HIGH_RECENT_EXPOSURE -> listOf("Plusieurs faits de travail primaire récents sont enregistrés.")
}
BodyZoneHomeStatus(
zone.zoneId, zone.displayName,
item.lastPrimary?.second, item.lastSecondary?.second,
item.lastPrimary?.first?.let { now.utcSecond - it.utcSecond },
item.lastSecondary?.first?.let { now.utcSecond - it.utcSecond },
item.primary7, item.secondary7, item.primary30, item.secondary30,
item.sessions30.size, available, state, reasons,
)
}
val byId = statuses.associateBy { it.zoneId }
return BodyZoneHomeOverview(
zones = statuses,
recommendations = ranked.mapNotNull(byId::get),
hasTrainingHistory = hasActualHistory,
hasInvalidHistoryTimestamp = invalidTimestamp,
)
}
fun getExerciseKnowledge(exerciseId: String): ExerciseKnowledge? =
trainingKnowledge.getExerciseKnowledge(exerciseId)

View file

@ -1,11 +1,56 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.labfytools.trainlog.data.BodyZoneHomeOverview
import com.labfytools.trainlog.data.BodyZoneHomeState
import com.labfytools.trainlog.data.BodyZoneHomeStatus
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.LatestExerciseMax
import com.labfytools.trainlog.model.SessionSummary
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography
@Composable
fun HomeScreen(
@ -13,7 +58,10 @@ fun HomeScreen(
draftError: String?,
latestSession: SessionSummary?,
latestMaximum: LatestExerciseMax?,
bodyZoneOverview: BodyZoneHomeOverview,
onSession: () -> Unit,
onOpenExercises: (String) -> Unit,
onOpenStatistics: () -> Unit,
onBody: () -> Unit,
onOpenLatestSession: (String) -> Unit,
onSync: () -> Unit,
@ -29,6 +77,7 @@ fun HomeScreen(
TrainlogPrimaryAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
}
}
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises, onOpenStatistics)
latestSession?.let { session ->
TrainlogFrame("Dernière séance") {
TrainlogAction(formatStartedAt(session.startedAt), "${sessionTypeLabel(session.sessionType)} · ${session.exerciseCount} exercice(s)", { onOpenLatestSession(session.sessionId) })
@ -44,3 +93,415 @@ fun HomeScreen(
}
}
}
/**
* CONTRACT: this section reports conservative recorded exposure only. Its
* wording and actions must not imply recovery/readiness/fatigue or expose the
* hidden session generator.
*/
@Composable
internal fun BodyZoneHomeSection(
overview: BodyZoneHomeOverview,
onOpenExercises: (String) -> Unit,
onOpenStatistics: () -> Unit,
) {
val colors = LocalTrainlogColors.current
var showExposureInfo by remember { mutableStateOf(false) }
var selectedId by remember(overview.zones) {
mutableStateOf(overview.recommendations.firstOrNull()?.zoneId ?: overview.zones.firstOrNull()?.zoneId)
}
val selected = overview.zones.firstOrNull { it.zoneId == selectedId }
TrainlogFrame("Zones à travailler", active = overview.recommendations.isNotEmpty()) {
TextButton(onClick = { showExposureInfo = true }) {
Text("Exposition récente ⓘ")
}
if (!overview.hasTrainingHistory) {
TrainlogInfo("Pas encore d’historique d’entraînement.")
}
if (overview.hasInvalidHistoryTimestamp) {
TrainlogInfo("Données insuffisantes : un horodatage historique ne peut pas être interprété.", colors.warning)
}
BodyZoneMap(overview.zones, selectedId) { selectedId = it }
selected?.let { zone ->
BasicText(
"Sélection : ${zone.displayName}",
modifier = Modifier.padding(bottom = 8.dp),
style = TrainlogTypography.normal.copy(color = colors.lavender),
)
}
TrainlogInfo("Priorités", colors.accent)
overview.recommendations.take(3).forEachIndexed { index, zone ->
TrainlogAction(
"${index + 1}. ${zone.displayName} · ${homeStateLabel(zone)}",
recommendationReason(zone),
{ selectedId = zone.zoneId },
)
}
if (overview.recommendations.isEmpty()) {
TrainlogInfo("Aucune zone disposant d’un exercice résolu n’est disponible.")
}
selected?.let { zone ->
Column(
Modifier.fillMaxWidth().padding(top = 8.dp).background(colors.surface, RoundedCornerShape(8.dp)).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
BasicText(zone.displayName, style = TrainlogTypography.section.copy(color = colors.text))
BasicText(homeStateLabel(zone), style = TrainlogTypography.small.copy(color = stateColor(zone.state, colors)))
BasicText(
"Dernier travail principal : ${ageLabel(zone.primaryExposureAgeSeconds)}",
style = TrainlogTypography.small.copy(color = colors.text),
)
BasicText(
exposureCountLabel(zone.primaryWork7Days, "principale") + " sur 7 j · " +
exposureCountLabel(zone.secondaryWork7Days, "secondaire") + " sur 7 j",
style = TrainlogTypography.small.copy(color = colors.text),
)
BasicText(
availableExerciseLabel(zone.availableExerciseCount),
style = TrainlogTypography.small.copy(color = colors.text),
)
if (zone.availableExerciseCount == 0) TrainlogInfo(BodyZoneHomeState.UNSUPPORTED.label, colors.muted)
TrainlogAction("Voir les exercices", "", { onOpenExercises(zone.zoneId) })
TrainlogAction("Voir les statistiques", "", onOpenStatistics)
}
}
}
if (showExposureInfo) {
AlertDialog(
onDismissRequest = { showExposureInfo = false },
title = { Text("Exposition récente") },
text = {
Text("Ces couleurs décrivent le travail enregistré récemment. Elles ne mesurent pas la récupération physiologique.")
},
confirmButton = {
TextButton(onClick = { showExposureInfo = false }) { Text("Compris") }
},
)
}
}
@Composable
private fun BodyZoneMap(zones: List<BodyZoneHomeStatus>, selectedId: String?, select: (String) -> Unit) {
val byId = zones.associateBy { it.zoneId }
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
BodyMapPanel(BodyView.FRONT, byId, selectedId, select, Modifier.weight(1f))
BodyMapPanel(BodyView.BACK, byId, selectedId, select, Modifier.weight(1f))
}
}
@Composable
private fun BodyMapPanel(
view: BodyView,
zones: Map<String, BodyZoneHomeStatus>,
selectedId: String?,
select: (String) -> Unit,
modifier: Modifier,
) {
val colors = LocalTrainlogColors.current
val visibleZones = view.zoneIds.mapNotNull(zones::get)
Column(modifier.semantics { contentDescription = "Carte corporelle, ${view.label}" }) {
BasicText(view.label, style = TrainlogTypography.small.copy(color = colors.muted))
BoxWithConstraints(Modifier.fillMaxWidth().height(236.dp)) {
val panelWidth = maxWidth
val panelHeight = maxHeight
Canvas(
Modifier
.matchParentSize()
.zIndex(1f)
.testTag("body-map-${view.testName}")
.pointerInput(view, visibleZones) {
detectTapGestures { tap ->
bodyZoneAtPoint(
viewName = view.testName,
width = size.width.toFloat(),
height = size.height.toFloat(),
point = tap,
visibleZoneIds = visibleZones.mapTo(mutableSetOf()) { it.zoneId },
)?.let(select)
}
},
) {
drawOval(
color = colors.surfaceAlt,
topLeft = Offset(size.width * .405f, size.height * .015f),
size = Size(size.width * .19f, size.height * .135f),
)
drawPath(neckPath(size.width, size.height), colors.surfaceAlt)
bodyRegions(view, size.width, size.height).forEach { region ->
val zone = zones[region.zoneId] ?: return@forEach
val chosen = selectedId == region.zoneId
region.paths.forEach { path ->
drawPath(path, stateColor(zone.state, colors).copy(alpha = .62f))
if (chosen) {
drawPath(
path,
color = colors.lavender.copy(alpha = .34f),
style = Stroke(width = 8f),
)
}
drawPath(
path,
color = if (chosen) colors.lavender else colors.crust.copy(alpha = .72f),
style = Stroke(width = if (chosen) 4.5f else 1.25f),
)
}
}
}
// Accessibility controls deliberately have no pointer modifier: direct
// touch is resolved against the Path geometry above, while TalkBack gets
// one explicit, comfortably sized Button target per visible zone.
visibleZones.forEach { zone ->
val target = view.accessibilityBounds.getValue(zone.zoneId)
val chosen = selectedId == zone.zoneId
Box(
Modifier
.offset(panelWidth * target.left, panelHeight * target.top)
.size(
width = maxOf(panelWidth * target.width, 48.dp),
height = maxOf(panelHeight * target.height, 48.dp),
)
.testTag("body-zone-${view.testName}-${zone.zoneId}")
.semantics {
role = Role.Button
selected = chosen
contentDescription = buildString {
append(view.label)
append(", ")
append(zone.displayName)
append(", ")
append(homeStateLabel(zone))
append(if (chosen) ", sélectionnée" else ", non sélectionnée")
}
onClick(label = "Sélectionner ${zone.displayName}") {
select(zone.zoneId)
true
}
},
)
}
}
}
}
private enum class BodyView(
val label: String,
val testName: String,
val zoneIds: List<String>,
val accessibilityBounds: Map<String, Rect>,
) {
FRONT(
label = "Face",
testName = "front",
zoneIds = listOf("shoulders", "chest", "arms", "core", "thighs", "calves"),
accessibilityBounds = mapOf(
"shoulders" to Rect(.18f, .14f, .82f, .32f),
"chest" to Rect(.27f, .24f, .73f, .40f),
"arms" to Rect(.02f, .30f, .32f, .59f),
"core" to Rect(.31f, .39f, .69f, .59f),
"thighs" to Rect(.25f, .58f, .75f, .81f),
"calves" to Rect(.27f, .79f, .73f, .99f),
),
),
BACK(
label = "Dos",
testName = "back",
zoneIds = listOf("shoulders", "back", "arms", "glutes", "thighs", "calves"),
accessibilityBounds = mapOf(
"shoulders" to Rect(.18f, .14f, .82f, .32f),
"back" to Rect(.29f, .26f, .71f, .53f),
"arms" to Rect(.68f, .30f, .98f, .59f),
"glutes" to Rect(.29f, .52f, .71f, .67f),
"thighs" to Rect(.25f, .65f, .75f, .83f),
"calves" to Rect(.27f, .81f, .73f, .99f),
),
),
}
private data class BodyRegion(val zoneId: String, val paths: List<Path>)
private fun Path.containsPoint(point: Offset): Boolean {
val approximation = asAndroidPath().approximate(.5f)
val pointCount = approximation.size / 3
if (pointCount < 3) return false
var inside = false
var previous = pointCount - 1
for (current in 0 until pointCount) {
val currentX = approximation[current * 3 + 1]
val currentY = approximation[current * 3 + 2]
val previousX = approximation[previous * 3 + 1]
val previousY = approximation[previous * 3 + 2]
if ((currentY > point.y) != (previousY > point.y)) {
val crossingX = (previousX - currentX) * (point.y - currentY) /
(previousY - currentY) + currentX
if (point.x < crossingX) inside = !inside
}
previous = current
}
return inside
}
internal fun bodyZoneAtPoint(
viewName: String,
width: Float,
height: Float,
point: Offset,
visibleZoneIds: Set<String>,
): String? {
val view = BodyView.entries.firstOrNull { it.testName == viewName } ?: return null
return bodyRegions(view, width, height)
.lastOrNull { it.zoneId in visibleZoneIds && it.paths.any { path -> path.containsPoint(point) } }
?.zoneId
}
/**
* CONTRACT: these stylized shapes represent only Trainlog BODY ZONES UX. They
* are not medical anatomy and never communicate recovery, fatigue or readiness.
* Every coordinate is normalized; drawing and hit testing must both call this
* function with the same Canvas size so their Path geometry remains identical.
*/
private fun bodyRegions(view: BodyView, width: Float, height: Float): List<BodyRegion> {
fun path(build: Path.(Float, Float) -> Unit) = Path().apply { build(width, height) }
fun paired(zoneId: String, left: Path, right: Path) = BodyRegion(zoneId, listOf(left, right))
val shoulders = paired(
"shoulders",
path { w, h ->
moveTo(.48f * w, .175f * h); cubicTo(.40f * w, .17f * h, .30f * w, .165f * h, .22f * w, .215f * h)
cubicTo(.18f * w, .24f * h, .18f * w, .285f * h, .23f * w, .30f * h)
cubicTo(.30f * w, .275f * h, .38f * w, .25f * h, .48f * w, .245f * h); close()
},
path { w, h ->
moveTo(.52f * w, .175f * h); cubicTo(.60f * w, .17f * h, .70f * w, .165f * h, .78f * w, .215f * h)
cubicTo(.82f * w, .24f * h, .82f * w, .285f * h, .77f * w, .30f * h)
cubicTo(.70f * w, .275f * h, .62f * w, .25f * h, .52f * w, .245f * h); close()
},
)
val arms = paired(
"arms",
path { w, h ->
moveTo(.215f * w, .255f * h); cubicTo(.16f * w, .285f * h, .145f * w, .365f * h, .13f * w, .43f * h)
lineTo(.105f * w, .56f * h); cubicTo(.12f * w, .59f * h, .17f * w, .595f * h, .19f * w, .555f * h)
lineTo(.255f * w, .305f * h); close()
},
path { w, h ->
moveTo(.785f * w, .255f * h); cubicTo(.84f * w, .285f * h, .855f * w, .365f * h, .87f * w, .43f * h)
lineTo(.895f * w, .56f * h); cubicTo(.88f * w, .59f * h, .83f * w, .595f * h, .81f * w, .555f * h)
lineTo(.745f * w, .305f * h); close()
},
)
val thighs = paired(
"thighs",
path { w, h ->
moveTo(.35f * w, .565f * h); cubicTo(.31f * w, .63f * h, .30f * w, .71f * h, .315f * w, .805f * h)
cubicTo(.34f * w, .83f * h, .405f * w, .825f * h, .435f * w, .795f * h)
lineTo(.475f * w, .57f * h); close()
},
path { w, h ->
moveTo(.65f * w, .565f * h); cubicTo(.69f * w, .63f * h, .70f * w, .71f * h, .685f * w, .805f * h)
cubicTo(.66f * w, .83f * h, .595f * w, .825f * h, .565f * w, .795f * h)
lineTo(.525f * w, .57f * h); close()
},
)
val calves = paired(
"calves",
path { w, h ->
moveTo(.315f * w, .805f * h); cubicTo(.285f * w, .86f * h, .315f * w, .91f * h, .335f * w, .98f * h)
lineTo(.405f * w, .98f * h); cubicTo(.41f * w, .91f * h, .45f * w, .855f * h, .435f * w, .795f * h); close()
},
path { w, h ->
moveTo(.685f * w, .805f * h); cubicTo(.715f * w, .86f * h, .685f * w, .91f * h, .665f * w, .98f * h)
lineTo(.595f * w, .98f * h); cubicTo(.59f * w, .91f * h, .55f * w, .855f * h, .565f * w, .795f * h); close()
},
)
return if (view == BodyView.FRONT) {
val chest = paired(
"chest",
path { w, h ->
moveTo(.27f * w, .285f * h); cubicTo(.32f * w, .245f * h, .40f * w, .24f * h, .49f * w, .265f * h)
lineTo(.49f * w, .38f * h); cubicTo(.42f * w, .395f * h, .34f * w, .38f * h, .29f * w, .345f * h); close()
},
path { w, h ->
moveTo(.73f * w, .285f * h); cubicTo(.68f * w, .245f * h, .60f * w, .24f * h, .51f * w, .265f * h)
lineTo(.51f * w, .38f * h); cubicTo(.58f * w, .395f * h, .66f * w, .38f * h, .71f * w, .345f * h); close()
},
)
val core = BodyRegion("core", listOf(path { w, h ->
moveTo(.30f * w, .36f * h); cubicTo(.35f * w, .40f * h, .38f * w, .43f * h, .37f * w, .55f * h)
cubicTo(.41f * w, .58f * h, .59f * w, .58f * h, .63f * w, .55f * h)
cubicTo(.62f * w, .43f * h, .65f * w, .40f * h, .70f * w, .36f * h)
cubicTo(.60f * w, .39f * h, .40f * w, .39f * h, .30f * w, .36f * h); close()
}))
listOf(shoulders, chest, arms, core, thighs, calves)
} else {
val back = BodyRegion("back", listOf(path { w, h ->
moveTo(.28f * w, .285f * h); cubicTo(.35f * w, .25f * h, .43f * w, .245f * h, .50f * w, .265f * h)
cubicTo(.57f * w, .245f * h, .65f * w, .25f * h, .72f * w, .285f * h)
cubicTo(.68f * w, .38f * h, .64f * w, .47f * h, .62f * w, .55f * h)
cubicTo(.56f * w, .57f * h, .44f * w, .57f * h, .38f * w, .55f * h)
cubicTo(.36f * w, .47f * h, .32f * w, .38f * h, .28f * w, .285f * h); close()
}))
val glutes = paired(
"glutes",
path { w, h ->
moveTo(.37f * w, .535f * h); cubicTo(.31f * w, .57f * h, .32f * w, .64f * h, .39f * w, .665f * h)
cubicTo(.43f * w, .675f * h, .475f * w, .645f * h, .49f * w, .595f * h); lineTo(.49f * w, .55f * h); close()
},
path { w, h ->
moveTo(.63f * w, .535f * h); cubicTo(.69f * w, .57f * h, .68f * w, .64f * h, .61f * w, .665f * h)
cubicTo(.57f * w, .675f * h, .525f * w, .645f * h, .51f * w, .595f * h); lineTo(.51f * w, .55f * h); close()
},
)
listOf(shoulders, back, arms, glutes, thighs, calves)
}
}
private fun neckPath(width: Float, height: Float) = Path().apply {
moveTo(.445f * width, .13f * height)
lineTo(.43f * width, .19f * height)
cubicTo(.46f * width, .205f * height, .54f * width, .205f * height, .57f * width, .19f * height)
lineTo(.555f * width, .13f * height)
close()
}
private fun stateColor(state: BodyZoneHomeState, colors: TrainlogColors): Color {
return when (state) {
BodyZoneHomeState.PRIORITIZE -> colors.lavender
BodyZoneHomeState.LITTLE_RECENT_WORK -> colors.info
BodyZoneHomeState.RECENT_WORK -> colors.success
BodyZoneHomeState.HIGH_RECENT_EXPOSURE -> colors.notice
BodyZoneHomeState.INSUFFICIENT_DATA -> colors.warning
BodyZoneHomeState.UNSUPPORTED -> colors.muted
}
}
internal fun recommendationReason(zone: BodyZoneHomeStatus): String = when {
zone.lastPrimaryExposure == null -> "Aucune exposition principale enregistrée"
zone.primaryWork7Days == 0 -> "Dernier travail principal : ${ageLabel(zone.primaryExposureAgeSeconds)}"
else -> exposureCountLabel(zone.primaryWork7Days, "principale") + " sur 7 j"
}
internal fun homeStateLabel(zone: BodyZoneHomeStatus): String =
if (zone.state == BodyZoneHomeState.PRIORITIZE && zone.primaryWork7Days > 0) {
"Priorité relative"
} else {
zone.state.label
}
internal fun exposureCountLabel(count: Int, adjective: String): String =
"$count ${if (count == 1) "exposition" else "expositions"} " +
if (count == 1) adjective else "${adjective}s"
private fun availableExerciseLabel(count: Int): String =
"$count ${if (count == 1) "exercice disponible" else "exercices disponibles"}"
internal fun ageLabel(seconds: Long?): String = when {
seconds == null -> "jamais enregistrée"
seconds < 86400L -> "aujourd’hui"
seconds < 2L * 86400L -> "il y a 1 jour"
else -> "il y a ${seconds / 86400L} jours"
}

View file

@ -76,7 +76,15 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
AppRoute.Home -> HomeScreen(
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.message ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning,
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
repository.getBodyZoneHomeOverview(),
::openManualSession,
{ zoneId ->
exerciseState.filterZoneId.value = zoneId
exerciseState.unclassifiedFilter.value = false
exerciseState.searchQuery.value = ""
open(AppRoute.Exercises)
},
{ open(AppRoute.Statistics) },
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
)
AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, ::openManualSession, { open(AppRoute.CompletedSessions) })

View file

@ -0,0 +1,187 @@
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.assertNull
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 BodyZoneHomeOverviewRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private val databaseName = "body-focus-${UUID.randomUUID()}.db"
private val repository = TrainlogRepository(context, databaseName)
@After fun close() { repository.close(); context.deleteDatabase(databaseName) }
@Test fun suppliedRankingSecondaryModifierAndUnsupportedZone() {
editDb { db ->
clearMappings(db)
exercise(db, CHEST, "Pectoraux test", "chest")
exercise(db, BACK, "Dos test", "back", listOf("shoulders"))
exercise(db, SHOULDERS, "Épaules test", "shoulders")
exercise(db, THIGHS, "Cuisses test", "thighs")
work(db, "chest-old", "2026-09-01T12:00:00Z", CHEST)
work(db, "shoulders-old", "2026-09-03T12:00:00Z", SHOULDERS)
work(db, "thighs-recent", "2026-09-08T12:00:00Z", THIGHS)
work(db, "back-recent", "2026-09-10T12:00:00Z", BACK)
}
val result = repository.getBodyZoneHomeOverview(now())
assertEquals(listOf("chest", "shoulders", "thighs"), result.recommendations.map { it.zoneId })
assertTrue(result.recommendations.take(2).map { it.zoneId }.containsAll(listOf("chest", "shoulders")))
assertFalse(result.recommendations.any { it.zoneId == "back" || it.zoneId == "calves" })
val shoulders = result.zones.single { it.zoneId == "shoulders" }
assertEquals(0, shoulders.primaryWork7Days)
assertEquals(1, shoulders.secondaryWork7Days)
assertEquals("2026-09-03T12:00:00Z", shoulders.lastPrimaryExposure)
assertEquals("2026-09-10T12:00:00Z", shoulders.lastSecondaryExposure)
assertEquals(BodyZoneHomeState.UNSUPPORTED, result.zones.single { it.zoneId == "calves" }.state)
assertEquals(listOf("chest", "back", "shoulders", "arms", "core", "glutes", "thighs", "calves"), result.zones.map { it.zoneId })
}
@Test fun aliasHistoryCountsOnceAndParentRelationsNeverEnterOverview() {
editDb { db ->
clearMappings(db)
exercise(db, CANONICAL, "Canonique", "chest")
exercise(db, RETIRED, "Ancien", null)
db.execSQL(
"INSERT INTO exercise_aliases(source_exercise_id,canonical_exercise_id) VALUES(?,?)",
arrayOf(RETIRED, CANONICAL),
)
// A corrupt grouping relation is impossible through production APIs;
// this fixture proves the read boundary still cannot aggregate it.
val canonicalRow = rowId(db, CANONICAL)
db.execSQL("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) VALUES(?,'upper_body','secondary')", arrayOf(canonicalRow))
work(db, "retired-work", "2026-09-10T12:00:00Z", RETIRED)
}
val result = repository.getBodyZoneHomeOverview(now())
val chest = result.zones.single { it.zoneId == "chest" }
assertEquals(1, chest.primaryWork7Days)
assertEquals(1, chest.primaryWork30Days)
assertEquals(1, chest.recentSessionCount)
assertEquals(1, chest.availableExerciseCount)
assertFalse(result.zones.any { it.zoneId == "upper_body" || it.zoneId == "lower_body" || it.zoneId == "full_body" })
}
@Test fun emptyHistoryIsHonestAndOnlySupportedZonesAreSuggested() {
editDb { db ->
clearMappings(db)
exercise(db, CHEST, "Pectoraux test", "chest")
exercise(db, BACK, "Dos test", "back")
}
val result = repository.getBodyZoneHomeOverview(now())
assertFalse(result.hasTrainingHistory)
assertEquals(listOf("back", "chest"), result.recommendations.map { it.zoneId })
assertTrue(result.recommendations.all { it.availableExerciseCount > 0 })
assertNull(result.zones.single { it.zoneId == "chest" }.lastPrimaryExposure)
assertEquals(BodyZoneHomeState.UNSUPPORTED, result.zones.single { it.zoneId == "calves" }.state)
}
@Test fun exactRollingInstantsAndTargetsOrDraftsDoNotQualify() {
editDb { db ->
clearMappings(db)
exercise(db, CHEST, "Pectoraux test", "chest")
work(db, "boundary", "2026-09-04T14:00:00+02:00", CHEST)
work(db, "outside", "2026-09-04T11:59:59.999999999Z", CHEST)
continuousWork(db, "continuous", "2026-09-10T09:00:00Z", CHEST)
maximumWork(db, "maximum", "2026-09-10T10:00:00Z", CHEST)
val session = session(db, "target", "2026-09-11T11:00:00Z")
db.execSQL(
"""INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,entry_id,target_sets)
VALUES(?,?,0,'sets','reps',0,'target-only',9)""".trimIndent(),
arrayOf(session, rowId(db, CHEST)),
)
}
val result = repository.getBodyZoneHomeOverview(now())
val chest = result.zones.single { it.zoneId == "chest" }
assertEquals(3, chest.primaryWork7Days)
assertEquals(4, chest.primaryWork30Days)
assertEquals(4, chest.recentSessionCount)
}
private fun now() = TrainlogTimestamp.parse("2026-09-11T12:00:00Z")!!
private fun editDb(block: (SQLiteDatabase) -> Unit) {
repository.listExercises()
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("PRAGMA foreign_keys=ON")
block(db)
}
}
private fun clearMappings(db: SQLiteDatabase) {
db.execSQL("DELETE FROM exercise_body_zones")
db.execSQL("DELETE FROM exercise_aliases")
}
private fun exercise(db: SQLiteDatabase, id: String, name: String, primary: String?, secondary: List<String> = emptyList()) {
db.execSQL(
"INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) VALUES(?,?,?,'sets','reps',0)",
arrayOf(id, name, name.lowercase()),
)
val row = rowId(db, id)
primary?.let { db.execSQL("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) VALUES(?,?,'primary')", arrayOf<Any>(row, it)) }
secondary.forEach { db.execSQL("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) VALUES(?,?,'secondary')", arrayOf<Any>(row, it)) }
}
private fun work(db: SQLiteDatabase, id: String, at: String, exerciseId: String) {
val session = session(db, id, at)
db.execSQL(
"""INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,entry_id)
VALUES(?,?,0,'sets','reps',0,?)""".trimIndent(),
arrayOf<Any>(session, rowId(db, exerciseId), "entry-$id"),
)
db.execSQL(
"INSERT INTO performed_sets(session_exercise_row_id,position,reps) SELECT id,0,8 FROM session_exercises WHERE entry_id=?",
arrayOf("entry-$id"),
)
}
private fun continuousWork(db: SQLiteDatabase, id: String, at: String, exerciseId: String) {
val session = session(db, id, at)
db.execSQL(
"""INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,entry_id)
VALUES(?,?,0,'continuous','duration',0,?)""".trimIndent(),
arrayOf<Any>(session, rowId(db, exerciseId), "entry-$id"),
)
db.execSQL("INSERT INTO continuous_activity(session_exercise_row_id,duration_seconds) SELECT id,600 FROM session_exercises WHERE entry_id=?", arrayOf("entry-$id"))
}
private fun maximumWork(db: SQLiteDatabase, id: String, at: String, exerciseId: String) {
val session = session(db, id, at)
db.execSQL(
"""INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,entry_id)
VALUES(?,?,0,'sets','reps',0,?)""".trimIndent(),
arrayOf<Any>(session, rowId(db, exerciseId), "entry-$id"),
)
db.execSQL("INSERT INTO max_results(session_exercise_row_id,max_weight_kg) SELECT id,100.0 FROM session_exercises WHERE entry_id=?", arrayOf("entry-$id"))
}
private fun session(db: SQLiteDatabase, id: String, at: String): Long {
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,'training')", arrayOf("session-$id", at))
return db.rawQuery("SELECT id FROM sessions WHERE session_id=?", arrayOf("session-$id")).use { it.moveToFirst(); it.getLong(0) }
}
private fun rowId(db: SQLiteDatabase, id: String): Long =
db.rawQuery("SELECT id FROM exercises WHERE exercise_id=?", arrayOf(id)).use { it.moveToFirst(); it.getLong(0) }
companion object {
private const val CHEST = "ex_10000000-0000-4000-8000-000000000001"
private const val BACK = "ex_10000000-0000-4000-8000-000000000002"
private const val SHOULDERS = "ex_10000000-0000-4000-8000-000000000003"
private const val THIGHS = "ex_10000000-0000-4000-8000-000000000004"
private const val CANONICAL = "ex_10000000-0000-4000-8000-000000000005"
private const val RETIRED = "ex_10000000-0000-4000-8000-000000000006"
}
}

View file

@ -0,0 +1,68 @@
package com.labfytools.trainlog.ui
import com.labfytools.trainlog.data.BodyZoneHomeState
import com.labfytools.trainlog.data.BodyZoneHomeStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BodyZoneHomePresentationTest {
@Test fun detailWordingIsExposureOnlyAndIncludesEvidence() {
val status = BodyZoneHomeStatus(
zoneId = "chest",
displayName = "Pectoraux",
lastPrimaryExposure = "2026-09-01T12:00:00Z",
lastSecondaryExposure = null,
primaryExposureAgeSeconds = 10 * 86400L,
secondaryExposureAgeSeconds = null,
primaryWork7Days = 0,
secondaryWork7Days = 1,
primaryWork30Days = 2,
secondaryWork30Days = 3,
recentSessionCount = 2,
availableExerciseCount = 4,
state = BodyZoneHomeState.PRIORITIZE,
reasons = listOf("Exposition primaire moins récente."),
)
val text = recommendationReason(status)
assertEquals("Dernier travail principal : il y a 10 jours", text)
assertEquals("jamais enregistrée", ageLabel(null))
assertEquals("aujourd’hui", ageLabel(60))
assertEquals("il y a 10 jours", ageLabel(10 * 86400L))
listOf("récupération", "fatigue", "prêt", "séance recommandée").forEach {
assertTrue(it !in text.lowercase())
}
}
@Test fun exposureLabelsUseNaturalFrenchSingularAndPlural() {
assertEquals("1 exposition principale", exposureCountLabel(1, "principale"))
assertEquals("2 expositions principales", exposureCountLabel(2, "principale"))
assertEquals("1 exposition secondaire", exposureCountLabel(1, "secondaire"))
assertEquals("3 expositions secondaires", exposureCountLabel(3, "secondaire"))
}
@Test fun recentPrioritizedExposureIsPresentedAsRelativePriority() {
val status = BodyZoneHomeStatus(
zoneId = "chest",
displayName = "Pectoraux",
lastPrimaryExposure = "2026-09-10T12:00:00Z",
lastSecondaryExposure = null,
primaryExposureAgeSeconds = 86400L,
secondaryExposureAgeSeconds = null,
primaryWork7Days = 2,
secondaryWork7Days = 1,
primaryWork30Days = 3,
secondaryWork30Days = 1,
recentSessionCount = 2,
availableExerciseCount = 4,
state = BodyZoneHomeState.PRIORITIZE,
reasons = emptyList(),
)
assertEquals("Priorité relative", homeStateLabel(status))
assertEquals("2 expositions principales sur 7 j", recommendationReason(status))
assertFalse(recommendationReason(status).contains("fait(s)"))
assertFalse(recommendationReason(status).contains("pondéré"))
}
}

View file

@ -0,0 +1,192 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.click
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.data.BodyZoneHomeOverview
import com.labfytools.trainlog.data.BodyZoneHomeState
import com.labfytools.trainlog.data.BodyZoneHomeStatus
import com.labfytools.trainlog.ui.theme.TrainlogTheme
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class BodyZoneHomeSemanticsTest {
@get:Rule val compose = createComposeRule()
@Test fun compactLargeFontMapExposesEveryViewSpecificZoneAndChangesSelection() {
setSection(widthDp = 288, fontScale = 1.6f)
compose.onNodeWithContentDescription("Carte corporelle, Face").assertExists()
compose.onNodeWithContentDescription("Carte corporelle, Dos").assertExists()
expectedFrontZones.forEach { (id, name) ->
val state = if (id == "chest") "Priorité relative" else "Travaillé récemment"
val selection = if (id == "chest") "sélectionnée" else "non sélectionnée"
compose.onNodeWithContentDescription("Face, $name, $state, $selection")
.assertExists()
.assertHasClickAction()
}
expectedBackZones.forEach { (_, name) ->
compose.onNodeWithContentDescription("Dos, $name, Travaillé récemment, non sélectionnée")
.assertExists()
.assertHasClickAction()
}
compose.onNodeWithContentDescription("Face, Pectoraux, Priorité relative, sélectionnée").assertIsSelected()
compose.onNodeWithText("Sélection : Pectoraux").assertExists()
compose.onNodeWithContentDescription("Dos, Fessiers, Travaillé récemment, non sélectionnée")
.assertIsNotSelected()
.performSemanticsAction(SemanticsActions.OnClick)
compose.onNodeWithContentDescription("Dos, Fessiers, Travaillé récemment, sélectionnée")
.assertIsSelected()
compose.onNodeWithText("Sélection : Fessiers").assertExists()
compose.onNodeWithText("Fessiers").assertTextEquals("Fessiers")
// Anatomical labels are not rendered over either vector: each tagged
// Canvas is a leaf and textual state/detail remains below the maps.
compose.onNodeWithTag("body-map-front", useUnmergedTree = true).onChildren().assertCountEquals(0)
compose.onNodeWithTag("body-map-back", useUnmergedTree = true).onChildren().assertCountEquals(0)
compose.onNodeWithText("1. Pectoraux · Priorité relative").assertExists()
compose.onNodeWithText("1 exposition principale sur 7 j").assertExists()
listOf("fait(s) primaire(s)", "fait(s) secondaire(s)", "secondaire pondéré plus faiblement").forEach {
compose.onAllNodesWithText(it, substring = true).assertCountEquals(0)
}
}
@Test fun compactExposureInfoOpensAccessibleExplanation() {
setSection(widthDp = 380, fontScale = 1f)
compose.onNodeWithText("Exposition récente ⓘ")
.assertHasClickAction()
.performSemanticsAction(SemanticsActions.OnClick)
compose.onNodeWithText(
"Ces couleurs décrivent le travail enregistré récemment. Elles ne mesurent pas la récupération physiologique.",
).assertExists()
compose.onNodeWithText("Compris").assertHasClickAction()
}
@Test fun wideLayoutKeepsBothMapsSelectionAndCompactPriorities() {
setSection(widthDp = 720, fontScale = 1f)
compose.onNodeWithTag("body-map-front").assertExists()
compose.onNodeWithTag("body-map-back").assertExists()
compose.onNodeWithText("Priorités").assertExists()
compose.onNodeWithText("1. Pectoraux · Priorité relative")
.assertHasClickAction()
.performSemanticsAction(SemanticsActions.OnClick)
compose.onNodeWithText("Sélection : Pectoraux").assertExists()
}
@Test fun practicalWidthMapSelectionFeedsExistingNavigationActions() {
var openedZone: String? = null
var statisticsOpened = false
setSection(
widthDp = 380,
fontScale = 1f,
onOpenExercises = { openedZone = it },
onOpenStatistics = { statisticsOpened = true },
)
assertEquals("core", bodyZoneAtPoint("front", 180f, 236f, Offset(90f, 113.28f), allZoneIds.toSet()))
compose.onNodeWithTag("body-map-front")
.performTouchInput { click(percentOffset(.5f, .48f)) }
compose.onNodeWithContentDescription("Face, Abdominaux / tronc, Travaillé récemment, sélectionnée")
.assertIsSelected()
compose.onNodeWithText("Voir les exercices").assertHasClickAction()
.performSemanticsAction(SemanticsActions.OnClick)
assertEquals("core", openedZone)
compose.onNodeWithText("Voir les statistiques").assertHasClickAction()
.performSemanticsAction(SemanticsActions.OnClick)
assertTrue(statisticsOpened)
}
private fun setSection(
widthDp: Int,
fontScale: Float,
onOpenExercises: (String) -> Unit = {},
onOpenStatistics: () -> Unit = {},
) {
val zones = allZoneIds.map { id ->
status(id, if (id == "chest") BodyZoneHomeState.PRIORITIZE else BodyZoneHomeState.RECENT_WORK)
}
compose.setContent {
TrainlogTheme {
CompositionLocalProvider(LocalDensity provides Density(1f, fontScale)) {
Box(Modifier.width(widthDp.dp)) {
BodyZoneHomeSection(
BodyZoneHomeOverview(zones, listOf(zones.first()), true, false),
onOpenExercises,
onOpenStatistics,
)
}
}
}
}
}
private fun status(id: String, state: BodyZoneHomeState) = BodyZoneHomeStatus(
zoneId = id,
displayName = mapOf(
"chest" to "Pectoraux", "back" to "Dos", "shoulders" to "Épaules", "arms" to "Bras",
"core" to "Abdominaux / tronc", "glutes" to "Fessiers", "thighs" to "Cuisses", "calves" to "Mollets",
).getValue(id),
lastPrimaryExposure = "2026-09-10T12:00:00Z",
lastSecondaryExposure = null,
primaryExposureAgeSeconds = 86400L,
secondaryExposureAgeSeconds = null,
primaryWork7Days = 1,
secondaryWork7Days = 0,
primaryWork30Days = 1,
secondaryWork30Days = 0,
recentSessionCount = 1,
availableExerciseCount = 1,
state = state,
reasons = listOf(state.label),
)
private companion object {
val allZoneIds = listOf("chest", "back", "shoulders", "arms", "core", "glutes", "thighs", "calves")
val expectedFrontZones = listOf(
"shoulders" to "Épaules",
"chest" to "Pectoraux",
"arms" to "Bras",
"core" to "Abdominaux / tronc",
"thighs" to "Cuisses",
"calves" to "Mollets",
)
val expectedBackZones = listOf(
"shoulders" to "Épaules",
"back" to "Dos",
"arms" to "Bras",
"glutes" to "Fessiers",
"thighs" to "Cuisses",
"calves" to "Mollets",
)
}
}

View file

@ -75,7 +75,7 @@ the dashboard remains available and reports that invalid history was ignored.
The section roots are:
```text
Accueil durable-draft resume, generation/manual capture, concise local links
Accueil durable-draft resume, manual capture, recent BODY ZONES exposure, concise local links
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
@ -144,7 +144,8 @@ targets.
## Session generator V1
Home exposes **Générer une séance**. The generator uses the shared frozen
The V1 generator remains implemented but hidden from normal UI; Home exposes no
generator action. The generator uses the shared frozen
policy for all 11 selectable BODY ZONES, four goals, policy duration presets
and custom bounds. A generated preview is read-only until acceptance and
explains target dose, rest, equipment, observed-load source or absence,
@ -157,6 +158,52 @@ non-mutating `existing_active_draft` conflict. Empty results cannot be accepted;
nonempty partial results may be accepted and edited normally. Final completion
continues to require actual captured work.
## Body focus Home V1
Home displays **Zones à travailler** immediately after the active-session or
manual-session action. This is a read-only description of recent recorded
training exposure. It does not estimate recovery, readiness or fatigue and it
does not prescribe or generate a session.
The production `getBodyZoneHomeOverview(now)` read model processes exactly the
eight meaningful child zones `chest`, `back`, `shoulders`, `arms`, `core`,
`glutes`, `thighs` and `calves`. Grouping zones `upper_body`, `lower_body` and
`full_body` are never overlays and never contribute duplicate exposure. Only
completed occurrence-owned `performed_sets`, `continuous_activity` and
`max_results` are evidence; targets, empty occurrences and active drafts are
excluded. Timestamps use the canonical parser and inclusive rolling instant
boundaries, never SQLite date functions or lexical ordering. Alias history is
resolved to the canonical exercise identity and counted once.
Each zone reports the last primary and secondary exposure, primary/secondary
actual-work counts for 7 and 30 days, distinct exposed-session count for 30
days, active canonical exercise availability, a textual state and reasons.
Primary exposure is the dominant signal. Secondary exposure is only a weaker
modifier and can never stand in for missing primary exposure.
Eligible zones have at least one present canonical exercise with a direct
resolved child-zone relation. Unsupported zones remain visible with **Aucun
exercice résolu disponible** but are never recommended. The top three (or
fewer) use this exact stable ordering: no primary exposure first; oldest last
primary exposure; lower primary work over 7 days; lower primary work over 30
days; lower secondary work over 7 days; lower secondary work over 30 days;
oldest last secondary exposure; stable `zone_id`. Empty history says **Pas
encore d’historique d’entraînement.** and ranks only supported zones.
The compact side-by-side Compose **Face**/**Dos** maps use independent filled,
anatomically contoured cubic `Path` regions for shoulders, chest/back, arms,
core/glutes, thighs and calves. The canvas contains no zone-name text and is
only a stylized BODY ZONES selector, never medical anatomy or a recovery model.
Direct taps are resolved against the same normalized paths used to draw each
region. Selection changes both fill and a high-contrast outline. Separate
transparent accessibility controls follow the region bounds, provide at least
48 dp targets, use the Button role, and announce view, zone, textual exposure
state and selected state; color is never the sole signal. Zone details remain
below the maps and show last-primary age, 7-day primary/secondary work and
available exercise count. **Voir les exercices** opens the existing Catalogue
with its body-zone filter, while **Voir les statistiques** opens the existing
Statistics landing route.
Load editing offers compact choices for automatic V1 qualification, a
user-selected `%MAX`, or no numeric target; direct manual kg remains available.
`%MAX` accepts an integer 1..100 and uses exactly

View file

@ -130,6 +130,8 @@ Implemented:
- native Kotlin/Compose application;
- local SQLite database v11, with non-destructive v3 -> v11 migration;
- one durable active-session draft, Home resume and raw-form restoration;
- `BODY_FOCUS_HOME_V1`: a compact accessible Home front/back BODY ZONES map,
textual top-three exposure ranking and Catalogue/Statistics drill-through;
- explicit confirmed discard and atomic completed-save/draft-clear;
- exercise creation;
- stable-ID exercise rename/editing with referenced-profile protection;
@ -149,6 +151,18 @@ Implemented:
- Android-triggered synchronization request;
- synchronization receipt handling.
The Home BODY ZONES overview is exposure-only and read-only. It uses only
actual performed sets, continuous activities and explicit MAX results from
completed history, with canonical parsed-instant 7/30-day windows. Plans,
targets and drafts never qualify. Exactly the eight child anatomical zones are
processed; grouping zones cannot double count. Primary exposure dominates the
stable ranking, while secondary exposure is a weaker tie modifier. Alias
history resolves once to the canonical exercise. Unsupported zones are shown
as **Aucun exercice résolu disponible** and excluded from suggestions; empty
history is stated explicitly and suggests supported zones only. The existing
session generator remains hidden from normal UI, and the overview makes no
recovery, readiness, fatigue or prescription claim.
The Android catalog exposes **Modifier** for every existing exercise. A rename
updates `name` and `normalized_name` in the original row, never creates an ID,
and remains valid for completed session and active-draft references. A profile

View file

@ -46,6 +46,7 @@ The current product baseline includes:
- native Android capture client;
- exercise catalog;
- canonical hierarchical body zones, primary/secondary relations and filters;
- Android `BODY_FOCUS_HOME_V1` recent-exposure map and deterministic supported-zone ranking;
- profile-aware set and continuous activity;
- heterogeneous repetition sets;
- session history and editing;
@ -84,6 +85,17 @@ metadata without duplicates. `ANDROID_BANNER_PARITY_V1` is a presentation-only
completed checkpoint: all Android screens share the Notcurses-derived compact
header; it does not reorder the roadmap below.
`BODY_FOCUS_HOME_V1` is a completed Android Home presentation/query tranche.
It adds no schema, sync or catalog semantics. The overview uses completed
actual work only, canonical exact-instant windows and exactly eight anatomical
child zones. Its stable order is: absent primary, oldest primary, lower primary
7d then 30d, weaker lower secondary 7d then 30d, oldest secondary, then stable
zone ID. Only zones backed by a current canonical exercise with a resolved
direct relation are eligible; unsupported zones stay visible but cannot be
recommended. It is descriptive exposure guidance, not medical recovery,
fatigue/readiness analysis or a session prescription. Session Generator V1
remains hidden.
## Product boundary
`ANDROID_SESSION_DRAFT_V1` is an implemented P0 capture-reliability correction:

View file

@ -300,6 +300,36 @@ Android repository host tests additionally cover exercise editing:
search+filter, unclassified rows, companion replay/update/conflict and
zone-safe exercise-identity merging.
`BodyZoneHomeOverviewRepositoryTest` exercises the production
`getBodyZoneHomeOverview(now)` query with a caller-supplied deterministic
instant. It covers the settled chest/back/shoulders/thighs example,
secondary-only exposure remaining weaker than primary, stable ranking,
unsupported calves exclusion, parent-zone non-participation, retired alias A
to canonical B counted once, active/resolved availability, honest empty
history, inclusive exact 7/30-day instant boundaries, and target-only exclusion.
`BodyZoneHomePresentationTest` covers concise evidence and last-primary age
wording without recovery/fatigue/prescription language.
`BodyZoneHomeSemanticsTest` renders the production section at 288 dp content
width (the constrained 320 dp-screen case) with 1.6x font scale and at a
practical 380 dp content width. It verifies every view-specific Face/Dos zone
and textual state, Button actions, selected/not-selected transitions, leaf
canvases with no anatomical text children, exact normalized-path containment,
a real Canvas tap selecting core, and both existing-route callbacks. The Home
implementation keeps side-by-side maps with separate region-aligned, at-least
48 dp semantic controls; 320/360/393/412 dp and TalkBack visual checks remain
explicit device/emulator validation.
Focused host command:
```bash
cd android
JAVA_HOME=/usr/lib/jvm/java-17-openjdk \
./gradlew :app:testDebugUnitTest \
--tests com.labfytools.trainlog.data.BodyZoneHomeOverviewRepositoryTest \
--tests com.labfytools.trainlog.ui.BodyZoneHomePresentationTest \
--tests com.labfytools.trainlog.ui.BodyZoneHomeSemanticsTest
```
## 7. Hardware MTP validation
Hardware probes and real synchronization are separate from the normal automated