Add measured max tracking and working loads

This commit is contained in:
fy59 2026-09-06 20:03:36 +02:00
parent e68b6cdc14
commit e2604131d4
21 changed files with 1859 additions and 55 deletions

View file

@ -84,3 +84,19 @@ ANDROID_SYNC_RECEIPT=PASS
TUI_SYNC_LOG_SHOW=PASS TUI_SYNC_LOG_SHOW=PASS
BIDIRECTIONAL_SYNC_V1=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.

View file

@ -29,7 +29,7 @@ ANDROID_TRIGGERED_SYNC=PASS
ANDROID_SYNC_RECEIPT=PASS ANDROID_SYNC_RECEIPT=PASS
BIDIRECTIONAL_SYNC_V1=PASS BIDIRECTIONAL_SYNC_V1=PASS
DESKTOP_TESTS=19/19 PASS DESKTOP_TESTS=20/20 PASS
ANDROID_BUILD=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; - no fake data representation to force incompatible models together;
- strict compiler warnings; - strict compiler warnings;
- documentation and tests are part of feature completion. - 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
```

View file

@ -16,6 +16,7 @@ import com.labfytools.trainlog.model.SessionSummary
import com.labfytools.trainlog.model.SessionDetail import com.labfytools.trainlog.model.SessionDetail
import com.labfytools.trainlog.model.SessionExerciseDetail import com.labfytools.trainlog.model.SessionExerciseDetail
import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.model.TrackingMode
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@ -296,7 +297,8 @@ class TrainlogRepository(
put( put(
"session_type", "session_type",
"training" draft.sessionType
.wireValue
) )
} }
@ -469,6 +471,7 @@ class TrainlogRepository(
SELECT SELECT
s.session_id, s.session_id,
s.started_at, s.started_at,
s.session_type,
COUNT(se.id) COUNT(se.id)
FROM sessions AS s FROM sessions AS s
LEFT JOIN session_exercises AS se LEFT JOIN session_exercises AS se
@ -488,7 +491,11 @@ class TrainlogRepository(
startedAt = startedAt =
cursor.getString(1), cursor.getString(1),
exerciseCount = exerciseCount =
cursor.getInt(2), cursor.getInt(3),
sessionType =
SessionType.fromWire(
cursor.getString(2)
),
) )
} }
} }
@ -1136,6 +1143,7 @@ class TrainlogRepository(
SELECT SELECT
s.session_id, s.session_id,
s.started_at, s.started_at,
s.session_type,
COUNT(se.id) COUNT(se.id)
FROM sessions AS s FROM sessions AS s
LEFT JOIN session_exercises AS se LEFT JOIN session_exercises AS se
@ -1154,7 +1162,11 @@ class TrainlogRepository(
startedAt = startedAt =
cursor.getString(1), cursor.getString(1),
exerciseCount = exerciseCount =
cursor.getInt(2), cursor.getInt(3),
sessionType =
SessionType.fromWire(
cursor.getString(2)
),
) )
} }
} ?: return null } ?: return null

View file

@ -1,5 +1,23 @@
package com.labfytools.trainlog.model 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( data class SessionSetDraft(
val reps: Int = 0, val reps: Int = 0,
val durationSeconds: Int = 0, val durationSeconds: Int = 0,
@ -15,12 +33,14 @@ data class SessionExerciseDraft(
data class SessionDraft( data class SessionDraft(
val exercises: List<SessionExerciseDraft>, val exercises: List<SessionExerciseDraft>,
val sessionType: SessionType = SessionType.TRAINING,
) )
data class SessionSummary( data class SessionSummary(
val sessionId: String, val sessionId: String,
val startedAt: String, val startedAt: String,
val exerciseCount: Int, val exerciseCount: Int,
val sessionType: SessionType = SessionType.TRAINING,
) )
data class SessionExerciseDetail( data class SessionExerciseDetail(

View file

@ -3,6 +3,7 @@ package com.labfytools.trainlog.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable @Composable
@ -49,12 +50,25 @@ fun HistoryScreen(
session.startedAt session.startedAt
), ),
description = description =
"${session.exerciseCount} exercice(s)", sessionTypeLabel(
session.sessionType
) +
" · " +
"${session.exerciseCount} exercice(s)",
onClick = { onClick = {
onOpenSession( onOpenSession(
session.sessionId 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( internal fun formatStartedAt(
value: String, value: String,
): String { ): String {

View file

@ -6,6 +6,7 @@ import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseDataFields
import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDetail import com.labfytools.trainlog.model.SessionExerciseDetail
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors 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( TrainlogInfo(
"${detail.summary.exerciseCount} exercice(s)" "${detail.summary.exerciseCount} exercice(s)"
) )

View file

@ -1,5 +1,7 @@
package com.labfytools.trainlog.ui package com.labfytools.trainlog.ui
/* TRAINLOG_ANDROID_MAX_TEST_SESSION_V1 */
/* TRAINLOG_ANDROID_SESSION_REMOVE */ /* TRAINLOG_ANDROID_SESSION_REMOVE */
/* TRAINLOG_VARIABLE_SET_REPS_V1 */ /* 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.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography import com.labfytools.trainlog.ui.theme.TrainlogTypography
@ -70,6 +73,13 @@ fun SessionScreen(
) )
} }
var sessionType by
remember {
mutableStateOf(
SessionType.TRAINING
)
}
var sessionRevision by var sessionRevision by
remember { remember {
mutableIntStateOf(0) mutableIntStateOf(0)
@ -93,9 +103,89 @@ fun SessionScreen(
accent = colors.muted, 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( TrainlogFrame(
title = "SEANCE EN COURS" 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 ( if (
draftExercises.isEmpty() draftExercises.isEmpty()
) { ) {
@ -254,7 +344,9 @@ fun SessionScreen(
.saveSession( .saveSession(
SessionDraft( SessionDraft(
exercises = exercises =
draftExercises draftExercises,
sessionType =
sessionType,
) )
) )
) { ) {
@ -265,6 +357,9 @@ fun SessionScreen(
selectedExercise = selectedExercise =
null null
sessionType =
SessionType.TRAINING
sessionRevision += 1 sessionRevision += 1
message = message =

View file

@ -242,3 +242,29 @@ Android is not intended to own:
- direct SQLite-file synchronization; - direct SQLite-file synchronization;
- exercise-name heuristics; - exercise-name heuristics;
- a mounted-filesystem dependency. - 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.

View file

@ -31,7 +31,7 @@ ANDROID_SYNC_RECEIPT=PASS
TUI_SYNC_LOG_SHOW=PASS TUI_SYNC_LOG_SHOW=PASS
BIDIRECTIONAL_SYNC_V1=PASS BIDIRECTIONAL_SYNC_V1=PASS
DESKTOP_TESTS=19/19 PASS DESKTOP_TESTS=20/20 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
HARDWARE_SYNC_VALIDATION=PASS HARDWARE_SYNC_VALIDATION=PASS
``` ```
@ -120,7 +120,7 @@ No mounted Android filesystem is required.
Desktop: Desktop:
```text ```text
19/19 Meson tests PASS 20/20 Meson tests PASS
frozen JSON validator PASS frozen JSON validator PASS
import-contract validator PASS import-contract validator PASS
git diff --check 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. No new feature is frozen by this documentation cleanup.
```text ```text
MEASURED_MAX_V1=PASS
WORKING_LOAD_PERCENTAGES=PASS
ANDROID_MAX_TEST_SESSION=PASS
NEXT_FEATURE=UNFROZEN NEXT_FEATURE=UNFROZEN
``` ```
Future work must start from this validated baseline rather than from obsolete Future work must start from this validated baseline rather than from obsolete
historical `NEXT` notes. 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.

View file

@ -276,3 +276,38 @@ schema_v5_migration
``` ```
The current normal suite contains 19 tests. 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.

View file

@ -199,3 +199,29 @@ Continuous activity must never be:
- hidden in notes; - hidden in notes;
- converted into a fake set; - converted into a fake set;
- silently discarded. - 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.

View file

@ -161,3 +161,23 @@ fake performed sets for continuous activity
fake uniform targets for heterogeneous actual sets fake uniform targets for heterogeneous actual sets
incompatible changes to Trainlog JSON v1 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
```

View file

@ -85,7 +85,7 @@ Current normal suite:
Validated checkpoint: Validated checkpoint:
```text ```text
19/19 PASS 20/20 PASS
``` ```
Notable regression coverage: 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 Documentation must describe the resulting state, not retain contradictory old
`NEXT` checkpoints. `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
```

View file

@ -264,3 +264,43 @@ Current normal suite:
```text ```text
19/19 PASS 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.

View file

@ -197,6 +197,7 @@ TrainlogStatus trainlog_database_latest_body_pair(
typedef struct TrainlogExercisePerformancePoint { typedef struct TrainlogExercisePerformancePoint {
char session_id[TRAINLOG_ID_MAX + 1U]; char session_id[TRAINLOG_ID_MAX + 1U];
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U]; char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
TrainlogSessionType session_type;
TrainlogTrackingMode tracking_mode; TrainlogTrackingMode tracking_mode;
TrainlogLoadMode load_mode; TrainlogLoadMode load_mode;
size_t actual_set_count; size_t actual_set_count;

View file

@ -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 <stdbool.h>
#include <stddef.h>
#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

View file

@ -36,6 +36,7 @@ trainlog_core_sources = files(
'src/usb.c', 'src/usb.c',
'src/mtp.c', 'src/mtp.c',
'src/reps.c', 'src/reps.c',
'src/measured_max.c',
'src/sync.c', 'src/sync.c',
) )
@ -341,3 +342,15 @@ trainlog_sync_once = executable(
dependencies: trainlog_core_dep, dependencies: trainlog_core_dep,
c_args: strict_c_args, 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,
)

View file

@ -3340,6 +3340,7 @@ static bool performance_candidate_better(
} }
} }
/* TRAINLOG_MEASURED_MAX_SESSION_TYPE_V1 */
TrainlogStatus trainlog_database_list_exercise_performance( TrainlogStatus trainlog_database_list_exercise_performance(
TrainlogDatabase *database, TrainlogDatabase *database,
const char *exercise_id, const char *exercise_id,
@ -3352,6 +3353,7 @@ TrainlogStatus trainlog_database_list_exercise_performance(
"SELECT " "SELECT "
"s.session_id, " "s.session_id, "
"s.started_at, " "s.started_at, "
"s.session_type, "
"e.tracking_mode, " "e.tracking_mode, "
"se.load_mode, " "se.load_mode, "
"ps.id, " "ps.id, "
@ -3377,13 +3379,19 @@ TrainlogStatus trainlog_database_list_exercise_performance(
size_t copied = 0U; size_t copied = 0U;
int rc; int rc;
if (database == NULL || if (
database == NULL ||
database->connection == NULL || database->connection == NULL ||
exercise_id == NULL || exercise_id == NULL ||
exercise_id[0] == '\0' || exercise_id[0] == '\0' ||
output_count == NULL || output_count == NULL ||
(capacity > 0U && output == NULL)) { (
return TRAINLOG_STATUS_INVALID_ARGUMENT; capacity > 0U &&
output == NULL
)
) {
return
TRAINLOG_STATUS_INVALID_ARGUMENT;
} }
*output_count = 0U; *output_count = 0U;
@ -3398,7 +3406,8 @@ TrainlogStatus trainlog_database_list_exercise_performance(
); );
if (rc != SQLITE_OK) { if (rc != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR; return
TRAINLOG_STATUS_DATABASE_ERROR;
} }
rc = sqlite3_bind_text( rc = sqlite3_bind_text(
@ -3410,31 +3419,63 @@ TrainlogStatus trainlog_database_list_exercise_performance(
); );
if (rc != SQLITE_OK) { if (rc != SQLITE_OK) {
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(
return TRAINLOG_STATUS_DATABASE_ERROR; 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 = const unsigned char *session_id =
sqlite3_column_text(statement, 0); sqlite3_column_text(
statement,
0
);
const unsigned char *started_at = 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 = const unsigned char *tracking_mode =
sqlite3_column_text(statement, 2); sqlite3_column_text(
statement,
3
);
const unsigned char *load_mode = const unsigned char *load_mode =
sqlite3_column_text(statement, 3); sqlite3_column_text(
statement,
4
);
bool new_session; bool new_session;
if (session_id == NULL || if (
session_id == NULL ||
started_at == NULL || started_at == NULL ||
session_type == NULL ||
tracking_mode == NULL || tracking_mode == NULL ||
load_mode == NULL) { load_mode == NULL
(void)sqlite3_finalize(statement); ) {
return TRAINLOG_STATUS_DATABASE_ERROR; (void)sqlite3_finalize(
statement
);
return
TRAINLOG_STATUS_DATABASE_ERROR;
} }
new_session = new_session =
@ -3455,7 +3496,8 @@ TrainlogStatus trainlog_database_list_exercise_performance(
current = NULL; current = NULL;
if (copied < capacity) { if (copied < capacity) {
current = &output[copied]; current =
&output[copied];
(void)memset( (void)memset(
current, current,
@ -3477,6 +3519,20 @@ TrainlogStatus trainlog_database_list_exercise_performance(
(const char *)started_at (const char *)started_at
); );
if (
!session_type_from_sql(
(const char *)session_type,
&current->session_type
)
) {
(void)sqlite3_finalize(
statement
);
return
TRAINLOG_STATUS_DATABASE_ERROR;
}
current->tracking_mode = current->tracking_mode =
tracking_mode_from_sql( tracking_mode_from_sql(
(const char *)tracking_mode (const char *)tracking_mode
@ -3491,63 +3547,100 @@ TrainlogStatus trainlog_database_list_exercise_performance(
} }
} }
if (current != NULL && if (
sqlite3_column_type(statement, 4) != SQLITE_NULL) { current != NULL &&
sqlite3_column_type(
statement,
5
) != SQLITE_NULL
) {
int metric_value; int metric_value;
int has_weight; int has_weight;
double weight_kg; double weight_kg;
++current->actual_set_count; ++current->actual_set_count;
if (current->tracking_mode == if (
TRAINLOG_TRACKING_REPS) { current->tracking_mode ==
TRAINLOG_TRACKING_REPS
) {
metric_value = metric_value =
sqlite3_column_type(statement, 5) != sqlite3_column_type(
SQLITE_NULL statement,
? sqlite3_column_int(statement, 5) 6
) != SQLITE_NULL
? sqlite3_column_int(
statement,
6
)
: 0; : 0;
} else { } else {
metric_value = metric_value =
sqlite3_column_type(statement, 6) != sqlite3_column_type(
SQLITE_NULL statement,
? sqlite3_column_int(statement, 6) 7
) != SQLITE_NULL
? sqlite3_column_int(
statement,
7
)
: 0; : 0;
} }
has_weight = has_weight =
sqlite3_column_type(statement, 7) != sqlite3_column_type(
SQLITE_NULL; statement,
8
) != SQLITE_NULL;
weight_kg = weight_kg =
has_weight != 0 has_weight != 0
? sqlite3_column_double(statement, 7) ? sqlite3_column_double(
statement,
8
)
: 0.0; : 0.0;
if (performance_candidate_better( if (
performance_candidate_better(
current, current,
current->load_mode, current->load_mode,
metric_value, metric_value,
has_weight, has_weight,
weight_kg weight_kg
)) { )
) {
current->has_performance = 1; current->has_performance = 1;
current->metric_value = metric_value; current->metric_value =
current->has_weight = has_weight; metric_value;
current->weight_kg = weight_kg; current->has_weight =
has_weight;
current->weight_kg =
weight_kg;
} }
} }
} }
if (rc != SQLITE_DONE) { if (rc != SQLITE_DONE) {
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(
return TRAINLOG_STATUS_DATABASE_ERROR; statement
);
return
TRAINLOG_STATUS_DATABASE_ERROR;
} }
if (sqlite3_finalize(statement) != SQLITE_OK) { if (
return TRAINLOG_STATUS_DATABASE_ERROR; sqlite3_finalize(
statement
) != SQLITE_OK
) {
return
TRAINLOG_STATUS_DATABASE_ERROR;
} }
*output_count = copied; *output_count = copied;
return TRAINLOG_STATUS_OK; return TRAINLOG_STATUS_OK;
} }

205
tui/src/measured_max.c Normal file
View file

@ -0,0 +1,205 @@
/**
* @file measured_max.c
* @brief Explicit measured-max and working-load implementation.
*/
#include "trainlog/measured_max.h"
#include <math.h>
#include <string.h>
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;
}

View file

@ -24,6 +24,7 @@
#include "trainlog/catalog.h" #include "trainlog/catalog.h"
#include "trainlog/duration.h" #include "trainlog/duration.h"
#include "trainlog/id.h" #include "trainlog/id.h"
#include "trainlog/measured_max.h"
#include "trainlog/mtp.h" #include "trainlog/mtp.h"
#include "trainlog/sync.h" #include "trainlog/sync.h"
#include "trainlog/reps.h" #include "trainlog/reps.h"
@ -642,7 +643,8 @@ static void draw_exercise_performance_graph(
TrainlogLoadMode mode, TrainlogLoadMode mode,
TrainlogTrackingMode tracking_mode, TrainlogTrackingMode tracking_mode,
int top, int top,
int height int height,
bool measured_max
) )
{ {
size_t indices[EXERCISE_GRAPH_POINTS]; size_t indices[EXERCISE_GRAPH_POINTS];
@ -720,7 +722,9 @@ static void draw_exercise_performance_graph(
mvprintw( mvprintw(
top, top,
left, left,
"Assistance (kg) — moins = mieux" measured_max
? "Assistance mesurée (kg) — moins = mieux"
: "Assistance (kg) — moins = mieux"
); );
attroff( attroff(
@ -733,7 +737,9 @@ static void draw_exercise_performance_graph(
mvprintw( mvprintw(
top, top,
left, left,
"Charge du meilleur set (kg)" measured_max
? "Max mesuré (kg)"
: "Charge du meilleur set (kg)"
); );
} else if ( } else if (
tracking_mode == tracking_mode ==
@ -742,13 +748,17 @@ static void draw_exercise_performance_graph(
mvprintw( mvprintw(
top, top,
left, left,
"Meilleure durée" measured_max
? "Durée max mesurée"
: "Meilleure durée"
); );
} else { } else {
mvprintw( mvprintw(
top, top,
left, 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, graph_mode,
exercise->tracking_mode, exercise->tracking_mode,
graph_content_top, 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 */ /* TRAINLOG_SECTION_ASCII_HEADER */
static void section_ascii_header( static void section_ascii_header(
@ -1560,7 +2062,7 @@ static void screen_exercises(
2, 2,
"%.*s", "%.*s",
COLS - 4, 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( attroff(
@ -1571,7 +2073,7 @@ static void screen_exercises(
} else { } else {
draw_shell( draw_shell(
"TRAINLOG — Exercices", "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; continue;
} }
if (count > 0U &&
(key == 'm' ||
key == 'M')) {
screen_exercise_measured_max(
database,
&exercises[selected]
);
continue;
}
if (count > 0U && if (count > 0U &&
(key == '\n' || (key == '\n' ||
key == KEY_ENTER)) { key == KEY_ENTER)) {

View file

@ -0,0 +1,514 @@
/**
* @file test_measured_max.c
* @brief Explicit measured-max and working-load regression tests.
*/
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#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;
}