diff --git a/CHANGELOG.md b/CHANGELOG.md index f06a0e1..4c846b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,3 +84,19 @@ ANDROID_SYNC_RECEIPT=PASS TUI_SYNC_LOG_SHOW=PASS BIDIRECTIONAL_SYNC_V1=PASS ``` + +### Measured max v1 + +Added: + +- explicit measured-max derivation from `max_test` sessions only; +- newest successful measured result and same-mode historical record; +- dedicated TUI measured-max history and graph; +- external working-load calculations at 60/70/80/90%; +- selectable 0.5/1/2.5/5.0 kg working-load rounding; +- direction-aware assistance measured-max semantics; +- Android `Entraînement` / `Test max` session selection; +- Android history/detail max-test identification; +- measured-max regression coverage. + +No estimated 1RM, schema v6, or frozen Trainlog JSON v1 change was introduced. diff --git a/README.md b/README.md index fa96c37..bebc3f8 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ ANDROID_TRIGGERED_SYNC=PASS ANDROID_SYNC_RECEIPT=PASS BIDIRECTIONAL_SYNC_V1=PASS -DESKTOP_TESTS=19/19 PASS +DESKTOP_TESTS=20/20 PASS ANDROID_BUILD=PASS ``` @@ -187,3 +187,23 @@ runs, a receipt is returned to Android, and the PC catalog is applied locally. - no fake data representation to force incompatible models together; - strict compiler warnings; - documentation and tests are part of feature completion. + +## Measured max + +Explicit `max_test` sessions are the only source of measured maxima. + +Ordinary training best sets remain ordinary performance even when they exceed a +previous max-test result. + +The desktop exercise catalog exposes a separate measured-max view with current +result, same-mode record, test history, a dedicated graph, and 60/70/80/90% +working loads for external resistance. Working loads are rounded to a selectable +practical increment and are not calculated for assistance. + +Android can explicitly save a session as `Entraînement` or `Test max`. + +```text +MEASURED_MAX_V1=PASS +WORKING_LOAD_PERCENTAGES=PASS +ANDROID_MAX_TEST_SESSION=PASS +``` diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 185ef6c..ceaff42 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -16,6 +16,7 @@ import com.labfytools.trainlog.model.SessionSummary import com.labfytools.trainlog.model.SessionDetail import com.labfytools.trainlog.model.SessionExerciseDetail import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.SessionType import com.labfytools.trainlog.model.TrackingMode import org.json.JSONArray import org.json.JSONObject @@ -296,7 +297,8 @@ class TrainlogRepository( put( "session_type", - "training" + draft.sessionType + .wireValue ) } @@ -469,6 +471,7 @@ class TrainlogRepository( SELECT s.session_id, s.started_at, + s.session_type, COUNT(se.id) FROM sessions AS s LEFT JOIN session_exercises AS se @@ -488,7 +491,11 @@ class TrainlogRepository( startedAt = cursor.getString(1), exerciseCount = - cursor.getInt(2), + cursor.getInt(3), + sessionType = + SessionType.fromWire( + cursor.getString(2) + ), ) } } @@ -1136,6 +1143,7 @@ class TrainlogRepository( SELECT s.session_id, s.started_at, + s.session_type, COUNT(se.id) FROM sessions AS s LEFT JOIN session_exercises AS se @@ -1154,7 +1162,11 @@ class TrainlogRepository( startedAt = cursor.getString(1), exerciseCount = - cursor.getInt(2), + cursor.getInt(3), + sessionType = + SessionType.fromWire( + cursor.getString(2) + ), ) } } ?: return null diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt index 2b6a7b2..342d68a 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt @@ -1,5 +1,23 @@ package com.labfytools.trainlog.model +enum class SessionType( + val wireValue: String, +) { + TRAINING("training"), + MAX_TEST("max_test"); + + companion object { + fun fromWire( + value: String, + ): SessionType = + if (value == "max_test") { + MAX_TEST + } else { + TRAINING + } + } +} + data class SessionSetDraft( val reps: Int = 0, val durationSeconds: Int = 0, @@ -15,12 +33,14 @@ data class SessionExerciseDraft( data class SessionDraft( val exercises: List, + val sessionType: SessionType = SessionType.TRAINING, ) data class SessionSummary( val sessionId: String, val startedAt: String, val exerciseCount: Int, + val sessionType: SessionType = SessionType.TRAINING, ) data class SessionExerciseDetail( diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt index 7f1be59..a5c8415 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt @@ -3,6 +3,7 @@ package com.labfytools.trainlog.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.model.SessionType import com.labfytools.trainlog.ui.theme.LocalTrainlogColors @Composable @@ -49,12 +50,25 @@ fun HistoryScreen( session.startedAt ), description = - "${session.exerciseCount} exercice(s)", + sessionTypeLabel( + session.sessionType + ) + + " · " + + "${session.exerciseCount} exercice(s)", onClick = { onOpenSession( session.sessionId ) }, + accent = + if ( + session.sessionType == + SessionType.MAX_TEST + ) { + colors.warning + } else { + colors.accent + }, ) } } @@ -62,6 +76,18 @@ fun HistoryScreen( } } +internal fun sessionTypeLabel( + value: SessionType, +): String = + if ( + value == + SessionType.MAX_TEST + ) { + "TEST MAX" + } else { + "ENTRAÎNEMENT" + } + internal fun formatStartedAt( value: String, ): String { diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt index 6e750b0..8ae7a26 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt @@ -6,6 +6,7 @@ import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.SessionExerciseDetail +import com.labfytools.trainlog.model.SessionType import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.ui.theme.LocalTrainlogColors @@ -63,6 +64,25 @@ fun SessionDetailScreen( ) ) + TrainlogInfo( + text = + "Type : " + + sessionTypeLabel( + detail.summary + .sessionType + ), + color = + if ( + detail.summary + .sessionType == + SessionType.MAX_TEST + ) { + colors.warning + } else { + colors.accent + }, + ) + TrainlogInfo( "${detail.summary.exerciseCount} exercice(s)" ) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index 8f274c2..d008c3d 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -1,5 +1,7 @@ package com.labfytools.trainlog.ui +/* TRAINLOG_ANDROID_MAX_TEST_SESSION_V1 */ + /* TRAINLOG_ANDROID_SESSION_REMOVE */ /* TRAINLOG_VARIABLE_SET_REPS_V1 */ @@ -30,6 +32,7 @@ import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.SessionDraft import com.labfytools.trainlog.model.SessionExerciseDraft import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.SessionType import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.TrainlogTypography @@ -70,6 +73,13 @@ fun SessionScreen( ) } + var sessionType by + remember { + mutableStateOf( + SessionType.TRAINING + ) + } + var sessionRevision by remember { mutableIntStateOf(0) @@ -93,9 +103,89 @@ fun SessionScreen( accent = colors.muted, ) + TrainlogFrame( + title = "TYPE DE SEANCE" + ) { + TrainlogAction( + label = + if ( + sessionType == + SessionType.TRAINING + ) { + "[✓] Entraînement" + } else { + "[ ] Entraînement" + }, + description = + "Séance normale de travail.", + accent = + if ( + sessionType == + SessionType.TRAINING + ) { + colors.success + } else { + colors.muted + }, + onClick = { + sessionType = + SessionType.TRAINING + }, + ) + + TrainlogAction( + label = + if ( + sessionType == + SessionType.MAX_TEST + ) { + "[✓] Test max" + } else { + "[ ] Test max" + }, + description = + "Séance explicitement dédiée à une mesure de max.", + accent = + if ( + sessionType == + SessionType.MAX_TEST + ) { + colors.warning + } else { + colors.muted + }, + onClick = { + sessionType = + SessionType.MAX_TEST + }, + ) + } + TrainlogFrame( title = "SEANCE EN COURS" ) { + TrainlogInfo( + text = + "Type : " + + if ( + sessionType == + SessionType.MAX_TEST + ) { + "TEST MAX" + } else { + "ENTRAÎNEMENT" + }, + color = + if ( + sessionType == + SessionType.MAX_TEST + ) { + colors.warning + } else { + colors.accent + }, + ) + if ( draftExercises.isEmpty() ) { @@ -254,7 +344,9 @@ fun SessionScreen( .saveSession( SessionDraft( exercises = - draftExercises + draftExercises, + sessionType = + sessionType, ) ) ) { @@ -265,6 +357,9 @@ fun SessionScreen( selectedExercise = null + sessionType = + SessionType.TRAINING + sessionRevision += 1 message = diff --git a/docs/android.md b/docs/android.md index e11a244..a146c87 100644 --- a/docs/android.md +++ b/docs/android.md @@ -242,3 +242,29 @@ Android is not intended to own: - direct SQLite-file synchronization; - exercise-name heuristics; - a mounted-filesystem dependency. + +## 15. Test-max sessions + +Android session entry exposes: + +```text +Entraînement +Test max +``` + +The selection is persisted in the existing Android `sessions.session_type` +column and exported in the mobile snapshot as: + +```text +training +max_test +``` + +History and detail visibly identify max-test sessions. + +Selecting `Test max` is explicit metadata; Trainlog does not infer max tests +from large repetition or duration values. + +Android's current session form still records the exercise data fields it +supports. Measured-max classification on the desktop uses only actual values +that were truly captured and synchronized. diff --git a/docs/current_state.md b/docs/current_state.md index 1827fca..dfd0450 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -31,7 +31,7 @@ ANDROID_SYNC_RECEIPT=PASS TUI_SYNC_LOG_SHOW=PASS BIDIRECTIONAL_SYNC_V1=PASS -DESKTOP_TESTS=19/19 PASS +DESKTOP_TESTS=20/20 PASS ANDROID_BUILD=PASS HARDWARE_SYNC_VALIDATION=PASS ``` @@ -120,7 +120,7 @@ No mounted Android filesystem is required. Desktop: ```text -19/19 Meson tests PASS +20/20 Meson tests PASS frozen JSON validator PASS import-contract validator PASS git diff --check PASS @@ -139,8 +139,31 @@ multiple distinct request IDs consumed once each PASS No new feature is frozen by this documentation cleanup. ```text +MEASURED_MAX_V1=PASS +WORKING_LOAD_PERCENTAGES=PASS +ANDROID_MAX_TEST_SESSION=PASS NEXT_FEATURE=UNFROZEN ``` Future work must start from this validated baseline rather than from obsolete historical `NEXT` notes. + +## Measured max v1 + +```text +MEASURED_MAX_V1=PASS +MEASURED_MAX_ONLY_FROM_MAX_TEST=PASS +WORKING_LOAD_PERCENTAGES=PASS +ASSISTANCE_DIRECTION_AWARE=PASS +ANDROID_MAX_TEST_SESSION=PASS +DESKTOP_TESTS=20/20 PASS +``` + +A measured maximum is derived only from explicit `max_test` sessions. Ordinary +training is never promoted implicitly. + +The current measured result is the newest successful max test. The historical +record compares max tests using the same load mode. + +External-load working percentages are pure calculations from the current +measured load; they are not persisted and no estimated 1RM is introduced. diff --git a/docs/database.md b/docs/database.md index 7a1eb26..8bdcd0e 100644 --- a/docs/database.md +++ b/docs/database.md @@ -276,3 +276,38 @@ schema_v5_migration ``` The current normal suite contains 19 tests. + +## 11. Measured-max derivation + +Measured maxima require no desktop schema v6. + +The existing `sessions.session_type = max_test` classification plus actual +`performed_sets` are sufficient. + +Exercise performance points carry the originating session type so the +measured-max layer can distinguish explicit tests from ordinary training. + +Rules: + +```text +training session + never becomes measured max implicitly + +max_test + external + greatest successful actual load + tie -> greatest reps/duration + +max_test + assistance + lowest successful assistance + tie -> greatest reps/duration + +max_test + no load + greatest successful reps/duration +``` + +A zero-repetition failed attempt is not a successful measurement. + +The current measured result is the newest successful max-test point. The record +is the best max-test point using the same load mode. + +No extra maximum row is persisted; results are derived from canonical history. diff --git a/docs/exercise_data_model.md b/docs/exercise_data_model.md index 88d0b3c..d97e4f1 100644 --- a/docs/exercise_data_model.md +++ b/docs/exercise_data_model.md @@ -199,3 +199,29 @@ Continuous activity must never be: - hidden in notes; - converted into a fake set; - silently discarded. + +## 10. Measured max semantics + +`session_type = max_test` is an explicit semantic boundary. + +Measured max v1 never equates an ordinary best set with a measured maximum. + +For set-based exercises: + +```text +external + max measured load = greatest successful actual load in a max_test + +assistance + best measured assistance = lowest successful assistance in a max_test + +none + measured max = greatest successful reps/duration in a max_test +``` + +Ties use greater repetitions/duration. + +The newest successful explicit test is the current measured result. A separate +same-mode historical record may be older. + +No estimated 1RM is mixed into this contract. diff --git a/docs/roadmap.md b/docs/roadmap.md index 701c70c..bd53fa6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -161,3 +161,23 @@ fake performed sets for continuous activity fake uniform targets for heterogeneous actual sets incompatible changes to Trainlog JSON v1 ``` + +## Measured max v1 + +```text +MEASURED_MAX_V1=PASS +MEASURED_MAX_ONLY_FROM_MAX_TEST=PASS +MEASURED_MAX_HISTORY=PASS +MEASURED_MAX_GRAPH=PASS +WORKING_LOAD_PERCENTAGES=PASS +ANDROID_MAX_TEST_SESSION=PASS +DESKTOP_TESTS=20/20 PASS +``` + +No schema v6 and no Trainlog JSON v1 change were required. + +The next product feature remains intentionally unfrozen: + +```text +NEXT_FEATURE=UNFROZEN +``` diff --git a/docs/tests.md b/docs/tests.md index f695a0b..809cfbb 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -85,7 +85,7 @@ Current normal suite: Validated checkpoint: ```text -19/19 PASS +20/20 PASS ``` Notable regression coverage: @@ -214,3 +214,29 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug Documentation must describe the resulting state, not retain contradictory old `NEXT` checkpoints. + +## 11. Measured-max regression + +The normal Meson suite contains: + +```text +measured_max +``` + +Coverage proves: + +- stronger ordinary training is ignored by measured-max classification; +- only `max_test` sessions participate; +- zero-repetition failed attempts are not promoted; +- newest successful explicit test is the current measurement; +- historical external-load record can remain older than current; +- lower assistance is better; +- no-load max tests compare actual reps/duration; +- external working loads round to the configured increment; +- working-load percentages reject assistance. + +Current normal baseline: + +```text +20/20 PASS +``` diff --git a/docs/tui.md b/docs/tui.md index 02cbfb7..acbe6ce 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -264,3 +264,43 @@ Current normal suite: ```text 19/19 PASS ``` + +## 15. Measured max view + +From `3 Exercices`, the selected exercise exposes: + +```text +Enter ordinary performance history +m measured max +``` + +The measured-max page is deliberately separate from ordinary best-set history. + +It shows: + +- count of explicit max-test sessions; +- newest successful measured result; +- best historical result using the same load mode; +- dedicated max-test graph; +- max-test history; +- external-load working percentages at 60%, 70%, 80%, and 90%. + +For external load, `r` cycles practical rounding increments: + +```text +0.5 kg +1.0 kg +2.5 kg +5.0 kg +``` + +Working percentages are display calculations only. + +Assistance remains inverse-direction: + +```text +less assistance = better +``` + +No percentage-of-max working load is produced for assistance or no-load +performance. diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index c8bed8e..e360f60 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -197,6 +197,7 @@ TrainlogStatus trainlog_database_latest_body_pair( typedef struct TrainlogExercisePerformancePoint { char session_id[TRAINLOG_ID_MAX + 1U]; char started_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + TrainlogSessionType session_type; TrainlogTrackingMode tracking_mode; TrainlogLoadMode load_mode; size_t actual_set_count; diff --git a/tui/include/trainlog/measured_max.h b/tui/include/trainlog/measured_max.h new file mode 100644 index 0000000..10ae4b9 --- /dev/null +++ b/tui/include/trainlog/measured_max.h @@ -0,0 +1,60 @@ +#ifndef TRAINLOG_MEASURED_MAX_H +#define TRAINLOG_MEASURED_MAX_H + +/** + * @file measured_max.h + * @brief Explicit measured-max classification and working-load calculations. + */ + +#include +#include + +#include "trainlog/database.h" +#include "trainlog/status.h" + +typedef struct TrainlogMeasuredMaxSummary { + size_t test_count; + size_t successful_test_count; + bool found; + TrainlogExercisePerformancePoint current; + bool has_record; + TrainlogExercisePerformancePoint record; +} TrainlogMeasuredMaxSummary; + +/** + * @brief Summarize explicit max-test sessions from newest-first performance. + * + * Only points whose session_type is TRAINLOG_SESSION_MAX_TEST participate. + * Ordinary training performance is never promoted to a measured maximum. + * + * current is the newest successful explicit max-test result. + * record is the best successful max-test result using the same load mode as + * current: + * + * - external: greatest actual load, tie -> greatest reps/duration; + * - assistance: lowest assistance, tie -> greatest reps/duration; + * - no load: greatest reps/duration. + * + * No estimated 1RM is calculated. + */ +TrainlogStatus trainlog_measured_max_summarize( + const TrainlogExercisePerformancePoint *points, + size_t count, + TrainlogMeasuredMaxSummary *output +); + +/** + * @brief Calculate a working load from the current measured external load. + * + * The result is percentage of the measured load rounded to the nearest + * positive increment. This calculation is deliberately unavailable for + * assistance and no-load performance. + */ +TrainlogStatus trainlog_measured_max_working_load( + const TrainlogExercisePerformancePoint *measured, + double percentage, + double increment_kg, + double *output_kg +); + +#endif diff --git a/tui/meson.build b/tui/meson.build index 7ffa258..ba27b51 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -36,6 +36,7 @@ trainlog_core_sources = files( 'src/usb.c', 'src/mtp.c', 'src/reps.c', + 'src/measured_max.c', 'src/sync.c', ) @@ -341,3 +342,15 @@ trainlog_sync_once = executable( dependencies: trainlog_core_dep, c_args: strict_c_args, ) + +test_measured_max = executable( + 'test_measured_max', + 'tests/test_measured_max.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + +test( + 'measured_max', + test_measured_max, +) diff --git a/tui/src/database.c b/tui/src/database.c index f36eeb9..b6f0546 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -3340,6 +3340,7 @@ static bool performance_candidate_better( } } +/* TRAINLOG_MEASURED_MAX_SESSION_TYPE_V1 */ TrainlogStatus trainlog_database_list_exercise_performance( TrainlogDatabase *database, const char *exercise_id, @@ -3352,6 +3353,7 @@ TrainlogStatus trainlog_database_list_exercise_performance( "SELECT " "s.session_id, " "s.started_at, " + "s.session_type, " "e.tracking_mode, " "se.load_mode, " "ps.id, " @@ -3377,13 +3379,19 @@ TrainlogStatus trainlog_database_list_exercise_performance( size_t copied = 0U; int rc; - if (database == NULL || + if ( + database == NULL || database->connection == NULL || exercise_id == NULL || exercise_id[0] == '\0' || output_count == NULL || - (capacity > 0U && output == NULL)) { - return TRAINLOG_STATUS_INVALID_ARGUMENT; + ( + capacity > 0U && + output == NULL + ) + ) { + return + TRAINLOG_STATUS_INVALID_ARGUMENT; } *output_count = 0U; @@ -3398,7 +3406,8 @@ TrainlogStatus trainlog_database_list_exercise_performance( ); if (rc != SQLITE_OK) { - return TRAINLOG_STATUS_DATABASE_ERROR; + return + TRAINLOG_STATUS_DATABASE_ERROR; } rc = sqlite3_bind_text( @@ -3410,31 +3419,63 @@ TrainlogStatus trainlog_database_list_exercise_performance( ); if (rc != SQLITE_OK) { - (void)sqlite3_finalize(statement); - return TRAINLOG_STATUS_DATABASE_ERROR; + (void)sqlite3_finalize( + statement + ); + + return + TRAINLOG_STATUS_DATABASE_ERROR; } - while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { + while ( + (rc = sqlite3_step(statement)) == + SQLITE_ROW + ) { const unsigned char *session_id = - sqlite3_column_text(statement, 0); + sqlite3_column_text( + statement, + 0 + ); const unsigned char *started_at = - sqlite3_column_text(statement, 1); + sqlite3_column_text( + statement, + 1 + ); + + const unsigned char *session_type = + sqlite3_column_text( + statement, + 2 + ); const unsigned char *tracking_mode = - sqlite3_column_text(statement, 2); + sqlite3_column_text( + statement, + 3 + ); const unsigned char *load_mode = - sqlite3_column_text(statement, 3); + sqlite3_column_text( + statement, + 4 + ); bool new_session; - if (session_id == NULL || + if ( + session_id == NULL || started_at == NULL || + session_type == NULL || tracking_mode == NULL || - load_mode == NULL) { - (void)sqlite3_finalize(statement); - return TRAINLOG_STATUS_DATABASE_ERROR; + load_mode == NULL + ) { + (void)sqlite3_finalize( + statement + ); + + return + TRAINLOG_STATUS_DATABASE_ERROR; } new_session = @@ -3455,7 +3496,8 @@ TrainlogStatus trainlog_database_list_exercise_performance( current = NULL; if (copied < capacity) { - current = &output[copied]; + current = + &output[copied]; (void)memset( current, @@ -3477,6 +3519,20 @@ TrainlogStatus trainlog_database_list_exercise_performance( (const char *)started_at ); + if ( + !session_type_from_sql( + (const char *)session_type, + ¤t->session_type + ) + ) { + (void)sqlite3_finalize( + statement + ); + + return + TRAINLOG_STATUS_DATABASE_ERROR; + } + current->tracking_mode = tracking_mode_from_sql( (const char *)tracking_mode @@ -3491,63 +3547,100 @@ TrainlogStatus trainlog_database_list_exercise_performance( } } - if (current != NULL && - sqlite3_column_type(statement, 4) != SQLITE_NULL) { + if ( + current != NULL && + sqlite3_column_type( + statement, + 5 + ) != SQLITE_NULL + ) { int metric_value; int has_weight; double weight_kg; ++current->actual_set_count; - if (current->tracking_mode == - TRAINLOG_TRACKING_REPS) { + if ( + current->tracking_mode == + TRAINLOG_TRACKING_REPS + ) { metric_value = - sqlite3_column_type(statement, 5) != - SQLITE_NULL - ? sqlite3_column_int(statement, 5) + sqlite3_column_type( + statement, + 6 + ) != SQLITE_NULL + ? sqlite3_column_int( + statement, + 6 + ) : 0; } else { metric_value = - sqlite3_column_type(statement, 6) != - SQLITE_NULL - ? sqlite3_column_int(statement, 6) + sqlite3_column_type( + statement, + 7 + ) != SQLITE_NULL + ? sqlite3_column_int( + statement, + 7 + ) : 0; } has_weight = - sqlite3_column_type(statement, 7) != - SQLITE_NULL; + sqlite3_column_type( + statement, + 8 + ) != SQLITE_NULL; weight_kg = has_weight != 0 - ? sqlite3_column_double(statement, 7) + ? sqlite3_column_double( + statement, + 8 + ) : 0.0; - if (performance_candidate_better( + if ( + performance_candidate_better( current, current->load_mode, metric_value, has_weight, weight_kg - )) { + ) + ) { current->has_performance = 1; - current->metric_value = metric_value; - current->has_weight = has_weight; - current->weight_kg = weight_kg; + current->metric_value = + metric_value; + current->has_weight = + has_weight; + current->weight_kg = + weight_kg; } } } if (rc != SQLITE_DONE) { - (void)sqlite3_finalize(statement); - return TRAINLOG_STATUS_DATABASE_ERROR; + (void)sqlite3_finalize( + statement + ); + + return + TRAINLOG_STATUS_DATABASE_ERROR; } - if (sqlite3_finalize(statement) != SQLITE_OK) { - return TRAINLOG_STATUS_DATABASE_ERROR; + if ( + sqlite3_finalize( + statement + ) != SQLITE_OK + ) { + return + TRAINLOG_STATUS_DATABASE_ERROR; } *output_count = copied; + return TRAINLOG_STATUS_OK; } diff --git a/tui/src/measured_max.c b/tui/src/measured_max.c new file mode 100644 index 0000000..c3e7427 --- /dev/null +++ b/tui/src/measured_max.c @@ -0,0 +1,205 @@ +/** + * @file measured_max.c + * @brief Explicit measured-max and working-load implementation. + */ + +#include "trainlog/measured_max.h" + +#include +#include + +static bool measured_point_better( + const TrainlogExercisePerformancePoint *candidate, + const TrainlogExercisePerformancePoint *current +) +{ + if ( + candidate == NULL || + candidate->has_performance == 0 + ) { + return false; + } + + if ( + current == NULL || + current->has_performance == 0 + ) { + return true; + } + + if ( + candidate->load_mode != + current->load_mode + ) { + return false; + } + + switch (candidate->load_mode) { + case TRAINLOG_LOAD_EXTERNAL: + if (candidate->has_weight == 0) { + return false; + } + + if ( + candidate->weight_kg > + current->weight_kg + ) { + return true; + } + + return + candidate->weight_kg == + current->weight_kg && + candidate->metric_value > + current->metric_value; + + case TRAINLOG_LOAD_ASSISTANCE: + if (candidate->has_weight == 0) { + return false; + } + + if ( + candidate->weight_kg < + current->weight_kg + ) { + return true; + } + + return + candidate->weight_kg == + current->weight_kg && + candidate->metric_value > + current->metric_value; + + case TRAINLOG_LOAD_NONE: + default: + return + candidate->metric_value > + current->metric_value; + } +} + +TrainlogStatus trainlog_measured_max_summarize( + const TrainlogExercisePerformancePoint *points, + size_t count, + TrainlogMeasuredMaxSummary *output +) +{ + size_t index; + + if ( + output == NULL || + ( + count > 0U && + points == NULL + ) + ) { + return + TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + (void)memset( + output, + 0, + sizeof(*output) + ); + + for ( + index = 0U; + index < count; + ++index + ) { + const TrainlogExercisePerformancePoint *point = + &points[index]; + + if ( + point->session_type != + TRAINLOG_SESSION_MAX_TEST + ) { + continue; + } + + ++output->test_count; + + if (point->has_performance == 0) { + continue; + } + + ++output->successful_test_count; + + if (!output->found) { + output->found = true; + output->current = *point; + output->has_record = true; + output->record = *point; + continue; + } + + if ( + point->load_mode == + output->current.load_mode && + measured_point_better( + point, + &output->record + ) + ) { + output->record = *point; + } + } + + return TRAINLOG_STATUS_OK; +} + +TrainlogStatus trainlog_measured_max_working_load( + const TrainlogExercisePerformancePoint *measured, + double percentage, + double increment_kg, + double *output_kg +) +{ + double raw; + double rounded; + + if ( + measured == NULL || + output_kg == NULL || + measured->session_type != + TRAINLOG_SESSION_MAX_TEST || + measured->has_performance == 0 || + measured->load_mode != + TRAINLOG_LOAD_EXTERNAL || + measured->has_weight == 0 || + !isfinite(measured->weight_kg) || + measured->weight_kg <= 0.0 || + !isfinite(percentage) || + percentage <= 0.0 || + percentage > 100.0 || + !isfinite(increment_kg) || + increment_kg <= 0.0 + ) { + return + TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + raw = + measured->weight_kg * + percentage / + 100.0; + + rounded = + floor( + raw / + increment_kg + + 0.5 + ) * + increment_kg; + + if (rounded <= 0.0) { + rounded = + increment_kg; + } + + *output_kg = rounded; + + return TRAINLOG_STATUS_OK; +} diff --git a/tui/src/tui.c b/tui/src/tui.c index a9c2f55..6c2bbe9 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -24,6 +24,7 @@ #include "trainlog/catalog.h" #include "trainlog/duration.h" #include "trainlog/id.h" +#include "trainlog/measured_max.h" #include "trainlog/mtp.h" #include "trainlog/sync.h" #include "trainlog/reps.h" @@ -642,7 +643,8 @@ static void draw_exercise_performance_graph( TrainlogLoadMode mode, TrainlogTrackingMode tracking_mode, int top, - int height + int height, + bool measured_max ) { size_t indices[EXERCISE_GRAPH_POINTS]; @@ -720,7 +722,9 @@ static void draw_exercise_performance_graph( mvprintw( top, left, - "Assistance (kg) — moins = mieux" + measured_max + ? "Assistance mesurée (kg) — moins = mieux" + : "Assistance (kg) — moins = mieux" ); attroff( @@ -733,7 +737,9 @@ static void draw_exercise_performance_graph( mvprintw( top, left, - "Charge du meilleur set (kg)" + measured_max + ? "Max mesuré (kg)" + : "Charge du meilleur set (kg)" ); } else if ( tracking_mode == @@ -742,13 +748,17 @@ static void draw_exercise_performance_graph( mvprintw( top, left, - "Meilleure durée" + measured_max + ? "Durée max mesurée" + : "Meilleure durée" ); } else { mvprintw( top, left, - "Meilleures répétitions" + measured_max + ? "Répétitions max mesurées" + : "Meilleures répétitions" ); } @@ -1234,7 +1244,8 @@ static void screen_exercise_performance( graph_mode, exercise->tracking_mode, graph_content_top, - graph_height + graph_height, + false ); } @@ -1300,6 +1311,497 @@ static void screen_exercise_performance( } } +/* TRAINLOG_MEASURED_MAX_TUI_V1 */ + +static void screen_exercise_measured_max( + TrainlogDatabase *database, + const TrainlogExercise *exercise +) +{ + static const double increments[] = { + 0.5, + 1.0, + 2.5, + 5.0 + }; + + static const double percentages[] = { + 60.0, + 70.0, + 80.0, + 90.0 + }; + + size_t increment_index = 2U; + + if ( + database == NULL || + exercise == NULL + ) { + return; + } + + for (;;) { + TrainlogExercisePerformancePoint + points[MAX_SESSIONS]; + + TrainlogExercisePerformancePoint + max_points[MAX_SESSIONS]; + + TrainlogMeasuredMaxSummary summary; + + size_t count = 0U; + size_t max_count = 0U; + size_t index; + size_t history_limit; + + bool decorated = + COLS >= 100 && + LINES >= 30; + + int summary_top = + decorated ? 8 : 3; + + int summary_bottom = + decorated ? 15 : 9; + + int graph_top = + decorated ? 16 : 10; + + int graph_bottom = + decorated ? 23 : 17; + + int history_top = + decorated ? 24 : 18; + + int history_bottom = + LINES - 4; + + int key; + + if ( + trainlog_database_list_exercise_performance( + database, + exercise->exercise_id, + points, + MAX_SESSIONS, + &count + ) != TRAINLOG_STATUS_OK || + trainlog_measured_max_summarize( + points, + count, + &summary + ) != TRAINLOG_STATUS_OK + ) { + draw_shell( + "TRAINLOG — Max mesuré", + "Une touche pour revenir" + ); + + status_line( + "Impossible de lire les tests de max.", + TRAINLOG_COLOR_ERROR + ); + + wait_key(); + return; + } + + for ( + index = 0U; + index < count && + max_count < MAX_SESSIONS; + ++index + ) { + if ( + points[index].session_type == + TRAINLOG_SESSION_MAX_TEST + ) { + max_points[max_count] = + points[index]; + + ++max_count; + } + } + + erase(); + box( + stdscr, + 0, + 0 + ); + + if (decorated) { + section_ascii_header( + ":: M A X M E S U R E ::" + ); + + dashboard_panel( + summary_top, + 2, + summary_bottom, + COLS - 3, + "MAX MESURE" + ); + + dashboard_panel( + graph_top, + 2, + graph_bottom, + COLS - 3, + "EVOLUTION DES TESTS MAX" + ); + + if ( + history_bottom > + history_top + 1 + ) { + exercise_panel( + history_top, + 2, + history_bottom, + COLS - 3, + "HISTORIQUE TESTS MAX" + ); + } + + attron( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + mvprintw( + LINES - 2, + 2, + "%.*s", + COLS - 4, + "r arrondi charge b/Échap retour" + ); + + attroff( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + } else { + draw_shell( + "TRAINLOG — Max mesuré", + "r arrondi charge b/Échap retour" + ); + + exercise_panel( + summary_top, + 2, + summary_bottom, + COLS - 3, + "MAX MESURE" + ); + + exercise_panel( + graph_top, + 2, + graph_bottom, + COLS - 3, + "EVOLUTION" + ); + + if ( + history_bottom > + history_top + 1 + ) { + exercise_panel( + history_top, + 2, + history_bottom, + COLS - 3, + "HISTORIQUE" + ); + } + } + + attron( + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + + mvprintw( + summary_top + 1, + 5, + "%s", + exercise->name + ); + + attroff( + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + + mvprintw( + summary_top + 2, + 5, + "Tests max : %zu · réussis : %zu", + summary.test_count, + summary.successful_test_count + ); + + if (!summary.found) { + mvprintw( + summary_top + 3, + 5, + "Aucun max mesuré réussi." + ); + + mvprintw( + summary_top + 4, + 5, + "Seules les séances explicitement « Test de max » comptent." + ); + } else { + char current_text[128]; + char record_text[128]; + char current_date[11]; + char record_date[11]; + + exercise_format_performance( + &summary.current, + current_text, + sizeof(current_text) + ); + + exercise_format_performance( + &summary.record, + record_text, + sizeof(record_text) + ); + + exercise_short_date( + summary.current.started_at, + current_date + ); + + exercise_short_date( + summary.record.started_at, + record_date + ); + + mvprintw( + summary_top + 3, + 5, + "Actuel : %s · %.*s", + current_date, + COLS - 32, + current_text + ); + + mvprintw( + summary_top + 4, + 5, + "Record même mode : %s · %.*s", + record_date, + COLS - 42, + record_text + ); + + if ( + summary.current.load_mode == + TRAINLOG_LOAD_EXTERNAL + ) { + double working[4]; + bool valid = true; + + for ( + index = 0U; + index < 4U; + ++index + ) { + if ( + trainlog_measured_max_working_load( + &summary.current, + percentages[index], + increments[increment_index], + &working[index] + ) != TRAINLOG_STATUS_OK + ) { + valid = false; + break; + } + } + + if (valid) { + mvprintw( + summary_top + 5, + 5, + "Travail : 60%% %.1f · 70%% %.1f · 80%% %.1f · 90%% %.1f kg", + working[0], + working[1], + working[2], + working[3] + ); + + mvprintw( + summary_top + 6, + 5, + "Arrondi : %.1f kg (r pour changer) · aucun 1RM estimé", + increments[increment_index] + ); + } + } else if ( + summary.current.load_mode == + TRAINLOG_LOAD_ASSISTANCE + ) { + attron( + trainlog_theme_attribute( + TRAINLOG_COLOR_WARNING + ) + ); + + mvprintw( + summary_top + 5, + 5, + "Assistance : moins de kg = mieux." + ); + + mvprintw( + summary_top + 6, + 5, + "Pourcentages de charge non applicables à l'assistance." + ); + + attroff( + trainlog_theme_attribute( + TRAINLOG_COLOR_WARNING + ) + ); + } else { + mvprintw( + summary_top + 5, + 5, + "Sans charge externe : pourcentages non applicables." + ); + + mvprintw( + summary_top + 6, + 5, + "Le max reste une valeur réellement réalisée, jamais estimée." + ); + } + } + + if ( + summary.found && + max_count > 0U + ) { + int graph_content_top = + graph_top + 1; + + int graph_height = + graph_bottom - + graph_top - + 2; + + draw_exercise_performance_graph( + max_points, + max_count, + summary.current.load_mode, + exercise->tracking_mode, + graph_content_top, + graph_height, + true + ); + } else { + mvprintw( + graph_top + 2, + 5, + "Aucun point de max mesuré à tracer." + ); + } + + if ( + history_bottom > + history_top + 1 + ) { + int first_row = + history_top + 1; + + history_limit = + history_bottom > + first_row + ? (size_t)( + history_bottom - + first_row + ) + : 0U; + + for ( + index = 0U; + index < max_count && + index < history_limit; + ++index + ) { + char date[11]; + char text[128]; + + exercise_short_date( + max_points[index].started_at, + date + ); + + exercise_format_performance( + &max_points[index], + text, + sizeof(text) + ); + + mvprintw( + first_row + + (int)index, + 5, + "%s %-13s %.*s", + date, + exercise_load_mode_label( + max_points[index] + .load_mode + ), + COLS - 40, + text + ); + } + } + + refresh(); + key = getch(); + + if ( + key == 'b' || + key == 'B' || + key == 27 || + key == '\n' || + key == KEY_ENTER + ) { + return; + } + + if ( + key == 'r' || + key == 'R' + ) { + increment_index = + ( + increment_index + 1U + ) % + ( + sizeof(increments) / + sizeof(increments[0]) + ); + } + } +} + /* TRAINLOG_SECTION_ASCII_HEADER */ static void section_ascii_header( @@ -1560,7 +2062,7 @@ static void screen_exercises( 2, "%.*s", COLS - 4, - "Tab zone ↑↓/PgUp/PgDn catalogue ←→ menu Entrée ouvrir a ajouter 0/Home accueil F1-F4 direct b/Échap retour" + "Tab zone ↑↓/PgUp/PgDn catalogue ←→ menu Entrée ouvrir m max mesuré a ajouter 0/Home accueil F1-F4 direct b/Échap retour" ); attroff( @@ -1571,7 +2073,7 @@ static void screen_exercises( } else { draw_shell( "TRAINLOG — Exercices", - "↑↓ naviguer Entrée performance a ajouter b/Échap retour" + "↑↓ naviguer Entrée performance m max mesuré a ajouter b/Échap retour" ); } @@ -1757,6 +2259,17 @@ if (primary_top_nav_activate( continue; } + if (count > 0U && + (key == 'm' || + key == 'M')) { + screen_exercise_measured_max( + database, + &exercises[selected] + ); + + continue; + } + if (count > 0U && (key == '\n' || key == KEY_ENTER)) { diff --git a/tui/tests/test_measured_max.c b/tui/tests/test_measured_max.c new file mode 100644 index 0000000..f697401 --- /dev/null +++ b/tui/tests/test_measured_max.c @@ -0,0 +1,514 @@ +/** + * @file test_measured_max.c + * @brief Explicit measured-max and working-load regression tests. + */ + +#include +#include +#include + +#include "trainlog/database.h" +#include "trainlog/measured_max.h" + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf( \ + stderr, \ + "CHECK failed at %s:%d: %s\n", \ + __FILE__, \ + __LINE__, \ + #condition \ + ); \ + return false; \ + } \ + } while (0) + +static bool add_exercise( + TrainlogDatabase *database, + const char *id, + const char *name +) +{ + return + trainlog_database_insert_exercise( + database, + id, + name, + name, + TRAINLOG_TRACKING_REPS + ) == TRAINLOG_STATUS_OK; +} + +static bool insert_reps_session( + TrainlogDatabase *database, + const char *session_id, + const char *started_at, + TrainlogSessionType session_type, + const char *exercise_id, + TrainlogLoadMode load_mode, + const int *reps, + const double *weights, + size_t set_count +) +{ + TrainlogSetInput sets[4]; + TrainlogSessionExerciseInput exercise; + TrainlogSessionInput session; + size_t index; + + if ( + database == NULL || + session_id == NULL || + started_at == NULL || + exercise_id == NULL || + reps == NULL || + set_count == 0U || + set_count > 4U + ) { + return false; + } + + (void)memset( + sets, + 0, + sizeof(sets) + ); + + for ( + index = 0U; + index < set_count; + ++index + ) { + sets[index].reps = + reps[index]; + + if ( + load_mode != + TRAINLOG_LOAD_NONE + ) { + if (weights == NULL) { + return false; + } + + sets[index].has_weight = true; + sets[index].weight_kg = + weights[index]; + } + } + + (void)memset( + &exercise, + 0, + sizeof(exercise) + ); + + (void)snprintf( + exercise.exercise_id, + sizeof(exercise.exercise_id), + "%s", + exercise_id + ); + + exercise.recording_mode = + TRAINLOG_RECORDING_SETS; + + exercise.load_mode = + load_mode; + + exercise.rest_seconds = 120; + exercise.target_sets = + (int)set_count; + exercise.target_reps = 1; + + if ( + load_mode != + TRAINLOG_LOAD_NONE + ) { + exercise.target_has_weight = true; + exercise.target_weight_kg = + weights[0]; + } + + exercise.sets = sets; + exercise.set_count = set_count; + + (void)memset( + &session, + 0, + sizeof(session) + ); + + (void)snprintf( + session.session_id, + sizeof(session.session_id), + "%s", + session_id + ); + + (void)snprintf( + session.started_at, + sizeof(session.started_at), + "%s", + started_at + ); + + (void)snprintf( + session.ended_at, + sizeof(session.ended_at), + "%s", + "2026-09-06T21:00:00+02:00" + ); + + session.session_type = + session_type; + + session.exercises = + &exercise; + + session.exercise_count = 1U; + + return + trainlog_database_insert_session( + database, + &session + ) == TRAINLOG_STATUS_OK; +} + +static bool test_measured_max_semantics(void) +{ + TrainlogDatabase *database = NULL; + + TrainlogExercisePerformancePoint + points[16]; + + TrainlogMeasuredMaxSummary summary; + + size_t count = 0U; + double working = 0.0; + + const int one_rep[] = {1}; + const int failed_then_one[] = {0, 1}; + const int reps_20[] = {20}; + const int reps_15[] = {15}; + + const double training_140[] = {140.0}; + const double external_110[] = {110.0}; + const double external_latest[] = { + 115.0, + 105.0, + }; + const double assistance_30[] = {30.0}; + const double assistance_35[] = {35.0}; + + CHECK( + trainlog_database_open( + ":memory:", + &database + ) == TRAINLOG_STATUS_OK + ); + + CHECK( + add_exercise( + database, + "ex_measured_external", + "measured external" + ) + ); + + CHECK( + add_exercise( + database, + "ex_measured_assistance", + "measured assistance" + ) + ); + + CHECK( + add_exercise( + database, + "ex_measured_none", + "measured none" + ) + ); + + /* + * A stronger ordinary training set must never become a measured maximum. + */ + CHECK( + insert_reps_session( + database, + "se_training_140", + "2026-09-01T18:00:00+02:00", + TRAINLOG_SESSION_TRAINING, + "ex_measured_external", + TRAINLOG_LOAD_EXTERNAL, + one_rep, + training_140, + 1U + ) + ); + + CHECK( + insert_reps_session( + database, + "se_max_external_old", + "2026-09-02T18:00:00+02:00", + TRAINLOG_SESSION_MAX_TEST, + "ex_measured_external", + TRAINLOG_LOAD_EXTERNAL, + one_rep, + external_110, + 1U + ) + ); + + /* + * 115 kg x 0 is a failed attempt. The newest successful measured result is + * 105 kg while the older historical measured record remains 110 kg. + */ + CHECK( + insert_reps_session( + database, + "se_max_external_new", + "2026-09-03T18:00:00+02:00", + TRAINLOG_SESSION_MAX_TEST, + "ex_measured_external", + TRAINLOG_LOAD_EXTERNAL, + failed_then_one, + external_latest, + 2U + ) + ); + + CHECK( + trainlog_database_list_exercise_performance( + database, + "ex_measured_external", + points, + 16U, + &count + ) == TRAINLOG_STATUS_OK + ); + + CHECK(count == 3U); + + CHECK( + points[0].session_type == + TRAINLOG_SESSION_MAX_TEST + ); + + CHECK( + points[2].session_type == + TRAINLOG_SESSION_TRAINING + ); + + CHECK( + trainlog_measured_max_summarize( + points, + count, + &summary + ) == TRAINLOG_STATUS_OK + ); + + CHECK(summary.test_count == 2U); + CHECK(summary.successful_test_count == 2U); + CHECK(summary.found); + CHECK(summary.has_record); + + CHECK( + strcmp( + summary.current.session_id, + "se_max_external_new" + ) == 0 + ); + + CHECK( + summary.current.weight_kg > + 104.99 + ); + + CHECK( + summary.current.weight_kg < + 105.01 + ); + + CHECK( + summary.current.metric_value == + 1 + ); + + CHECK( + summary.record.weight_kg > + 109.99 + ); + + CHECK( + summary.record.weight_kg < + 110.01 + ); + + CHECK( + trainlog_measured_max_working_load( + &summary.current, + 80.0, + 2.5, + &working + ) == TRAINLOG_STATUS_OK + ); + + CHECK( + working > 84.99 && + working < 85.01 + ); + + CHECK( + insert_reps_session( + database, + "se_max_assistance_old", + "2026-09-02T19:00:00+02:00", + TRAINLOG_SESSION_MAX_TEST, + "ex_measured_assistance", + TRAINLOG_LOAD_ASSISTANCE, + one_rep, + assistance_30, + 1U + ) + ); + + CHECK( + insert_reps_session( + database, + "se_max_assistance_new", + "2026-09-03T19:00:00+02:00", + TRAINLOG_SESSION_MAX_TEST, + "ex_measured_assistance", + TRAINLOG_LOAD_ASSISTANCE, + one_rep, + assistance_35, + 1U + ) + ); + + CHECK( + trainlog_database_list_exercise_performance( + database, + "ex_measured_assistance", + points, + 16U, + &count + ) == TRAINLOG_STATUS_OK + ); + + CHECK( + trainlog_measured_max_summarize( + points, + count, + &summary + ) == TRAINLOG_STATUS_OK + ); + + CHECK( + summary.current.weight_kg > + 34.99 + ); + + CHECK( + summary.current.weight_kg < + 35.01 + ); + + CHECK( + summary.record.weight_kg > + 29.99 + ); + + CHECK( + summary.record.weight_kg < + 30.01 + ); + + CHECK( + trainlog_measured_max_working_load( + &summary.current, + 80.0, + 2.5, + &working + ) == TRAINLOG_STATUS_INVALID_ARGUMENT + ); + + CHECK( + insert_reps_session( + database, + "se_max_none_old", + "2026-09-02T20:00:00+02:00", + TRAINLOG_SESSION_MAX_TEST, + "ex_measured_none", + TRAINLOG_LOAD_NONE, + reps_20, + NULL, + 1U + ) + ); + + CHECK( + insert_reps_session( + database, + "se_max_none_new", + "2026-09-03T20:00:00+02:00", + TRAINLOG_SESSION_MAX_TEST, + "ex_measured_none", + TRAINLOG_LOAD_NONE, + reps_15, + NULL, + 1U + ) + ); + + CHECK( + trainlog_database_list_exercise_performance( + database, + "ex_measured_none", + points, + 16U, + &count + ) == TRAINLOG_STATUS_OK + ); + + CHECK( + trainlog_measured_max_summarize( + points, + count, + &summary + ) == TRAINLOG_STATUS_OK + ); + + CHECK( + summary.current.metric_value == + 15 + ); + + CHECK( + summary.record.metric_value == + 20 + ); + + trainlog_database_close( + database + ); + + return true; +} + +int main(void) +{ + CHECK( + test_measured_max_semantics() + ); + + (void)printf( + "PASS measured_max\n" + ); + + return 0; +}