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
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
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
```

View file

@ -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

View file

@ -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<SessionExerciseDraft>,
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(

View file

@ -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 {

View file

@ -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)"
)

View file

@ -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 =

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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
```

View file

@ -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
```

View file

@ -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.

View file

@ -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;

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/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,
)

View file

@ -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,
&current->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;
}

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/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)) {

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;
}