feat(exercises): add canonical naming and finalize app shell
This commit is contained in:
parent
1f027743f2
commit
b2249b10b1
31 changed files with 543 additions and 65 deletions
|
|
@ -338,3 +338,7 @@ Added:
|
|||
|
||||
Estimated analytics remain derived display values and are never persisted as
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -346,3 +346,7 @@ BODY_PROPORTION_RATIOS=PASS
|
|||
BODY_SYMMETRY_ANALYTICS=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.
|
||||
|
|
|
|||
|
|
@ -98,7 +98,6 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
onBody = {},
|
||||
onOpenLatestSession = {},
|
||||
onSync = {},
|
||||
onGenerateSession = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -274,6 +274,7 @@ class TrainlogRepository(
|
|||
ANDROID_DATABASE_NAME,
|
||||
) {
|
||||
private val applicationContext = context.applicationContext
|
||||
private val canonicalExerciseNames = ExerciseNameCatalog.load(applicationContext)
|
||||
private val bodyZones = BodyZoneCatalog.load(applicationContext)
|
||||
private val trainingKnowledge = TrainingKnowledgeCatalog.load(applicationContext, bodyZones)
|
||||
private val sessionGenerationPolicy =
|
||||
|
|
@ -1534,10 +1535,8 @@ class TrainlogRepository(
|
|||
val exerciseId = resolveExerciseId(db, suppliedExerciseId)
|
||||
val suppliedRetiredAlias = suppliedExerciseId != exerciseId
|
||||
|
||||
val name =
|
||||
item.getString(
|
||||
"name"
|
||||
)
|
||||
val suppliedName = item.getString("name")
|
||||
val name = canonicalExerciseNames[exerciseId] ?: suppliedName
|
||||
|
||||
val normalized =
|
||||
normalizeName(
|
||||
|
|
@ -1600,6 +1599,12 @@ class TrainlogRepository(
|
|||
}
|
||||
|
||||
if (
|
||||
!NewExerciseProfile(
|
||||
name = suppliedName,
|
||||
recordingMode = recording,
|
||||
trackingMode = tracking,
|
||||
dataFields = dataFields,
|
||||
).validate() ||
|
||||
!NewExerciseProfile(
|
||||
name = name,
|
||||
recordingMode = recording,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ fun HomeScreen(
|
|||
latestSession: SessionSummary?,
|
||||
latestMaximum: LatestExerciseMax?,
|
||||
onSession: () -> Unit,
|
||||
onGenerateSession: () -> Unit,
|
||||
onBody: () -> Unit,
|
||||
onOpenLatestSession: (String) -> Unit,
|
||||
onSync: () -> Unit,
|
||||
|
|
@ -26,10 +25,8 @@ fun HomeScreen(
|
|||
if (activeDraft != null) {
|
||||
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)
|
||||
TrainlogAction("Programmer une séance", "La proposition sera conservée séparément.", onGenerateSession)
|
||||
} else {
|
||||
TrainlogPrimaryAction("Programmer une séance", "Préparer une proposition modifiable à partir d'une zone, d'un objectif et d'une durée.", onGenerateSession)
|
||||
TrainlogAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
|
||||
TrainlogPrimaryAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
|
||||
}
|
||||
}
|
||||
latestSession?.let { session ->
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
|||
fun SessionsHub(
|
||||
draft: ActiveSessionDraft?,
|
||||
onResume: () -> Unit,
|
||||
onGenerate: () -> Unit,
|
||||
onManual: () -> 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)
|
||||
}
|
||||
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("Séances effectuées", "Consulter les actuals, plans et MAX enregistrés.", onHistory)
|
||||
|
|
|
|||
|
|
@ -76,10 +76,10 @@ 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(),
|
||||
::openManualSession, { open(AppRoute.SessionGenerator) },
|
||||
::openManualSession,
|
||||
{ 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.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."
|
||||
|
|
|
|||
|
|
@ -590,6 +590,49 @@ class TrainlogRepositoryDraftTest {
|
|||
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
|
||||
fun marcheReconciliationKeepsRicherProfileAndTwoOccurrenceValues() {
|
||||
val repo = openRepository()
|
||||
|
|
|
|||
22
catalog/exercise-names-v1.json
Normal file
22
catalog/exercise-names-v1.json
Normal 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"]}
|
||||
]
|
||||
}
|
||||
|
|
@ -637,3 +637,7 @@ The tranche is `TRAINING_KNOWLEDGE_V1=PASS`. The Android loader enforces canonic
|
|||
and equipment identity syntax, bidirectional exercise/capability compatibility,
|
||||
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).
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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).
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
findings were repaired, independently verified, and final-validated;
|
||||
`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.
|
||||
|
|
|
|||
|
|
@ -352,3 +352,7 @@ the same comparison; malformed cursors or matching stored timestamps report
|
|||
explicit errors. The initial audit's stale temporal documentation, Android
|
||||
loader, Meson input, and C role-only query findings were repaired and
|
||||
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.
|
||||
|
|
|
|||
37
docs/reviews/exercise_naming_v1_audit.md
Normal file
37
docs/reviews/exercise_naming_v1_audit.md
Normal 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.
|
||||
30
docs/reviews/stats_v1_design.md
Normal file
30
docs/reviews/stats_v1_design.md
Normal 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.
|
||||
|
|
@ -436,3 +436,14 @@ assistance interpreted as external load
|
|||
body-composition estimates presented as direct measurements
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
a different generation and is ignored. This preserves rather than clears
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -638,3 +638,7 @@ verified. See the [temporal contract](reviews/training_knowledge_v1_temporal_con
|
|||
for the established contract. No real Android install,
|
||||
manual TUI visual exercise, or manual MTP hardware validation was performed
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
explicitly. The full-tranche audit repair chain and independent verification
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ DESKTOP_WALK_ID = "ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d"
|
|||
ANDROID_WALK_ID = "ex_23212d79-52ce-4195-914d-dd983f133936"
|
||||
ANDROID_SESSION_ID = "se_ac3908d6-8e3e-4ac6-81a6-62dc2c39075a"
|
||||
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 = """
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
|
@ -485,6 +487,96 @@ def main():
|
|||
assert before == after
|
||||
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")
|
||||
|
||||
|
||||
|
|
|
|||
83
tools/exercise_names.py
Normal file
83
tools/exercise_names.py
Normal 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
|
||||
|
|
@ -6,6 +6,8 @@ import sqlite3
|
|||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from exercise_names import load_exercise_names
|
||||
|
||||
|
||||
def default_database_path():
|
||||
data_home = os.environ.get("XDG_DATA_HOME")
|
||||
|
|
@ -57,6 +59,7 @@ def main():
|
|||
)
|
||||
|
||||
try:
|
||||
canonical_names = load_exercise_names()
|
||||
version = connection.execute(
|
||||
"PRAGMA user_version;"
|
||||
).fetchone()[0]
|
||||
|
|
@ -95,7 +98,7 @@ def main():
|
|||
"exercises": [
|
||||
{
|
||||
"exercise_id": row[0],
|
||||
"name": row[1],
|
||||
"name": canonical_names.get(row[0], row[1]),
|
||||
"recording_mode": row[2],
|
||||
"tracking_mode": row[3],
|
||||
"data_fields": row[4],
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from datetime import datetime
|
|||
from pathlib import Path
|
||||
|
||||
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"
|
||||
|
|
@ -70,13 +71,16 @@ def main():
|
|||
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)")
|
||||
known_equipment = supplied_equipment_ids()
|
||||
canonical_names = load_exercise_names()
|
||||
known_equipment.update(row[0] for row in con.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
root = {"format": "trainlog-mobile-export", "version": args.version,
|
||||
"generated_at": datetime.now().astimezone().isoformat(),
|
||||
"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"):
|
||||
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"):
|
||||
# CONTRACT: a V3 producer must not publish history which the exact
|
||||
# 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']}"
|
||||
)
|
||||
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"],
|
||||
"data_fields": entry["data_fields"],
|
||||
"load_mode": entry["load_mode"] if args.version == 3 else "none",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import unicodedata
|
|||
from pathlib import Path
|
||||
|
||||
from validate_json import TrainlogSemanticError, parse_timestamp
|
||||
from exercise_names import ExerciseNameCatalogError, load_exercise_names
|
||||
|
||||
|
||||
FORMAT = "trainlog-mobile-export"
|
||||
|
|
@ -1005,8 +1006,16 @@ def import_exercises(
|
|||
trace_exercises=False,
|
||||
):
|
||||
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"]
|
||||
normalized = normalize_name(
|
||||
exercise["name"]
|
||||
|
|
|
|||
|
|
@ -441,6 +441,7 @@ def validate_document(
|
|||
return [str(exc)]
|
||||
|
||||
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
|
||||
if is_body_zones:
|
||||
try:
|
||||
|
|
@ -448,6 +449,13 @@ def validate_document(
|
|||
except TrainlogSemanticError as exc:
|
||||
return [str(exc)]
|
||||
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:
|
||||
errors = structural_errors(validator, document)
|
||||
if errors:
|
||||
|
|
@ -486,6 +494,17 @@ def run_suite(validator: jsonschema.Draft202012Validator) -> int:
|
|||
else:
|
||||
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:
|
||||
print("FAIL test suite: no valid fixtures found")
|
||||
return 1
|
||||
|
|
|
|||
|
|
@ -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_box(TrainlogTerminal *terminal, int top, int left,
|
||||
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,
|
||||
bool shifted,
|
||||
int *key);
|
||||
bool shifted, bool control, bool alt,
|
||||
bool super, int *key);
|
||||
int trainlog_terminal_get_key(TrainlogTerminal *terminal);
|
||||
bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint,
|
||||
char utf8[5]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
bool shifted,
|
||||
int *key)
|
||||
bool shifted, bool control, bool alt,
|
||||
bool super, int *key)
|
||||
{
|
||||
if (key == NULL || id == 0U || id == UINT32_MAX) {
|
||||
return false;
|
||||
|
|
@ -312,7 +312,14 @@ bool trainlog_terminal_translate_input(uint32_t id,
|
|||
case TRAINLOG_INPUT_UNKNOWN:
|
||||
case TRAINLOG_INPUT_PRESS:
|
||||
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;
|
||||
case TRAINLOG_INPUT_RELEASE:
|
||||
default:
|
||||
|
|
@ -347,10 +354,12 @@ static bool terminal_read_input(TrainlogTerminal *terminal,
|
|||
if (id == 0U || id == UINT32_MAX) {
|
||||
return false;
|
||||
}
|
||||
if (!trainlog_terminal_translate_input(id,
|
||||
if (!trainlog_terminal_translate_input(id, input.eff_text[0],
|
||||
terminal_event_type(input.evtype),
|
||||
ncinput_shift_p(&input),
|
||||
key)) {
|
||||
ncinput_ctrl_p(&input),
|
||||
ncinput_alt_p(&input),
|
||||
ncinput_super_p(&input), key)) {
|
||||
continue;
|
||||
}
|
||||
if (utf8 != NULL) {
|
||||
|
|
|
|||
|
|
@ -4235,9 +4235,6 @@ static void app_shell_actions(TrainlogAppContext *app)
|
|||
if (route == TRAINLOG_ROUTE_HOME)
|
||||
app_shell_add_action(app, "session", '1', "1 Séance", 1U,
|
||||
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 ||
|
||||
route == TRAINLOG_ROUTE_SYNC || route == TRAINLOG_ROUTE_SETTINGS)
|
||||
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) {
|
||||
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, 9, 2, "g Programmer une séance");
|
||||
trainlog_surface_printf(app->content, 11, 2, "5 Ajouter ou consulter des mensurations");
|
||||
trainlog_surface_printf(app->content, 9, 2, "5 Ajouter ou consulter des mensurations");
|
||||
} else if (route == TRAINLOG_ROUTE_SESSIONS) {
|
||||
static const char *const rows[] = {"Séance en cours", "Programmer une séance",
|
||||
"Nouvelle séance manuelle", "Séances effectuées"};
|
||||
static const char *const rows[] = {"Séance en cours", "Nouvelle séance manuelle",
|
||||
"Séances effectuées"};
|
||||
size_t index;
|
||||
for (index = 0U; index < 4U; ++index) {
|
||||
for (index = 0U; index < 3U; ++index) {
|
||||
trainlog_surface_set_role(app->content,
|
||||
app->content_selected == index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
|
||||
TRAINLOG_RGB_BASE,
|
||||
|
|
@ -6214,9 +6210,8 @@ static void app_shell_primary(TrainlogAppContext *app)
|
|||
TrainlogAppRoute route = app->navigation.current.route;
|
||||
if (route == TRAINLOG_ROUTE_SESSIONS) {
|
||||
static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_SESSION_CURRENT,
|
||||
TRAINLOG_ROUTE_SESSION_GENERATOR, TRAINLOG_ROUTE_SESSION_MANUAL,
|
||||
TRAINLOG_ROUTE_SESSIONS_COMPLETED};
|
||||
app_shell_open_route(app, targets[app->content_selected % 4U]);
|
||||
TRAINLOG_ROUTE_SESSION_MANUAL, TRAINLOG_ROUTE_SESSIONS_COMPLETED};
|
||||
app_shell_open_route(app, targets[app->content_selected % 3U]);
|
||||
} else if (route == TRAINLOG_ROUTE_STATS) {
|
||||
static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_STATS_EXERCISE,
|
||||
TRAINLOG_ROUTE_BODY, TRAINLOG_ROUTE_MAX};
|
||||
|
|
@ -7379,6 +7374,17 @@ static void app_shell_dispatch(TrainlogAppContext *app, int key)
|
|||
/* INVARIANT: the rendered Actions control owns its input. */
|
||||
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_equipment_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 ||
|
||||
app->navigation.current.route == TRAINLOG_ROUTE_STATS) &&
|
||||
(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_DOWN && app->content_selected + 1U < count) ++app->content_selected;
|
||||
return;
|
||||
|
|
@ -7641,20 +7647,7 @@ shell_nonanalytics_input:
|
|||
else if (key == TRAINLOG_KEY_F3) key = '3';
|
||||
else if (key == TRAINLOG_KEY_F4) key = '4';
|
||||
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);
|
||||
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) {
|
||||
app_shell_open_route(app, action->route); return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,24 +29,24 @@ static bool test_event_type_policy(void)
|
|||
int key = 12345;
|
||||
|
||||
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(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(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);
|
||||
|
||||
key = 12345;
|
||||
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(!trainlog_terminal_translate_input(
|
||||
NCKEY_RIGHT, (TrainlogInputEventType)99, false, &key));
|
||||
NCKEY_RIGHT, 0U, (TrainlogInputEventType)99, false, false, false, false, &key));
|
||||
CHECK(key == 12345);
|
||||
|
||||
return true;
|
||||
|
|
@ -57,35 +57,48 @@ static bool test_key_translation(void)
|
|||
int key = TRAINLOG_KEY_NONE;
|
||||
|
||||
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(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(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(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(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(trainlog_terminal_translate_input(
|
||||
0x00e9U, TRAINLOG_INPUT_REPEAT, false, &key));
|
||||
0x00e9U, 0x00e9U, TRAINLOG_INPUT_REPEAT, false, false, false, false, &key));
|
||||
CHECK(key == 0x00e9);
|
||||
|
||||
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(
|
||||
UINT32_MAX, TRAINLOG_INPUT_PRESS, false, &key));
|
||||
UINT32_MAX, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue