Add exercise performance history and framed TUI views

This commit is contained in:
fy59 2026-09-05 22:44:11 +02:00
parent 55efbf84bb
commit d555ddeffd
9 changed files with 1608 additions and 13 deletions

View file

@ -76,3 +76,11 @@ Next:
- empty months remain visible and empty; - empty months remain visible and empty;
- no zero fill or interpolation across missing months; - no zero fill or interpolation across missing months;
- multiple readings in one month use the last monthly value. - multiple readings in one month use the last monthly value.
### Exercise performance history
- added Enter-to-open exercise performance detail;
- added mode-aware representative best-set history;
- added terminal performance graph;
- assistance explicitly treats lower assistance as better;
- best recorded set remains distinct from measured max.

View file

@ -0,0 +1,27 @@
# Exercise performance history
## Status
```text
TUI_EXERCISE_PERFORMANCE=IMPLEMENTED
MEASURED_MAX_TRACKING=DEFERRED
```
`F3 Exercices` now opens a read-only performance screen with Enter.
Representative session performance is deliberately mode-aware:
- no load: greatest successful reps or duration;
- external load: greatest actual load, then reps/duration;
- assistance: lowest actual assistance, then reps/duration.
A failed 0-repetition attempt is not promoted as representative performance.
The graph follows the most recent load mode and excludes incompatible load
modes from that graph. Assistance remains displayed in actual assistance kg and
is explicitly labelled `moins = mieux`.
The screen distinguishes `meilleur set enregistré` from a future
`max mesuré`. No estimated 1RM or pseudo-max is created in this slice.
No database schema or Trainlog JSON v1 change is required.

View file

@ -118,3 +118,9 @@ No Trainlog JSON v1 change is expected.
```text ```text
DASHBOARD_12_MONTHS=IMPLEMENTED DASHBOARD_12_MONTHS=IMPLEMENTED
``` ```
```text
TUI_EXERCISE_PERFORMANCE=IMPLEMENTED
MEASURED_MAX_TRACKING=NEXT
PREVIOUS_SESSION_DEFAULTS=AFTER
```

View file

@ -494,3 +494,33 @@ Rules:
The dashboard legend keeps the last visible raw value and the percentage The dashboard legend keeps the last visible raw value and the percentage
change over the visible 12-month window. change over the visible 12-month window.
## Exercise performance history
`F3 Exercices` opens an exercise performance screen with `Enter`.
The current slice tracks representative actual performance per workout without
inventing a maximum.
Semantics:
```text
load none
greatest successful reps/duration
external load
greatest actual load
tie -> greatest reps/duration
assistance
lowest actual assistance
tie -> greatest reps/duration
```
Assistance graphs keep kilograms as actual assistance and explicitly state that
lower assistance is better.
A recorded best set is not a measured maximum.
Measured maxima, max-session scheduling and working-load percentages remain a
separate later contract.

View file

@ -172,4 +172,39 @@ TrainlogStatus trainlog_database_latest_body_pair(
TrainlogBodyPairPoint *output TrainlogBodyPairPoint *output
); );
/* TRAINLOG_EXERCISE_PERFORMANCE_API */
typedef struct TrainlogExercisePerformancePoint {
char session_id[TRAINLOG_ID_MAX + 1U];
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
TrainlogTrackingMode tracking_mode;
TrainlogLoadMode load_mode;
size_t actual_set_count;
int has_performance;
int metric_value;
int has_weight;
double weight_kg;
} TrainlogExercisePerformancePoint;
/**
* @brief Read newest-first per-session representative performance.
*
* Representative-set semantics:
*
* - no load: greatest successful reps/duration;
* - external load: greatest load, then greatest reps/duration;
* - assistance: lowest assistance, then greatest reps/duration.
*
* A zero-repetition failed attempt is never promoted to representative
* performance. This API does not create or infer a measured maximum.
*/
TrainlogStatus trainlog_database_list_exercise_performance(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogExercisePerformancePoint *output,
size_t capacity,
size_t *output_count
);
#endif #endif

View file

@ -135,3 +135,15 @@ test_bodyviz = executable(
) )
test('bodyviz', test_bodyviz) test('bodyviz', test_bodyviz)
test_exercise_performance = executable(
'test_exercise_performance',
'tests/test_exercise_performance.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test(
'exercise_performance',
test_exercise_performance,
)

View file

@ -1910,3 +1910,283 @@ TrainlogStatus trainlog_database_latest_body_pair(
? TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_DATABASE_ERROR; : TRAINLOG_STATUS_DATABASE_ERROR;
} }
/* TRAINLOG_EXERCISE_PERFORMANCE_IMPLEMENTATION */
static TrainlogLoadMode performance_load_mode_from_sql(
const char *text
)
{
if (text != NULL &&
strcmp(text, "external") == 0) {
return TRAINLOG_LOAD_EXTERNAL;
}
if (text != NULL &&
strcmp(text, "assistance") == 0) {
return TRAINLOG_LOAD_ASSISTANCE;
}
return TRAINLOG_LOAD_NONE;
}
static bool performance_candidate_better(
const TrainlogExercisePerformancePoint *current,
TrainlogLoadMode load_mode,
int metric_value,
int has_weight,
double weight_kg
)
{
if (current == NULL ||
metric_value <= 0) {
return false;
}
if (current->has_performance == 0) {
return true;
}
switch (load_mode) {
case TRAINLOG_LOAD_EXTERNAL:
if (has_weight == 0) {
return false;
}
if (weight_kg > current->weight_kg) {
return true;
}
return
weight_kg == current->weight_kg &&
metric_value > current->metric_value;
case TRAINLOG_LOAD_ASSISTANCE:
if (has_weight == 0) {
return false;
}
if (weight_kg < current->weight_kg) {
return true;
}
return
weight_kg == current->weight_kg &&
metric_value > current->metric_value;
case TRAINLOG_LOAD_NONE:
default:
return metric_value > current->metric_value;
}
}
TrainlogStatus trainlog_database_list_exercise_performance(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogExercisePerformancePoint *output,
size_t capacity,
size_t *output_count
)
{
static const char *const SQL =
"SELECT "
"s.session_id, "
"s.started_at, "
"e.tracking_mode, "
"se.load_mode, "
"ps.id, "
"ps.reps, "
"ps.duration_seconds, "
"ps.weight_kg "
"FROM session_exercises AS se "
"JOIN sessions AS s "
" ON s.id = se.session_row_id "
"JOIN exercises AS e "
" ON e.id = se.exercise_row_id "
"LEFT JOIN performed_sets AS ps "
" ON ps.session_exercise_row_id = se.id "
"WHERE e.exercise_id = ?1 "
"ORDER BY "
"s.started_at DESC, "
"s.id DESC, "
"ps.position ASC;";
sqlite3_stmt *statement = NULL;
TrainlogExercisePerformancePoint *current = NULL;
char current_session_id[TRAINLOG_ID_MAX + 1U];
size_t copied = 0U;
int rc;
if (database == NULL ||
database->connection == NULL ||
exercise_id == NULL ||
exercise_id[0] == '\0' ||
output_count == NULL ||
(capacity > 0U && output == NULL)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_count = 0U;
current_session_id[0] = '\0';
rc = sqlite3_prepare_v2(
database->connection,
SQL,
-1,
&statement,
NULL
);
if (rc != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
rc = sqlite3_bind_text(
statement,
1,
exercise_id,
-1,
SQLITE_TRANSIENT
);
if (rc != SQLITE_OK) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
const unsigned char *session_id =
sqlite3_column_text(statement, 0);
const unsigned char *started_at =
sqlite3_column_text(statement, 1);
const unsigned char *tracking_mode =
sqlite3_column_text(statement, 2);
const unsigned char *load_mode =
sqlite3_column_text(statement, 3);
bool new_session;
if (session_id == NULL ||
started_at == NULL ||
tracking_mode == NULL ||
load_mode == NULL) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
new_session =
current_session_id[0] == '\0' ||
strcmp(
current_session_id,
(const char *)session_id
) != 0;
if (new_session) {
(void)snprintf(
current_session_id,
sizeof(current_session_id),
"%s",
(const char *)session_id
);
current = NULL;
if (copied < capacity) {
current = &output[copied];
(void)memset(
current,
0,
sizeof(*current)
);
(void)snprintf(
current->session_id,
sizeof(current->session_id),
"%s",
(const char *)session_id
);
(void)snprintf(
current->started_at,
sizeof(current->started_at),
"%s",
(const char *)started_at
);
current->tracking_mode =
tracking_mode_from_sql(
(const char *)tracking_mode
);
current->load_mode =
performance_load_mode_from_sql(
(const char *)load_mode
);
++copied;
}
}
if (current != NULL &&
sqlite3_column_type(statement, 4) != SQLITE_NULL) {
int metric_value;
int has_weight;
double weight_kg;
++current->actual_set_count;
if (current->tracking_mode ==
TRAINLOG_TRACKING_REPS) {
metric_value =
sqlite3_column_type(statement, 5) !=
SQLITE_NULL
? sqlite3_column_int(statement, 5)
: 0;
} else {
metric_value =
sqlite3_column_type(statement, 6) !=
SQLITE_NULL
? sqlite3_column_int(statement, 6)
: 0;
}
has_weight =
sqlite3_column_type(statement, 7) !=
SQLITE_NULL;
weight_kg =
has_weight != 0
? sqlite3_column_double(statement, 7)
: 0.0;
if (performance_candidate_better(
current,
current->load_mode,
metric_value,
has_weight,
weight_kg
)) {
current->has_performance = 1;
current->metric_value = metric_value;
current->has_weight = has_weight;
current->weight_kg = weight_kg;
}
}
}
if (rc != SQLITE_DONE) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
*output_count = copied;
return TRAINLOG_STATUS_OK;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,263 @@
/**
* @file test_exercise_performance.c
* @brief Representative exercise-performance history tests.
*/
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "trainlog/database.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf( \
stderr, \
"CHECK failed at %s:%d: %s\n", \
__FILE__, \
__LINE__, \
#condition \
); \
return false; \
} \
} while (0)
static void bind_exercise(
TrainlogSessionExerciseInput *exercise,
const char *exercise_id,
TrainlogLoadMode load_mode,
double target_weight,
TrainlogSetInput *sets,
size_t set_count
)
{
(void)memset(exercise, 0, sizeof(*exercise));
(void)snprintf(
exercise->exercise_id,
sizeof(exercise->exercise_id),
"%s",
exercise_id
);
exercise->load_mode = load_mode;
exercise->rest_seconds = 60;
exercise->target_sets = 2;
exercise->target_reps = 10;
exercise->target_has_weight =
load_mode != TRAINLOG_LOAD_NONE;
exercise->target_weight_kg = target_weight;
exercise->sets = sets;
exercise->set_count = set_count;
}
static bool test_performance_semantics(void)
{
TrainlogDatabase *database = NULL;
TrainlogSetInput external_sets[2];
TrainlogSetInput assistance_sets[2];
TrainlogSetInput bodyweight_sets[2];
TrainlogSessionExerciseInput exercises[3];
TrainlogSessionInput session;
TrainlogExercisePerformancePoint points[8];
size_t count = 0U;
CHECK(
trainlog_database_open(
":memory:",
&database
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_insert_exercise(
database,
"ex_external",
"External",
"external",
TRAINLOG_TRACKING_REPS
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_insert_exercise(
database,
"ex_assistance",
"Assistance",
"assistance",
TRAINLOG_TRACKING_REPS
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_insert_exercise(
database,
"ex_none",
"Bodyweight",
"bodyweight",
TRAINLOG_TRACKING_REPS
) == TRAINLOG_STATUS_OK
);
(void)memset(
external_sets,
0,
sizeof(external_sets)
);
external_sets[0].reps = 10;
external_sets[0].has_weight = true;
external_sets[0].weight_kg = 80.0;
external_sets[1].reps = 5;
external_sets[1].has_weight = true;
external_sets[1].weight_kg = 90.0;
(void)memset(
assistance_sets,
0,
sizeof(assistance_sets)
);
assistance_sets[0].reps = 10;
assistance_sets[0].has_weight = true;
assistance_sets[0].weight_kg = 40.0;
assistance_sets[1].reps = 5;
assistance_sets[1].has_weight = true;
assistance_sets[1].weight_kg = 30.0;
(void)memset(
bodyweight_sets,
0,
sizeof(bodyweight_sets)
);
bodyweight_sets[0].reps = 8;
bodyweight_sets[1].reps = 12;
bind_exercise(
&exercises[0],
"ex_external",
TRAINLOG_LOAD_EXTERNAL,
80.0,
external_sets,
2U
);
bind_exercise(
&exercises[1],
"ex_assistance",
TRAINLOG_LOAD_ASSISTANCE,
40.0,
assistance_sets,
2U
);
bind_exercise(
&exercises[2],
"ex_none",
TRAINLOG_LOAD_NONE,
0.0,
bodyweight_sets,
2U
);
(void)memset(&session, 0, sizeof(session));
(void)snprintf(
session.session_id,
sizeof(session.session_id),
"%s",
"se_performance"
);
(void)snprintf(
session.started_at,
sizeof(session.started_at),
"%s",
"2026-09-05T18:00:00+02:00"
);
(void)snprintf(
session.ended_at,
sizeof(session.ended_at),
"%s",
"2026-09-05T19:00:00+02:00"
);
session.exercises = exercises;
session.exercise_count = 3U;
CHECK(
trainlog_database_insert_session(
database,
&session
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_list_exercise_performance(
database,
"ex_external",
points,
8U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 1U);
CHECK(points[0].has_performance != 0);
CHECK(points[0].weight_kg > 89.99);
CHECK(points[0].weight_kg < 90.01);
CHECK(points[0].metric_value == 5);
CHECK(
trainlog_database_list_exercise_performance(
database,
"ex_assistance",
points,
8U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 1U);
CHECK(points[0].has_performance != 0);
CHECK(points[0].weight_kg > 29.99);
CHECK(points[0].weight_kg < 30.01);
CHECK(points[0].metric_value == 5);
CHECK(
trainlog_database_list_exercise_performance(
database,
"ex_none",
points,
8U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 1U);
CHECK(points[0].has_performance != 0);
CHECK(points[0].metric_value == 12);
CHECK(points[0].has_weight == 0);
trainlog_database_close(database);
return true;
}
int main(void)
{
CHECK(test_performance_semantics());
(void)printf(
"PASS exercise_performance\n"
);
return 0;
}