diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb31b0..420649b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,27 @@ Added: Planned next: -- human duration input such as `1:30`, `1m30`, `2m`, and `45s`; -- automatic minute/second display formatting; -- history graphs for all body measurements; -- left/right asymmetry summaries. +- human duration input such as `1:30`, `1m30`, `2m`, and `45s` (implemented); +- automatic minute/second display formatting (implemented); +- history graphs for all body measurements (implemented); +- left/right asymmetry summaries (implemented). + + +### Current TUI checkpoint + +Implemented: +- human duration input; +- human minute/second display; +- graphs for every persisted body metric; +- recent values for every body metric; +- left/right asymmetry summaries. + +Next: +- normalized global body overlay graph; +- metric legend using both colors and symbols; +- percentage evolution summary; +- richer dashboard weight graph; +- dashboard previous-weight delta and min/max; +- compact body/asymmetry dashboard summary. + diff --git a/docs/reviews/tui_duration_body_checkpoint.md b/docs/reviews/tui_duration_body_checkpoint.md new file mode 100644 index 0000000..2d4d451 --- /dev/null +++ b/docs/reviews/tui_duration_body_checkpoint.md @@ -0,0 +1,85 @@ +# Human Duration and Body Graph Checkpoint + +## Status + +```text +FIRST_USABLE_TUI=PASS +TUI_V0_2_POLISH=IMPLEMENTED +TUI_SESSION_DETAILS=IMPLEMENTED +TUI_DURATION_HUMAN_INPUT=IMPLEMENTED +TUI_BODY_METRIC_GRAPHS=IMPLEMENTED +GATE_2=IN_PROGRESS +TRAINLOG_FORMAT_V1=FROZEN +``` + +## Implemented + +### Human duration entry + +Accepted examples: + +```text +90 +90s +1:30 +1m30 +1m30s +2m +45s +2:05 +``` + +Persistence remains canonical seconds. +Display uses human minute/second formatting. + +### Body tracking + +`F4 Corps` now provides per-metric history for weight, neck, shoulders, chest, +waist, hips, left/right arm, left/right forearm, left/right thigh and +left/right calf. + +Each metric has latest value, first value, change, graph and recent dated +values. Paired limbs expose same-observation asymmetry. + +## Next visualization contract + +### Global body graph + +All available body series are overlaid after normalizing each metric's first +real value to 100. The global graph is display-only. + +### Dashboard graph v2 + +The home screen remains compact but gains: +- current weight; +- first-to-current delta; +- previous-to-current delta; +- min/max; +- richer recent weight graph; +- latest waist; +- compact asymmetry status. + +## Validation before push + +```bash +meson compile -C build +meson test -C build --print-errorlogs +python tools/validate_json.py +python tools/validate_import_contract.py +git diff --check +git diff --cached --check +``` + +Sanitizer validation: + +```bash +meson compile -C build-asan +meson test -C build-asan --print-errorlogs +``` + +## Next + +```text +TUI_GLOBAL_BODY_OVERLAY +DASHBOARD_GRAPH_V2 +``` diff --git a/docs/reviews/tui_duration_body_graphs.md b/docs/reviews/tui_duration_body_graphs.md new file mode 100644 index 0000000..30597af --- /dev/null +++ b/docs/reviews/tui_duration_body_graphs.md @@ -0,0 +1,18 @@ +# Human Durations and Body Metric Graphs + +## Status + +```text +TUI_DURATION_HUMAN_INPUT=IMPLEMENTED +TUI_BODY_METRIC_GRAPHS=IMPLEMENTED +TRAINLOG_FORMAT_V1=FROZEN +``` + +Durations accept `90`, `90s`, `1:30`, `1m30`, `1m30s`, `2m`, `45s` +and `2:05`, while persistence remains integer seconds. + +F4 now browses all frozen body measurements with left/right navigation, +latest/first/change summaries, a graph, recent values, and same-observation +left/right asymmetry for arms, forearms, thighs, and calves. + +No database migration and no JSON v1 change are required. diff --git a/docs/roadmap.md b/docs/roadmap.md index 34af50a..1657f99 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -51,8 +51,8 @@ Current state: FIRST_USABLE_TUI=PASS TUI_V0_2_POLISH=IMPLEMENTED TUI_SESSION_DETAILS=IMPLEMENTED -TUI_DURATION_HUMAN_INPUT=NEXT -TUI_BODY_METRIC_GRAPHS=NEXT +TUI_DURATION_HUMAN_INPUT=IMPLEMENTED +TUI_BODY_METRIC_GRAPHS=IMPLEMENTED GATE_2=IN_PROGRESS TRAINLOG_FORMAT_V1=FROZEN ``` @@ -83,3 +83,34 @@ Next implementation slice: No incompatible change to frozen Trainlog JSON v1 is required. + + +## Next TUI visualization slice + +Canonical state after the current checkpoint: + +```text +FIRST_USABLE_TUI=PASS +TUI_V0_2_POLISH=IMPLEMENTED +TUI_SESSION_DETAILS=IMPLEMENTED +TUI_DURATION_HUMAN_INPUT=IMPLEMENTED +TUI_BODY_METRIC_GRAPHS=IMPLEMENTED +TUI_GLOBAL_BODY_OVERLAY=NEXT +DASHBOARD_GRAPH_V2=NEXT +GATE_2=IN_PROGRESS +TRAINLOG_FORMAT_V1=FROZEN +``` + +Next deliverables: +1. normalized global body graph in `F4`; +2. color + symbol identity for every overlaid metric; +3. global percent-change summary; +4. richer home weight graph; +5. previous-measurement delta on dashboard; +6. dashboard min/max weight; +7. latest waist summary when available; +8. compact asymmetry warning when relevant. + +No database schema migration is expected. +No Trainlog JSON v1 change is expected. + diff --git a/docs/tui.md b/docs/tui.md index a328214..9e7eb27 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -267,7 +267,7 @@ Inside one workout, left/right or up/down changes the selected exercise. Assistance remains direction-aware: more assistance kilograms mean more help, not greater strength. -## 18. Duration input and display — next implementation slice +## 18. Duration input and display — implemented Persistent duration and rest units remain **seconds**. @@ -313,7 +313,7 @@ The same parser and formatter are reused for: Invalid malformed forms are rejected before persistence. -## 19. Body screen and measurement graphs — next implementation slice +## 19. Body screen and measurement graphs — implemented `F4 Corps` becomes a full history and visualization screen. @@ -388,3 +388,72 @@ Immediate order: 7. Android recorder and JSON import workflow ``` + + +## 21. Global body evolution graph — next implementation slice + +`F4 Corps` keeps its current per-metric graph and gains a global normalized +overlay view. + +The global graph must not overlay raw kilograms and centimeters directly. + +Each metric is normalized to its own first real observation: + +```text +first recorded value = 100 +``` + +Examples: + +```text +waist 100 -> 96 = -4 % +right arm 100 -> 103 = +3 % +weight 100 -> 98 = -2 % +``` + +This makes unlike units visually comparable without changing persisted data. + +Rules: +- no missing observation becomes zero; +- each metric begins only at its first real value; +- original dates remain ordered; +- every series has both a color and a distinct text/symbol identity; +- left/right limb metrics remain separate; +- normalization is display-only; +- canonical SQLite values remain untouched. + +The global view is toggled from `F4 Corps` with `g`. +Per-metric navigation remains left/right. + +The global view also shows a compact percentage summary from first to latest +recorded value for each available metric. + +## 22. Dashboard graph v2 — next implementation slice + +The dashboard body-weight graph becomes a richer summary. + +It will show: +- current body weight; +- change from first recorded weight; +- change from previous recorded weight; +- minimum recorded weight; +- maximum recorded weight; +- recent weight graph; +- latest waist measurement when available; +- latest left/right asymmetry alert when meaningful. + +The dashboard remains intentionally compact. +The complete multi-metric overlay belongs to `F4 Corps`. + +## 23. Immediate TUI implementation order + +```text +TUI_DURATION_HUMAN_INPUT=IMPLEMENTED +TUI_BODY_METRIC_GRAPHS=IMPLEMENTED +TUI_GLOBAL_BODY_OVERLAY=NEXT +DASHBOARD_GRAPH_V2=NEXT +EXERCISE_PERFORMANCE_GRAPHS=AFTER +``` + +Android remains deferred until these TUI daily-use views are satisfactory. + diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 4051b6a..8e8f806 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -124,4 +124,52 @@ TrainlogStatus trainlog_database_get_session_details( size_t *output_exercise_count ); + +/* TRAINLOG_BODY_METRIC_HISTORY_API */ + +typedef enum TrainlogBodyMetric { + TRAINLOG_BODY_METRIC_WEIGHT = 0, + TRAINLOG_BODY_METRIC_NECK, + TRAINLOG_BODY_METRIC_SHOULDERS, + TRAINLOG_BODY_METRIC_CHEST, + TRAINLOG_BODY_METRIC_WAIST, + TRAINLOG_BODY_METRIC_HIPS, + TRAINLOG_BODY_METRIC_LEFT_ARM, + TRAINLOG_BODY_METRIC_RIGHT_ARM, + TRAINLOG_BODY_METRIC_LEFT_FOREARM, + TRAINLOG_BODY_METRIC_RIGHT_FOREARM, + TRAINLOG_BODY_METRIC_LEFT_THIGH, + TRAINLOG_BODY_METRIC_RIGHT_THIGH, + TRAINLOG_BODY_METRIC_LEFT_CALF, + TRAINLOG_BODY_METRIC_RIGHT_CALF, + TRAINLOG_BODY_METRIC_COUNT +} TrainlogBodyMetric; + +typedef struct TrainlogBodyMetricPoint { + char observed_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + double value; +} TrainlogBodyMetricPoint; + +typedef struct TrainlogBodyPairPoint { + bool found; + char observed_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + double left_value; + double right_value; +} TrainlogBodyPairPoint; + +TrainlogStatus trainlog_database_list_body_metric_points( + TrainlogDatabase *database, + TrainlogBodyMetric metric, + TrainlogBodyMetricPoint *output, + size_t capacity, + size_t *output_count +); + +TrainlogStatus trainlog_database_latest_body_pair( + TrainlogDatabase *database, + TrainlogBodyMetric left_metric, + TrainlogBodyMetric right_metric, + TrainlogBodyPairPoint *output +); + #endif diff --git a/tui/include/trainlog/duration.h b/tui/include/trainlog/duration.h new file mode 100644 index 0000000..9625312 --- /dev/null +++ b/tui/include/trainlog/duration.h @@ -0,0 +1,24 @@ +#ifndef TRAINLOG_DURATION_H +#define TRAINLOG_DURATION_H + +/** + * @file duration.h + * @brief Human duration parsing and formatting for Trainlog. + */ + +#include + +#include "trainlog/status.h" + +TrainlogStatus trainlog_duration_parse( + const char *text, + int *output_seconds +); + +TrainlogStatus trainlog_duration_format( + int seconds, + char *output, + size_t output_size +); + +#endif diff --git a/tui/meson.build b/tui/meson.build index c80fcd1..ff15a46 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -27,6 +27,7 @@ strict_c_args = [ trainlog_core_sources = files( 'src/catalog.c', 'src/database.c', + 'src/duration.c', 'src/id.c', 'src/timeutil.c', ) @@ -107,3 +108,20 @@ test( 'session_detail', test_session_detail, ) + +test_duration = executable( + 'test_duration', + 'tests/test_duration.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + +test_body_metrics = executable( + 'test_body_metrics', + 'tests/test_body_metrics.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + +test('duration', test_duration) +test('body_metrics', test_body_metrics) diff --git a/tui/src/database.c b/tui/src/database.c index bcbee51..65b42b0 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -4,6 +4,7 @@ */ #include "trainlog/database.h" +#include "trainlog/duration.h" #include #include @@ -1351,20 +1352,31 @@ static TrainlogStatus detail_fill_sets( } else if (has_duration != 0) { int duration = sqlite3_column_int(statement, 1); + char duration_text[64]; + + if (trainlog_duration_format( + duration, + duration_text, + sizeof(duration_text) + ) != TRAINLOG_STATUS_OK) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (has_weight != 0) { written = snprintf( fragment, sizeof(fragment), - "%ds@%.1f", - duration, + "%s@%.1f", + duration_text, sqlite3_column_double(statement, 2) ); } else { written = snprintf( fragment, sizeof(fragment), - "%ds", - duration + "%s", + duration_text ); } } else { @@ -1661,3 +1673,240 @@ TrainlogStatus trainlog_database_get_session_details( return TRAINLOG_STATUS_OK; } + +/* TRAINLOG_BODY_METRIC_HISTORY_IMPLEMENTATION */ + +static const char *body_metric_column(TrainlogBodyMetric metric) +{ + switch (metric) { + case TRAINLOG_BODY_METRIC_WEIGHT: + return "body_weight_kg"; + case TRAINLOG_BODY_METRIC_NECK: + return "neck_cm"; + case TRAINLOG_BODY_METRIC_SHOULDERS: + return "shoulders_cm"; + case TRAINLOG_BODY_METRIC_CHEST: + return "chest_cm"; + case TRAINLOG_BODY_METRIC_WAIST: + return "waist_cm"; + case TRAINLOG_BODY_METRIC_HIPS: + return "hips_cm"; + case TRAINLOG_BODY_METRIC_LEFT_ARM: + return "left_arm_cm"; + case TRAINLOG_BODY_METRIC_RIGHT_ARM: + return "right_arm_cm"; + case TRAINLOG_BODY_METRIC_LEFT_FOREARM: + return "left_forearm_cm"; + case TRAINLOG_BODY_METRIC_RIGHT_FOREARM: + return "right_forearm_cm"; + case TRAINLOG_BODY_METRIC_LEFT_THIGH: + return "left_thigh_cm"; + case TRAINLOG_BODY_METRIC_RIGHT_THIGH: + return "right_thigh_cm"; + case TRAINLOG_BODY_METRIC_LEFT_CALF: + return "left_calf_cm"; + case TRAINLOG_BODY_METRIC_RIGHT_CALF: + return "right_calf_cm"; + case TRAINLOG_BODY_METRIC_COUNT: + default: + return NULL; + } +} + +TrainlogStatus trainlog_database_list_body_metric_points( + TrainlogDatabase *database, + TrainlogBodyMetric metric, + TrainlogBodyMetricPoint *output, + size_t capacity, + size_t *output_count +) +{ + const char *column; + char sql[256]; + sqlite3_stmt *statement = NULL; + size_t count = 0U; + int written; + int rc; + + if (database == NULL || + database->connection == NULL || + output_count == NULL || + (capacity > 0U && output == NULL)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + column = body_metric_column(metric); + if (column == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + written = snprintf( + sql, + sizeof(sql), + "SELECT observed_at, %s " + "FROM body_observations " + "WHERE %s IS NOT NULL " + "ORDER BY observed_at ASC, id ASC;", + column, + column + ); + + if (written < 0 || + (size_t)written >= sizeof(sql)) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + rc = sqlite3_prepare_v2( + database->connection, + sql, + -1, + &statement, + NULL + ); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { + if (count < capacity) { + const unsigned char *observed = + sqlite3_column_text(statement, 0); + + if (observed == NULL) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + (void)snprintf( + output[count].observed_at, + sizeof(output[count].observed_at), + "%s", + (const char *)observed + ); + + output[count].value = + sqlite3_column_double(statement, 1); + } + + ++count; + } + + 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 = + count < capacity + ? count + : capacity; + + return TRAINLOG_STATUS_OK; +} + +TrainlogStatus trainlog_database_latest_body_pair( + TrainlogDatabase *database, + TrainlogBodyMetric left_metric, + TrainlogBodyMetric right_metric, + TrainlogBodyPairPoint *output +) +{ + const char *left_column; + const char *right_column; + char sql[384]; + sqlite3_stmt *statement = NULL; + int written; + int rc; + + if (database == NULL || + database->connection == NULL || + output == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + left_column = body_metric_column(left_metric); + right_column = body_metric_column(right_metric); + + if (left_column == NULL || + right_column == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + (void)memset(output, 0, sizeof(*output)); + + written = snprintf( + sql, + sizeof(sql), + "SELECT observed_at, %s, %s " + "FROM body_observations " + "WHERE %s IS NOT NULL AND %s IS NOT NULL " + "ORDER BY observed_at DESC, id DESC " + "LIMIT 1;", + left_column, + right_column, + left_column, + right_column + ); + + if (written < 0 || + (size_t)written >= sizeof(sql)) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + rc = sqlite3_prepare_v2( + database->connection, + sql, + -1, + &statement, + NULL + ); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_step(statement); + + if (rc == SQLITE_DONE) { + (void)sqlite3_finalize(statement); + output->found = false; + return TRAINLOG_STATUS_OK; + } + + if (rc != SQLITE_ROW) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + { + const unsigned char *observed = + sqlite3_column_text(statement, 0); + + if (observed == NULL) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + output->found = true; + + (void)snprintf( + output->observed_at, + sizeof(output->observed_at), + "%s", + (const char *)observed + ); + + output->left_value = + sqlite3_column_double(statement, 1); + + output->right_value = + sqlite3_column_double(statement, 2); + } + + return sqlite3_finalize(statement) == SQLITE_OK + ? TRAINLOG_STATUS_OK + : TRAINLOG_STATUS_DATABASE_ERROR; +} diff --git a/tui/src/duration.c b/tui/src/duration.c new file mode 100644 index 0000000..6e9e800 --- /dev/null +++ b/tui/src/duration.c @@ -0,0 +1,270 @@ +/** + * @file duration.c + * @brief Human duration parser/formatter implementation. + */ + +#include "trainlog/duration.h" + +#include +#include +#include +#include + +static bool ascii_space(char value) +{ + return value == ' ' || + value == '\t' || + value == '\n' || + value == '\r' || + value == '\f' || + value == '\v'; +} + +static bool ascii_digit(char value) +{ + return value >= '0' && value <= '9'; +} + +static bool parse_decimal_range( + const char *begin, + const char *end, + int *output +) +{ + long long value = 0; + const char *cursor; + + if (begin == NULL || + end == NULL || + output == NULL || + begin >= end) { + return false; + } + + for (cursor = begin; cursor < end; ++cursor) { + int digit; + + if (!ascii_digit(*cursor)) { + return false; + } + + digit = *cursor - '0'; + + if (value > ((long long)INT_MAX - digit) / 10LL) { + return false; + } + + value = (value * 10LL) + digit; + } + + *output = (int)value; + return true; +} + +static bool checked_minutes_seconds( + int minutes, + int seconds, + int *output +) +{ + long long total; + + if (minutes < 0 || + seconds < 0 || + seconds >= 60 || + output == NULL) { + return false; + } + + total = ((long long)minutes * 60LL) + seconds; + + if (total > (long long)INT_MAX) { + return false; + } + + *output = (int)total; + return true; +} + +TrainlogStatus trainlog_duration_parse( + const char *text, + int *output_seconds +) +{ + const char *begin; + const char *end; + const char *cursor; + const char *colon = NULL; + const char *minute_marker = NULL; + int value; + + if (text == NULL || output_seconds == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + begin = text; + while (*begin != '\0' && ascii_space(*begin)) { + ++begin; + } + + end = text + strlen(text); + while (end > begin && ascii_space(end[-1])) { + --end; + } + + if (begin == end || + (size_t)(end - begin) > 63U) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + for (cursor = begin; cursor < end; ++cursor) { + if (ascii_space(*cursor)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + if (*cursor == ':') { + if (colon != NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + colon = cursor; + } + + if (*cursor == 'm' || *cursor == 'M') { + if (minute_marker != NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + minute_marker = cursor; + } + } + + if (colon != NULL) { + int minutes; + int seconds; + + if (minute_marker != NULL || + !parse_decimal_range(begin, colon, &minutes) || + !parse_decimal_range(colon + 1, end, &seconds) || + !checked_minutes_seconds( + minutes, + seconds, + output_seconds + )) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + return TRAINLOG_STATUS_OK; + } + + if (minute_marker != NULL) { + int minutes; + int seconds = 0; + const char *seconds_begin = minute_marker + 1; + const char *seconds_end = end; + + if (!parse_decimal_range( + begin, + minute_marker, + &minutes + )) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + if (seconds_begin < end && + (end[-1] == 's' || end[-1] == 'S')) { + seconds_end = end - 1; + } + + if (seconds_begin < seconds_end) { + if (!parse_decimal_range( + seconds_begin, + seconds_end, + &seconds + )) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + } else if (seconds_begin < end) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + if (!checked_minutes_seconds( + minutes, + seconds, + output_seconds + )) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + return TRAINLOG_STATUS_OK; + } + + if (end[-1] == 's' || end[-1] == 'S') { + if (!parse_decimal_range( + begin, + end - 1, + &value + )) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + *output_seconds = value; + return TRAINLOG_STATUS_OK; + } + + if (!parse_decimal_range(begin, end, &value)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + *output_seconds = value; + return TRAINLOG_STATUS_OK; +} + +TrainlogStatus trainlog_duration_format( + int seconds, + char *output, + size_t output_size +) +{ + int minutes; + int remainder; + int written; + + if (seconds < 0 || + output == NULL || + output_size == 0U) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + minutes = seconds / 60; + remainder = seconds % 60; + + if (minutes == 0) { + written = snprintf( + output, + output_size, + "%d s", + remainder + ); + } else if (remainder == 0) { + written = snprintf( + output, + output_size, + "%d min", + minutes + ); + } else { + written = snprintf( + output, + output_size, + "%d min %d s", + minutes, + remainder + ); + } + + if (written < 0 || + (size_t)written >= output_size) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + return TRAINLOG_STATUS_OK; +} diff --git a/tui/src/tui.c b/tui/src/tui.c index 7642868..2710ff6 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -15,6 +15,7 @@ #include #include "trainlog/catalog.h" +#include "trainlog/duration.h" #include "trainlog/id.h" #include "trainlog/theme.h" #include "trainlog/timeutil.h" @@ -639,6 +640,377 @@ static bool choose_exercise( } } +/* TRAINLOG_DURATION_BODY_GRAPH_HELPERS */ + +#define MAX_BODY_METRIC_POINTS 256U + +typedef struct TrainlogBodyMetricView { + TrainlogBodyMetric metric; + const char *label; + const char *unit; +} TrainlogBodyMetricView; + +static const TrainlogBodyMetricView BODY_METRICS[] = { + {TRAINLOG_BODY_METRIC_WEIGHT, "Poids", "kg"}, + {TRAINLOG_BODY_METRIC_NECK, "Cou", "cm"}, + {TRAINLOG_BODY_METRIC_SHOULDERS, "Épaules", "cm"}, + {TRAINLOG_BODY_METRIC_CHEST, "Poitrine", "cm"}, + {TRAINLOG_BODY_METRIC_WAIST, "Tour de taille", "cm"}, + {TRAINLOG_BODY_METRIC_HIPS, "Hanches", "cm"}, + {TRAINLOG_BODY_METRIC_LEFT_ARM, "Bras gauche", "cm"}, + {TRAINLOG_BODY_METRIC_RIGHT_ARM, "Bras droit", "cm"}, + {TRAINLOG_BODY_METRIC_LEFT_FOREARM, "Avant-bras gauche", "cm"}, + {TRAINLOG_BODY_METRIC_RIGHT_FOREARM, "Avant-bras droit", "cm"}, + {TRAINLOG_BODY_METRIC_LEFT_THIGH, "Cuisse gauche", "cm"}, + {TRAINLOG_BODY_METRIC_RIGHT_THIGH, "Cuisse droite", "cm"}, + {TRAINLOG_BODY_METRIC_LEFT_CALF, "Mollet gauche", "cm"}, + {TRAINLOG_BODY_METRIC_RIGHT_CALF, "Mollet droit", "cm"} +}; + +static bool prompt_duration_value( + int row, + const char *label, + int minimum_seconds, + int maximum_seconds, + int default_seconds, + int *output_seconds +) +{ + char buffer[64]; + char default_text[64]; + char decorated[192]; + + if (trainlog_duration_format( + default_seconds, + default_text, + sizeof(default_text) + ) != TRAINLOG_STATUS_OK) { + return false; + } + + for (;;) { + int parsed; + + (void)snprintf( + decorated, + sizeof(decorated), + "%s [%s]: ", + label, + default_text + ); + + if (!prompt_text( + row, + decorated, + buffer, + sizeof(buffer), + true + )) { + return false; + } + + if (buffer[0] == '\0') { + *output_seconds = default_seconds; + return true; + } + + if (trainlog_duration_parse(buffer, &parsed) == + TRAINLOG_STATUS_OK && + parsed >= minimum_seconds && + parsed <= maximum_seconds) { + *output_seconds = parsed; + return true; + } + + status_line( + "Durée invalide : 90, 90s, 1:30, 1m30, 2m.", + TRAINLOG_COLOR_ERROR + ); + refresh(); + } +} + +static void draw_body_metric_graph( + int row, + int height, + const TrainlogBodyMetricPoint *points, + size_t count, + const char *unit +) +{ + double minimum; + double maximum; + size_t start; + size_t index; + int width; + + if (count == 0U) { + mvprintw(row, 4, "Aucune donnée pour cette mesure."); + return; + } + + width = COLS - 14; + if (width < 10 || height < 3) { + return; + } + + start = count > (size_t)width + ? count - (size_t)width + : 0U; + + minimum = points[start].value; + maximum = points[start].value; + + for (index = start + 1U; index < count; ++index) { + if (points[index].value < minimum) { + minimum = points[index].value; + } + if (points[index].value > maximum) { + maximum = points[index].value; + } + } + + if (maximum == minimum) { + int graph_row = row + (height / 2); + + mvprintw(graph_row, 2, "%.1f %s", minimum, unit); + + attron(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH)); + + for (index = start; index < count; ++index) { + int x = 12 + (int)(index - start); + + if (x < COLS - 2) { + mvaddch(graph_row, x, (chtype)'*'); + } + } + + attroff(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH)); + return; + } + + mvprintw(row, 2, "%.1f", maximum); + mvprintw(row + height - 1, 2, "%.1f", minimum); + + attron(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH)); + + for (index = start; index < count; ++index) { + double ratio = + (points[index].value - minimum) / + (maximum - minimum); + + int y = row + height - 1 - + (int)(ratio * (double)(height - 1)); + + int x = 10 + (int)(index - start); + + if (y < row) { + y = row; + } + if (y > row + height - 1) { + y = row + height - 1; + } + + if (x < COLS - 2) { + mvaddch(y, x, (chtype)'*'); + } + } + + attroff(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH)); +} + +static bool body_metric_pair( + TrainlogBodyMetric metric, + TrainlogBodyMetric *left, + TrainlogBodyMetric *right, + const char **label +) +{ + if (left == NULL || right == NULL || label == NULL) { + return false; + } + + switch (metric) { + case TRAINLOG_BODY_METRIC_LEFT_ARM: + case TRAINLOG_BODY_METRIC_RIGHT_ARM: + *left = TRAINLOG_BODY_METRIC_LEFT_ARM; + *right = TRAINLOG_BODY_METRIC_RIGHT_ARM; + *label = "Bras"; + return true; + + case TRAINLOG_BODY_METRIC_LEFT_FOREARM: + case TRAINLOG_BODY_METRIC_RIGHT_FOREARM: + *left = TRAINLOG_BODY_METRIC_LEFT_FOREARM; + *right = TRAINLOG_BODY_METRIC_RIGHT_FOREARM; + *label = "Avant-bras"; + return true; + + case TRAINLOG_BODY_METRIC_LEFT_THIGH: + case TRAINLOG_BODY_METRIC_RIGHT_THIGH: + *left = TRAINLOG_BODY_METRIC_LEFT_THIGH; + *right = TRAINLOG_BODY_METRIC_RIGHT_THIGH; + *label = "Cuisses"; + return true; + + case TRAINLOG_BODY_METRIC_LEFT_CALF: + case TRAINLOG_BODY_METRIC_RIGHT_CALF: + *left = TRAINLOG_BODY_METRIC_LEFT_CALF; + *right = TRAINLOG_BODY_METRIC_RIGHT_CALF; + *label = "Mollets"; + return true; + + default: + return false; + } +} + +static void add_body_observation(TrainlogDatabase *database) +{ + TrainlogBodyObservationInput observation; + char id[TRAINLOG_GENERATED_ID_CAPACITY]; + char timestamp[TRAINLOG_TIMESTAMP_MAX + 1U]; + int row; + + (void)memset(&observation, 0, sizeof(observation)); + + if (trainlog_id_generate("bo", id, sizeof(id)) != TRAINLOG_STATUS_OK || + trainlog_time_now_rfc3339( + timestamp, + sizeof(timestamp) + ) != TRAINLOG_STATUS_OK) { + return; + } + + (void)snprintf( + observation.observation_id, + sizeof(observation.observation_id), + "%s", + id + ); + + (void)snprintf( + observation.observed_at, + sizeof(observation.observed_at), + "%s", + timestamp + ); + + draw_shell( + "Nouvelle mesure — 1/2", + "Laissez vide les mesures non faites aujourd'hui" + ); + + row = 4; + +#define BODY_PROMPT(label_, flag_, value_) \ + do { \ + (void)prompt_optional_double( \ + row++, \ + (label_), \ + &(flag_), \ + &(value_) \ + ); \ + } while (0) + + BODY_PROMPT( + "Poids kg : ", + observation.has_body_weight, + observation.body_weight_kg + ); + BODY_PROMPT( + "Cou cm : ", + observation.has_neck, + observation.neck_cm + ); + BODY_PROMPT( + "Épaules cm : ", + observation.has_shoulders, + observation.shoulders_cm + ); + BODY_PROMPT( + "Poitrine cm : ", + observation.has_chest, + observation.chest_cm + ); + BODY_PROMPT( + "Tour de taille cm : ", + observation.has_waist, + observation.waist_cm + ); + BODY_PROMPT( + "Hanches cm : ", + observation.has_hips, + observation.hips_cm + ); + + draw_shell( + "Nouvelle mesure — 2/2", + "Laissez vide les mesures non faites aujourd'hui" + ); + + row = 4; + + BODY_PROMPT( + "Bras gauche cm : ", + observation.has_left_arm, + observation.left_arm_cm + ); + BODY_PROMPT( + "Bras droit cm : ", + observation.has_right_arm, + observation.right_arm_cm + ); + BODY_PROMPT( + "Avant-bras gauche cm : ", + observation.has_left_forearm, + observation.left_forearm_cm + ); + BODY_PROMPT( + "Avant-bras droit cm : ", + observation.has_right_forearm, + observation.right_forearm_cm + ); + BODY_PROMPT( + "Cuisse gauche cm : ", + observation.has_left_thigh, + observation.left_thigh_cm + ); + BODY_PROMPT( + "Cuisse droite cm : ", + observation.has_right_thigh, + observation.right_thigh_cm + ); + BODY_PROMPT( + "Mollet gauche cm : ", + observation.has_left_calf, + observation.left_calf_cm + ); + BODY_PROMPT( + "Mollet droit cm : ", + observation.has_right_calf, + observation.right_calf_cm + ); + +#undef BODY_PROMPT + + if (trainlog_database_insert_body_observation( + database, + &observation + ) == TRAINLOG_STATUS_OK) { + status_line( + "✓ Mesures enregistrées.", + TRAINLOG_COLOR_SUCCESS + ); + } else { + status_line( + "Aucune mesure valide enregistrée.", + TRAINLOG_COLOR_WARNING + ); + } + + wait_key(); +} + static bool build_session_exercise( TrainlogDatabase *database, TrainlogSessionExerciseInput *output, @@ -649,19 +1021,26 @@ static bool build_session_exercise( TrainlogExercise exercise; int load_mode = 1; int target_sets = 3; - int target_metric = 10; + int target_metric; int rest_seconds = 60; int actual_sets; - size_t set_index; bool target_has_weight = false; double target_weight = 0.0; + size_t set_index; if (!choose_exercise(database, &exercise)) { return false; } - erase(); - title(exercise.name); + target_metric = + exercise.tracking_mode == TRAINLOG_TRACKING_REPS + ? 10 + : 45; + + draw_shell( + exercise.name, + "Durées : 90, 90s, 1:30, 1m30, 2m" + ); if (!prompt_int_value( 4, @@ -687,7 +1066,7 @@ static bool build_session_exercise( if (!prompt_int_value( 6, - "Nombre de séries prévues", + "Séries prévues", 1, (int)set_capacity, 3, @@ -696,22 +1075,33 @@ static bool build_session_exercise( return false; } - if (!prompt_int_value( - 7, - exercise.tracking_mode == TRAINLOG_TRACKING_REPS - ? "Répétitions cibles" - : "Durée cible (secondes)", - 1, - 10000, - exercise.tracking_mode == TRAINLOG_TRACKING_REPS ? 10 : 45, - &target_metric - )) { - return false; + if (exercise.tracking_mode == TRAINLOG_TRACKING_REPS) { + if (!prompt_int_value( + 7, + "Répétitions cibles", + 1, + 10000, + target_metric, + &target_metric + )) { + return false; + } + } else { + if (!prompt_duration_value( + 7, + "Durée cible", + 1, + 86400, + target_metric, + &target_metric + )) { + return false; + } } - if (!prompt_int_value( + if (!prompt_duration_value( 8, - "Repos prévu (secondes)", + "Repos prévu", 0, 86400, 60, @@ -732,28 +1122,34 @@ static bool build_session_exercise( } (void)memset(output, 0, sizeof(*output)); + (void)snprintf( output->exercise_id, sizeof(output->exercise_id), "%s", exercise.exercise_id ); + output->load_mode = load_mode == 1 ? TRAINLOG_LOAD_NONE : (load_mode == 2 ? TRAINLOG_LOAD_EXTERNAL : TRAINLOG_LOAD_ASSISTANCE); + output->rest_seconds = rest_seconds; output->target_sets = target_sets; + output->target_reps = exercise.tracking_mode == TRAINLOG_TRACKING_REPS ? target_metric : 0; + output->target_duration_seconds = exercise.tracking_mode == TRAINLOG_TRACKING_DURATION ? target_metric : 0; + output->target_has_weight = target_has_weight; output->target_weight_kg = target_weight; output->sets = set_storage; @@ -761,78 +1157,91 @@ static bool build_session_exercise( for (set_index = 0U; set_index < output->set_count; ++set_index) { int actual_metric = target_metric; - char label[128]; - (void)memset(&set_storage[set_index], 0, sizeof(set_storage[set_index])); + (void)memset( + &set_storage[set_index], + 0, + sizeof(set_storage[set_index]) + ); + + draw_shell( + exercise.name, + "Durées : 90, 90s, 1:30, 1m30, 2m" + ); - erase(); - title(exercise.name); mvprintw( 3, - 2, + 4, "Série %zu / %zu", set_index + 1U, output->set_count ); - (void)snprintf( - label, - sizeof(label), - "%s réalisé", - exercise.tracking_mode == TRAINLOG_TRACKING_REPS - ? "Répétitions" - : "Durée (secondes)" - ); - - if (!prompt_int_value( - 5, - label, - exercise.tracking_mode == TRAINLOG_TRACKING_REPS ? 0 : 1, - 10000, - target_metric, - &actual_metric - )) { - return false; - } - if (exercise.tracking_mode == TRAINLOG_TRACKING_REPS) { + if (!prompt_int_value( + 5, + "Répétitions réalisées", + 0, + 10000, + target_metric, + &actual_metric + )) { + return false; + } + set_storage[set_index].reps = actual_metric; } else { + if (!prompt_duration_value( + 5, + "Durée réalisée", + 1, + 86400, + target_metric, + &actual_metric + )) { + return false; + } + set_storage[set_index].duration_seconds = actual_metric; } if (target_has_weight) { - bool has_weight = false; - double weight = target_weight; + char buffer[64]; char prompt[128]; + double actual_weight = target_weight; (void)snprintf( prompt, sizeof(prompt), - "Charge kg [%.1f, vide = cible] : ", + "Charge kg [%.1f] : ", target_weight ); - { - char buffer[64]; - if (!prompt_text(6, prompt, buffer, sizeof(buffer), true)) { - return false; - } - - if (buffer[0] == '\0') { - has_weight = true; - weight = target_weight; - } else if (parse_double_positive(buffer, &weight)) { - has_weight = true; - } else { - status_line("Charge invalide.", TRAINLOG_COLOR_ERROR); - wait_key(); - return false; - } + if (!prompt_text( + 6, + prompt, + buffer, + sizeof(buffer), + true + )) { + return false; } - set_storage[set_index].has_weight = has_weight; - set_storage[set_index].weight_kg = weight; + if (buffer[0] != '\0' && + !parse_double_positive( + buffer, + &actual_weight + )) { + status_line( + "Charge invalide.", + TRAINLOG_COLOR_ERROR + ); + wait_key(); + return false; + } + + set_storage[set_index].has_weight = true; + set_storage[set_index].weight_kg = actual_weight; } } @@ -997,10 +1406,7 @@ static void screen_session_detail( ); if (status != TRAINLOG_STATUS_OK) { - draw_shell( - "Détail séance", - "Une touche pour revenir" - ); + draw_shell("Détail séance", "Une touche pour revenir"); status_line( "Impossible de charger la séance.", TRAINLOG_COLOR_ERROR @@ -1017,12 +1423,7 @@ static void screen_session_detail( "←→ ou ↑↓ exercice précédent/suivant b/Échap retour" ); - mvprintw( - 3, - 4, - "Début : %s", - session.started_at - ); + mvprintw(3, 4, "Début : %s", session.started_at); mvprintw( 4, @@ -1034,20 +1435,40 @@ static void screen_session_detail( ); if (count == 0U) { - mvprintw( - 7, - 4, - "Aucun exercice dans cette séance." - ); + mvprintw(7, 4, "Aucun exercice dans cette séance."); } else { TrainlogPersistedExerciseDetail *exercise = &exercises[selected]; + char rest_text[64]; + char target_duration_text[64]; + + if (trainlog_duration_format( + exercise->rest_seconds, + rest_text, + sizeof(rest_text) + ) != TRAINLOG_STATUS_OK) { + (void)snprintf( + rest_text, + sizeof(rest_text), + "%ds", + exercise->rest_seconds + ); + } + + target_duration_text[0] = '\0'; + + if (exercise->tracking_mode == TRAINLOG_TRACKING_DURATION) { + (void)trainlog_duration_format( + exercise->target_duration_seconds, + target_duration_text, + sizeof(target_duration_text) + ); + } + attron( A_BOLD | - trainlog_theme_attribute( - TRAINLOG_COLOR_ACCENT - ) + trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT) ); mvprintw( @@ -1061,27 +1482,21 @@ static void screen_session_detail( attroff( A_BOLD | - trainlog_theme_attribute( - TRAINLOG_COLOR_ACCENT - ) + trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT) ); mvprintw( 8, 4, - "Mode : %-12s Charge : %-10s Repos : %ds", - exercise->tracking_mode == - TRAINLOG_TRACKING_REPS + "Mode : %-12s Charge : %-10s Repos : %s", + exercise->tracking_mode == TRAINLOG_TRACKING_REPS ? "répétitions" : "durée", - session_detail_load_label( - exercise->load_mode - ), - exercise->rest_seconds + session_detail_load_label(exercise->load_mode), + rest_text ); - if (exercise->tracking_mode == - TRAINLOG_TRACKING_REPS) { + if (exercise->tracking_mode == TRAINLOG_TRACKING_REPS) { mvprintw( 10, 4, @@ -1093,9 +1508,9 @@ static void screen_session_detail( mvprintw( 10, 4, - "Cible : %d série(s) × %ds", + "Cible : %d série(s) × %s", exercise->target_sets, - exercise->target_duration_seconds + target_duration_text ); } @@ -1107,18 +1522,12 @@ static void screen_session_detail( exercise->target_weight_kg ); } else { - mvprintw( - 11, - 4, - "Charge cible : —" - ); + mvprintw(11, 4, "Charge cible : —"); } attron( A_BOLD | - trainlog_theme_attribute( - TRAINLOG_COLOR_SUCCESS - ) + trainlog_theme_attribute(TRAINLOG_COLOR_SUCCESS) ); mvprintw( @@ -1130,9 +1539,7 @@ static void screen_session_detail( attroff( A_BOLD | - trainlog_theme_attribute( - TRAINLOG_COLOR_SUCCESS - ) + trainlog_theme_attribute(TRAINLOG_COLOR_SUCCESS) ); mvprintw( @@ -1143,24 +1550,17 @@ static void screen_session_detail( exercise->actual_summary ); - if (exercise->load_mode == - TRAINLOG_LOAD_ASSISTANCE) { + if (exercise->load_mode == TRAINLOG_LOAD_ASSISTANCE) { attron( - trainlog_theme_attribute( - TRAINLOG_COLOR_WARNING - ) + trainlog_theme_attribute(TRAINLOG_COLOR_WARNING) ); - mvprintw( 17, 4, "Assistance : plus de kg = davantage d'aide." ); - attroff( - trainlog_theme_attribute( - TRAINLOG_COLOR_WARNING - ) + trainlog_theme_attribute(TRAINLOG_COLOR_WARNING) ); } } @@ -1173,15 +1573,13 @@ static void screen_session_detail( } if (count > 0U && - (key == KEY_RIGHT || - key == KEY_DOWN)) { + (key == KEY_RIGHT || key == KEY_DOWN)) { selected = selected + 1U < count ? selected + 1U : 0U; } else if (count > 0U && - (key == KEY_LEFT || - key == KEY_UP)) { + (key == KEY_LEFT || key == KEY_UP)) { selected = selected > 0U ? selected - 1U @@ -1306,52 +1704,135 @@ static void screen_history(TrainlogDatabase *database) static void screen_body(TrainlogDatabase *database) { + size_t selected = 0U; + const size_t metric_count = + sizeof(BODY_METRICS) / sizeof(BODY_METRICS[0]); + for (;;) { - TrainlogWeightPoint points[MAX_WEIGHT_POINTS]; + const TrainlogBodyMetricView *view = + &BODY_METRICS[selected]; + + TrainlogBodyMetricPoint points[MAX_BODY_METRIC_POINTS]; size_t count = 0U; size_t start; size_t index; int key; - (void)trainlog_database_list_weight_points( + (void)trainlog_database_list_body_metric_points( database, + view->metric, points, - MAX_WEIGHT_POINTS, + MAX_BODY_METRIC_POINTS, &count ); draw_shell( "TRAINLOG — Corps", - "a ajouter une mesure b/Échap retour" + "←→ métrique a ajouter des mesures b/Échap retour" + ); + + attron( + A_BOLD | + trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT) + ); + + mvprintw( + 3, + 4, + "%s [%zu/%zu]", + view->label, + selected + 1U, + metric_count + ); + + attroff( + A_BOLD | + trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT) ); if (count > 0U) { - double latest = points[count - 1U].body_weight_kg; - double delta = latest - points[0].body_weight_kg; + double first = points[0].value; + double latest = points[count - 1U].value; + double delta = latest - first; mvprintw( - 3, 4, - "Poids actuel : %.1f kg évolution : %+.1f kg", + 4, + "Actuel : %.1f %s Départ : %.1f %s Évolution : %+.1f %s", latest, - delta + view->unit, + first, + view->unit, + delta, + view->unit ); } else { - mvprintw(3, 4, "Aucune mesure de poids."); + mvprintw(4, 4, "Aucune valeur enregistrée."); } - draw_weight_sparkline(5, points, count); + { + TrainlogBodyMetric left; + TrainlogBodyMetric right; + const char *pair_label; - mvprintw(12, 4, "Dernières pesées :"); - start = count > 5U ? count - 5U : 0U; + if (body_metric_pair( + view->metric, + &left, + &right, + &pair_label + )) { + TrainlogBodyPairPoint pair; + + if (trainlog_database_latest_body_pair( + database, + left, + right, + &pair + ) == TRAINLOG_STATUS_OK && + pair.found) { + double difference = + pair.right_value - pair.left_value; + + mvprintw( + 5, + 4, + "%s : G %.1f cm D %.1f cm écart %.1f cm %s", + pair_label, + pair.left_value, + pair.right_value, + difference < 0.0 + ? -difference + : difference, + difference > 0.0 + ? "à droite" + : (difference < 0.0 + ? "à gauche" + : "équilibré") + ); + } + } + } + + draw_body_metric_graph( + 7, + 5, + points, + count, + view->unit + ); + + mvprintw(13, 4, "Dernières valeurs :"); + + start = count > 3U ? count - 3U : 0U; for (index = start; index < count; ++index) { mvprintw( - 13 + (int)(index - start), + 14 + (int)(index - start), 6, - "%-25s %.1f kg", + "%-25s %.1f %s", points[index].observed_at, - points[index].body_weight_kg + points[index].value, + view->unit ); } @@ -1362,120 +1843,18 @@ static void screen_body(TrainlogDatabase *database) return; } - if (key == 'a') { - TrainlogBodyObservationInput observation; - char id[TRAINLOG_GENERATED_ID_CAPACITY]; - char timestamp[TRAINLOG_TIMESTAMP_MAX + 1U]; - int row = 4; - - (void)memset(&observation, 0, sizeof(observation)); - - if (trainlog_id_generate( - "bo", - id, - sizeof(id) - ) != TRAINLOG_STATUS_OK || - trainlog_time_now_rfc3339( - timestamp, - sizeof(timestamp) - ) != TRAINLOG_STATUS_OK) { - continue; - } - - (void)snprintf( - observation.observation_id, - sizeof(observation.observation_id), - "%s", - id - ); - (void)snprintf( - observation.observed_at, - sizeof(observation.observed_at), - "%s", - timestamp - ); - - draw_shell( - "Nouvelle mesure", - "Laissez vide les valeurs non mesurées" - ); - -#define BODY_PROMPT(label_, flag_, value_) \ - do { \ - (void)prompt_optional_double( \ - row++, \ - (label_), \ - &(flag_), \ - &(value_) \ - ); \ - } while (0) - - BODY_PROMPT( - "Poids kg : ", - observation.has_body_weight, - observation.body_weight_kg - ); - BODY_PROMPT( - "Tour de taille cm : ", - observation.has_waist, - observation.waist_cm - ); - BODY_PROMPT( - "Poitrine cm : ", - observation.has_chest, - observation.chest_cm - ); - BODY_PROMPT( - "Épaules cm : ", - observation.has_shoulders, - observation.shoulders_cm - ); - BODY_PROMPT( - "Bras gauche cm : ", - observation.has_left_arm, - observation.left_arm_cm - ); - BODY_PROMPT( - "Bras droit cm : ", - observation.has_right_arm, - observation.right_arm_cm - ); - BODY_PROMPT( - "Cuisse gauche cm : ", - observation.has_left_thigh, - observation.left_thigh_cm - ); - BODY_PROMPT( - "Cuisse droite cm : ", - observation.has_right_thigh, - observation.right_thigh_cm - ); - BODY_PROMPT( - "Mollet gauche cm : ", - observation.has_left_calf, - observation.left_calf_cm - ); - BODY_PROMPT( - "Mollet droit cm : ", - observation.has_right_calf, - observation.right_calf_cm - ); - -#undef BODY_PROMPT - - if (trainlog_database_insert_body_observation( - database, - &observation - ) == TRAINLOG_STATUS_OK) { - status_line("✓ Mesures enregistrées.", TRAINLOG_COLOR_SUCCESS); - } else { - status_line( - "Aucune mesure valide enregistrée.", - TRAINLOG_COLOR_WARNING - ); - } - - wait_key(); + if (key == KEY_RIGHT) { + selected = + selected + 1U < metric_count + ? selected + 1U + : 0U; + } else if (key == KEY_LEFT) { + selected = + selected > 0U + ? selected - 1U + : metric_count - 1U; + } else if (key == 'a') { + add_body_observation(database); } } } diff --git a/tui/tests/test_body_metrics.c b/tui/tests/test_body_metrics.c new file mode 100644 index 0000000..e520f2e --- /dev/null +++ b/tui/tests/test_body_metrics.c @@ -0,0 +1,104 @@ +/** + * @file test_body_metrics.c + * @brief Generic body-metric 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 bool test_body_metrics(void) +{ + TrainlogDatabase *database = NULL; + TrainlogBodyObservationInput observation; + TrainlogBodyMetricPoint points[8]; + TrainlogBodyPairPoint pair; + size_t count = 0U; + + CHECK( + trainlog_database_open(":memory:", &database) == + TRAINLOG_STATUS_OK + ); + + (void)memset(&observation, 0, sizeof(observation)); + + (void)snprintf( + observation.observation_id, + sizeof(observation.observation_id), + "%s", + "bo_metrics" + ); + + (void)snprintf( + observation.observed_at, + sizeof(observation.observed_at), + "%s", + "2026-09-05T08:00:00+02:00" + ); + + observation.has_body_weight = true; + observation.body_weight_kg = 84.1; + observation.has_waist = true; + observation.waist_cm = 96.4; + observation.has_left_arm = true; + observation.left_arm_cm = 34.2; + observation.has_right_arm = true; + observation.right_arm_cm = 34.8; + + CHECK( + trainlog_database_insert_body_observation( + database, + &observation + ) == TRAINLOG_STATUS_OK + ); + + CHECK( + trainlog_database_list_body_metric_points( + database, + TRAINLOG_BODY_METRIC_WAIST, + points, + 8U, + &count + ) == TRAINLOG_STATUS_OK + ); + + CHECK(count == 1U); + CHECK(points[0].value > 96.39); + CHECK(points[0].value < 96.41); + + CHECK( + trainlog_database_latest_body_pair( + database, + TRAINLOG_BODY_METRIC_LEFT_ARM, + TRAINLOG_BODY_METRIC_RIGHT_ARM, + &pair + ) == TRAINLOG_STATUS_OK + ); + + CHECK(pair.found); + CHECK(pair.left_value > 34.19); + CHECK(pair.left_value < 34.21); + CHECK(pair.right_value > 34.79); + CHECK(pair.right_value < 34.81); + + trainlog_database_close(database); + return true; +} + +int main(void) +{ + CHECK(test_body_metrics()); + (void)printf("PASS body_metrics\n"); + return 0; +} diff --git a/tui/tests/test_duration.c b/tui/tests/test_duration.c new file mode 100644 index 0000000..368d521 --- /dev/null +++ b/tui/tests/test_duration.c @@ -0,0 +1,112 @@ +/** + * @file test_duration.c + * @brief Human duration parser/formatter tests. + */ + +#include +#include +#include + +#include "trainlog/duration.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 bool expect_parse(const char *text, int expected) +{ + int seconds = -1; + + CHECK( + trainlog_duration_parse(text, &seconds) == + TRAINLOG_STATUS_OK + ); + CHECK(seconds == expected); + return true; +} + +static bool test_parsing(void) +{ + int scratch = 0; + + CHECK(expect_parse("90", 90)); + CHECK(expect_parse("90s", 90)); + CHECK(expect_parse("1:30", 90)); + CHECK(expect_parse("1m30", 90)); + CHECK(expect_parse("1m30s", 90)); + CHECK(expect_parse("2m", 120)); + CHECK(expect_parse("45s", 45)); + CHECK(expect_parse("2:05", 125)); + CHECK(expect_parse(" 2m ", 120)); + + CHECK( + trainlog_duration_parse("1:60", &scratch) == + TRAINLOG_STATUS_INVALID_ARGUMENT + ); + CHECK( + trainlog_duration_parse("1m90", &scratch) == + TRAINLOG_STATUS_INVALID_ARGUMENT + ); + CHECK( + trainlog_duration_parse("1 m 30", &scratch) == + TRAINLOG_STATUS_INVALID_ARGUMENT + ); + CHECK( + trainlog_duration_parse("", &scratch) == + TRAINLOG_STATUS_INVALID_ARGUMENT + ); + + return true; +} + +static bool test_formatting(void) +{ + char buffer[64]; + + CHECK( + trainlog_duration_format(45, buffer, sizeof(buffer)) == + TRAINLOG_STATUS_OK + ); + CHECK(strcmp(buffer, "45 s") == 0); + + CHECK( + trainlog_duration_format(60, buffer, sizeof(buffer)) == + TRAINLOG_STATUS_OK + ); + CHECK(strcmp(buffer, "1 min") == 0); + + CHECK( + trainlog_duration_format(90, buffer, sizeof(buffer)) == + TRAINLOG_STATUS_OK + ); + CHECK(strcmp(buffer, "1 min 30 s") == 0); + + CHECK( + trainlog_duration_format(125, buffer, sizeof(buffer)) == + TRAINLOG_STATUS_OK + ); + CHECK(strcmp(buffer, "2 min 5 s") == 0); + + return true; +} + +int main(void) +{ + CHECK(test_parsing()); + (void)printf("PASS duration_parsing\n"); + + CHECK(test_formatting()); + (void)printf("PASS duration_formatting\n"); + + return 0; +}