diff --git a/docs/android.md b/docs/android.md index be768b3..f323082 100644 --- a/docs/android.md +++ b/docs/android.md @@ -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. + + +## 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. + diff --git a/docs/database.md b/docs/database.md index 9a6c957..a9990a2 100644 --- a/docs/database.md +++ b/docs/database.md @@ -282,3 +282,25 @@ python tools/validate_json.py python tools/validate_import_contract.py git diff --check ``` + + +## 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. + diff --git a/docs/exercise_data_model.md b/docs/exercise_data_model.md new file mode 100644 index 0000000..43580d6 --- /dev/null +++ b/docs/exercise_data_model.md @@ -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 +``` diff --git a/docs/roadmap.md b/docs/roadmap.md index 0e6923f..f059a44 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. + + +## 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`. + diff --git a/docs/tui.md b/docs/tui.md index 7d80e36..22a4118 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -674,3 +674,25 @@ PC -> Android The session exchange remains frozen Trainlog JSON v1. Catalog synchronization is a separate versioned contract. + + +## 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. + diff --git a/tui/include/trainlog/catalog.h b/tui/include/trainlog/catalog.h index 212960d..e2630c8 100644 --- a/tui/include/trainlog/catalog.h +++ b/tui/include/trainlog/catalog.h @@ -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 diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 8e8c8e8..0c12d66 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -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; diff --git a/tui/include/trainlog/model.h b/tui/include/trainlog/model.h index 4b1ef52..e7663b5 100644 --- a/tui/include/trainlog/model.h +++ b/tui/include/trainlog/model.h @@ -8,6 +8,7 @@ #include #include +#include #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; diff --git a/tui/meson.build b/tui/meson.build index 7507a5f..adcff0f 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -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, +) diff --git a/tui/src/catalog.c b/tui/src/catalog.c index e395f64..59558b1 100644 --- a/tui/src/catalog.c +++ b/tui/src/catalog.c @@ -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 + ); +} diff --git a/tui/src/database.c b/tui/src/database.c index b0f11df..ba381be 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -17,7 +17,7 @@ struct TrainlogDatabase { sqlite3 *connection; }; -static const char *const SCHEMA_V2_SQL = +static const char *const SCHEMA_V4_SQL_A = "BEGIN IMMEDIATE;" "CREATE TABLE IF NOT EXISTS exercises (" @@ -26,7 +26,13 @@ static const char *const SCHEMA_V2_SQL = " name TEXT NOT NULL," " normalized_name TEXT NOT NULL UNIQUE," " tracking_mode TEXT NOT NULL" - " CHECK (tracking_mode IN ('reps', 'duration'))" + " CHECK (tracking_mode IN ('reps', 'duration'))," + " recording_mode TEXT NOT NULL DEFAULT 'sets'" + " CHECK (recording_mode IN ('sets', 'continuous'))," + " data_fields INTEGER NOT NULL DEFAULT 0" + " CHECK (data_fields >= 0 AND (data_fields & ~3) = 0)," + " CHECK (recording_mode != 'continuous' OR" + " tracking_mode = 'duration')" ");" "CREATE TABLE IF NOT EXISTS sessions (" @@ -45,11 +51,15 @@ static const char *const SCHEMA_V2_SQL = " REFERENCES sessions(id) ON DELETE CASCADE," " exercise_row_id INTEGER NOT NULL" " REFERENCES exercises(id) ON DELETE RESTRICT," + " recording_mode TEXT NOT NULL DEFAULT 'sets'" + " CHECK (recording_mode IN ('sets', 'continuous'))," + " data_fields INTEGER NOT NULL DEFAULT 0" + " CHECK (data_fields >= 0 AND (data_fields & ~3) = 0)," " position INTEGER NOT NULL CHECK (position >= 0)," " load_mode TEXT NOT NULL" " CHECK (load_mode IN ('none', 'external', 'assistance'))," " rest_seconds INTEGER NOT NULL CHECK (rest_seconds >= 0)," - " target_sets INTEGER NOT NULL CHECK (target_sets > 0)," + " target_sets INTEGER CHECK (target_sets > 0)," " target_reps INTEGER CHECK (target_reps >= 1)," " target_duration_seconds INTEGER" " CHECK (target_duration_seconds > 0)," @@ -58,10 +68,19 @@ static const char *const SCHEMA_V2_SQL = " UNIQUE (session_row_id, position)," " UNIQUE (session_row_id, exercise_row_id)," " CHECK (" - " (target_reps IS NOT NULL AND" - " target_duration_seconds IS NULL) OR" - " (target_reps IS NULL AND" - " target_duration_seconds IS NOT NULL)" + " (recording_mode = 'sets' AND" + " target_sets IS NOT NULL AND" + " ((target_reps IS NOT NULL AND" + " target_duration_seconds IS NULL) OR" + " (target_reps IS NULL AND" + " target_duration_seconds IS NOT NULL))) OR" + " (recording_mode = 'continuous' AND" + " target_sets IS NULL AND" + " target_reps IS NULL AND" + " target_duration_seconds IS NULL AND" + " load_mode = 'none' AND" + " rest_seconds = 0 AND" + " target_weight_kg IS NULL)" " )," " CHECK (" " (load_mode = 'none' AND target_weight_kg IS NULL) OR" @@ -83,6 +102,16 @@ static const char *const SCHEMA_V2_SQL = " (reps IS NOT NULL AND duration_seconds IS NULL) OR" " (reps IS NULL AND duration_seconds IS NOT NULL)" " )" + ");"; + +static const char *const SCHEMA_V4_SQL_B = + "CREATE TABLE IF NOT EXISTS continuous_activity (" + " id INTEGER PRIMARY KEY," + " session_exercise_row_id INTEGER NOT NULL UNIQUE" + " REFERENCES session_exercises(id) ON DELETE CASCADE," + " duration_seconds INTEGER NOT NULL CHECK (duration_seconds > 0)," + " speed_kmh REAL CHECK (speed_kmh > 0.0)," + " distance_km REAL CHECK (distance_km > 0.0)" ");" "CREATE TABLE IF NOT EXISTS body_observations (" @@ -118,15 +147,172 @@ static const char *const SCHEMA_V2_SQL = " )" ");" - "PRAGMA user_version = 2;" + "PRAGMA user_version = 4;" "COMMIT;"; -static const char *const MIGRATE_V1_TO_V2_SQL = +static const char *const MIGRATE_V1_TO_V3_SQL = "BEGIN IMMEDIATE;" "ALTER TABLE sessions " "ADD COLUMN session_type TEXT NOT NULL DEFAULT 'training' " "CHECK (session_type IN ('training', 'max_test'));" - "PRAGMA user_version = 2;" + "ALTER TABLE exercises " + "ADD COLUMN recording_mode TEXT NOT NULL DEFAULT 'sets' " + "CHECK (recording_mode IN ('sets', 'continuous'));" + "ALTER TABLE exercises " + "ADD COLUMN data_fields INTEGER NOT NULL DEFAULT 0 " + "CHECK (data_fields >= 0 AND (data_fields & ~3) = 0);" + "ALTER TABLE session_exercises " + "ADD COLUMN recording_mode TEXT NOT NULL DEFAULT 'sets' " + "CHECK (recording_mode IN ('sets', 'continuous'));" + "ALTER TABLE session_exercises " + "ADD COLUMN data_fields INTEGER NOT NULL DEFAULT 0 " + "CHECK (data_fields >= 0 AND (data_fields & ~3) = 0);" + "CREATE TABLE continuous_activity (" + "id INTEGER PRIMARY KEY," + "session_exercise_row_id INTEGER NOT NULL UNIQUE " + "REFERENCES session_exercises(id) ON DELETE CASCADE," + "duration_seconds INTEGER NOT NULL CHECK (duration_seconds > 0)," + "speed_kmh REAL CHECK (speed_kmh > 0.0)," + "distance_km REAL CHECK (distance_km > 0.0)" + ");" + "PRAGMA user_version = 3;" + "COMMIT;"; + +static const char *const MIGRATE_V2_TO_V3_SQL = + "BEGIN IMMEDIATE;" + "ALTER TABLE exercises " + "ADD COLUMN recording_mode TEXT NOT NULL DEFAULT 'sets' " + "CHECK (recording_mode IN ('sets', 'continuous'));" + "ALTER TABLE exercises " + "ADD COLUMN data_fields INTEGER NOT NULL DEFAULT 0 " + "CHECK (data_fields >= 0 AND (data_fields & ~3) = 0);" + "ALTER TABLE session_exercises " + "ADD COLUMN recording_mode TEXT NOT NULL DEFAULT 'sets' " + "CHECK (recording_mode IN ('sets', 'continuous'));" + "ALTER TABLE session_exercises " + "ADD COLUMN data_fields INTEGER NOT NULL DEFAULT 0 " + "CHECK (data_fields >= 0 AND (data_fields & ~3) = 0);" + "CREATE TABLE continuous_activity (" + "id INTEGER PRIMARY KEY," + "session_exercise_row_id INTEGER NOT NULL UNIQUE " + "REFERENCES session_exercises(id) ON DELETE CASCADE," + "duration_seconds INTEGER NOT NULL CHECK (duration_seconds > 0)," + "speed_kmh REAL CHECK (speed_kmh > 0.0)," + "distance_km REAL CHECK (distance_km > 0.0)" + ");" + "PRAGMA user_version = 3;" + "COMMIT;"; + +static const char *const MIGRATE_V3_TO_V4_SQL_A = + "BEGIN IMMEDIATE;" + + "CREATE TABLE session_exercises_v4 (" + " id INTEGER PRIMARY KEY," + " session_row_id INTEGER NOT NULL" + " REFERENCES sessions(id) ON DELETE CASCADE," + " exercise_row_id INTEGER NOT NULL" + " REFERENCES exercises(id) ON DELETE RESTRICT," + " recording_mode TEXT NOT NULL" + " CHECK (recording_mode IN ('sets', 'continuous'))," + " data_fields INTEGER NOT NULL DEFAULT 0" + " CHECK (data_fields >= 0 AND (data_fields & ~3) = 0)," + " position INTEGER NOT NULL CHECK (position >= 0)," + " load_mode TEXT NOT NULL" + " CHECK (load_mode IN ('none', 'external', 'assistance'))," + " rest_seconds INTEGER NOT NULL CHECK (rest_seconds >= 0)," + " target_sets INTEGER CHECK (target_sets > 0)," + " target_reps INTEGER CHECK (target_reps >= 1)," + " target_duration_seconds INTEGER" + " CHECK (target_duration_seconds > 0)," + " target_weight_kg REAL CHECK (target_weight_kg > 0.0)," + " notes TEXT," + " UNIQUE (session_row_id, position)," + " UNIQUE (session_row_id, exercise_row_id)," + " CHECK (" + " (recording_mode = 'sets' AND" + " target_sets IS NOT NULL AND" + " ((target_reps IS NOT NULL AND" + " target_duration_seconds IS NULL) OR" + " (target_reps IS NULL AND" + " target_duration_seconds IS NOT NULL))) OR" + " (recording_mode = 'continuous' AND" + " target_sets IS NULL AND" + " target_reps IS NULL AND" + " target_duration_seconds IS NULL AND" + " load_mode = 'none' AND" + " rest_seconds = 0 AND" + " target_weight_kg IS NULL)" + " )," + " CHECK (" + " (load_mode = 'none' AND target_weight_kg IS NULL) OR" + " (load_mode IN ('external', 'assistance') AND" + " target_weight_kg IS NOT NULL)" + " )" + ");" + + "INSERT INTO session_exercises_v4(" + "id, session_row_id, exercise_row_id, recording_mode, data_fields," + "position, load_mode, rest_seconds, target_sets, target_reps," + "target_duration_seconds, target_weight_kg, notes" + ") SELECT " + "id, session_row_id, exercise_row_id, recording_mode, data_fields," + "position, load_mode, rest_seconds, target_sets, target_reps," + "target_duration_seconds, target_weight_kg, notes " + "FROM session_exercises;" + + "CREATE TABLE performed_sets_v4 (" + " id INTEGER PRIMARY KEY," + " session_exercise_row_id INTEGER NOT NULL" + " REFERENCES session_exercises_v4(id) ON DELETE CASCADE," + " position INTEGER NOT NULL CHECK (position >= 0)," + " reps INTEGER CHECK (reps >= 0)," + " duration_seconds INTEGER CHECK (duration_seconds > 0)," + " weight_kg REAL CHECK (weight_kg > 0.0)," + " UNIQUE (session_exercise_row_id, position)," + " CHECK (" + " (reps IS NOT NULL AND duration_seconds IS NULL) OR" + " (reps IS NULL AND duration_seconds IS NOT NULL)" + " )" + ");" + + "INSERT INTO performed_sets_v4(" + "id, session_exercise_row_id, position, reps," + "duration_seconds, weight_kg" + ") SELECT " + "id, session_exercise_row_id, position, reps," + "duration_seconds, weight_kg " + "FROM performed_sets;"; + +static const char *const MIGRATE_V3_TO_V4_SQL_B = + "CREATE TABLE continuous_activity_v4 (" + " id INTEGER PRIMARY KEY," + " session_exercise_row_id INTEGER NOT NULL UNIQUE" + " REFERENCES session_exercises_v4(id) ON DELETE CASCADE," + " duration_seconds INTEGER NOT NULL CHECK (duration_seconds > 0)," + " speed_kmh REAL CHECK (speed_kmh > 0.0)," + " distance_km REAL CHECK (distance_km > 0.0)" + ");" + + "INSERT INTO continuous_activity_v4(" + "id, session_exercise_row_id, duration_seconds," + "speed_kmh, distance_km" + ") SELECT " + "id, session_exercise_row_id, duration_seconds," + "speed_kmh, distance_km " + "FROM continuous_activity;" + + "DROP TABLE continuous_activity;" + "DROP TABLE performed_sets;" + "DROP TABLE session_exercises;" + + "ALTER TABLE session_exercises_v4" + " RENAME TO session_exercises;" + "ALTER TABLE performed_sets_v4" + " RENAME TO performed_sets;" + "ALTER TABLE continuous_activity_v4" + " RENAME TO continuous_activity;" + + "PRAGMA user_version = 4;" "COMMIT;"; static TrainlogStatus execute_sql( @@ -191,6 +377,7 @@ static TrainlogStatus initialize_or_validate_schema( database, &version ); + if (status != TRAINLOG_STATUS_OK) { return status; } @@ -206,13 +393,65 @@ static TrainlogStatus initialize_or_validate_schema( if (version == 0) { status = execute_sql( database, - SCHEMA_V2_SQL + SCHEMA_V4_SQL_A ); + + if (status == TRAINLOG_STATUS_OK) { + status = execute_sql( + database, + SCHEMA_V4_SQL_B + ); + } } else if (version == 1) { status = execute_sql( database, - MIGRATE_V1_TO_V2_SQL + MIGRATE_V1_TO_V3_SQL ); + + if (status == TRAINLOG_STATUS_OK) { + status = execute_sql( + database, + MIGRATE_V3_TO_V4_SQL_A + ); + } + + if (status == TRAINLOG_STATUS_OK) { + status = execute_sql( + database, + MIGRATE_V3_TO_V4_SQL_B + ); + } + } else if (version == 2) { + status = execute_sql( + database, + MIGRATE_V2_TO_V3_SQL + ); + + if (status == TRAINLOG_STATUS_OK) { + status = execute_sql( + database, + MIGRATE_V3_TO_V4_SQL_A + ); + } + + if (status == TRAINLOG_STATUS_OK) { + status = execute_sql( + database, + MIGRATE_V3_TO_V4_SQL_B + ); + } + } else if (version == 3) { + status = execute_sql( + database, + MIGRATE_V3_TO_V4_SQL_A + ); + + if (status == TRAINLOG_STATUS_OK) { + status = execute_sql( + database, + MIGRATE_V3_TO_V4_SQL_B + ); + } } else { return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED; } @@ -346,6 +585,42 @@ static const char *tracking_mode_to_sql(TrainlogTrackingMode mode) } } +static const char *recording_mode_to_sql( + TrainlogRecordingMode mode +) +{ + switch (mode) { + case TRAINLOG_RECORDING_SETS: + return "sets"; + case TRAINLOG_RECORDING_CONTINUOUS: + return "continuous"; + default: + return NULL; + } +} + +static bool exercise_profile_valid( + TrainlogTrackingMode tracking_mode, + TrainlogRecordingMode recording_mode, + TrainlogExerciseDataFields data_fields +) +{ + if ((data_fields & + ~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) != 0U) { + return false; + } + + if (recording_mode == TRAINLOG_RECORDING_CONTINUOUS) { + return tracking_mode == TRAINLOG_TRACKING_DURATION; + } + + return + recording_mode == TRAINLOG_RECORDING_SETS && + (tracking_mode == TRAINLOG_TRACKING_REPS || + tracking_mode == TRAINLOG_TRACKING_DURATION); +} + + static const char *load_mode_to_sql(TrainlogLoadMode mode) { switch (mode) { @@ -376,20 +651,25 @@ static const char *session_type_to_sql( } } -TrainlogStatus trainlog_database_insert_exercise( +TrainlogStatus trainlog_database_insert_exercise_profiled( TrainlogDatabase *database, const char *exercise_id, const char *name, const char *normalized_name, - TrainlogTrackingMode tracking_mode + TrainlogTrackingMode tracking_mode, + TrainlogRecordingMode recording_mode, + TrainlogExerciseDataFields data_fields ) { static const char *const SQL = "INSERT INTO exercises(" - "exercise_id, name, normalized_name, tracking_mode" - ") VALUES(?1, ?2, ?3, ?4);"; + "exercise_id, name, normalized_name, tracking_mode, " + "recording_mode, data_fields" + ") VALUES(?1, ?2, ?3, ?4, ?5, ?6);"; + sqlite3_stmt *statement = NULL; - const char *mode; + const char *tracking; + const char *recording; int rc; if (database == NULL || @@ -399,16 +679,30 @@ TrainlogStatus trainlog_database_insert_exercise( name == NULL || name[0] == '\0' || normalized_name == NULL || - normalized_name[0] == '\0') { + normalized_name[0] == '\0' || + !exercise_profile_valid( + tracking_mode, + recording_mode, + data_fields + )) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } - mode = tracking_mode_to_sql(tracking_mode); - if (mode == NULL) { + tracking = tracking_mode_to_sql(tracking_mode); + recording = recording_mode_to_sql(recording_mode); + + if (tracking == NULL || recording == NULL) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } - rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL); + rc = sqlite3_prepare_v2( + database->connection, + SQL, + -1, + &statement, + NULL + ); + if (rc != SQLITE_OK) { return TRAINLOG_STATUS_DATABASE_ERROR; } @@ -416,16 +710,20 @@ TrainlogStatus trainlog_database_insert_exercise( if (sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, name, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 3, normalized_name, -1, SQLITE_TRANSIENT) != SQLITE_OK || - sqlite3_bind_text(statement, 4, mode, -1, SQLITE_STATIC) != SQLITE_OK) { + sqlite3_bind_text(statement, 4, tracking, -1, SQLITE_STATIC) != SQLITE_OK || + sqlite3_bind_text(statement, 5, recording, -1, SQLITE_STATIC) != SQLITE_OK || + sqlite3_bind_int64(statement, 6, (sqlite3_int64)data_fields) != SQLITE_OK) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; } rc = sqlite3_step(statement); + if (rc == SQLITE_CONSTRAINT) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_CONFLICT; } + if (rc != SQLITE_DONE) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; @@ -436,6 +734,25 @@ TrainlogStatus trainlog_database_insert_exercise( : TRAINLOG_STATUS_DATABASE_ERROR; } +TrainlogStatus trainlog_database_insert_exercise( + TrainlogDatabase *database, + const char *exercise_id, + const char *name, + const char *normalized_name, + TrainlogTrackingMode tracking_mode +) +{ + return trainlog_database_insert_exercise_profiled( + database, + exercise_id, + name, + normalized_name, + tracking_mode, + TRAINLOG_RECORDING_SETS, + 0U + ); +} + static TrainlogStatus count_query( TrainlogDatabase *database, const char *sql, @@ -508,6 +825,18 @@ static TrainlogTrackingMode tracking_mode_from_sql(const char *text) : TRAINLOG_TRACKING_REPS; } +static TrainlogRecordingMode recording_mode_from_sql( + const char *text +) +{ + return + text != NULL && + strcmp(text, "continuous") == 0 + ? TRAINLOG_RECORDING_CONTINUOUS + : TRAINLOG_RECORDING_SETS; +} + + static bool session_type_from_sql( const char *text, TrainlogSessionType *output @@ -539,8 +868,11 @@ TrainlogStatus trainlog_database_list_exercises( ) { static const char *const SQL = - "SELECT exercise_id, name, tracking_mode " - "FROM exercises ORDER BY name COLLATE NOCASE, exercise_id;"; + "SELECT exercise_id, name, tracking_mode, " + "recording_mode, data_fields " + "FROM exercises " + "ORDER BY name COLLATE NOCASE, exercise_id;"; + sqlite3_stmt *statement = NULL; size_t count = 0U; int rc; @@ -548,41 +880,83 @@ TrainlogStatus trainlog_database_list_exercises( if (database == NULL || database->connection == NULL || output_count == NULL || - (capacity > 0U && output == NULL)) { + (output == NULL && capacity != 0U)) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } - rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL); + *output_count = 0U; + + rc = sqlite3_prepare_v2( + database->connection, + SQL, + -1, + &statement, + NULL + ); + if (rc != SQLITE_OK) { return TRAINLOG_STATUS_DATABASE_ERROR; } while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { - if (count < capacity) { - const unsigned char *id = sqlite3_column_text(statement, 0); - const unsigned char *name = sqlite3_column_text(statement, 1); - const unsigned char *mode = sqlite3_column_text(statement, 2); + if (output != NULL && count < capacity) { + const unsigned char *exercise_id = + sqlite3_column_text(statement, 0); + const unsigned char *name = + sqlite3_column_text(statement, 1); + const unsigned char *tracking = + sqlite3_column_text(statement, 2); + const unsigned char *recording = + sqlite3_column_text(statement, 3); + sqlite3_int64 data_fields = + sqlite3_column_int64(statement, 4); - if (id == NULL || name == NULL || mode == NULL) { + if (exercise_id == NULL || + name == NULL || + tracking == NULL || + recording == NULL || + data_fields < 0 || + (uint64_t)data_fields > (uint64_t)UINT32_MAX || + (((TrainlogExerciseDataFields)data_fields) & + ~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) != 0U) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; } + (void)memset( + &output[count], + 0, + sizeof(output[count]) + ); + (void)snprintf( output[count].exercise_id, sizeof(output[count].exercise_id), "%s", - (const char *)id + (const char *)exercise_id ); + (void)snprintf( output[count].name, sizeof(output[count].name), "%s", (const char *)name ); + output[count].tracking_mode = - tracking_mode_from_sql((const char *)mode); + tracking_mode_from_sql( + (const char *)tracking + ); + + output[count].recording_mode = + recording_mode_from_sql( + (const char *)recording + ); + + output[count].data_fields = + (TrainlogExerciseDataFields)data_fields; } + ++count; } @@ -595,7 +969,12 @@ TrainlogStatus trainlog_database_list_exercises( return TRAINLOG_STATUS_DATABASE_ERROR; } - *output_count = count < capacity ? count : capacity; + *output_count = count; + + if (output != NULL && count > capacity) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + return TRAINLOG_STATUS_OK; } @@ -843,11 +1222,69 @@ static TrainlogStatus insert_session_exercise( sqlite3_stmt *statement = NULL; sqlite3_int64 exercise_row_id; const char *load_mode; + const char *recording_mode; int rc; TrainlogStatus status; - load_mode = load_mode_to_sql(input->load_mode); - if (load_mode == NULL) { + if (input == NULL || + output_row_id == NULL || + (input->data_fields & + ~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) != 0U) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + load_mode = + load_mode_to_sql( + input->load_mode + ); + + recording_mode = + recording_mode_to_sql( + input->recording_mode + ); + + if (load_mode == NULL || + recording_mode == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + if (input->recording_mode == + TRAINLOG_RECORDING_CONTINUOUS) { + bool speed_required = + (input->data_fields & + TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U; + + bool distance_required = + (input->data_fields & + TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U; + + if (input->load_mode != TRAINLOG_LOAD_NONE || + input->rest_seconds != 0 || + input->target_sets != 0 || + input->target_reps != 0 || + input->target_duration_seconds != 0 || + input->target_has_weight || + input->set_count != 0U || + input->continuous_duration_seconds <= 0 || + input->continuous_has_speed != speed_required || + input->continuous_has_distance != distance_required || + (input->continuous_has_speed && + input->continuous_speed_kmh <= 0.0) || + (input->continuous_has_distance && + input->continuous_distance_km <= 0.0)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + } else if ( + input->recording_mode == + TRAINLOG_RECORDING_SETS + ) { + if (input->target_sets <= 0 || + input->continuous_duration_seconds != 0 || + input->continuous_has_speed || + input->continuous_has_distance) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + } else { return TRAINLOG_STATUS_INVALID_ARGUMENT; } @@ -856,6 +1293,7 @@ static TrainlogStatus insert_session_exercise( input->exercise_id, &exercise_row_id ); + if (status != TRAINLOG_STATUS_OK) { return status; } @@ -863,63 +1301,150 @@ static TrainlogStatus insert_session_exercise( rc = sqlite3_prepare_v2( database->connection, "INSERT INTO session_exercises(" - "session_row_id, exercise_row_id, position, load_mode, " - "rest_seconds, target_sets, target_reps, " - "target_duration_seconds, target_weight_kg, notes" - ") VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);", + "session_row_id, exercise_row_id, " + "recording_mode, data_fields, " + "position, load_mode, rest_seconds, " + "target_sets, target_reps, " + "target_duration_seconds, " + "target_weight_kg, notes" + ") VALUES(" + "?1, ?2, ?3, ?4, ?5, ?6, " + "?7, ?8, ?9, ?10, ?11, ?12" + ");", -1, &statement, NULL ); + if (rc != SQLITE_OK) { return TRAINLOG_STATUS_DATABASE_ERROR; } - rc = sqlite3_bind_int64(statement, 1, session_row_id); + rc = sqlite3_bind_int64( + statement, + 1, + session_row_id + ); + if (rc == SQLITE_OK) { - rc = sqlite3_bind_int64(statement, 2, exercise_row_id); + rc = sqlite3_bind_int64( + statement, + 2, + exercise_row_id + ); } + if (rc == SQLITE_OK) { - rc = sqlite3_bind_int64(statement, 3, (sqlite3_int64)position); + rc = sqlite3_bind_text( + statement, + 3, + recording_mode, + -1, + SQLITE_STATIC + ); } + if (rc == SQLITE_OK) { - rc = sqlite3_bind_text(statement, 4, load_mode, -1, SQLITE_STATIC); + rc = sqlite3_bind_int64( + statement, + 4, + (sqlite3_int64)input->data_fields + ); } + if (rc == SQLITE_OK) { - rc = sqlite3_bind_int(statement, 5, input->rest_seconds); + rc = sqlite3_bind_int64( + statement, + 5, + (sqlite3_int64)position + ); } + if (rc == SQLITE_OK) { - rc = sqlite3_bind_int(statement, 6, input->target_sets); + rc = sqlite3_bind_text( + statement, + 6, + load_mode, + -1, + SQLITE_STATIC + ); } + + if (rc == SQLITE_OK) { + rc = sqlite3_bind_int( + statement, + 7, + input->rest_seconds + ); + } + + if (rc == SQLITE_OK) { + rc = input->target_sets > 0 + ? sqlite3_bind_int( + statement, + 8, + input->target_sets + ) + : sqlite3_bind_null( + statement, + 8 + ); + } + if (rc == SQLITE_OK) { rc = input->target_reps > 0 - ? sqlite3_bind_int(statement, 7, input->target_reps) - : sqlite3_bind_null(statement, 7); + ? sqlite3_bind_int( + statement, + 9, + input->target_reps + ) + : sqlite3_bind_null( + statement, + 9 + ); } + if (rc == SQLITE_OK) { rc = input->target_duration_seconds > 0 ? sqlite3_bind_int( statement, - 8, + 10, input->target_duration_seconds ) - : sqlite3_bind_null(statement, 8); + : sqlite3_bind_null( + statement, + 10 + ); } + if (rc == SQLITE_OK) { rc = input->target_has_weight - ? sqlite3_bind_double(statement, 9, input->target_weight_kg) - : sqlite3_bind_null(statement, 9); - } - if (rc == SQLITE_OK) { - rc = input->notes != NULL && input->notes[0] != '\0' - ? sqlite3_bind_text( + ? sqlite3_bind_double( statement, - 10, - input->notes, - -1, - SQLITE_TRANSIENT + 11, + input->target_weight_kg ) - : sqlite3_bind_null(statement, 10); + : sqlite3_bind_null( + statement, + 11 + ); + } + + if (rc == SQLITE_OK) { + rc = + input->notes != NULL && + input->notes[0] != '\0' + ? sqlite3_bind_text( + statement, + 12, + input->notes, + -1, + SQLITE_TRANSIENT + ) + : sqlite3_bind_null( + statement, + 12 + ); } if (rc != SQLITE_OK) { @@ -928,10 +1453,12 @@ static TrainlogStatus insert_session_exercise( } rc = sqlite3_step(statement); + if (rc == SQLITE_CONSTRAINT) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_CONFLICT; } + if (rc != SQLITE_DONE) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; @@ -941,10 +1468,109 @@ static TrainlogStatus insert_session_exercise( return TRAINLOG_STATUS_DATABASE_ERROR; } - *output_row_id = sqlite3_last_insert_rowid(database->connection); + *output_row_id = + sqlite3_last_insert_rowid( + database->connection + ); + return TRAINLOG_STATUS_OK; } +static TrainlogStatus insert_continuous_activity( + TrainlogDatabase *database, + sqlite3_int64 session_exercise_row_id, + const TrainlogSessionExerciseInput *input +) +{ + sqlite3_stmt *statement = NULL; + int rc; + + if (database == NULL || + database->connection == NULL || + input == NULL || + input->recording_mode != TRAINLOG_RECORDING_CONTINUOUS || + input->continuous_duration_seconds <= 0) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + rc = sqlite3_prepare_v2( + database->connection, + "INSERT INTO continuous_activity(" + "session_exercise_row_id, " + "duration_seconds, speed_kmh, " + "distance_km" + ") VALUES(?1, ?2, ?3, ?4);", + -1, + &statement, + NULL + ); + + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_bind_int64( + statement, + 1, + session_exercise_row_id + ); + + if (rc == SQLITE_OK) { + rc = sqlite3_bind_int( + statement, + 2, + input->continuous_duration_seconds + ); + } + + if (rc == SQLITE_OK) { + rc = input->continuous_has_speed + ? sqlite3_bind_double( + statement, + 3, + input->continuous_speed_kmh + ) + : sqlite3_bind_null( + statement, + 3 + ); + } + + if (rc == SQLITE_OK) { + rc = input->continuous_has_distance + ? sqlite3_bind_double( + statement, + 4, + input->continuous_distance_km + ) + : sqlite3_bind_null( + statement, + 4 + ); + } + + if (rc != SQLITE_OK) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_step(statement); + + if (rc == SQLITE_CONSTRAINT) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_CONFLICT; + } + + if (rc != SQLITE_DONE) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + return sqlite3_finalize(statement) == SQLITE_OK + ? TRAINLOG_STATUS_OK + : TRAINLOG_STATUS_DATABASE_ERROR; +} + static TrainlogStatus insert_performed_set( TrainlogDatabase *database, sqlite3_int64 session_exercise_row_id, @@ -1019,7 +1645,8 @@ static TrainlogStatus insert_session_children( if (database == NULL || database->connection == NULL || - (exercise_count > 0U && exercises == NULL)) { + (exercise_count > 0U && + exercises == NULL)) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } @@ -1045,6 +1672,21 @@ static TrainlogStatus insert_session_children( return status; } + if (exercise->recording_mode == + TRAINLOG_RECORDING_CONTINUOUS) { + status = insert_continuous_activity( + database, + session_exercise_row_id, + exercise + ); + + if (status != TRAINLOG_STATUS_OK) { + return status; + } + + continue; + } + for (set_index = 0U; set_index < exercise->set_count; ++set_index) { @@ -1801,13 +2443,22 @@ TrainlogStatus trainlog_database_get_session_details( static const char *const EXERCISE_SQL = "SELECT " - "e.name, e.tracking_mode, se.load_mode, se.rest_seconds, " - "se.target_sets, COALESCE(se.target_reps, 0), " + "e.name, e.tracking_mode, " + "se.recording_mode, se.data_fields, " + "se.load_mode, se.rest_seconds, " + "COALESCE(se.target_sets, 0), " + "COALESCE(se.target_reps, 0), " "COALESCE(se.target_duration_seconds, 0), " - "se.target_weight_kg, se.id " + "se.target_weight_kg, " + "ca.duration_seconds, ca.speed_kmh, ca.distance_km, " + "se.id " "FROM session_exercises AS se " - "JOIN sessions AS s ON s.id = se.session_row_id " - "JOIN exercises AS e ON e.id = se.exercise_row_id " + "JOIN sessions AS s " + " ON s.id = se.session_row_id " + "JOIN exercises AS e " + " ON e.id = se.exercise_row_id " + "LEFT JOIN continuous_activity AS ca " + " ON ca.session_exercise_row_id = se.id " "WHERE s.session_id = ?1 " "ORDER BY se.position ASC;"; @@ -1823,11 +2474,17 @@ TrainlogStatus trainlog_database_get_session_details( session_id[0] == '\0' || output_session == NULL || output_exercise_count == NULL || - (exercise_capacity > 0U && output_exercises == NULL)) { + (exercise_capacity > 0U && + output_exercises == NULL)) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } - (void)memset(output_session, 0, sizeof(*output_session)); + (void)memset( + output_session, + 0, + sizeof(*output_session) + ); + *output_exercise_count = 0U; rc = sqlite3_prepare_v2( @@ -1837,6 +2494,7 @@ TrainlogStatus trainlog_database_get_session_details( &header, NULL ); + if (rc != SQLITE_OK) { return TRAINLOG_STATUS_DATABASE_ERROR; } @@ -1848,16 +2506,19 @@ TrainlogStatus trainlog_database_get_session_details( -1, SQLITE_TRANSIENT ); + if (rc != SQLITE_OK) { (void)sqlite3_finalize(header); return TRAINLOG_STATUS_DATABASE_ERROR; } rc = sqlite3_step(header); + if (rc == SQLITE_DONE) { (void)sqlite3_finalize(header); return TRAINLOG_STATUS_NOT_FOUND; } + if (rc != SQLITE_ROW) { (void)sqlite3_finalize(header); return TRAINLOG_STATUS_DATABASE_ERROR; @@ -1866,6 +2527,7 @@ TrainlogStatus trainlog_database_get_session_details( { const unsigned char *started = sqlite3_column_text(header, 0); + const unsigned char *ended = sqlite3_column_text(header, 1); @@ -1920,6 +2582,7 @@ TrainlogStatus trainlog_database_get_session_details( &exercises, NULL ); + if (rc != SQLITE_OK) { return TRAINLOG_STATUS_DATABASE_ERROR; } @@ -1931,6 +2594,7 @@ TrainlogStatus trainlog_database_get_session_details( -1, SQLITE_TRANSIENT ); + if (rc != SQLITE_OK) { (void)sqlite3_finalize(exercises); return TRAINLOG_STATUS_DATABASE_ERROR; @@ -1947,22 +2611,38 @@ TrainlogStatus trainlog_database_get_session_details( const unsigned char *tracking = sqlite3_column_text(exercises, 1); - const unsigned char *load = + const unsigned char *recording = sqlite3_column_text(exercises, 2); + sqlite3_int64 data_fields = + sqlite3_column_int64(exercises, 3); + + const unsigned char *load = + sqlite3_column_text(exercises, 4); + sqlite3_int64 session_exercise_row_id = - sqlite3_column_int64(exercises, 8); + sqlite3_column_int64(exercises, 13); TrainlogStatus status; if (name == NULL || tracking == NULL || - load == NULL) { + recording == NULL || + load == NULL || + data_fields < 0 || + (uint64_t)data_fields > + (uint64_t)UINT32_MAX || + (((TrainlogExerciseDataFields)data_fields) & + ~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) != 0U) { (void)sqlite3_finalize(exercises); return TRAINLOG_STATUS_DATABASE_ERROR; } - (void)memset(detail, 0, sizeof(*detail)); + (void)memset( + detail, + 0, + sizeof(*detail) + ); (void)snprintf( detail->name, @@ -1979,46 +2659,137 @@ TrainlogStatus trainlog_database_get_session_details( ? TRAINLOG_TRACKING_DURATION : TRAINLOG_TRACKING_REPS; + detail->recording_mode = + recording_mode_from_sql( + (const char *)recording + ); + + detail->data_fields = + (TrainlogExerciseDataFields) + data_fields; + detail->load_mode = detail_load_mode_from_text( (const char *)load ); detail->rest_seconds = - sqlite3_column_int(exercises, 3); + sqlite3_column_int( + exercises, + 5 + ); detail->target_sets = - sqlite3_column_int(exercises, 4); + sqlite3_column_int( + exercises, + 6 + ); detail->target_reps = - sqlite3_column_int(exercises, 5); + sqlite3_column_int( + exercises, + 7 + ); detail->target_duration_seconds = - sqlite3_column_int(exercises, 6); + sqlite3_column_int( + exercises, + 8 + ); detail->has_target_weight = sqlite3_column_type( exercises, - 7 + 9 ) != SQLITE_NULL; detail->target_weight_kg = detail->has_target_weight != 0 ? sqlite3_column_double( exercises, - 7 + 9 ) : 0.0; - status = detail_fill_sets( - database, - session_exercise_row_id, - detail - ); + if (detail->recording_mode == + TRAINLOG_RECORDING_CONTINUOUS) { + bool speed_required = + (detail->data_fields & + TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U; - if (status != TRAINLOG_STATUS_OK) { - (void)sqlite3_finalize(exercises); - return status; + bool distance_required = + (detail->data_fields & + TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U; + + if (sqlite3_column_type( + exercises, + 10 + ) == SQLITE_NULL) { + (void)sqlite3_finalize(exercises); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + detail->continuous_duration_seconds = + sqlite3_column_int( + exercises, + 10 + ); + + detail->has_continuous_speed = + sqlite3_column_type( + exercises, + 11 + ) != SQLITE_NULL; + + detail->continuous_speed_kmh = + detail->has_continuous_speed != 0 + ? sqlite3_column_double( + exercises, + 11 + ) + : 0.0; + + detail->has_continuous_distance = + sqlite3_column_type( + exercises, + 12 + ) != SQLITE_NULL; + + detail->continuous_distance_km = + detail->has_continuous_distance != 0 + ? sqlite3_column_double( + exercises, + 12 + ) + : 0.0; + + if ((detail->has_continuous_speed != 0) != + speed_required || + (detail->has_continuous_distance != 0) != + distance_required) { + (void)sqlite3_finalize(exercises); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + detail->actual_set_count = 0U; + + (void)snprintf( + detail->actual_summary, + sizeof(detail->actual_summary), + "%s", + "activité continue" + ); + } else { + status = detail_fill_sets( + database, + session_exercise_row_id, + detail + ); + + if (status != TRAINLOG_STATUS_OK) { + (void)sqlite3_finalize(exercises); + return status; + } } ++copied; diff --git a/tui/src/tui.c b/tui/src/tui.c index c56d272..eed7e4d 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -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 + ) + ); + } } } diff --git a/tui/tests/test_catalog.c b/tui/tests/test_catalog.c index d2609a4..ce847c0 100644 --- a/tui/tests/test_catalog.c +++ b/tui/tests/test_catalog.c @@ -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; } diff --git a/tui/tests/test_continuous_detail.c b/tui/tests/test_continuous_detail.c new file mode 100644 index 0000000..e9dd366 --- /dev/null +++ b/tui/tests/test_continuous_detail.c @@ -0,0 +1,168 @@ +#include +#include +#include + +#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; +} diff --git a/tui/tests/test_continuous_session.c b/tui/tests/test_continuous_session.c new file mode 100644 index 0000000..f19a273 --- /dev/null +++ b/tui/tests/test_continuous_session.c @@ -0,0 +1,275 @@ +#include +#include +#include +#include +#include + +#include + +#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; +} diff --git a/tui/tests/test_exercise_profile_schema.c b/tui/tests/test_exercise_profile_schema.c new file mode 100644 index 0000000..405b9cc --- /dev/null +++ b/tui/tests/test_exercise_profile_schema.c @@ -0,0 +1,288 @@ +#include +#include +#include +#include +#include +#include + +#include + +#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; +} diff --git a/tui/tests/test_session_type_schema.c b/tui/tests/test_session_type_schema.c index 9755fa9..f8bd84d 100644 --- a/tui/tests/test_session_type_schema.c +++ b/tui/tests/test_session_type_schema.c @@ -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 @@ -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("