Add profile-aware continuous exercise tracking

This commit is contained in:
fy59 2026-09-06 13:13:33 +02:00
parent a3eeb91869
commit db349c609d
17 changed files with 2677 additions and 280 deletions

View file

@ -260,3 +260,27 @@ Transport/export is added only after the local recorder workflow is comfortable.
The Android client must preserve stable exercise IDs so a catalog snapshot from The Android client must preserve stable exercise IDs so a catalog snapshot from
the PC can normalize exercise selection on both sides. the PC can normalize exercise selection on both sides.
<!-- TRAINLOG_ANDROID_NEXT_SLICE _END --> <!-- TRAINLOG_ANDROID_NEXT_SLICE _END -->
<!-- TRAINLOG_ANDROID_PROFILE_AWARE_ENTRY -->
## Profile-aware Android forms
Android uses the same exercise metadata as the TUI:
```text
recording_mode
tracking_mode
data_fields
```
A continuous `Marche` configured with speed displays only:
```text
Durée
Vitesse
```
and no set count.
Creating an exercise directly inside session entry must configure this metadata
before adding it to the catalog/session.
<!-- TRAINLOG_ANDROID_PROFILE_AWARE_ENTRY _END -->

View file

@ -282,3 +282,25 @@ python tools/validate_json.py
python tools/validate_import_contract.py python tools/validate_import_contract.py
git diff --check git diff --check
``` ```
<!-- TRAINLOG_EXERCISE_DATA_MODEL_V1 -->
## Exercise recording metadata — schema v3 direction
The next SQLite migration adds:
```text
recording_mode = sets | continuous
data_fields = bounded bit mask
```
Existing `tracking_mode = reps | duration` remains stable.
Every `session_exercises` row snapshots this metadata so later catalog changes
do not reinterpret historical sessions.
Migration v2 -> v3 defaults all existing rows to `sets` with `data_fields = 0`.
No name-based migration is allowed.
Continuous actual activity data is stored separately from `performed_sets`;
Trainlog will not manufacture a fake one-set workout.
<!-- TRAINLOG_EXERCISE_DATA_MODEL_V1 _END -->

195
docs/exercise_data_model.md Normal file
View file

@ -0,0 +1,195 @@
# Exercise data model
## Status
```text
EXERCISE_DATA_MODEL_V1=FROZEN_FOR_IMPLEMENTATION
DATABASE_SCHEMA_V3=NEXT
TUI_PROFILE_AWARE_ENTRY=AFTER_SCHEMA_V3
ANDROID_PROFILE_AWARE_ENTRY=AFTER_TUI
TRAINLOG_FORMAT_V1=FROZEN
SESSION_EXCHANGE_V2=DESIGN_REQUIRED_LATER
```
Trainlog must not use one universal exercise form.
An exercise is defined by three independent pieces of metadata:
```text
recording_mode = SETS | CONTINUOUS
tracking_mode = REPS | DURATION
data_fields = supplemental field bit mask
```
Initial valid combinations:
```text
SETS + REPS
SETS + DURATION
CONTINUOUS + DURATION
```
`CONTINUOUS + REPS` is invalid in model v1.
Initial supplemental fields:
```text
SPEED_KMH
DISTANCE_KM
```
Unknown field bits are invalid.
Examples:
```text
Presse à cuisses
SETS + REPS
Gainage
SETS + DURATION
Marche
CONTINUOUS + DURATION
SPEED_KMH
Course
CONTINUOUS + DURATION
SPEED_KMH
Vélo
CONTINUOUS + DURATION
SPEED_KMH | DISTANCE_KM
Rameur
CONTINUOUS + DURATION
DISTANCE_KM
```
Load semantics remain separate and session-specific:
```text
none
external
assistance
```
A continuous exercise does not ask for:
```text
number of sets
repetitions
per-set rest
```
For example:
```text
Marche
Durée 45 min
Vitesse 5.8 km/h
```
Creating/editing an exercise asks for:
```text
Name
Organization: Sets | Continuous
Primary metric: Repetitions | Duration
Supplemental fields: Speed | Distance
```
Rules:
- continuous forces duration in model v1;
- sets accepts reps or duration;
- UI fields are driven by metadata, never exercise-name heuristics;
- changing catalog metadata affects future entry only.
Historical stability:
Every session exercise stores a snapshot of:
```text
recording_mode
tracking_mode
data_fields
```
So changing `Marche` from an old set-based duration exercise to continuous
duration + speed does not reinterpret old sessions.
## SQLite schema v3 direction
Schema v3 adds to `exercises`:
```text
recording_mode
data_fields
```
and snapshots the same values in `session_exercises`.
Migration v2 -> v3 is conservative:
```text
all existing exercises -> SETS
all existing session exercises -> SETS
data_fields -> 0
```
No migration guesses by exercise name.
Continuous actual activity data gets its own one-to-one record:
```text
duration_seconds
speed_kmh nullable
distance_km nullable
```
Continuous activities have no `performed_sets` rows and no fake one-set
representation.
## Frozen JSON v1
Trainlog JSON session v1 remains frozen.
It represents the existing set-based exchange model.
Continuous data that cannot be represented in v1 must not be:
- hidden in notes;
- converted into a fake set;
- silently discarded.
A future explicit session exchange v2 will carry profile-aware exercise data
while v1 import remains supported.
## Android/TUI parity
Both interfaces consume identical exercise metadata.
The future PC -> Android catalog snapshot must contain:
```text
exercise_id
name
recording_mode
tracking_mode
data_fields
```
## Implementation order
```text
1. SQLite schema v3 + migration tests
2. C model/API additions
3. catalog create/edit support
4. TUI profile-aware session entry
5. manually convert Marche to CONTINUOUS + SPEED_KMH
6. profile-aware detail/history
7. Android uses the same model
8. design session exchange v2
```

View file

@ -285,3 +285,27 @@ Fictitious sessions, exercises, and body observations are used during Android
development and synchronization testing. The development database will be development and synchronization testing. The development database will be
purged before normal production use begins. purged before normal production use begins.
<!-- TRAINLOG_SYNC_FINAL_ROADMAP _END --> <!-- TRAINLOG_SYNC_FINAL_ROADMAP _END -->
<!-- TRAINLOG_EXERCISE_DATA_MODEL_ROADMAP -->
## Exercise data-model checkpoint
```text
EXERCISE_DATA_MODEL_V1=FROZEN_FOR_IMPLEMENTATION
DATABASE_SCHEMA_V3=NEXT
PROFILE_AWARE_C_MODEL=AFTER_SCHEMA
PROFILE_AWARE_TUI=AFTER
ANDROID_PROFILE_AWARE_UI=AFTER
SESSION_EXCHANGE_V2=DESIGN_LATER
TRAINLOG_FORMAT_V1=FROZEN
```
The model separates:
```text
recording organization: SETS | CONTINUOUS
primary metric: REPS | DURATION
supplemental fields: SPEED_KMH | DISTANCE_KM
```
Existing schema-v2 data migrates conservatively to `SETS`.
<!-- TRAINLOG_EXERCISE_DATA_MODEL_ROADMAP _END -->

View file

@ -674,3 +674,25 @@ PC -> Android
The session exchange remains frozen Trainlog JSON v1. The session exchange remains frozen Trainlog JSON v1.
Catalog synchronization is a separate versioned contract. Catalog synchronization is a separate versioned contract.
<!-- TRAINLOG_SYNC_FINAL_CHECKPOINT _END --> <!-- TRAINLOG_SYNC_FINAL_CHECKPOINT _END -->
<!-- TRAINLOG_PROFILE_AWARE_ENTRY -->
## Profile-aware exercise entry
The TUI form is driven by exercise metadata.
```text
SETS + REPS
series, reps, optional load, rest
SETS + DURATION
series, duration, optional load, rest
CONTINUOUS + DURATION + SPEED_KMH
duration, speed
```
Continuous exercises do not display a set count.
`Marche` will use the continuous form only after its catalog metadata is
explicitly changed; behavior is never inferred from its name.
<!-- TRAINLOG_PROFILE_AWARE_ENTRY _END -->

View file

@ -40,4 +40,13 @@ TrainlogStatus trainlog_catalog_create_exercise(
TrainlogExercise *output_exercise TrainlogExercise *output_exercise
); );
TrainlogStatus trainlog_catalog_create_exercise_profiled(
TrainlogDatabase *database,
const char *name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields,
TrainlogExercise *output_exercise
);
#endif #endif

View file

@ -11,7 +11,7 @@
#include "trainlog/model.h" #include "trainlog/model.h"
#include "trainlog/status.h" #include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 2 #define TRAINLOG_DATABASE_SCHEMA_VERSION 4
typedef struct TrainlogDatabase TrainlogDatabase; typedef struct TrainlogDatabase TrainlogDatabase;
@ -50,6 +50,16 @@ TrainlogStatus trainlog_database_insert_exercise(
TrainlogTrackingMode tracking_mode TrainlogTrackingMode tracking_mode
); );
TrainlogStatus trainlog_database_insert_exercise_profiled(
TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields
);
TrainlogStatus trainlog_database_exercise_count( TrainlogStatus trainlog_database_exercise_count(
TrainlogDatabase *database, TrainlogDatabase *database,
size_t *output_count size_t *output_count
@ -99,6 +109,8 @@ TrainlogStatus trainlog_database_list_weight_points(
typedef struct TrainlogPersistedExerciseDetail { typedef struct TrainlogPersistedExerciseDetail {
char name[TRAINLOG_NAME_MAX + 1U]; char name[TRAINLOG_NAME_MAX + 1U];
TrainlogTrackingMode tracking_mode; TrainlogTrackingMode tracking_mode;
TrainlogRecordingMode recording_mode;
TrainlogExerciseDataFields data_fields;
TrainlogLoadMode load_mode; TrainlogLoadMode load_mode;
int rest_seconds; int rest_seconds;
int target_sets; int target_sets;
@ -106,6 +118,13 @@ typedef struct TrainlogPersistedExerciseDetail {
int target_duration_seconds; int target_duration_seconds;
int has_target_weight; int has_target_weight;
double target_weight_kg; double target_weight_kg;
int continuous_duration_seconds;
int has_continuous_speed;
double continuous_speed_kmh;
int has_continuous_distance;
double continuous_distance_km;
size_t actual_set_count; size_t actual_set_count;
char actual_summary[TRAINLOG_SET_SUMMARY_MAX + 1U]; char actual_summary[TRAINLOG_SET_SUMMARY_MAX + 1U];
} TrainlogPersistedExerciseDetail; } TrainlogPersistedExerciseDetail;

View file

@ -8,6 +8,7 @@
#include <stdbool.h> #include <stdbool.h>
#include <stddef.h> #include <stddef.h>
#include <stdint.h>
#define TRAINLOG_ID_MAX 128U #define TRAINLOG_ID_MAX 128U
#define TRAINLOG_NAME_MAX 200U #define TRAINLOG_NAME_MAX 200U
@ -19,6 +20,19 @@ typedef enum TrainlogTrackingMode {
TRAINLOG_TRACKING_DURATION TRAINLOG_TRACKING_DURATION
} TrainlogTrackingMode; } TrainlogTrackingMode;
typedef enum TrainlogRecordingMode {
TRAINLOG_RECORDING_SETS = 0,
TRAINLOG_RECORDING_CONTINUOUS
} TrainlogRecordingMode;
typedef uint32_t TrainlogExerciseDataFields;
#define TRAINLOG_EXERCISE_DATA_SPEED_KMH UINT32_C(1)
#define TRAINLOG_EXERCISE_DATA_DISTANCE_KM UINT32_C(2)
#define TRAINLOG_EXERCISE_DATA_KNOWN_MASK \
(TRAINLOG_EXERCISE_DATA_SPEED_KMH | \
TRAINLOG_EXERCISE_DATA_DISTANCE_KM)
typedef enum TrainlogLoadMode { typedef enum TrainlogLoadMode {
TRAINLOG_LOAD_NONE = 0, TRAINLOG_LOAD_NONE = 0,
TRAINLOG_LOAD_EXTERNAL, TRAINLOG_LOAD_EXTERNAL,
@ -34,6 +48,8 @@ typedef struct TrainlogExercise {
char exercise_id[TRAINLOG_ID_MAX + 1U]; char exercise_id[TRAINLOG_ID_MAX + 1U];
char name[TRAINLOG_NAME_MAX + 1U]; char name[TRAINLOG_NAME_MAX + 1U];
TrainlogTrackingMode tracking_mode; TrainlogTrackingMode tracking_mode;
TrainlogRecordingMode recording_mode;
TrainlogExerciseDataFields data_fields;
} TrainlogExercise; } TrainlogExercise;
typedef struct TrainlogSetInput { typedef struct TrainlogSetInput {
@ -45,6 +61,8 @@ typedef struct TrainlogSetInput {
typedef struct TrainlogSessionExerciseInput { typedef struct TrainlogSessionExerciseInput {
char exercise_id[TRAINLOG_ID_MAX + 1U]; char exercise_id[TRAINLOG_ID_MAX + 1U];
TrainlogRecordingMode recording_mode;
TrainlogExerciseDataFields data_fields;
TrainlogLoadMode load_mode; TrainlogLoadMode load_mode;
int rest_seconds; int rest_seconds;
int target_sets; int target_sets;
@ -52,6 +70,13 @@ typedef struct TrainlogSessionExerciseInput {
int target_duration_seconds; int target_duration_seconds;
bool target_has_weight; bool target_has_weight;
double target_weight_kg; double target_weight_kg;
int continuous_duration_seconds;
bool continuous_has_speed;
double continuous_speed_kmh;
bool continuous_has_distance;
double continuous_distance_km;
const char *notes; const char *notes;
const TrainlogSetInput *sets; const TrainlogSetInput *sets;
size_t set_count; size_t set_count;

View file

@ -243,3 +243,39 @@ trainlog_mtp_roundtrip_probe = executable(
dependencies: trainlog_core_dep, dependencies: trainlog_core_dep,
c_args: strict_c_args, c_args: strict_c_args,
) )
test_exercise_profile_schema = executable(
'test_exercise_profile_schema',
'tests/test_exercise_profile_schema.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test(
'exercise_profile_schema',
test_exercise_profile_schema,
)
test_continuous_session = executable(
'test_continuous_session',
'tests/test_continuous_session.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test(
'continuous_session',
test_continuous_session,
)
test_continuous_detail = executable(
'test_continuous_detail',
'tests/test_continuous_detail.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test(
'continuous_detail',
test_continuous_detail,
)

View file

@ -131,10 +131,12 @@ TrainlogStatus trainlog_catalog_normalize_name(
return TRAINLOG_STATUS_OK; return TRAINLOG_STATUS_OK;
} }
TrainlogStatus trainlog_catalog_create_exercise( TrainlogStatus trainlog_catalog_create_exercise_profiled(
TrainlogDatabase *database, TrainlogDatabase *database,
const char *name, const char *name,
TrainlogTrackingMode tracking_mode, TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields,
TrainlogExercise *output_exercise TrainlogExercise *output_exercise
) )
{ {
@ -154,6 +156,7 @@ TrainlogStatus trainlog_catalog_create_exercise(
normalized, normalized,
sizeof(normalized) sizeof(normalized)
); );
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
return status; return status;
} }
@ -163,35 +166,65 @@ TrainlogStatus trainlog_catalog_create_exercise(
exercise_id, exercise_id,
sizeof(exercise_id) sizeof(exercise_id)
); );
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
return status; return status;
} }
status = trainlog_database_insert_exercise( status = trainlog_database_insert_exercise_profiled(
database, database,
exercise_id, exercise_id,
name, name,
normalized, normalized,
tracking_mode tracking_mode,
recording_mode,
data_fields
); );
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
return status; return status;
} }
(void)memset(output_exercise, 0, sizeof(*output_exercise)); (void)memset(
output_exercise,
0,
sizeof(*output_exercise)
);
(void)snprintf( (void)snprintf(
output_exercise->exercise_id, output_exercise->exercise_id,
sizeof(output_exercise->exercise_id), sizeof(output_exercise->exercise_id),
"%s", "%s",
exercise_id exercise_id
); );
(void)snprintf( (void)snprintf(
output_exercise->name, output_exercise->name,
sizeof(output_exercise->name), sizeof(output_exercise->name),
"%s", "%s",
name name
); );
output_exercise->tracking_mode = tracking_mode; output_exercise->tracking_mode = tracking_mode;
output_exercise->recording_mode = recording_mode;
output_exercise->data_fields = data_fields;
return TRAINLOG_STATUS_OK; return TRAINLOG_STATUS_OK;
} }
TrainlogStatus trainlog_catalog_create_exercise(
TrainlogDatabase *database,
const char *name,
TrainlogTrackingMode tracking_mode,
TrainlogExercise *output_exercise
)
{
return trainlog_catalog_create_exercise_profiled(
database,
name,
tracking_mode,
TRAINLOG_RECORDING_SETS,
0U,
output_exercise
);
}

File diff suppressed because it is too large Load diff

View file

@ -38,6 +38,7 @@
/* TRAINLOG_TUI_V02_POLISH */ /* TRAINLOG_TUI_V02_POLISH */
/* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */
typedef enum DashboardAction { typedef enum DashboardAction {
DASHBOARD_NEW_SESSION = 0, DASHBOARD_NEW_SESSION = 0,
@ -1756,7 +1757,11 @@ if (primary_top_nav_activate(
if (key == 'a' || if (key == 'a' ||
key == 'A') { key == 'A') {
char name[TRAINLOG_NAME_MAX + 1U]; char name[TRAINLOG_NAME_MAX + 1U];
int mode = 1; int mode = 1;
int organization = 1;
int wants_speed = 0;
int wants_distance = 0;
TrainlogExerciseDataFields data_fields = 0U;
TrainlogExercise created; TrainlogExercise created;
TrainlogStatus status; TrainlogStatus status;
@ -1786,14 +1791,64 @@ if (primary_top_nav_activate(
continue; continue;
} }
status = if (!prompt_int_value(
trainlog_catalog_create_exercise( 8,
"Organisation (1 séries, 2 continu)",
1,
2,
1,
&organization
)) {
continue;
}
if (organization == 2) {
mode = 2;
if (!prompt_int_value(
10,
"Mesurer la vitesse km/h ? (0 non, 1 oui)",
0,
1,
1,
&wants_speed
)) {
continue;
}
if (!prompt_int_value(
12,
"Mesurer la distance km ? (0 non, 1 oui)",
0,
1,
0,
&wants_distance
)) {
continue;
}
if (wants_speed != 0) {
data_fields |=
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
}
if (wants_distance != 0) {
data_fields |=
TRAINLOG_EXERCISE_DATA_DISTANCE_KM;
}
}
status =
trainlog_catalog_create_exercise_profiled(
database, database,
name, name,
mode == 1 mode == 1
? TRAINLOG_TRACKING_REPS ? TRAINLOG_TRACKING_REPS
: TRAINLOG_TRACKING_DURATION, : TRAINLOG_TRACKING_DURATION,
&created organization == 2
? TRAINLOG_RECORDING_CONTINUOUS
: TRAINLOG_RECORDING_SETS,
data_fields,
&created
); );
if (status == if (status ==
@ -1827,7 +1882,11 @@ static bool create_exercise_inline(
) )
{ {
char name[TRAINLOG_NAME_MAX + 1U]; char name[TRAINLOG_NAME_MAX + 1U];
int mode = 1; int mode = 1;
int organization = 1;
int wants_speed = 0;
int wants_distance = 0;
TrainlogExerciseDataFields data_fields = 0U;
TrainlogExercise created; TrainlogExercise created;
TrainlogStatus status; TrainlogStatus status;
@ -1857,14 +1916,64 @@ static bool create_exercise_inline(
return false; return false;
} }
status = if (!prompt_int_value(
trainlog_catalog_create_exercise( 8,
"Organisation (1 séries, 2 continu)",
1,
2,
1,
&organization
)) {
return false;
}
if (organization == 2) {
mode = 2;
if (!prompt_int_value(
10,
"Mesurer la vitesse km/h ? (0 non, 1 oui)",
0,
1,
1,
&wants_speed
)) {
return false;
}
if (!prompt_int_value(
12,
"Mesurer la distance km ? (0 non, 1 oui)",
0,
1,
0,
&wants_distance
)) {
return false;
}
if (wants_speed != 0) {
data_fields |=
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
}
if (wants_distance != 0) {
data_fields |=
TRAINLOG_EXERCISE_DATA_DISTANCE_KM;
}
}
status =
trainlog_catalog_create_exercise_profiled(
database, database,
name, name,
mode == 1 mode == 1
? TRAINLOG_TRACKING_REPS ? TRAINLOG_TRACKING_REPS
: TRAINLOG_TRACKING_DURATION, : TRAINLOG_TRACKING_DURATION,
&created organization == 2
? TRAINLOG_RECORDING_CONTINUOUS
: TRAINLOG_RECORDING_SETS,
data_fields,
&created
); );
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
@ -4378,12 +4487,119 @@ static bool build_session_exercise(
double target_weight = 0.0; double target_weight = 0.0;
size_t set_index; size_t set_index;
if (!choose_exercise(database, &exercise)) { if (!choose_exercise(
database,
&exercise
)) {
return false; return false;
} }
(void)memset(
output,
0,
sizeof(*output)
);
(void)snprintf(
output->exercise_id,
sizeof(output->exercise_id),
"%s",
exercise.exercise_id
);
output->recording_mode =
exercise.recording_mode;
output->data_fields =
exercise.data_fields;
if (exercise.recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS) {
int duration_minutes = 30;
int duration_seconds;
bool has_value = false;
double value = 0.0;
draw_shell(
exercise.name,
"Échap annuler · activité continue"
);
if (!prompt_int_value(
4,
"Durée (minutes)",
1,
1440,
duration_minutes,
&duration_minutes
)) {
return false;
}
duration_seconds =
duration_minutes * 60;
output->continuous_duration_seconds =
duration_seconds;
output->load_mode =
TRAINLOG_LOAD_NONE;
output->rest_seconds = 0;
output->target_sets = 0;
output->target_reps = 0;
output->target_duration_seconds = 0;
output->target_has_weight = false;
output->sets = NULL;
output->set_count = 0U;
if ((exercise.data_fields &
TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U) {
if (!prompt_optional_double(
5,
"Vitesse km/h : ",
&has_value,
&value
) ||
!has_value) {
return false;
}
output->continuous_has_speed =
true;
output->continuous_speed_kmh =
value;
}
if ((exercise.data_fields &
TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U) {
has_value = false;
value = 0.0;
if (!prompt_optional_double(
6,
"Distance km : ",
&has_value,
&value
) ||
!has_value) {
return false;
}
output->continuous_has_distance =
true;
output->continuous_distance_km =
value;
}
return true;
}
target_metric = target_metric =
exercise.tracking_mode == TRAINLOG_TRACKING_REPS exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS
? 10 ? 10
: 45; : 45;
@ -4409,7 +4625,8 @@ static bool build_session_exercise(
"Charge cible kg : ", "Charge cible kg : ",
&target_has_weight, &target_has_weight,
&target_weight &target_weight
) || !target_has_weight) { ) ||
!target_has_weight) {
return false; return false;
} }
} }
@ -4425,7 +4642,8 @@ static bool build_session_exercise(
return false; return false;
} }
if (exercise.tracking_mode == TRAINLOG_TRACKING_REPS) { if (exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS) {
if (!prompt_int_value( if (!prompt_int_value(
7, 7,
"Répétitions cibles", "Répétitions cibles",
@ -4471,42 +4689,45 @@ static bool build_session_exercise(
return false; return false;
} }
(void)memset(output, 0, sizeof(*output));
(void)snprintf(
output->exercise_id,
sizeof(output->exercise_id),
"%s",
exercise.exercise_id
);
output->load_mode = output->load_mode =
load_mode == 1 load_mode == 1
? TRAINLOG_LOAD_NONE ? TRAINLOG_LOAD_NONE
: (load_mode == 2 : (
? TRAINLOG_LOAD_EXTERNAL load_mode == 2
: TRAINLOG_LOAD_ASSISTANCE); ? TRAINLOG_LOAD_EXTERNAL
: TRAINLOG_LOAD_ASSISTANCE
);
output->rest_seconds = rest_seconds; output->rest_seconds = rest_seconds;
output->target_sets = target_sets; output->target_sets = target_sets;
output->target_reps = output->target_reps =
exercise.tracking_mode == TRAINLOG_TRACKING_REPS exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS
? target_metric ? target_metric
: 0; : 0;
output->target_duration_seconds = output->target_duration_seconds =
exercise.tracking_mode == TRAINLOG_TRACKING_DURATION exercise.tracking_mode ==
TRAINLOG_TRACKING_DURATION
? target_metric ? target_metric
: 0; : 0;
output->target_has_weight = target_has_weight; output->target_has_weight =
output->target_weight_kg = target_weight; target_has_weight;
output->sets = set_storage;
output->set_count = (size_t)actual_sets;
for (set_index = 0U; set_index < output->set_count; ++set_index) { output->target_weight_kg =
int actual_metric = target_metric; target_weight;
output->sets = set_storage;
output->set_count =
(size_t)actual_sets;
for (set_index = 0U;
set_index < output->set_count;
++set_index) {
int actual_metric =
target_metric;
(void)memset( (void)memset(
&set_storage[set_index], &set_storage[set_index],
@ -4527,7 +4748,8 @@ static bool build_session_exercise(
output->set_count output->set_count
); );
if (exercise.tracking_mode == TRAINLOG_TRACKING_REPS) { if (exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS) {
if (!prompt_int_value( if (!prompt_int_value(
5, 5,
"Répétitions réalisées", "Répétitions réalisées",
@ -4539,7 +4761,8 @@ static bool build_session_exercise(
return false; return false;
} }
set_storage[set_index].reps = actual_metric; set_storage[set_index].reps =
actual_metric;
} else { } else {
if (!prompt_duration_value( if (!prompt_duration_value(
5, 5,
@ -4552,13 +4775,17 @@ static bool build_session_exercise(
return false; return false;
} }
set_storage[set_index].duration_seconds = actual_metric; set_storage[
set_index
].duration_seconds =
actual_metric;
} }
if (target_has_weight) { if (target_has_weight) {
char buffer[64]; char buffer[64];
char prompt[128]; char prompt[128];
double actual_weight = target_weight; double actual_weight =
target_weight;
(void)snprintf( (void)snprintf(
prompt, prompt,
@ -4586,12 +4813,20 @@ static bool build_session_exercise(
"Charge invalide.", "Charge invalide.",
TRAINLOG_COLOR_ERROR TRAINLOG_COLOR_ERROR
); );
wait_key(); wait_key();
return false; return false;
} }
set_storage[set_index].has_weight = true; set_storage[
set_storage[set_index].weight_kg = actual_weight; set_index
].has_weight =
true;
set_storage[
set_index
].weight_kg =
actual_weight;
} }
} }
@ -6178,173 +6413,277 @@ static void screen_session_detail(
TrainlogPersistedExerciseDetail *exercise = TrainlogPersistedExerciseDetail *exercise =
&exercises[selected]; &exercises[selected];
char rest_text[64]; if (exercise->recording_mode ==
char target_duration_text[64]; TRAINLOG_RECORDING_CONTINUOUS) {
char duration_text[64];
int title_row = int title_row =
decorated ? 16 : 6; decorated ? 16 : 6;
int mode_row = int mode_row =
decorated ? 18 : 8; decorated ? 18 : 8;
int target_row = int first_data_row =
decorated ? 20 : 10; decorated ? 20 : 10;
int weight_row = int row =
decorated ? 21 : 11; first_data_row;
int actual_row = if (trainlog_duration_format(
decorated ? 23 : 13; exercise->continuous_duration_seconds,
duration_text,
sizeof(duration_text)
) != TRAINLOG_STATUS_OK) {
(void)snprintf(
duration_text,
sizeof(duration_text),
"%d s",
exercise->continuous_duration_seconds
);
}
int summary_row =
decorated ? 25 : 15;
int warning_row =
decorated ? 27 : 17;
if (trainlog_duration_format(
exercise->rest_seconds,
rest_text,
sizeof(rest_text)
) != TRAINLOG_STATUS_OK) {
(void)snprintf(
rest_text,
sizeof(rest_text),
"%ds",
exercise->rest_seconds
);
}
target_duration_text[0] = '\0';
if (exercise->tracking_mode ==
TRAINLOG_TRACKING_DURATION) {
(void)trainlog_duration_format(
exercise->target_duration_seconds,
target_duration_text,
sizeof(target_duration_text)
);
}
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
title_row,
decorated ? 5 : 4,
"Exercice %zu/%zu — %s",
selected + 1U,
count,
exercise->name
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
mode_row,
decorated ? 5 : 4,
"Mode : %-12s Charge : %-10s Repos : %s",
exercise->tracking_mode ==
TRAINLOG_TRACKING_REPS
? "répétitions"
: "durée",
session_detail_load_label(
exercise->load_mode
),
rest_text
);
if (exercise->tracking_mode ==
TRAINLOG_TRACKING_REPS) {
mvprintw(
target_row,
decorated ? 5 : 4,
"Cible : %d série(s) × %d reps",
exercise->target_sets,
exercise->target_reps
);
} else {
mvprintw(
target_row,
decorated ? 5 : 4,
"Cible : %d série(s) × %s",
exercise->target_sets,
target_duration_text
);
}
if (exercise->has_target_weight != 0) {
mvprintw(
weight_row,
decorated ? 5 : 4,
"Charge cible : %.1f kg",
exercise->target_weight_kg
);
} else {
mvprintw(
weight_row,
decorated ? 5 : 4,
"Charge cible : —"
);
}
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_SUCCESS
)
);
mvprintw(
actual_row,
decorated ? 5 : 4,
"Réalisé : %zu série(s)",
exercise->actual_set_count
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_SUCCESS
)
);
mvprintw(
summary_row,
decorated ? 5 : 4,
"%.*s",
COLS - 10,
exercise->actual_summary
);
if (exercise->load_mode ==
TRAINLOG_LOAD_ASSISTANCE) {
attron( attron(
A_BOLD |
trainlog_theme_attribute( trainlog_theme_attribute(
TRAINLOG_COLOR_WARNING TRAINLOG_COLOR_ACCENT
) )
); );
mvprintw( mvprintw(
warning_row, title_row,
decorated ? 5 : 4, decorated ? 5 : 4,
"Assistance : plus de kg = davantage d'aide." "Exercice %zu/%zu — %s",
selected + 1U,
count,
exercise->name
); );
attroff( attroff(
A_BOLD |
trainlog_theme_attribute( trainlog_theme_attribute(
TRAINLOG_COLOR_WARNING TRAINLOG_COLOR_ACCENT
) )
); );
mvprintw(
mode_row,
decorated ? 5 : 4,
"Mode : continu"
);
mvprintw(
row++,
decorated ? 5 : 4,
"Durée : %s",
duration_text
);
if (exercise->has_continuous_speed != 0) {
mvprintw(
row++,
decorated ? 5 : 4,
"Vitesse : %.1f km/h",
exercise->continuous_speed_kmh
);
}
if (exercise->has_continuous_distance != 0) {
mvprintw(
row++,
decorated ? 5 : 4,
"Distance : %.2f km",
exercise->continuous_distance_km
);
}
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_SUCCESS
)
);
mvprintw(
row + 1,
decorated ? 5 : 4,
"Réalisé : activité continue"
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_SUCCESS
)
);
} else {
char rest_text[64];
char target_duration_text[64];
int title_row =
decorated ? 16 : 6;
int mode_row =
decorated ? 18 : 8;
int target_row =
decorated ? 20 : 10;
int weight_row =
decorated ? 21 : 11;
int actual_row =
decorated ? 23 : 13;
int summary_row =
decorated ? 25 : 15;
int warning_row =
decorated ? 27 : 17;
if (trainlog_duration_format(
exercise->rest_seconds,
rest_text,
sizeof(rest_text)
) != TRAINLOG_STATUS_OK) {
(void)snprintf(
rest_text,
sizeof(rest_text),
"%ds",
exercise->rest_seconds
);
}
target_duration_text[0] = '\0';
if (exercise->tracking_mode ==
TRAINLOG_TRACKING_DURATION) {
(void)trainlog_duration_format(
exercise->target_duration_seconds,
target_duration_text,
sizeof(target_duration_text)
);
}
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
title_row,
decorated ? 5 : 4,
"Exercice %zu/%zu — %s",
selected + 1U,
count,
exercise->name
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
mode_row,
decorated ? 5 : 4,
"Mode : %-12s Charge : %-10s Repos : %s",
exercise->tracking_mode ==
TRAINLOG_TRACKING_REPS
? "répétitions"
: "durée",
session_detail_load_label(
exercise->load_mode
),
rest_text
);
if (exercise->tracking_mode ==
TRAINLOG_TRACKING_REPS) {
mvprintw(
target_row,
decorated ? 5 : 4,
"Cible : %d série(s) × %d reps",
exercise->target_sets,
exercise->target_reps
);
} else {
mvprintw(
target_row,
decorated ? 5 : 4,
"Cible : %d série(s) × %s",
exercise->target_sets,
target_duration_text
);
}
if (exercise->has_target_weight != 0) {
mvprintw(
weight_row,
decorated ? 5 : 4,
"Charge cible : %.1f kg",
exercise->target_weight_kg
);
} else {
mvprintw(
weight_row,
decorated ? 5 : 4,
"Charge cible : —"
);
}
attron(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_SUCCESS
)
);
mvprintw(
actual_row,
decorated ? 5 : 4,
"Réalisé : %zu série(s)",
exercise->actual_set_count
);
attroff(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_SUCCESS
)
);
mvprintw(
summary_row,
decorated ? 5 : 4,
"%.*s",
COLS - 10,
exercise->actual_summary
);
if (exercise->load_mode ==
TRAINLOG_LOAD_ASSISTANCE) {
attron(
trainlog_theme_attribute(
TRAINLOG_COLOR_WARNING
)
);
mvprintw(
warning_row,
decorated ? 5 : 4,
"Assistance : plus de kg = davantage d'aide."
);
attroff(
trainlog_theme_attribute(
TRAINLOG_COLOR_WARNING
)
);
}
} }
} }

View file

@ -72,6 +72,100 @@ static bool test_blank_rejected(void)
return true; return true;
} }
static bool test_profiled_creation(void)
{
TrainlogDatabase *database = NULL;
TrainlogExercise walk;
TrainlogExercise exercises[4];
size_t count = 0U;
size_t index;
bool found = false;
CHECK(
trainlog_database_open(
":memory:",
&database
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_catalog_create_exercise_profiled(
database,
"Marche",
TRAINLOG_TRACKING_DURATION,
TRAINLOG_RECORDING_CONTINUOUS,
TRAINLOG_EXERCISE_DATA_SPEED_KMH,
&walk
) == TRAINLOG_STATUS_OK
);
CHECK(
walk.recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS
);
CHECK(
walk.tracking_mode ==
TRAINLOG_TRACKING_DURATION
);
CHECK(
walk.data_fields ==
TRAINLOG_EXERCISE_DATA_SPEED_KMH
);
CHECK(
trainlog_catalog_create_exercise_profiled(
database,
"Profil invalide",
TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_CONTINUOUS,
0U,
&walk
) == TRAINLOG_STATUS_INVALID_ARGUMENT
);
CHECK(
trainlog_database_list_exercises(
database,
exercises,
4U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 1U);
for (index = 0U; index < count; ++index) {
if (strcmp(
exercises[index].name,
"Marche"
) == 0) {
CHECK(
exercises[index].recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS
);
CHECK(
exercises[index].tracking_mode ==
TRAINLOG_TRACKING_DURATION
);
CHECK(
exercises[index].data_fields ==
TRAINLOG_EXERCISE_DATA_SPEED_KMH
);
found = true;
}
}
CHECK(found);
trainlog_database_close(database);
return true;
}
int main(void) int main(void)
{ {
CHECK(test_normalization()); CHECK(test_normalization());
@ -80,5 +174,8 @@ int main(void)
CHECK(test_blank_rejected()); CHECK(test_blank_rejected());
(void)printf("PASS blank_rejected\n"); (void)printf("PASS blank_rejected\n");
CHECK(test_profiled_creation());
(void)printf("PASS profiled_creation\n");
return 0; return 0;
} }

View file

@ -0,0 +1,168 @@
#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 1; \
} \
} while (0)
int main(void)
{
TrainlogDatabase *database = NULL;
TrainlogSessionExerciseInput exercise;
TrainlogSessionInput session;
TrainlogSessionSummary summary;
TrainlogPersistedExerciseDetail details[2];
size_t count = 0U;
CHECK(
trainlog_database_open(
":memory:",
&database
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_insert_exercise_profiled(
database,
"ex_walk",
"Marche",
"marche",
TRAINLOG_TRACKING_DURATION,
TRAINLOG_RECORDING_CONTINUOUS,
TRAINLOG_EXERCISE_DATA_SPEED_KMH
) == TRAINLOG_STATUS_OK
);
(void)memset(
&exercise,
0,
sizeof(exercise)
);
(void)snprintf(
exercise.exercise_id,
sizeof(exercise.exercise_id),
"%s",
"ex_walk"
);
exercise.recording_mode =
TRAINLOG_RECORDING_CONTINUOUS;
exercise.data_fields =
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
exercise.load_mode =
TRAINLOG_LOAD_NONE;
exercise.continuous_duration_seconds =
1800;
exercise.continuous_has_speed =
true;
exercise.continuous_speed_kmh =
5.8;
(void)memset(
&session,
0,
sizeof(session)
);
(void)snprintf(
session.session_id,
sizeof(session.session_id),
"%s",
"se_walk_detail"
);
(void)snprintf(
session.started_at,
sizeof(session.started_at),
"%s",
"2026-09-06T13: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,
session.session_id,
&summary,
details,
2U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 1U);
CHECK(
details[0].recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS
);
CHECK(
details[0].continuous_duration_seconds ==
1800
);
CHECK(
details[0].has_continuous_speed != 0
);
CHECK(
details[0].continuous_speed_kmh >
5.79
);
CHECK(
details[0].continuous_speed_kmh <
5.81
);
CHECK(
details[0].actual_set_count ==
0U
);
CHECK(
strcmp(
details[0].actual_summary,
"activité continue"
) == 0
);
trainlog_database_close(
database
);
(void)printf(
"PASS continuous_detail\n"
);
return 0;
}

View file

@ -0,0 +1,275 @@
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sqlite3.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 1; \
} \
} while (0)
int main(void)
{
char path[] =
"/tmp/trainlog-continuous-XXXXXX";
TrainlogDatabase *database = NULL;
TrainlogSessionExerciseInput exercise;
TrainlogSessionInput session;
sqlite3 *raw = NULL;
sqlite3_stmt *statement = NULL;
int fd;
fd = mkstemp(path);
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(
trainlog_database_open(
path,
&database
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_insert_exercise_profiled(
database,
"ex_walk",
"Marche",
"marche",
TRAINLOG_TRACKING_DURATION,
TRAINLOG_RECORDING_CONTINUOUS,
TRAINLOG_EXERCISE_DATA_SPEED_KMH
) == TRAINLOG_STATUS_OK
);
(void)memset(
&exercise,
0,
sizeof(exercise)
);
(void)snprintf(
exercise.exercise_id,
sizeof(exercise.exercise_id),
"%s",
"ex_walk"
);
exercise.recording_mode =
TRAINLOG_RECORDING_CONTINUOUS;
exercise.data_fields =
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
exercise.load_mode =
TRAINLOG_LOAD_NONE;
exercise.continuous_duration_seconds =
2700;
exercise.continuous_has_speed =
true;
exercise.continuous_speed_kmh =
5.8;
(void)memset(
&session,
0,
sizeof(session)
);
(void)snprintf(
session.session_id,
sizeof(session.session_id),
"%s",
"se_walk"
);
(void)snprintf(
session.started_at,
sizeof(session.started_at),
"%s",
"2026-09-06T12:00:00+02:00"
);
session.exercises = &exercise;
session.exercise_count = 1U;
CHECK(
trainlog_database_insert_session(
database,
&session
) == TRAINLOG_STATUS_OK
);
trainlog_database_close(database);
database = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(
sqlite3_prepare_v2(
raw,
"SELECT recording_mode, data_fields, "
"target_sets, target_reps, "
"target_duration_seconds "
"FROM session_exercises "
"WHERE id = 1;",
-1,
&statement,
NULL
) == SQLITE_OK
);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(
strcmp(
(const char *)
sqlite3_column_text(
statement,
0
),
"continuous"
) == 0
);
CHECK(
sqlite3_column_int(
statement,
1
) ==
(int)TRAINLOG_EXERCISE_DATA_SPEED_KMH
);
CHECK(
sqlite3_column_type(
statement,
2
) == SQLITE_NULL
);
CHECK(
sqlite3_column_type(
statement,
3
) == SQLITE_NULL
);
CHECK(
sqlite3_column_type(
statement,
4
) == SQLITE_NULL
);
CHECK(
sqlite3_finalize(
statement
) == SQLITE_OK
);
statement = NULL;
CHECK(
sqlite3_prepare_v2(
raw,
"SELECT duration_seconds, "
"speed_kmh, distance_km "
"FROM continuous_activity "
"WHERE session_exercise_row_id = 1;",
-1,
&statement,
NULL
) == SQLITE_OK
);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(
sqlite3_column_int(
statement,
0
) == 2700
);
CHECK(
sqlite3_column_double(
statement,
1
) > 5.79
);
CHECK(
sqlite3_column_double(
statement,
1
) < 5.81
);
CHECK(
sqlite3_column_type(
statement,
2
) == SQLITE_NULL
);
CHECK(
sqlite3_finalize(
statement
) == SQLITE_OK
);
statement = NULL;
CHECK(
sqlite3_prepare_v2(
raw,
"SELECT COUNT(*) "
"FROM performed_sets;",
-1,
&statement,
NULL
) == SQLITE_OK
);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(
sqlite3_column_int(
statement,
0
) == 0
);
CHECK(
sqlite3_finalize(
statement
) == SQLITE_OK
);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(unlink(path) == 0);
(void)printf(
"PASS continuous_session\n"
);
return 0;
}

View file

@ -0,0 +1,288 @@
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sqlite3.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_profile_roundtrip(void)
{
TrainlogDatabase *database = NULL;
TrainlogExercise exercises[4];
size_t count = 0U;
size_t index;
bool found_walk = false;
bool found_legacy = false;
CHECK(
trainlog_database_open(
":memory:",
&database
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_insert_exercise(
database,
"ex_legacy",
"Presse",
"presse",
TRAINLOG_TRACKING_REPS
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_insert_exercise_profiled(
database,
"ex_walk",
"Marche",
"marche",
TRAINLOG_TRACKING_DURATION,
TRAINLOG_RECORDING_CONTINUOUS,
TRAINLOG_EXERCISE_DATA_SPEED_KMH
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_insert_exercise_profiled(
database,
"ex_invalid",
"Invalid",
"invalid",
TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_CONTINUOUS,
0U
) == TRAINLOG_STATUS_INVALID_ARGUMENT
);
CHECK(
trainlog_database_insert_exercise_profiled(
database,
"ex_unknown",
"Unknown",
"unknown",
TRAINLOG_TRACKING_DURATION,
TRAINLOG_RECORDING_CONTINUOUS,
UINT32_C(8)
) == TRAINLOG_STATUS_INVALID_ARGUMENT
);
CHECK(
trainlog_database_list_exercises(
database,
exercises,
4U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 2U);
for (index = 0U; index < count; ++index) {
if (strcmp(exercises[index].exercise_id, "ex_walk") == 0) {
CHECK(
exercises[index].recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS
);
CHECK(
exercises[index].tracking_mode ==
TRAINLOG_TRACKING_DURATION
);
CHECK(
exercises[index].data_fields ==
TRAINLOG_EXERCISE_DATA_SPEED_KMH
);
found_walk = true;
}
if (strcmp(exercises[index].exercise_id, "ex_legacy") == 0) {
CHECK(
exercises[index].recording_mode ==
TRAINLOG_RECORDING_SETS
);
CHECK(exercises[index].data_fields == 0U);
found_legacy = true;
}
}
CHECK(found_walk);
CHECK(found_legacy);
trainlog_database_close(database);
return true;
}
static bool test_v2_to_v3_migration(void)
{
char path[] =
"/tmp/trainlog-schema-v2-profile-XXXXXX";
static const char *const V2_SQL =
"CREATE TABLE exercises ("
"id INTEGER PRIMARY KEY,"
"exercise_id TEXT NOT NULL UNIQUE,"
"name TEXT NOT NULL,"
"normalized_name TEXT NOT NULL UNIQUE,"
"tracking_mode TEXT NOT NULL"
");"
"CREATE TABLE sessions ("
"id INTEGER PRIMARY KEY,"
"session_id TEXT NOT NULL UNIQUE,"
"started_at TEXT NOT NULL,"
"ended_at TEXT,"
"session_type TEXT NOT NULL DEFAULT 'training',"
"notes TEXT"
");"
"CREATE TABLE session_exercises ("
"id INTEGER PRIMARY KEY,"
"session_row_id INTEGER NOT NULL REFERENCES sessions(id),"
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),"
"position INTEGER NOT NULL,"
"load_mode TEXT NOT NULL,"
"rest_seconds INTEGER NOT NULL,"
"target_sets INTEGER NOT NULL,"
"target_reps INTEGER,"
"target_duration_seconds INTEGER,"
"target_weight_kg REAL,"
"notes TEXT"
");"
"INSERT INTO exercises("
"exercise_id, name, normalized_name, tracking_mode"
") VALUES('ex_old', 'Ancien', 'ancien', 'duration');"
"INSERT INTO sessions("
"session_id, started_at, session_type"
") VALUES('se_old', '2026-09-01T10:00:00+02:00', 'training');"
"INSERT INTO session_exercises("
"session_row_id, exercise_row_id, position, load_mode,"
"rest_seconds, target_sets, target_duration_seconds"
") VALUES(1, 1, 0, 'none', 0, 1, 60);"
"PRAGMA user_version = 2;";
sqlite3 *raw = NULL;
sqlite3_stmt *statement = NULL;
TrainlogDatabase *database = NULL;
int fd;
int version = 0;
fd = mkstemp(path);
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(
sqlite3_exec(raw, V2_SQL, NULL, NULL, NULL) ==
SQLITE_OK
);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(
trainlog_database_open(path, &database) ==
TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_schema_version(
database,
&version
) == TRAINLOG_STATUS_OK
);
CHECK(version == 3);
trainlog_database_close(database);
database = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(
sqlite3_prepare_v2(
raw,
"SELECT recording_mode, data_fields "
"FROM exercises WHERE exercise_id='ex_old';",
-1,
&statement,
NULL
) == SQLITE_OK
);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(
strcmp(
(const char *)sqlite3_column_text(statement, 0),
"sets"
) == 0
);
CHECK(sqlite3_column_int64(statement, 1) == 0);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
statement = NULL;
CHECK(
sqlite3_prepare_v2(
raw,
"SELECT recording_mode, data_fields "
"FROM session_exercises WHERE id=1;",
-1,
&statement,
NULL
) == SQLITE_OK
);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(
strcmp(
(const char *)sqlite3_column_text(statement, 0),
"sets"
) == 0
);
CHECK(sqlite3_column_int64(statement, 1) == 0);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
statement = NULL;
CHECK(
sqlite3_prepare_v2(
raw,
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name='continuous_activity';",
-1,
&statement,
NULL
) == SQLITE_OK
);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(unlink(path) == 0);
return true;
}
int main(void)
{
CHECK(test_profile_roundtrip());
CHECK(test_v2_to_v3_migration());
(void)printf("PASS exercise_profile_schema\n");
return 0;
}

View file

@ -1,6 +1,6 @@
/** /**
* @file test_session_type_schema.c * @file test_session_type_schema.c
* @brief Schema v2 and session type persistence tests. * @brief Historical schema migration and session type persistence tests.
*/ */
#include <stdbool.h> #include <stdbool.h>
@ -141,6 +141,14 @@ static bool test_v1_to_v2_migration(void)
"/tmp/trainlog-schema-v1-XXXXXX"; "/tmp/trainlog-schema-v1-XXXXXX";
static const char *const V1_SQL = static const char *const V1_SQL =
"CREATE TABLE exercises ("
"id INTEGER PRIMARY KEY,"
"exercise_id TEXT NOT NULL UNIQUE,"
"name TEXT NOT NULL,"
"normalized_name TEXT NOT NULL UNIQUE,"
"tracking_mode TEXT NOT NULL"
" CHECK (tracking_mode IN ('reps', 'duration'))"
");"
"CREATE TABLE sessions (" "CREATE TABLE sessions ("
"id INTEGER PRIMARY KEY," "id INTEGER PRIMARY KEY,"
"session_id TEXT NOT NULL UNIQUE," "session_id TEXT NOT NULL UNIQUE,"
@ -148,6 +156,48 @@ static bool test_v1_to_v2_migration(void)
"ended_at TEXT," "ended_at TEXT,"
"notes TEXT" "notes TEXT"
");" ");"
"CREATE TABLE session_exercises ("
"id INTEGER PRIMARY KEY,"
"session_row_id INTEGER NOT NULL REFERENCES sessions(id),"
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),"
"position INTEGER NOT NULL,"
"load_mode TEXT NOT NULL,"
"rest_seconds INTEGER NOT NULL,"
"target_sets INTEGER NOT NULL,"
"target_reps INTEGER,"
"target_duration_seconds INTEGER,"
"target_weight_kg REAL,"
"notes TEXT"
");"
"CREATE TABLE performed_sets ("
"id INTEGER PRIMARY KEY,"
"session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id),"
"position INTEGER NOT NULL,"
"reps INTEGER,"
"duration_seconds INTEGER,"
"weight_kg REAL"
");"
"CREATE TABLE body_observations ("
"id INTEGER PRIMARY KEY,"
"observation_id TEXT NOT NULL UNIQUE,"
"observed_at TEXT NOT NULL,"
"session_row_id INTEGER UNIQUE REFERENCES sessions(id),"
"body_weight_kg REAL,"
"neck_cm REAL,"
"shoulders_cm REAL,"
"chest_cm REAL,"
"waist_cm REAL,"
"hips_cm REAL,"
"left_arm_cm REAL,"
"right_arm_cm REAL,"
"left_forearm_cm REAL,"
"right_forearm_cm REAL,"
"left_thigh_cm REAL,"
"right_thigh_cm REAL,"
"left_calf_cm REAL,"
"right_calf_cm REAL,"
"notes TEXT"
");"
"INSERT INTO sessions(" "INSERT INTO sessions("
"session_id, started_at, ended_at, notes" "session_id, started_at, ended_at, notes"
") VALUES(" ") VALUES("