Add variable sets and session exercise removal

This commit is contained in:
fy59 2026-09-06 16:53:25 +02:00
parent 28f6f8ea44
commit b3734b8674
20 changed files with 2659 additions and 302 deletions

View file

@ -199,3 +199,84 @@ Next:
- documented the next synchronization architecture: structured history, - documented the next synchronization architecture: structured history,
detailed sync inspection, common sync engine and PC-side `trainlog-syncd`. detailed sync inspection, common sync engine and PC-side `trainlog-syncd`.
<!-- TRAINLOG_SYNC_FOUNDATION_CHANGELOG _END --> <!-- TRAINLOG_SYNC_FOUNDATION_CHANGELOG _END -->
<!-- TRAINLOG_VARIABLE_SET_REPS_V1 -->
## Variable repetition sets
Trainlog preserves each performed set independently.
Accepted repetition input:
```text
5x10
4,5,6,7,8,9,10,9,8,7,6,5,4
4..10..4
```
`4..10..4` expands to:
```text
4,5,6,7,8,9,10,9,8,7,6,5,4
```
Desktop schema v5 permits targetless `SETS` rows for actual-only mobile
observations. Synchronization therefore does not invent a uniform target when
performed sets are heterogeneous.
`performed_sets` remains the source of truth for actual per-set values.
Existing planned desktop sessions may still carry explicit target sets/reps or
target durations.
`trainlog-mobile-export` v1 keeps ordered heterogeneous `sets[]`.
Frozen `TRAINLOG_FORMAT_V1` is unchanged.
<!-- TRAINLOG_VARIABLE_SET_REPS_V1 _END -->
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL -->
## Variable sets and session exercise removal checkpoint
Validated functionality in this checkpoint:
```text
VARIABLE_REPETITION_SETS=PASS
REPETITION_SHORTHAND_5x10=PASS
REPETITION_EXPLICIT_LIST=PASS
REPETITION_PYRAMID=PASS
DESKTOP_SCHEMA_V5=PASS
V4_TO_V5_MIGRATION_REGRESSION=PASS
MOBILE_HETEROGENEOUS_SET_IMPORT=PASS
MOBILE_IMPORT_IDEMPOTENCE=PASS
NO_FAKE_UNIFORM_TARGET=PASS
ANDROID_SESSION_DRAFT_EXERCISE_REMOVE=PASS
DESKTOP_SESSION_EXERCISE_REMOVE=PASS
```
Accepted repetition examples:
```text
5x10
4,5,6,7,8,9,10,9,8,7,6,5,4
4..10..4
```
A heterogeneous mobile session is persisted as ordered `performed_sets`.
The desktop does not invent `target_sets`, `target_reps` or
`target_duration_seconds` for actual-only mobile observations.
On Android, an exercise already added to the current session can be removed
before saving the session.
On the desktop TUI, session editing already supports:
```text
d supprimer
```
for removing the selected exercise from a current or persisted session draft.
The database replacement remains transactional.
`TRAINLOG_FORMAT_V1` remains frozen and unchanged.
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->

View file

@ -1,5 +1,9 @@
package com.labfytools.trainlog.ui package com.labfytools.trainlog.ui
/* TRAINLOG_ANDROID_SESSION_REMOVE */
/* TRAINLOG_VARIABLE_SET_REPS_V1 */
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -113,6 +117,30 @@ fun SessionScreen(
color = color =
colors.text, colors.text,
) )
TrainlogAction(
label =
"Retirer ${draft.exercise.name}",
description =
"Supprimer cet exercice de la séance en cours.",
accent =
colors.error,
onClick = {
draftExercises =
draftExercises
.filterIndexed {
itemIndex,
_ ->
itemIndex !=
index
}
sessionRevision += 1
message =
"Exercice retiré de la séance."
},
)
} }
} }
} }
@ -269,7 +297,9 @@ fun SessionScreen(
message == message ==
"Séance enregistrée." || "Séance enregistrée." ||
message == message ==
"Exercice ajouté à la séance." "Exercice ajouté à la séance." ||
message ==
"Exercice retiré de la séance."
) { ) {
colors.success colors.success
} else { } else {
@ -382,7 +412,7 @@ private fun SessionExerciseForm(
var repsText by var repsText by
remember(key) { remember(key) {
mutableStateOf("10") mutableStateOf("3x10")
} }
var durationText by var durationText by
@ -423,24 +453,13 @@ private fun SessionExerciseForm(
exercise.recordingMode == exercise.recordingMode ==
RecordingMode.SETS RecordingMode.SETS
) { ) {
SessionNumberField(
label =
"Nombre de séries",
value =
setCountText,
onValueChange = {
setCountText = it
error = null
},
)
if ( if (
exercise.trackingMode == exercise.trackingMode ==
TrackingMode.REPS TrackingMode.REPS
) { ) {
SessionNumberField( SessionNumberField(
label = label =
"Répétitions par série", "Séries / répétitions",
value = value =
repsText, repsText,
onValueChange = { onValueChange = {
@ -448,7 +467,25 @@ private fun SessionExerciseForm(
error = null error = null
}, },
) )
TrainlogInfo(
text =
"Formats : 5x10 · 4,5,6,7 · 4..10..4",
color =
colors.muted,
)
} else { } else {
SessionNumberField(
label =
"Nombre de séries",
value =
setCountText,
onValueChange = {
setCountText = it
error = null
},
)
SessionNumberField( SessionNumberField(
label = label =
"Durée par série (secondes)", "Durée par série (secondes)",
@ -624,6 +661,160 @@ private fun SessionNumberField(
} }
} }
private const val MAX_SESSION_SETS = 64
private const val MAX_REPS_PER_SET = 10000
private fun parseRepSequence(
text: String,
): List<Int>? {
val normalized =
text.trim()
.lowercase()
.replace(
'×',
'x'
)
if (normalized.isEmpty()) {
return null
}
val repeated =
Regex(
"""^(\d+)\s*x\s*(\d+)$"""
).matchEntire(
normalized
)
if (repeated != null) {
val count =
repeated.groupValues[1]
.toIntOrNull()
val reps =
repeated.groupValues[2]
.toIntOrNull()
if (
count == null ||
reps == null ||
count !in 1..MAX_SESSION_SETS ||
reps !in 0..MAX_REPS_PER_SET
) {
return null
}
return List(count) {
reps
}
}
val pyramid =
Regex(
"""^(\d+)\s*\.\.\s*(\d+)\s*\.\.\s*(\d+)$"""
).matchEntire(
normalized
)
if (pyramid != null) {
val start =
pyramid.groupValues[1]
.toIntOrNull()
val peak =
pyramid.groupValues[2]
.toIntOrNull()
val end =
pyramid.groupValues[3]
.toIntOrNull()
if (
start == null ||
peak == null ||
end == null ||
start !in 0..MAX_REPS_PER_SET ||
peak !in 0..MAX_REPS_PER_SET ||
end !in 0..MAX_REPS_PER_SET ||
start > peak ||
end > peak
) {
return null
}
val values =
mutableListOf<Int>()
for (value in start..peak) {
values += value
if (
values.size >
MAX_SESSION_SETS
) {
return null
}
}
if (peak > end) {
for (
value in
(peak - 1) downTo end
) {
values += value
if (
values.size >
MAX_SESSION_SETS
) {
return null
}
}
}
return values
}
val parts =
normalized
.split(
Regex(
"""[\s,;]+"""
)
)
.filter {
it.isNotEmpty()
}
if (
parts.isEmpty() ||
parts.size >
MAX_SESSION_SETS
) {
return null
}
val values =
mutableListOf<Int>()
for (part in parts) {
val reps =
part.toIntOrNull()
?: return null
if (
reps !in
0..MAX_REPS_PER_SET
) {
return null
}
values += reps
}
return values
}
private fun buildSessionExerciseDraft( private fun buildSessionExerciseDraft(
exercise: ExerciseProfile, exercise: ExerciseProfile,
setCountText: String, setCountText: String,
@ -637,8 +828,7 @@ private fun buildSessionExerciseDraft(
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
) { ) {
val minutes = val minutes =
durationText durationText.toIntOrNull()
.toIntOrNull()
if ( if (
minutes == null || minutes == null ||
@ -649,35 +839,25 @@ private fun buildSessionExerciseDraft(
} else { } else {
val wantsSpeed = val wantsSpeed =
exercise.dataFields and exercise.dataFields and
ExerciseDataFields ExerciseDataFields.SPEED_KMH != 0
.SPEED_KMH != 0
val wantsDistance = val wantsDistance =
exercise.dataFields and exercise.dataFields and
ExerciseDataFields ExerciseDataFields.DISTANCE_KM != 0
.DISTANCE_KM != 0
val speed = val speed =
if (wantsSpeed) { if (wantsSpeed) {
speedText speedText
.replace( .replace(',', '.')
',',
'.'
)
.toDoubleOrNull() .toDoubleOrNull()
} else { } else {
null null
} }
val distance = val distance =
if ( if (wantsDistance) {
wantsDistance
) {
distanceText distanceText
.replace( .replace(',', '.')
',',
'.'
)
.toDoubleOrNull() .toDoubleOrNull()
} else { } else {
null null
@ -686,152 +866,148 @@ private fun buildSessionExerciseDraft(
if ( if (
( (
wantsSpeed && wantsSpeed &&
( (
speed == null || speed == null ||
speed <= 0.0 speed <= 0.0
) )
) || ) ||
( (
wantsDistance && wantsDistance &&
( (
distance == null || distance == null ||
distance <= 0.0 distance <= 0.0
) )
) )
) { ) {
null null
} else { } else {
SessionExerciseDraft( SessionExerciseDraft(
exercise = exercise = exercise,
exercise,
continuousDurationSeconds = continuousDurationSeconds =
minutes * 60, minutes * 60,
speedKmh = speedKmh = speed,
speed, distanceKm = distance,
distanceKm =
distance,
) )
} }
} }
} else if (
exercise.trackingMode ==
TrackingMode.REPS
) {
val reps =
parseRepSequence(
repsText
) ?: return null
SessionExerciseDraft(
exercise = exercise,
sets =
reps.map {
SessionSetDraft(
reps = it
)
},
)
} else { } else {
val count = val count =
setCountText setCountText.toIntOrNull()
.toIntOrNull()
val seconds =
durationText.toIntOrNull()
if ( if (
count == null || count == null ||
count <= 0 || count <= 0 ||
count > 64 count > MAX_SESSION_SETS ||
seconds == null ||
seconds <= 0 ||
seconds > 86400
) { ) {
null null
} else if (
exercise.trackingMode ==
TrackingMode.REPS
) {
val reps =
repsText
.toIntOrNull()
if (
reps == null ||
reps < 0 ||
reps > 10000
) {
null
} else {
SessionExerciseDraft(
exercise =
exercise,
sets =
List(count) {
SessionSetDraft(
reps = reps
)
},
)
}
} else { } else {
val seconds = SessionExerciseDraft(
durationText exercise = exercise,
.toIntOrNull() sets =
List(count) {
if ( SessionSetDraft(
seconds == null || durationSeconds =
seconds <= 0 || seconds
seconds > 86400 )
) { },
null )
} else {
SessionExerciseDraft(
exercise =
exercise,
sets =
List(count) {
SessionSetDraft(
durationSeconds =
seconds
)
},
)
}
} }
} }
} }
private fun draftSummary( private fun draftSummary(
draft: draft: SessionExerciseDraft,
SessionExerciseDraft,
): String { ): String {
val exercise =
draft.exercise
return if ( return if (
exercise.recordingMode == draft.exercise.recordingMode ==
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
) { ) {
buildString { buildString {
append( append(
exercise.name draft.exercise.name
) )
append(" · ")
append( append(
draft " · ${draft.continuousDurationSeconds / 60} min"
.continuousDurationSeconds /
60
) )
append(" min") draft.speedKmh?.let {
append(
draft.speedKmh " · %.1f km/h"
?.let { .format(it)
append( )
" · %.1f km/h"
.format(it)
)
}
draft.distanceKm
?.let {
append(
" · %.2f km"
.format(it)
)
}
}
} else {
val metric =
if (
exercise.trackingMode ==
TrackingMode.REPS
) {
"${draft.sets.firstOrNull()?.reps ?: 0} reps"
} else {
"${draft.sets.firstOrNull()?.durationSeconds ?: 0} s"
} }
"${exercise.name} · ${draft.sets.size} × $metric" draft.distanceKm?.let {
append(
" · %.2f km"
.format(it)
)
}
}
} else if (
draft.exercise.trackingMode ==
TrackingMode.REPS
) {
val reps =
draft.sets.map {
it.reps
}
if (
reps.isNotEmpty() &&
reps.all {
it == reps.first()
}
) {
(
"${draft.exercise.name} · " +
"${reps.size} × " +
"${reps.first()} reps"
)
} else {
(
"${draft.exercise.name} · " +
"${reps.size} séries · " +
reps.joinToString(
separator = ","
) +
" reps"
)
}
} else {
val first =
draft.sets.firstOrNull()
(
"${draft.exercise.name} · " +
"${draft.sets.size} × " +
"${first?.durationSeconds ?: 0} s"
)
} }
} }

View file

@ -708,3 +708,51 @@ The final Android synchronization workflow must evolve toward a single
`Synchroniser maintenant` action backed by a PC-side synchronization agent, `Synchroniser maintenant` action backed by a PC-side synchronization agent,
rather than manual export/import steps. rather than manual export/import steps.
<!-- TRAINLOG_ANDROID_SYNC_FOLDER_CHECKPOINT _END --> <!-- TRAINLOG_ANDROID_SYNC_FOLDER_CHECKPOINT _END -->
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL -->
## Variable sets and session exercise removal checkpoint
Validated functionality in this checkpoint:
```text
VARIABLE_REPETITION_SETS=PASS
REPETITION_SHORTHAND_5x10=PASS
REPETITION_EXPLICIT_LIST=PASS
REPETITION_PYRAMID=PASS
DESKTOP_SCHEMA_V5=PASS
V4_TO_V5_MIGRATION_REGRESSION=PASS
MOBILE_HETEROGENEOUS_SET_IMPORT=PASS
MOBILE_IMPORT_IDEMPOTENCE=PASS
NO_FAKE_UNIFORM_TARGET=PASS
ANDROID_SESSION_DRAFT_EXERCISE_REMOVE=PASS
DESKTOP_SESSION_EXERCISE_REMOVE=PASS
```
Accepted repetition examples:
```text
5x10
4,5,6,7,8,9,10,9,8,7,6,5,4
4..10..4
```
A heterogeneous mobile session is persisted as ordered `performed_sets`.
The desktop does not invent `target_sets`, `target_reps` or
`target_duration_seconds` for actual-only mobile observations.
On Android, an exercise already added to the current session can be removed
before saving the session.
On the desktop TUI, session editing already supports:
```text
d supprimer
```
for removing the selected exercise from a current or persisted session draft.
The database replacement remains transactional.
`TRAINLOG_FORMAT_V1` remains frozen and unchanged.
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->

View file

@ -503,3 +503,84 @@ manual fake sets for continuous activities
overloading frozen Trainlog JSON v1 overloading frozen Trainlog JSON v1
``` ```
<!-- TRAINLOG_SYNC_VALIDATED_ROADMAP _END --> <!-- TRAINLOG_SYNC_VALIDATED_ROADMAP _END -->
<!-- TRAINLOG_VARIABLE_SET_REPS_V1 -->
## Variable repetition sets
Trainlog preserves each performed set independently.
Accepted repetition input:
```text
5x10
4,5,6,7,8,9,10,9,8,7,6,5,4
4..10..4
```
`4..10..4` expands to:
```text
4,5,6,7,8,9,10,9,8,7,6,5,4
```
Desktop schema v5 permits targetless `SETS` rows for actual-only mobile
observations. Synchronization therefore does not invent a uniform target when
performed sets are heterogeneous.
`performed_sets` remains the source of truth for actual per-set values.
Existing planned desktop sessions may still carry explicit target sets/reps or
target durations.
`trainlog-mobile-export` v1 keeps ordered heterogeneous `sets[]`.
Frozen `TRAINLOG_FORMAT_V1` is unchanged.
<!-- TRAINLOG_VARIABLE_SET_REPS_V1 _END -->
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL -->
## Variable sets and session exercise removal checkpoint
Validated functionality in this checkpoint:
```text
VARIABLE_REPETITION_SETS=PASS
REPETITION_SHORTHAND_5x10=PASS
REPETITION_EXPLICIT_LIST=PASS
REPETITION_PYRAMID=PASS
DESKTOP_SCHEMA_V5=PASS
V4_TO_V5_MIGRATION_REGRESSION=PASS
MOBILE_HETEROGENEOUS_SET_IMPORT=PASS
MOBILE_IMPORT_IDEMPOTENCE=PASS
NO_FAKE_UNIFORM_TARGET=PASS
ANDROID_SESSION_DRAFT_EXERCISE_REMOVE=PASS
DESKTOP_SESSION_EXERCISE_REMOVE=PASS
```
Accepted repetition examples:
```text
5x10
4,5,6,7,8,9,10,9,8,7,6,5,4
4..10..4
```
A heterogeneous mobile session is persisted as ordered `performed_sets`.
The desktop does not invent `target_sets`, `target_reps` or
`target_duration_seconds` for actual-only mobile observations.
On Android, an exercise already added to the current session can be removed
before saving the session.
On the desktop TUI, session editing already supports:
```text
d supprimer
```
for removing the selected exercise from a current or persisted session draft.
The database replacement remains transactional.
`TRAINLOG_FORMAT_V1` remains frozen and unchanged.
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->

View file

@ -282,3 +282,84 @@ Android-triggered request/receipt workflow
automatic mobile snapshot maintenance automatic mobile snapshot maintenance
``` ```
<!-- TRAINLOG_BIDIRECTIONAL_VALIDATED_CHECKPOINT _END --> <!-- TRAINLOG_BIDIRECTIONAL_VALIDATED_CHECKPOINT _END -->
<!-- TRAINLOG_VARIABLE_SET_REPS_V1 -->
## Variable repetition sets
Trainlog preserves each performed set independently.
Accepted repetition input:
```text
5x10
4,5,6,7,8,9,10,9,8,7,6,5,4
4..10..4
```
`4..10..4` expands to:
```text
4,5,6,7,8,9,10,9,8,7,6,5,4
```
Desktop schema v5 permits targetless `SETS` rows for actual-only mobile
observations. Synchronization therefore does not invent a uniform target when
performed sets are heterogeneous.
`performed_sets` remains the source of truth for actual per-set values.
Existing planned desktop sessions may still carry explicit target sets/reps or
target durations.
`trainlog-mobile-export` v1 keeps ordered heterogeneous `sets[]`.
Frozen `TRAINLOG_FORMAT_V1` is unchanged.
<!-- TRAINLOG_VARIABLE_SET_REPS_V1 _END -->
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL -->
## Variable sets and session exercise removal checkpoint
Validated functionality in this checkpoint:
```text
VARIABLE_REPETITION_SETS=PASS
REPETITION_SHORTHAND_5x10=PASS
REPETITION_EXPLICIT_LIST=PASS
REPETITION_PYRAMID=PASS
DESKTOP_SCHEMA_V5=PASS
V4_TO_V5_MIGRATION_REGRESSION=PASS
MOBILE_HETEROGENEOUS_SET_IMPORT=PASS
MOBILE_IMPORT_IDEMPOTENCE=PASS
NO_FAKE_UNIFORM_TARGET=PASS
ANDROID_SESSION_DRAFT_EXERCISE_REMOVE=PASS
DESKTOP_SESSION_EXERCISE_REMOVE=PASS
```
Accepted repetition examples:
```text
5x10
4,5,6,7,8,9,10,9,8,7,6,5,4
4..10..4
```
A heterogeneous mobile session is persisted as ordered `performed_sets`.
The desktop does not invent `target_sets`, `target_reps` or
`target_duration_seconds` for actual-only mobile observations.
On Android, an exercise already added to the current session can be removed
before saving the session.
On the desktop TUI, session editing already supports:
```text
d supprimer
```
for removing the selected exercise from a current or persisted session draft.
The database replacement remains transactional.
`TRAINLOG_FORMAT_V1` remains frozen and unchanged.
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->

View file

@ -836,3 +836,51 @@ DD/MM/YYYY HH:MM
while stored timestamps remain RFC3339. while stored timestamps remain RFC3339.
<!-- TRAINLOG_TUI_SYNC_FOUNDATION_CHECKPOINT _END --> <!-- TRAINLOG_TUI_SYNC_FOUNDATION_CHECKPOINT _END -->
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL -->
## Variable sets and session exercise removal checkpoint
Validated functionality in this checkpoint:
```text
VARIABLE_REPETITION_SETS=PASS
REPETITION_SHORTHAND_5x10=PASS
REPETITION_EXPLICIT_LIST=PASS
REPETITION_PYRAMID=PASS
DESKTOP_SCHEMA_V5=PASS
V4_TO_V5_MIGRATION_REGRESSION=PASS
MOBILE_HETEROGENEOUS_SET_IMPORT=PASS
MOBILE_IMPORT_IDEMPOTENCE=PASS
NO_FAKE_UNIFORM_TARGET=PASS
ANDROID_SESSION_DRAFT_EXERCISE_REMOVE=PASS
DESKTOP_SESSION_EXERCISE_REMOVE=PASS
```
Accepted repetition examples:
```text
5x10
4,5,6,7,8,9,10,9,8,7,6,5,4
4..10..4
```
A heterogeneous mobile session is persisted as ordered `performed_sets`.
The desktop does not invent `target_sets`, `target_reps` or
`target_duration_seconds` for actual-only mobile observations.
On Android, an exercise already added to the current session can be removed
before saving the session.
On the desktop TUI, session editing already supports:
```text
d supprimer
```
for removing the selected exercise from a current or persisted session draft.
The database replacement remains transactional.
`TRAINLOG_FORMAT_V1` remains frozen and unchanged.
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->

View file

@ -0,0 +1,331 @@
#!/usr/bin/env python3
"""Regression test for heterogeneous mobile set import."""
from __future__ import annotations
import json
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
IMPORTER = ROOT / "tools" / "import_mobile_export.py"
EXPECTED_REPS = [
4,
5,
6,
7,
8,
9,
10,
9,
8,
7,
6,
5,
4,
]
SCHEMA = """
PRAGMA foreign_keys=ON;
CREATE TABLE exercises (
id INTEGER PRIMARY KEY,
exercise_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
normalized_name TEXT NOT NULL UNIQUE,
tracking_mode TEXT NOT NULL,
recording_mode TEXT NOT NULL,
data_fields INTEGER NOT NULL
);
CREATE TABLE sessions (
id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL UNIQUE,
started_at TEXT NOT NULL,
ended_at TEXT,
session_type TEXT NOT NULL,
notes TEXT
);
CREATE TABLE session_exercises (
id INTEGER PRIMARY KEY,
session_row_id INTEGER NOT NULL
REFERENCES sessions(id) ON DELETE CASCADE,
exercise_row_id INTEGER NOT NULL
REFERENCES exercises(id),
recording_mode TEXT NOT NULL,
data_fields INTEGER NOT NULL,
position INTEGER NOT NULL,
load_mode TEXT NOT NULL,
rest_seconds INTEGER NOT NULL,
target_sets INTEGER,
target_reps INTEGER,
target_duration_seconds INTEGER,
target_weight_kg REAL,
notes TEXT
);
CREATE TABLE performed_sets (
id INTEGER PRIMARY KEY,
session_exercise_row_id INTEGER NOT NULL
REFERENCES session_exercises(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
reps INTEGER,
duration_seconds INTEGER,
weight_kg REAL
);
CREATE TABLE continuous_activity (
id INTEGER PRIMARY KEY,
session_exercise_row_id INTEGER NOT NULL UNIQUE
REFERENCES session_exercises(id) ON DELETE CASCADE,
duration_seconds INTEGER NOT NULL,
speed_kmh REAL,
distance_km REAL
);
CREATE TABLE body_observations (
id INTEGER PRIMARY KEY,
observation_id TEXT NOT NULL UNIQUE,
observed_at TEXT NOT NULL,
session_row_id INTEGER,
body_weight_kg REAL,
neck_cm REAL,
shoulders_cm REAL,
chest_cm REAL,
waist_cm REAL,
hips_cm REAL,
left_arm_cm REAL,
right_arm_cm REAL,
left_forearm_cm REAL,
right_forearm_cm REAL,
left_thigh_cm REAL,
right_thigh_cm REAL,
left_calf_cm REAL,
right_calf_cm REAL,
notes TEXT
);
PRAGMA user_version=5;
"""
def payload() -> dict:
return {
"format":
"trainlog-mobile-export",
"version":
1,
"generated_at":
"2026-09-06T16:00:00+02:00",
"exercises": [
{
"exercise_id":
"ex_mobile_pyramid",
"name":
"Pompes pyramide",
"recording_mode":
"sets",
"tracking_mode":
"reps",
"data_fields":
0,
}
],
"sessions": [
{
"session_id":
"se_mobile_pyramid",
"started_at":
"2026-09-06T16:01:00+02:00",
"session_type":
"training",
"exercises": [
{
"exercise_id":
"ex_mobile_pyramid",
"name":
"Pompes pyramide",
"recording_mode":
"sets",
"tracking_mode":
"reps",
"data_fields":
0,
"load_mode":
"none",
"rest_seconds":
0,
"sets": [
{
"reps": reps
}
for reps
in EXPECTED_REPS
],
}
],
}
],
"body_observations": [],
}
def run_import(
json_path: Path,
database_path: Path,
) -> str:
result = subprocess.run(
[
sys.executable,
str(IMPORTER),
str(json_path),
"--database",
str(database_path),
],
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise AssertionError(
"import failed:\n"
+ result.stdout
+ result.stderr
)
return result.stdout
def main() -> int:
with tempfile.TemporaryDirectory(
prefix="trainlog-mobile-variable-"
) as directory:
base = Path(directory)
database_path = base / "trainlog.db"
json_path = base / "mobile.json"
connection = sqlite3.connect(
database_path
)
try:
connection.executescript(
SCHEMA
)
connection.commit()
finally:
connection.close()
json_path.write_text(
json.dumps(
payload(),
ensure_ascii=False,
),
encoding="utf-8",
)
first = run_import(
json_path,
database_path,
)
if (
"MOBILE_IMPORT=PASS"
not in first
or "sessions_imported=1"
not in first
):
raise AssertionError(
"first import report invalid:\n"
+ first
)
second = run_import(
json_path,
database_path,
)
if (
"MOBILE_IMPORT=PASS"
not in second
or "sessions_skipped=1"
not in second
):
raise AssertionError(
"second import is not idempotent:\n"
+ second
)
connection = sqlite3.connect(
database_path
)
try:
rows = connection.execute(
"""
SELECT ps.reps
FROM performed_sets ps
JOIN session_exercises se
ON se.id =
ps.session_exercise_row_id
JOIN sessions s
ON s.id = se.session_row_id
WHERE s.session_id =
'se_mobile_pyramid'
ORDER BY ps.position;
"""
).fetchall()
reps = [
row[0]
for row in rows
]
if reps != EXPECTED_REPS:
raise AssertionError(
f"reps mismatch: {reps!r}"
)
target = connection.execute(
"""
SELECT
target_sets,
target_reps,
target_duration_seconds
FROM session_exercises se
JOIN sessions s
ON s.id = se.session_row_id
WHERE s.session_id =
'se_mobile_pyramid';
"""
).fetchone()
if target != (
None,
None,
None,
):
raise AssertionError(
f"fake target persisted: {target!r}"
)
finally:
connection.close()
print(
"PASS mobile_import_variable_sets"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -61,7 +61,7 @@ def main():
"PRAGMA user_version;" "PRAGMA user_version;"
).fetchone()[0] ).fetchone()[0]
if version != 4: if version != 5:
raise SystemExit( raise SystemExit(
"PC_CATALOG_EXPORT=FAIL " "PC_CATALOG_EXPORT=FAIL "
f"schema={version}" f"schema={version}"

View file

@ -491,30 +491,13 @@ def validate_session_exercise(
f"{label}.sets: tableau non vide attendu" f"{label}.sets: tableau non vide attendu"
) )
metric_values = []
for set_index, set_item in enumerate(sets): for set_index, set_item in enumerate(sets):
_, metric = validate_set_item( validate_set_item(
set_item, set_item,
tracking_mode, tracking_mode,
f"{label}.sets[{set_index}]", f"{label}.sets[{set_index}]",
) )
metric_values.append(metric)
if len(set(metric_values)) != 1:
raise ImportFailure(
f"{label}: mobile export v1 exige des séries uniformes"
)
if (
tracking_mode == "reps"
and metric_values[0] < 1
):
raise ImportFailure(
f"{label}: mobile export v1 ne peut pas dériver une cible depuis 0 reps"
)
def validate_sessions( def validate_sessions(
payload, payload,
@ -659,14 +642,14 @@ def validate_payload(payload):
validate_body(payload) validate_body(payload)
def require_schema_v4(connection): def require_schema_v5(connection):
version = connection.execute( version = connection.execute(
"PRAGMA user_version;" "PRAGMA user_version;"
).fetchone()[0] ).fetchone()[0]
if version != 4: if version != 5:
raise ImportFailure( raise ImportFailure(
f"base desktop schema v4 attendue, version trouvée: {version}" f"base desktop schema v5 attendue, version trouvée: {version}"
) )
@ -856,26 +839,6 @@ def import_set_session_exercise(
): ):
sets = item["sets"] sets = item["sets"]
tracking = item["tracking_mode"] tracking = item["tracking_mode"]
metric_values = []
for set_item in sets:
if tracking == "reps":
metric_values.append(
set_item["reps"]
)
else:
metric_values.append(
set_item["duration_seconds"]
)
target_metric = metric_values[0]
if tracking == "reps":
target_reps = target_metric
target_duration = None
else:
target_reps = None
target_duration = target_metric
cursor = connection.execute( cursor = connection.execute(
""" """
@ -894,7 +857,7 @@ def import_set_session_exercise(
notes notes
) VALUES( ) VALUES(
?, ?, 'sets', ?, ?, 'none', 0, ?, ?, 'sets', ?, ?, 'none', 0,
?, ?, ?, NULL, NULL NULL, NULL, NULL, NULL, NULL
); );
""", """,
( (
@ -902,9 +865,6 @@ def import_set_session_exercise(
exercise_row, exercise_row,
item["data_fields"], item["data_fields"],
position, position,
len(sets),
target_reps,
target_duration,
), ),
) )
@ -940,7 +900,6 @@ def import_set_session_exercise(
), ),
) )
def import_continuous_session_exercise( def import_continuous_session_exercise(
connection, connection,
session_row_id, session_row_id,
@ -1205,7 +1164,7 @@ def run_import(
"PRAGMA foreign_keys = ON;" "PRAGMA foreign_keys = ON;"
) )
require_schema_v4( require_schema_v5(
connection connection
) )

View file

@ -11,7 +11,7 @@
#include "trainlog/model.h" #include "trainlog/model.h"
#include "trainlog/status.h" #include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 4 #define TRAINLOG_DATABASE_SCHEMA_VERSION 5
typedef struct TrainlogDatabase TrainlogDatabase; typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -0,0 +1,26 @@
#ifndef TRAINLOG_REPS_H
#define TRAINLOG_REPS_H
#include <stddef.h>
#include "trainlog/status.h"
/**
* @brief Parse one bounded repetition sequence.
*
* Accepted forms:
*
* - 5x10 -> 10,10,10,10,10
* - 4,5,6,7 -> explicit ordered sets
* - 4..10..4 -> ascending then descending pyramid
*
* Repetitions are bounded to 0..10000 and output is never truncated.
*/
TrainlogStatus trainlog_reps_parse_sequence(
const char *text,
int *output,
size_t capacity,
size_t *output_count
);
#endif

View file

@ -35,6 +35,7 @@ trainlog_core_sources = files(
'src/timeutil.c', 'src/timeutil.c',
'src/usb.c', 'src/usb.c',
'src/mtp.c', 'src/mtp.c',
'src/reps.c',
) )
trainlog_core = static_library( trainlog_core = static_library(
@ -287,3 +288,48 @@ trainlog_mtp_mobile_export_probe = executable(
c_args: strict_c_args, c_args: strict_c_args,
) )
test_reps = executable(
'test_reps',
'tests/test_reps.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test(
'reps',
test_reps,
)
test_variable_sets = executable(
'test_variable_sets',
'tests/test_variable_sets.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test(
'variable_sets',
test_variable_sets,
)
test_schema_v5_migration = executable(
'test_schema_v5_migration',
'tests/test_schema_v5_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test(
'schema_v5_migration',
test_schema_v5_migration,
)
python3_trainlog_tests = find_program('python3')
test(
'mobile_import_variable_sets',
python3_trainlog_tests,
args: [
meson.project_source_root() / 'tests/test_mobile_import_variable_sets.py',
],
)

View file

@ -17,7 +17,7 @@ struct TrainlogDatabase {
sqlite3 *connection; sqlite3 *connection;
}; };
static const char *const SCHEMA_V4_SQL_A = static const char *const SCHEMA_V5_SQL_A =
"BEGIN IMMEDIATE;" "BEGIN IMMEDIATE;"
"CREATE TABLE IF NOT EXISTS exercises (" "CREATE TABLE IF NOT EXISTS exercises ("
@ -69,11 +69,16 @@ static const char *const SCHEMA_V4_SQL_A =
" UNIQUE (session_row_id, exercise_row_id)," " UNIQUE (session_row_id, exercise_row_id),"
" CHECK (" " CHECK ("
" (recording_mode = 'sets' AND" " (recording_mode = 'sets' AND"
" target_sets IS NOT NULL AND" " ("
" ((target_reps IS NOT NULL AND" " (target_sets IS NULL AND"
" target_duration_seconds IS NULL) OR" " target_reps IS NULL AND"
" (target_reps IS NULL AND" " target_duration_seconds IS NULL) OR"
" target_duration_seconds IS NOT NULL))) OR" " (target_sets IS NOT NULL AND"
" ((target_reps IS NOT NULL AND"
" target_duration_seconds IS NULL) OR"
" (target_reps IS NULL AND"
" target_duration_seconds IS NOT NULL)))"
" )) OR"
" (recording_mode = 'continuous' AND" " (recording_mode = 'continuous' AND"
" target_sets IS NULL AND" " target_sets IS NULL AND"
" target_reps IS NULL AND" " target_reps IS NULL AND"
@ -104,7 +109,7 @@ static const char *const SCHEMA_V4_SQL_A =
" )" " )"
");"; ");";
static const char *const SCHEMA_V4_SQL_B = static const char *const SCHEMA_V5_SQL_B =
"CREATE TABLE IF NOT EXISTS continuous_activity (" "CREATE TABLE IF NOT EXISTS continuous_activity ("
" id INTEGER PRIMARY KEY," " id INTEGER PRIMARY KEY,"
" session_exercise_row_id INTEGER NOT NULL UNIQUE" " session_exercise_row_id INTEGER NOT NULL UNIQUE"
@ -147,7 +152,7 @@ static const char *const SCHEMA_V4_SQL_B =
" )" " )"
");" ");"
"PRAGMA user_version = 4;" "PRAGMA user_version = 5;"
"COMMIT;"; "COMMIT;";
static const char *const MIGRATE_V1_TO_V3_SQL = static const char *const MIGRATE_V1_TO_V3_SQL =
@ -315,6 +320,123 @@ static const char *const MIGRATE_V3_TO_V4_SQL_B =
"PRAGMA user_version = 4;" "PRAGMA user_version = 4;"
"COMMIT;"; "COMMIT;";
static const char *const MIGRATE_V4_TO_V5_SQL_A =
"BEGIN IMMEDIATE;"
"CREATE TABLE session_exercises_v5 ("
" id INTEGER PRIMARY KEY,"
" session_row_id INTEGER NOT NULL"
" REFERENCES sessions(id) ON DELETE CASCADE,"
" exercise_row_id INTEGER NOT NULL"
" REFERENCES exercises(id) ON DELETE RESTRICT,"
" recording_mode TEXT NOT NULL DEFAULT 'sets'"
" CHECK (recording_mode IN ('sets', 'continuous')),"
" data_fields INTEGER NOT NULL DEFAULT 0"
" CHECK (data_fields >= 0 AND (data_fields & ~3) = 0),"
" position INTEGER NOT NULL CHECK (position >= 0),"
" load_mode TEXT NOT NULL"
" CHECK (load_mode IN ('none', 'external', 'assistance')),"
" rest_seconds INTEGER NOT NULL CHECK (rest_seconds >= 0),"
" target_sets INTEGER CHECK (target_sets > 0),"
" target_reps INTEGER CHECK (target_reps >= 1),"
" target_duration_seconds INTEGER"
" CHECK (target_duration_seconds > 0),"
" target_weight_kg REAL CHECK (target_weight_kg > 0.0),"
" notes TEXT,"
" UNIQUE (session_row_id, position),"
" UNIQUE (session_row_id, exercise_row_id),"
" CHECK ("
" (recording_mode = 'sets' AND"
" ("
" (target_sets IS NULL AND"
" target_reps IS NULL AND"
" target_duration_seconds IS NULL) OR"
" (target_sets IS NOT NULL AND"
" ((target_reps IS NOT NULL AND"
" target_duration_seconds IS NULL) OR"
" (target_reps IS NULL AND"
" target_duration_seconds IS NOT NULL)))"
" )) OR"
" (recording_mode = 'continuous' AND"
" target_sets IS NULL AND"
" target_reps IS NULL AND"
" target_duration_seconds IS NULL AND"
" load_mode = 'none' AND"
" rest_seconds = 0 AND"
" target_weight_kg IS NULL)"
" ),"
" CHECK ("
" (load_mode = 'none' AND target_weight_kg IS NULL) OR"
" (load_mode IN ('external', 'assistance') AND"
" target_weight_kg IS NOT NULL)"
" )"
");"
"INSERT INTO session_exercises_v5("
"id, session_row_id, exercise_row_id, recording_mode, data_fields,"
"position, load_mode, rest_seconds, target_sets, target_reps,"
"target_duration_seconds, target_weight_kg, notes"
") SELECT "
"id, session_row_id, exercise_row_id, recording_mode, data_fields,"
"position, load_mode, rest_seconds, target_sets, target_reps,"
"target_duration_seconds, target_weight_kg, notes "
"FROM session_exercises;"
"CREATE TABLE performed_sets_v5 ("
" id INTEGER PRIMARY KEY,"
" session_exercise_row_id INTEGER NOT NULL"
" REFERENCES session_exercises_v5(id) ON DELETE CASCADE,"
" position INTEGER NOT NULL CHECK (position >= 0),"
" reps INTEGER CHECK (reps >= 0),"
" duration_seconds INTEGER CHECK (duration_seconds > 0),"
" weight_kg REAL CHECK (weight_kg > 0.0),"
" UNIQUE (session_exercise_row_id, position),"
" CHECK ("
" (reps IS NOT NULL AND duration_seconds IS NULL) OR"
" (reps IS NULL AND duration_seconds IS NOT NULL)"
" )"
");"
"INSERT INTO performed_sets_v5("
"id, session_exercise_row_id, position, reps,"
"duration_seconds, weight_kg"
") SELECT "
"id, session_exercise_row_id, position, reps,"
"duration_seconds, weight_kg "
"FROM performed_sets;";
static const char *const MIGRATE_V4_TO_V5_SQL_B =
"CREATE TABLE continuous_activity_v5 ("
" id INTEGER PRIMARY KEY,"
" session_exercise_row_id INTEGER NOT NULL UNIQUE"
" REFERENCES session_exercises_v5(id) ON DELETE CASCADE,"
" duration_seconds INTEGER NOT NULL CHECK (duration_seconds > 0),"
" speed_kmh REAL CHECK (speed_kmh > 0.0),"
" distance_km REAL CHECK (distance_km > 0.0)"
");"
"INSERT INTO continuous_activity_v5("
"id, session_exercise_row_id, duration_seconds,"
"speed_kmh, distance_km"
") SELECT "
"id, session_exercise_row_id, duration_seconds,"
"speed_kmh, distance_km "
"FROM continuous_activity;"
"DROP TABLE continuous_activity;"
"DROP TABLE performed_sets;"
"DROP TABLE session_exercises;"
"ALTER TABLE session_exercises_v5"
" RENAME TO session_exercises;"
"ALTER TABLE performed_sets_v5"
" RENAME TO performed_sets;"
"ALTER TABLE continuous_activity_v5"
" RENAME TO continuous_activity;"
"PRAGMA user_version = 5;"
"COMMIT;";
static TrainlogStatus execute_sql( static TrainlogStatus execute_sql(
TrainlogDatabase *database, TrainlogDatabase *database,
const char *sql const char *sql
@ -373,90 +495,160 @@ static TrainlogStatus initialize_or_validate_schema(
int version = 0; int version = 0;
TrainlogStatus status; TrainlogStatus status;
status = trainlog_database_schema_version( status =
database, trainlog_database_schema_version(
&version database,
); &version
);
if (status != TRAINLOG_STATUS_OK) { if (
status !=
TRAINLOG_STATUS_OK
) {
return status; return status;
} }
if (version > TRAINLOG_DATABASE_SCHEMA_VERSION) { if (
return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED; version >
TRAINLOG_DATABASE_SCHEMA_VERSION
) {
return
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
} }
if (version == TRAINLOG_DATABASE_SCHEMA_VERSION) { if (
version ==
TRAINLOG_DATABASE_SCHEMA_VERSION
) {
return TRAINLOG_STATUS_OK; return TRAINLOG_STATUS_OK;
} }
if (version == 0) { if (version == 0) {
status = execute_sql( status =
database, execute_sql(
SCHEMA_V4_SQL_A
);
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(
database, database,
SCHEMA_V4_SQL_B SCHEMA_V5_SQL_A
); );
}
} else if (version == 1) {
status = execute_sql(
database,
MIGRATE_V1_TO_V3_SQL
);
if (status == TRAINLOG_STATUS_OK) { if (
status = execute_sql( status ==
database, TRAINLOG_STATUS_OK
MIGRATE_V3_TO_V4_SQL_A ) {
); status =
} execute_sql(
database,
if (status == TRAINLOG_STATUS_OK) { SCHEMA_V5_SQL_B
status = execute_sql( );
database,
MIGRATE_V3_TO_V4_SQL_B
);
}
} else if (version == 2) {
status = execute_sql(
database,
MIGRATE_V2_TO_V3_SQL
);
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(
database,
MIGRATE_V3_TO_V4_SQL_A
);
}
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(
database,
MIGRATE_V3_TO_V4_SQL_B
);
}
} else if (version == 3) {
status = execute_sql(
database,
MIGRATE_V3_TO_V4_SQL_A
);
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(
database,
MIGRATE_V3_TO_V4_SQL_B
);
} }
} else { } else {
return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED; if (version == 1) {
status =
execute_sql(
database,
MIGRATE_V1_TO_V3_SQL
);
if (
status ==
TRAINLOG_STATUS_OK
) {
status =
execute_sql(
database,
MIGRATE_V3_TO_V4_SQL_A
);
}
if (
status ==
TRAINLOG_STATUS_OK
) {
status =
execute_sql(
database,
MIGRATE_V3_TO_V4_SQL_B
);
}
} else if (version == 2) {
status =
execute_sql(
database,
MIGRATE_V2_TO_V3_SQL
);
if (
status ==
TRAINLOG_STATUS_OK
) {
status =
execute_sql(
database,
MIGRATE_V3_TO_V4_SQL_A
);
}
if (
status ==
TRAINLOG_STATUS_OK
) {
status =
execute_sql(
database,
MIGRATE_V3_TO_V4_SQL_B
);
}
} else if (version == 3) {
status =
execute_sql(
database,
MIGRATE_V3_TO_V4_SQL_A
);
if (
status ==
TRAINLOG_STATUS_OK
) {
status =
execute_sql(
database,
MIGRATE_V3_TO_V4_SQL_B
);
}
} else if (version == 4) {
status =
TRAINLOG_STATUS_OK;
} else {
return
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
}
if (
status ==
TRAINLOG_STATUS_OK
) {
status =
execute_sql(
database,
MIGRATE_V4_TO_V5_SQL_A
);
}
if (
status ==
TRAINLOG_STATUS_OK
) {
status =
execute_sql(
database,
MIGRATE_V4_TO_V5_SQL_B
);
}
} }
if (status != TRAINLOG_STATUS_OK) { if (
status !=
TRAINLOG_STATUS_OK
) {
(void)sqlite3_exec( (void)sqlite3_exec(
database->connection, database->connection,
"ROLLBACK;", "ROLLBACK;",
@ -1278,10 +1470,39 @@ static TrainlogStatus insert_session_exercise(
input->recording_mode == input->recording_mode ==
TRAINLOG_RECORDING_SETS TRAINLOG_RECORDING_SETS
) { ) {
if (input->target_sets <= 0 || bool has_target_sets =
input->target_sets > 0;
bool has_target_reps =
input->target_reps > 0;
bool has_target_duration =
input->target_duration_seconds > 0;
bool any_target =
has_target_sets ||
has_target_reps ||
has_target_duration;
bool target_valid =
!any_target ||
(
has_target_sets &&
(
has_target_reps !=
has_target_duration
)
);
if (
input->target_sets < 0 ||
input->target_reps < 0 ||
input->target_duration_seconds < 0 ||
!target_valid ||
input->continuous_duration_seconds != 0 || input->continuous_duration_seconds != 0 ||
input->continuous_has_speed || input->continuous_has_speed ||
input->continuous_has_distance) { input->continuous_has_distance
) {
return TRAINLOG_STATUS_INVALID_ARGUMENT; return TRAINLOG_STATUS_INVALID_ARGUMENT;
} }
} else { } else {

429
tui/src/reps.c Normal file
View file

@ -0,0 +1,429 @@
/**
* @file reps.c
* @brief Bounded parser for Trainlog repetition-set shorthand.
*/
#include "trainlog/reps.h"
#include <ctype.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define TRAINLOG_REPS_TEXT_MAX 511U
#define TRAINLOG_REPS_VALUE_MAX 10000
static bool parse_token(
const char *start,
size_t length,
int *output
)
{
char buffer[32];
char *end = NULL;
long value;
if (
start == NULL ||
output == NULL ||
length == 0U ||
length >= sizeof(buffer)
) {
return false;
}
(void)memcpy(
buffer,
start,
length
);
buffer[length] = '\0';
value =
strtol(
buffer,
&end,
10
);
if (
end == buffer ||
*end != '\0' ||
value < 0L ||
value > TRAINLOG_REPS_VALUE_MAX
) {
return false;
}
*output = (int)value;
return true;
}
static bool compact_text(
const char *text,
char output[TRAINLOG_REPS_TEXT_MAX + 1U]
)
{
size_t read_index;
size_t write_index = 0U;
if (
text == NULL ||
output == NULL
) {
return false;
}
for (
read_index = 0U;
text[read_index] != '\0';
++read_index
) {
unsigned char value =
(unsigned char)text[read_index];
if (isspace(value) != 0) {
continue;
}
if (
write_index >=
TRAINLOG_REPS_TEXT_MAX
) {
return false;
}
output[write_index] =
(char)value;
++write_index;
}
output[write_index] = '\0';
return write_index > 0U;
}
static TrainlogStatus parse_repeat(
char *text,
int *output,
size_t capacity,
size_t *output_count
)
{
char *separator =
strchr(
text,
'x'
);
int count;
int reps;
size_t index;
if (separator == NULL) {
separator =
strchr(
text,
'X'
);
}
if (separator == NULL) {
return TRAINLOG_STATUS_NOT_FOUND;
}
if (
strchr(
separator + 1,
'x'
) != NULL ||
strchr(
separator + 1,
'X'
) != NULL
) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*separator = '\0';
if (
!parse_token(
text,
strlen(text),
&count
) ||
!parse_token(
separator + 1,
strlen(separator + 1),
&reps
) ||
count < 1 ||
(size_t)count > capacity
) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
for (
index = 0U;
index < (size_t)count;
++index
) {
output[index] = reps;
}
*output_count = (size_t)count;
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus parse_pyramid(
char *text,
int *output,
size_t capacity,
size_t *output_count
)
{
char *first =
strstr(
text,
".."
);
char *second;
int start;
int peak;
int end;
size_t count = 0U;
int value;
if (first == NULL) {
return TRAINLOG_STATUS_NOT_FOUND;
}
second =
strstr(
first + 2,
".."
);
if (
second == NULL ||
strstr(
second + 2,
".."
) != NULL
) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*first = '\0';
*second = '\0';
if (
!parse_token(
text,
strlen(text),
&start
) ||
!parse_token(
first + 2,
strlen(first + 2),
&peak
) ||
!parse_token(
second + 2,
strlen(second + 2),
&end
) ||
start > peak ||
end > peak
) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
for (
value = start;
value <= peak;
++value
) {
if (count >= capacity) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
output[count] = value;
++count;
}
if (peak > end) {
for (
value = peak - 1;
value >= end;
--value
) {
if (count >= capacity) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
output[count] = value;
++count;
}
}
if (count == 0U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_count = count;
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus parse_explicit(
const char *text,
int *output,
size_t capacity,
size_t *output_count
)
{
const char *cursor = text;
const char *token_start = text;
size_t count = 0U;
for (;;) {
if (
*cursor == ',' ||
*cursor == ';' ||
*cursor == '\0'
) {
int reps;
if (
count >= capacity ||
!parse_token(
token_start,
(size_t)(
cursor -
token_start
),
&reps
)
) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
output[count] = reps;
++count;
if (*cursor == '\0') {
break;
}
token_start =
cursor + 1;
}
++cursor;
}
if (count == 0U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_count = count;
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_reps_parse_sequence(
const char *text,
int *output,
size_t capacity,
size_t *output_count
)
{
char compact[
TRAINLOG_REPS_TEXT_MAX + 1U
];
char working[
TRAINLOG_REPS_TEXT_MAX + 1U
];
TrainlogStatus status;
if (
text == NULL ||
output_count == NULL ||
capacity == 0U ||
output == NULL
) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_count = 0U;
if (
!compact_text(
text,
compact
)
) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)snprintf(
working,
sizeof(working),
"%s",
compact
);
status =
parse_repeat(
working,
output,
capacity,
output_count
);
if (
status !=
TRAINLOG_STATUS_NOT_FOUND
) {
return status;
}
(void)snprintf(
working,
sizeof(working),
"%s",
compact
);
status =
parse_pyramid(
working,
output,
capacity,
output_count
);
if (
status !=
TRAINLOG_STATUS_NOT_FOUND
) {
return status;
}
return
parse_explicit(
compact,
output,
capacity,
output_count
);
}

View file

@ -25,6 +25,7 @@
#include "trainlog/duration.h" #include "trainlog/duration.h"
#include "trainlog/id.h" #include "trainlog/id.h"
#include "trainlog/mtp.h" #include "trainlog/mtp.h"
#include "trainlog/reps.h"
#include "trainlog/theme.h" #include "trainlog/theme.h"
#include "trainlog/timeutil.h" #include "trainlog/timeutil.h"
#include "trainlog/usb.h" #include "trainlog/usb.h"
@ -40,6 +41,7 @@
/* TRAINLOG_TUI_V02_POLISH */ /* TRAINLOG_TUI_V02_POLISH */
/* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */ /* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */
/* TRAINLOG_VARIABLE_SET_REPS_V1 */
/* TRAINLOG_SYNC_RESPONSIVE_CACHE */ /* TRAINLOG_SYNC_RESPONSIVE_CACHE */
/* TRAINLOG_SYNC_LARGE_LAYOUT_S_FIX */ /* TRAINLOG_SYNC_LARGE_LAYOUT_S_FIX */
/* TRAINLOG_SYNC_HISTORY_BIDIRECTIONAL_V1 */ /* TRAINLOG_SYNC_HISTORY_BIDIRECTIONAL_V1 */
@ -4493,7 +4495,10 @@ static bool build_session_exercise(
int target_sets = 3; int target_sets = 3;
int target_metric; int target_metric;
int rest_seconds = 60; int rest_seconds = 60;
int actual_sets; int actual_sets = 0;
int rep_values[MAX_SETS_PER_EXERCISE];
size_t rep_count = 0U;
char rep_sequence[512];
bool target_has_weight = false; bool target_has_weight = false;
double target_weight = 0.0; double target_weight = 0.0;
size_t set_index; size_t set_index;
@ -4689,15 +4694,51 @@ static bool build_session_exercise(
return false; return false;
} }
if (!prompt_int_value( if (
9, exercise.tracking_mode ==
"Séries réellement faites", TRAINLOG_TRACKING_REPS
0, ) {
(int)set_capacity, for (;;) {
target_sets, if (!prompt_text(
&actual_sets 9,
)) { "Séries réalisées (5x10 | 4,5,6,... | 4..10..4) : ",
return false; rep_sequence,
sizeof(rep_sequence),
false
)) {
return false;
}
if (
trainlog_reps_parse_sequence(
rep_sequence,
rep_values,
set_capacity,
&rep_count
) ==
TRAINLOG_STATUS_OK
) {
break;
}
status_line(
"Séries invalides. Exemples : 5x10 · 4,5,6,7 · 4..10..4",
TRAINLOG_COLOR_ERROR
);
refresh();
}
} else {
if (!prompt_int_value(
9,
"Séries réellement faites",
0,
(int)set_capacity,
target_sets,
&actual_sets
)) {
return false;
}
} }
output->load_mode = output->load_mode =
@ -4732,7 +4773,10 @@ static bool build_session_exercise(
output->sets = set_storage; output->sets = set_storage;
output->set_count = output->set_count =
(size_t)actual_sets; exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS
? rep_count
: (size_t)actual_sets;
for (set_index = 0U; for (set_index = 0U;
set_index < output->set_count; set_index < output->set_count;
@ -4746,31 +4790,31 @@ static bool build_session_exercise(
sizeof(set_storage[set_index]) sizeof(set_storage[set_index])
); );
draw_shell( if (
exercise.name, exercise.tracking_mode ==
"Échap annuler · Durées : 90, 90s, 1:30, 1m30, 2m" TRAINLOG_TRACKING_DURATION ||
); target_has_weight
) {
draw_shell(
exercise.name,
"Échap annuler · Durées : 90, 90s, 1:30, 1m30, 2m"
);
mvprintw( mvprintw(
3, 3,
4, 4,
"Série %zu / %zu", "Série %zu / %zu",
set_index + 1U, set_index + 1U,
output->set_count output->set_count
); );
}
if (exercise.tracking_mode == if (
TRAINLOG_TRACKING_REPS) { exercise.tracking_mode ==
if (!prompt_int_value( TRAINLOG_TRACKING_REPS
5, ) {
"Répétitions réalisées", actual_metric =
0, rep_values[set_index];
10000,
target_metric,
&actual_metric
)) {
return false;
}
set_storage[set_index].reps = set_storage[set_index].reps =
actual_metric; actual_metric;
@ -6612,8 +6656,18 @@ static void screen_session_detail(
rest_text rest_text
); );
if (exercise->tracking_mode == if (
TRAINLOG_TRACKING_REPS) { exercise->target_sets <= 0
) {
mvprintw(
target_row,
decorated ? 5 : 4,
"Cible : non renseignée"
);
} else if (
exercise->tracking_mode ==
TRAINLOG_TRACKING_REPS
) {
mvprintw( mvprintw(
target_row, target_row,
decorated ? 5 : 4, decorated ? 5 : 4,

View file

@ -130,7 +130,7 @@ static bool test_profile_roundtrip(void)
return true; return true;
} }
static bool test_v2_to_v3_migration(void) static bool test_v2_to_current_migration(void)
{ {
char path[] = char path[] =
"/tmp/trainlog-schema-v2-profile-XXXXXX"; "/tmp/trainlog-schema-v2-profile-XXXXXX";
@ -206,7 +206,10 @@ static bool test_v2_to_v3_migration(void)
) == TRAINLOG_STATUS_OK ) == TRAINLOG_STATUS_OK
); );
CHECK(version == 3); CHECK(
version ==
TRAINLOG_DATABASE_SCHEMA_VERSION
);
trainlog_database_close(database); trainlog_database_close(database);
database = NULL; database = NULL;
@ -281,7 +284,7 @@ static bool test_v2_to_v3_migration(void)
int main(void) int main(void)
{ {
CHECK(test_profile_roundtrip()); CHECK(test_profile_roundtrip());
CHECK(test_v2_to_v3_migration()); CHECK(test_v2_to_current_migration());
(void)printf("PASS exercise_profile_schema\n"); (void)printf("PASS exercise_profile_schema\n");
return 0; return 0;

178
tui/tests/test_reps.c Normal file
View file

@ -0,0 +1,178 @@
#include <stdbool.h>
#include <stdio.h>
#include "trainlog/reps.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf( \
stderr, \
"CHECK failed at %s:%d: %s\n", \
__FILE__, \
__LINE__, \
#condition \
); \
return false; \
} \
} while (0)
static bool test_repeat(void)
{
int values[16];
size_t count = 0U;
size_t index;
CHECK(
trainlog_reps_parse_sequence(
"5x10",
values,
16U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 5U);
for (
index = 0U;
index < count;
++index
) {
CHECK(values[index] == 10);
}
return true;
}
static bool test_explicit(void)
{
static const int expected[] = {
4, 5, 6, 7, 8, 9, 10,
9, 8, 7, 6, 5, 4
};
int values[16];
size_t count = 0U;
size_t index;
CHECK(
trainlog_reps_parse_sequence(
"4,5,6,7,8,9,10,9,8,7,6,5,4",
values,
16U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(
count ==
sizeof(expected) /
sizeof(expected[0])
);
for (
index = 0U;
index < count;
++index
) {
CHECK(
values[index] ==
expected[index]
);
}
return true;
}
static bool test_pyramid(void)
{
static const int expected[] = {
4, 5, 6, 7, 8, 9, 10,
9, 8, 7, 6, 5, 4
};
int values[16];
size_t count = 0U;
size_t index;
CHECK(
trainlog_reps_parse_sequence(
"4..10..4",
values,
16U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(
count ==
sizeof(expected) /
sizeof(expected[0])
);
for (
index = 0U;
index < count;
++index
) {
CHECK(
values[index] ==
expected[index]
);
}
return true;
}
static bool test_invalid(void)
{
int values[4];
size_t count = 0U;
CHECK(
trainlog_reps_parse_sequence(
"5x10",
values,
4U,
&count
) ==
TRAINLOG_STATUS_INVALID_ARGUMENT
);
CHECK(
trainlog_reps_parse_sequence(
"10..4..10",
values,
4U,
&count
) ==
TRAINLOG_STATUS_INVALID_ARGUMENT
);
CHECK(
trainlog_reps_parse_sequence(
"4,,5",
values,
4U,
&count
) ==
TRAINLOG_STATUS_INVALID_ARGUMENT
);
return true;
}
int main(void)
{
CHECK(test_repeat());
CHECK(test_explicit());
CHECK(test_pyramid());
CHECK(test_invalid());
(void)printf(
"PASS reps_sequence\n"
);
return 0;
}

View file

@ -0,0 +1,239 @@
/**
* @file test_schema_v5_migration.c
* @brief Direct v4 -> v5 migration regression test.
*/
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sqlite3.h>
#include "trainlog/database.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf( \
stderr, \
"CHECK failed at %s:%d: %s\n", \
__FILE__, \
__LINE__, \
#condition \
); \
return false; \
} \
} while (0)
static bool test_v4_to_v5_preserves_session(void)
{
char path[] =
"/tmp/trainlog-schema-v4-v5-XXXXXX";
static const char *const V4_SQL =
"CREATE TABLE exercises ("
"id INTEGER PRIMARY KEY,"
"exercise_id TEXT NOT NULL UNIQUE,"
"name TEXT NOT NULL,"
"normalized_name TEXT NOT NULL UNIQUE,"
"tracking_mode TEXT NOT NULL,"
"recording_mode TEXT NOT NULL DEFAULT 'sets',"
"data_fields INTEGER NOT NULL DEFAULT 0"
");"
"CREATE TABLE sessions ("
"id INTEGER PRIMARY KEY,"
"session_id TEXT NOT NULL UNIQUE,"
"started_at TEXT NOT NULL,"
"ended_at TEXT,"
"session_type TEXT NOT NULL DEFAULT 'training',"
"notes TEXT"
");"
"CREATE TABLE session_exercises ("
"id INTEGER PRIMARY KEY,"
"session_row_id INTEGER NOT NULL REFERENCES sessions(id),"
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),"
"recording_mode TEXT NOT NULL DEFAULT 'sets',"
"data_fields INTEGER NOT NULL DEFAULT 0,"
"position INTEGER NOT NULL,"
"load_mode TEXT NOT NULL,"
"rest_seconds INTEGER NOT NULL,"
"target_sets INTEGER CHECK(target_sets > 0),"
"target_reps INTEGER CHECK(target_reps >= 1),"
"target_duration_seconds INTEGER CHECK(target_duration_seconds > 0),"
"target_weight_kg REAL,"
"notes TEXT,"
"CHECK("
" (recording_mode='sets' AND"
" target_sets IS NOT NULL AND"
" ((target_reps IS NOT NULL AND"
" target_duration_seconds IS NULL) OR"
" (target_reps IS NULL AND"
" target_duration_seconds IS NOT NULL))) OR"
" (recording_mode='continuous' AND"
" target_sets IS NULL AND"
" target_reps IS NULL AND"
" target_duration_seconds IS NULL)"
")"
");"
"CREATE TABLE performed_sets ("
"id INTEGER PRIMARY KEY,"
"session_exercise_row_id INTEGER NOT NULL"
" REFERENCES session_exercises(id),"
"position INTEGER NOT NULL,"
"reps INTEGER,"
"duration_seconds INTEGER,"
"weight_kg REAL"
");"
"CREATE TABLE continuous_activity ("
"id INTEGER PRIMARY KEY,"
"session_exercise_row_id INTEGER NOT NULL UNIQUE"
" REFERENCES session_exercises(id),"
"duration_seconds INTEGER NOT NULL,"
"speed_kmh REAL,"
"distance_km REAL"
");"
"INSERT INTO exercises("
"exercise_id,name,normalized_name,tracking_mode,"
"recording_mode,data_fields"
") VALUES("
"'ex_v4','Pompes','pompes','reps','sets',0"
");"
"INSERT INTO sessions("
"session_id,started_at,ended_at,session_type,notes"
") VALUES("
"'se_v4',"
"'2026-09-06T10:00:00+02:00',"
"'2026-09-06T10:30:00+02:00',"
"'training',NULL"
");"
"INSERT INTO session_exercises("
"session_row_id,exercise_row_id,recording_mode,data_fields,"
"position,load_mode,rest_seconds,target_sets,target_reps,"
"target_duration_seconds,target_weight_kg,notes"
") VALUES("
"1,1,'sets',0,0,'none',60,3,10,NULL,NULL,NULL"
");"
"INSERT INTO performed_sets("
"session_exercise_row_id,position,reps,duration_seconds,weight_kg"
") VALUES"
"(1,0,10,NULL,NULL),"
"(1,1,9,NULL,NULL),"
"(1,2,8,NULL,NULL);"
"PRAGMA user_version=4;";
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
TrainlogSessionSummary summary;
TrainlogPersistedExerciseDetail details[2];
size_t detail_count = 0U;
int version = 0;
int fd;
fd = mkstemp(path);
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(
sqlite3_open(
path,
&raw
) == SQLITE_OK
);
CHECK(
sqlite3_exec(
raw,
V4_SQL,
NULL,
NULL,
NULL
) == SQLITE_OK
);
CHECK(
sqlite3_close(raw) ==
SQLITE_OK
);
raw = NULL;
CHECK(
trainlog_database_open(
path,
&database
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_schema_version(
database,
&version
) == TRAINLOG_STATUS_OK
);
CHECK(
version ==
TRAINLOG_DATABASE_SCHEMA_VERSION
);
CHECK(
version == 5
);
CHECK(
trainlog_database_get_session_details(
database,
"se_v4",
&summary,
details,
2U,
&detail_count
) == TRAINLOG_STATUS_OK
);
CHECK(detail_count == 1U);
CHECK(details[0].target_sets == 3);
CHECK(details[0].target_reps == 10);
CHECK(details[0].actual_set_count == 3U);
CHECK(
strcmp(
details[0].actual_summary,
"10 / 9 / 8"
) == 0
);
trainlog_database_close(
database
);
database = NULL;
CHECK(unlink(path) == 0);
return true;
}
int main(void)
{
CHECK(
test_v4_to_v5_preserves_session()
);
(void)printf(
"PASS schema_v5_migration\n"
);
return 0;
}

View file

@ -352,9 +352,156 @@ static bool test_load_replace_and_rollback(void)
return true; return true;
} }
static bool test_remove_exercise_from_session(void)
{
TrainlogDatabase *database = NULL;
TrainlogSessionExerciseInput exercises[2];
TrainlogSessionExerciseInput replacement;
TrainlogSetInput press_sets[2];
TrainlogSetInput plank_sets[1];
TrainlogSessionInput session;
TrainlogSessionSummary loaded_session;
TrainlogEditableExerciseRecord loaded_exercises[4];
TrainlogSetInput loaded_sets[16];
size_t exercise_count = 0U;
size_t set_count = 0U;
CHECK(
trainlog_database_open(
":memory:",
&database
) == TRAINLOG_STATUS_OK
);
CHECK(add_exercises(database));
bind_reps_exercise(
&exercises[0],
press_sets,
2U,
80.0
);
(void)memset(
&exercises[1],
0,
sizeof(exercises[1])
);
(void)memset(
plank_sets,
0,
sizeof(plank_sets)
);
(void)snprintf(
exercises[1].exercise_id,
sizeof(exercises[1].exercise_id),
"%s",
"ex_plank"
);
exercises[1].recording_mode =
TRAINLOG_RECORDING_SETS;
exercises[1].load_mode =
TRAINLOG_LOAD_NONE;
exercises[1].rest_seconds = 30;
exercises[1].target_sets = 1;
exercises[1].target_duration_seconds = 60;
exercises[1].sets = plank_sets;
exercises[1].set_count = 1U;
plank_sets[0].duration_seconds = 55;
(void)memset(
&session,
0,
sizeof(session)
);
(void)snprintf(
session.session_id,
sizeof(session.session_id),
"%s",
"se_remove_exercise"
);
(void)snprintf(
session.started_at,
sizeof(session.started_at),
"%s",
"2026-09-06T14:00:00+02:00"
);
(void)snprintf(
session.ended_at,
sizeof(session.ended_at),
"%s",
"2026-09-06T14:30:00+02:00"
);
session.session_type =
TRAINLOG_SESSION_TRAINING;
session.exercises = exercises;
session.exercise_count = 2U;
CHECK(
trainlog_database_insert_session(
database,
&session
) == TRAINLOG_STATUS_OK
);
replacement = exercises[0];
CHECK(
trainlog_database_replace_session_exercises(
database,
"se_remove_exercise",
&replacement,
1U
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_load_session_editable(
database,
"se_remove_exercise",
&loaded_session,
loaded_exercises,
4U,
&exercise_count,
loaded_sets,
16U,
&set_count
) == TRAINLOG_STATUS_OK
);
CHECK(exercise_count == 1U);
CHECK(
strcmp(
loaded_exercises[0].exercise_id,
"ex_press"
) == 0
);
CHECK(set_count == 2U);
trainlog_database_close(
database
);
return true;
}
int main(void) int main(void)
{ {
CHECK(test_load_replace_and_rollback()); CHECK(test_load_replace_and_rollback());
CHECK(test_remove_exercise_from_session());
(void)printf( (void)printf(
"PASS session_edit\n" "PASS session_edit\n"

View file

@ -0,0 +1,209 @@
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "trainlog/database.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf( \
stderr, \
"CHECK failed at %s:%d: %s\n", \
__FILE__, \
__LINE__, \
#condition \
); \
return false; \
} \
} while (0)
static bool test_targetless_variable_sets(void)
{
static const int reps[] = {
4, 5, 6, 7, 8, 9, 10,
9, 8, 7, 6, 5, 4
};
TrainlogDatabase *database = NULL;
TrainlogSetInput sets[
sizeof(reps) /
sizeof(reps[0])
];
TrainlogSessionExerciseInput exercise;
TrainlogSessionInput session;
TrainlogSessionSummary summary;
TrainlogPersistedExerciseDetail detail[2];
size_t detail_count = 0U;
size_t index;
int schema_version = 0;
CHECK(
trainlog_database_open(
":memory:",
&database
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_schema_version(
database,
&schema_version
) == TRAINLOG_STATUS_OK
);
CHECK(
schema_version ==
TRAINLOG_DATABASE_SCHEMA_VERSION
);
CHECK(
trainlog_database_insert_exercise(
database,
"ex_variable_reps",
"Pompes pyramide",
"pompes pyramide",
TRAINLOG_TRACKING_REPS
) == TRAINLOG_STATUS_OK
);
(void)memset(
sets,
0,
sizeof(sets)
);
for (
index = 0U;
index <
sizeof(reps) /
sizeof(reps[0]);
++index
) {
sets[index].reps =
reps[index];
}
(void)memset(
&exercise,
0,
sizeof(exercise)
);
(void)snprintf(
exercise.exercise_id,
sizeof(exercise.exercise_id),
"%s",
"ex_variable_reps"
);
exercise.recording_mode =
TRAINLOG_RECORDING_SETS;
exercise.load_mode =
TRAINLOG_LOAD_NONE;
exercise.rest_seconds = 0;
/*
* No target is invented. These are actual observations imported from
* a mobile session.
*/
exercise.target_sets = 0;
exercise.target_reps = 0;
exercise.target_duration_seconds = 0;
exercise.sets = sets;
exercise.set_count =
sizeof(reps) /
sizeof(reps[0]);
(void)memset(
&session,
0,
sizeof(session)
);
(void)snprintf(
session.session_id,
sizeof(session.session_id),
"%s",
"se_variable_reps"
);
(void)snprintf(
session.started_at,
sizeof(session.started_at),
"%s",
"2026-09-06T16:30:00+02:00"
);
(void)snprintf(
session.ended_at,
sizeof(session.ended_at),
"%s",
"2026-09-06T16:45:00+02:00"
);
session.exercises =
&exercise;
session.exercise_count = 1U;
CHECK(
trainlog_database_insert_session(
database,
&session
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_get_session_details(
database,
"se_variable_reps",
&summary,
detail,
2U,
&detail_count
) == TRAINLOG_STATUS_OK
);
CHECK(detail_count == 1U);
CHECK(summary.exercise_count == 1U);
CHECK(detail[0].target_sets == 0);
CHECK(detail[0].target_reps == 0);
CHECK(
detail[0].actual_set_count ==
sizeof(reps) /
sizeof(reps[0])
);
CHECK(
strcmp(
detail[0].actual_summary,
"4 / 5 / 6 / 7 / 8 / 9 / 10 / 9 / 8 / 7 / 6 / 5 / 4"
) == 0
);
trainlog_database_close(
database
);
return true;
}
int main(void)
{
CHECK(
test_targetless_variable_sets()
);
(void)printf(
"PASS variable_sets\n"
);
return 0;
}