diff --git a/CHANGELOG.md b/CHANGELOG.md index 19a013d..6bb31b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,22 +8,37 @@ All notable changes to Trainlog will be documented in this file. - Frozen Trainlog JSON v1 contract. - SQLite persistence foundation. -- UUIDv4 Trainlog identifiers. -- Frozen Unicode exercise-name normalization using utf8proc. -- Direct exercise creation. -- Direct workout recording in the TUI. -- Automatic workout start/end timestamps. -- Repetition and timed exercises. -- None/external/assistance load entry. -- Per-set actual performance entry. -- Planned rest entry. -- Body weight and common body measurement entry. -- Session history. -- Colorful ncursesw dashboard. -- Compact terminal body-weight sparkline. -- Catalog and persistence tests. +- Direct workout entry. +- Exercise catalog. +- Body tracking. +- Colored ncursesw dashboard. +- Body-weight graph. +- Arrow-key and F1-F4 navigation. +- Highlighted list selections. +- Navigable session history. ### Changed -- Development switches to larger vertical slices for the Monday 2026-09-07 05:00 usable-version target. -- Minimal TUI work is pulled forward before Android so a functional fallback exists. +- Exercise selection now uses interactive keyboard navigation. +- Body tracking is now a persistent history screen instead of entry-only. +- TUI polish is prioritized before Android development. + + +### TUI v0.2 checkpoint + +Added: + +- bordered colorful dashboard; +- arrow-key and F-key navigation; +- weight history visualization; +- flat-series weight graph handling; +- navigable session history; +- complete read-only workout details. + +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. + diff --git a/docs/reviews/tui_session_details.md b/docs/reviews/tui_session_details.md new file mode 100644 index 0000000..dd30662 --- /dev/null +++ b/docs/reviews/tui_session_details.md @@ -0,0 +1,24 @@ +# TUI Session Details + +## Status + +```text +TUI_SESSION_DETAILS=IMPLEMENTED +``` + +The History screen now opens a complete read-only workout view. + +For each exercise the TUI displays: + +- name; +- repetitions or duration mode; +- load mode; +- planned rest; +- planned sets and reps/duration; +- target load; +- actual set count; +- ordered actual sets and per-set loads. + +Navigation inside a workout uses left/right or up/down. + +No database migration is required. diff --git a/docs/reviews/tui_v0_2_checkpoint.md b/docs/reviews/tui_v0_2_checkpoint.md new file mode 100644 index 0000000..20b6e65 --- /dev/null +++ b/docs/reviews/tui_v0_2_checkpoint.md @@ -0,0 +1,112 @@ +# TUI v0.2 Checkpoint + +## Status + +```text +FIRST_USABLE_TUI=PASS +TUI_V0_2_POLISH=IMPLEMENTED +TUI_SESSION_DETAILS=IMPLEMENTED +GATE_2=IN_PROGRESS +TRAINLOG_FORMAT_V1=FROZEN +``` + +## Purpose + +This checkpoint records the first Trainlog interface that is already usable as +a local workout journal while development continues. + +Android is intentionally not required for this checkpoint. + +## Implemented user-facing functionality + +### Dashboard + +- colored centralized theme; +- arrow-key navigation; +- F1/F2/F3/F4 shortcuts; +- session and exercise counts; +- latest weight; +- weight delta; +- terminal weight graph; +- sensible flat-series rendering. + +### Workout entry + +- exercise selection; +- repetitions or timed exercises; +- none/external/assistance load modes; +- target sets; +- target repetitions/duration; +- target load; +- planned rest; +- performed sets; +- actual per-set load; +- automatic start/end timestamps. + +### Workout history + +- navigable session list; +- complete session detail; +- exercise-by-exercise browsing; +- target versus actual work; +- ordered actual-set summary. + +### Body + +- body observation persistence; +- weight entry; +- measurement entry; +- initial weight visualization. + +## Next frozen implementation contract + +### Human durations + +Accepted forms: + +```text +90 +90s +1:30 +1m30 +1m30s +2m +``` + +Storage remains seconds. + +Display becomes human-readable minutes and seconds. + +### F4 Body graphs + +Every stored body metric becomes selectable and graphable. + +Paired measurements additionally display left/right asymmetry. + +## Validation required before checkpoint 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 +``` + +The sanitizer build remains required when the current `build-asan` directory +matches the latest source tree: + +```bash +meson compile -C build-asan +meson test -C build-asan --print-errorlogs +``` + +## Next checkpoint + +```text +TUI_DURATION_HUMAN_INPUT +TUI_BODY_METRIC_GRAPHS +``` diff --git a/docs/reviews/tui_v0_2_polish.md b/docs/reviews/tui_v0_2_polish.md new file mode 100644 index 0000000..d712ffe --- /dev/null +++ b/docs/reviews/tui_v0_2_polish.md @@ -0,0 +1,28 @@ +# TUI v0.2 Polish + +## Status + +```text +TUI_V0_2_POLISH=IMPLEMENTED +``` + +## Delivered + +- bordered screen shell; +- persistent footer help; +- arrow-key dashboard navigation; +- F1/F2/F3/F4 shortcuts; +- highlighted current selection; +- graphical body-weight history; +- body history screen with recent weigh-ins; +- arrow-key exercise selection; +- arrow-key exercise catalog navigation; +- arrow-key session-history navigation; +- quick session preview. + +## Deferred to the next TUI polish block + +- full exercise-by-exercise session detail; +- exercise performance graphs; +- reusable previous-session defaults; +- safe editing/deletion. diff --git a/docs/roadmap.md b/docs/roadmap.md index e9d7854..34af50a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,106 +4,82 @@ Status: PASS -```text -GATE_0=PASS -``` - ## Gate 1 — Exchange format v1 freeze Status: PASS ```text -GATE_1=PASS TRAINLOG_FORMAT_V1=FROZEN ``` -## Gate 2 / Weekend MVP — Usable persistence + direct entry +## Gate 2 — Persistence + usable TUI Status: IN PROGRESS -Current vertical slice: +```text +FIRST_USABLE_TUI=PASS +TUI_V0_2_POLISH=IMPLEMENTED +GATE_2=IN_PROGRESS +``` + +Current TUI capabilities: + +- direct workout entry; +- exercise catalog; +- body tracking; +- weight graph; +- colored dashboard; +- arrow/F-key navigation; +- navigable history; +- Unicode anti-duplicate exercise names. + +Next TUI polish blocks: + +1. full session detail; +2. exercise performance history and graphs; +3. previous-session defaults; +4. safe edit/delete flows. + +Android remains deferred until the TUI daily workflow is satisfactory. + + +## TUI v0.2 checkpoint + +Current state: ```text -WEEKEND_MVP_TUI=IMPLEMENTED +FIRST_USABLE_TUI=PASS +TUI_V0_2_POLISH=IMPLEMENTED +TUI_SESSION_DETAILS=IMPLEMENTED +TUI_DURATION_HUMAN_INPUT=NEXT +TUI_BODY_METRIC_GRAPHS=NEXT GATE_2=IN_PROGRESS -DATABASE_SCHEMA_V1=DRAFT +TRAINLOG_FORMAT_V1=FROZEN ``` -Delivered in this slice: +Completed before this checkpoint: -- SQLite persistence foundation; -- UUIDv4 creation; -- frozen Unicode exercise-name normalization; -- direct exercise creation; -- direct session creation; -- automatic start/end timestamps; -- reps/duration tracking; -- none/external/assistance load modes; -- actual set recording; -- planned rest; -- standalone body measurements; -- session history; -- dashboard; -- colored ncursesw interface; -- weight sparkline. +- C17/Meson persistence core; +- SQLite schema v1 foundation; +- UUIDv4 generation; +- Unicode catalog normalization; +- direct workout recording; +- direct body observation recording; +- colored ncursesw dashboard; +- keyboard navigation; +- body-weight graph; +- flat-series graph rendering; +- navigable workout history; +- full read-only session detail. -Validation required before push: +Next implementation slice: -```bash -python tools/validate_json.py -python tools/validate_import_contract.py +1. shared duration parser accepting seconds and minute-oriented syntax; +2. shared human duration formatter; +3. generic body-metric history query; +4. F4 metric selector; +5. graphs for weight and every body measurement; +6. left/right asymmetry presentation. -CC=clang meson setup build -meson compile -C build -meson test -C build --print-errorlogs - -CC=clang meson setup build-asan \ - -Db_sanitize=address,undefined \ - -Db_lundef=false -meson compile -C build-asan -meson test -C build-asan --print-errorlogs - -git diff --check -``` - -Next vertical slice: - -- Android v0.1 recorder; -- JSON v1 export; -- C17 JSON importer; -- catalog reconciliation; -- idempotent import. - -Gate 2 remains open until import transactions and idempotency are complete. - -## Gate 3 — TUI polish - -The minimal colored TUI has been pulled forward for the Monday usability target. - -Gate 3 later adds: - -- richer navigation; -- session details; -- editing; -- more graphs; -- advanced layout polish. - -## Gate 4 — Android recorder - -Pulled forward immediately after Weekend MVP TUI. - -## Gate 5 — Analytics - -- body-weight trends; -- measurement trends; -- exercise performance; -- volume; -- max/estimated max; -- balance analysis. - -## Gate 6 — Hardening - -- migrations; -- packaging; -- broader tests; -- first tagged release. +No incompatible change to frozen Trainlog JSON v1 is required. + diff --git a/docs/tui.md b/docs/tui.md index d3769c5..a328214 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -188,3 +188,203 @@ se_ ``` The database stores these identifiers as opaque stable text. + + +## 16. Current usable TUI checkpoint + +The first usable ncurses interface is implemented. + +Current daily-use flow: + +```text +Dashboard + -> New session + -> History + -> Exercises + -> Body +``` + +Implemented interaction: + +- colored centralized theme; +- bordered screens; +- arrow-key navigation; +- `Enter` activation; +- `F1` new session; +- `F2` history; +- `F3` exercises; +- `F4` body; +- `q` quit; +- minimum terminal fallback at `72x20`. + +The dashboard shows: + +- session count; +- exercise count; +- latest body weight; +- recorded weight delta; +- terminal-native body-weight graph. + +A flat weight history with only one distinct value renders one centered axis +value instead of repeating the same minimum and maximum label. + +## 17. Session history and detail + +History is navigable with the keyboard. + +Selecting a workout and pressing `Enter` opens a read-only detail view. + +For every exercise the detail view exposes: + +- exercise name; +- repetition or duration tracking mode; +- load mode; +- planned rest; +- planned number of sets; +- planned repetitions or duration; +- target load when applicable; +- actual set count; +- ordered performed sets and their actual loads. + +Example: + +```text +Presse à cuisses + +Mode : répétitions +Charge : externe +Repos : 1 min + +Cible : 4 séries × 5 reps +Charge cible : 80.0 kg + +Réalisé: +5@80.0 / 5@80.0 / 5@80.0 / 3@80.0 +``` + +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 + +Persistent duration and rest units remain **seconds**. + +No SQLite schema or Trainlog JSON v1 change is required. + +The TUI parser will accept these equivalent forms: + +```text +90 +90s +1:30 +1m30 +1m30s +``` + +All represent 90 seconds. + +Additional examples: + +```text +2m -> 120 seconds +45s -> 45 seconds +2:05 -> 125 seconds +``` + +A bare integer remains seconds for fast backward-compatible entry. + +Canonical display formatting: + +```text +45 seconds -> 45 s +60 seconds -> 1 min +90 seconds -> 1 min 30 s +120 seconds -> 2 min +125 seconds -> 2 min 5 s +``` + +The same parser and formatter are reused for: + +- timed exercise targets; +- timed actual sets; +- planned rest. + +Invalid malformed forms are rejected before persistence. + +## 19. Body screen and measurement graphs — next implementation slice + +`F4 Corps` becomes a full history and visualization screen. + +Canonical selectable metrics: + +```text +body_weight_kg +neck_cm +shoulders_cm +chest_cm +waist_cm +hips_cm +left_arm_cm +right_arm_cm +left_forearm_cm +right_forearm_cm +left_thigh_cm +right_thigh_cm +left_calf_cm +right_calf_cm +``` + +The screen supports left/right navigation between metrics. + +For the selected metric it shows: + +- latest value; +- first recorded value; +- absolute change; +- terminal-native history graph; +- recent dated values. + +Units: + +```text +body weight -> kg +measurements -> cm +``` + +Paired measurements also expose asymmetry: + +```text +left arm : 34.2 cm +right arm : 34.8 cm +difference: 0.6 cm right +``` + +Relevant pairs: + +- arm; +- forearm; +- thigh; +- calf. + +The graph layer must not invent zero values when one side or one date is +missing. + +## 20. TUI priority before Android + +Android remains intentionally deferred until the TUI is comfortable for daily +use. + +Immediate order: + +```text +1. session details +2. human duration parsing/formatting +3. full F4 measurement history and graphs +4. exercise performance history and graphs +5. previous-session defaults +6. safe editing/deletion +7. Android recorder and JSON import workflow +``` + diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index c0aed58..4051b6a 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -91,4 +91,37 @@ TrainlogStatus trainlog_database_list_weight_points( size_t *output_count ); + +/* TRAINLOG_SESSION_DETAILS_API */ + +#define TRAINLOG_SET_SUMMARY_MAX 1024U + +typedef struct TrainlogPersistedExerciseDetail { + char name[TRAINLOG_NAME_MAX + 1U]; + TrainlogTrackingMode tracking_mode; + TrainlogLoadMode load_mode; + int rest_seconds; + int target_sets; + int target_reps; + int target_duration_seconds; + int has_target_weight; + double target_weight_kg; + size_t actual_set_count; + char actual_summary[TRAINLOG_SET_SUMMARY_MAX + 1U]; +} TrainlogPersistedExerciseDetail; + +/** + * @brief Load one session header plus ordered exercise details. + * + * The function is read-only and allocates nothing. + */ +TrainlogStatus trainlog_database_get_session_details( + TrainlogDatabase *database, + const char *session_id, + TrainlogSessionSummary *output_session, + TrainlogPersistedExerciseDetail *output_exercises, + size_t exercise_capacity, + size_t *output_exercise_count +); + #endif diff --git a/tui/meson.build b/tui/meson.build index acd69db..c80fcd1 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -95,3 +95,15 @@ test( 'catalog', test_catalog, ) + +test_session_detail = executable( + 'test_session_detail', + 'tests/test_session_detail.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + +test( + 'session_detail', + test_session_detail, +) diff --git a/tui/src/database.c b/tui/src/database.c index 124a7d3..bcbee51 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -1203,3 +1203,461 @@ TrainlogStatus trainlog_database_list_weight_points( *output_count = count < capacity ? count : capacity; return TRAINLOG_STATUS_OK; } + +/* TRAINLOG_SESSION_DETAILS_IMPLEMENTATION */ + +static TrainlogLoadMode detail_load_mode_from_text(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 TrainlogStatus detail_append_text( + char *output, + size_t output_size, + size_t *used, + const char *text +) +{ + size_t remaining; + int written; + + if (output == NULL || + used == NULL || + text == NULL || + *used >= output_size) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + remaining = output_size - *used; + + written = snprintf( + output + *used, + remaining, + "%s", + text + ); + + if (written < 0) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if ((size_t)written >= remaining) { + if (output_size >= 4U) { + output[output_size - 4U] = '.'; + output[output_size - 3U] = '.'; + output[output_size - 2U] = '.'; + output[output_size - 1U] = '\0'; + } + + *used = output_size - 1U; + return TRAINLOG_STATUS_OK; + } + + *used += (size_t)written; + return TRAINLOG_STATUS_OK; +} + +static TrainlogStatus detail_fill_sets( + TrainlogDatabase *database, + sqlite3_int64 session_exercise_row_id, + TrainlogPersistedExerciseDetail *detail +) +{ + static const char *const SQL = + "SELECT reps, duration_seconds, weight_kg " + "FROM performed_sets " + "WHERE session_exercise_row_id = ?1 " + "ORDER BY position ASC;"; + + sqlite3_stmt *statement = NULL; + size_t used = 0U; + size_t count = 0U; + int rc; + + rc = sqlite3_prepare_v2( + database->connection, + SQL, + -1, + &statement, + NULL + ); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_bind_int64( + statement, + 1, + session_exercise_row_id + ); + if (rc != SQLITE_OK) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + detail->actual_summary[0] = '\0'; + + while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { + char fragment[96]; + int written; + int has_reps = + sqlite3_column_type(statement, 0) != SQLITE_NULL; + int has_duration = + sqlite3_column_type(statement, 1) != SQLITE_NULL; + int has_weight = + sqlite3_column_type(statement, 2) != SQLITE_NULL; + + if (count > 0U) { + TrainlogStatus status = detail_append_text( + detail->actual_summary, + sizeof(detail->actual_summary), + &used, + " / " + ); + + if (status != TRAINLOG_STATUS_OK) { + (void)sqlite3_finalize(statement); + return status; + } + } + + if (has_reps != 0) { + int reps = sqlite3_column_int(statement, 0); + + if (has_weight != 0) { + written = snprintf( + fragment, + sizeof(fragment), + "%d@%.1f", + reps, + sqlite3_column_double(statement, 2) + ); + } else { + written = snprintf( + fragment, + sizeof(fragment), + "%d", + reps + ); + } + } else if (has_duration != 0) { + int duration = sqlite3_column_int(statement, 1); + + if (has_weight != 0) { + written = snprintf( + fragment, + sizeof(fragment), + "%ds@%.1f", + duration, + sqlite3_column_double(statement, 2) + ); + } else { + written = snprintf( + fragment, + sizeof(fragment), + "%ds", + duration + ); + } + } else { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (written < 0 || + (size_t)written >= sizeof(fragment)) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + { + TrainlogStatus status = detail_append_text( + detail->actual_summary, + sizeof(detail->actual_summary), + &used, + fragment + ); + + if (status != TRAINLOG_STATUS_OK) { + (void)sqlite3_finalize(statement); + return status; + } + } + + ++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; + } + + detail->actual_set_count = count; + + if (count == 0U) { + (void)snprintf( + detail->actual_summary, + sizeof(detail->actual_summary), + "%s", + "aucune série réalisée" + ); + } + + return TRAINLOG_STATUS_OK; +} + +TrainlogStatus trainlog_database_get_session_details( + TrainlogDatabase *database, + const char *session_id, + TrainlogSessionSummary *output_session, + TrainlogPersistedExerciseDetail *output_exercises, + size_t exercise_capacity, + size_t *output_exercise_count +) +{ + static const char *const HEADER_SQL = + "SELECT started_at, COALESCE(ended_at, '') " + "FROM sessions " + "WHERE session_id = ?1;"; + + static const char *const EXERCISE_SQL = + "SELECT " + "e.name, e.tracking_mode, se.load_mode, se.rest_seconds, " + "se.target_sets, COALESCE(se.target_reps, 0), " + "COALESCE(se.target_duration_seconds, 0), " + "se.target_weight_kg, se.id " + "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 " + "WHERE s.session_id = ?1 " + "ORDER BY se.position ASC;"; + + sqlite3_stmt *header = NULL; + sqlite3_stmt *exercises = NULL; + size_t copied = 0U; + size_t total = 0U; + int rc; + + if (database == NULL || + database->connection == NULL || + session_id == NULL || + session_id[0] == '\0' || + output_session == NULL || + output_exercise_count == NULL || + (exercise_capacity > 0U && output_exercises == NULL)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + (void)memset(output_session, 0, sizeof(*output_session)); + *output_exercise_count = 0U; + + rc = sqlite3_prepare_v2( + database->connection, + HEADER_SQL, + -1, + &header, + NULL + ); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_bind_text( + header, + 1, + session_id, + -1, + SQLITE_TRANSIENT + ); + if (rc != SQLITE_OK) { + (void)sqlite3_finalize(header); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_step(header); + if (rc == SQLITE_DONE) { + (void)sqlite3_finalize(header); + return TRAINLOG_STATUS_NOT_FOUND; + } + if (rc != SQLITE_ROW) { + (void)sqlite3_finalize(header); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + { + const unsigned char *started = + sqlite3_column_text(header, 0); + const unsigned char *ended = + sqlite3_column_text(header, 1); + + if (started == NULL || ended == NULL) { + (void)sqlite3_finalize(header); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + (void)snprintf( + output_session->session_id, + sizeof(output_session->session_id), + "%s", + session_id + ); + + (void)snprintf( + output_session->started_at, + sizeof(output_session->started_at), + "%s", + (const char *)started + ); + + (void)snprintf( + output_session->ended_at, + sizeof(output_session->ended_at), + "%s", + (const char *)ended + ); + } + + if (sqlite3_finalize(header) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_prepare_v2( + database->connection, + EXERCISE_SQL, + -1, + &exercises, + NULL + ); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_bind_text( + exercises, + 1, + session_id, + -1, + SQLITE_TRANSIENT + ); + if (rc != SQLITE_OK) { + (void)sqlite3_finalize(exercises); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + while ((rc = sqlite3_step(exercises)) == SQLITE_ROW) { + if (copied < exercise_capacity) { + TrainlogPersistedExerciseDetail *detail = + &output_exercises[copied]; + + const unsigned char *name = + sqlite3_column_text(exercises, 0); + + const unsigned char *tracking = + sqlite3_column_text(exercises, 1); + + const unsigned char *load = + sqlite3_column_text(exercises, 2); + + sqlite3_int64 session_exercise_row_id = + sqlite3_column_int64(exercises, 8); + + TrainlogStatus status; + + if (name == NULL || + tracking == NULL || + load == NULL) { + (void)sqlite3_finalize(exercises); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + (void)memset(detail, 0, sizeof(*detail)); + + (void)snprintf( + detail->name, + sizeof(detail->name), + "%s", + (const char *)name + ); + + detail->tracking_mode = + strcmp( + (const char *)tracking, + "duration" + ) == 0 + ? TRAINLOG_TRACKING_DURATION + : TRAINLOG_TRACKING_REPS; + + detail->load_mode = + detail_load_mode_from_text( + (const char *)load + ); + + detail->rest_seconds = + sqlite3_column_int(exercises, 3); + + detail->target_sets = + sqlite3_column_int(exercises, 4); + + detail->target_reps = + sqlite3_column_int(exercises, 5); + + detail->target_duration_seconds = + sqlite3_column_int(exercises, 6); + + detail->has_target_weight = + sqlite3_column_type( + exercises, + 7 + ) != SQLITE_NULL; + + detail->target_weight_kg = + detail->has_target_weight != 0 + ? sqlite3_column_double( + exercises, + 7 + ) + : 0.0; + + status = detail_fill_sets( + database, + session_exercise_row_id, + detail + ); + + if (status != TRAINLOG_STATUS_OK) { + (void)sqlite3_finalize(exercises); + return status; + } + + ++copied; + } + + ++total; + } + + if (rc != SQLITE_DONE) { + (void)sqlite3_finalize(exercises); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (sqlite3_finalize(exercises) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + output_session->exercise_count = total; + *output_exercise_count = copied; + + return TRAINLOG_STATUS_OK; +} diff --git a/tui/src/tui.c b/tui/src/tui.c index ce9ab75..7642868 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -25,6 +25,30 @@ #define MAX_SESSIONS 128U #define MAX_WEIGHT_POINTS 256U +/* TRAINLOG_TUI_V02_POLISH */ + +typedef enum DashboardAction { + DASHBOARD_NEW_SESSION = 0, + DASHBOARD_HISTORY, + DASHBOARD_EXERCISES, + DASHBOARD_BODY, + DASHBOARD_QUIT +} DashboardAction; + +static void draw_shell(const char *heading, const char *footer) +{ + erase(); + box(stdscr, 0, 0); + + attron(A_BOLD | trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT)); + mvprintw(1, 2, " %s ", heading); + attroff(A_BOLD | trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT)); + + attron(trainlog_theme_attribute(TRAINLOG_COLOR_MUTED)); + mvprintw(LINES - 2, 2, "%-*s", COLS - 4, footer); + attroff(trainlog_theme_attribute(TRAINLOG_COLOR_MUTED)); +} + static void wait_key(void) { attron(trainlog_theme_attribute(TRAINLOG_COLOR_MUTED)); @@ -190,21 +214,27 @@ static void draw_weight_sparkline( size_t count ) { - static const char *const blocks[] = { - "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█" - }; double minimum; double maximum; size_t start; size_t index; - int column = 2; + int width; + int height = 6; if (count == 0U) { - mvprintw(row, 2, "Poids : aucune donnée"); + mvprintw(row, 4, "Aucune donnée de poids."); return; } - start = count > 40U ? count - 40U : 0U; + width = COLS - 12; + if (width < 10) { + return; + } + + start = count > (size_t)width + ? count - (size_t)width + : 0U; + minimum = points[start].body_weight_kg; maximum = points[start].body_weight_kg; @@ -217,80 +247,190 @@ static void draw_weight_sparkline( } } - mvprintw( - row, - 2, - "Poids %.1f kg min %.1f max %.1f ", - points[count - 1U].body_weight_kg, - minimum, - maximum - ); - column = getcurx(stdscr); + /* + * A flat series has no useful vertical scale. Showing the same value at + * both ends of the axis is visually misleading, so render one centered + * reference value instead. + */ + if (maximum == minimum) { + int graph_row = row + (height / 2); - attron(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH)); - for (index = start; index < count && column < COLS - 2; ++index) { - size_t bucket = 3U; + mvprintw(graph_row, 2, "%.1f kg", minimum); - if (maximum > minimum) { - double ratio = - (points[index].body_weight_kg - minimum) / - (maximum - minimum); - double scaled = ratio * 7.0; - bucket = (size_t)(scaled + 0.5); - if (bucket > 7U) { - bucket = 7U; + attron(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH)); + + for (index = start; index < count; ++index) { + int x = 10 + (int)(index - start); + + if (x < COLS - 2) { + mvaddch(graph_row, x, (chtype)'*'); } } - mvprintw(row, column, "%s", blocks[bucket]); - ++column; + 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].body_weight_kg - minimum) / + (maximum - minimum); + int y = row + height - 1 - + (int)(ratio * (double)(height - 1)); + int x = 8 + (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 void screen_dashboard(TrainlogDatabase *database) +static DashboardAction screen_dashboard(TrainlogDatabase *database) { + static const char *const labels[] = { + "Nouvelle séance", + "Historique", + "Exercices", + "Corps / mensurations" + }; size_t session_count = 0U; size_t exercise_count = 0U; TrainlogWeightPoint points[MAX_WEIGHT_POINTS]; size_t weight_count = 0U; + int selected = 0; - erase(); - title("TRAINLOG — Dashboard"); + for (;;) { + int key; + int index; - (void)trainlog_database_session_count(database, &session_count); - (void)trainlog_database_exercise_count(database, &exercise_count); - (void)trainlog_database_list_weight_points( - database, - points, - MAX_WEIGHT_POINTS, - &weight_count - ); + (void)trainlog_database_session_count(database, &session_count); + (void)trainlog_database_exercise_count(database, &exercise_count); + (void)trainlog_database_list_weight_points( + database, + points, + MAX_WEIGHT_POINTS, + &weight_count + ); - mvprintw(4, 2, "Séances enregistrées : %zu", session_count); - mvprintw(5, 2, "Exercices connus : %zu", exercise_count); + draw_shell( + "TRAINLOG — Dashboard", + "↑↓ naviguer Entrée ouvrir F1 séance F2 historique F3 exercices F4 corps q quitter" + ); - draw_weight_sparkline(7, points, weight_count); + mvprintw( + 3, + 4, + "Séances : %-6zu Exercices : %-6zu", + session_count, + exercise_count + ); - mvprintw(10, 2, "1 Nouvelle séance"); - mvprintw(11, 2, "2 Historique"); - mvprintw(12, 2, "3 Exercices"); - mvprintw(13, 2, "4 Corps / mensurations"); - mvprintw(14, 2, "q Quitter"); + if (weight_count > 0U) { + double latest = points[weight_count - 1U].body_weight_kg; + double delta = latest - points[0].body_weight_kg; - refresh(); + mvprintw( + 4, + 4, + "Poids : %.1f kg évolution enregistrée : %+.1f kg", + latest, + delta + ); + } else { + mvprintw(4, 4, "Poids : aucune donnée"); + } + + draw_weight_sparkline(6, points, weight_count); + + for (index = 0; index < 4; ++index) { + int menu_row = 13 + index; + + if (index == selected) { + attron( + A_REVERSE | + trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT) + ); + } + + mvprintw( + menu_row, + 4, + " %d %-28s ", + index + 1, + labels[index] + ); + + if (index == selected) { + attroff( + A_REVERSE | + trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT) + ); + } + } + + refresh(); + key = getch(); + + switch (key) { + case KEY_UP: + selected = selected > 0 ? selected - 1 : 3; + break; + case KEY_DOWN: + selected = selected < 3 ? selected + 1 : 0; + break; + case '\n': + case KEY_ENTER: + return (DashboardAction)selected; + case KEY_F(1): + case '1': + return DASHBOARD_NEW_SESSION; + case KEY_F(2): + case '2': + return DASHBOARD_HISTORY; + case KEY_F(3): + case '3': + return DASHBOARD_EXERCISES; + case KEY_F(4): + case '4': + return DASHBOARD_BODY; + case 'q': + case 'Q': + return DASHBOARD_QUIT; + default: + break; + } + } } static void screen_exercises(TrainlogDatabase *database) { TrainlogExercise exercises[MAX_EXERCISES]; - size_t count = 0U; - size_t index; - int key; + size_t selected = 0U; for (;;) { - erase(); - title("TRAINLOG — Exercices"); + size_t count = 0U; + size_t top = 0U; + size_t index; + int visible_rows = LINES - 7; + int key; + + if (visible_rows < 1) { + return; + } if (trainlog_database_list_exercises( database, @@ -298,46 +438,77 @@ static void screen_exercises(TrainlogDatabase *database) MAX_EXERCISES, &count ) != TRAINLOG_STATUS_OK) { - status_line("Erreur base de données.", TRAINLOG_COLOR_ERROR); - wait_key(); return; } + if (count > 0U && selected >= count) { + selected = count - 1U; + } + + if (count > 0U && + selected >= (size_t)visible_rows) { + top = selected - (size_t)visible_rows + 1U; + } + + draw_shell( + "TRAINLOG — Exercices", + "↑↓ naviguer a ajouter b/Échap retour" + ); + if (count == 0U) { - mvprintw(4, 2, "Aucun exercice."); - } else { - for (index = 0U; - index < count && 4 + (int)index < LINES - 5; - ++index) { - mvprintw( - 4 + (int)index, - 2, - "%3zu %-35s [%s]", - index + 1U, - exercises[index].name, - exercises[index].tracking_mode == TRAINLOG_TRACKING_REPS - ? "reps" - : "durée" + mvprintw(4, 4, "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; + + if (absolute == selected) { + attron( + A_REVERSE | + trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT) + ); + } + + mvprintw( + item_row, + 4, + " %-42s [%s] ", + exercises[absolute].name, + exercises[absolute].tracking_mode == TRAINLOG_TRACKING_REPS + ? "reps" + : "durée" + ); + + if (absolute == selected) { + attroff( + A_REVERSE | + trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT) ); } } - mvprintw(LINES - 3, 2, "a Ajouter b Retour"); refresh(); - key = getch(); + if (key == 'b' || key == 27) { return; } - if (key == 'a') { + if (count > 0U && key == KEY_UP) { + selected = selected > 0U ? selected - 1U : count - 1U; + } else if (count > 0U && key == KEY_DOWN) { + selected = selected + 1U < count ? selected + 1U : 0U; + } else if (key == 'a') { char name[TRAINLOG_NAME_MAX + 1U]; int mode = 1; TrainlogExercise created; TrainlogStatus status; - erase(); - title("Nouvel exercice"); + draw_shell("Nouvel exercice", "Entrée valide chaque champ"); if (!prompt_text( 4, @@ -370,15 +541,13 @@ static void screen_exercises(TrainlogDatabase *database) ); if (status == TRAINLOG_STATUS_OK) { - status_line("Exercice ajouté.", TRAINLOG_COLOR_SUCCESS); + status_line("✓ Exercice ajouté.", TRAINLOG_COLOR_SUCCESS); } else if (status == TRAINLOG_STATUS_CONFLICT) { - status_line( - "Doublon détecté : nom ou identité déjà présent.", - TRAINLOG_COLOR_WARNING - ); + status_line("Doublon détecté.", TRAINLOG_COLOR_WARNING); } else { status_line("Impossible d'ajouter l'exercice.", TRAINLOG_COLOR_ERROR); } + wait_key(); } } @@ -391,8 +560,7 @@ static bool choose_exercise( { TrainlogExercise exercises[MAX_EXERCISES]; size_t count = 0U; - size_t index; - int selected = 0; + size_t selected = 0U; if (trainlog_database_list_exercises( database, @@ -404,34 +572,71 @@ static bool choose_exercise( return false; } - erase(); - title("Choisir un exercice"); + for (;;) { + size_t index; + size_t top = 0U; + int visible_rows = LINES - 7; + int key; - for (index = 0U; - index < count && 4 + (int)index < LINES - 5; - ++index) { - mvprintw( - 4 + (int)index, - 2, - "%3zu %s", - index + 1U, - exercises[index].name + if (visible_rows < 1) { + return false; + } + + if (selected >= (size_t)visible_rows) { + top = selected - (size_t)visible_rows + 1U; + } + + draw_shell( + "Choisir un exercice", + "↑↓ naviguer Entrée choisir Échap annuler" ); - } - if (!prompt_int_value( - LINES - 4, - "Numéro", - 1, - (int)count, - 1, - &selected - )) { - return false; - } + for (index = 0U; + index < (size_t)visible_rows && + top + index < count; + ++index) { + size_t absolute = top + index; + int item_row = 3 + (int)index; - *output = exercises[(size_t)selected - 1U]; - return true; + if (absolute == selected) { + attron( + A_REVERSE | + trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT) + ); + } + + mvprintw( + item_row, + 4, + " %-42s [%s] ", + exercises[absolute].name, + exercises[absolute].tracking_mode == TRAINLOG_TRACKING_REPS + ? "reps" + : "durée" + ); + + if (absolute == selected) { + attroff( + A_REVERSE | + trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT) + ); + } + } + + refresh(); + key = getch(); + + if (key == KEY_UP) { + selected = selected > 0U ? selected - 1U : count - 1U; + } else if (key == KEY_DOWN) { + selected = selected + 1U < count ? selected + 1U : 0U; + } else if (key == '\n' || key == KEY_ENTER) { + *output = exercises[selected]; + return true; + } else if (key == 27) { + return false; + } + } } static bool build_session_exercise( @@ -756,175 +961,527 @@ static void screen_new_session(TrainlogDatabase *database) wait_key(); } -static void screen_history(TrainlogDatabase *database) +/* TRAINLOG_SESSION_DETAILS_SCREEN */ + +static const char *session_detail_load_label(TrainlogLoadMode mode) { - TrainlogSessionSummary sessions[MAX_SESSIONS]; + switch (mode) { + case TRAINLOG_LOAD_EXTERNAL: + return "externe"; + case TRAINLOG_LOAD_ASSISTANCE: + return "assistance"; + case TRAINLOG_LOAD_NONE: + default: + return "aucune"; + } +} + +static void screen_session_detail( + TrainlogDatabase *database, + const char *session_id +) +{ + TrainlogSessionSummary session; + TrainlogPersistedExerciseDetail exercises[MAX_SESSION_EXERCISES]; size_t count = 0U; - size_t index; + size_t selected = 0U; + TrainlogStatus status; - erase(); - title("TRAINLOG — Historique"); + status = trainlog_database_get_session_details( + database, + session_id, + &session, + exercises, + MAX_SESSION_EXERCISES, + &count + ); - if (trainlog_database_list_sessions( - database, - sessions, - MAX_SESSIONS, - &count - ) != TRAINLOG_STATUS_OK) { - status_line("Erreur base de données.", TRAINLOG_COLOR_ERROR); + if (status != TRAINLOG_STATUS_OK) { + draw_shell( + "Détail séance", + "Une touche pour revenir" + ); + status_line( + "Impossible de charger la séance.", + TRAINLOG_COLOR_ERROR + ); wait_key(); return; } - if (count == 0U) { - mvprintw(4, 2, "Aucune séance."); - } else { - for (index = 0U; - index < count && 4 + (int)index < LINES - 4; - ++index) { + for (;;) { + int key; + + draw_shell( + "TRAINLOG — Détail séance", + "←→ ou ↑↓ exercice précédent/suivant b/Échap retour" + ); + + mvprintw( + 3, + 4, + "Début : %s", + session.started_at + ); + + mvprintw( + 4, + 4, + "Fin : %s", + session.ended_at[0] != '\0' + ? session.ended_at + : "séance ouverte" + ); + + if (count == 0U) { mvprintw( - 4 + (int)index, - 2, - "%-25s %2zu exercice(s)", - sessions[index].started_at, - sessions[index].exercise_count + 7, + 4, + "Aucun exercice dans cette séance." + ); + } else { + TrainlogPersistedExerciseDetail *exercise = + &exercises[selected]; + + attron( + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + + mvprintw( + 6, + 4, + "Exercice %zu/%zu — %s", + selected + 1U, + count, + exercise->name + ); + + attroff( + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + + mvprintw( + 8, + 4, + "Mode : %-12s Charge : %-10s Repos : %ds", + exercise->tracking_mode == + TRAINLOG_TRACKING_REPS + ? "répétitions" + : "durée", + session_detail_load_label( + exercise->load_mode + ), + exercise->rest_seconds + ); + + if (exercise->tracking_mode == + TRAINLOG_TRACKING_REPS) { + mvprintw( + 10, + 4, + "Cible : %d série(s) × %d reps", + exercise->target_sets, + exercise->target_reps + ); + } else { + mvprintw( + 10, + 4, + "Cible : %d série(s) × %ds", + exercise->target_sets, + exercise->target_duration_seconds + ); + } + + if (exercise->has_target_weight != 0) { + mvprintw( + 11, + 4, + "Charge cible : %.1f kg", + exercise->target_weight_kg + ); + } else { + mvprintw( + 11, + 4, + "Charge cible : —" + ); + } + + attron( + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_SUCCESS + ) + ); + + mvprintw( + 13, + 4, + "Réalisé : %zu série(s)", + exercise->actual_set_count + ); + + attroff( + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_SUCCESS + ) + ); + + mvprintw( + 15, + 4, + "%.*s", + COLS - 8, + exercise->actual_summary + ); + + if (exercise->load_mode == + TRAINLOG_LOAD_ASSISTANCE) { + attron( + trainlog_theme_attribute( + TRAINLOG_COLOR_WARNING + ) + ); + + mvprintw( + 17, + 4, + "Assistance : plus de kg = davantage d'aide." + ); + + attroff( + trainlog_theme_attribute( + TRAINLOG_COLOR_WARNING + ) + ); + } + } + + refresh(); + key = getch(); + + if (key == 'b' || key == 27) { + return; + } + + if (count > 0U && + (key == KEY_RIGHT || + key == KEY_DOWN)) { + selected = + selected + 1U < count + ? selected + 1U + : 0U; + } else if (count > 0U && + (key == KEY_LEFT || + key == KEY_UP)) { + selected = + selected > 0U + ? selected - 1U + : count - 1U; + } + } +} + +static void screen_history(TrainlogDatabase *database) +{ + TrainlogSessionSummary sessions[MAX_SESSIONS]; + size_t selected = 0U; + + for (;;) { + size_t count = 0U; + size_t top = 0U; + size_t index; + int visible_rows = LINES - 8; + int key; + + if (visible_rows < 1) { + return; + } + + if (trainlog_database_list_sessions( + database, + sessions, + MAX_SESSIONS, + &count + ) != TRAINLOG_STATUS_OK) { + return; + } + + draw_shell( + "TRAINLOG — Historique", + "↑↓ naviguer Entrée détail b/Échap retour" + ); + + if (count == 0U) { + mvprintw(4, 4, "Aucune séance."); + refresh(); + key = getch(); + + if (key == 'b' || key == 27) { + return; + } + + continue; + } + + if (selected >= count) { + selected = count - 1U; + } + + if (selected >= (size_t)visible_rows) { + top = + selected - + (size_t)visible_rows + + 1U; + } + + for (index = 0U; + index < (size_t)visible_rows && + top + index < count; + ++index) { + size_t absolute = top + index; + int item_row = 3 + (int)index; + + if (absolute == selected) { + attron( + A_REVERSE | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + } + + mvprintw( + item_row, + 4, + " %-25s %2zu exercice(s) ", + sessions[absolute].started_at, + sessions[absolute].exercise_count + ); + + if (absolute == selected) { + attroff( + A_REVERSE | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + } + } + + refresh(); + key = getch(); + + if (key == 'b' || key == 27) { + return; + } + + if (key == KEY_UP) { + selected = + selected > 0U + ? selected - 1U + : count - 1U; + } else if (key == KEY_DOWN) { + selected = + selected + 1U < count + ? selected + 1U + : 0U; + } else if (key == '\n' || + key == KEY_ENTER) { + screen_session_detail( + database, + sessions[selected].session_id ); } } - - wait_key(); -} - -static void prompt_body_metric( - int row, - const char *label, - bool *present, - double *value -) -{ - (void)prompt_optional_double(row, label, present, value); } static void screen_body(TrainlogDatabase *database) { - TrainlogBodyObservationInput observation; - char id[TRAINLOG_GENERATED_ID_CAPACITY]; - char timestamp[TRAINLOG_TIMESTAMP_MAX + 1U]; - TrainlogStatus status; - int row = 4; + for (;;) { + TrainlogWeightPoint points[MAX_WEIGHT_POINTS]; + size_t count = 0U; + size_t start; + size_t index; + int key; - (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 - ); - - erase(); - title("TRAINLOG — Corps / mensurations"); - mvprintw(3, 2, "Laissez vide ce que vous ne mesurez pas aujourd'hui."); - - prompt_body_metric( - row++, - "Poids kg : ", - &observation.has_body_weight, - &observation.body_weight_kg - ); - prompt_body_metric( - row++, - "Tour de taille cm : ", - &observation.has_waist, - &observation.waist_cm - ); - prompt_body_metric( - row++, - "Poitrine cm : ", - &observation.has_chest, - &observation.chest_cm - ); - prompt_body_metric( - row++, - "Épaules cm : ", - &observation.has_shoulders, - &observation.shoulders_cm - ); - prompt_body_metric( - row++, - "Bras gauche cm : ", - &observation.has_left_arm, - &observation.left_arm_cm - ); - prompt_body_metric( - row++, - "Bras droit cm : ", - &observation.has_right_arm, - &observation.right_arm_cm - ); - prompt_body_metric( - row++, - "Cuisse gauche cm : ", - &observation.has_left_thigh, - &observation.left_thigh_cm - ); - prompt_body_metric( - row++, - "Cuisse droite cm : ", - &observation.has_right_thigh, - &observation.right_thigh_cm - ); - prompt_body_metric( - row++, - "Mollet gauche cm : ", - &observation.has_left_calf, - &observation.left_calf_cm - ); - prompt_body_metric( - row++, - "Mollet droit cm : ", - &observation.has_right_calf, - &observation.right_calf_cm - ); - - status = trainlog_database_insert_body_observation( - database, - &observation - ); - - if (status == TRAINLOG_STATUS_OK) { - status_line("✓ Mesures enregistrées.", TRAINLOG_COLOR_SUCCESS); - } else if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) { - status_line( - "Aucune mesure saisie : rien n'a été enregistré.", - TRAINLOG_COLOR_WARNING + (void)trainlog_database_list_weight_points( + database, + points, + MAX_WEIGHT_POINTS, + &count ); - } else { - status_line( - "Impossible d'enregistrer les mesures.", - TRAINLOG_COLOR_ERROR - ); - } - wait_key(); + draw_shell( + "TRAINLOG — Corps", + "a ajouter une mesure b/Échap retour" + ); + + if (count > 0U) { + double latest = points[count - 1U].body_weight_kg; + double delta = latest - points[0].body_weight_kg; + + mvprintw( + 3, + 4, + "Poids actuel : %.1f kg évolution : %+.1f kg", + latest, + delta + ); + } else { + mvprintw(3, 4, "Aucune mesure de poids."); + } + + draw_weight_sparkline(5, points, count); + + mvprintw(12, 4, "Dernières pesées :"); + start = count > 5U ? count - 5U : 0U; + + for (index = start; index < count; ++index) { + mvprintw( + 13 + (int)(index - start), + 6, + "%-25s %.1f kg", + points[index].observed_at, + points[index].body_weight_kg + ); + } + + refresh(); + key = getch(); + + if (key == 'b' || key == 27) { + 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(); + } + } } int trainlog_tui_run(TrainlogDatabase *database) { - int key; - if (database == NULL) { return 1; } @@ -942,7 +1499,11 @@ int trainlog_tui_run(TrainlogDatabase *database) trainlog_theme_initialize(); for (;;) { + DashboardAction action; + if (LINES < 20 || COLS < 72) { + int key; + erase(); mvprintw( 1, @@ -953,30 +1514,28 @@ int trainlog_tui_run(TrainlogDatabase *database) refresh(); key = getch(); - if (key == 'q') { + if (key == 'q' || key == 'Q') { break; } continue; } - screen_dashboard(database); - key = getch(); + action = screen_dashboard(database); - switch (key) { - case '1': + switch (action) { + case DASHBOARD_NEW_SESSION: screen_new_session(database); break; - case '2': + case DASHBOARD_HISTORY: screen_history(database); break; - case '3': + case DASHBOARD_EXERCISES: screen_exercises(database); break; - case '4': + case DASHBOARD_BODY: screen_body(database); break; - case 'q': - case 'Q': + case DASHBOARD_QUIT: endwin(); return 0; default: diff --git a/tui/tests/test_session_detail.c b/tui/tests/test_session_detail.c new file mode 100644 index 0000000..444f578 --- /dev/null +++ b/tui/tests/test_session_detail.c @@ -0,0 +1,164 @@ +/** + * @file test_session_detail.c + * @brief Tests for persisted workout detail queries. + */ + +#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_session_details(void) +{ + TrainlogDatabase *database = NULL; + TrainlogSetInput sets[3]; + TrainlogSessionExerciseInput exercise; + TrainlogSessionInput session; + TrainlogSessionSummary summary; + TrainlogPersistedExerciseDetail details[4]; + size_t count = 0U; + + CHECK( + trainlog_database_open( + ":memory:", + &database + ) == TRAINLOG_STATUS_OK + ); + + CHECK( + trainlog_database_insert_exercise( + database, + "ex_detail", + "Presse à cuisses", + "presse à cuisses", + TRAINLOG_TRACKING_REPS + ) == TRAINLOG_STATUS_OK + ); + + (void)memset(sets, 0, sizeof(sets)); + + sets[0].reps = 5; + sets[0].has_weight = true; + sets[0].weight_kg = 80.0; + + sets[1] = sets[0]; + + sets[2].reps = 3; + sets[2].has_weight = true; + sets[2].weight_kg = 80.0; + + (void)memset( + &exercise, + 0, + sizeof(exercise) + ); + + (void)snprintf( + exercise.exercise_id, + sizeof(exercise.exercise_id), + "%s", + "ex_detail" + ); + + exercise.load_mode = + TRAINLOG_LOAD_EXTERNAL; + exercise.rest_seconds = 60; + exercise.target_sets = 3; + exercise.target_reps = 5; + exercise.target_has_weight = true; + exercise.target_weight_kg = 80.0; + exercise.sets = sets; + exercise.set_count = 3U; + + (void)memset( + &session, + 0, + sizeof(session) + ); + + (void)snprintf( + session.session_id, + sizeof(session.session_id), + "%s", + "se_detail" + ); + + (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 = &exercise; + session.exercise_count = 1U; + + CHECK( + trainlog_database_insert_session( + database, + &session + ) == TRAINLOG_STATUS_OK + ); + + CHECK( + trainlog_database_get_session_details( + database, + "se_detail", + &summary, + details, + 4U, + &count + ) == TRAINLOG_STATUS_OK + ); + + CHECK(count == 1U); + CHECK(summary.exercise_count == 1U); + CHECK( + strcmp( + details[0].name, + "Presse à cuisses" + ) == 0 + ); + CHECK(details[0].target_sets == 3); + CHECK(details[0].target_reps == 5); + CHECK(details[0].rest_seconds == 60); + CHECK(details[0].actual_set_count == 3U); + CHECK( + strcmp( + details[0].actual_summary, + "5@80.0 / 5@80.0 / 3@80.0" + ) == 0 + ); + + trainlog_database_close(database); + return true; +} + +int main(void) +{ + CHECK(test_session_details()); + (void)printf("PASS session_details\n"); + return 0; +}