Add exercise performance history and framed TUI views
This commit is contained in:
parent
55efbf84bb
commit
d555ddeffd
9 changed files with 1608 additions and 13 deletions
|
|
@ -76,3 +76,11 @@ Next:
|
|||
- empty months remain visible and empty;
|
||||
- no zero fill or interpolation across missing months;
|
||||
- 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.
|
||||
|
|
|
|||
27
docs/reviews/exercise_performance.md
Normal file
27
docs/reviews/exercise_performance.md
Normal 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.
|
||||
|
|
@ -118,3 +118,9 @@ No Trainlog JSON v1 change is expected.
|
|||
```text
|
||||
DASHBOARD_12_MONTHS=IMPLEMENTED
|
||||
```
|
||||
|
||||
```text
|
||||
TUI_EXERCISE_PERFORMANCE=IMPLEMENTED
|
||||
MEASURED_MAX_TRACKING=NEXT
|
||||
PREVIOUS_SESSION_DEFAULTS=AFTER
|
||||
```
|
||||
|
|
|
|||
30
docs/tui.md
30
docs/tui.md
|
|
@ -494,3 +494,33 @@ Rules:
|
|||
|
||||
The dashboard legend keeps the last visible raw value and the percentage
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -172,4 +172,39 @@ TrainlogStatus trainlog_database_latest_body_pair(
|
|||
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
|
||||
|
|
|
|||
|
|
@ -135,3 +135,15 @@ test_bodyviz = executable(
|
|||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1910,3 +1910,283 @@ TrainlogStatus trainlog_database_latest_body_pair(
|
|||
? TRAINLOG_STATUS_OK
|
||||
: 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;
|
||||
}
|
||||
|
|
|
|||
960
tui/src/tui.c
960
tui/src/tui.c
File diff suppressed because it is too large
Load diff
263
tui/tests/test_exercise_performance.c
Normal file
263
tui/tests/test_exercise_performance.c
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue