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 PC can normalize exercise selection on both sides.
<!-- 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
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
purged before normal production use begins.
<!-- 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.
Catalog synchronization is a separate versioned contract.
<!-- 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
);
TrainlogStatus trainlog_catalog_create_exercise_profiled(
TrainlogDatabase *database,
const char *name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields,
TrainlogExercise *output_exercise
);
#endif

View file

@ -11,7 +11,7 @@
#include "trainlog/model.h"
#include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 2
#define TRAINLOG_DATABASE_SCHEMA_VERSION 4
typedef struct TrainlogDatabase TrainlogDatabase;
@ -50,6 +50,16 @@ TrainlogStatus trainlog_database_insert_exercise(
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(
TrainlogDatabase *database,
size_t *output_count
@ -99,6 +109,8 @@ TrainlogStatus trainlog_database_list_weight_points(
typedef struct TrainlogPersistedExerciseDetail {
char name[TRAINLOG_NAME_MAX + 1U];
TrainlogTrackingMode tracking_mode;
TrainlogRecordingMode recording_mode;
TrainlogExerciseDataFields data_fields;
TrainlogLoadMode load_mode;
int rest_seconds;
int target_sets;
@ -106,6 +118,13 @@ typedef struct TrainlogPersistedExerciseDetail {
int target_duration_seconds;
int has_target_weight;
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;
char actual_summary[TRAINLOG_SET_SUMMARY_MAX + 1U];
} TrainlogPersistedExerciseDetail;

View file

@ -8,6 +8,7 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define TRAINLOG_ID_MAX 128U
#define TRAINLOG_NAME_MAX 200U
@ -19,6 +20,19 @@ typedef enum TrainlogTrackingMode {
TRAINLOG_TRACKING_DURATION
} 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 {
TRAINLOG_LOAD_NONE = 0,
TRAINLOG_LOAD_EXTERNAL,
@ -34,6 +48,8 @@ typedef struct TrainlogExercise {
char exercise_id[TRAINLOG_ID_MAX + 1U];
char name[TRAINLOG_NAME_MAX + 1U];
TrainlogTrackingMode tracking_mode;
TrainlogRecordingMode recording_mode;
TrainlogExerciseDataFields data_fields;
} TrainlogExercise;
typedef struct TrainlogSetInput {
@ -45,6 +61,8 @@ typedef struct TrainlogSetInput {
typedef struct TrainlogSessionExerciseInput {
char exercise_id[TRAINLOG_ID_MAX + 1U];
TrainlogRecordingMode recording_mode;
TrainlogExerciseDataFields data_fields;
TrainlogLoadMode load_mode;
int rest_seconds;
int target_sets;
@ -52,6 +70,13 @@ typedef struct TrainlogSessionExerciseInput {
int target_duration_seconds;
bool target_has_weight;
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 TrainlogSetInput *sets;
size_t set_count;

View file

@ -243,3 +243,39 @@ trainlog_mtp_roundtrip_probe = executable(
dependencies: trainlog_core_dep,
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;
}
TrainlogStatus trainlog_catalog_create_exercise(
TrainlogStatus trainlog_catalog_create_exercise_profiled(
TrainlogDatabase *database,
const char *name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields,
TrainlogExercise *output_exercise
)
{
@ -154,6 +156,7 @@ TrainlogStatus trainlog_catalog_create_exercise(
normalized,
sizeof(normalized)
);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
@ -163,35 +166,65 @@ TrainlogStatus trainlog_catalog_create_exercise(
exercise_id,
sizeof(exercise_id)
);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
status = trainlog_database_insert_exercise(
status = trainlog_database_insert_exercise_profiled(
database,
exercise_id,
name,
normalized,
tracking_mode
tracking_mode,
recording_mode,
data_fields
);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
(void)memset(output_exercise, 0, sizeof(*output_exercise));
(void)memset(
output_exercise,
0,
sizeof(*output_exercise)
);
(void)snprintf(
output_exercise->exercise_id,
sizeof(output_exercise->exercise_id),
"%s",
exercise_id
);
(void)snprintf(
output_exercise->name,
sizeof(output_exercise->name),
"%s",
name
);
output_exercise->tracking_mode = tracking_mode;
output_exercise->recording_mode = recording_mode;
output_exercise->data_fields = data_fields;
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_PROFILED_EXERCISE_CREATION */
typedef enum DashboardAction {
DASHBOARD_NEW_SESSION = 0,
@ -1756,7 +1757,11 @@ if (primary_top_nav_activate(
if (key == 'a' ||
key == 'A') {
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;
TrainlogStatus status;
@ -1786,14 +1791,64 @@ if (primary_top_nav_activate(
continue;
}
status =
trainlog_catalog_create_exercise(
if (!prompt_int_value(
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,
name,
mode == 1
? TRAINLOG_TRACKING_REPS
: TRAINLOG_TRACKING_DURATION,
&created
organization == 2
? TRAINLOG_RECORDING_CONTINUOUS
: TRAINLOG_RECORDING_SETS,
data_fields,
&created
);
if (status ==
@ -1827,7 +1882,11 @@ static bool create_exercise_inline(
)
{
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;
TrainlogStatus status;
@ -1857,14 +1916,64 @@ static bool create_exercise_inline(
return false;
}
status =
trainlog_catalog_create_exercise(
if (!prompt_int_value(
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,
name,
mode == 1
? TRAINLOG_TRACKING_REPS
: TRAINLOG_TRACKING_DURATION,
&created
organization == 2
? TRAINLOG_RECORDING_CONTINUOUS
: TRAINLOG_RECORDING_SETS,
data_fields,
&created
);
if (status == TRAINLOG_STATUS_OK) {
@ -4378,12 +4487,119 @@ static bool build_session_exercise(
double target_weight = 0.0;
size_t set_index;
if (!choose_exercise(database, &exercise)) {
if (!choose_exercise(
database,
&exercise
)) {
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 =
exercise.tracking_mode == TRAINLOG_TRACKING_REPS
exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS
? 10
: 45;
@ -4409,7 +4625,8 @@ static bool build_session_exercise(
"Charge cible kg : ",
&target_has_weight,
&target_weight
) || !target_has_weight) {
) ||
!target_has_weight) {
return false;
}
}
@ -4425,7 +4642,8 @@ static bool build_session_exercise(
return false;
}
if (exercise.tracking_mode == TRAINLOG_TRACKING_REPS) {
if (exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS) {
if (!prompt_int_value(
7,
"Répétitions cibles",
@ -4471,42 +4689,45 @@ static bool build_session_exercise(
return false;
}
(void)memset(output, 0, sizeof(*output));
(void)snprintf(
output->exercise_id,
sizeof(output->exercise_id),
"%s",
exercise.exercise_id
);
output->load_mode =
load_mode == 1
? TRAINLOG_LOAD_NONE
: (load_mode == 2
? TRAINLOG_LOAD_EXTERNAL
: TRAINLOG_LOAD_ASSISTANCE);
: (
load_mode == 2
? TRAINLOG_LOAD_EXTERNAL
: TRAINLOG_LOAD_ASSISTANCE
);
output->rest_seconds = rest_seconds;
output->target_sets = target_sets;
output->target_reps =
exercise.tracking_mode == TRAINLOG_TRACKING_REPS
exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS
? target_metric
: 0;
output->target_duration_seconds =
exercise.tracking_mode == TRAINLOG_TRACKING_DURATION
exercise.tracking_mode ==
TRAINLOG_TRACKING_DURATION
? target_metric
: 0;
output->target_has_weight = target_has_weight;
output->target_weight_kg = target_weight;
output->sets = set_storage;
output->set_count = (size_t)actual_sets;
output->target_has_weight =
target_has_weight;
for (set_index = 0U; set_index < output->set_count; ++set_index) {
int actual_metric = target_metric;
output->target_weight_kg =
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(
&set_storage[set_index],
@ -4527,7 +4748,8 @@ static bool build_session_exercise(
output->set_count
);
if (exercise.tracking_mode == TRAINLOG_TRACKING_REPS) {
if (exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS) {
if (!prompt_int_value(
5,
"Répétitions réalisées",
@ -4539,7 +4761,8 @@ static bool build_session_exercise(
return false;
}
set_storage[set_index].reps = actual_metric;
set_storage[set_index].reps =
actual_metric;
} else {
if (!prompt_duration_value(
5,
@ -4552,13 +4775,17 @@ static bool build_session_exercise(
return false;
}
set_storage[set_index].duration_seconds = actual_metric;
set_storage[
set_index
].duration_seconds =
actual_metric;
}
if (target_has_weight) {
char buffer[64];
char prompt[128];
double actual_weight = target_weight;
double actual_weight =
target_weight;
(void)snprintf(
prompt,
@ -4586,12 +4813,20 @@ static bool build_session_exercise(
"Charge invalide.",
TRAINLOG_COLOR_ERROR
);
wait_key();
return false;
}
set_storage[set_index].has_weight = true;
set_storage[set_index].weight_kg = actual_weight;
set_storage[
set_index
].has_weight =
true;
set_storage[
set_index
].weight_kg =
actual_weight;
}
}
@ -6178,173 +6413,277 @@ static void screen_session_detail(
TrainlogPersistedExerciseDetail *exercise =
&exercises[selected];
char rest_text[64];
char target_duration_text[64];
if (exercise->recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS) {
char duration_text[64];
int title_row =
decorated ? 16 : 6;
int title_row =
decorated ? 16 : 6;
int mode_row =
decorated ? 18 : 8;
int mode_row =
decorated ? 18 : 8;
int target_row =
decorated ? 20 : 10;
int first_data_row =
decorated ? 20 : 10;
int weight_row =
decorated ? 21 : 11;
int row =
first_data_row;
int actual_row =
decorated ? 23 : 13;
if (trainlog_duration_format(
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(
A_BOLD |
trainlog_theme_attribute(
TRAINLOG_COLOR_WARNING
TRAINLOG_COLOR_ACCENT
)
);
mvprintw(
warning_row,
title_row,
decorated ? 5 : 4,
"Assistance : plus de kg = davantage d'aide."
"Exercice %zu/%zu — %s",
selected + 1U,
count,
exercise->name
);
attroff(
A_BOLD |
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;
}
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)
{
CHECK(test_normalization());
@ -80,5 +174,8 @@ int main(void)
CHECK(test_blank_rejected());
(void)printf("PASS blank_rejected\n");
CHECK(test_profiled_creation());
(void)printf("PASS profiled_creation\n");
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
* @brief Schema v2 and session type persistence tests.
* @brief Historical schema migration and session type persistence tests.
*/
#include <stdbool.h>
@ -141,6 +141,14 @@ static bool test_v1_to_v2_migration(void)
"/tmp/trainlog-schema-v1-XXXXXX";
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 ("
"id INTEGER PRIMARY KEY,"
"session_id TEXT NOT NULL UNIQUE,"
@ -148,6 +156,48 @@ static bool test_v1_to_v2_migration(void)
"ended_at 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("
"session_id, started_at, ended_at, notes"
") VALUES("