Polish Trainlog TUI and add session details
This commit is contained in:
parent
3e36b89d6b
commit
afd6847947
11 changed files with 1941 additions and 360 deletions
47
CHANGELOG.md
47
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.
|
||||
|
||||
<!-- 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 -->
|
||||
|
|
|
|||
24
docs/reviews/tui_session_details.md
Normal file
24
docs/reviews/tui_session_details.md
Normal 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.
|
||||
112
docs/reviews/tui_v0_2_checkpoint.md
Normal file
112
docs/reviews/tui_v0_2_checkpoint.md
Normal 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
|
||||
```
|
||||
28
docs/reviews/tui_v0_2_polish.md
Normal file
28
docs/reviews/tui_v0_2_polish.md
Normal 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.
|
||||
142
docs/roadmap.md
142
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.
|
||||
|
||||
<!-- 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 -->
|
||||
|
|
|
|||
200
docs/tui.md
200
docs/tui.md
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
1081
tui/src/tui.c
1081
tui/src/tui.c
File diff suppressed because it is too large
Load diff
164
tui/tests/test_session_detail.c
Normal file
164
tui/tests/test_session_detail.c
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue