Add editable TUI workflows and unified navigation
This commit is contained in:
parent
23c5fcdf06
commit
3d99be00f5
12 changed files with 6556 additions and 853 deletions
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -91,3 +91,35 @@ Next:
|
|||
- added transactional SQLite schema migration v1 -> v2;
|
||||
- preserved migrated sessions as `training`;
|
||||
- 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 -->
|
||||
|
|
|
|||
38
docs/reviews/body_observation_edit.md
Normal file
38
docs/reviews/body_observation_edit.md
Normal 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.
|
||||
33
docs/reviews/session_edit_persistence.md
Normal file
33
docs/reviews/session_edit_persistence.md
Normal 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.
|
||||
|
|
@ -33,14 +33,15 @@ Current TUI capabilities:
|
|||
- navigable history;
|
||||
- Unicode anti-duplicate exercise names.
|
||||
|
||||
Next TUI polish blocks:
|
||||
Current remaining Gate 2 direction:
|
||||
|
||||
1. full session detail;
|
||||
2. exercise performance history and graphs;
|
||||
3. previous-session defaults;
|
||||
4. safe edit/delete flows.
|
||||
1. finish the current visual/navigation checkpoint;
|
||||
2. validate persisted editing on the real database;
|
||||
3. detect Android over USB/ADB;
|
||||
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 -->
|
||||
## TUI v0.2 checkpoint
|
||||
|
|
@ -130,11 +131,65 @@ PREVIOUS_SESSION_DEFAULTS=AFTER
|
|||
```text
|
||||
DATABASE_SCHEMA_V2=IMPLEMENTED
|
||||
SESSION_TYPE_PERSISTENCE=IMPLEMENTED
|
||||
SESSION_TYPE_TUI=NEXT
|
||||
MEASURED_MAX_TRACKING=AFTER
|
||||
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
|
||||
MEASURED_MAX_TRACKING=LATER
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
```
|
||||
|
||||
SQLite schema v2 adds `sessions.session_type` with `training` and `max_test`.
|
||||
Existing v1 rows migrate to `training`. No existing session is retroactively
|
||||
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 -->
|
||||
|
|
|
|||
|
|
@ -81,36 +81,53 @@ Gate 1 includes rejection of:
|
|||
- blank exercise notes;
|
||||
- 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;
|
||||
- duplicate `session_id` idempotency;
|
||||
- duplicate `exercise_id` barrier;
|
||||
- transactional rollback;
|
||||
- schema migration correctness.
|
||||
```text
|
||||
database
|
||||
catalog
|
||||
session_detail
|
||||
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;
|
||||
- strict-warning build;
|
||||
- ASan/UBSan build;
|
||||
- formatter check;
|
||||
- relevant unit/integration tests.
|
||||
## 8. C validation
|
||||
|
||||
Current pre-push validation includes:
|
||||
|
||||
- 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
|
||||
|
||||
Before every meaningful push:
|
||||
|
||||
1. run `python tools/validate_json.py`;
|
||||
2. run relevant compiled tests when available;
|
||||
3. run sanitizers when relevant;
|
||||
4. run `git diff --check`;
|
||||
5. inspect `git status --short`;
|
||||
6. review documentation changes.
|
||||
2. run `python tools/validate_import_contract.py`;
|
||||
3. run `meson compile -C build`;
|
||||
4. run `meson test -C build --print-errorlogs`;
|
||||
5. run sanitizers when relevant;
|
||||
6. run `git diff --check`;
|
||||
7. inspect `git status --short`;
|
||||
8. review documentation changes.
|
||||
## 10. Catalog reconciliation contract
|
||||
|
||||
Before the C17 importer exists, Gate 1 defines local catalog merge behavior through an executable Python specification.
|
||||
|
|
|
|||
169
docs/tui.md
169
docs/tui.md
|
|
@ -232,7 +232,7 @@ value instead of repeating the same minimum and maximum label.
|
|||
|
||||
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:
|
||||
|
||||
|
|
@ -262,7 +262,7 @@ 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.
|
||||
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,
|
||||
not greater strength.
|
||||
|
|
@ -313,11 +313,42 @@ The same parser and formatter are reused for:
|
|||
|
||||
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
|
||||
body_weight_kg
|
||||
|
|
@ -336,57 +367,26 @@ left_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;
|
||||
- first recorded value;
|
||||
- absolute change;
|
||||
- terminal-native history graph;
|
||||
- recent dated values.
|
||||
The TUI editability checkpoint is complete enough to move toward the Android
|
||||
input workflow.
|
||||
|
||||
Units:
|
||||
Current order:
|
||||
|
||||
```text
|
||||
body weight -> kg
|
||||
measurements -> cm
|
||||
1. finish visual/navigation consistency
|
||||
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_GLOBAL_BODY_GRAPH_NEXT -->
|
||||
|
|
@ -422,8 +422,7 @@ Rules:
|
|||
- normalization is display-only;
|
||||
- canonical SQLite values remain untouched.
|
||||
|
||||
The global view is toggled from `F4 Corps` with `g`.
|
||||
Per-metric navigation remains left/right.
|
||||
The global view 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.
|
||||
|
||||
The global view also shows a compact percentage summary from first to latest
|
||||
recorded value for each available metric.
|
||||
|
|
@ -445,17 +444,27 @@ It will show:
|
|||
The dashboard remains intentionally compact.
|
||||
The complete multi-metric overlay belongs to `F4 Corps`.
|
||||
|
||||
## 23. Immediate TUI implementation order
|
||||
## 23. Current implementation cursor
|
||||
|
||||
```text
|
||||
TUI_DURATION_HUMAN_INPUT=IMPLEMENTED
|
||||
TUI_BODY_METRIC_GRAPHS=IMPLEMENTED
|
||||
TUI_GLOBAL_BODY_OVERLAY=NEXT
|
||||
DASHBOARD_GRAPH_V2=NEXT
|
||||
EXERCISE_PERFORMANCE_GRAPHS=AFTER
|
||||
TUI_GLOBAL_BODY_OVERLAY=IMPLEMENTED
|
||||
DASHBOARD_GRAPH_V2=IMPLEMENTED
|
||||
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 -->
|
||||
|
||||
## 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
|
||||
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 -->
|
||||
|
|
|
|||
|
|
@ -207,4 +207,121 @@ TrainlogStatus trainlog_database_list_exercise_performance(
|
|||
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
|
||||
|
|
|
|||
|
|
@ -159,3 +159,27 @@ 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,
|
||||
)
|
||||
|
|
|
|||
1283
tui/src/database.c
1283
tui/src/database.c
File diff suppressed because it is too large
Load diff
5033
tui/src/tui.c
5033
tui/src/tui.c
File diff suppressed because it is too large
Load diff
190
tui/tests/test_body_observation_edit.c
Normal file
190
tui/tests/test_body_observation_edit.c
Normal 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;
|
||||
}
|
||||
364
tui/tests/test_session_edit.c
Normal file
364
tui/tests/test_session_edit.c
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue