From d555ddeffd50471d6adcd88a307b20b7b08b2105 Mon Sep 17 00:00:00 2001 From: fy59 Date: Sat, 5 Sep 2026 22:44:11 +0200 Subject: [PATCH] Add exercise performance history and framed TUI views --- CHANGELOG.md | 8 + docs/reviews/exercise_performance.md | 27 + docs/roadmap.md | 6 + docs/tui.md | 30 + tui/include/trainlog/database.h | 35 + tui/meson.build | 12 + tui/src/database.c | 280 ++++++++ tui/src/tui.c | 960 +++++++++++++++++++++++++- tui/tests/test_exercise_performance.c | 263 +++++++ 9 files changed, 1608 insertions(+), 13 deletions(-) create mode 100644 docs/reviews/exercise_performance.md create mode 100644 tui/tests/test_exercise_performance.c diff --git a/CHANGELOG.md b/CHANGELOG.md index ddddeee..794dcd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/reviews/exercise_performance.md b/docs/reviews/exercise_performance.md new file mode 100644 index 0000000..8b4b15f --- /dev/null +++ b/docs/reviews/exercise_performance.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index d277fa0..5d8e9bc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 +``` diff --git a/docs/tui.md b/docs/tui.md index 86ea45d..95fb856 100644 --- a/docs/tui.md +++ b/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. diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 8e8f806..35e8def 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -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 diff --git a/tui/meson.build b/tui/meson.build index 620db8d..9410cfe 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -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, +) diff --git a/tui/src/database.c b/tui/src/database.c index 65b42b0..1f56597 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -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; +} diff --git a/tui/src/tui.c b/tui/src/tui.c index ef4862c..3a0b607 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -238,6 +238,887 @@ static bool prompt_optional_double( } } +/* TRAINLOG_EXERCISE_PERFORMANCE_TUI */ + +#define EXERCISE_GRAPH_POINTS 12U + +static const char *exercise_load_mode_label( + TrainlogLoadMode mode +) +{ + switch (mode) { + case TRAINLOG_LOAD_EXTERNAL: + return "charge externe"; + + case TRAINLOG_LOAD_ASSISTANCE: + return "assistance"; + + case TRAINLOG_LOAD_NONE: + default: + return "sans charge"; + } +} + +static void exercise_short_date( + const char *timestamp, + char output[11] +) +{ + if (timestamp == NULL || + strlen(timestamp) < 10U) { + (void)snprintf( + output, + 11U, + "%s", + "----------" + ); + return; + } + + (void)memcpy( + output, + timestamp, + 10U + ); + + output[10] = '\0'; +} + +static void exercise_format_performance( + const TrainlogExercisePerformancePoint *point, + char *output, + size_t output_size +) +{ + if (point == NULL || + output == NULL || + output_size == 0U) { + return; + } + + if (point->has_performance == 0) { + (void)snprintf( + output, + output_size, + "%s", + "aucune série réussie" + ); + return; + } + + if (point->tracking_mode == + TRAINLOG_TRACKING_DURATION) { + char duration[64]; + + if (trainlog_duration_format( + point->metric_value, + duration, + sizeof(duration) + ) != TRAINLOG_STATUS_OK) { + (void)snprintf( + duration, + sizeof(duration), + "%d s", + point->metric_value + ); + } + + if (point->load_mode == + TRAINLOG_LOAD_EXTERNAL) { + (void)snprintf( + output, + output_size, + "%.1f kg × %s", + point->weight_kg, + duration + ); + } else if ( + point->load_mode == + TRAINLOG_LOAD_ASSISTANCE + ) { + (void)snprintf( + output, + output_size, + "%.1f kg aide × %s", + point->weight_kg, + duration + ); + } else { + (void)snprintf( + output, + output_size, + "%s", + duration + ); + } + + return; + } + + if (point->load_mode == + TRAINLOG_LOAD_EXTERNAL) { + (void)snprintf( + output, + output_size, + "%.1f kg × %d reps", + point->weight_kg, + point->metric_value + ); + } else if ( + point->load_mode == + TRAINLOG_LOAD_ASSISTANCE + ) { + (void)snprintf( + output, + output_size, + "%.1f kg aide × %d reps", + point->weight_kg, + point->metric_value + ); + } else { + (void)snprintf( + output, + output_size, + "%d reps", + point->metric_value + ); + } +} + +static bool exercise_point_better( + const TrainlogExercisePerformancePoint *candidate, + const TrainlogExercisePerformancePoint *current +) +{ + if (candidate == NULL || + candidate->has_performance == 0) { + return false; + } + + if (current == NULL || + current->has_performance == 0) { + return true; + } + + if (candidate->load_mode != + current->load_mode) { + return false; + } + + switch (candidate->load_mode) { + case TRAINLOG_LOAD_EXTERNAL: + if (candidate->weight_kg > + current->weight_kg) { + return true; + } + + return + candidate->weight_kg == + current->weight_kg && + candidate->metric_value > + current->metric_value; + + case TRAINLOG_LOAD_ASSISTANCE: + if (candidate->weight_kg < + current->weight_kg) { + return true; + } + + return + candidate->weight_kg == + current->weight_kg && + candidate->metric_value > + current->metric_value; + + case TRAINLOG_LOAD_NONE: + default: + return + candidate->metric_value > + current->metric_value; + } +} + +static double exercise_graph_value( + const TrainlogExercisePerformancePoint *point +) +{ + if (point->load_mode == + TRAINLOG_LOAD_NONE) { + return (double)point->metric_value; + } + + return point->weight_kg; +} + +static void draw_exercise_performance_graph( + const TrainlogExercisePerformancePoint *points, + size_t count, + TrainlogLoadMode mode, + TrainlogTrackingMode tracking_mode, + int top, + int height +) +{ + size_t indices[EXERCISE_GRAPH_POINTS]; + size_t selected_count = 0U; + size_t index; + double minimum = 0.0; + double maximum = 0.0; + const int left = 11; + int width = + COLS - left - 4; + + if (points == NULL || + count == 0U || + height < 3 || + width < 12) { + return; + } + + for (index = 0U; + index < count && + selected_count < EXERCISE_GRAPH_POINTS; + ++index) { + if (points[index].has_performance != 0 && + points[index].load_mode == mode) { + indices[selected_count] = index; + ++selected_count; + } + } + + if (selected_count == 0U) { + mvprintw( + top + 1, + 4, + "Aucune performance réussie pour ce mode." + ); + return; + } + + minimum = + exercise_graph_value( + &points[indices[0]] + ); + + maximum = minimum; + + for (index = 1U; + index < selected_count; + ++index) { + double value = + exercise_graph_value( + &points[indices[index]] + ); + + if (value < minimum) { + minimum = value; + } + + if (value > maximum) { + maximum = value; + } + } + + if (maximum == minimum) { + minimum -= 1.0; + maximum += 1.0; + } + + if (mode == TRAINLOG_LOAD_ASSISTANCE) { + attron( + trainlog_theme_attribute( + TRAINLOG_COLOR_WARNING + ) + ); + + mvprintw( + top, + left, + "Assistance (kg) — moins = mieux" + ); + + attroff( + trainlog_theme_attribute( + TRAINLOG_COLOR_WARNING + ) + ); + } else if (mode == + TRAINLOG_LOAD_EXTERNAL) { + mvprintw( + top, + left, + "Charge du meilleur set (kg)" + ); + } else if ( + tracking_mode == + TRAINLOG_TRACKING_DURATION + ) { + mvprintw( + top, + left, + "Meilleure durée" + ); + } else { + mvprintw( + top, + left, + "Meilleures répétitions" + ); + } + + mvprintw( + top + 1, + 2, + "%.1f", + maximum + ); + + mvprintw( + top + height - 1, + 2, + "%.1f", + minimum + ); + + attron( + trainlog_theme_attribute( + TRAINLOG_COLOR_GRAPH + ) + ); + + { + int previous_x = -1; + int previous_y = -1; + size_t order; + + for (order = selected_count; + order > 0U; + --order) { + size_t chronological = + selected_count - order; + + const TrainlogExercisePerformancePoint *point = + &points[indices[order - 1U]]; + + double value = + exercise_graph_value(point); + + double ratio = + (value - minimum) / + (maximum - minimum); + + int x = + selected_count == 1U + ? left + (width / 2) + : left + + (int)( + (chronological * + (size_t)(width - 1)) / + (selected_count - 1U) + ); + + int y = + top + + height - + 1 - + (int)( + ratio * + (double)(height - 1) + ); + + if (previous_x >= 0 && + x > previous_x) { + int line_x; + + for (line_x = previous_x + 1; + line_x < x; + ++line_x) { + int line_y = + previous_y + + (((y - previous_y) * + (line_x - previous_x)) / + (x - previous_x)); + + mvaddch( + line_y, + line_x, + (chtype)'.' + ); + } + } + + mvaddch( + y, + x, + chronological + 1U == + selected_count + ? (chtype)'O' + : (chtype)'*' + ); + + previous_x = x; + previous_y = y; + } + } + + attroff( + trainlog_theme_attribute( + TRAINLOG_COLOR_GRAPH + ) + ); + + { + char oldest[11]; + char newest[11]; + + exercise_short_date( + points[indices[selected_count - 1U]] + .started_at, + oldest + ); + + exercise_short_date( + points[indices[0]].started_at, + newest + ); + + mvprintw( + top + height, + left, + "%s", + oldest + ); + + mvprintw( + top + height, + left + width - 10, + "%s", + newest + ); + } +} + +/* TRAINLOG_EXERCISE_FRAMES */ + +static void exercise_panel( + int top, + int left, + int bottom, + int right, + const char *label +) +{ + WINDOW *panel; + int height; + int width; + + if (top < 0 || + left < 0 || + bottom <= top || + right <= left || + bottom >= LINES || + right >= COLS) { + return; + } + + height = bottom - top + 1; + width = right - left + 1; + + panel = derwin( + stdscr, + height, + width, + top, + left + ); + + if (panel == NULL) { + return; + } + + box(panel, 0, 0); + + if (label != NULL && + label[0] != '\0' && + width > 8) { + wattron( + panel, + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + + mvwprintw( + panel, + 0, + 2, + " %.*s ", + width - 6, + label + ); + + wattroff( + panel, + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + } + + syncok(panel, TRUE); + wsyncup(panel); + delwin(panel); +} + +static void screen_exercise_performance( + TrainlogDatabase *database, + const TrainlogExercise *exercise +) +{ + TrainlogExercisePerformancePoint + points[MAX_SESSIONS]; + + size_t count = 0U; + size_t index; + size_t history_limit; + + TrainlogLoadMode graph_mode = + TRAINLOG_LOAD_NONE; + + const TrainlogExercisePerformancePoint *latest = + NULL; + + const TrainlogExercisePerformancePoint *best = + NULL; + + char latest_text[128]; + char best_text[128]; + + bool framed = + COLS >= 100 && + LINES >= 30; + + int summary_top = 3; + int summary_bottom = + framed ? 9 : 8; + + int graph_top = + framed ? 11 : 10; + + int graph_bottom = + framed ? 20 : 18; + + int history_top = + framed ? 22 : 19; + + int history_bottom = + LINES - 4; + + if (database == NULL || + exercise == NULL) { + return; + } + + if (trainlog_database_list_exercise_performance( + database, + exercise->exercise_id, + points, + MAX_SESSIONS, + &count + ) != TRAINLOG_STATUS_OK) { + draw_shell( + "Performance exercice", + "Une touche pour revenir" + ); + + status_line( + "Impossible de lire l'historique.", + TRAINLOG_COLOR_ERROR + ); + + wait_key(); + return; + } + + for (index = 0U; + index < count; + ++index) { + if (points[index].has_performance != 0) { + latest = &points[index]; + graph_mode = + points[index].load_mode; + break; + } + } + + if (latest != NULL) { + for (index = 0U; + index < count; + ++index) { + if (points[index].has_performance != 0 && + points[index].load_mode == + graph_mode && + exercise_point_better( + &points[index], + best + )) { + best = &points[index]; + } + } + } + + exercise_format_performance( + latest, + latest_text, + sizeof(latest_text) + ); + + exercise_format_performance( + best, + best_text, + sizeof(best_text) + ); + + draw_shell( + "TRAINLOG — Performance exercice", + "b/Échap retour" + ); + + if (framed) { + exercise_panel( + summary_top, + 2, + summary_bottom, + COLS - 3, + "PERFORMANCE" + ); + + exercise_panel( + graph_top, + 2, + graph_bottom, + COLS - 3, + "EVOLUTION" + ); + + if (history_bottom > + history_top + 2) { + exercise_panel( + history_top, + 2, + history_bottom, + COLS - 3, + "HISTORIQUE" + ); + } + } + + attron( + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + + mvprintw( + framed ? summary_top + 1 : 3, + framed ? 5 : 4, + "%s", + exercise->name + ); + + attroff( + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + + mvprintw( + framed ? summary_top + 2 : 4, + framed ? 5 : 4, + "Séances enregistrées : %zu", + count + ); + + if (latest == NULL) { + mvprintw( + framed ? summary_top + 3 : 5, + framed ? 5 : 4, + "Aucune série réussie enregistrée." + ); + } else { + mvprintw( + framed ? summary_top + 3 : 5, + framed ? 5 : 4, + "Mode suivi : %s", + exercise_load_mode_label( + graph_mode + ) + ); + + mvprintw( + framed ? summary_top + 4 : 6, + framed ? 5 : 4, + "Dernier meilleur set : %.*s", + COLS - 30, + latest_text + ); + + mvprintw( + framed ? summary_top + 5 : 7, + framed ? 5 : 4, + "Meilleur set enregistré : %.*s", + COLS - 33, + best_text + ); + + if (graph_mode != + TRAINLOG_LOAD_NONE) { + attron( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + mvprintw( + framed ? summary_bottom - 1 : 8, + framed ? 5 : 4, + "Meilleur set ≠ max mesuré." + ); + + attroff( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + } + } + + if (latest != NULL) { + int graph_content_top = + framed + ? graph_top + 1 + : graph_top; + + int graph_height = + framed + ? graph_bottom - + graph_top - + 2 + : 7; + + draw_exercise_performance_graph( + points, + count, + graph_mode, + exercise->tracking_mode, + graph_content_top, + graph_height + ); + } + + if (framed) { + int history_first_row = + history_top + 1; + + history_limit = + history_bottom > + history_first_row + ? (size_t)( + history_bottom - + history_first_row + ) + : 0U; + + for (index = 0U; + index < count && + index < history_limit; + ++index) { + char date[11]; + char summary[128]; + + exercise_short_date( + points[index].started_at, + date + ); + + exercise_format_performance( + &points[index], + summary, + sizeof(summary) + ); + + mvprintw( + history_first_row + + (int)index, + 5, + "%s %-13s %.*s", + date, + exercise_load_mode_label( + points[index].load_mode + ), + COLS - 40, + summary + ); + } + } else { + history_limit = + LINES > 12 + ? (size_t)(LINES - 12) + : 0U; + + mvprintw( + 19, + 4, + "Dernières séances :" + ); + + for (index = 0U; + index < count && + index < history_limit; + ++index) { + char date[11]; + char summary[128]; + + exercise_short_date( + points[index].started_at, + date + ); + + exercise_format_performance( + &points[index], + summary, + sizeof(summary) + ); + + mvprintw( + 20 + (int)index, + 6, + "%s %.*s", + date, + COLS - 20, + summary + ); + } + } + + refresh(); + + for (;;) { + int key = getch(); + + if (key == 'b' || + key == 27 || + key == '\n' || + key == KEY_ENTER) { + return; + } + } +} + static void screen_exercises(TrainlogDatabase *database) { TrainlogExercise exercises[MAX_EXERCISES]; @@ -247,7 +1128,23 @@ static void screen_exercises(TrainlogDatabase *database) size_t count = 0U; size_t top = 0U; size_t index; - int visible_rows = LINES - 7; + bool framed = + COLS >= 90 && + LINES >= 24; + + int list_top = + framed ? 3 : 3; + + int list_bottom = + framed + ? LINES - 4 + : LINES - 3; + + int visible_rows = + framed + ? list_bottom - list_top - 1 + : LINES - 7; + int key; if (visible_rows < 1) { @@ -263,7 +1160,8 @@ static void screen_exercises(TrainlogDatabase *database) return; } - if (count > 0U && selected >= count) { + if (count > 0U && + selected >= count) { selected = count - 1U; } @@ -277,19 +1175,40 @@ static void screen_exercises(TrainlogDatabase *database) draw_shell( "TRAINLOG — Exercices", - "↑↓ naviguer a ajouter b/Échap retour" + "↑↓ naviguer Entrée performance a ajouter b/Échap retour" ); + if (framed) { + exercise_panel( + list_top, + 2, + list_bottom, + COLS - 3, + "CATALOGUE" + ); + } + if (count == 0U) { - mvprintw(4, 4, "Aucun exercice."); + mvprintw( + framed ? list_top + 2 : 4, + 5, + "Aucun exercice." + ); } for (index = 0U; index < (size_t)visible_rows && top + index < count; ++index) { - size_t absolute = top + index; - int item_row = 3 + (int)index; + size_t absolute = + top + index; + + int item_row = + (framed ? list_top + 1 : 3) + + (int)index; + + int item_col = + framed ? 5 : 4; if (absolute == selected) { attron( @@ -302,7 +1221,7 @@ static void screen_exercises(TrainlogDatabase *database) mvprintw( item_row, - 4, + item_col, " %-42s [%s] ", exercises[absolute].name, exercises[absolute].tracking_mode == @@ -324,21 +1243,34 @@ static void screen_exercises(TrainlogDatabase *database) refresh(); key = getch(); - if (key == 'b' || key == 27) { + if (key == 'b' || + key == 27) { return; } - if (count > 0U && key == KEY_UP) { + if (count > 0U && + key == KEY_UP) { selected = selected > 0U ? selected - 1U : count - 1U; - } else if (count > 0U && - key == KEY_DOWN) { + } else if ( + count > 0U && + key == KEY_DOWN + ) { selected = selected + 1U < count ? selected + 1U : 0U; + } else if ( + count > 0U && + (key == '\n' || + key == KEY_ENTER) + ) { + screen_exercise_performance( + database, + &exercises[selected] + ); } else if (key == 'a') { char name[TRAINLOG_NAME_MAX + 1U]; int mode = 1; @@ -381,13 +1313,15 @@ static void screen_exercises(TrainlogDatabase *database) &created ); - if (status == TRAINLOG_STATUS_OK) { + if (status == + TRAINLOG_STATUS_OK) { status_line( "✓ Exercice ajouté.", TRAINLOG_COLOR_SUCCESS ); } else if ( - status == TRAINLOG_STATUS_CONFLICT + status == + TRAINLOG_STATUS_CONFLICT ) { status_line( "Doublon détecté.", diff --git a/tui/tests/test_exercise_performance.c b/tui/tests/test_exercise_performance.c new file mode 100644 index 0000000..1faa8e7 --- /dev/null +++ b/tui/tests/test_exercise_performance.c @@ -0,0 +1,263 @@ +/** + * @file test_exercise_performance.c + * @brief Representative exercise-performance history tests. + */ + +#include +#include +#include + +#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; +}