Add editable TUI workflows and unified navigation

This commit is contained in:
fy59 2026-09-06 09:54:28 +02:00
parent 23c5fcdf06
commit 3d99be00f5
12 changed files with 6556 additions and 853 deletions

View file

@ -91,3 +91,35 @@ Next:
- added transactional SQLite schema migration v1 -> v2; - added transactional SQLite schema migration v1 -> v2;
- preserved migrated sessions as `training`; - preserved migrated sessions as `training`;
- kept Trainlog JSON v1 frozen and unchanged. - kept Trainlog JSON v1 frozen and unchanged.
### Transaction-safe session editing foundation
- added exact bounded loading of persisted exercise/set values;
- added atomic replacement of recorded session exercise/set rows;
- preserved the parent session row and linked body observations;
- added rollback coverage for failed replacements.
### Body observation history and correction
- redesigned `F4 Corps` around recorded measurement dates;
- added a framed trend graph above the newest-first record list;
- added PageUp/PageDown and a visual ncurses scrollbar;
- added two-page detail views with `e Modifier` on every page;
- added correction of existing observations without changing their timestamp or
session link;
- added `-` to clear one erroneous measurement and Escape to cancel the edit.
<!-- TRAINLOG_EDITABILITY_NAV_CHANGELOG -->
### TUI editability and navigation checkpoint
- added local `training` / `max_test` selection to the session workflow;
- added safe persisted-session editing while preserving parent session identity;
- added newest-first body-observation history, detail pages, and editing;
- added immediate Escape cancellation to text/numeric entry paths;
- added a shared top navigation bar with `0 Accueil`, `1-4`, and `F1-F4`;
- added Tab/Shift+Tab focus on multi-zone screens;
- focused frames use a yellow border/title without recoloring content;
- added consistent ASCII banners and ncurses frames to secondary detail views;
- added direct exercise creation from the in-session exercise chooser;
- kept the final rolling 12-month `MM/YY` dashboard label inside its frame.
<!-- TRAINLOG_EDITABILITY_NAV_CHANGELOG _END -->

View file

@ -0,0 +1,38 @@
# Body observation history and editing
## Status
```text
BODY_OBSERVATION_HISTORY_UI=IMPLEMENTED
BODY_OBSERVATION_EDIT=IMPLEMENTED
BODY_OBSERVATION_SCROLLBAR=IMPLEMENTED
DATABASE_SCHEMA_V2=UNCHANGED
TRAINLOG_FORMAT_V1=FROZEN
```
`F4 Corps` is now record-oriented rather than metric-oriented.
The home view contains:
- a framed body-weight trend graph;
- a newest-first framed observation list;
- one compact summary per observation;
- keyboard scrolling plus PageUp/PageDown;
- a ncurses-drawn vertical scrollbar when history exceeds the visible window.
Enter opens the selected observation.
The detail view has two pages:
1. general measurements;
2. left/right limb measurements.
Both pages expose `e Modifier`.
Editing preserves the observation row identity, timestamp and optional session
link. A field can be kept with Enter, replaced with another positive number, or
cleared using `-`. Escape cancels the entire edit without persistence.
At least one metric must remain present.
No schema migration and no JSON v1 change are required.

View file

@ -0,0 +1,33 @@
# Transaction-safe session editing
## Status
```text
SESSION_EDIT_PERSISTENCE=IMPLEMENTED
SESSION_EDIT_TUI=NEXT
TRAINLOG_FORMAT_V1=FROZEN
DATABASE_SCHEMA_V2=UNCHANGED
```
This slice adds the persistence foundation required to correct a recorded
workout without deleting the session itself.
`trainlog_database_replace_session_exercises(...)` replaces the
`session_exercises` and `performed_sets` children inside one explicit
transaction.
The parent `sessions` row is never deleted. Therefore these remain stable:
- `session_id`;
- start/end timestamps;
- `session_type`;
- session notes;
- any `body_observations.session_row_id` link.
On any lookup, constraint or insertion error, the whole replacement rolls back.
`trainlog_database_load_session_editable(...)` exposes the exact persisted
exercise/set values in bounded caller-owned buffers. It refuses insufficient
capacity instead of silently truncating editable data.
No schema migration and no JSON v1 change are required.

View file

@ -33,14 +33,15 @@ Current TUI capabilities:
- navigable history; - navigable history;
- Unicode anti-duplicate exercise names. - Unicode anti-duplicate exercise names.
Next TUI polish blocks: Current remaining Gate 2 direction:
1. full session detail; 1. finish the current visual/navigation checkpoint;
2. exercise performance history and graphs; 2. validate persisted editing on the real database;
3. previous-session defaults; 3. detect Android over USB/ADB;
4. safe edit/delete flows. 4. build the minimal Android recorder;
5. exercise frozen JSON v1 import/export end to end.
Android remains deferred until the TUI daily workflow is satisfactory. Measured-max semantics remain a later independent analytics contract.
<!-- TRAINLOG_TUI_V02_ROADMAP --> <!-- TRAINLOG_TUI_V02_ROADMAP -->
## TUI v0.2 checkpoint ## TUI v0.2 checkpoint
@ -130,11 +131,65 @@ PREVIOUS_SESSION_DEFAULTS=AFTER
```text ```text
DATABASE_SCHEMA_V2=IMPLEMENTED DATABASE_SCHEMA_V2=IMPLEMENTED
SESSION_TYPE_PERSISTENCE=IMPLEMENTED SESSION_TYPE_PERSISTENCE=IMPLEMENTED
SESSION_TYPE_TUI=NEXT SESSION_TYPE_TUI=IMPLEMENTED
MEASURED_MAX_TRACKING=AFTER TUI_SESSION_EDIT=IMPLEMENTED
TUI_BODY_OBSERVATION_EDIT=IMPLEMENTED
TUI_PRIMARY_NAVIGATION=IMPLEMENTED
TUI_SECONDARY_VIEW_POLISH=IMPLEMENTED
ANDROID_USB_DETECTION=NEXT
MEASURED_MAX_TRACKING=LATER
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
``` ```
SQLite schema v2 adds `sessions.session_type` with `training` and `max_test`. SQLite schema v2 adds `sessions.session_type` with `training` and `max_test`.
Existing v1 rows migrate to `training`. No existing session is retroactively Existing v1 rows migrate to `training`. No existing session is retroactively
classified as a max test. classified as a max test.
## Editable session data
```text
SESSION_EDIT_PERSISTENCE=IMPLEMENTED
SESSION_EDIT_TUI=NEXT
BODY_OBSERVATION_EDIT=AFTER
USB_PHONE_DETECTION=AFTER_EDITING
```
Recorded session exercise/set correction uses atomic replacement of child rows
while preserving the parent session row and linked body observations.
## Body observation record workflow
```text
BODY_OBSERVATION_HISTORY_UI=IMPLEMENTED
BODY_OBSERVATION_EDIT=IMPLEMENTED
BODY_OBSERVATION_SCROLLBAR=IMPLEMENTED
USB_PHONE_DETECTION=NEXT
```
`F4 Corps` now uses newest-first observation records with detail/edit views
instead of making individual metrics the primary navigation model.
<!-- TRAINLOG_EDITABILITY_ROADMAP -->
## Editability/navigation checkpoint
Completed:
- local schema v2 migration with `training` / `max_test`;
- session-type selection and persistence;
- persisted session editing with stable parent identity;
- body-observation history, detail, and editing;
- Escape-safe prompt cancellation;
- framed ASCII-banner primary and secondary views;
- top `Accueil / Séance / Historique / Exercices / Corps` navigation;
- Tab focus with yellow border-only focus indication;
- direct exercise creation while building a session;
- 12-month dashboard axis kept inside its frame.
Next implementation cursor:
```text
ANDROID_USB_DETECTION=NEXT
ANDROID_MINIMAL_RECORDER=AFTER
JSON_V1_USB_IMPORT_EXPORT=AFTER
```
<!-- TRAINLOG_EDITABILITY_ROADMAP _END -->

View file

@ -81,36 +81,53 @@ Gate 1 includes rejection of:
- blank exercise notes; - blank exercise notes;
- negative actual repetitions. - negative actual repetitions.
## 7. Future database validation ## 7. Gate 2 compiled validation
Gate 2 must verify: The normal Meson suite currently covers:
- foreign keys enabled; ```text
- duplicate `session_id` idempotency; database
- duplicate `exercise_id` barrier; catalog
- transactional rollback; session_detail
- schema migration correctness. duration
body_metrics
bodyviz
exercise_performance
session_type_schema
session_edit
body_observation_edit
```
## 8. Future C validation The session-edit test verifies transactional child replacement without changing
the parent session identity. The body-observation edit test verifies stable
observation identity while metric values and notes are updated.
C implementation gates will include: Schema validation includes the v1 -> v2 `session_type` migration.
- normal build; ## 8. C validation
- strict-warning build;
- ASan/UBSan build; Current pre-push validation includes:
- formatter check;
- relevant unit/integration tests. - normal strict-warning build;
- the complete Meson test suite;
- `git diff --check`;
- frozen JSON v1 validators.
ASan/UBSan is run for meaningful implementation checkpoints before declaring a
gate complete.
## 9. Pre-push checklist ## 9. Pre-push checklist
Before every meaningful push: Before every meaningful push:
1. run `python tools/validate_json.py`; 1. run `python tools/validate_json.py`;
2. run relevant compiled tests when available; 2. run `python tools/validate_import_contract.py`;
3. run sanitizers when relevant; 3. run `meson compile -C build`;
4. run `git diff --check`; 4. run `meson test -C build --print-errorlogs`;
5. inspect `git status --short`; 5. run sanitizers when relevant;
6. review documentation changes. 6. run `git diff --check`;
7. inspect `git status --short`;
8. review documentation changes.
## 10. Catalog reconciliation contract ## 10. Catalog reconciliation contract
Before the C17 importer exists, Gate 1 defines local catalog merge behavior through an executable Python specification. Before the C17 importer exists, Gate 1 defines local catalog merge behavior through an executable Python specification.

View file

@ -232,7 +232,7 @@ value instead of repeating the same minimum and maximum label.
History is navigable with the keyboard. History is navigable with the keyboard.
Selecting a workout and pressing `Enter` opens a read-only detail view. Selecting a workout and pressing `Enter` opens its detail view.
For every exercise the detail view exposes: For every exercise the detail view exposes:
@ -262,7 +262,7 @@ Réalisé:
5@80.0 / 5@80.0 / 5@80.0 / 3@80.0 5@80.0 / 5@80.0 / 5@80.0 / 3@80.0
``` ```
Inside one workout, left/right or up/down changes the selected exercise. Inside one workout, left/right or up/down changes the selected exercise. `e` opens the persisted session editor without replacing the parent session identity or timestamps.
Assistance remains direction-aware: more assistance kilograms mean more help, Assistance remains direction-aware: more assistance kilograms mean more help,
not greater strength. not greater strength.
@ -313,11 +313,42 @@ The same parser and formatter are reused for:
Invalid malformed forms are rejected before persistence. Invalid malformed forms are rejected before persistence.
## 19. Body screen and measurement graphs — implemented ## 19. Body screen and measurement history — implemented
`F4 Corps` becomes a full history and visualization screen. `F4 Corps` is record-oriented.
Canonical selectable metrics: The primary body screen shows:
- the weight evolution graph;
- recorded body observations newest first;
- a compact summary per observation;
- a visual scrollbar when the history is longer than the visible area.
Controls:
```text
↑↓ / PgUp / PgDn select a recorded observation
Enter open observation detail
e edit the selected observation
a add an observation
g open the normalized global overlay
b / Esc return
```
Each observation detail is split into two framed pages:
```text
GENERAL
MEMBRES
```
Left/right changes page and `e` edits the observation.
Editing preserves the observation identity, timestamp, and optional session
link. Enter keeps the existing value, `-` clears a metric, and Escape cancels
the complete edit without persistence.
Canonical persisted metrics remain:
```text ```text
body_weight_kg body_weight_kg
@ -336,57 +367,26 @@ left_calf_cm
right_calf_cm right_calf_cm
``` ```
The screen supports left/right navigation between metrics. Missing observations are never invented as zero values.
For the selected metric it shows: ## 20. Current priority before Android
- latest value; The TUI editability checkpoint is complete enough to move toward the Android
- first recorded value; input workflow.
- absolute change;
- terminal-native history graph;
- recent dated values.
Units: Current order:
```text ```text
body weight -> kg 1. finish visual/navigation consistency
measurements -> cm 2. keep persisted session/body editing safe
3. detect an Android phone over USB/ADB
4. build the minimal Android recorder
5. transfer/import through the frozen Trainlog JSON v1 contract
``` ```
Paired measurements also expose asymmetry: Measured-max analytics remain separate from ordinary best-set performance and
are not required for the Android transport milestone.
```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 --> <!-- TRAINLOG_TUI_V02_CURRENT_AND_NEXT _END -->
<!-- TRAINLOG_GLOBAL_BODY_GRAPH_NEXT --> <!-- TRAINLOG_GLOBAL_BODY_GRAPH_NEXT -->
@ -422,8 +422,7 @@ Rules:
- normalization is display-only; - normalization is display-only;
- canonical SQLite values remain untouched. - canonical SQLite values remain untouched.
The global view is toggled from `F4 Corps` with `g`. The global view is toggled from `F4 Corps` with `g`. The normal `F4 Corps` screen remains a newest-first observation history; left/right navigation is used inside observation detail pages.
Per-metric navigation remains left/right.
The global view also shows a compact percentage summary from first to latest The global view also shows a compact percentage summary from first to latest
recorded value for each available metric. recorded value for each available metric.
@ -445,17 +444,27 @@ It will show:
The dashboard remains intentionally compact. The dashboard remains intentionally compact.
The complete multi-metric overlay belongs to `F4 Corps`. The complete multi-metric overlay belongs to `F4 Corps`.
## 23. Immediate TUI implementation order ## 23. Current implementation cursor
```text ```text
TUI_DURATION_HUMAN_INPUT=IMPLEMENTED TUI_DURATION_HUMAN_INPUT=IMPLEMENTED
TUI_BODY_METRIC_GRAPHS=IMPLEMENTED TUI_BODY_METRIC_GRAPHS=IMPLEMENTED
TUI_GLOBAL_BODY_OVERLAY=NEXT TUI_GLOBAL_BODY_OVERLAY=IMPLEMENTED
DASHBOARD_GRAPH_V2=NEXT DASHBOARD_GRAPH_V2=IMPLEMENTED
EXERCISE_PERFORMANCE_GRAPHS=AFTER DASHBOARD_12_MONTHS=IMPLEMENTED
TUI_EXERCISE_PERFORMANCE=IMPLEMENTED
DATABASE_SCHEMA_V2=IMPLEMENTED
SESSION_TYPE_PERSISTENCE=IMPLEMENTED
SESSION_TYPE_TUI=IMPLEMENTED
TUI_SESSION_EDIT=IMPLEMENTED
TUI_BODY_OBSERVATION_EDIT=IMPLEMENTED
TUI_PRIMARY_NAVIGATION=IMPLEMENTED
TUI_SECONDARY_VIEW_POLISH=IMPLEMENTED
ANDROID_USB_DETECTION=NEXT
``` ```
Android remains deferred until these TUI daily-use views are satisfactory. The frozen Trainlog JSON v1 contract remains unchanged.
<!-- TRAINLOG_GLOBAL_BODY_GRAPH_NEXT _END --> <!-- TRAINLOG_GLOBAL_BODY_GRAPH_NEXT _END -->
## Dashboard graph-only layout ## Dashboard graph-only layout
@ -524,3 +533,53 @@ A recorded best set is not a measured maximum.
Measured maxima, max-session scheduling and working-load percentages remain a Measured maxima, max-session scheduling and working-load percentages remain a
separate later contract. separate later contract.
<!-- TRAINLOG_TUI_EDITABILITY_NAV_CHECKPOINT -->
## Current navigation and editability checkpoint
Primary large-layout screens share the same visual identity:
```text
TRAINLOG ASCII banner
top navigation bar
framed page content
footer shortcuts
```
The top navigation is:
```text
0 Accueil 1 Séance 2 Historique 3 Exercices 4 Corps
```
Direct shortcuts keep `1`-`4` / `F1`-`F4`; `0` or `Home` returns to the
dashboard. On multi-zone pages, `Tab` / `Shift+Tab` changes focus. Only the
border and title of the focused frame use the warning/yellow role; content
colors are unchanged.
Large-layout framed/bannnered views include:
- dashboard;
- history;
- exercise catalog;
- body history;
- new-session type selection;
- in-progress session review;
- session detail;
- exercise performance;
- body-observation detail;
- exercise selection during session entry.
Session entry can create a missing exercise directly from the exercise chooser
with `a`, then return to the chooser.
Text prompts treat Escape as immediate cancellation. A cancelled draft or edit
does not persist partial state.
Persisted session replacement edits only session children. The parent session
row, stable ID, timestamps, session type, notes, and body-observation links are
preserved.
The dashboard rolling 12-month axis clamps the final `MM/YY` label inside the
dashboard frame so the current-month label does not overwrite the right border.
<!-- TRAINLOG_TUI_EDITABILITY_NAV_CHECKPOINT _END -->

View file

@ -207,4 +207,121 @@ TrainlogStatus trainlog_database_list_exercise_performance(
size_t *output_count size_t *output_count
); );
/* TRAINLOG_SESSION_EDIT_API */
typedef struct TrainlogEditableExerciseRecord {
char exercise_id[TRAINLOG_ID_MAX + 1U];
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;
char notes[TRAINLOG_NOTE_MAX + 1U];
size_t set_offset;
size_t set_count;
} TrainlogEditableExerciseRecord;
/**
* @brief Load a complete persisted session into caller-owned editable buffers.
*
* The function never truncates a session silently. If either caller capacity is
* too small it returns TRAINLOG_STATUS_INVALID_ARGUMENT and the outputs must not
* be used.
*/
TrainlogStatus trainlog_database_load_session_editable(
TrainlogDatabase *database,
const char *session_id,
TrainlogSessionSummary *output_session,
TrainlogEditableExerciseRecord *output_exercises,
size_t exercise_capacity,
size_t *output_exercise_count,
TrainlogSetInput *output_sets,
size_t set_capacity,
size_t *output_set_count
);
/**
* @brief Replace only the exercise/set contents of an existing session.
*
* The session row itself is preserved, so session_id, timestamps, session type,
* session notes and body_observations.session_row_id remain attached to the
* same row. The replacement is atomic: any error rolls the whole operation
* back.
*/
TrainlogStatus trainlog_database_replace_session_exercises(
TrainlogDatabase *database,
const char *session_id,
const TrainlogSessionExerciseInput *exercises,
size_t exercise_count
);
/* TRAINLOG_BODY_OBSERVATION_RECORD_API */
typedef struct TrainlogBodyObservationRecord {
char observation_id[TRAINLOG_ID_MAX + 1U];
char observed_at[TRAINLOG_TIMESTAMP_MAX + 1U];
char session_id[TRAINLOG_ID_MAX + 1U];
bool has_body_weight;
double body_weight_kg;
bool has_neck;
double neck_cm;
bool has_shoulders;
double shoulders_cm;
bool has_chest;
double chest_cm;
bool has_waist;
double waist_cm;
bool has_hips;
double hips_cm;
bool has_left_arm;
double left_arm_cm;
bool has_right_arm;
double right_arm_cm;
bool has_left_forearm;
double left_forearm_cm;
bool has_right_forearm;
double right_forearm_cm;
bool has_left_thigh;
double left_thigh_cm;
bool has_right_thigh;
double right_thigh_cm;
bool has_left_calf;
double left_calf_cm;
bool has_right_calf;
double right_calf_cm;
char notes[TRAINLOG_NOTE_MAX + 1U];
} TrainlogBodyObservationRecord;
TrainlogStatus trainlog_database_list_body_observations(
TrainlogDatabase *database,
TrainlogBodyObservationRecord *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_get_body_observation(
TrainlogDatabase *database,
const char *observation_id,
TrainlogBodyObservationRecord *output
);
/**
* @brief Correct metric values of one existing observation.
*
* observation_id identifies the stable row. observed_at and session linkage
* remain unchanged. At least one body metric must remain present.
*/
TrainlogStatus trainlog_database_update_body_observation(
TrainlogDatabase *database,
const TrainlogBodyObservationInput *observation
);
#endif #endif

View file

@ -159,3 +159,27 @@ test(
'session_type_schema', 'session_type_schema',
test_session_type_schema, test_session_type_schema,
) )
test_session_edit = executable(
'test_session_edit',
'tests/test_session_edit.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test(
'session_edit',
test_session_edit,
)
test_body_observation_edit = executable(
'test_body_observation_edit',
'tests/test_body_observation_edit.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test(
'body_observation_edit',
test_body_observation_edit,
)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,190 @@
/**
* @file test_body_observation_edit.c
* @brief Body observation list/detail/update persistence tests.
*/
#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_body_observation_edit(void)
{
TrainlogDatabase *database = NULL;
TrainlogBodyObservationInput first;
TrainlogBodyObservationInput second;
TrainlogBodyObservationRecord records[4];
TrainlogBodyObservationRecord loaded;
size_t count = 0U;
CHECK(
trainlog_database_open(
":memory:",
&database
) == TRAINLOG_STATUS_OK
);
(void)memset(&first, 0, sizeof(first));
(void)snprintf(
first.observation_id,
sizeof(first.observation_id),
"%s",
"bo_first"
);
(void)snprintf(
first.observed_at,
sizeof(first.observed_at),
"%s",
"2026-08-01T08:00:00+02:00"
);
first.has_body_weight = true;
first.body_weight_kg = 85.0;
first.has_waist = true;
first.waist_cm = 98.0;
CHECK(
trainlog_database_insert_body_observation(
database,
&first
) == TRAINLOG_STATUS_OK
);
(void)memset(&second, 0, sizeof(second));
(void)snprintf(
second.observation_id,
sizeof(second.observation_id),
"%s",
"bo_second"
);
(void)snprintf(
second.observed_at,
sizeof(second.observed_at),
"%s",
"2026-09-01T08:00:00+02:00"
);
second.has_body_weight = true;
second.body_weight_kg = 84.0;
second.has_waist = true;
second.waist_cm = 96.0;
second.has_chest = true;
second.chest_cm = 104.0;
CHECK(
trainlog_database_insert_body_observation(
database,
&second
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_list_body_observations(
database,
records,
4U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 2U);
CHECK(
strcmp(
records[0].observation_id,
"bo_second"
) == 0
);
CHECK(
strcmp(
records[1].observation_id,
"bo_first"
) == 0
);
second.body_weight_kg = 83.5;
second.has_waist = false;
second.waist_cm = 0.0;
second.has_chest = true;
second.chest_cm = 105.0;
CHECK(
trainlog_database_update_body_observation(
database,
&second
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_get_body_observation(
database,
"bo_second",
&loaded
) == TRAINLOG_STATUS_OK
);
CHECK(
strcmp(
loaded.observed_at,
"2026-09-01T08:00:00+02:00"
) == 0
);
CHECK(loaded.has_body_weight);
CHECK(loaded.body_weight_kg > 83.49);
CHECK(loaded.body_weight_kg < 83.51);
CHECK(!loaded.has_waist);
CHECK(loaded.has_chest);
CHECK(loaded.chest_cm > 104.99);
CHECK(loaded.chest_cm < 105.01);
(void)memset(&second, 0, sizeof(second));
(void)snprintf(
second.observation_id,
sizeof(second.observation_id),
"%s",
"bo_second"
);
CHECK(
trainlog_database_update_body_observation(
database,
&second
) == TRAINLOG_STATUS_INVALID_ARGUMENT
);
trainlog_database_close(database);
return true;
}
int main(void)
{
CHECK(test_body_observation_edit());
(void)printf(
"PASS body_observation_edit\n"
);
return 0;
}

View file

@ -0,0 +1,364 @@
/**
* @file test_session_edit.c
* @brief Transaction-safe persisted session editing tests.
*/
#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 add_exercises(
TrainlogDatabase *database
)
{
CHECK(
trainlog_database_insert_exercise(
database,
"ex_press",
"Presse",
"presse",
TRAINLOG_TRACKING_REPS
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_insert_exercise(
database,
"ex_plank",
"Gainage",
"gainage",
TRAINLOG_TRACKING_DURATION
) == TRAINLOG_STATUS_OK
);
return true;
}
static void bind_reps_exercise(
TrainlogSessionExerciseInput *exercise,
TrainlogSetInput *sets,
size_t set_count,
double weight
)
{
size_t index;
(void)memset(
exercise,
0,
sizeof(*exercise)
);
(void)snprintf(
exercise->exercise_id,
sizeof(exercise->exercise_id),
"%s",
"ex_press"
);
exercise->load_mode =
TRAINLOG_LOAD_EXTERNAL;
exercise->rest_seconds = 90;
exercise->target_sets = 3;
exercise->target_reps = 10;
exercise->target_has_weight = true;
exercise->target_weight_kg = weight;
exercise->notes = "note presse";
exercise->sets = sets;
exercise->set_count = set_count;
for (index = 0U;
index < set_count;
++index) {
(void)memset(
&sets[index],
0,
sizeof(sets[index])
);
sets[index].reps =
10 - (int)index;
sets[index].has_weight = true;
sets[index].weight_kg = weight;
}
}
static bool test_load_replace_and_rollback(void)
{
TrainlogDatabase *database = NULL;
TrainlogSetInput original_sets[3];
TrainlogSetInput replacement_sets[2];
TrainlogSetInput rollback_sets[1];
TrainlogSessionExerciseInput original;
TrainlogSessionExerciseInput replacement;
TrainlogSessionExerciseInput bad[2];
TrainlogSessionInput session;
TrainlogBodyObservationInput body;
TrainlogSessionSummary loaded_session;
TrainlogEditableExerciseRecord loaded_exercises[4];
TrainlogSetInput loaded_sets[16];
size_t exercise_count = 0U;
size_t set_count = 0U;
TrainlogBodyMetricPoint weight_points[4];
size_t weight_count = 0U;
CHECK(
trainlog_database_open(
":memory:",
&database
) == TRAINLOG_STATUS_OK
);
CHECK(add_exercises(database));
bind_reps_exercise(
&original,
original_sets,
3U,
80.0
);
(void)memset(
&session,
0,
sizeof(session)
);
(void)snprintf(
session.session_id,
sizeof(session.session_id),
"%s",
"se_edit"
);
(void)snprintf(
session.started_at,
sizeof(session.started_at),
"%s",
"2026-09-06T08:00:00+02:00"
);
(void)snprintf(
session.ended_at,
sizeof(session.ended_at),
"%s",
"2026-09-06T09:00:00+02:00"
);
session.session_type =
TRAINLOG_SESSION_TRAINING;
session.exercises = &original;
session.exercise_count = 1U;
CHECK(
trainlog_database_insert_session(
database,
&session
) == TRAINLOG_STATUS_OK
);
(void)memset(&body, 0, sizeof(body));
(void)snprintf(
body.observation_id,
sizeof(body.observation_id),
"%s",
"bo_edit"
);
(void)snprintf(
body.observed_at,
sizeof(body.observed_at),
"%s",
"2026-09-06T09:05:00+02:00"
);
body.session_id = "se_edit";
body.has_body_weight = true;
body.body_weight_kg = 84.1;
CHECK(
trainlog_database_insert_body_observation(
database,
&body
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_load_session_editable(
database,
"se_edit",
&loaded_session,
loaded_exercises,
4U,
&exercise_count,
loaded_sets,
16U,
&set_count
) == TRAINLOG_STATUS_OK
);
CHECK(exercise_count == 1U);
CHECK(set_count == 3U);
CHECK(
strcmp(
loaded_exercises[0].exercise_id,
"ex_press"
) == 0
);
CHECK(
strcmp(
loaded_exercises[0].notes,
"note presse"
) == 0
);
CHECK(loaded_exercises[0].set_offset == 0U);
CHECK(loaded_exercises[0].set_count == 3U);
CHECK(loaded_sets[0].reps == 10);
CHECK(loaded_sets[2].reps == 8);
CHECK(loaded_sets[0].weight_kg > 79.99);
CHECK(loaded_sets[0].weight_kg < 80.01);
bind_reps_exercise(
&replacement,
replacement_sets,
2U,
85.0
);
replacement_sets[0].reps = 12;
replacement_sets[1].reps = 11;
CHECK(
trainlog_database_replace_session_exercises(
database,
"se_edit",
&replacement,
1U
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_load_session_editable(
database,
"se_edit",
&loaded_session,
loaded_exercises,
4U,
&exercise_count,
loaded_sets,
16U,
&set_count
) == TRAINLOG_STATUS_OK
);
CHECK(exercise_count == 1U);
CHECK(set_count == 2U);
CHECK(loaded_sets[0].reps == 12);
CHECK(loaded_sets[1].reps == 11);
CHECK(loaded_sets[0].weight_kg > 84.99);
CHECK(loaded_sets[0].weight_kg < 85.01);
/*
* The body observation is linked to the stable sessions row. Replacing
* child exercise/set rows must not delete or detach it.
*/
CHECK(
trainlog_database_list_body_metric_points(
database,
TRAINLOG_BODY_METRIC_WEIGHT,
weight_points,
4U,
&weight_count
) == TRAINLOG_STATUS_OK
);
CHECK(weight_count == 1U);
CHECK(weight_points[0].value > 84.09);
CHECK(weight_points[0].value < 84.11);
bind_reps_exercise(
&bad[0],
rollback_sets,
1U,
95.0
);
bad[1] = bad[0];
(void)snprintf(
bad[1].exercise_id,
sizeof(bad[1].exercise_id),
"%s",
"ex_missing"
);
CHECK(
trainlog_database_replace_session_exercises(
database,
"se_edit",
bad,
2U
) == TRAINLOG_STATUS_NOT_FOUND
);
/*
* The failed replacement deleted and reinserted rows inside one
* transaction. Rollback must restore the previous 85 kg / 12,11 data.
*/
CHECK(
trainlog_database_load_session_editable(
database,
"se_edit",
&loaded_session,
loaded_exercises,
4U,
&exercise_count,
loaded_sets,
16U,
&set_count
) == TRAINLOG_STATUS_OK
);
CHECK(exercise_count == 1U);
CHECK(set_count == 2U);
CHECK(loaded_sets[0].reps == 12);
CHECK(loaded_sets[1].reps == 11);
CHECK(loaded_sets[0].weight_kg > 84.99);
CHECK(loaded_sets[0].weight_kg < 85.01);
trainlog_database_close(database);
return true;
}
int main(void)
{
CHECK(test_load_replace_and_rollback());
(void)printf(
"PASS session_edit\n"
);
return 0;
}