Polish Trainlog TUI and add session details

This commit is contained in:
fy59 2026-09-05 20:23:37 +02:00
parent 3e36b89d6b
commit afd6847947
11 changed files with 1941 additions and 360 deletions

View file

@ -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.
<!-- TRAINLOG_TUI_V02_CHANGELOG -->
### 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.
<!-- TRAINLOG_TUI_V02_CHANGELOG _END -->

View file

@ -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.

View file

@ -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
```

View file

@ -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.

View file

@ -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.
<!-- TRAINLOG_TUI_V02_ROADMAP -->
## 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.
<!-- TRAINLOG_TUI_V02_ROADMAP _END -->

View file

@ -188,3 +188,203 @@ se_<random UUID v4>
```
The database stores these identifiers as opaque stable text.
<!-- TRAINLOG_TUI_V02_CURRENT_AND_NEXT -->
## 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
```
<!-- TRAINLOG_TUI_V02_CURRENT_AND_NEXT _END -->

View file

@ -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

View file

@ -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,
)

View file

@ -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;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,164 @@
/**
* @file test_session_detail.c
* @brief Tests for persisted workout detail queries.
*/
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "trainlog/database.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf( \
stderr, \
"CHECK failed at %s:%d: %s\n", \
__FILE__, \
__LINE__, \
#condition \
); \
return false; \
} \
} while (0)
static 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;
}