feat(exercises): add canonical naming and finalize app shell

This commit is contained in:
fy59 2026-09-11 14:16:31 +02:00
parent 1f027743f2
commit b2249b10b1
31 changed files with 543 additions and 65 deletions

View file

@ -338,3 +338,7 @@ Added:
Estimated analytics remain derived display values and are never persisted as Estimated analytics remain derived display values and are never persisted as
direct measurements. Android remains capture-only for this feature. direct measurements. Android remains capture-only for this feature.
- Hid `SESSION_GENERATOR_V1` from normal UI pending V2; manual `%MAX` entry is unchanged.
- Added versioned canonical exercise display names, stale-peer-name convergence,
and the STATS_V1 query-contract preparation (no analytics implementation).

View file

@ -346,3 +346,7 @@ BODY_PROPORTION_RATIOS=PASS
BODY_SYMMETRY_ANALYTICS=PASS BODY_SYMMETRY_ANALYTICS=PASS
DESKTOP_TESTS=39/39 PASS DESKTOP_TESTS=39/39 PASS
``` ```
`SESSION_GENERATOR_V1` remains implemented but is hidden from normal Android
and TUI navigation pending V2. `catalog/exercise-names-v1.json` owns mapped
exercise display metadata across sync while preserving stable IDs and history.

View file

@ -98,7 +98,6 @@ class DraftUiTestActivity : ComponentActivity() {
onBody = {}, onBody = {},
onOpenLatestSession = {}, onOpenLatestSession = {},
onSync = {}, onSync = {},
onGenerateSession = {},
) )
} }
} }

View file

@ -0,0 +1,61 @@
package com.labfytools.trainlog.data
import android.content.Context
import org.json.JSONObject
import java.text.Normalizer
import java.util.Locale
import java.util.UUID
/** Strict shared owner map for presentation names of known stable exercise IDs. */
object ExerciseNameCatalog {
private const val ASSET_NAME = "exercise-names-v1.json"
fun load(context: Context): Map<String, String> {
val root = context.assets.open(ASSET_NAME).bufferedReader().use {
JSONObject(it.readText())
}
check(root.keys().asSequence().toSet() == setOf("format", "version", "names"))
check(root.getString("format") == "trainlog-exercise-names-v1")
check(root.getInt("version") == 1)
val names = root.getJSONArray("names")
val result = linkedMapOf<String, String>()
val canonicalOwners = mutableSetOf<String>()
val previousOwners = mutableSetOf<String>()
for (index in 0 until names.length()) {
val item = names.getJSONObject(index)
check(item.keys().asSequence().toSet() == setOf(
"exercise_id", "display_name", "normalized_name", "previous_display_names",
))
val exerciseId = item.getString("exercise_id")
check(exerciseId.startsWith("ex_"))
val uuid = UUID.fromString(exerciseId.removePrefix("ex_"))
check(uuid.version() == 4 && "ex_$uuid" == exerciseId)
val displayName = item.getString("display_name")
val normalizedName = normalizeManifestName(displayName)
check(displayName == displayName.trim() && item.getString("normalized_name") == normalizedName)
check(result.put(exerciseId, displayName) == null)
check(canonicalOwners.add(normalizedName))
val previous = item.getJSONArray("previous_display_names")
check(previous.length() > 0)
val localPrevious = mutableSetOf<String>()
for (previousIndex in 0 until previous.length()) {
val oldName = previous.getString(previousIndex)
val oldNormalized = normalizeManifestName(oldName)
check(oldName == oldName.trim() && oldNormalized != normalizedName)
check(localPrevious.add(oldNormalized) && previousOwners.add(oldNormalized))
}
}
check(canonicalOwners.intersect(previousOwners).isEmpty())
return result
}
private fun normalizeManifestName(value: String): String {
val normalized = Normalizer.normalize(value.lowercase(Locale.ROOT), Normalizer.Form.NFC)
.replace(Regex("\\s+"), " ")
.trim()
check(normalized.isNotEmpty())
return normalized
}
}

View file

@ -274,6 +274,7 @@ class TrainlogRepository(
ANDROID_DATABASE_NAME, ANDROID_DATABASE_NAME,
) { ) {
private val applicationContext = context.applicationContext private val applicationContext = context.applicationContext
private val canonicalExerciseNames = ExerciseNameCatalog.load(applicationContext)
private val bodyZones = BodyZoneCatalog.load(applicationContext) private val bodyZones = BodyZoneCatalog.load(applicationContext)
private val trainingKnowledge = TrainingKnowledgeCatalog.load(applicationContext, bodyZones) private val trainingKnowledge = TrainingKnowledgeCatalog.load(applicationContext, bodyZones)
private val sessionGenerationPolicy = private val sessionGenerationPolicy =
@ -1534,10 +1535,8 @@ class TrainlogRepository(
val exerciseId = resolveExerciseId(db, suppliedExerciseId) val exerciseId = resolveExerciseId(db, suppliedExerciseId)
val suppliedRetiredAlias = suppliedExerciseId != exerciseId val suppliedRetiredAlias = suppliedExerciseId != exerciseId
val name = val suppliedName = item.getString("name")
item.getString( val name = canonicalExerciseNames[exerciseId] ?: suppliedName
"name"
)
val normalized = val normalized =
normalizeName( normalizeName(
@ -1600,6 +1599,12 @@ class TrainlogRepository(
} }
if ( if (
!NewExerciseProfile(
name = suppliedName,
recordingMode = recording,
trackingMode = tracking,
dataFields = dataFields,
).validate() ||
!NewExerciseProfile( !NewExerciseProfile(
name = name, name = name,
recordingMode = recording, recordingMode = recording,

View file

@ -14,7 +14,6 @@ fun HomeScreen(
latestSession: SessionSummary?, latestSession: SessionSummary?,
latestMaximum: LatestExerciseMax?, latestMaximum: LatestExerciseMax?,
onSession: () -> Unit, onSession: () -> Unit,
onGenerateSession: () -> Unit,
onBody: () -> Unit, onBody: () -> Unit,
onOpenLatestSession: (String) -> Unit, onOpenLatestSession: (String) -> Unit,
onSync: () -> Unit, onSync: () -> Unit,
@ -26,10 +25,8 @@ fun HomeScreen(
if (activeDraft != null) { if (activeDraft != null) {
val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) "Test max" else "Entraînement" val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) "Test max" else "Entraînement"
TrainlogPrimaryAction("Reprendre la séance en cours", "$kind · ${activeDraft.exercises.size} exercice(s)", onSession) TrainlogPrimaryAction("Reprendre la séance en cours", "$kind · ${activeDraft.exercises.size} exercice(s)", onSession)
TrainlogAction("Programmer une séance", "La proposition sera conservée séparément.", onGenerateSession)
} else { } else {
TrainlogPrimaryAction("Programmer une séance", "Préparer une proposition modifiable à partir d'une zone, d'un objectif et d'une durée.", onGenerateSession) TrainlogPrimaryAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
TrainlogAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
} }
} }
latestSession?.let { session -> latestSession?.let { session ->

View file

@ -20,7 +20,6 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
fun SessionsHub( fun SessionsHub(
draft: ActiveSessionDraft?, draft: ActiveSessionDraft?,
onResume: () -> Unit, onResume: () -> Unit,
onGenerate: () -> Unit,
onManual: () -> Unit, onManual: () -> Unit,
onHistory: () -> Unit, onHistory: () -> Unit,
) { ) {
@ -31,7 +30,6 @@ fun SessionsHub(
else TrainlogAction("Reprendre", "${draft.exercises.size} exercice(s) · le brouillon durable est conservé.", onResume, accent = colors.success) else TrainlogAction("Reprendre", "${draft.exercises.size} exercice(s) · le brouillon durable est conservé.", onResume, accent = colors.success)
} }
TrainlogFrame("Préparer") { TrainlogFrame("Préparer") {
TrainlogAction("Programmer une séance", "Générer une proposition modifiable.", onGenerate, accent = if (draft == null) colors.success else colors.accent)
TrainlogAction("Nouvelle séance manuelle", if (draft == null) "Créer explicitement un brouillon de séance." else "Ouvrir la séance en cours sans l'écraser.", onManual) TrainlogAction("Nouvelle séance manuelle", if (draft == null) "Créer explicitement un brouillon de séance." else "Ouvrir la séance en cours sans l'écraser.", onManual)
} }
TrainlogAction("Séances effectuées", "Consulter les actuals, plans et MAX enregistrés.", onHistory) TrainlogAction("Séances effectuées", "Consulter les actuals, plans et MAX enregistrés.", onHistory)

View file

@ -76,10 +76,10 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
AppRoute.Home -> HomeScreen( AppRoute.Home -> HomeScreen(
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.message ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning, activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.message ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning,
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(), repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
::openManualSession, { open(AppRoute.SessionGenerator) }, ::openManualSession,
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) }, { open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
) )
AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, { open(AppRoute.SessionGenerator) }, ::openManualSession, { open(AppRoute.CompletedSessions) }) AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, ::openManualSession, { open(AppRoute.CompletedSessions) })
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exporter.exportMobileBundle(); draftRevision++ }) AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exporter.exportMobileBundle(); draftRevision++ })
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, { AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée." draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée."

View file

@ -590,6 +590,49 @@ class TrainlogRepositoryDraftTest {
assertEquals(1, repo.listExercises().count { it.exerciseId == canonicalId }) assertEquals(1, repo.listExercises().count { it.exerciseId == canonicalId })
} }
@Test
fun pcCatalogUsesCanonicalMappedNameButLeavesDistinctAndCustomIdsAlone() {
val repo = openRepository()
val mappedId = "ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163"
val distinctId = "ex_617007f9-7420-4408-91b9-8ffb77900f13"
val customId = "ex_fcc75fa7-671e-4868-bab2-47033128dfb7"
fun item(id: String, name: String) = JSONObject()
.put("exercise_id", id)
.put("name", name)
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0)
val stalePcCatalog = JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put(
"exercises",
org.json.JSONArray()
.put(item(mappedId, "Seated leg curl"))
.put(item(distinctId, "Seated Leg"))
.put(item(customId, "My custom curl")),
)
assertEquals(
PcCatalogImportResult.Applied(imported = 3, reconciled = 0, skipped = 0),
repo.applyPcCatalogJson(stalePcCatalog.toString()),
)
val byId = repo.listExercises().associateBy { it.exerciseId }
assertEquals("Flexion de genou assise", byId.getValue(mappedId).name)
assertEquals("Seated Leg", byId.getValue(distinctId).name)
assertEquals("My custom curl", byId.getValue(customId).name)
/* Replaying the stale mapped label is idempotent and cannot restore it. */
assertEquals(
PcCatalogImportResult.Applied(imported = 0, reconciled = 0, skipped = 3),
repo.applyPcCatalogJson(stalePcCatalog.toString()),
)
assertEquals(
"Flexion de genou assise",
repo.listExercises().single { it.exerciseId == mappedId }.name,
)
}
@Test @Test
fun marcheReconciliationKeepsRicherProfileAndTwoOccurrenceValues() { fun marcheReconciliationKeepsRicherProfileAndTwoOccurrenceValues() {
val repo = openRepository() val repo = openRepository()

View file

@ -0,0 +1,22 @@
{
"format": "trainlog-exercise-names-v1",
"version": 1,
"names": [
{"exercise_id":"ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef","display_name":"Adduction de hanche assise","normalized_name":"adduction de hanche assise","previous_display_names":["Hip adduction"]},
{"exercise_id":"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab","display_name":"Extension de genou assise","normalized_name":"extension de genou assise","previous_display_names":["Leg extension"]},
{"exercise_id":"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0","display_name":"Rotation du tronc à la machine","normalized_name":"rotation du tronc à la machine","previous_display_names":["Rotary torso"]},
{"exercise_id":"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de","display_name":"Tirage horizontal divergent assis","normalized_name":"tirage horizontal divergent assis","previous_display_names":["Diverged seated row"]},
{"exercise_id":"ex_33f79331-871c-4eed-babe-346e53a99070","display_name":"Tirage horizontal assis","normalized_name":"tirage horizontal assis","previous_display_names":["Seated row"]},
{"exercise_id":"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835","display_name":"Extension du tronc","normalized_name":"extension du tronc","previous_display_names":["Back extension"]},
{"exercise_id":"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962","display_name":"Écarté inversé à la machine","normalized_name":"écarté inversé à la machine","previous_display_names":["Rear Delt"]},
{"exercise_id":"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b","display_name":"Développé épaules convergent","normalized_name":"développé épaules convergent","previous_display_names":["Converging Shoulder Press"]},
{"exercise_id":"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b","display_name":"Abduction de hanche assise","normalized_name":"abduction de hanche assise","previous_display_names":["Hip abduction"]},
{"exercise_id":"ex_9adf7566-f10c-443c-b63b-681d37665693","display_name":"Crunch à la machine","normalized_name":"crunch à la machine","previous_display_names":["Abdominal crunch"]},
{"exercise_id":"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163","display_name":"Flexion de genou assise","normalized_name":"flexion de genou assise","previous_display_names":["Seated leg curl"]},
{"exercise_id":"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1","display_name":"Tirage vertical à la poulie","normalized_name":"tirage vertical à la poulie","previous_display_names":["Lat pull"]},
{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","display_name":"Presse à cuisses","normalized_name":"presse à cuisses","previous_display_names":["Leg press"]},
{"exercise_id":"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6","display_name":"Tirage vertical divergent","normalized_name":"tirage vertical divergent","previous_display_names":["Diverging lat pulldown"]},
{"exercise_id":"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5","display_name":"Flexion de genou couchée","normalized_name":"flexion de genou couchée","previous_display_names":["Prone leg curl"]},
{"exercise_id":"ex_ec619fc2-4685-4044-873c-86764bd4a0fe","display_name":"Flexion de coude à la machine","normalized_name":"flexion de coude à la machine","previous_display_names":["Arm curl"]}
]
}

View file

@ -637,3 +637,7 @@ The tranche is `TRAINING_KNOWLEDGE_V1=PASS`. The Android loader enforces canonic
and equipment identity syntax, bidirectional exercise/capability compatibility, and equipment identity syntax, bidirectional exercise/capability compatibility,
HIGH evidence source type, and non-unresolved BODY ZONE audit evidence. Its HIGH evidence source type, and non-unresolved BODY ZONE audit evidence. Its
full source and uncertainty contract is in [Training knowledge system V1](domain/knowledge_system.md). full source and uncertainty contract is in [Training knowledge system V1](domain/knowledge_system.md).
The normal shell exposes manual sessions but not SESSION_GENERATOR_V1 pending
V2. When importing the PC catalog, Android applies the versioned canonical
exercise-name mapping for mapped IDs; unmapped user exercises remain editable.

View file

@ -567,3 +567,8 @@ fails explicitly. The initial full-tranche audit's stale temporal-documentation,
Android loader, Meson input, and C role-only query findings were resolved by one Android loader, Meson input, and C role-only query findings were resolved by one
bounded repair chain and independently verified. Its full contract, uncertainty boundary and bounded repair chain and independently verified. Its full contract, uncertainty boundary and
future-only planning architecture are in [Training knowledge system V1](domain/knowledge_system.md). future-only planning architecture are in [Training knowledge system V1](domain/knowledge_system.md).
Canonical mapped exercise names are a separate, small versioned presentation
catalog. Stable `exercise_id` owns both display and normalized metadata for
mapped IDs during PC/Android reconciliation; it does not alter observations,
aliases, BODY ZONES, or equipment context.

View file

@ -487,3 +487,11 @@ needed for the optional body-fat estimate.
No estimated body-fat, fat-mass, lean-mass, ratio, or asymmetry value is stored No estimated body-fat, fat-mass, lean-mass, ratio, or asymmetry value is stored
as if it were a real measurement. as if it were a real measurement.
## Shell and naming closeout
`SESSION_GENERATOR_V1` is technically retained but hidden from normal UI pending
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`.

View file

@ -618,3 +618,7 @@ The temporal contract has independent PASS evidence. The initial audit's stale
temporal documentation, Android loader, Meson input, and C role-only query temporal documentation, Android loader, Meson input, and C role-only query
findings were repaired, independently verified, and final-validated; findings were repaired, independently verified, and final-validated;
`TRAINING_KNOWLEDGE_V1=PASS`. `TRAINING_KNOWLEDGE_V1=PASS`.
Exercise naming changes update only `exercises.name` and `normalized_name` for
explicitly mapped stable IDs; no schema migration or historical-row rewrite is
required.

View file

@ -352,3 +352,7 @@ the same comparison; malformed cursors or matching stored timestamps report
explicit errors. The initial audit's stale temporal documentation, Android explicit errors. The initial audit's stale temporal documentation, Android
loader, Meson input, and C role-only query findings were repaired and loader, Meson input, and C role-only query findings were repaired and
independently verified; final validation passed. independently verified; final validation passed.
Exercise names are mutable display metadata. The versioned canonical-name map
applies only to listed stable IDs and never makes two distinct exercises,
equipment contexts, MAX observations, or historical occurrences equivalent.

View file

@ -0,0 +1,37 @@
# EXERCISE_NAMING_V1 audit
The mapping in `catalog/exercise-names-v1.json` is a presentation-metadata
mapping keyed solely by stable `exercise_id`. It does not create IDs, merge
rows, or rewrite session observations. Its evidence is the resolved movement
interpretation in `catalog/exercise-knowledge-v1.json`.
| id | current | proposed | status / confidence | action |
| --- | --- | --- | --- | --- |
| ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef | Hip adduction | Adduction de hanche assise | resolved/high | SAFE_RENAME |
| ex_1872246a-39ae-44dc-b58d-f87e90ca49ab | Leg extension | Extension de genou assise | resolved/high | SAFE_RENAME |
| ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0 | Rotary torso | Rotation du tronc à la machine | resolved/moderate | SAFE_RENAME |
| ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de | Diverged seated row | Tirage horizontal divergent assis | resolved/moderate | SAFE_RENAME |
| ex_33f79331-871c-4eed-babe-346e53a99070 | Seated row | Tirage horizontal assis | resolved/moderate | SAFE_RENAME |
| ex_474ec393-3efa-4aaa-8e08-1a0245ed7835 | Back extension | Extension du tronc | resolved/moderate | SAFE_RENAME |
| ex_4cd2433e-80b1-478a-b8df-73fc6ef80962 | Rear Delt | Écarté inversé à la machine | resolved/moderate | SAFE_RENAME |
| ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b | Converging Shoulder Press | Développé épaules convergent | resolved/moderate | SAFE_RENAME |
| ex_7e7cf906-2214-4066-bcb7-c16382d83b3b | Hip abduction | Abduction de hanche assise | resolved/moderate | SAFE_RENAME |
| ex_9adf7566-f10c-443c-b63b-681d37665693 | Abdominal crunch | Crunch à la machine | resolved/moderate | SAFE_RENAME |
| ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163 | Seated leg curl | Flexion de genou assise | resolved/high | SAFE_RENAME |
| ex_a72fa713-4b0e-431d-95e2-42d95beb77b1 | Lat pull | Tirage vertical à la poulie | resolved/moderate | SAFE_RENAME |
| ex_b432623f-bfe9-4daf-a653-60ec7fdffbde | Leg press | Presse à cuisses | resolved/moderate | SAFE_RENAME |
| ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6 | Diverging lat pulldown | Tirage vertical divergent | resolved/moderate | SAFE_RENAME |
| ex_d7398d9f-d928-4d2e-94e9-74e201da55c5 | Prone leg curl | Flexion de genou couchée | resolved/high | SAFE_RENAME |
| ex_ec619fc2-4685-4044-873c-86764bd4a0fe | Arm curl | Flexion de coude à la machine | resolved/high | SAFE_RENAME |
`Abdominal`, `Chest press`, and `Converting chest press` are conditional with
uncertain identity and are intentionally unchanged. `Gym échauffement`,
`Marche`, `Planche`, and custom floor variants are human-readable or have no
resolved catalog mapping and remain unchanged. The user explicitly confirmed
that `Seated Leg` (`ex_617007f9-7420-4408-91b9-8ffb77900f13`) and `Seated leg
curl` (`ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163`) are distinct exercises on
different physical machines. They are **NOT_DUPLICATE**: no merge, shared MAX,
shared performance context, or history collapse is permitted. `Seated Leg`
therefore remains unchanged until its exact identity has independent evidence;
only the independently named `Seated leg curl` receives the resolved seated
knee-flexion label.

View file

@ -0,0 +1,30 @@
# 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`,
`performed_sets`, `continuous_activity`, `max_results`, `exercise_body_zones`,
and `body_observations`. Drafts and planned targets are never frequency or
volume facts.
Proposed 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
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.
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
the existing primary/secondary hierarchy without double-counting parent and
child zones. Movement patterns are read-only knowledge projections; unresolved
exercises remain unclassified.
Android starts with overview, frequency, progress, exercise, zones,
mensurations and MAX cards; TUI uses the same query contract with tables and
small text sparklines. Validate empty windows, DST/date boundaries, duplicate
aliases, heterogeneous sets, continuous-only records, NULL equipment, planned
but unperformed targets, and fixture parity. Caches are future-only and require
an explicit schema tranche.

View file

@ -436,3 +436,14 @@ assistance interpreted as external load
body-composition estimates presented as direct measurements body-composition estimates presented as direct measurements
incompatible changes to Trainlog JSON v1 without a new format version incompatible changes to Trainlog JSON v1 without a new format version
``` ```
## Session generator V2
V1 remains technically implemented but hidden from normal UI. V2 must construct
the requested duration with warm-up, appropriate ramp-up, coherent main work,
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.

View file

@ -651,3 +651,7 @@ The historical V1 fallback never consumes a V2 equipment companion: V1 carries
no occurrence-level equipment signal, so a neighboring V2 companion belongs to no occurrence-level equipment signal, so a neighboring V2 companion belongs to
a different generation and is ignored. This preserves rather than clears a different generation and is ignored. This preserves rather than clears
existing equipment. existing equipment.
Mapped canonical exercise names are metadata owned by `exercise_id`: PC exports
them and both import paths prevent stale mapped peer labels from restoring an
old display name. This does not change TRAINLOG_FORMAT_V1.

View file

@ -638,3 +638,7 @@ verified. See the [temporal contract](reviews/training_knowledge_v1_temporal_con
for the established contract. No real Android install, for the established contract. No real Android install,
manual TUI visual exercise, or manual MTP hardware validation was performed manual TUI visual exercise, or manual MTP hardware validation was performed
for Training Knowledge V1. for Training Knowledge V1.
Shell/naming closeout coverage includes real-PTY shell behavior, canonical
name export/import replay on temporary databases, and preservation of sets,
MAX, equipment, BODY ZONES, aliases, and distinct unmapped exercises.

View file

@ -547,3 +547,7 @@ exclusive source-text cursors, bounded selection before hydration, and one
read snapshot per call. Malformed cursors and matching stored timestamps fail read snapshot per call. Malformed cursors and matching stored timestamps fail
explicitly. The full-tranche audit repair chain and independent verification explicitly. The full-tranche audit repair chain and independent verification
passed. See [Training knowledge system V1](domain/knowledge_system.md). passed. See [Training knowledge system V1](domain/knowledge_system.md).
The normal Sessions hub has current/manual/completed paths only; the retained
SESSION_GENERATOR_V1 route is not advertised. Physical `/` and F7 Rechercher
dispatch the one registered list-search action on supported lists.

View file

@ -23,6 +23,8 @@ DESKTOP_WALK_ID = "ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d"
ANDROID_WALK_ID = "ex_23212d79-52ce-4195-914d-dd983f133936" ANDROID_WALK_ID = "ex_23212d79-52ce-4195-914d-dd983f133936"
ANDROID_SESSION_ID = "se_ac3908d6-8e3e-4ac6-81a6-62dc2c39075a" ANDROID_SESSION_ID = "se_ac3908d6-8e3e-4ac6-81a6-62dc2c39075a"
ANDROID_WALK_ENTRY_ID = "sxe_f25142c8-455e-4346-9bfc-31d0989e275d" ANDROID_WALK_ENTRY_ID = "sxe_f25142c8-455e-4346-9bfc-31d0989e275d"
SEATED_CURL_ID = "ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163"
SEATED_LEG_DISTINCT_ID = "ex_617007f9-7420-4408-91b9-8ffb77900f13"
SCHEMA = """ SCHEMA = """
PRAGMA foreign_keys=ON; PRAGMA foreign_keys=ON;
@ -485,6 +487,96 @@ def main():
assert before == after assert before == after
assert_no_integrity_error(connection) assert_no_integrity_error(connection)
# Canonical-name ownership is ID-scoped. A stale Android catalogue
# cannot rename the mapped curl back, while the distinct ex_617...
# identity and an unmapped custom exercise keep their own semantics.
names_db = root / "canonical-names.db"
create_database(names_db, fields=1)
with sqlite3.connect(names_db) as connection:
connection.execute("DELETE FROM exercises")
for exercise_id, name in (
(SEATED_CURL_ID, "Seated leg curl"),
(SEATED_LEG_DISTINCT_ID, "Seated Leg"),
("ex_fcc75fa7-671e-4868-bab2-47033128dfb7", "Custom curl"),
):
connection.execute(
"INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields) "
"VALUES(?,?,lower(?),'reps','sets',0)",
(exercise_id, name, name),
)
exercise_row = connection.execute(
"SELECT id FROM exercises WHERE exercise_id=?", (SEATED_CURL_ID,)
).fetchone()[0]
session_row = connection.execute(
"INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?)",
("se_c6daf1c6-b98b-4f18-8583-72e8c7cc3c99", "2026-09-10T08:00:00+02:00", "max_test"),
).lastrowid
set_entry = connection.execute(
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) "
"VALUES(?,?,?,'sets',0,0,'none',0,'seated_leg_curl')",
("sxe_names_set", session_row, exercise_row),
).lastrowid
connection.execute(
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) VALUES(?,0,11,42.5)",
(set_entry,),
)
max_entry = connection.execute(
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) "
"VALUES(?,?,?,'sets',0,1,'none',0,'seated_leg_curl')",
("sxe_names_max", session_row, exercise_row),
).lastrowid
connection.execute(
"INSERT INTO max_results(session_exercise_row_id,max_weight_kg) VALUES(?,?)",
(max_entry, 73.0),
)
connection.execute(
"CREATE TABLE exercise_body_zones(exercise_row_id INTEGER,zone_id TEXT,role TEXT)"
)
connection.execute(
"INSERT INTO exercise_body_zones VALUES(?,'thighs','primary')", (exercise_row,)
)
connection.commit()
stale = {
"format": "trainlog-mobile-export", "version": 2,
"generated_at": "2026-09-10T09:00:00+02:00",
"exercises": [
{"exercise_id": SEATED_CURL_ID, "name": "Seated leg curl", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0},
{"exercise_id": SEATED_LEG_DISTINCT_ID, "name": "Seated Leg", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0},
{"exercise_id": "ex_fcc75fa7-671e-4868-bab2-47033128dfb7", "name": "Custom curl", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0},
],
"sessions": [], "body_observations": [],
}
stale_path = root / "stale-names.json"
stale_path.write_text(json.dumps(stale), encoding="utf-8")
run(IMPORT_MOBILE, stale_path, "--database", names_db)
run(IMPORT_MOBILE, stale_path, "--database", names_db)
names_catalog = root / "names-pc-catalog.json"
names_mobile = root / "names-pc-mobile.json"
run(EXPORT_CATALOG, names_catalog, "--database", names_db)
run(EXPORT_MOBILE, names_mobile, "--database", names_db, "--version", "2")
with sqlite3.connect(names_db) as connection:
assert connection.execute(
"SELECT name FROM exercises WHERE exercise_id=?", (SEATED_CURL_ID,)
).fetchone()[0] == "Flexion de genou assise"
assert connection.execute(
"SELECT name FROM exercises WHERE exercise_id=?", (SEATED_LEG_DISTINCT_ID,)
).fetchone()[0] == "Seated Leg"
assert connection.execute(
"SELECT name FROM exercises WHERE exercise_id='ex_fcc75fa7-671e-4868-bab2-47033128dfb7'"
).fetchone()[0] == "Custom curl"
assert connection.execute("SELECT reps,weight_kg FROM performed_sets").fetchall() == [(11, 42.5)]
assert connection.execute("SELECT max_weight_kg FROM max_results").fetchall() == [(73.0,)]
assert connection.execute("SELECT equipment_id FROM session_exercises ORDER BY position").fetchall() == [("seated_leg_curl",), ("seated_leg_curl",)]
assert connection.execute("SELECT zone_id,role FROM exercise_body_zones").fetchall() == [("thighs", "primary")]
for artifact in (names_catalog, names_mobile):
exercises = json.loads(artifact.read_text(encoding="utf-8"))["exercises"]
by_id = {item["exercise_id"]: item["name"] for item in exercises}
assert by_id[SEATED_CURL_ID] == "Flexion de genou assise"
assert by_id[SEATED_LEG_DISTINCT_ID] == "Seated Leg"
assert by_id["ex_fcc75fa7-671e-4868-bab2-47033128dfb7"] == "Custom curl"
print("PASS exercise_reconciliation") print("PASS exercise_reconciliation")

83
tools/exercise_names.py Normal file
View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Strict reader for the shared canonical exercise-name ownership map."""
import json
import unicodedata
import uuid
from pathlib import Path
CATALOG_PATH = (
Path(__file__).resolve().parents[1] / "catalog" / "exercise-names-v1.json"
)
class ExerciseNameCatalogError(ValueError):
pass
def normalize_catalog_name(value):
if not isinstance(value, str):
raise ExerciseNameCatalogError("nom canonique non textuel")
normalized = " ".join(unicodedata.normalize("NFC", value.casefold()).split())
if not normalized:
raise ExerciseNameCatalogError("nom canonique vide")
return normalized
def load_exercise_names(path=CATALOG_PATH):
try:
root = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ExerciseNameCatalogError(
f"catalogue de noms d'exercices illisible: {error}"
) from error
if not isinstance(root, dict) or set(root) != {"format", "version", "names"}:
raise ExerciseNameCatalogError("forme racine exercise-names-v1 invalide")
if root["format"] != "trainlog-exercise-names-v1" or root["version"] != 1:
raise ExerciseNameCatalogError("format/version exercise-names-v1 invalide")
if not isinstance(root["names"], list):
raise ExerciseNameCatalogError("liste names absente")
result = {}
normalized_owners = set()
previous_names = set()
for index, item in enumerate(root["names"]):
label = f"names[{index}]"
if not isinstance(item, dict) or set(item) != {
"exercise_id", "display_name", "normalized_name", "previous_display_names"
}:
raise ExerciseNameCatalogError(f"{label}: forme invalide")
exercise_id = item["exercise_id"]
if not isinstance(exercise_id, str) or not exercise_id.startswith("ex_"):
raise ExerciseNameCatalogError(f"{label}: exercise_id invalide")
try:
parsed = uuid.UUID(exercise_id[3:])
except (ValueError, AttributeError) as error:
raise ExerciseNameCatalogError(f"{label}: exercise_id invalide") from error
if parsed.version != 4 or str(parsed) != exercise_id[3:]:
raise ExerciseNameCatalogError(f"{label}: exercise_id UUIDv4 invalide")
display_name = item["display_name"]
normalized_name = normalize_catalog_name(display_name)
if display_name != display_name.strip() or item["normalized_name"] != normalized_name:
raise ExerciseNameCatalogError(f"{label}: nom normalisé incohérent")
if exercise_id in result or normalized_name in normalized_owners:
raise ExerciseNameCatalogError(f"{label}: identité canonique dupliquée")
previous = item["previous_display_names"]
if not isinstance(previous, list) or not previous:
raise ExerciseNameCatalogError(f"{label}: previous_display_names invalide")
local_previous = set()
for old_name in previous:
old_normalized = normalize_catalog_name(old_name)
if old_name != old_name.strip() or old_normalized == normalized_name:
raise ExerciseNameCatalogError(f"{label}: ancien nom invalide")
if old_normalized in local_previous or old_normalized in previous_names:
raise ExerciseNameCatalogError(f"{label}: ancien nom dupliqué")
local_previous.add(old_normalized)
result[exercise_id] = display_name
normalized_owners.add(normalized_name)
previous_names.update(local_previous)
if normalized_owners & previous_names:
raise ExerciseNameCatalogError("un ancien nom appartient déjà à un nom canonique")
return result

View file

@ -6,6 +6,8 @@ import sqlite3
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from exercise_names import load_exercise_names
def default_database_path(): def default_database_path():
data_home = os.environ.get("XDG_DATA_HOME") data_home = os.environ.get("XDG_DATA_HOME")
@ -57,6 +59,7 @@ def main():
) )
try: try:
canonical_names = load_exercise_names()
version = connection.execute( version = connection.execute(
"PRAGMA user_version;" "PRAGMA user_version;"
).fetchone()[0] ).fetchone()[0]
@ -95,7 +98,7 @@ def main():
"exercises": [ "exercises": [
{ {
"exercise_id": row[0], "exercise_id": row[0],
"name": row[1], "name": canonical_names.get(row[0], row[1]),
"recording_mode": row[2], "recording_mode": row[2],
"tracking_mode": row[3], "tracking_mode": row[3],
"data_fields": row[4], "data_fields": row[4],

View file

@ -9,6 +9,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from validate_json import TrainlogSemanticError, parse_timestamp from validate_json import TrainlogSemanticError, parse_timestamp
from exercise_names import load_exercise_names
CATALOG_PATH = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json" CATALOG_PATH = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json"
@ -70,13 +71,16 @@ def main():
if schema_version not in (11, 12) and not (args.version == 2 and schema_version == 10): if schema_version not in (11, 12) and not (args.version == 2 and schema_version == 10):
raise ValueError("schema desktop v11/v12 requis (v10 accepté pour export V2 explicite)") raise ValueError("schema desktop v11/v12 requis (v10 accepté pour export V2 explicite)")
known_equipment = supplied_equipment_ids() known_equipment = supplied_equipment_ids()
canonical_names = load_exercise_names()
known_equipment.update(row[0] for row in con.execute( known_equipment.update(row[0] for row in con.execute(
"SELECT equipment_id FROM custom_equipment")) "SELECT equipment_id FROM custom_equipment"))
root = {"format": "trainlog-mobile-export", "version": args.version, root = {"format": "trainlog-mobile-export", "version": args.version,
"generated_at": datetime.now().astimezone().isoformat(), "generated_at": datetime.now().astimezone().isoformat(),
"exercises": [], "sessions": [], "body_observations": []} "exercises": [], "sessions": [], "body_observations": []}
for row in con.execute("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises ORDER BY exercise_id"): for row in con.execute("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises ORDER BY exercise_id"):
root["exercises"].append(dict(row)) exported = dict(row)
exported["name"] = canonical_names.get(row["exercise_id"], row["name"])
root["exercises"].append(exported)
for session in con.execute("SELECT id,session_id,started_at,session_type FROM sessions ORDER BY started_at,id"): for session in con.execute("SELECT id,session_id,started_at,session_type FROM sessions ORDER BY started_at,id"):
# CONTRACT: a V3 producer must not publish history which the exact # CONTRACT: a V3 producer must not publish history which the exact
# temporal readers reject. V2 export retains its published behavior. # temporal readers reject. V2 export retains its published behavior.
@ -108,7 +112,8 @@ def main():
f"session_id={session['session_id']} entry_id={entry['entry_id']}" f"session_id={session['session_id']} entry_id={entry['entry_id']}"
) )
item = {"entry_id": entry["entry_id"], "position": entry["position"], item = {"entry_id": entry["entry_id"], "position": entry["position"],
"exercise_id": entry["exercise_id"], "name": entry["name"], "exercise_id": entry["exercise_id"],
"name": canonical_names.get(entry["exercise_id"], entry["name"]),
"recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"], "recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"],
"data_fields": entry["data_fields"], "data_fields": entry["data_fields"],
"load_mode": entry["load_mode"] if args.version == 3 else "none", "load_mode": entry["load_mode"] if args.version == 3 else "none",

View file

@ -9,6 +9,7 @@ import unicodedata
from pathlib import Path from pathlib import Path
from validate_json import TrainlogSemanticError, parse_timestamp from validate_json import TrainlogSemanticError, parse_timestamp
from exercise_names import ExerciseNameCatalogError, load_exercise_names
FORMAT = "trainlog-mobile-export" FORMAT = "trainlog-mobile-export"
@ -1005,8 +1006,16 @@ def import_exercises(
trace_exercises=False, trace_exercises=False,
): ):
mapping = {} mapping = {}
try:
canonical_names = load_exercise_names()
except ExerciseNameCatalogError as error:
raise ImportFailure(str(error)) from error
for exercise in payload["exercises"]: for supplied_exercise in payload["exercises"]:
exercise = dict(supplied_exercise)
exercise["name"] = canonical_names.get(
exercise["exercise_id"], exercise["name"]
)
exercise_id = exercise["exercise_id"] exercise_id = exercise["exercise_id"]
normalized = normalize_name( normalized = normalize_name(
exercise["name"] exercise["name"]

View file

@ -441,6 +441,7 @@ def validate_document(
return [str(exc)] return [str(exc)]
is_body_zones = isinstance(document, dict) and document.get("format") == "trainlog-body-zone-catalog" is_body_zones = isinstance(document, dict) and document.get("format") == "trainlog-body-zone-catalog"
is_exercise_names = isinstance(document, dict) and document.get("format") == "trainlog-exercise-names-v1"
is_mobile_v2 = isinstance(document, dict) and document.get("format") == "trainlog-mobile-export" and document.get("version") == 2 is_mobile_v2 = isinstance(document, dict) and document.get("format") == "trainlog-mobile-export" and document.get("version") == 2
if is_body_zones: if is_body_zones:
try: try:
@ -448,6 +449,13 @@ def validate_document(
except TrainlogSemanticError as exc: except TrainlogSemanticError as exc:
return [str(exc)] return [str(exc)]
return [] return []
if is_exercise_names:
try:
from exercise_names import load_exercise_names
load_exercise_names(path)
except ValueError as exc:
return [str(exc)]
return []
if not is_mobile_v2: if not is_mobile_v2:
errors = structural_errors(validator, document) errors = structural_errors(validator, document)
if errors: if errors:
@ -486,6 +494,17 @@ def run_suite(validator: jsonschema.Draft202012Validator) -> int:
else: else:
print(f"PASS body-zone catalog: {BODY_ZONE_CATALOG_PATH}") print(f"PASS body-zone catalog: {BODY_ZONE_CATALOG_PATH}")
try:
from exercise_names import load_exercise_names
exercise_names = load_exercise_names()
print(
"PASS exercise-name catalog: "
f"{ROOT / 'catalog' / 'exercise-names-v1.json'} ({len(exercise_names)} names)"
)
except ValueError as error:
print(f"FAIL exercise-name catalog: {error}")
failed = True
if not valid: if not valid:
print("FAIL test suite: no valid fixtures found") print("FAIL test suite: no valid fixtures found")
return 1 return 1

View file

@ -72,10 +72,14 @@ void trainlog_terminal_cursor_visible(TrainlogTerminal *terminal, bool visible);
void trainlog_terminal_draw(TrainlogTerminal *terminal, int row, int column, uint32_t codepoint); void trainlog_terminal_draw(TrainlogTerminal *terminal, int row, int column, uint32_t codepoint);
void trainlog_terminal_box(TrainlogTerminal *terminal, int top, int left, void trainlog_terminal_box(TrainlogTerminal *terminal, int top, int left,
int bottom, int right); int bottom, int right);
bool trainlog_terminal_translate_input(uint32_t id, /* WHY: Notcurses can report a physical key in id while eff_text holds its
* layout-resolved Unicode character. CONTRACT: Shift may produce a printable
* action character (for example French AZERTY '/'); Ctrl/Alt/Super never
* collapse into an unmodified action. */
bool trainlog_terminal_translate_input(uint32_t id, uint32_t effective_text,
TrainlogInputEventType event_type, TrainlogInputEventType event_type,
bool shifted, bool shifted, bool control, bool alt,
int *key); bool super, int *key);
int trainlog_terminal_get_key(TrainlogTerminal *terminal); int trainlog_terminal_get_key(TrainlogTerminal *terminal);
bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint, bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint,
char utf8[5]); char utf8[5]);

View file

@ -299,10 +299,10 @@ static int terminal_key(uint32_t id, bool shifted)
} }
} }
bool trainlog_terminal_translate_input(uint32_t id, bool trainlog_terminal_translate_input(uint32_t id, uint32_t effective_text,
TrainlogInputEventType event_type, TrainlogInputEventType event_type,
bool shifted, bool shifted, bool control, bool alt,
int *key) bool super, int *key)
{ {
if (key == NULL || id == 0U || id == UINT32_MAX) { if (key == NULL || id == 0U || id == UINT32_MAX) {
return false; return false;
@ -312,7 +312,14 @@ bool trainlog_terminal_translate_input(uint32_t id,
case TRAINLOG_INPUT_UNKNOWN: case TRAINLOG_INPUT_UNKNOWN:
case TRAINLOG_INPUT_PRESS: case TRAINLOG_INPUT_PRESS:
case TRAINLOG_INPUT_REPEAT: case TRAINLOG_INPUT_REPEAT:
*key = terminal_key(id, shifted); /* INVARIANT: only Shift is a text-producing modifier. The effective
* character is layout-independent; do not turn Ctrl/Alt/Super chords
* into ordinary shortcuts. */
if (effective_text >= 0x20U && effective_text <= 0x10ffffU) {
if (control || alt || super) return false;
*key = (int)effective_text;
} else
*key = terminal_key(id, shifted);
return true; return true;
case TRAINLOG_INPUT_RELEASE: case TRAINLOG_INPUT_RELEASE:
default: default:
@ -347,10 +354,12 @@ static bool terminal_read_input(TrainlogTerminal *terminal,
if (id == 0U || id == UINT32_MAX) { if (id == 0U || id == UINT32_MAX) {
return false; return false;
} }
if (!trainlog_terminal_translate_input(id, if (!trainlog_terminal_translate_input(id, input.eff_text[0],
terminal_event_type(input.evtype), terminal_event_type(input.evtype),
ncinput_shift_p(&input), ncinput_shift_p(&input),
key)) { ncinput_ctrl_p(&input),
ncinput_alt_p(&input),
ncinput_super_p(&input), key)) {
continue; continue;
} }
if (utf8 != NULL) { if (utf8 != NULL) {

View file

@ -4235,9 +4235,6 @@ static void app_shell_actions(TrainlogAppContext *app)
if (route == TRAINLOG_ROUTE_HOME) if (route == TRAINLOG_ROUTE_HOME)
app_shell_add_action(app, "session", '1', "1 Séance", 1U, app_shell_add_action(app, "session", '1', "1 Séance", 1U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_MANUAL); TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_MANUAL);
if (route == TRAINLOG_ROUTE_SESSIONS)
app_shell_add_action(app, "generate", 'g', "g Programmer", 1U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_GENERATOR);
if (route == TRAINLOG_ROUTE_BODY || if (route == TRAINLOG_ROUTE_BODY ||
route == TRAINLOG_ROUTE_SYNC || route == TRAINLOG_ROUTE_SETTINGS) route == TRAINLOG_ROUTE_SYNC || route == TRAINLOG_ROUTE_SETTINGS)
app_shell_add_action(app, "open", TRAINLOG_KEY_ENTER, "Entrée Ouvrir", 1U, app_shell_add_action(app, "open", TRAINLOG_KEY_ENTER, "Entrée Ouvrir", 1U,
@ -5488,13 +5485,12 @@ static void app_shell_render_content(TrainlogAppContext *app)
} else if (route == TRAINLOG_ROUTE_HOME) { } else if (route == TRAINLOG_ROUTE_HOME) {
trainlog_surface_printf(app->content, 4, 2, "Votre entraînement, au même endroit."); trainlog_surface_printf(app->content, 4, 2, "Votre entraînement, au même endroit.");
trainlog_surface_printf(app->content, 7, 2, "1 Reprendre ou commencer une séance"); trainlog_surface_printf(app->content, 7, 2, "1 Reprendre ou commencer une séance");
trainlog_surface_printf(app->content, 9, 2, "g Programmer une séance"); trainlog_surface_printf(app->content, 9, 2, "5 Ajouter ou consulter des mensurations");
trainlog_surface_printf(app->content, 11, 2, "5 Ajouter ou consulter des mensurations");
} else if (route == TRAINLOG_ROUTE_SESSIONS) { } else if (route == TRAINLOG_ROUTE_SESSIONS) {
static const char *const rows[] = {"Séance en cours", "Programmer une séance", static const char *const rows[] = {"Séance en cours", "Nouvelle séance manuelle",
"Nouvelle séance manuelle", "Séances effectuées"}; "Séances effectuées"};
size_t index; size_t index;
for (index = 0U; index < 4U; ++index) { for (index = 0U; index < 3U; ++index) {
trainlog_surface_set_role(app->content, trainlog_surface_set_role(app->content,
app->content_selected == index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT, app->content_selected == index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_RGB_BASE,
@ -6214,9 +6210,8 @@ static void app_shell_primary(TrainlogAppContext *app)
TrainlogAppRoute route = app->navigation.current.route; TrainlogAppRoute route = app->navigation.current.route;
if (route == TRAINLOG_ROUTE_SESSIONS) { if (route == TRAINLOG_ROUTE_SESSIONS) {
static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_SESSION_CURRENT, static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_SESSION_CURRENT,
TRAINLOG_ROUTE_SESSION_GENERATOR, TRAINLOG_ROUTE_SESSION_MANUAL, TRAINLOG_ROUTE_SESSION_MANUAL, TRAINLOG_ROUTE_SESSIONS_COMPLETED};
TRAINLOG_ROUTE_SESSIONS_COMPLETED}; app_shell_open_route(app, targets[app->content_selected % 3U]);
app_shell_open_route(app, targets[app->content_selected % 4U]);
} else if (route == TRAINLOG_ROUTE_STATS) { } else if (route == TRAINLOG_ROUTE_STATS) {
static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_STATS_EXERCISE, static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_STATS_EXERCISE,
TRAINLOG_ROUTE_BODY, TRAINLOG_ROUTE_MAX}; TRAINLOG_ROUTE_BODY, TRAINLOG_ROUTE_MAX};
@ -7379,6 +7374,17 @@ static void app_shell_dispatch(TrainlogAppContext *app, int key)
/* INVARIANT: the rendered Actions control owns its input. */ /* INVARIANT: the rendered Actions control owns its input. */
return; return;
} }
/* WHY: route controllers may legitimately own printable keys. Search is a
* shell action, so resolve its registered '/' before those controllers.
* CONTRACT: physical '/' and F7 select the same list.search action. */
action = trainlog_actions_find_key(&app->actions, key);
if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_SEARCH &&
app_shell_is_list_route(app->navigation.current.route)) {
app->search.open = true;
app->search.focused = true;
app->focus = TRAINLOG_FOCUS_SEARCH;
return;
}
if (app_shell_dispatch_exercise_controller(app, key)) return; if (app_shell_dispatch_exercise_controller(app, key)) return;
if (app_shell_dispatch_equipment_controller(app, key)) return; if (app_shell_dispatch_equipment_controller(app, key)) return;
if (app_shell_dispatch_body_controller(app, key)) return; if (app_shell_dispatch_body_controller(app, key)) return;
@ -7388,7 +7394,7 @@ static void app_shell_dispatch(TrainlogAppContext *app, int key)
if ((app->navigation.current.route == TRAINLOG_ROUTE_SESSIONS || if ((app->navigation.current.route == TRAINLOG_ROUTE_SESSIONS ||
app->navigation.current.route == TRAINLOG_ROUTE_STATS) && app->navigation.current.route == TRAINLOG_ROUTE_STATS) &&
(key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) { (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) {
size_t count = app->navigation.current.route == TRAINLOG_ROUTE_SESSIONS ? 4U : 3U; size_t count = 3U;
if (key == TRAINLOG_KEY_UP && app->content_selected > 0U) --app->content_selected; if (key == TRAINLOG_KEY_UP && app->content_selected > 0U) --app->content_selected;
if (key == TRAINLOG_KEY_DOWN && app->content_selected + 1U < count) ++app->content_selected; if (key == TRAINLOG_KEY_DOWN && app->content_selected + 1U < count) ++app->content_selected;
return; return;
@ -7641,20 +7647,7 @@ shell_nonanalytics_input:
else if (key == TRAINLOG_KEY_F3) key = '3'; else if (key == TRAINLOG_KEY_F3) key = '3';
else if (key == TRAINLOG_KEY_F4) key = '4'; else if (key == TRAINLOG_KEY_F4) key = '4';
else if (key == TRAINLOG_KEY_F5) key = '5'; else if (key == TRAINLOG_KEY_F5) key = '5';
if ((key == 'g' || key == 'G') && app->navigation.current.route == TRAINLOG_ROUTE_HOME) {
app_shell_open_route(app, TRAINLOG_ROUTE_SESSION_GENERATOR); return;
}
action = trainlog_actions_find_key(&app->actions, key); action = trainlog_actions_find_key(&app->actions, key);
if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_SEARCH &&
app_shell_is_list_route(app->navigation.current.route)) {
/* CONTRACT: the physical '/' follows input adapter -> top-level action
* registry -> focus/search -> list filter. F7 invokes the same stable
* action and Escape retains the established clear-then-close behavior. */
app->search.open = true;
app->search.focused = true;
app->focus = TRAINLOG_FOCUS_SEARCH;
return;
}
if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_ROUTE) { if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_ROUTE) {
app_shell_open_route(app, action->route); return; app_shell_open_route(app, action->route); return;
} }

View file

@ -29,24 +29,24 @@ static bool test_event_type_policy(void)
int key = 12345; int key = 12345;
CHECK(trainlog_terminal_translate_input( CHECK(trainlog_terminal_translate_input(
NCKEY_RIGHT, TRAINLOG_INPUT_UNKNOWN, false, &key)); NCKEY_RIGHT, 0U, TRAINLOG_INPUT_UNKNOWN, false, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_RIGHT); CHECK(key == TRAINLOG_KEY_RIGHT);
CHECK(trainlog_terminal_translate_input( CHECK(trainlog_terminal_translate_input(
NCKEY_RIGHT, TRAINLOG_INPUT_PRESS, false, &key)); NCKEY_RIGHT, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_RIGHT); CHECK(key == TRAINLOG_KEY_RIGHT);
CHECK(trainlog_terminal_translate_input( CHECK(trainlog_terminal_translate_input(
NCKEY_RIGHT, TRAINLOG_INPUT_REPEAT, false, &key)); NCKEY_RIGHT, 0U, TRAINLOG_INPUT_REPEAT, false, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_RIGHT); CHECK(key == TRAINLOG_KEY_RIGHT);
key = 12345; key = 12345;
CHECK(!trainlog_terminal_translate_input( CHECK(!trainlog_terminal_translate_input(
NCKEY_RIGHT, TRAINLOG_INPUT_RELEASE, false, &key)); NCKEY_RIGHT, 0U, TRAINLOG_INPUT_RELEASE, false, false, false, false, &key));
CHECK(key == 12345); CHECK(key == 12345);
CHECK(!trainlog_terminal_translate_input( CHECK(!trainlog_terminal_translate_input(
NCKEY_RIGHT, (TrainlogInputEventType)99, false, &key)); NCKEY_RIGHT, 0U, (TrainlogInputEventType)99, false, false, false, false, &key));
CHECK(key == 12345); CHECK(key == 12345);
return true; return true;
@ -57,35 +57,48 @@ static bool test_key_translation(void)
int key = TRAINLOG_KEY_NONE; int key = TRAINLOG_KEY_NONE;
CHECK(trainlog_terminal_translate_input( CHECK(trainlog_terminal_translate_input(
NCKEY_TAB, TRAINLOG_INPUT_PRESS, false, &key)); NCKEY_TAB, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_TAB); CHECK(key == TRAINLOG_KEY_TAB);
CHECK(trainlog_terminal_translate_input( CHECK(trainlog_terminal_translate_input(
NCKEY_TAB, TRAINLOG_INPUT_PRESS, true, &key)); NCKEY_TAB, 0U, TRAINLOG_INPUT_PRESS, true, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_SHIFT_TAB); CHECK(key == TRAINLOG_KEY_SHIFT_TAB);
CHECK(trainlog_terminal_translate_input( CHECK(trainlog_terminal_translate_input(
NCKEY_F06, TRAINLOG_INPUT_PRESS, false, &key)); NCKEY_F06, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_F6); CHECK(key == TRAINLOG_KEY_F6);
CHECK(trainlog_terminal_translate_input( CHECK(trainlog_terminal_translate_input(
NCKEY_F07, TRAINLOG_INPUT_REPEAT, false, &key)); NCKEY_F07, 0U, TRAINLOG_INPUT_REPEAT, false, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_F7); CHECK(key == TRAINLOG_KEY_F7);
CHECK(trainlog_terminal_translate_input( CHECK(trainlog_terminal_translate_input(
27U, TRAINLOG_INPUT_PRESS, false, &key)); 27U, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_ESCAPE); CHECK(key == TRAINLOG_KEY_ESCAPE);
CHECK(trainlog_terminal_translate_input( CHECK(trainlog_terminal_translate_input(
0x00e9U, TRAINLOG_INPUT_REPEAT, false, &key)); 0x00e9U, 0x00e9U, TRAINLOG_INPUT_REPEAT, false, false, false, false, &key));
CHECK(key == 0x00e9); CHECK(key == 0x00e9);
CHECK(!trainlog_terminal_translate_input( CHECK(!trainlog_terminal_translate_input(
0U, TRAINLOG_INPUT_PRESS, false, &key)); 0U, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
CHECK(!trainlog_terminal_translate_input( CHECK(!trainlog_terminal_translate_input(
UINT32_MAX, TRAINLOG_INPUT_PRESS, false, &key)); UINT32_MAX, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
CHECK(!trainlog_terminal_translate_input( CHECK(!trainlog_terminal_translate_input(
NCKEY_RIGHT, TRAINLOG_INPUT_PRESS, false, NULL)); NCKEY_RIGHT, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, NULL));
/* French AZERTY commonly needs Shift to yield '/'. eff_text, rather than
* the physical id, is the action character and must reach list.search. */
CHECK(trainlog_terminal_translate_input(
':', '/', TRAINLOG_INPUT_PRESS, true, false, false, false, &key));
CHECK(key == '/');
CHECK(trainlog_terminal_translate_input(
'/', '/', TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
CHECK(key == '/');
CHECK(!trainlog_terminal_translate_input(
':', '/', TRAINLOG_INPUT_PRESS, true, true, false, false, &key));
CHECK(!trainlog_terminal_translate_input(
':', '/', TRAINLOG_INPUT_PRESS, true, false, true, false, &key));
return true; return true;
} }