feat(max): add explicit max test results and migrate legacy 1x1 entries

This commit is contained in:
fy59 2026-09-08 19:58:55 +02:00
parent 8066b39ce3
commit 56535704ca
39 changed files with 2161 additions and 238 deletions

View file

@ -164,7 +164,7 @@ It is not the canonical analytics store.
The Android UI is driven by exercise metadata, never by exercise-name The Android UI is driven by exercise metadata, never by exercise-name
heuristics. heuristics.
Android local SQLite schema v4 owns exactly one durable active-session draft. Android local SQLite schema v9 owns exactly one durable active-session draft.
Every meaningful draft/form mutation is persisted by the repository. Back, Every meaningful draft/form mutation is persisted by the repository. Back,
backgrounding and process death never delete the draft. Home offers explicit backgrounding and process death never delete the draft. Home offers explicit
resume; whole-draft discard requires confirmation. Final completed-session resume; whole-draft discard requires confirmation. Final completed-session
@ -220,7 +220,8 @@ Desktop SQLite schema is versioned with:
PRAGMA user_version; PRAGMA user_version;
``` ```
The current desktop schema is v5. The current desktop schema is v9. Schema v9 adds an occurrence-owned explicit
maximum result without changing `TRAINLOG_FORMAT_V1`.
Every incompatible schema evolution requires an explicit migration and Every incompatible schema evolution requires an explicit migration and
regression coverage. regression coverage.

View file

@ -9,6 +9,19 @@ Detailed implementation chronology remains available in Git history and
### Added ### Added
- explicit MAX result mode for `max_test` sessions: exercise, optional
equipment context and positive `max_weight_kg`, with no synthetic set or
repetition;
- Android and TUI max-only entry/edit/detail flows, Android latest max per
exercise, and stable-ID continuation of a completed Test max;
- V2 round-trip of explicit max results in both directions, including bounded
reconciliation of an appended or corrected continuation of the same
max-test session;
- desktop and Android schema v9 migrations that convert only an unambiguous
sole `1 rep × positive load` legacy result and preserve ambiguous attempts;
- explicit-max persistence, migration, sync and same-machine/different-movement
regression coverage;
- desktop equipment v8: a supplied catalogue generated from - desktop equipment v8: a supplied catalogue generated from
`catalog/equipment-v1.json`, browse/search/detail views, and local custom `catalog/equipment-v1.json`, browse/search/detail views, and local custom
equipment creation/selection; occurrence links resolve supplied or local equipment creation/selection; occurrence links resolve supplied or local
@ -74,6 +87,9 @@ Detailed implementation chronology remains available in Git history and
### Changed ### Changed
- desktop and Android schema v9 add one-to-one completed/draft max-result rows;
`TRAINLOG_FORMAT_V1` remains frozen and V1 export refuses explicit MAX data
rather than losing or fabricating it;
- desktop and Android schema v8 preserve existing equipment references while - desktop and Android schema v8 preserve existing equipment references while
permitting custom definitions with `load_semantics = none`; Android's v7 -> permitting custom definitions with `load_semantics = none`; Android's v7 ->
v8 migration is non-destructive; v8 migration is non-destructive;
@ -150,14 +166,16 @@ Current validated baseline:
```text ```text
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V8=PASS DESKTOP_SCHEMA_V9=PASS
DESKTOP_TESTS=32/32 PASS DESKTOP_TESTS=34/34 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
ANDROID_LOCAL_WORKFLOWS=PASS ANDROID_LOCAL_WORKFLOWS=PASS
ANDROID_LOCAL_DATABASE_V8=PASS ANDROID_LOCAL_DATABASE_V9=PASS
ANDROID_TEST_DEBUG_UNIT=PASS ANDROID_TEST_DEBUG_UNIT=PASS
ANDROID_SESSION_DRAFT_V1=PASS ANDROID_SESSION_DRAFT_V1=PASS
ANDROID_MAX_V9_REAL_DATA_MIGRATION=PASS
ANDROID_MAX_V9_INSTALL_ADB=PASS
USB_MTP_DETECTION=PASS USB_MTP_DETECTION=PASS
MTP_HARDWARE_ROUNDTRIP=HISTORICAL_PASS MTP_HARDWARE_ROUNDTRIP=HISTORICAL_PASS
@ -177,6 +195,7 @@ MULTI_OCCURRENCE_SESSION_V2=PASS
EQUIPMENT_ASSOCIATIONS_V2=PASS EQUIPMENT_ASSOCIATIONS_V2=PASS
EQUIPMENT_DEFINITIONS_V1=PASS EQUIPMENT_DEFINITIONS_V1=PASS
EXERCISE_RECONCILIATION_V2=PASS EXERCISE_RECONCILIATION_V2=PASS
EXPLICIT_MAX_RESULTS_V1=PASS
``` ```
### Measured max v1 ### Measured max v1

View file

@ -16,9 +16,9 @@ desktop.
```text ```text
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V8=PASS DESKTOP_SCHEMA_V9=PASS
ANDROID_LOCAL_WORKFLOWS=PASS ANDROID_LOCAL_WORKFLOWS=PASS
ANDROID_LOCAL_DATABASE_V8=PASS ANDROID_LOCAL_DATABASE_V9=PASS
ANDROID_SESSION_DRAFT_V1=PASS ANDROID_SESSION_DRAFT_V1=PASS
EXERCISE_EDIT_V1=PASS EXERCISE_EDIT_V1=PASS
ANDROID_BANNER_PARITY_V1=PASS ANDROID_BANNER_PARITY_V1=PASS
@ -36,8 +36,9 @@ MULTI_OCCURRENCE_SESSION_V2=PASS
EQUIPMENT_ASSOCIATIONS_V2=PASS EQUIPMENT_ASSOCIATIONS_V2=PASS
EQUIPMENT_DEFINITIONS_V1=PASS EQUIPMENT_DEFINITIONS_V1=PASS
EXERCISE_RECONCILIATION_V2=PASS EXERCISE_RECONCILIATION_V2=PASS
EXPLICIT_MAX_RESULTS_V1=PASS
DESKTOP_TESTS=32/32 PASS DESKTOP_TESTS=34/34 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
``` ```
@ -111,6 +112,11 @@ occurrence, as do its actual per-set loads. `external` records an applied or
machine-displayed load; `assistance` records assistance and is not interpreted machine-displayed load; `assistance` records assistance and is not interpreted
as increasing strength. as increasing strength.
In a `max_test` session, an occurrence may instead own one explicit positive
`max_weight_kg`. This result has no performed set, repetitions, or target-set
count. Its identity remains the movement's `exercise_id` plus the occurrence's
`entry_id`; optional equipment is context and never owns a shared maximum.
During V2 synchronization, a different-ID normalized-name collision is merged During V2 synchronization, a different-ID normalized-name collision is merged
only when recording/tracking modes match, every other known invariant is only when recording/tracking modes match, every other known invariant is
compatible, and one `data_fields` mask contains the other. The desktop identity compatible, and one `data_fields` mask contains the other. The desktop identity
@ -154,13 +160,14 @@ draft; abandoning the draft requires confirmation. Final save atomically
creates completed history and clears the draft. Drafts never enter mobile creates completed history and clears the draft. Drafts never enter mobile
export or desktop synchronization as completed sessions. export or desktop synchronization as completed sessions.
Schema v4 migrates additively from v3, preserving existing capture data. See Schema migrations are additive and preserve existing capture data. See
[Android behavior](docs/android.md) and [validation](docs/tests.md). [Android behavior](docs/android.md) and [validation](docs/tests.md).
The current Android schema is v8. Its additive v4 -> v8 chain adds the shared The current Android schema is v9. Its additive v4 -> v9 chain adds the shared
equipment catalogue, per-occurrence equipment links, durable occurrence equipment catalogue, per-occurrence equipment links, durable occurrence
identities, and custom-equipment definition support without recreating completed identities, custom-equipment definition support, explicit MAX results and
history or the active draft. stable-source Test max resumption without recreating completed history or
discarding the active draft.
Exercises can be renamed in place from Android. The `ex_<uuid-v4>` identity is Exercises can be renamed in place from Android. The `ex_<uuid-v4>` identity is
unchanged; completed history, an active draft, and synchronization therefore unchanged; completed history, an active draft, and synchronization therefore
@ -247,7 +254,9 @@ remain readable as legacy artifacts; they are not silently redefined as V2.
## Measured max ## Measured max
Explicit `max_test` sessions are the only source of measured maxima. Explicit `max_test` sessions are the only source of measured maxima. Schema v9
stores each new weight result in a one-to-one `max_results` row; it no longer
encodes a maximum as a synthetic `1 × 1` set.
Ordinary training best sets remain ordinary performance even when they exceed a Ordinary training best sets remain ordinary performance even when they exceed a
previous max-test result. previous max-test result.
@ -257,12 +266,16 @@ 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 working loads for external resistance. Working loads are rounded to a selectable
practical increment and are not calculated for assistance. practical increment and are not calculated for assistance.
Android can explicitly save a session as `Entraînement` or `Test max`. Android and the TUI expose a dedicated `Test max` form containing exercise,
optional equipment, and `Poids max (kg)`. Android can reopen an existing Test
max and atomically replace its ordered entries while preserving the original
session and occurrence identities.
```text ```text
MEASURED_MAX_V1=PASS MEASURED_MAX_V1=PASS
WORKING_LOAD_PERCENTAGES=PASS WORKING_LOAD_PERCENTAGES=PASS
ANDROID_MAX_TEST_SESSION=PASS ANDROID_MAX_TEST_SESSION=PASS
EXPLICIT_MAX_RESULTS_V1=PASS
``` ```
## Body analytics ## Body analytics
@ -283,5 +296,5 @@ BODY_ANALYTICS_V1=PASS
BODY_COMPOSITION_ESTIMATE=PASS BODY_COMPOSITION_ESTIMATE=PASS
BODY_PROPORTION_RATIOS=PASS BODY_PROPORTION_RATIOS=PASS
BODY_SYMMETRY_ANALYTICS=PASS BODY_SYMMETRY_ANALYTICS=PASS
DESKTOP_TESTS=32/32 PASS DESKTOP_TESTS=34/34 PASS
``` ```

View file

@ -11,6 +11,7 @@ import com.labfytools.trainlog.model.BodyObservationSummary
import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseDataFields
import com.labfytools.trainlog.model.ExerciseProfile import com.labfytools.trainlog.model.ExerciseProfile
import com.labfytools.trainlog.model.ExerciseEditInput import com.labfytools.trainlog.model.ExerciseEditInput
import com.labfytools.trainlog.model.LatestExerciseMax
import com.labfytools.trainlog.model.NewExerciseProfile import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft import com.labfytools.trainlog.model.SessionDraft
@ -98,6 +99,7 @@ sealed interface MobileSessionImportResult {
val sessionsSkipped: Int, val sessionsSkipped: Int,
val bodyObservationsAdded: Int, val bodyObservationsAdded: Int,
val bodyObservationsSkipped: Int, val bodyObservationsSkipped: Int,
val sessionsUpdated: Int = 0,
) : MobileSessionImportResult ) : MobileSessionImportResult
data class Invalid(val message: String) : MobileSessionImportResult data class Invalid(val message: String) : MobileSessionImportResult
data object DatabaseError : MobileSessionImportResult data object DatabaseError : MobileSessionImportResult
@ -536,8 +538,10 @@ class TrainlogRepository(
): ActiveDraftMutationResult { ): ActiveDraftMutationResult {
if ( if (
draft.exercises.any { draft.exercises.any {
!validateSessionExercise(it) !validateSessionExercise(it, draft.sessionType)
} || } ||
(draft.sourceSessionId != null &&
(draft.sourceSessionId.isBlank() || draft.sessionType != SessionType.MAX_TEST)) ||
/* exercise_id identifies the catalogue movement. Repeated passages /* exercise_id identifies the catalogue movement. Repeated passages
* are valid; only their stable occurrence IDs must be unique. */ * are valid; only their stable occurrence IDs must be unique. */
draft.exercises draft.exercises
@ -555,6 +559,8 @@ class TrainlogRepository(
listOf( listOf(
draft.form.setCountText, draft.form.setCountText,
draft.form.repsText, draft.form.repsText,
draft.form.weightText,
draft.form.maxWeightText,
draft.form.durationText, draft.form.durationText,
draft.form.speedText, draft.form.speedText,
draft.form.distanceText, draft.form.distanceText,
@ -642,7 +648,7 @@ class TrainlogRepository(
if ( if (
completed.exercises.isEmpty() || completed.exercises.isEmpty() ||
completed.exercises.any { completed.exercises.any {
!validateSessionExercise(it) !validateSessionExercise(it, completed.sessionType)
} }
) { ) {
db.endTransaction() db.endTransaction()
@ -656,6 +662,7 @@ class TrainlogRepository(
insertCompletedSession( insertCompletedSession(
db, db,
completed, completed,
active.draft.sourceSessionId,
) )
/* /*
@ -704,7 +711,7 @@ class TrainlogRepository(
if ( if (
draft.exercises.isEmpty() || draft.exercises.isEmpty() ||
draft.exercises.any { draft.exercises.any {
!validateSessionExercise(it) !validateSessionExercise(it, draft.sessionType)
} }
) { ) {
return SaveSessionResult.Invalid return SaveSessionResult.Invalid
@ -815,7 +822,16 @@ class TrainlogRepository(
exerciseValues exerciseValues
) )
if ( if (exerciseDraft.maxWeightKg != null) {
db.insertOrThrow(
"max_results",
null,
ContentValues().apply {
put("session_exercise_row_id", sessionExerciseRowId)
put("max_weight_kg", exerciseDraft.maxWeightKg)
},
)
} else if (
exerciseDraft.exercise exerciseDraft.exercise
.recordingMode == .recordingMode ==
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
@ -955,6 +971,71 @@ class TrainlogRepository(
return output return output
} }
/**
* Reopen an existing max_test as the one durable draft. The completed row
* remains the crash-safe baseline until finalization atomically replaces
* its children; session_id and started_at are never regenerated.
*/
fun resumeMaxTestSession(sessionId: String): ActiveDraftMutationResult {
if (sessionId.isBlank()) {
return ActiveDraftMutationResult.Error("Identité de séance invalide.")
}
when (val active = loadActiveSessionDraft()) {
is ActiveDraftLoadResult.Loaded -> {
if (active.draft.sourceSessionId == sessionId) {
return ActiveDraftMutationResult.Saved
}
/*
* WHY: the application persists a singleton default draft, so
* database-row presence alone does not mean user work exists.
* CONTRACT: only the byte-for-byte default empty form may be
* replaced implicitly; partial raw input remains protected.
*/
if (
active.draft.exercises.isNotEmpty() ||
active.draft.sourceSessionId != null ||
active.draft.form != SessionDraftForm()
) {
return ActiveDraftMutationResult.Error(
"Une autre séance est déjà en cours ; reprenez-la ou supprimez-la explicitement.",
)
}
}
is ActiveDraftLoadResult.Error -> return ActiveDraftMutationResult.Error(active.message)
ActiveDraftLoadResult.None -> Unit
}
val detail = getSessionDetail(sessionId)
?: return ActiveDraftMutationResult.Error("Séance introuvable.")
if (detail.summary.sessionType != SessionType.MAX_TEST) {
return ActiveDraftMutationResult.Error("Seul un Test max peut être repris.")
}
val profiles = listExercises().associateBy { it.exerciseId }
val exercises = detail.exercises.map { item ->
val profile = profiles[item.exerciseId]
?: return ActiveDraftMutationResult.Error(
"Profil d'exercice introuvable : ${item.exerciseId}",
)
SessionExerciseDraft(
entryId = item.entryId,
exercise = profile,
equipmentId = item.equipmentId,
maxWeightKg = item.maxWeightKg,
sets = item.sets,
continuousDurationSeconds = item.continuousDurationSeconds,
speedKmh = item.speedKmh,
distanceKm = item.distanceKm,
)
}
return saveActiveSessionDraft(
ActiveSessionDraft(
exercises = exercises,
sessionType = SessionType.MAX_TEST,
sourceSessionId = sessionId,
),
)
}
@ -1554,10 +1635,13 @@ class TrainlogRepository(
} }
} }
fun buildMobileExportJson(): String { fun buildMobileExportJson(): String = buildMobileExport(1)
private fun buildMobileExport(version: Int): String {
require(version == 1 || version == 2)
val root = JSONObject() val root = JSONObject()
root.put("format", "trainlog-mobile-export") root.put("format", "trainlog-mobile-export")
root.put("version", 1) root.put("version", version)
root.put("generated_at", OffsetDateTime.now().toString()) root.put("generated_at", OffsetDateTime.now().toString())
val exerciseArray = JSONArray() val exerciseArray = JSONArray()
@ -1589,8 +1673,11 @@ class TrainlogRepository(
.put("session_type", sessions.getString(3)) .put("session_type", sessions.getString(3))
val sessionExercises = JSONArray() val sessionExercises = JSONArray()
db.rawQuery( db.rawQuery(
"SELECT se.id, e.exercise_id, e.name, se.recording_mode, se.tracking_mode, se.data_fields " + "SELECT se.id, e.exercise_id, e.name, se.recording_mode, se.tracking_mode, se.data_fields, " +
"se.entry_id,se.position,eq.equipment_id,mr.max_weight_kg " +
"FROM session_exercises AS se JOIN exercises AS e ON e.id = se.exercise_row_id " + "FROM session_exercises AS se JOIN exercises AS e ON e.id = se.exercise_row_id " +
"LEFT JOIN equipment AS eq ON eq.id=se.equipment_row_id " +
"LEFT JOIN max_results AS mr ON mr.session_exercise_row_id=se.id " +
"WHERE se.session_row_id = ? ORDER BY se.position ASC;", "WHERE se.session_row_id = ? ORDER BY se.position ASC;",
arrayOf(sessionRowId.toString()), arrayOf(sessionRowId.toString()),
).use { exerciseCursor -> ).use { exerciseCursor ->
@ -1606,7 +1693,20 @@ class TrainlogRepository(
.put("data_fields", exerciseCursor.getInt(5)) .put("data_fields", exerciseCursor.getInt(5))
.put("load_mode", "none") .put("load_mode", "none")
.put("rest_seconds", 0) .put("rest_seconds", 0)
if (recording == "continuous") { if (version == 2) {
item.put("entry_id", exerciseCursor.getString(6))
item.put("position", exerciseCursor.getInt(7))
if (exerciseCursor.isNull(8)) item.put("equipment_id", JSONObject.NULL)
else item.put("equipment_id", exerciseCursor.getString(8))
}
if (!exerciseCursor.isNull(9)) {
/* TRAINLOG_FORMAT_V1 is frozen and has no max
* result shape. Refuse instead of inventing 1x1. */
check(version == 2) {
"Un résultat max explicite exige l'export mobile V2."
}
item.put("max_weight_kg", exerciseCursor.getDouble(9))
} else if (recording == "continuous") {
db.query( db.query(
"continuous_activity", "continuous_activity",
arrayOf("duration_seconds", "speed_kmh", "distance_km"), arrayOf("duration_seconds", "speed_kmh", "distance_km"),
@ -1631,7 +1731,7 @@ class TrainlogRepository(
val sets = JSONArray() val sets = JSONArray()
db.query( db.query(
"performed_sets", "performed_sets",
arrayOf("reps", "duration_seconds"), arrayOf("reps", "duration_seconds", "weight_kg"),
"session_exercise_row_id = ?", "session_exercise_row_id = ?",
arrayOf(sessionExerciseRowId.toString()), arrayOf(sessionExerciseRowId.toString()),
null, null, "position ASC", null, null, "position ASC",
@ -1643,6 +1743,9 @@ class TrainlogRepository(
} else { } else {
set.put("duration_seconds", setCursor.getInt(1)) set.put("duration_seconds", setCursor.getInt(1))
} }
if (version == 2 && !setCursor.isNull(2)) {
set.put("weight_kg", setCursor.getDouble(2))
}
sets.put(set) sets.put(set)
} }
} }
@ -1691,44 +1794,7 @@ class TrainlogRepository(
* session occurrence carries its durable Android entry_id, ordering, * session occurrence carries its durable Android entry_id, ordering,
* equipment and actual load so repeated catalogue exercises round-trip. * equipment and actual load so repeated catalogue exercises round-trip.
*/ */
fun buildMobileExportV2Json(): String { fun buildMobileExportV2Json(): String = buildMobileExport(2)
val root = JSONObject(buildMobileExportJson())
root.put("version", 2)
val db = database.readableDatabase
val sessions = root.getJSONArray("sessions")
for (sessionIndex in 0 until sessions.length()) {
val session = sessions.getJSONObject(sessionIndex)
val items = session.getJSONArray("exercises")
db.rawQuery(
"SELECT se.id,se.entry_id,se.position,eq.equipment_id FROM session_exercises se " +
"JOIN sessions s ON s.id=se.session_row_id LEFT JOIN equipment eq ON eq.id=se.equipment_row_id " +
"WHERE s.session_id=? ORDER BY se.position ASC;",
arrayOf(session.getString("session_id")),
).use { entries ->
var itemIndex = 0
while (entries.moveToNext()) {
val item = items.getJSONObject(itemIndex++)
val rowId = entries.getLong(0)
item.put("entry_id", entries.getString(1))
item.put("position", entries.getInt(2))
if (entries.isNull(3)) item.put("equipment_id", JSONObject.NULL)
else item.put("equipment_id", entries.getString(3))
if (item.has("sets")) {
val sets = item.getJSONArray("sets")
db.query("performed_sets", arrayOf("weight_kg"),
"session_exercise_row_id=?", arrayOf(rowId.toString()), null, null, "position ASC").use { cursor ->
var setIndex = 0
while (cursor.moveToNext()) {
if (!cursor.isNull(0)) sets.getJSONObject(setIndex).put("weight_kg", cursor.getDouble(0))
setIndex++
}
}
}
}
}
}
return root.toString()
}
/** Apply the same V2 session artifact emitted by desktop, keyed by entry_id. */ /** Apply the same V2 session artifact emitted by desktop, keyed by entry_id. */
fun applyPcMobileExportV2Json(json: String): MobileSessionImportResult { fun applyPcMobileExportV2Json(json: String): MobileSessionImportResult {
@ -1740,6 +1806,7 @@ class TrainlogRepository(
val db = database.writableDatabase val db = database.writableDatabase
var sessionsAdded = 0 var sessionsAdded = 0
var sessionsSkipped = 0 var sessionsSkipped = 0
var sessionsUpdated = 0
var bodyObservationsAdded = 0 var bodyObservationsAdded = 0
var bodyObservationsSkipped = 0 var bodyObservationsSkipped = 0
return try { return try {
@ -1775,9 +1842,19 @@ class TrainlogRepository(
sessionsSkipped += 1 sessionsSkipped += 1
continue continue
} }
if (!pcResumedMaxUpdateIsSafe(db, existingRowId, session)) {
return MobileSessionImportResult.Invalid("Conflit de contenu pour la séance $sessionId") return MobileSessionImportResult.Invalid("Conflit de contenu pour la séance $sessionId")
} }
val rowId = run { /* Child replacement is inside this import transaction;
* entry identity/order may only be retained and appended. */
db.delete(
"session_exercises",
"session_row_id=?",
arrayOf(existingRowId.toString()),
)
sessionsUpdated += 1
}
val rowId = existingRowId ?: run {
val values = ContentValues().apply { put("session_id", sessionId); put("started_at", startedAt); put("session_type", type) } val values = ContentValues().apply { put("session_id", sessionId); put("started_at", startedAt); put("session_type", type) }
db.insertOrThrow("sessions", null, values) db.insertOrThrow("sessions", null, values)
} }
@ -1809,7 +1886,12 @@ class TrainlogRepository(
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId) if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
} }
val occurrence = db.insertOrThrow("session_exercises", null, values) val occurrence = db.insertOrThrow("session_exercises", null, values)
if (recording == "continuous") { if (entry.has("max_weight_kg")) {
db.insertOrThrow("max_results", null, ContentValues().apply {
put("session_exercise_row_id", occurrence)
put("max_weight_kg", entry.getDouble("max_weight_kg"))
})
} else if (recording == "continuous") {
val c = entry.optJSONObject("continuous") ?: return MobileSessionImportResult.Invalid("Activité continue manquante.") val c = entry.optJSONObject("continuous") ?: return MobileSessionImportResult.Invalid("Activité continue manquante.")
db.insertOrThrow("continuous_activity", null, ContentValues().apply { db.insertOrThrow("continuous_activity", null, ContentValues().apply {
put("session_exercise_row_id", occurrence); put("duration_seconds", c.optInt("duration_seconds", 0)) put("session_exercise_row_id", occurrence); put("duration_seconds", c.optInt("duration_seconds", 0))
@ -1827,7 +1909,7 @@ class TrainlogRepository(
} }
} }
} }
sessionsAdded += 1 if (existingRowId == null) sessionsAdded += 1
} }
val body = root.optJSONArray("body_observations") val body = root.optJSONArray("body_observations")
?: return MobileSessionImportResult.Invalid("Mesures corporelles manquantes.") ?: return MobileSessionImportResult.Invalid("Mesures corporelles manquantes.")
@ -1868,6 +1950,7 @@ class TrainlogRepository(
sessionsSkipped = sessionsSkipped, sessionsSkipped = sessionsSkipped,
bodyObservationsAdded = bodyObservationsAdded, bodyObservationsAdded = bodyObservationsAdded,
bodyObservationsSkipped = bodyObservationsSkipped, bodyObservationsSkipped = bodyObservationsSkipped,
sessionsUpdated = sessionsUpdated,
) )
} catch (_: Exception) { MobileSessionImportResult.DatabaseError } catch (_: Exception) { MobileSessionImportResult.DatabaseError
} finally { db.endTransaction() } } finally { db.endTransaction() }
@ -1920,7 +2003,12 @@ class TrainlogRepository(
val entry = entries.opt(entryIndex) as? JSONObject ?: return "Entrée de séance V2 invalide." val entry = entries.opt(entryIndex) as? JSONObject ?: return "Entrée de séance V2 invalide."
val recording = entry.value("recording_mode") val recording = entry.value("recording_mode")
val tracking = entry.value("tracking_mode") val tracking = entry.value("tracking_mode")
val expectedKeys = entryBaseKeys + if (recording == "continuous") setOf("continuous") else setOf("sets") val hasMax = entry.has("max_weight_kg")
val expectedKeys = entryBaseKeys + when {
hasMax -> setOf("max_weight_kg")
recording == "continuous" -> setOf("continuous")
else -> setOf("sets")
}
val entryId = entry.value("entry_id") val entryId = entry.value("entry_id")
val exerciseId = entry.value("exercise_id") val exerciseId = entry.value("exercise_id")
val positionValue = entry.value("position") val positionValue = entry.value("position")
@ -1931,6 +2019,11 @@ class TrainlogRepository(
!(entry.value("equipment_id") === JSONObject.NULL || entry.value("equipment_id").isNonemptyJsonString())) { !(entry.value("equipment_id") === JSONObject.NULL || entry.value("equipment_id").isNonemptyJsonString())) {
return "Entrée de séance V2 invalide." return "Entrée de séance V2 invalide."
} }
if (hasMax &&
(session.getString("session_type") != "max_test" ||
!entry.value("max_weight_kg").isPositiveJsonNumber())) {
return "Résultat max V2 invalide."
}
val catalogProfile = exerciseProfiles[exerciseId] val catalogProfile = exerciseProfiles[exerciseId]
?: return "Exercice de séance V2 absent du catalogue." ?: return "Exercice de séance V2 absent du catalogue."
val entryFields = entry.getInt("data_fields") val entryFields = entry.getInt("data_fields")
@ -1943,7 +2036,10 @@ class TrainlogRepository(
* rewrite an older occurrence snapshot. */ * rewrite an older occurrence snapshot. */
return "Profil historique V2 incompatible avec le catalogue." return "Profil historique V2 incompatible avec le catalogue."
} }
if (recording == "continuous") { if (hasMax) {
/* The exact-key check above excludes set/continuous
* shadows; max identity remains the exercise entry. */
} else if (recording == "continuous") {
val continuous = entry.value("continuous") as? JSONObject ?: return "Activité continue V2 invalide." val continuous = entry.value("continuous") as? JSONObject ?: return "Activité continue V2 invalide."
val allowed = setOf("duration_seconds", "speed_kmh", "distance_km") val allowed = setOf("duration_seconds", "speed_kmh", "distance_km")
val fields = (entry.value("data_fields") as Number).toInt() val fields = (entry.value("data_fields") as Number).toInt()
@ -2044,7 +2140,13 @@ class TrainlogRepository(
item.optString("recording_mode"), item.optString("tracking_mode"), item.optInt("data_fields", -1), item.optString("recording_mode"), item.optString("tracking_mode"), item.optInt("data_fields", -1),
if (item.isNull("equipment_id")) null else item.optString("equipment_id")) if (item.isNull("equipment_id")) null else item.optString("equipment_id"))
if (metadata[index] != expected) return false if (metadata[index] != expected) return false
if (item.optString("recording_mode") == "continuous") { if (item.has("max_weight_kg")) {
val current = db.rawQuery(
"SELECT max_weight_kg FROM max_results WHERE session_exercise_row_id=?",
arrayOf(rows[index].toString()),
).use { cursor -> if (cursor.moveToFirst()) cursor.getDouble(0) else null }
if (current != item.getDouble("max_weight_kg")) return false
} else if (item.optString("recording_mode") == "continuous") {
val value = item.optJSONObject("continuous") ?: return false val value = item.optJSONObject("continuous") ?: return false
val current = db.rawQuery("SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity WHERE session_exercise_row_id=?", val current = db.rawQuery("SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity WHERE session_exercise_row_id=?",
arrayOf(rows[index].toString())).use { cursor -> arrayOf(rows[index].toString())).use { cursor ->
@ -2068,6 +2170,43 @@ class TrainlogRepository(
return true return true
} }
private fun pcResumedMaxUpdateIsSafe(
db: SQLiteDatabase,
rowId: Long,
session: JSONObject,
): Boolean {
if (session.optString("session_type") != "max_test") return false
val headerMatches = db.rawQuery(
"SELECT started_at,session_type FROM sessions WHERE id=?",
arrayOf(rowId.toString()),
).use {
it.moveToFirst() && it.getString(0) == session.optString("started_at") &&
it.getString(1) == "max_test"
}
if (!headerMatches) return false
val current = mutableListOf<Triple<String, Int, String>>()
db.rawQuery(
"SELECT se.entry_id,se.position,e.exercise_id FROM session_exercises se " +
"JOIN exercises e ON e.id=se.exercise_row_id WHERE se.session_row_id=? " +
"ORDER BY se.position;",
arrayOf(rowId.toString()),
).use { cursor ->
while (cursor.moveToNext()) {
current += Triple(cursor.getString(0), cursor.getInt(1), cursor.getString(2))
}
}
val incoming = session.optJSONArray("exercises") ?: return false
if (incoming.length() < current.size) return false
return current.indices.all { index ->
val item = incoming.optJSONObject(index) ?: return@all false
current[index] == Triple(
item.optString("entry_id"),
item.optInt("position", -1),
item.optString("exercise_id"),
)
}
}
/** /**
* CONTRACT: this companion artifact is deliberately outside frozen mobile * CONTRACT: this companion artifact is deliberately outside frozen mobile
* export v1. Version 2 keys an occurrence by `(session_id, entry_id)`; * export v1. Version 2 keys an occurrence by `(session_id, entry_id)`;
@ -2553,7 +2692,9 @@ class TrainlogRepository(
se.recording_mode, se.recording_mode,
se.tracking_mode, se.tracking_mode,
se.data_fields, se.data_fields,
eq.display_name eq.equipment_id,
eq.display_name,
mr.max_weight_kg
FROM session_exercises AS se FROM session_exercises AS se
JOIN sessions AS s JOIN sessions AS s
ON s.id = se.session_row_id ON s.id = se.session_row_id
@ -2561,6 +2702,8 @@ class TrainlogRepository(
ON e.id = se.exercise_row_id ON e.id = se.exercise_row_id
LEFT JOIN equipment AS eq LEFT JOIN equipment AS eq
ON eq.id = se.equipment_row_id ON eq.id = se.equipment_row_id
LEFT JOIN max_results AS mr
ON mr.session_exercise_row_id = se.id
WHERE s.session_id = ? WHERE s.session_id = ?
ORDER BY se.position ASC; ORDER BY se.position ASC;
""".trimIndent(), """.trimIndent(),
@ -2598,9 +2741,24 @@ class TrainlogRepository(
val dataFields = val dataFields =
cursor.getInt(6) cursor.getInt(6)
val equipmentDisplayName = if (cursor.isNull(7)) null else cursor.getString(7) val equipmentId = if (cursor.isNull(7)) null else cursor.getString(7)
val equipmentDisplayName = if (cursor.isNull(8)) null else cursor.getString(8)
val maxWeightKg = if (cursor.isNull(9)) null else cursor.getDouble(9)
if ( if (maxWeightKg != null) {
exercises +=
SessionExerciseDetail(
entryId = entryId,
exerciseId = exerciseId,
exerciseName = name,
equipmentId = equipmentId,
equipmentDisplayName = equipmentDisplayName,
recordingMode = recording,
trackingMode = tracking,
dataFields = dataFields,
maxWeightKg = maxWeightKg,
)
} else if (
recording == recording ==
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
) { ) {
@ -2637,6 +2795,7 @@ class TrainlogRepository(
exerciseId = exerciseId, exerciseId = exerciseId,
exerciseName = exerciseName =
name, name,
equipmentId = equipmentId,
equipmentDisplayName = equipmentDisplayName, equipmentDisplayName = equipmentDisplayName,
recordingMode = recordingMode =
recording, recording,
@ -2728,6 +2887,7 @@ class TrainlogRepository(
exerciseId = exerciseId, exerciseId = exerciseId,
exerciseName = exerciseName =
name, name,
equipmentId = equipmentId,
equipmentDisplayName = equipmentDisplayName, equipmentDisplayName = equipmentDisplayName,
recordingMode = recordingMode =
recording, recording,
@ -2747,6 +2907,51 @@ class TrainlogRepository(
) )
} }
/**
* Return one newest explicit measured max per movement. Equipment is
* presentation context only and never participates in max identity.
*/
fun listLatestExerciseMaxima(): List<LatestExerciseMax> {
val output = mutableListOf<LatestExerciseMax>()
database.readableDatabase.rawQuery(
"""
SELECT e.exercise_id, e.name, mr.max_weight_kg, s.started_at,
eq.display_name
FROM max_results AS mr
JOIN session_exercises AS se ON se.id = mr.session_exercise_row_id
JOIN sessions AS s ON s.id = se.session_row_id
JOIN exercises AS e ON e.id = se.exercise_row_id
LEFT JOIN equipment AS eq ON eq.id = se.equipment_row_id
WHERE s.session_type = 'max_test'
AND NOT EXISTS (
SELECT 1
FROM max_results AS newer_mr
JOIN session_exercises AS newer_se
ON newer_se.id = newer_mr.session_exercise_row_id
JOIN sessions AS newer_s ON newer_s.id = newer_se.session_row_id
WHERE newer_se.exercise_row_id = se.exercise_row_id
AND newer_s.session_type = 'max_test'
AND (newer_s.started_at > s.started_at OR
(newer_s.started_at = s.started_at AND
newer_se.position > se.position))
)
ORDER BY e.name COLLATE NOCASE, e.exercise_id;
""".trimIndent(),
null,
).use { cursor ->
while (cursor.moveToNext()) {
output += LatestExerciseMax(
exerciseId = cursor.getString(0),
exerciseName = cursor.getString(1),
maxWeightKg = cursor.getDouble(2),
startedAt = cursor.getString(3),
equipmentDisplayName = if (cursor.isNull(4)) null else cursor.getString(4),
)
}
}
return output
}
fun setCompletedSessionEquipment( fun setCompletedSessionEquipment(
sessionId: String, sessionId: String,
entryId: String, entryId: String,
@ -2783,6 +2988,9 @@ class TrainlogRepository(
d.updated_at, d.updated_at,
d.selected_exercise_label, d.selected_exercise_label,
d.selected_equipment_id, d.selected_equipment_id,
d.weight_text,
d.max_weight_text,
d.source_session_id,
e.exercise_id, e.exercise_id,
e.name, e.name,
e.normalized_name, e.normalized_name,
@ -2800,15 +3008,15 @@ class TrainlogRepository(
null null
} else { } else {
val missingSelection = val missingSelection =
cursor.isNull(9) && cursor.isNull(12) &&
!cursor.isNull(7) !cursor.isNull(7)
val selected = val selected =
if (cursor.isNull(9)) { if (cursor.isNull(12)) {
null null
} else { } else {
exerciseProfileFromCursor( exerciseProfileFromCursor(
cursor, cursor,
9, 12,
) )
} }
@ -2820,12 +3028,15 @@ class TrainlogRepository(
form = SessionDraftForm( form = SessionDraftForm(
selectedExercise = selected, selectedExercise = selected,
selectedEquipmentId = if (cursor.isNull(8)) null else cursor.getString(8), selectedEquipmentId = if (cursor.isNull(8)) null else cursor.getString(8),
weightText = cursor.getString(9),
maxWeightText = cursor.getString(10),
setCountText = cursor.getString(1), setCountText = cursor.getString(1),
repsText = cursor.getString(2), repsText = cursor.getString(2),
durationText = cursor.getString(3), durationText = cursor.getString(3),
speedText = cursor.getString(4), speedText = cursor.getString(4),
distanceText = cursor.getString(5), distanceText = cursor.getString(5),
), ),
sourceSessionId = if (cursor.isNull(11)) null else cursor.getString(11),
updatedAt = updatedAt =
cursor.getString(6), cursor.getString(6),
warning = warning =
@ -2855,12 +3066,15 @@ class TrainlogRepository(
de.tracking_mode, de.tracking_mode,
de.data_fields, de.data_fields,
eq.equipment_id, eq.equipment_id,
de.entry_id de.entry_id,
mr.max_weight_kg
FROM draft_session_exercises AS de FROM draft_session_exercises AS de
JOIN exercises AS e JOIN exercises AS e
ON e.id = de.exercise_row_id ON e.id = de.exercise_row_id
LEFT JOIN equipment AS eq LEFT JOIN equipment AS eq
ON eq.id = de.equipment_row_id ON eq.id = de.equipment_row_id
LEFT JOIN draft_max_results AS mr
ON mr.draft_exercise_row_id = de.id
WHERE de.draft_id = ? WHERE de.draft_id = ?
ORDER BY de.position ASC; ORDER BY de.position ASC;
""".trimIndent(), """.trimIndent(),
@ -2888,8 +3102,17 @@ class TrainlogRepository(
) )
val equipmentId = if (cursor.isNull(7)) null else cursor.getString(7) val equipmentId = if (cursor.isNull(7)) null else cursor.getString(7)
val entryId = cursor.getString(8) val entryId = cursor.getString(8)
val maxWeightKg = if (cursor.isNull(9)) null else cursor.getDouble(9)
if ( if (maxWeightKg != null) {
exercises +=
SessionExerciseDraft(
entryId = entryId,
exercise = exercise,
equipmentId = equipmentId,
maxWeightKg = maxWeightKg,
)
} else if (
exercise.recordingMode == exercise.recordingMode ==
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
) { ) {
@ -2982,6 +3205,7 @@ class TrainlogRepository(
draft = ActiveSessionDraft( draft = ActiveSessionDraft(
exercises = exercises, exercises = exercises,
sessionType = header.sessionType, sessionType = header.sessionType,
sourceSessionId = header.sourceSessionId,
form = header.form, form = header.form,
updatedAt = header.updatedAt, updatedAt = header.updatedAt,
), ),
@ -3005,7 +3229,10 @@ class TrainlogRepository(
val values = val values =
ContentValues().apply { ContentValues().apply {
put("session_type", draft.sessionType.wireValue) put("session_type", draft.sessionType.wireValue)
putOptionalString("source_session_id", draft.sourceSessionId)
putOptionalString("selected_equipment_id", draft.form.selectedEquipmentId) putOptionalString("selected_equipment_id", draft.form.selectedEquipmentId)
put("weight_text", draft.form.weightText)
put("max_weight_text", draft.form.maxWeightText)
if (selectedRowId == null) { if (selectedRowId == null) {
putNull("selected_exercise_row_id") putNull("selected_exercise_row_id")
putNull("selected_exercise_label") putNull("selected_exercise_label")
@ -3091,7 +3318,16 @@ class TrainlogRepository(
exerciseValues, exerciseValues,
) )
if ( if (exerciseDraft.maxWeightKg != null) {
db.insertOrThrow(
"draft_max_results",
null,
ContentValues().apply {
put("draft_exercise_row_id", draftExerciseRowId)
put("max_weight_kg", exerciseDraft.maxWeightKg)
},
)
} else if (
exerciseDraft.exercise.recordingMode == exerciseDraft.exercise.recordingMode ==
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
) { ) {
@ -3154,24 +3390,43 @@ class TrainlogRepository(
private fun insertCompletedSession( private fun insertCompletedSession(
db: SQLiteDatabase, db: SQLiteDatabase,
draft: SessionDraft, draft: SessionDraft,
sourceSessionId: String? = null,
): String { ): String {
val sessionId = val sessionId: String
"se_" + UUID.randomUUID().toString() val sessionRowId: Long
/* Preserve the existing Android meaning: started_at is assigned when if (sourceSessionId == null) {
* the completed session is saved, not when its draft is first opened. */ sessionId = "se_" + UUID.randomUUID().toString()
val startedAt = OffsetDateTime.now().toString() /* Preserve the existing Android meaning: started_at is assigned
val sessionValues = * when a new completed session is saved. */
ContentValues().apply { val sessionValues = ContentValues().apply {
put("session_id", sessionId) put("session_id", sessionId)
put("started_at", startedAt) put("started_at", OffsetDateTime.now().toString())
put("session_type", draft.sessionType.wireValue) put("session_type", draft.sessionType.wireValue)
} }
val sessionRowId = sessionRowId = db.insertOrThrow("sessions", null, sessionValues)
db.insertOrThrow( } else {
"sessions", check(draft.sessionType == SessionType.MAX_TEST) {
null, "Seul un Test max peut remplacer une séance reprise."
sessionValues, }
sessionId = sourceSessionId
sessionRowId = db.rawQuery(
"SELECT id FROM sessions WHERE session_id=? AND session_type='max_test';",
arrayOf(sourceSessionId),
).use { cursor ->
check(cursor.moveToFirst()) { "Séance Test max source introuvable." }
cursor.getLong(0)
}
check(resumedDraftIdentityIsSafe(db, sessionRowId, draft)) {
"Une séance reprise ne peut supprimer, réordonner ou réaffecter ses entrées existantes."
}
/* INVARIANT: child replacement and draft deletion are in the
* caller's transaction; failure restores the completed baseline. */
db.delete(
"session_exercises",
"session_row_id=?",
arrayOf(sessionRowId.toString()),
) )
}
draft.exercises.forEachIndexed { draft.exercises.forEachIndexed {
exerciseIndex, exerciseIndex,
@ -3212,7 +3467,16 @@ class TrainlogRepository(
exerciseValues, exerciseValues,
) )
if ( if (exerciseDraft.maxWeightKg != null) {
db.insertOrThrow(
"max_results",
null,
ContentValues().apply {
put("session_exercise_row_id", sessionExerciseRowId)
put("max_weight_kg", exerciseDraft.maxWeightKg)
},
)
} else if (
exerciseDraft.exercise.recordingMode == exerciseDraft.exercise.recordingMode ==
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
) { ) {
@ -3261,6 +3525,31 @@ class TrainlogRepository(
return sessionId return sessionId
} }
private fun resumedDraftIdentityIsSafe(
db: SQLiteDatabase,
sessionRowId: Long,
draft: SessionDraft,
): Boolean {
val current = mutableListOf<Pair<String, String>>()
db.rawQuery(
"SELECT se.entry_id,e.exercise_id FROM session_exercises se " +
"JOIN exercises e ON e.id=se.exercise_row_id " +
"WHERE se.session_row_id=? ORDER BY se.position;",
arrayOf(sessionRowId.toString()),
).use { cursor ->
while (cursor.moveToNext()) {
current += cursor.getString(0) to cursor.getString(1)
}
}
if (draft.exercises.size < current.size) return false
return current.indices.all { index ->
draft.exercises[index].let { entry ->
entry.entryId == current[index].first &&
entry.exercise.exerciseId == current[index].second
}
}
}
private fun exerciseProfileFromCursor( private fun exerciseProfileFromCursor(
cursor: android.database.Cursor, cursor: android.database.Cursor,
offset: Int, offset: Int,
@ -3301,6 +3590,7 @@ class TrainlogRepository(
private data class ActiveDraftHeader( private data class ActiveDraftHeader(
val sessionType: SessionType, val sessionType: SessionType,
val form: SessionDraftForm, val form: SessionDraftForm,
val sourceSessionId: String?,
val updatedAt: String, val updatedAt: String,
val warning: String?, val warning: String?,
) )
@ -3312,7 +3602,21 @@ class TrainlogRepository(
private fun validateSessionExercise( private fun validateSessionExercise(
draft: SessionExerciseDraft, draft: SessionExerciseDraft,
sessionType: SessionType,
): Boolean { ): Boolean {
val maxWeight = draft.maxWeightKg
if (maxWeight != null) {
/* INVARIANT: max is a first-class result owned by the movement
* occurrence. No set or continuous payload shadows it. */
return sessionType == SessionType.MAX_TEST &&
maxWeight.isFinite() &&
maxWeight > 0.0 &&
draft.sets.isEmpty() &&
draft.continuousDurationSeconds == 0 &&
draft.speedKmh == null &&
draft.distanceKm == null
}
return when ( return when (
draft.exercise.recordingMode draft.exercise.recordingMode
) { ) {
@ -3469,7 +3773,7 @@ private class TrainlogDatabaseHelper(
appContext, appContext,
databaseName, databaseName,
null, null,
8, 9,
) { ) {
override fun onConfigure( override fun onConfigure(
db: SQLiteDatabase, db: SQLiteDatabase,
@ -3567,6 +3871,11 @@ private class TrainlogDatabaseHelper(
version = 8 version = 8
} }
if (version < 9 && newVersion >= 9) {
migrateExplicitMaxResultsToVersionNine(db)
version = 9
}
if (version != newVersion) { if (version != newVersion) {
error( error(
"Unsupported Android DB upgrade " + "Unsupported Android DB upgrade " +
@ -3866,6 +4175,18 @@ private class TrainlogDatabaseHelper(
); );
""".trimIndent() """.trimIndent()
) )
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS max_results(
session_exercise_row_id INTEGER PRIMARY KEY
REFERENCES session_exercises(id)
ON DELETE CASCADE,
max_weight_kg REAL NOT NULL
CHECK(max_weight_kg > 0.0)
);
""".trimIndent()
)
} }
@ -3949,6 +4270,9 @@ private class TrainlogDatabaseHelper(
ON DELETE SET NULL, ON DELETE SET NULL,
selected_exercise_label TEXT, selected_exercise_label TEXT,
selected_equipment_id TEXT, selected_equipment_id TEXT,
source_session_id TEXT,
weight_text TEXT NOT NULL DEFAULT '',
max_weight_text TEXT NOT NULL DEFAULT '',
set_count_text TEXT NOT NULL, set_count_text TEXT NOT NULL,
reps_text TEXT NOT NULL, reps_text TEXT NOT NULL,
duration_text TEXT NOT NULL, duration_text TEXT NOT NULL,
@ -4047,6 +4371,18 @@ private class TrainlogDatabaseHelper(
); );
""".trimIndent() """.trimIndent()
) )
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS draft_max_results(
draft_exercise_row_id INTEGER PRIMARY KEY
REFERENCES draft_session_exercises(id)
ON DELETE CASCADE,
max_weight_kg REAL NOT NULL
CHECK(max_weight_kg > 0.0)
);
""".trimIndent()
)
} }
private fun createEquipmentTables( private fun createEquipmentTables(
@ -4106,6 +4442,12 @@ private class TrainlogDatabaseHelper(
) { ) {
db.execSQL("PRAGMA defer_foreign_keys = ON;") db.execSQL("PRAGMA defer_foreign_keys = ON;")
/* Older chained upgrades call the current table creators while still
* below v9. Those provisional empty result tables must not retain FKs
* to the v7 names that are rebuilt immediately below. */
db.execSQL("DROP TABLE IF EXISTS draft_max_results;")
db.execSQL("DROP TABLE IF EXISTS max_results;")
db.execSQL("ALTER TABLE equipment_aliases RENAME TO equipment_aliases_v7;") db.execSQL("ALTER TABLE equipment_aliases RENAME TO equipment_aliases_v7;")
db.execSQL("ALTER TABLE exercise_equipment RENAME TO exercise_equipment_v7;") db.execSQL("ALTER TABLE exercise_equipment RENAME TO exercise_equipment_v7;")
db.execSQL("ALTER TABLE catalog_exercise_equipment RENAME TO catalog_exercise_equipment_v7;") db.execSQL("ALTER TABLE catalog_exercise_equipment RENAME TO catalog_exercise_equipment_v7;")
@ -4178,6 +4520,110 @@ private class TrainlogDatabaseHelper(
db.execSQL("DROP TABLE equipment_v7;") db.execSQL("DROP TABLE equipment_v7;")
} }
private fun migrateExplicitMaxResultsToVersionNine(
db: SQLiteDatabase,
) {
/*
* CONTRACT: v9 introduces an occurrence-owned max result without
* changing exercise profiles or TRAINLOG_FORMAT_V1. A legacy max-test
* entry is converted only when its sole source row is exactly one
* successful rep with a positive load. Multiple attempts and every
* other shape remain byte-for-byte represented by performed_sets.
*/
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS max_results(
session_exercise_row_id INTEGER PRIMARY KEY
REFERENCES session_exercises(id) ON DELETE CASCADE,
max_weight_kg REAL NOT NULL CHECK(max_weight_kg > 0.0)
);
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS draft_max_results(
draft_exercise_row_id INTEGER PRIMARY KEY
REFERENCES draft_session_exercises(id) ON DELETE CASCADE,
max_weight_kg REAL NOT NULL CHECK(max_weight_kg > 0.0)
);
""".trimIndent(),
)
if (!tableHasColumn(db, "active_session_draft", "weight_text")) {
db.execSQL(
"ALTER TABLE active_session_draft " +
"ADD COLUMN weight_text TEXT NOT NULL DEFAULT '';",
)
}
if (!tableHasColumn(db, "active_session_draft", "max_weight_text")) {
db.execSQL(
"ALTER TABLE active_session_draft " +
"ADD COLUMN max_weight_text TEXT NOT NULL DEFAULT '';",
)
}
if (!tableHasColumn(db, "active_session_draft", "source_session_id")) {
db.execSQL(
"ALTER TABLE active_session_draft ADD COLUMN source_session_id TEXT;",
)
}
db.execSQL(
"""
INSERT INTO max_results(session_exercise_row_id, max_weight_kg)
SELECT se.id, ps.weight_kg
FROM session_exercises AS se
JOIN sessions AS s ON s.id = se.session_row_id
JOIN performed_sets AS ps ON ps.session_exercise_row_id = se.id
WHERE s.session_type = 'max_test'
AND se.recording_mode = 'sets'
AND ps.reps = 1
AND ps.duration_seconds IS NULL
AND ps.weight_kg > 0.0
AND (SELECT COUNT(*) FROM performed_sets AS all_ps
WHERE all_ps.session_exercise_row_id = se.id) = 1;
""".trimIndent(),
)
db.execSQL(
"DELETE FROM performed_sets WHERE session_exercise_row_id " +
"IN (SELECT session_exercise_row_id FROM max_results);",
)
db.execSQL(
"""
INSERT INTO draft_max_results(draft_exercise_row_id, max_weight_kg)
SELECT de.id, ps.weight_kg
FROM draft_session_exercises AS de
JOIN active_session_draft AS d ON d.id = de.draft_id
JOIN draft_performed_sets AS ps ON ps.draft_exercise_row_id = de.id
WHERE d.session_type = 'max_test'
AND de.recording_mode = 'sets'
AND ps.reps = 1
AND ps.duration_seconds IS NULL
AND ps.weight_kg > 0.0
AND (SELECT COUNT(*) FROM draft_performed_sets AS all_ps
WHERE all_ps.draft_exercise_row_id = de.id) = 1;
""".trimIndent(),
)
db.execSQL(
"DELETE FROM draft_performed_sets WHERE draft_exercise_row_id " +
"IN (SELECT draft_exercise_row_id FROM draft_max_results);",
)
}
private fun tableHasColumn(
db: SQLiteDatabase,
table: String,
column: String,
): Boolean =
db.rawQuery("PRAGMA table_info($table);", null).use { cursor ->
var found = false
while (cursor.moveToNext()) {
if (cursor.getString(1) == column) {
found = true
break
}
}
found
}
private fun addEquipmentReferenceColumns( private fun addEquipmentReferenceColumns(
db: SQLiteDatabase, db: SQLiteDatabase,
) { ) {

View file

@ -30,6 +30,12 @@ data class SessionExerciseDraft(
val exercise: ExerciseProfile, val exercise: ExerciseProfile,
/** Stable canonical equipment ID selected for this occurrence, if any. */ /** Stable canonical equipment ID selected for this occurrence, if any. */
val equipmentId: String? = null, val equipmentId: String? = null,
/**
* CONTRACT: a measured maximum is an occurrence result, not a synthetic
* performed set. It is present only for an explicit MAX_TEST entry and is
* mutually exclusive with sets/continuous data.
*/
val maxWeightKg: Double? = null,
val sets: List<SessionSetDraft> = emptyList(), val sets: List<SessionSetDraft> = emptyList(),
val continuousDurationSeconds: Int = 0, val continuousDurationSeconds: Int = 0,
val speedKmh: Double? = null, val speedKmh: Double? = null,
@ -47,6 +53,8 @@ data class SessionDraftForm(
val editingExerciseIndex: Int? = null, val editingExerciseIndex: Int? = null,
val editingEntryId: String? = null, val editingEntryId: String? = null,
val selectedEquipmentId: String? = null, val selectedEquipmentId: String? = null,
/** Raw max input is durable so a French decimal fragment survives restart. */
val maxWeightText: String = "",
val setCountText: String = "3", val setCountText: String = "3",
val repsText: String = "3x10", val repsText: String = "3x10",
val weightText: String = "", val weightText: String = "",
@ -63,6 +71,8 @@ data class SessionDraftForm(
data class ActiveSessionDraft( data class ActiveSessionDraft(
val exercises: List<SessionExerciseDraft> = emptyList(), val exercises: List<SessionExerciseDraft> = emptyList(),
val sessionType: SessionType = SessionType.TRAINING, val sessionType: SessionType = SessionType.TRAINING,
/** Existing max_test session updated atomically on finalization, if any. */
val sourceSessionId: String? = null,
val form: SessionDraftForm = SessionDraftForm(), val form: SessionDraftForm = SessionDraftForm(),
val updatedAt: String = "", val updatedAt: String = "",
) )
@ -79,10 +89,13 @@ data class SessionExerciseDetail(
val entryId: String, val entryId: String,
val exerciseId: String, val exerciseId: String,
val exerciseName: String, val exerciseName: String,
val equipmentId: String? = null,
val equipmentDisplayName: String? = null, val equipmentDisplayName: String? = null,
val recordingMode: RecordingMode, val recordingMode: RecordingMode,
val trackingMode: TrackingMode, val trackingMode: TrackingMode,
val dataFields: Int, val dataFields: Int,
/** Explicit max result; null also represents a preserved legacy max entry. */
val maxWeightKg: Double? = null,
val sets: List<SessionSetDraft> = emptyList(), val sets: List<SessionSetDraft> = emptyList(),
val continuousDurationSeconds: Int = 0, val continuousDurationSeconds: Int = 0,
val speedKmh: Double? = null, val speedKmh: Double? = null,
@ -93,3 +106,11 @@ data class SessionDetail(
val summary: SessionSummary, val summary: SessionSummary,
val exercises: List<SessionExerciseDetail>, val exercises: List<SessionExerciseDetail>,
) )
data class LatestExerciseMax(
val exerciseId: String,
val exerciseName: String,
val maxWeightKg: Double,
val startedAt: String,
val equipmentDisplayName: String? = null,
)

View file

@ -19,6 +19,10 @@ fun HistoryScreen(
remember { remember {
repository.listSessions() repository.listSessions()
} }
val latestMaxima =
remember {
repository.listLatestExerciseMaxima()
}
TrainlogScreen( TrainlogScreen(
subtitle = "H I S T O R I Q U E" subtitle = "H I S T O R I Q U E"
@ -73,6 +77,28 @@ fun HistoryScreen(
} }
} }
} }
TrainlogFrame(
title = "DERNIERS MAX",
active = latestMaxima.isNotEmpty(),
) {
if (latestMaxima.isEmpty()) {
TrainlogInfo("Aucun max explicite enregistré.")
} else {
latestMaxima.forEach { max ->
val weight = "%.2f".format(java.util.Locale.FRANCE, max.maxWeightKg)
.trimEnd('0').trimEnd(',')
TrainlogInfo(
text = "${max.exerciseName} · $weight kg · ${formatStartedAt(max.startedAt).take(10)}",
color = colors.warning,
)
TrainlogInfo(
text = "Équipement : ${max.equipmentDisplayName ?: "aucun"}",
color = colors.muted,
)
}
}
}
} }
} }

View file

@ -6,6 +6,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseDataFields
import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDetail import com.labfytools.trainlog.model.SessionExerciseDetail
@ -18,6 +19,7 @@ fun SessionDetailScreen(
repository: TrainlogRepository, repository: TrainlogRepository,
sessionId: String?, sessionId: String?,
onBack: () -> Unit, onBack: () -> Unit,
onResumeMaxTest: () -> Unit,
) { ) {
val colors = val colors =
LocalTrainlogColors.current LocalTrainlogColors.current
@ -25,6 +27,7 @@ fun SessionDetailScreen(
var revision by remember(sessionId) { mutableStateOf(0) } var revision by remember(sessionId) { mutableStateOf(0) }
var editingEntryId by remember(sessionId) { mutableStateOf<String?>(null) } var editingEntryId by remember(sessionId) { mutableStateOf<String?>(null) }
var equipmentQuery by remember(sessionId) { mutableStateOf("") } var equipmentQuery by remember(sessionId) { mutableStateOf("") }
var resumeMessage by remember(sessionId) { mutableStateOf<String?>(null) }
val detail = val detail =
remember(sessionId, revision) { remember(sessionId, revision) {
@ -93,6 +96,21 @@ fun SessionDetailScreen(
TrainlogInfo( TrainlogInfo(
"${detail.summary.exerciseCount} exercice(s)" "${detail.summary.exerciseCount} exercice(s)"
) )
if (detail.summary.sessionType == SessionType.MAX_TEST) {
TrainlogAction(
label = "Reprendre ce Test max",
description = "Continuer la même séance en conservant son identifiant et sa date.",
accent = colors.success,
onClick = {
when (val result = repository.resumeMaxTestSession(detail.summary.sessionId)) {
ActiveDraftMutationResult.Saved -> onResumeMaxTest()
is ActiveDraftMutationResult.Error -> resumeMessage = result.message
}
},
)
resumeMessage?.let { TrainlogInfo(it, color = colors.error) }
}
} }
detail.exercises detail.exercises
@ -143,7 +161,14 @@ fun SessionDetailScreen(
onClick = { editingEntryId = exercise.entryId }, onClick = { editingEntryId = exercise.entryId },
) )
} }
if ( if (exercise.maxWeightKg != null) {
val rendered = "%.2f".format(java.util.Locale.FRANCE, exercise.maxWeightKg)
.trimEnd('0').trimEnd(',')
TrainlogInfo(
text = "Max : $rendered kg",
color = colors.warning,
)
} else if (
exercise.recordingMode == exercise.recordingMode ==
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
) { ) {

View file

@ -312,6 +312,7 @@ fun SessionScreen(
}, },
) )
if (currentDraft.sourceSessionId == null) {
TrainlogAction( TrainlogAction(
label = label =
"Retirer ${draft.exercise.name}", "Retirer ${draft.exercise.name}",
@ -337,6 +338,7 @@ fun SessionScreen(
} }
} }
} }
}
val editingExercise = val editingExercise =
currentDraft.form.selectedExercise currentDraft.form.selectedExercise
@ -344,10 +346,12 @@ fun SessionScreen(
if (editingExercise != null) { if (editingExercise != null) {
SessionExerciseForm( SessionExerciseForm(
key = key =
editingExercise.exerciseId, "${editingExercise.exerciseId}:${currentDraft.sessionType.wireValue}:" +
currentDraft.form.editingEntryId.orEmpty(),
repository = repository, repository = repository,
exercise = exercise =
editingExercise, editingExercise,
sessionType = currentDraft.sessionType,
initialForm = initialForm =
currentDraft.form, currentDraft.form,
onFormChanged = { onFormChanged = {
@ -688,6 +692,7 @@ private fun SessionExerciseForm(
key: String, key: String,
repository: TrainlogRepository, repository: TrainlogRepository,
exercise: ExerciseProfile, exercise: ExerciseProfile,
sessionType: SessionType,
initialForm: SessionDraftForm, initialForm: SessionDraftForm,
onFormChanged: (SessionDraftForm) -> Unit, onFormChanged: (SessionDraftForm) -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
@ -719,6 +724,11 @@ private fun SessionExerciseForm(
) )
} }
var maxWeightText by
remember(key) {
mutableStateOf(initialForm.maxWeightText)
}
var durationText by var durationText by
remember(key) { remember(key) {
mutableStateOf( mutableStateOf(
@ -760,10 +770,7 @@ private fun SessionExerciseForm(
"SAISIE — ${exercise.name}" "SAISIE — ${exercise.name}"
) { ) {
TrainlogInfo( TrainlogInfo(
text = text = if (sessionType == SessionType.MAX_TEST) "TEST MAX" else exerciseProfileLabel(exercise),
exerciseProfileLabel(
exercise
),
color = colors.accent, color = colors.accent,
) )
@ -787,7 +794,7 @@ private fun SessionExerciseForm(
customEquipmentName = "" customEquipmentName = ""
equipmentRevision += 1 equipmentRevision += 1
selectedEquipmentId = result.equipment.equipmentId selectedEquipmentId = result.equipment.equipmentId
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, selectedEquipmentId, weightText)) onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, selectedEquipmentId, weightText, maxWeightText))
} }
CreateEquipmentResult.Invalid -> error = "Donnez un nom de machine valide." CreateEquipmentResult.Invalid -> error = "Donnez un nom de machine valide."
CreateEquipmentResult.Conflict -> error = "Cette machine existe déjà." CreateEquipmentResult.Conflict -> error = "Cette machine existe déjà."
@ -803,7 +810,7 @@ private fun SessionExerciseForm(
accent = colors.success, accent = colors.success,
onClick = { onClick = {
selectedEquipmentId = null selectedEquipmentId = null
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, null, weightText)) onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, null, weightText, maxWeightText))
}, },
) )
} }
@ -814,12 +821,32 @@ private fun SessionExerciseForm(
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted, accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted,
onClick = { onClick = {
selectedEquipmentId = equipment.equipmentId selectedEquipmentId = equipment.equipmentId
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText)) onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText))
}, },
) )
} }
if ( if (sessionType == SessionType.MAX_TEST) {
SessionNumberField(
label = "Poids max (kg)",
value = maxWeightText,
onValueChange = {
maxWeightText = it
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId,
weightText, it,
)
)
},
)
TrainlogInfo(
text = "Valeur strictement positive, virgule française acceptée.",
color = colors.muted,
)
} else if (
exercise.recordingMode == exercise.recordingMode ==
RecordingMode.SETS RecordingMode.SETS
) { ) {
@ -1021,6 +1048,7 @@ private fun SessionExerciseForm(
buildSessionExerciseDraft( buildSessionExerciseDraft(
exercise = exercise =
exercise, exercise,
sessionType = sessionType,
setCountText = setCountText =
setCountText, setCountText,
repsText = repsText =
@ -1033,12 +1061,17 @@ private fun SessionExerciseForm(
distanceText, distanceText,
equipmentId = selectedEquipmentId, equipmentId = selectedEquipmentId,
weightText = weightText, weightText = weightText,
maxWeightText = maxWeightText,
entryId = initialForm.editingEntryId, entryId = initialForm.editingEntryId,
) )
if (draft == null) { if (draft == null) {
error = error =
if (sessionType == SessionType.MAX_TEST) {
"Saisissez un poids max strictement positif (ex. 100 ou 86,5)."
} else {
"Valeurs invalides." "Valeurs invalides."
}
} else { } else {
onAdd(draft) onAdd(draft)
} }
@ -1077,6 +1110,7 @@ private fun currentForm(
distanceText: String, distanceText: String,
equipmentId: String? = null, equipmentId: String? = null,
weightText: String = "", weightText: String = "",
maxWeightText: String = "",
): SessionDraftForm = ): SessionDraftForm =
SessionDraftForm( SessionDraftForm(
selectedExercise = exercise, selectedExercise = exercise,
@ -1084,6 +1118,7 @@ private fun currentForm(
setCountText = setCountText, setCountText = setCountText,
repsText = repsText, repsText = repsText,
weightText = weightText, weightText = weightText,
maxWeightText = maxWeightText,
durationText = durationText, durationText = durationText,
speedText = speedText, speedText = speedText,
distanceText = distanceText, distanceText = distanceText,
@ -1259,6 +1294,7 @@ private fun parseRepSequence(
private fun buildSessionExerciseDraft( private fun buildSessionExerciseDraft(
exercise: ExerciseProfile, exercise: ExerciseProfile,
sessionType: SessionType,
setCountText: String, setCountText: String,
repsText: String, repsText: String,
durationText: String, durationText: String,
@ -1266,8 +1302,22 @@ private fun buildSessionExerciseDraft(
distanceText: String, distanceText: String,
equipmentId: String? = null, equipmentId: String? = null,
weightText: String = "", weightText: String = "",
maxWeightText: String = "",
entryId: String? = null, entryId: String? = null,
): SessionExerciseDraft? { ): SessionExerciseDraft? {
if (sessionType == SessionType.MAX_TEST) {
val maxWeight = maxWeightText.trim().replace(',', '.').toDoubleOrNull()
if (maxWeight == null || !maxWeight.isFinite() || maxWeight <= 0.0) {
return null
}
return SessionExerciseDraft(
entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(),
exercise = exercise,
equipmentId = equipmentId,
maxWeightKg = maxWeight,
)
}
return if ( return if (
exercise.recordingMode == exercise.recordingMode ==
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
@ -1404,6 +1454,7 @@ private fun formForExistingExercise(
editingExerciseIndex = index, editingExerciseIndex = index,
editingEntryId = draft.entryId, editingEntryId = draft.entryId,
selectedEquipmentId = draft.equipmentId, selectedEquipmentId = draft.equipmentId,
maxWeightText = draft.maxWeightKg?.let(::formatMaxWeight).orEmpty(),
setCountText = draft.sets.size.toString(), setCountText = draft.sets.size.toString(),
repsText = if (draft.exercise.trackingMode == TrackingMode.REPS) { repsText = if (draft.exercise.trackingMode == TrackingMode.REPS) {
draft.sets.joinToString(",") { it.reps.toString() } draft.sets.joinToString(",") { it.reps.toString() }
@ -1440,7 +1491,9 @@ private fun parseWeightSequence(text: String, count: Int): List<Double?>? {
private fun draftSummary( private fun draftSummary(
draft: SessionExerciseDraft, draft: SessionExerciseDraft,
): String { ): String {
return if ( return if (draft.maxWeightKg != null) {
"${draft.exercise.name} · Max : ${formatMaxWeight(draft.maxWeightKg)} kg"
} else if (
draft.exercise.recordingMode == draft.exercise.recordingMode ==
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
) { ) {
@ -1509,6 +1562,12 @@ private fun draftSummary(
} }
} }
private fun formatMaxWeight(value: Double): String =
java.math.BigDecimal.valueOf(value)
.stripTrailingZeros()
.toPlainString()
.replace('.', ',')
private fun exerciseProfileLabel( private fun exerciseProfileLabel(
exercise: ExerciseProfile, exercise: ExerciseProfile,
): String { ): String {

View file

@ -285,6 +285,10 @@ fun TrainlogApp(
screen = screen =
TrainlogScreenId.HISTORY TrainlogScreenId.HISTORY
}, },
onResumeMaxTest = {
draftRevision += 1
screen = TrainlogScreenId.SESSION
},
) )
TrainlogScreenId.SYNC -> TrainlogScreenId.SYNC ->

View file

@ -1383,7 +1383,7 @@ class TrainlogRepositoryDraftTest {
).use { db -> ).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor -> db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())
assertEquals(8, cursor.getInt(0)) assertEquals(9, cursor.getInt(0))
} }
db.rawQuery( db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -1510,7 +1510,7 @@ class TrainlogRepositoryDraftTest {
).use { db -> ).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor -> db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())
assertEquals(8, cursor.getInt(0)) assertEquals(9, cursor.getInt(0))
} }
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())
@ -1522,6 +1522,188 @@ class TrainlogRepositoryDraftTest {
} }
} }
@Test
fun explicitMaxDraftEditsReopensFinalizesAndKeepsMachineContextPerMovement() {
val first = openRepository()
val pecFly = createExercise(first, "Pec Fly", RecordingMode.SETS, TrackingMode.REPS)
val rearDelt = createExercise(first, "Rear Delt Fly", RecordingMode.SETS, TrackingMode.REPS)
val pecEntry = SessionExerciseDraft(
entryId = "sxe_max_pec",
exercise = pecFly,
equipmentId = "rear_delt_pec_fly",
maxWeightKg = 100.0,
)
val rearEntry = SessionExerciseDraft(
entryId = "sxe_max_rear",
exercise = rearDelt,
equipmentId = "rear_delt_pec_fly",
maxWeightKg = 86.0,
)
val draft = ActiveSessionDraft(
exercises = listOf(pecEntry, rearEntry),
sessionType = SessionType.MAX_TEST,
form = SessionDraftForm(
selectedExercise = rearDelt,
selectedEquipmentId = "rear_delt_pec_fly",
maxWeightText = "86,",
),
)
assertEquals(ActiveDraftMutationResult.Saved, first.saveActiveSessionDraft(draft))
first.close(); repository = null
val reopened = openRepository()
val restored = loadDraft(reopened)
assertEquals("86,", restored.form.maxWeightText)
assertEquals(listOf(100.0, 86.0), restored.exercises.map { it.maxWeightKg })
assertTrue(restored.exercises.all { it.sets.isEmpty() })
val edited = restored.copy(
exercises = restored.exercises.map {
if (it.entryId == "sxe_max_pec") it.copy(maxWeightKg = 101.5) else it
},
)
assertEquals(ActiveDraftMutationResult.Saved, reopened.saveActiveSessionDraft(edited))
assertTrue(reopened.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
val sessionId = reopened.listSessions().single().sessionId
val detail = reopened.getSessionDetail(sessionId)!!
assertEquals(SessionType.MAX_TEST, detail.summary.sessionType)
assertEquals(listOf("sxe_max_pec", "sxe_max_rear"), detail.exercises.map { it.entryId })
assertEquals(listOf(101.5, 86.0), detail.exercises.map { it.maxWeightKg })
assertTrue(detail.exercises.all { it.sets.isEmpty() })
assertEquals(2, reopened.listLatestExerciseMaxima().size)
try {
reopened.buildMobileExportJson()
fail("Frozen V1 must refuse explicit max data")
} catch (_: IllegalStateException) {
// Expected: V1 has no lossless explicit-max representation.
}
val exportedJson = reopened.buildMobileExportV2Json()
val exported = JSONObject(exportedJson)
.getJSONArray("sessions").getJSONObject(0)
.getJSONArray("exercises")
assertEquals(101.5, exported.getJSONObject(0).getDouble("max_weight_kg"), 0.0)
assertEquals(86.0, exported.getJSONObject(1).getDouble("max_weight_kg"), 0.0)
assertFalse(exported.getJSONObject(0).has("sets"))
assertFalse(exported.getJSONObject(1).has("sets"))
val replay = reopened.applyPcMobileExportV2Json(exportedJson)
assertTrue(replay is MobileSessionImportResult.Applied)
assertEquals(1, (replay as MobileSessionImportResult.Applied).sessionsSkipped)
assertEquals(1, reopened.listSessions().size)
/* The UI creates this harmless singleton before history is opened. */
assertEquals(
ActiveDraftMutationResult.Saved,
reopened.saveActiveSessionDraft(ActiveSessionDraft()),
)
assertEquals(ActiveDraftMutationResult.Saved, reopened.resumeMaxTestSession(sessionId))
val resumed = loadDraft(reopened)
assertEquals(sessionId, resumed.sourceSessionId)
assertEquals(listOf("sxe_max_pec", "sxe_max_rear"), resumed.exercises.map { it.entryId })
assertEquals(
ActiveDraftMutationResult.Saved,
reopened.saveActiveSessionDraft(
resumed.copy(
exercises = resumed.exercises.map {
if (it.entryId == "sxe_max_rear") it.copy(maxWeightKg = 87.0) else it
},
),
),
)
val resumedResult = reopened.finalizeActiveSessionDraft()
assertTrue(resumedResult is FinalizeActiveDraftResult.Saved)
assertEquals(sessionId, (resumedResult as FinalizeActiveDraftResult.Saved).sessionId)
assertEquals(1, reopened.listSessions().size)
assertEquals(87.0, reopened.getSessionDetail(sessionId)!!.exercises[1].maxWeightKg!!, 0.0)
val pcUpdate = JSONObject(reopened.buildMobileExportV2Json())
pcUpdate.getJSONArray("sessions").getJSONObject(0)
.getJSONArray("exercises").getJSONObject(1)
.put("max_weight_kg", 88.0)
val appliedUpdate = reopened.applyPcMobileExportV2Json(pcUpdate.toString())
assertTrue(appliedUpdate is MobileSessionImportResult.Applied)
assertEquals(1, (appliedUpdate as MobileSessionImportResult.Applied).sessionsUpdated)
assertEquals(88.0, reopened.getSessionDetail(sessionId)!!.exercises[1].maxWeightKg!!, 0.0)
assertEquals(ActiveDraftMutationResult.Saved, reopened.resumeMaxTestSession(sessionId))
val unsafeRemoval = loadDraft(reopened).let { active ->
active.copy(exercises = active.exercises.dropLast(1))
}
assertEquals(ActiveDraftMutationResult.Saved, reopened.saveActiveSessionDraft(unsafeRemoval))
assertTrue(reopened.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.DatabaseError)
assertEquals(
listOf("sxe_max_pec", "sxe_max_rear"),
reopened.getSessionDetail(sessionId)!!.exercises.map { it.entryId },
)
assertEquals(sessionId, loadDraft(reopened).sourceSessionId)
}
@Test
fun versionNineConvertsOnlyUnambiguousLegacyMaxEntries() {
val initial = openRepository()
val unambiguous = createExercise(initial, "Max net", RecordingMode.SETS, TrackingMode.REPS)
val ambiguous = createExercise(initial, "Max ambigu", RecordingMode.SETS, TrackingMode.REPS)
initial.close(); repository = null
SQLiteDatabase.openDatabase(
context.getDatabasePath(databaseName).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE draft_max_results;")
db.execSQL("DROP TABLE max_results;")
db.execSQL("PRAGMA user_version = 8;")
db.execSQL(
"INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?);",
arrayOf("se_max_migration", "2031-02-03T08:15:00+01:00", "max_test"),
)
val sessionRow = db.rawQuery(
"SELECT id FROM sessions WHERE session_id='se_max_migration';", null,
).use { it.moveToFirst(); it.getLong(0) }
val rows = listOf(unambiguous to "sxe_unambiguous", ambiguous to "sxe_ambiguous").mapIndexed { index, (exercise, entryId) ->
val exerciseRow = db.rawQuery(
"SELECT id FROM exercises WHERE exercise_id=?;", arrayOf(exercise.exerciseId),
).use { it.moveToFirst(); it.getLong(0) }
db.execSQL(
"INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,entry_id) VALUES(?,?,?,?,?,?,?);",
arrayOf<Any>(sessionRow, exerciseRow, index, "sets", "reps", 0, entryId),
)
db.rawQuery("SELECT id FROM session_exercises WHERE entry_id=?;", arrayOf(entryId))
.use { it.moveToFirst(); it.getLong(0) }
}
db.execSQL(
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) VALUES(?,?,?,?);",
arrayOf<Any>(rows[0], 0, 1, 100.0),
)
db.execSQL(
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) VALUES(?,?,?,?);",
arrayOf<Any>(rows[1], 0, 1, 80.0),
)
db.execSQL(
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) VALUES(?,?,?,?);",
arrayOf<Any>(rows[1], 1, 1, 86.0),
)
}
val migrated = openRepository()
val details = migrated.getSessionDetail("se_max_migration")!!.exercises
assertEquals(100.0, details[0].maxWeightKg!!, 0.0)
assertTrue(details[0].sets.isEmpty())
assertEquals(null, details[1].maxWeightKg)
assertEquals(listOf(80.0, 86.0), details[1].sets.map { it.weightKg })
SQLiteDatabase.openDatabase(
context.getDatabasePath(databaseName).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
db.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) }
db.rawQuery("PRAGMA integrity_check;", null).use {
assertTrue(it.moveToFirst()); assertEquals("ok", it.getString(0))
}
}
}
private fun openRepository(): TrainlogRepository { private fun openRepository(): TrainlogRepository {
return TrainlogRepository(context, databaseName).also { repository = it } return TrainlogRepository(context, databaseName).also { repository = it }
} }

View file

@ -294,7 +294,7 @@ result.
Before applying the PC catalog or its V2 artifacts, Android applies Before applying the PC catalog or its V2 artifacts, Android applies
`trainlog-pc-equipment-definitions-v1.json`. Thus custom definitions are known `trainlog-pc-equipment-definitions-v1.json`. Thus custom definitions are known
before a received V2 association references them. Android schema v8 provides before a received V2 association references them. Android schema v9 provides
the non-destructive v7 -> v8 migration required for `load_semantics = none`. the non-destructive v7 -> v8 migration required for `load_semantics = none`.
A receipt belonging to another request is ignored as pending rather than A receipt belonging to another request is ignored as pending rather than
@ -403,9 +403,23 @@ History and detail visibly identify max-test sessions.
Selecting `Test max` is explicit metadata; Trainlog does not infer max tests Selecting `Test max` is explicit metadata; Trainlog does not infer max tests
from large repetition or duration values. from large repetition or duration values.
Android's current session form still records the exercise data fields it Its exercise form contains only the existing exercise search, optional
supports. Measured-max classification on the desktop uses only actual values machine/equipment selection, and `Poids max (kg)`. French decimal commas are
that were truly captured and synchronized. accepted; empty is distinct from zero and only a finite positive value can be
saved. Each saved line is immediately visible as `Exercice · Max : N kg`, can
be edited in place with the same `entry_id`, or cancelled without mutation.
Several movement results may be appended successively.
Schema v9 stores the result in `max_results` or `draft_max_results`, never in a
synthetic one-repetition set. Equipment remains occurrence context. History
also lists the newest explicit maximum for each `exercise_id`, with date and
equipment used.
A completed max-test detail exposes `Reprendre ce Test max`. The one durable
draft then records the source `session_id` and retains all existing occurrence
IDs, order, movement IDs and equipment. Finalization atomically replaces that
same completed session and may append new occurrences. The completed source is
left intact as the crash-safe baseline until finalization.
## 17. Audited limitations ## 17. Audited limitations
@ -414,7 +428,7 @@ frozen desktop/Python normalization contract uses NFC, Unicode whitespace
collapse and case folding without accent removal. Existing Marche/Leg press collapse and case folding without accent removal. Existing Marche/Leg press
data is unaffected, but changing this safely requires an explicit Android data is unaffected, but changing this safely requires an explicit Android
schema migration that recomputes every normalized key and handles newly exposed schema migration that recomputes every normalized key and handles newly exposed
collisions. It is not silently changed inside schema v8. collisions. It is not silently changed inside schema v9.
The bundled exercise/equipment relationship metadata is seeded and preserved, The bundled exercise/equipment relationship metadata is seeded and preserved,
including during exercise-identity reconciliation, but the current equipment including during exercise-identity reconciliation, but the current equipment
@ -430,7 +444,7 @@ occurrence references.
The PC-catalog V1 inbox validates required IDs, modes, names, and bounded field The PC-catalog V1 inbox validates required IDs, modes, names, and bounded field
masks, but unlike the newer mobile V2 and equipment-companion parsers it does masks, but unlike the newer mobile V2 and equipment-companion parsers it does
not reject every unknown root or item key. Tightening this published V1 reader not reject every unknown root or item key. Tightening this published V1 reader
requires a compatibility decision rather than an incidental schema-v8 change. requires a compatibility decision rather than an incidental schema-v9 change.
Android requires `data_fields = 0` for `SETS`, while the desktop model/API Android requires `data_fields = 0` for `SETS`, while the desktop model/API
currently accepts known supplemental bits on either recording mode. Supplied currently accepts known supplemental bits on either recording mode. Supplied

View file

@ -120,7 +120,7 @@ Continuous work is persisted separately from performed sets.
### Desktop ### Desktop
Desktop SQLite schema v8 is canonical long-term history. `session_exercises` Desktop SQLite schema v9 is canonical long-term history. `session_exercises`
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
occur more than once in one session without identity fusion. occur more than once in one session without identity fusion.
@ -132,13 +132,17 @@ sessions
session_exercises session_exercises
performed_sets performed_sets
continuous_activity continuous_activity
max_results
body_observations body_observations
custom_equipment custom_equipment
``` ```
### Android ### Android
Android has an independent local SQLite schema, currently v8. Android has an independent local SQLite schema, currently v9. Completed and
draft MAX values use one-to-one `max_results` and `draft_max_results` rows;
resuming a completed Test max records its stable source session in the one
durable draft.
It mirrors domain concepts needed for capture, but its schema version is not It mirrors domain concepts needed for capture, but its schema version is not
coupled to the desktop schema. coupled to the desktop schema.

View file

@ -14,8 +14,8 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V8=PASS DESKTOP_SCHEMA_V9=PASS
ANDROID_LOCAL_DATABASE_V8=PASS ANDROID_LOCAL_DATABASE_V9=PASS
ANDROID_SESSION_DRAFT_V1=PASS ANDROID_SESSION_DRAFT_V1=PASS
ANDROID_DRAFT_DURABLE=PASS ANDROID_DRAFT_DURABLE=PASS
ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS
@ -32,6 +32,10 @@ EXERCISE_RENAME_STABLE_ID=PASS
ANDROID_BANNER_PARITY_V1=PASS ANDROID_BANNER_PARITY_V1=PASS
ANDROID_INSTALL_ADB=PASS ANDROID_INSTALL_ADB=PASS
ANDROID_USER_DATA_PRESERVED=PASS ANDROID_USER_DATA_PRESERVED=PASS
ANDROID_MAX_V9_INSTALL_ADB=PASS
ANDROID_MAX_V9_REAL_DATA_MIGRATION=PASS
DESKTOP_MAX_V9_REAL_DATA_MIGRATION=PASS
REAL_DATABASE_APPLICATION=PASS
PROFILE_AWARE_EXERCISES=PASS PROFILE_AWARE_EXERCISES=PASS
CONTINUOUS_ACTIVITY=PASS CONTINUOUS_ACTIVITY=PASS
@ -50,10 +54,12 @@ MULTI_OCCURRENCE_SESSION_V2=PASS
EQUIPMENT_ASSOCIATIONS_V2=PASS EQUIPMENT_ASSOCIATIONS_V2=PASS
EQUIPMENT_DEFINITIONS_V1=PASS EQUIPMENT_DEFINITIONS_V1=PASS
EXERCISE_RECONCILIATION_V2=PASS EXERCISE_RECONCILIATION_V2=PASS
EXPLICIT_MAX_RESULTS_V1=PASS
MAX_TEST_RESUME_STABLE_ID=PASS
DESKTOP_TESTS=32/32 PASS DESKTOP_TESTS=34/34 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
HARDWARE_SYNC_VALIDATION=HISTORICAL_BASELINE_ONLY HARDWARE_SYNC_VALIDATION=PASS
``` ```
## Desktop ## Desktop
@ -61,9 +67,9 @@ HARDWARE_SYNC_VALIDATION=HISTORICAL_BASELINE_ONLY
Implemented: Implemented:
- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback); - C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback);
- SQLite schema v8, with stable ordered `session_exercises.entry_id`, - SQLite schema v9, with stable ordered `session_exercises.entry_id`,
occurrence-level equipment identity, and desktop-local custom-equipment occurrence-level equipment identity, and desktop-local custom-equipment
definitions; definitions, plus occurrence-owned `max_results`;
- direct session entry; - direct session entry;
- persisted session detail and editing; - persisted session detail and editing;
- exercise removal from a session through transactional child replacement; - exercise removal from a session through transactional child replacement;
@ -215,10 +221,10 @@ No mounted Android filesystem is required.
Desktop: Desktop:
```text ```text
32/32 Meson tests PASS for the current desktop schema v8 baseline 34/34 Meson tests PASS for the current desktop schema v9 baseline
frozen JSON validator PASS frozen JSON validator PASS
import-contract validator PASS import-contract validator PASS
ASan/UBSan 32/32 Meson tests PASS ASan/UBSan 34/34 Meson tests PASS
git diff --check PASS git diff --check PASS
``` ```
@ -257,6 +263,8 @@ ordering change was made by this corrective tranche.
MEASURED_MAX_V1=PASS MEASURED_MAX_V1=PASS
WORKING_LOAD_PERCENTAGES=PASS WORKING_LOAD_PERCENTAGES=PASS
ANDROID_MAX_TEST_SESSION=PASS ANDROID_MAX_TEST_SESSION=PASS
EXPLICIT_MAX_RESULTS_V1=PASS
MAX_TEST_RESUME_STABLE_ID=PASS
CURRENT_OPERATIONAL_CURSOR=REAL_DATA_BASELINE_V1 CURRENT_OPERATIONAL_CURSOR=REAL_DATA_BASELINE_V1
NEXT_FEATURE=GYM_CATALOG_V1 NEXT_FEATURE=GYM_CATALOG_V1
``` ```
@ -272,11 +280,22 @@ MEASURED_MAX_ONLY_FROM_MAX_TEST=PASS
WORKING_LOAD_PERCENTAGES=PASS WORKING_LOAD_PERCENTAGES=PASS
ASSISTANCE_DIRECTION_AWARE=PASS ASSISTANCE_DIRECTION_AWARE=PASS
ANDROID_MAX_TEST_SESSION=PASS ANDROID_MAX_TEST_SESSION=PASS
DESKTOP_TESTS=32/32 PASS EXPLICIT_MAX_RESULTS_V1=PASS
MAX_TEST_RESUME_STABLE_ID=PASS
DESKTOP_TESTS=34/34 PASS
``` ```
A measured maximum is derived only from explicit `max_test` sessions. Ordinary A measured maximum belongs to an exercise occurrence in an explicit `max_test`
training is never promoted implicitly. session. Schema v9 persists a positive `max_weight_kg` separately from sets;
equipment is optional context, so one physical machine may carry independent
Pec Fly and Rear Delt Fly results. Ordinary training is never promoted
implicitly.
The Android and TUI max forms request no set or repetition count. Android loads
a completed Test max without changing `session_id`, existing `entry_id` values,
order, exercise identity, or equipment context; finalization replaces that
session's children atomically, permits explicit value/equipment corrections,
and may append new entries.
The current measured result is the newest successful max test. The historical The current measured result is the newest successful max test. The historical
record compares max tests using the same load mode. record compares max tests using the same load mode.
@ -293,7 +312,7 @@ BODY_COMPOSITION_ESTIMATE=PASS
BODY_PROPORTION_RATIOS=PASS BODY_PROPORTION_RATIOS=PASS
BODY_SYMMETRY_ANALYTICS=PASS BODY_SYMMETRY_ANALYTICS=PASS
NO_ESTIMATE_PERSISTENCE=PASS NO_ESTIMATE_PERSISTENCE=PASS
DESKTOP_TESTS=32/32 PASS DESKTOP_TESTS=34/34 PASS
``` ```
Android remains capture-only for this feature. Android remains capture-only for this feature.

View file

@ -3,8 +3,8 @@
## 1. Status ## 1. Status
```text ```text
TRAINLOG_DATABASE_SCHEMA_VERSION=8 TRAINLOG_DATABASE_SCHEMA_VERSION=9
DATABASE_SCHEMA_V8=PASS DATABASE_SCHEMA_V9=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
``` ```
@ -23,7 +23,7 @@ PRAGMA user_version;
Current value: Current value:
```text ```text
8 9
``` ```
Supported historical databases are migrated explicitly through the implemented Supported historical databases are migrated explicitly through the implemented
@ -41,6 +41,12 @@ additive: it retains all historic occurrences and their `equipment_id` values.
Supplied definitions continue to be generated from `catalog/equipment-v1.json`; Supplied definitions continue to be generated from `catalog/equipment-v1.json`;
custom definitions exist only in the desktop database. custom definitions exist only in the desktop database.
Version 9 adds the one-to-one `max_results` table. The v8 → v9 migration
converts a legacy `max_test` occurrence only when it contains exactly one
performed set with `reps = 1`, no duration, and a positive weight. The stable
session, occurrence, exercise, position, and equipment identities are retained.
Multiple attempts and every other ambiguous shape remain as historical sets.
A schema fixture must represent the real historical structure. Rewriting only A schema fixture must represent the real historical structure. Rewriting only
`user_version` is not an acceptable migration test. `user_version` is not an acceptable migration test.
@ -188,6 +194,19 @@ distance_km nullable
Continuous activity never creates a fake performed set. Continuous activity never creates a fake performed set.
### `max_results`
One-to-one explicit result for an occurrence in a `max_test` session.
```text
session_exercise_row_id PRIMARY KEY, foreign key
max_weight_kg finite positive weight
```
The occurrence has no `performed_sets` or `continuous_activity` row. The
referenced exercise owns the performance identity; nullable
`session_exercises.equipment_id` remains contextual metadata.
### `body_observations` ### `body_observations`
```text ```text
@ -296,17 +315,21 @@ distance km
The Android SQLite database is independent. The Android SQLite database is independent.
Current Android-local version: **8**. The explicit migration chain adds the Current Android-local version: **9**. The explicit migration chain adds the
durable draft in v4, equipment references in v5, per-set load in v6, occurrence durable draft in v4, equipment references in v5, per-set load in v6, occurrence
identity/multi-occurrence support in v7, and the widened custom-equipment identity/multi-occurrence support in v7, and the widened custom-equipment
definition graph in v8. definition graph in v8. Version 9 adds completed/draft explicit max rows, raw
max and load form text, and the optional stable source session used to resume a
completed Test max.
| Table | Ownership | | Table | Ownership |
| --- | --- | | --- | --- |
| `active_session_draft` | Single `id = 1` row, session type, selected catalog row, raw form text, update time | | `active_session_draft` | Single `id = 1` row, session type, selected catalog row, raw form text including MAX, optional resumed source session, update time |
| `draft_session_exercises` | Ordered draft exercises and profile snapshots | | `draft_session_exercises` | Ordered draft exercises and profile snapshots |
| `draft_performed_sets` | Ordered heterogeneous repetition or duration actuals | | `draft_performed_sets` | Ordered heterogeneous repetition or duration actuals |
| `draft_continuous_activity` | Duration and configured speed/distance without synthetic sets | | `draft_continuous_activity` | Duration and configured speed/distance without synthetic sets |
| `draft_max_results` | Positive explicit max weight, one-to-one with a draft occurrence |
| `max_results` | Positive explicit max weight, one-to-one with a completed occurrence |
| `equipment`, `equipment_aliases` | Supplied and user-created definitions used by selectors and occurrence FKs | | `equipment`, `equipment_aliases` | Supplied and user-created definitions used by selectors and occurrence FKs |
| `exercise_equipment`, `catalog_exercise_equipment` | Persisted manifest relationship metadata retained across migrations and identity reconciliation | | `exercise_equipment`, `catalog_exercise_equipment` | Persisted manifest relationship metadata retained across migrations and identity reconciliation |
@ -317,11 +340,11 @@ both on failure. Repeating finalization after success cannot create another
completed session. Completed `started_at` semantics are unchanged by this repair. completed session. Completed `started_at` semantics are unchanged by this repair.
Migration tests cover historical v4 and v7 shapes rather than changing only Migration tests cover historical v4 and v7 shapes rather than changing only
`user_version`. The prior physical Samsung migration preserved every existing `user_version`. The physical Samsung and desktop v9 migrations preserved every
domain row, with successful integrity and foreign-key checks. Current existing domain row after fresh coherent backups, with successful integrity and
reconciliation validation additionally uses coherent Android and desktop v8 foreign-key checks. Reconciliation validation also uses coherent Android and
copies whose integrity and foreign keys are checked before and after import. desktop v9 copies before applying migrations to the real stores. SQLite files
SQLite files are never synchronization artifacts. are never synchronization artifacts.
The schemas deliberately differ where ownership differs. Desktop sessions own The schemas deliberately differ where ownership differs. Desktop sessions own
`ended_at`, notes, planned targets and session-specific load semantics; the `ended_at`, notes, planned targets and session-specific load semantics; the
@ -337,7 +360,7 @@ Audit limitation: Android persists supplied exercise/equipment relationship
tables, but the current session selector searches the complete equipment list tables, but the current session selector searches the complete equipment list
instead of filtering or ranking it through those relations. The tables are instead of filtering or ranking it through those relations. The tables are
retained because migration and identity reconciliation already preserve them; retained because migration and identity reconciliation already preserve them;
making them authoritative UI policy is future catalog work, not a v8 cleanup. making them authoritative UI policy is future catalog work, not a v9 cleanup.
Desktop and Android schema versions are not required to match. Desktop and Android schema versions are not required to match.
@ -356,21 +379,24 @@ Migration-specific regression coverage includes:
schema_v5_migration schema_v5_migration
schema_v7_migration schema_v7_migration
exercise_reconciliation exercise_reconciliation
max_results
max_sync
``` ```
The current normal desktop suite contains 32 tests. The current normal desktop suite contains 34 tests.
## 11. Measured-max derivation ## 11. Explicit and legacy measured maxima
Measured maxima require no schema change beyond the current desktop schema v8. Schema v9 persists a weight maximum in `max_results`, separate from
`performed_sets`. A new max occurrence therefore contains no repetition or set
The existing `sessions.session_type = max_test` classification plus actual count. Only `sessions.session_type = max_test` may own this row.
`performed_sets` are sufficient.
Exercise performance points carry the originating session type so the Exercise performance points carry the originating session type so the
measured-max layer can distinguish explicit tests from ordinary training. measured-max layer can distinguish explicit tests from ordinary training.
Rules: The performance reader also preserves the prior measured-max interpretation for
ambiguous legacy max-test sets that the migration deliberately did not convert.
Rules for those legacy rows remain:
```text ```text
training session training session
@ -388,12 +414,14 @@ max_test + no load
greatest successful reps/duration greatest successful reps/duration
``` ```
A zero-repetition failed attempt is not a successful measurement. A zero-repetition failed legacy attempt is not a successful measurement.
The current measured result is the newest successful max-test point. The record The current measured result is the newest successful max-test point. The record
is the best max-test point using the same load mode. is the best max-test point using the same load mode.
No extra maximum row is persisted; results are derived from canonical history. For explicit rows, `max_weight_kg` is the canonical result. Latest-max history
groups by `exercise_id`, then reports date and occurrence equipment context;
it never groups by machine.
## 12. Equipment and occurrence migration ## 12. Equipment and occurrence migration
@ -408,6 +436,10 @@ v8 migration. Existing occurrence links remain unchanged. They resolve through
the supplied manifest or the local custom table; unknown historic references the supplied manifest or the local custom table; unknown historic references
remain explicit rather than being discarded. remain explicit rather than being discarded.
Schema v9 adds explicit max results through the bounded v8 -> v9 conversion
described above. It never chooses among multiple one-repetition attempts and
never deletes an ambiguous source row.
Android schema v6 added nullable `weight_kg` to completed and durable draft Android schema v6 added nullable `weight_kg` to completed and durable draft
set rows. Schema v7 assigns stable `entry_id` values to completed and draft set rows. Schema v7 assigns stable `entry_id` values to completed and draft
occurrences. Actual per-set weights remain independent values, so heterogeneous occurrences. Actual per-set weights remain independent values, so heterogeneous
@ -417,9 +449,15 @@ Android schema v8 non-destructively migrates the v7 equipment reference graph
so custom definitions may use `load_semantics = none`. Historic completed so custom definitions may use `load_semantics = none`. Historic completed
occurrences, draft occurrences, and their equipment references remain intact. occurrences, draft occurrences, and their equipment references remain intact.
Android schema v9 applies the same bounded legacy conversion to completed and
durable-draft occurrences. A resumed max-test draft preserves its source
`session_id`; atomic finalization replaces that session's ordered children
instead of generating a second session.
## 13. Body analytics persistence rule ## 13. Body analytics persistence rule
Body analytics require no schema change beyond schema v8. Body analytics still require no schema change beyond schema v8; schema v9 does
not alter their storage.
Canonical persistence continues to contain only measurements actually entered Canonical persistence continues to contain only measurements actually entered
by the user. by the user.

View file

@ -236,9 +236,31 @@ Continuous activity must never be:
`session_type = max_test` is an explicit semantic boundary. `session_type = max_test` is an explicit semantic boundary.
Measured max v1 never equates an ordinary best set with a measured maximum. An explicit weight maximum is an occurrence-owned result:
For set-based exercises: ```text
exercise_id
entry_id
equipment_id optional
max_weight_kg > 0
position
```
It is mutually exclusive with `performed_sets` and `continuous_activity` for
that occurrence. No `sets = 1` or `reps = 1` value is stored. This result mode
does not change the frozen catalogue combinations: a catalogue exercise keeps
its existing recording/tracking profile, while the containing `max_test`
session selects the explicit MAX capture form.
`exercise_id` owns the movement result. `equipment_id` identifies only the
physical context, so two movements performed on the same combined machine have
independent maxima.
Measured max v1 never equates an ordinary best set with a measured maximum.
Legacy `max_test` sets that cannot be migrated unambiguously remain available
under their historical semantics.
For those retained legacy set-based exercises:
```text ```text
external external

View file

@ -14,19 +14,21 @@ GATE_1=PASS
GATE_2=PASS GATE_2=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V8=PASS DESKTOP_SCHEMA_V9=PASS
ANDROID_LOCAL_DATABASE_V8=PASS ANDROID_LOCAL_DATABASE_V9=PASS
DIRECT_MTP_TRANSPORT=PASS DIRECT_MTP_TRANSPORT=PASS
BIDIRECTIONAL_SYNC_V1=PASS BIDIRECTIONAL_SYNC_V1=PASS
VARIABLE_REPETITION_SETS=PASS VARIABLE_REPETITION_SETS=PASS
MEASURED_MAX_V1=PASS MEASURED_MAX_V1=PASS
EXPLICIT_MAX_RESULTS_V1=PASS
MAX_TEST_RESUME_STABLE_ID=PASS
BODY_ANALYTICS_V1=PASS BODY_ANALYTICS_V1=PASS
EXERCISE_EDIT_V1=PASS EXERCISE_EDIT_V1=PASS
ANDROID_BANNER_PARITY_V1=PASS ANDROID_BANNER_PARITY_V1=PASS
DESKTOP_TESTS=32/32 PASS DESKTOP_TESTS=34/34 PASS
TUI_NOTCURSES_V1=PASS TUI_NOTCURSES_V1=PASS
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
NOTCURSES_TRUECOLOR_THEME=PASS NOTCURSES_TRUECOLOR_THEME=PASS
@ -43,6 +45,7 @@ The current product baseline includes:
- session history and editing; - session history and editing;
- body measurements and body history; - body measurements and body history;
- measured-max sessions and measured-max analysis; - measured-max sessions and measured-max analysis;
- explicit max-weight capture without synthetic sets, with stable-ID continuation;
- desktop body analytics; - desktop body analytics;
- direct USB/MTP transport; - direct USB/MTP transport;
- bidirectional Android/PC synchronization; - bidirectional Android/PC synchronization;
@ -97,7 +100,7 @@ Expected baseline:
- first real training sessions; - first real training sessions;
- Android used for capture; - Android used for capture;
- Android -> PC synchronization after training; - Android -> PC synchronization after training;
- measured max recorded only through explicit `max_test` sessions; - measured max recorded only through explicit `max_test` results;
- no fictitious user data in the canonical databases. - no fictitious user data in the canonical databases.
Gate: Gate:

View file

@ -16,6 +16,7 @@ MULTI_OCCURRENCE_SESSION_V2=PASS
EQUIPMENT_ASSOCIATIONS_V2=PASS EQUIPMENT_ASSOCIATIONS_V2=PASS
EQUIPMENT_DEFINITIONS_V1=PASS EQUIPMENT_DEFINITIONS_V1=PASS
EXERCISE_RECONCILIATION_V2=PASS EXERCISE_RECONCILIATION_V2=PASS
EXPLICIT_MAX_RESULTS_V2=PASS
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
``` ```
@ -132,6 +133,7 @@ entry_id stable occurrence identity
position stable order within session position stable order within session
equipment_id optional canonical equipment identity equipment_id optional canonical equipment identity
weight_kg optional actual value on each set weight_kg optional actual value on each set
max_weight_kg optional explicit max-test result
``` ```
The desktop imports sessions first, preserving `entry_id` and their equipment, The desktop imports sessions first, preserving `entry_id` and their equipment,
@ -181,6 +183,19 @@ continuous
No synthetic set is created for continuous work. No synthetic set is created for continuous work.
An explicit weight result uses the mutually exclusive shape:
```text
max_weight_kg finite and > 0
```
It is valid only when the containing session has `session_type = max_test` and
the entry has neither `sets` nor `continuous`. Android, desktop import, desktop
export and the strict validator preserve `session_id`, `entry_id`,
`exercise_id`, `position`, optional `equipment_id`, and the weight. Frozen V1
is unchanged; Android refuses a V1 export containing explicit max data instead
of inventing a `1 × 1` set or dropping the result.
Android-local active-session drafts are excluded from this snapshot and remain Android-local active-session drafts are excluded from this snapshot and remain
local during synchronization. Only successful atomic finalization makes a draft local during synchronization. Only successful atomic finalization makes a draft
a completed exportable session. The v1 artifact has no draft fields or tables; a completed exportable session. The v1 artifact has no draft fields or tables;
@ -207,6 +222,7 @@ profile conflict rejection
heterogeneous performed-set preservation heterogeneous performed-set preservation
targetless schema-v5 import when no true target exists targetless schema-v5 import when no true target exists
continuous activity kept separate continuous activity kept separate
explicit max result kept separate from sets
``` ```
The importer never invents a uniform target merely to fit desktop persistence. The importer never invents a uniform target merely to fit desktop persistence.
@ -363,6 +379,14 @@ source artifact/direction, then records a concise source summary in the run
history. The conflicting persisted value remains preserved; resolution is an history. The conflicting persisted value remains preserved; resolution is an
explicit correction or reconciliation, not a side effect of synchronization. explicit correction or reconciliation, not a side effect of synchronization.
The sole bounded session exception is continuation of the same `max_test`:
`session_id` and `started_at` must match; every existing `entry_id`, movement,
position and ordering prefix must remain; existing values may be corrected and
new ordered entries may be appended. Removal, reorder, exercise rebinding,
session-type change, or an unrelated same-ID divergence still conflicts. This
rule permits an Android-resumed max test to update the canonical desktop and a
subsequent PC snapshot to update Android without duplicating the session.
## 12. Concurrency and request consumption ## 12. Concurrency and request consumption
Synchronization owns: Synchronization owns:
@ -485,12 +509,14 @@ receipt publication/readback PASS
multiple distinct Android request IDs PASS multiple distinct Android request IDs PASS
``` ```
The current reconciliation checkpoint pulled the real Android v8 database and The current reconciliation checkpoint first ran the production definition, V2
shared-storage artifacts through ADB for read-only inspection, then ran the mobile, association, body and outbound exporters on coherent Android and
production definition, V2 mobile, association, body and outbound exporters on a desktop copies. The explicit-max migration was then applied to the real stores:
coherent desktop v8 copy. The second inbound/outbound replay was stable and both the identified session retained all stable identities, converted eight
SQLite integrity checks passed. This is real-data importer/exporter evidence, unambiguous rows, retained its continuous warm-up, and passed integrity and
not a claim that the current libmtp transport ran inside the sandbox. foreign-key checks. Two hardware bidirectional libmtp runs imported no duplicate
session, exercise, measurement or equipment; both Android and PC V2 artifacts
retained the eight explicit results without synthetic sets.
The PC-to-Android idempotence regression additionally feeds artifacts from all The PC-to-Android idempotence regression additionally feeds artifacts from all
four production PC exporters into the production Android repository importers. four production PC exporters into the production Android repository importers.

View file

@ -96,6 +96,8 @@ sync_direction
sync_history sync_history
sync_screen_action sync_screen_action
measured_max measured_max
max_results
max_sync
body_analytics body_analytics
terminal_input_event_type_policy terminal_input_event_type_policy
``` ```
@ -103,7 +105,7 @@ terminal_input_event_type_policy
Validated current suite: Validated current suite:
```text ```text
32/32 Meson tests PASS 34/34 Meson tests PASS
``` ```
The desktop executable is additionally smoke-checked in isolated tmux PTYs at The desktop executable is additionally smoke-checked in isolated tmux PTYs at
@ -120,7 +122,9 @@ Notable regression coverage:
- continuous activity without fake sets; - continuous activity without fake sets;
- repetition shorthand/list/pyramid parsing; - repetition shorthand/list/pyramid parsing;
- direct v4 -> v7 database migration and v7 -> v8 custom-equipment migration; - direct v4 -> v7 database migration and v7 -> v8 custom-equipment migration;
- bounded v8 -> v9 explicit-max migration, including ambiguous-attempt preservation;
- heterogeneous mobile-set import; - heterogeneous mobile-set import;
- V2 explicit-max Android -> desktop -> Android replay and resumed same-session update;
- Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a - Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a
RELEASE event is consumed without creating a second navigation action. RELEASE event is consumed without creating a second navigation action.
- targetless mobile SETS persistence; - targetless mobile SETS persistence;
@ -315,7 +319,7 @@ Coverage proves:
Validated current normal suite: Validated current normal suite:
```text ```text
32/32 Meson tests PASS 34/34 Meson tests PASS
``` ```
## 12. Body analytics regression ## 12. Body analytics regression
@ -340,13 +344,13 @@ Coverage includes:
Validated current normal suite: Validated current normal suite:
```text ```text
32/32 Meson tests PASS 34/34 Meson tests PASS
``` ```
## 13. Android session draft v1 ## 13. Android session draft v1
Android schema v4 introduced one durable active draft; the current additive Android schema v4 introduced one durable active draft; the current additive
chain reaches schema v8 without clearing completed history or the draft. The chain reaches schema v9 without clearing completed history or the draft. The
current `testDebugUnitTest` suite and `assembleDebug` pass. Host coverage current `testDebugUnitTest` suite and `assembleDebug` pass. Host coverage
includes exercise includes exercise
shapes and raw partial text, fresh repository restore, remove/discard, atomic shapes and raw partial text, fresh repository restore, remove/discard, atomic
@ -354,6 +358,11 @@ finalization and repeated-finalize rejection, rollback, catalog reconciliation,
missing-selection recovery, explicit DB-open failure, historical migration, missing-selection recovery, explicit DB-open failure, historical migration,
equipment selection and occurrence identity. equipment selection and occurrence identity.
Schema-v9 host coverage additionally proves French raw max-text persistence,
explicit max creation/edit/finalization without sets, distinct movement values
on the same equipment, latest-per-exercise history, V2 replay, stable-ID resume
and bounded conversion that leaves multiple legacy attempts untouched.
```bash ```bash
cd android cd android
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test

View file

@ -327,11 +327,28 @@ meson test -C build --print-errorlogs
Validated current normal suite: Validated current normal suite:
```text ```text
32/32 Meson tests PASS 34/34 Meson tests PASS
``` ```
## 16. Measured max view ## 16. Measured max view
Creating or editing a `Test de max` session uses the existing exercise search
and optional equipment selector, then asks only for `Poids max (kg)`. It does
not prompt for sets or repetitions and persists the value in `max_results`.
The history detail for a max-test session is a compact selectable table:
```text
Exercice Machine Max
Pec Fly Rear Delt / Pec Fly 100 kg
Rear Delt Fly Rear Delt / Pec Fly 86 kg
```
Equipment is occurrence context. The rows remain distinct by movement and
stable `entry_id`; no machine-level maximum is calculated. A preserved
non-MAX entry, such as continuous warm-up, remains visible with `—` in the MAX
column.
From `3 Exercices`, the selected exercise exposes: From `3 Exercices`, the selected exercise exposes:
```text ```text

View file

@ -55,6 +55,7 @@ CREATE TABLE continuous_activity(
session_exercise_row_id INTEGER NOT NULL UNIQUE REFERENCES session_exercises(id) ON DELETE CASCADE, 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 duration_seconds INTEGER NOT NULL, speed_kmh REAL, distance_km REAL
); );
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY,max_weight_kg REAL NOT NULL);
CREATE TABLE body_observations( CREATE TABLE body_observations(
id INTEGER PRIMARY KEY, observation_id TEXT NOT NULL UNIQUE, observed_at TEXT NOT NULL, 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, session_row_id INTEGER, body_weight_kg REAL, neck_cm REAL, shoulders_cm REAL,
@ -66,7 +67,7 @@ CREATE TABLE custom_equipment(
equipment_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, label_name TEXT NOT NULL, equipment_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, label_name TEXT NOT NULL,
equipment_type TEXT NOT NULL, load_semantics TEXT NOT NULL equipment_type TEXT NOT NULL, load_semantics TEXT NOT NULL
); );
PRAGMA user_version=8; PRAGMA user_version=9;
""" """

118
tests/test_max_sync.py Normal file
View file

@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Explicit max V2 import/export and idempotence regression."""
import copy
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"
EXPORTER = ROOT / "tools/export_pc_mobile.py"
SCHEMA = """
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,normalized_name TEXT UNIQUE,tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER);
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,started_at TEXT,ended_at TEXT,session_type TEXT,notes TEXT);
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,session_row_id INTEGER,exercise_row_id INTEGER,recording_mode TEXT,data_fields INTEGER,position INTEGER,load_mode TEXT,rest_seconds INTEGER,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);
CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER UNIQUE,duration_seconds INTEGER,speed_kmh REAL,distance_km REAL);
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY,max_weight_kg REAL NOT NULL CHECK(max_weight_kg>0));
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,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);
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL);
PRAGMA user_version=9;
"""
def payload():
catalog = [
{"exercise_id": "ex_pec", "name": "Pec Fly", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0},
{"exercise_id": "ex_rear", "name": "Rear Delt Fly", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0},
]
entries = [
{"entry_id": "sxe_pec", "position": 0, "exercise_id": "ex_pec", "name": "Pec Fly", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0, "load_mode": "none", "rest_seconds": 0, "equipment_id": "rear_delt_pec_fly", "max_weight_kg": 100.0},
{"entry_id": "sxe_rear", "position": 1, "exercise_id": "ex_rear", "name": "Rear Delt Fly", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0, "load_mode": "none", "rest_seconds": 0, "equipment_id": "rear_delt_pec_fly", "max_weight_kg": 86.0},
]
return {
"format": "trainlog-mobile-export",
"version": 2,
"generated_at": "2031-02-03T09:00:00+01:00",
"exercises": catalog,
"sessions": [{"session_id": "se_max", "started_at": "2031-02-03T08:15:00+01:00", "session_type": "max_test", "exercises": entries}],
"body_observations": [],
}
def run(*arguments):
return subprocess.run(
[sys.executable, *map(str, arguments)],
text=True,
capture_output=True,
)
def main():
with tempfile.TemporaryDirectory(prefix="trainlog-max-sync-") as temp:
root = Path(temp)
database = root / "trainlog.db"
artifact = root / "mobile-v2.json"
exported = root / "pc-v2.json"
connection = sqlite3.connect(database)
connection.executescript(SCHEMA)
connection.close()
artifact.write_text(json.dumps(payload()), encoding="utf-8")
first = run(IMPORTER, artifact, "--database", database)
assert first.returncode == 0, first.stdout + first.stderr
assert "sessions_imported=1" in first.stdout
connection = sqlite3.connect(database)
rows = connection.execute(
"SELECT e.exercise_id,se.entry_id,se.equipment_id,mr.max_weight_kg "
"FROM max_results mr JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id ORDER BY se.position"
).fetchall()
assert rows == [
("ex_pec", "sxe_pec", "rear_delt_pec_fly", 100.0),
("ex_rear", "sxe_rear", "rear_delt_pec_fly", 86.0),
]
assert connection.execute("SELECT count(*) FROM performed_sets").fetchone()[0] == 0
connection.close()
second = run(IMPORTER, artifact, "--database", database)
assert second.returncode == 0, second.stdout + second.stderr
assert "sessions_skipped=1" in second.stdout
resumed = payload()
resumed["sessions"][0]["exercises"][0]["max_weight_kg"] = 101.5
artifact.write_text(json.dumps(resumed), encoding="utf-8")
update = run(IMPORTER, artifact, "--database", database)
assert update.returncode == 0, update.stdout + update.stderr
assert "sessions_reconciled=1" in update.stdout
assert sqlite3.connect(database).execute(
"SELECT max_weight_kg FROM max_results mr JOIN session_exercises se "
"ON se.id=mr.session_exercise_row_id WHERE se.entry_id='sxe_pec'"
).fetchone() == (101.5,)
result = run(EXPORTER, exported, "--database", database)
assert result.returncode == 0, result.stdout + result.stderr
round_trip = json.loads(exported.read_text(encoding="utf-8"))
values = round_trip["sessions"][0]["exercises"]
assert [entry["max_weight_kg"] for entry in values] == [101.5, 86.0]
assert all("sets" not in entry and "continuous" not in entry for entry in values)
invalid = copy.deepcopy(payload())
invalid["sessions"][0]["session_type"] = "training"
rejected_path = root / "invalid.json"
rejected_path.write_text(json.dumps(invalid), encoding="utf-8")
rejected = run(IMPORTER, rejected_path, "--database", database)
assert rejected.returncode != 0
assert "max_weight_kg exige" in rejected.stderr
assert sqlite3.connect(database).execute("SELECT count(*) FROM sessions").fetchone()[0] == 1
print("max sync: PASS")
if __name__ == "__main__":
main()

View file

@ -21,9 +21,10 @@ CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,started_at T
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,session_row_id INTEGER,exercise_row_id INTEGER,recording_mode TEXT,data_fields INTEGER,position INTEGER,load_mode TEXT,rest_seconds INTEGER,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position)); CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,session_row_id INTEGER,exercise_row_id INTEGER,recording_mode TEXT,data_fields INTEGER,position INTEGER,load_mode TEXT,rest_seconds INTEGER,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL); CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);
CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER UNIQUE,duration_seconds INTEGER,speed_kmh REAL,distance_km REAL); CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER UNIQUE,duration_seconds INTEGER,speed_kmh REAL,distance_km REAL);
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY,max_weight_kg REAL NOT NULL);
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,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); CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,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);
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL); CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL);
PRAGMA user_version=8; PRAGMA user_version=9;
""" """
def payload(): def payload():

View file

@ -27,8 +27,8 @@ def main():
args = parser.parse_args() args = parser.parse_args()
connection = sqlite3.connect(args.database) connection = sqlite3.connect(args.database)
try: try:
if connection.execute("PRAGMA user_version;").fetchone()[0] != 8: if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9):
raise ValueError("schema desktop v8 requis") raise ValueError("schema desktop v8 ou v9 requis")
known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute( known_equipment.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment")) "SELECT equipment_id FROM custom_equipment"))

View file

@ -23,8 +23,8 @@ def main():
connection = sqlite3.connect(args.database) connection = sqlite3.connect(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
if connection.execute("PRAGMA user_version").fetchone()[0] != 8: if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9):
raise ValueError("schema desktop v8 requis") raise ValueError("schema desktop v8 ou v9 requis")
equipment = [dict(row) for row in connection.execute( equipment = [dict(row) for row in connection.execute(
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics " "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment ORDER BY equipment_id")] "FROM custom_equipment ORDER BY equipment_id")]

View file

@ -64,7 +64,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC # CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current # catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database. # canonical desktop schema rather than accept a stale pre-v8 database.
if version != 8: if version not in (8, 9):
raise SystemExit( raise SystemExit(
"PC_CATALOG_EXPORT=FAIL " "PC_CATALOG_EXPORT=FAIL "
f"schema={version}" f"schema={version}"

View file

@ -30,8 +30,8 @@ def main():
con = sqlite3.connect(args.database) con = sqlite3.connect(args.database)
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
try: try:
if con.execute("PRAGMA user_version").fetchone()[0] != 8: if con.execute("PRAGMA user_version").fetchone()[0] != 9:
raise ValueError("schema desktop v8 requis") raise ValueError("schema desktop v9 requis")
known_equipment = supplied_equipment_ids() known_equipment = supplied_equipment_ids()
known_equipment.update(row[0] for row in con.execute( known_equipment.update(row[0] for row in con.execute(
"SELECT equipment_id FROM custom_equipment")) "SELECT equipment_id FROM custom_equipment"))
@ -45,7 +45,7 @@ def main():
payload["exercises"] = [] payload["exercises"] = []
# INVARIANT: tracking mode is catalogue metadata. v7 occurrences # INVARIANT: tracking mode is catalogue metadata. v7 occurrences
# retain their stable entry_id but do not duplicate that field. # retain their stable entry_id but do not duplicate that field.
sql = "SELECT se.id,se.entry_id,se.position,se.recording_mode,e.tracking_mode,se.data_fields,se.equipment_id,e.exercise_id,e.name FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position" sql = "SELECT se.id,se.entry_id,se.position,se.recording_mode,e.tracking_mode,se.data_fields,se.equipment_id,e.exercise_id,e.name,mr.max_weight_kg FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE se.session_row_id=? ORDER BY se.position"
for entry in con.execute(sql, (session["id"],)): for entry in con.execute(sql, (session["id"],)):
# CONTRACT: references remain in mobile-export v2 unchanged; # CONTRACT: references remain in mobile-export v2 unchanged;
# definitions-v1 travels first and makes custom IDs resolvable. # definitions-v1 travels first and makes custom IDs resolvable.
@ -60,7 +60,11 @@ def main():
"recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"], "recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"],
"data_fields": entry["data_fields"], "load_mode": "none", "rest_seconds": 0, "data_fields": entry["data_fields"], "load_mode": "none", "rest_seconds": 0,
"equipment_id": entry["equipment_id"]} "equipment_id": entry["equipment_id"]}
if entry["recording_mode"] == "continuous": if entry["max_weight_kg"] is not None:
# CONTRACT: explicit max is an occurrence result, never a
# synthetic one-repetition performed set.
item["max_weight_kg"] = entry["max_weight_kg"]
elif entry["recording_mode"] == "continuous":
activity = con.execute("SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity WHERE session_exercise_row_id=?", (entry["id"],)).fetchone() activity = con.execute("SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity WHERE session_exercise_row_id=?", (entry["id"],)).fetchone()
if activity is None: raise ValueError("activité continue absente") if activity is None: raise ValueError("activité continue absente")
item["continuous"] = {key: activity[key] for key in activity.keys() if activity[key] is not None} item["continuous"] = {key: activity[key] for key in activity.keys() if activity[key] is not None}

View file

@ -113,8 +113,8 @@ def main():
fail("clés extension équipement invalides") fail("clés extension équipement invalides")
connection = sqlite3.connect(args.database) connection = sqlite3.connect(args.database)
try: try:
if connection.execute("PRAGMA user_version;").fetchone()[0] != 8: if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9):
fail("schema desktop v8 requis") fail("schema desktop v8 ou v9 requis")
known = load_catalog(args.catalog) known = load_catalog(args.catalog)
known.update(row[0] for row in connection.execute( known.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment")) "SELECT equipment_id FROM custom_equipment"))

View file

@ -60,8 +60,8 @@ def main():
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog)) definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
connection = sqlite3.connect(args.database) connection = sqlite3.connect(args.database)
try: try:
if connection.execute("PRAGMA user_version").fetchone()[0] != 8: if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9):
fail("schema desktop v8 requis") fail("schema desktop v8 ou v9 requis")
imported = skipped = 0 imported = skipped = 0
# Validate every same-ID row before inserting any definition. # Validate every same-ID row before inserting any definition.
for definition in definitions: for definition in definitions:

View file

@ -54,7 +54,7 @@ SESSION_EXERCISE_KEYS = {
} }
V2_SESSION_EXERCISE_KEYS = SESSION_EXERCISE_KEYS | { V2_SESSION_EXERCISE_KEYS = SESSION_EXERCISE_KEYS | {
"entry_id", "position", "equipment_id" "entry_id", "position", "equipment_id", "max_weight_kg"
} }
BODY_BASE_KEYS = { BODY_BASE_KEYS = {
@ -405,13 +405,14 @@ def validate_session_exercise(
item, item,
label, label,
known_exercise_ids, known_exercise_ids,
session_type,
): ):
is_v2 = "entry_id" in item or "position" in item or "equipment_id" in item is_v2 = "entry_id" in item or "position" in item or "equipment_id" in item
require_exact_keys( require_exact_keys(
item, item,
V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS, V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS,
(V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS) (V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS)
- {"sets", "continuous"}, - {"sets", "continuous", "max_weight_kg"},
label, label,
) )
@ -469,6 +470,21 @@ def validate_session_exercise(
f"{label}: mobile export v1 exige rest_seconds=0" f"{label}: mobile export v1 exige rest_seconds=0"
) )
if "max_weight_kg" in item:
if not is_v2 or session_type != "max_test":
raise ImportFailure(
f"{label}: max_weight_kg exige mobile V2 et session max_test"
)
if "sets" in item or "continuous" in item:
raise ImportFailure(
f"{label}: max_weight_kg exclut sets et continuous"
)
require_positive_number(
item["max_weight_kg"],
f"{label}.max_weight_kg",
)
return
if recording_mode == "continuous": if recording_mode == "continuous":
if "sets" in item: if "sets" in item:
raise ImportFailure( raise ImportFailure(
@ -634,6 +650,7 @@ def validate_sessions(
exercise, exercise,
exercise_label, exercise_label,
known_exercise_ids, known_exercise_ids,
session["session_type"],
) )
equipment_id = exercise.get("equipment_id") equipment_id = exercise.get("equipment_id")
@ -738,11 +755,11 @@ def require_supported_schema(connection):
"PRAGMA user_version;" "PRAGMA user_version;"
).fetchone()[0] ).fetchone()[0]
# CONTRACT: desktop startup migrates canonical databases through v8; the # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# mobile-v2 tables used below retain their v7 shape in v8. # readable for legacy artifacts and are never made to fake that table.
if version not in (5, 6, 7, 8): if version not in (5, 6, 7, 8, 9):
raise ImportFailure( raise ImportFailure(
f"base desktop schema v5, v6, v7 ou v8 attendue, version trouvée: {version}" f"base desktop schema v5 à v9 attendue, version trouvée: {version}"
) )
@ -1244,6 +1261,46 @@ def import_continuous_session_exercise(
) )
def import_max_session_exercise(
connection,
session_row_id,
position,
item,
exercise_row,
):
"""Persist a V2 max without manufacturing a performed set."""
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if schema_version < 9:
raise ImportFailure(
"max_weight_kg exige le schéma desktop v9"
)
cursor = connection.execute(
"""
INSERT INTO session_exercises(
entry_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, equipment_id
) VALUES(?, ?, ?, ?, ?, ?, 'none', 0,
NULL, NULL, NULL, NULL, NULL, ?);
""",
(
item["entry_id"],
session_row_id,
exercise_row,
item["recording_mode"],
item["data_fields"],
position,
item.get("equipment_id"),
),
)
connection.execute(
"INSERT INTO max_results(session_exercise_row_id,max_weight_kg) "
"VALUES(?,?);",
(cursor.lastrowid, item["max_weight_kg"]),
)
def import_sessions( def import_sessions(
connection, connection,
payload, payload,
@ -1278,9 +1335,24 @@ def import_sessions(
(session_row_id,)).fetchall() (session_row_id,)).fetchall()
current = [(row[0], row[1]) for row in rows] current = [(row[0], row[1]) for row in rows]
legacy = all(value[0].startswith("sxe_legacy_") or value[0].startswith("sxe_v1_") for value in current) legacy = all(value[0].startswith("sxe_legacy_") or value[0].startswith("sxe_v1_") for value in current)
header = connection.execute(
"SELECT started_at,session_type FROM sessions WHERE id=?;",
(session_row_id,),
).fetchone()
resumable_max = (
header is not None
and tuple(header) == (session["started_at"], "max_test")
and session["session_type"] == "max_test"
and len(incoming) >= len(current)
and all(current[index] == incoming[index]
for index in range(len(current)))
)
# A v1 history may be upgraded only when exercise/order mapping is # A v1 history may be upgraded only when exercise/order mapping is
# unique. Any other identity disagreement is an explicit conflict. # unique. Any other identity disagreement is an explicit conflict.
if current != incoming and not (legacy and [x[1] for x in current] == [x[1] for x in incoming]): if current != incoming and not (
(legacy and [x[1] for x in current] == [x[1] for x in incoming])
or resumable_max
):
raise ImportFailure("conflit d'identités d'entrées pour " + session["session_id"]) raise ImportFailure("conflit d'identités d'entrées pour " + session["session_id"])
if not legacy and session_semantically_matches( if not legacy and session_semantically_matches(
connection, connection,
@ -1290,12 +1362,18 @@ def import_sessions(
): ):
report["sessions_skipped"] += 1 report["sessions_skipped"] += 1
continue continue
if not legacy: if not legacy and not resumable_max:
raise ImportFailure("conflit de contenu pour " + session["session_id"]) raise ImportFailure("conflit de contenu pour " + session["session_id"])
# CONTRACT: a resumed max_test may edit existing max values and
# append occurrences, but cannot remove/reorder/rebind any stable
# entry. This bounded replacement makes tomorrow's continuation
# idempotent without turning arbitrary session conflicts into wins.
# Explicit child deletion makes reconciliation safe even for old # Explicit child deletion makes reconciliation safe even for old
# databases which were created without enforced foreign keys. # databases which were created without enforced foreign keys.
connection.execute("DELETE FROM performed_sets WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,)) connection.execute("DELETE FROM performed_sets WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
connection.execute("DELETE FROM continuous_activity WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,)) connection.execute("DELETE FROM continuous_activity WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 9:
connection.execute("DELETE FROM max_results WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
connection.execute("DELETE FROM session_exercises WHERE session_row_id=?;", (session_row_id,)) connection.execute("DELETE FROM session_exercises WHERE session_row_id=?;", (session_row_id,))
report["sessions_reconciled"] += 1 report["sessions_reconciled"] += 1
else: else:
@ -1359,7 +1437,15 @@ def import_sessions(
desktop_id, desktop_id,
) )
if item["recording_mode"] == "continuous": if "max_weight_kg" in item:
import_max_session_exercise(
connection,
session_row_id,
position,
item,
row_id,
)
elif item["recording_mode"] == "continuous":
import_continuous_session_exercise( import_continuous_session_exercise(
connection, connection,
session_row_id, session_row_id,
@ -1402,7 +1488,15 @@ def session_semantically_matches(
item["recording_mode"], item["tracking_mode"], item["recording_mode"], item["tracking_mode"],
item["data_fields"], item.get("equipment_id")): item["data_fields"], item.get("equipment_id")):
return False return False
if item["recording_mode"] == "continuous": if "max_weight_kg" in item:
current = connection.execute(
"SELECT max_weight_kg FROM max_results "
"WHERE session_exercise_row_id=?",
(row[0],),
).fetchone()
if current is None or current[0] != item["max_weight_kg"]:
return False
elif item["recording_mode"] == "continuous":
current = connection.execute( current = connection.execute(
"SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity " "SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity "
"WHERE session_exercise_row_id=?", (row[0],)).fetchone() "WHERE session_exercise_row_id=?", (row[0],)).fetchone()
@ -1544,6 +1638,15 @@ def run_import(
connection connection
) )
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
has_explicit_max = any(
"max_weight_kg" in entry
for session in payload["sessions"]
for entry in session["exercises"]
)
if has_explicit_max and schema_version < 9:
raise ImportFailure("max_weight_kg exige le schéma desktop v9")
connection.execute( connection.execute(
"BEGIN IMMEDIATE;" "BEGIN IMMEDIATE;"
) )

View file

@ -1,10 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Validate Trainlog v1 JSON documents structurally and semantically.""" """Validate frozen Trainlog v1 and active mobile-export v2 documents."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import json
import math
import sys import sys
import unicodedata import unicodedata
from datetime import datetime from datetime import datetime
@ -29,7 +30,7 @@ INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid"
class TrainlogSemanticError(ValueError): class TrainlogSemanticError(ValueError):
"""Raised when structurally valid JSON violates Trainlog v1 semantics.""" """Raised when structurally valid JSON violates its format semantics."""
def load_json(path: Path) -> Any: def load_json(path: Path) -> Any:
@ -253,6 +254,15 @@ def validate_mobile_export_v2(document: Any) -> None:
raise TrainlogSemanticError("mobile export V2: duplicate entry or unknown exercise") raise TrainlogSemanticError("mobile export V2: duplicate entry or unknown exercise")
if isinstance(entry.get("position"), bool) or not isinstance(entry.get("position"), int) or entry["position"] < 0 or entry["position"] in positions: if isinstance(entry.get("position"), bool) or not isinstance(entry.get("position"), int) or entry["position"] < 0 or entry["position"] in positions:
raise TrainlogSemanticError("mobile export V2: invalid/duplicate entry position") raise TrainlogSemanticError("mobile export V2: invalid/duplicate entry position")
has_max = "max_weight_kg" in entry
has_sets = "sets" in entry
has_continuous = "continuous" in entry
if sum((has_max, has_sets, has_continuous)) != 1:
raise TrainlogSemanticError("mobile export V2: exactly one result shape required")
if has_max:
value = entry["max_weight_kg"]
if session.get("session_type") != "max_test" or isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)) or value <= 0:
raise TrainlogSemanticError("mobile export V2: invalid explicit max")
entry_ids.add(entry["entry_id"]); positions.add(entry["position"]) entry_ids.add(entry["entry_id"]); positions.add(entry["position"])
def structural_errors( def structural_errors(
validator: jsonschema.Draft202012Validator, validator: jsonschema.Draft202012Validator,
@ -347,7 +357,7 @@ def run_suite(validator: jsonschema.Draft202012Validator) -> int:
def parse_args(argv: list[str]) -> argparse.Namespace: def parse_args(argv: list[str]) -> argparse.Namespace:
"""Parse command-line arguments.""" """Parse command-line arguments."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Validate Trainlog v1 JSON structurally and semantically." description="Validate Trainlog v1 or mobile-export v2 JSON."
) )
parser.add_argument( parser.add_argument(
"paths", "paths",

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 8 #define TRAINLOG_DATABASE_SCHEMA_VERSION 9
typedef struct TrainlogDatabase TrainlogDatabase; typedef struct TrainlogDatabase TrainlogDatabase;
@ -188,6 +188,9 @@ typedef struct TrainlogPersistedExerciseDetail {
int has_target_weight; int has_target_weight;
double target_weight_kg; double target_weight_kg;
int has_max_weight;
double max_weight_kg;
int continuous_duration_seconds; int continuous_duration_seconds;
int has_continuous_speed; int has_continuous_speed;
double continuous_speed_kmh; double continuous_speed_kmh;
@ -271,9 +274,12 @@ typedef struct TrainlogExercisePerformancePoint {
TrainlogLoadMode load_mode; TrainlogLoadMode load_mode;
size_t actual_set_count; size_t actual_set_count;
int has_performance; int has_performance;
/* Explicit schema-v9 max: weight is the result; metric_value is internal. */
int has_explicit_max;
int metric_value; int metric_value;
int has_weight; int has_weight;
double weight_kg; double weight_kg;
char equipment_id[TRAINLOG_ID_MAX + 1U];
} TrainlogExercisePerformancePoint; } TrainlogExercisePerformancePoint;
/** /**
@ -305,6 +311,8 @@ typedef struct TrainlogEditableExerciseRecord {
char equipment_id[TRAINLOG_ID_MAX + 1U]; char equipment_id[TRAINLOG_ID_MAX + 1U];
char name[TRAINLOG_NAME_MAX + 1U]; char name[TRAINLOG_NAME_MAX + 1U];
TrainlogTrackingMode tracking_mode; TrainlogTrackingMode tracking_mode;
TrainlogRecordingMode recording_mode;
TrainlogExerciseDataFields data_fields;
TrainlogLoadMode load_mode; TrainlogLoadMode load_mode;
int rest_seconds; int rest_seconds;
int target_sets; int target_sets;
@ -312,6 +320,13 @@ typedef struct TrainlogEditableExerciseRecord {
int target_duration_seconds; int target_duration_seconds;
int has_target_weight; int has_target_weight;
double target_weight_kg; double target_weight_kg;
int has_max_weight;
double max_weight_kg;
int continuous_duration_seconds;
int has_continuous_speed;
double continuous_speed_kmh;
int has_continuous_distance;
double continuous_distance_km;
char notes[TRAINLOG_NOTE_MAX + 1U]; char notes[TRAINLOG_NOTE_MAX + 1U];
size_t set_offset; size_t set_offset;
size_t set_count; size_t set_count;

View file

@ -75,6 +75,13 @@ typedef struct TrainlogSessionExerciseInput {
bool target_has_weight; bool target_has_weight;
double target_weight_kg; double target_weight_kg;
/*
* CONTRACT: max weight is an occurrence-owned result for max_test only.
* When present it is mutually exclusive with sets and continuous data.
*/
bool has_max_weight;
double max_weight_kg;
int continuous_duration_seconds; int continuous_duration_seconds;
bool continuous_has_speed; bool continuous_has_speed;
double continuous_speed_kmh; double continuous_speed_kmh;

View file

@ -196,6 +196,15 @@ test(
test_session_type_schema, test_session_type_schema,
) )
test_max_results = executable(
'test_max_results',
'tests/test_max_results.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('max_results', test_max_results)
test_session_edit = executable( test_session_edit = executable(
'test_session_edit', 'test_session_edit',
'tests/test_session_edit.c', 'tests/test_session_edit.c',
@ -379,6 +388,12 @@ test(
args: [meson.project_source_root() / 'tests/test_mobile_import_multi_occurrence.py'], args: [meson.project_source_root() / 'tests/test_mobile_import_multi_occurrence.py'],
) )
test(
'max_sync',
python3_trainlog_tests,
args: [meson.project_source_root() / 'tests/test_max_sync.py'],
)
test( test(
'equipment_associations_exchange', 'equipment_associations_exchange',
python3_trainlog_tests, python3_trainlog_tests,

View file

@ -8,6 +8,7 @@
#include "trainlog/equipment_catalog.h" #include "trainlog/equipment_catalog.h"
#include "trainlog/id.h" #include "trainlog/id.h"
#include <math.h>
#include <stdint.h> #include <stdint.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@ -263,6 +264,32 @@ static const char *const MIGRATE_V7_TO_V8_SQL =
"load_semantics TEXT NOT NULL CHECK(load_semantics IN ('none','external','assistance'))" "load_semantics TEXT NOT NULL CHECK(load_semantics IN ('none','external','assistance'))"
");PRAGMA user_version = 8;COMMIT;"; ");PRAGMA user_version = 8;COMMIT;";
/*
* WHY: a measured maximum is not a performed set with invented cardinality.
* CONTRACT: conversion is limited to the only legacy shape whose successful
* result is unambiguous: one max_test set, one rep, positive weight. Multiple
* attempts and every other shape remain in performed_sets without data loss.
*/
static const char *const MIGRATE_V8_TO_V9_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE max_results ("
"session_exercise_row_id INTEGER PRIMARY KEY "
"REFERENCES session_exercises(id) ON DELETE CASCADE,"
"max_weight_kg REAL NOT NULL CHECK(max_weight_kg > 0.0)"
");"
"INSERT INTO max_results(session_exercise_row_id,max_weight_kg) "
"SELECT se.id,ps.weight_kg FROM session_exercises se "
"JOIN sessions s ON s.id=se.session_row_id "
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id "
"WHERE s.session_type='max_test' AND se.recording_mode='sets' "
"AND ps.reps=1 AND ps.duration_seconds IS NULL "
"AND ps.weight_kg>0.0 AND "
"(SELECT COUNT(*) FROM performed_sets all_ps "
" WHERE all_ps.session_exercise_row_id=se.id)=1;"
"DELETE FROM performed_sets WHERE session_exercise_row_id IN "
"(SELECT session_exercise_row_id FROM max_results);"
"PRAGMA user_version = 9;COMMIT;";
static const char *const MIGRATE_V1_TO_V3_SQL = static const char *const MIGRATE_V1_TO_V3_SQL =
"BEGIN IMMEDIATE;" "BEGIN IMMEDIATE;"
"ALTER TABLE sessions " "ALTER TABLE sessions "
@ -667,10 +694,18 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V7_TO_V8_SQL); status = execute_sql(database, MIGRATE_V7_TO_V8_SQL);
} }
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
}
} else if (version == 7) { } else if (version == 7) {
/* CONTRACT: v7 is the immediate historic schema and must open through /* CONTRACT: v7 is the immediate historic schema and must open through
* its lossless custom-equipment-table migration. */ * its lossless custom-equipment-table migration. */
status = execute_sql(database, MIGRATE_V7_TO_V8_SQL); status = execute_sql(database, MIGRATE_V7_TO_V8_SQL);
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
}
} else if (version == 8) {
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
} else { } else {
if (version == 1) { if (version == 1) {
status = status =
@ -795,6 +830,9 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V7_TO_V8_SQL); status = execute_sql(database, MIGRATE_V7_TO_V8_SQL);
} }
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
}
} }
if ( if (
@ -804,7 +842,7 @@ static TrainlogStatus initialize_or_validate_schema(
set_open_diagnostic( set_open_diagnostic(
output_diagnostic, output_diagnostic,
output_diagnostic_capacity, output_diagnostic_capacity,
version == 0 ? "create schema v8" : "migrate database to schema v8", version == 0 ? "create schema v9" : "migrate database to schema v9",
database->connection, database->connection,
SQLITE_ERROR SQLITE_ERROR
); );
@ -1814,7 +1852,21 @@ static TrainlogStatus insert_session_exercise(
return TRAINLOG_STATUS_INVALID_ARGUMENT; return TRAINLOG_STATUS_INVALID_ARGUMENT;
} }
if (input->recording_mode == if (input->has_max_weight) {
if (!isfinite(input->max_weight_kg) ||
input->max_weight_kg <= 0.0 ||
input->rest_seconds != 0 ||
input->target_sets != 0 ||
input->target_reps != 0 ||
input->target_duration_seconds != 0 ||
input->target_has_weight ||
input->set_count != 0U ||
input->continuous_duration_seconds != 0 ||
input->continuous_has_speed ||
input->continuous_has_distance) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
} else if (input->recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS) { TRAINLOG_RECORDING_CONTINUOUS) {
bool speed_required = bool speed_required =
(input->data_fields & (input->data_fields &
@ -2240,9 +2292,50 @@ static TrainlogStatus insert_performed_set(
: TRAINLOG_STATUS_DATABASE_ERROR; : TRAINLOG_STATUS_DATABASE_ERROR;
} }
static TrainlogStatus insert_max_result(
TrainlogDatabase *database,
sqlite3_int64 session_exercise_row_id,
double max_weight_kg
)
{
sqlite3_stmt *statement = NULL;
int rc;
if (database == NULL || database->connection == NULL ||
!isfinite(max_weight_kg) || max_weight_kg <= 0.0) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
rc = sqlite3_prepare_v2(
database->connection,
"INSERT INTO max_results(session_exercise_row_id,max_weight_kg) "
"VALUES(?1,?2);",
-1, &statement, NULL
);
if (rc == SQLITE_OK) {
rc = sqlite3_bind_int64(statement, 1, session_exercise_row_id);
}
if (rc == SQLITE_OK) {
rc = sqlite3_bind_double(statement, 2, max_weight_kg);
}
if (rc == SQLITE_OK) {
rc = sqlite3_step(statement);
}
if (rc != SQLITE_DONE) {
(void)sqlite3_finalize(statement);
return rc == SQLITE_CONSTRAINT
? TRAINLOG_STATUS_CONFLICT
: TRAINLOG_STATUS_DATABASE_ERROR;
}
return sqlite3_finalize(statement) == SQLITE_OK
? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus insert_session_children( static TrainlogStatus insert_session_children(
TrainlogDatabase *database, TrainlogDatabase *database,
sqlite3_int64 session_row_id, sqlite3_int64 session_row_id,
TrainlogSessionType session_type,
const TrainlogSessionExerciseInput *exercises, const TrainlogSessionExerciseInput *exercises,
size_t exercise_count size_t exercise_count
) )
@ -2266,6 +2359,11 @@ static TrainlogStatus insert_session_children(
size_t set_index; size_t set_index;
TrainlogStatus status; TrainlogStatus status;
if (exercise->has_max_weight &&
session_type != TRAINLOG_SESSION_MAX_TEST) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = insert_session_exercise( status = insert_session_exercise(
database, database,
session_row_id, session_row_id,
@ -2278,6 +2376,18 @@ static TrainlogStatus insert_session_children(
return status; return status;
} }
if (exercise->has_max_weight) {
status = insert_max_result(
database,
session_exercise_row_id,
exercise->max_weight_kg
);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
continue;
}
if (exercise->recording_mode == if (exercise->recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS) { TRAINLOG_RECORDING_CONTINUOUS) {
status = insert_continuous_activity( status = insert_continuous_activity(
@ -2349,6 +2459,7 @@ TrainlogStatus trainlog_database_insert_session(
status = insert_session_children( status = insert_session_children(
database, database,
session_row_id, session_row_id,
session->session_type,
session->exercises, session->exercises,
session->exercise_count session->exercise_count
); );
@ -2377,6 +2488,7 @@ TrainlogStatus trainlog_database_replace_session_exercises(
{ {
sqlite3_int64 session_row_id; sqlite3_int64 session_row_id;
sqlite3_stmt *statement = NULL; sqlite3_stmt *statement = NULL;
TrainlogSessionType session_type;
TrainlogStatus status; TrainlogStatus status;
int rc; int rc;
@ -2404,6 +2516,32 @@ TrainlogStatus trainlog_database_replace_session_exercises(
return status; return status;
} }
rc = sqlite3_prepare_v2(
database->connection,
"SELECT session_type FROM sessions WHERE id=?1;",
-1, &statement, NULL
);
if (rc == SQLITE_OK) {
rc = sqlite3_bind_int64(statement, 1, session_row_id);
}
if (rc == SQLITE_OK) {
rc = sqlite3_step(statement);
}
if (rc != SQLITE_ROW ||
!session_type_from_sql(
(const char *)sqlite3_column_text(statement, 0),
&session_type)) {
(void)sqlite3_finalize(statement);
(void)trainlog_database_rollback(database);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
rc = sqlite3_finalize(statement);
statement = NULL;
if (rc != SQLITE_OK) {
(void)trainlog_database_rollback(database);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
rc = sqlite3_prepare_v2( rc = sqlite3_prepare_v2(
database->connection, database->connection,
"DELETE FROM session_exercises " "DELETE FROM session_exercises "
@ -2441,6 +2579,7 @@ TrainlogStatus trainlog_database_replace_session_exercises(
status = insert_session_children( status = insert_session_children(
database, database,
session_row_id, session_row_id,
session_type,
exercises, exercises,
exercise_count exercise_count
); );
@ -3058,6 +3197,7 @@ TrainlogStatus trainlog_database_get_session_details(
"COALESCE(se.target_duration_seconds, 0), " "COALESCE(se.target_duration_seconds, 0), "
"se.target_weight_kg, " "se.target_weight_kg, "
"ca.duration_seconds, ca.speed_kmh, ca.distance_km, " "ca.duration_seconds, ca.speed_kmh, ca.distance_km, "
"mr.max_weight_kg, "
"se.equipment_id, " "se.equipment_id, "
"se.entry_id, " "se.entry_id, "
"se.id " "se.id "
@ -3068,6 +3208,8 @@ TrainlogStatus trainlog_database_get_session_details(
" ON e.id = se.exercise_row_id " " ON e.id = se.exercise_row_id "
"LEFT JOIN continuous_activity AS ca " "LEFT JOIN continuous_activity AS ca "
" ON ca.session_exercise_row_id = se.id " " ON ca.session_exercise_row_id = se.id "
"LEFT JOIN max_results AS mr "
" ON mr.session_exercise_row_id = se.id "
"WHERE s.session_id = ?1 " "WHERE s.session_id = ?1 "
"ORDER BY se.position ASC;"; "ORDER BY se.position ASC;";
@ -3230,10 +3372,10 @@ TrainlogStatus trainlog_database_get_session_details(
sqlite3_column_text(exercises, 4); sqlite3_column_text(exercises, 4);
sqlite3_int64 session_exercise_row_id = sqlite3_int64 session_exercise_row_id =
sqlite3_column_int64(exercises, 15); sqlite3_column_int64(exercises, 16);
const unsigned char *equipment_id = const unsigned char *equipment_id =
sqlite3_column_text(exercises, 13); sqlite3_column_text(exercises, 14);
const unsigned char *entry_id = sqlite3_column_text(exercises, 14); const unsigned char *entry_id = sqlite3_column_text(exercises, 15);
TrainlogStatus status; TrainlogStatus status;
@ -3334,7 +3476,22 @@ TrainlogStatus trainlog_database_get_session_details(
) )
: 0.0; : 0.0;
if (detail->recording_mode == detail->has_max_weight =
sqlite3_column_type(exercises, 13) != SQLITE_NULL;
detail->max_weight_kg =
detail->has_max_weight != 0
? sqlite3_column_double(exercises, 13)
: 0.0;
if (detail->has_max_weight != 0) {
detail->actual_set_count = 0U;
(void)snprintf(
detail->actual_summary,
sizeof(detail->actual_summary),
"Max %.2f kg",
detail->max_weight_kg
);
} else if (detail->recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS) { TRAINLOG_RECORDING_CONTINUOUS) {
bool speed_required = bool speed_required =
(detail->data_fields & (detail->data_fields &
@ -3761,7 +3918,9 @@ TrainlogStatus trainlog_database_list_exercise_performance(
"ps.id, " "ps.id, "
"ps.reps, " "ps.reps, "
"ps.duration_seconds, " "ps.duration_seconds, "
"ps.weight_kg " "ps.weight_kg, "
"mr.max_weight_kg, "
"COALESCE(se.equipment_id, '') "
"FROM session_exercises AS se " "FROM session_exercises AS se "
"JOIN sessions AS s " "JOIN sessions AS s "
" ON s.id = se.session_row_id " " ON s.id = se.session_row_id "
@ -3769,11 +3928,13 @@ TrainlogStatus trainlog_database_list_exercise_performance(
" ON e.id = se.exercise_row_id " " ON e.id = se.exercise_row_id "
"LEFT JOIN performed_sets AS ps " "LEFT JOIN performed_sets AS ps "
" ON ps.session_exercise_row_id = se.id " " ON ps.session_exercise_row_id = se.id "
"LEFT JOIN max_results AS mr "
" ON mr.session_exercise_row_id = se.id "
"WHERE e.exercise_id = ?1 " "WHERE e.exercise_id = ?1 "
"ORDER BY " "ORDER BY "
"s.started_at DESC, " "s.started_at DESC, "
"s.id DESC, " "s.id DESC, "
"ps.position ASC;"; "se.position ASC, ps.position ASC;";
sqlite3_stmt *statement = NULL; sqlite3_stmt *statement = NULL;
TrainlogExercisePerformancePoint *current = NULL; TrainlogExercisePerformancePoint *current = NULL;
@ -3864,6 +4025,8 @@ TrainlogStatus trainlog_database_list_exercise_performance(
); );
bool new_session; bool new_session;
const unsigned char *equipment_id =
sqlite3_column_text(statement, 10);
if ( if (
session_id == NULL || session_id == NULL ||
@ -3871,6 +4034,8 @@ TrainlogStatus trainlog_database_list_exercise_performance(
session_type == NULL || session_type == NULL ||
tracking_mode == NULL || tracking_mode == NULL ||
load_mode == NULL load_mode == NULL
|| equipment_id == NULL
|| (size_t)sqlite3_column_bytes(statement, 10) > TRAINLOG_ID_MAX
) { ) {
(void)sqlite3_finalize( (void)sqlite3_finalize(
statement statement
@ -3949,7 +4114,29 @@ TrainlogStatus trainlog_database_list_exercise_performance(
} }
} }
if ( if (current != NULL &&
sqlite3_column_type(statement, 9) != SQLITE_NULL) {
double max_weight = sqlite3_column_double(statement, 9);
if (!isfinite(max_weight) || max_weight <= 0.0) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
/* Explicit max results participate in measured-max history without
* synthesizing a rep/set count. Repeated movement occurrences in
* one session reduce to the greatest recorded max. */
if (current->has_performance == 0 ||
max_weight > current->weight_kg) {
current->load_mode = TRAINLOG_LOAD_EXTERNAL;
current->has_performance = 1;
current->has_explicit_max = 1;
current->metric_value = 1;
current->has_weight = 1;
current->weight_kg = max_weight;
(void)snprintf(current->equipment_id,
sizeof(current->equipment_id), "%s",
(const char *)equipment_id);
}
} else if (
current != NULL && current != NULL &&
sqlite3_column_type( sqlite3_column_type(
statement, statement,
@ -4013,12 +4200,16 @@ TrainlogStatus trainlog_database_list_exercise_performance(
) )
) { ) {
current->has_performance = 1; current->has_performance = 1;
current->has_explicit_max = 0;
current->metric_value = current->metric_value =
metric_value; metric_value;
current->has_weight = current->has_weight =
has_weight; has_weight;
current->weight_kg = current->weight_kg =
weight_kg; weight_kg;
(void)snprintf(current->equipment_id,
sizeof(current->equipment_id), "%s",
(const char *)equipment_id);
} }
} }
} }
@ -4079,12 +4270,18 @@ TrainlogStatus trainlog_database_load_session_editable(
"COALESCE(se.target_duration_seconds, 0), " "COALESCE(se.target_duration_seconds, 0), "
"se.target_weight_kg, " "se.target_weight_kg, "
"COALESCE(se.notes, ''), " "COALESCE(se.notes, ''), "
"COALESCE(se.equipment_id, ''), se.entry_id " "COALESCE(se.equipment_id, ''), se.entry_id, mr.max_weight_kg, "
"se.recording_mode, se.data_fields, "
"ca.duration_seconds, ca.speed_kmh, ca.distance_km "
"FROM session_exercises AS se " "FROM session_exercises AS se "
"JOIN sessions AS s " "JOIN sessions AS s "
"ON s.id = se.session_row_id " "ON s.id = se.session_row_id "
"JOIN exercises AS e " "JOIN exercises AS e "
"ON e.id = se.exercise_row_id " "ON e.id = se.exercise_row_id "
"LEFT JOIN max_results AS mr "
"ON mr.session_exercise_row_id = se.id "
"LEFT JOIN continuous_activity AS ca "
"ON ca.session_exercise_row_id = se.id "
"WHERE s.session_id = ?1 " "WHERE s.session_id = ?1 "
"ORDER BY se.position ASC;"; "ORDER BY se.position ASC;";
@ -4348,6 +4545,22 @@ TrainlogStatus trainlog_database_load_session_editable(
(const char *)tracking (const char *)tracking
); );
record->recording_mode = recording_mode_from_sql(
(const char *)sqlite3_column_text(exercise_statement, 14));
{
sqlite3_int64 data_fields =
sqlite3_column_int64(exercise_statement, 15);
if (data_fields < 0 ||
(uint64_t)data_fields > (uint64_t)UINT32_MAX ||
(((TrainlogExerciseDataFields)data_fields) &
~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) != 0U) {
(void)sqlite3_finalize(exercise_statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
record->data_fields =
(TrainlogExerciseDataFields)data_fields;
}
record->load_mode = record->load_mode =
detail_load_mode_from_text( detail_load_mode_from_text(
(const char *)load (const char *)load
@ -4391,6 +4604,35 @@ TrainlogStatus trainlog_database_load_session_editable(
) )
: 0.0; : 0.0;
record->has_max_weight =
sqlite3_column_type(exercise_statement, 13) != SQLITE_NULL;
record->max_weight_kg =
record->has_max_weight != 0
? sqlite3_column_double(exercise_statement, 13)
: 0.0;
/*
* CONTRACT: persisted-session editing must round-trip a continuous
* occurrence exactly; otherwise replacing a mixed max-test session
* could discard or invalidate its warm-up entry.
*/
if (sqlite3_column_type(exercise_statement, 16) != SQLITE_NULL) {
record->continuous_duration_seconds =
sqlite3_column_int(exercise_statement, 16);
record->has_continuous_speed =
sqlite3_column_type(exercise_statement, 17) != SQLITE_NULL;
record->continuous_speed_kmh =
record->has_continuous_speed != 0
? sqlite3_column_double(exercise_statement, 17)
: 0.0;
record->has_continuous_distance =
sqlite3_column_type(exercise_statement, 18) != SQLITE_NULL;
record->continuous_distance_km =
record->has_continuous_distance != 0
? sqlite3_column_double(exercise_statement, 18)
: 0.0;
}
(void)snprintf( (void)snprintf(
record->notes, record->notes,
sizeof(record->notes), sizeof(record->notes),

View file

@ -664,6 +664,12 @@ static void exercise_short_date(
output[10] = '\0'; output[10] = '\0';
} }
static void format_compact_max_weight(
double value,
char *output,
size_t output_size
);
static void exercise_format_performance( static void exercise_format_performance(
const TrainlogExercisePerformancePoint *point, const TrainlogExercisePerformancePoint *point,
char *output, char *output,
@ -686,6 +692,13 @@ static void exercise_format_performance(
return; return;
} }
if (point->has_explicit_max != 0) {
char weight[32];
format_compact_max_weight(point->weight_kg, weight, sizeof(weight));
(void)snprintf(output, output_size, "%s kg", weight);
return;
}
if (point->tracking_mode == if (point->tracking_mode ==
TRAINLOG_TRACKING_DURATION) { TRAINLOG_TRACKING_DURATION) {
char duration[64]; char duration[64];
@ -1754,6 +1767,8 @@ static void screen_exercise_measured_max(
char record_text[128]; char record_text[128];
char current_date[11]; char current_date[11];
char record_date[11]; char record_date[11];
TrainlogResolvedEquipment current_equipment;
const char *current_equipment_label = "aucun";
exercise_format_performance( exercise_format_performance(
&summary.current, &summary.current,
@ -1777,13 +1792,23 @@ static void screen_exercise_measured_max(
record_date record_date
); );
if (summary.current.equipment_id[0] != '\0') {
current_equipment_label =
trainlog_database_resolve_equipment(database,
summary.current.equipment_id, &current_equipment) ==
TRAINLOG_STATUS_OK
? current_equipment.display_name
: summary.current.equipment_id;
}
trainlog_terminal_printf(tui_terminal, trainlog_terminal_printf(tui_terminal,
summary_top + 3, summary_top + 3,
5, 5,
"Actuel : %s · %.*s", "Actuel : %s · %s · Machine : %.*s",
current_date, current_date,
trainlog_terminal_columns(tui_terminal) - 32, current_text,
current_text trainlog_terminal_columns(tui_terminal) - 48,
current_equipment_label
); );
trainlog_terminal_printf(tui_terminal, trainlog_terminal_printf(tui_terminal,
@ -5145,6 +5170,7 @@ static DashboardAction screen_dashboard(
static bool build_session_exercise( static bool build_session_exercise(
TrainlogDatabase *database, TrainlogDatabase *database,
TrainlogSessionType session_type,
TrainlogSessionExerciseInput *output, TrainlogSessionExerciseInput *output,
TrainlogSetInput *set_storage, TrainlogSetInput *set_storage,
size_t set_capacity size_t set_capacity
@ -5194,6 +5220,36 @@ static bool build_session_exercise(
return false; return false;
} }
if (session_type == TRAINLOG_SESSION_MAX_TEST) {
bool has_max = false;
double max_weight = 0.0;
draw_shell(
exercise.name,
"Échap annuler · Test de max"
);
if (!prompt_optional_double(
4,
"Poids max (kg) : ",
&has_max,
&max_weight
) || !has_max || max_weight <= 0.0) {
status_line(
"Poids max requis et strictement positif.",
TRAINLOG_COLOR_ERROR
);
return false;
}
/* CONTRACT: the max result has no hidden series/repetition payload. */
output->has_max_weight = true;
output->max_weight_kg = max_weight;
output->load_mode = TRAINLOG_LOAD_NONE;
output->sets = NULL;
output->set_count = 0U;
return true;
}
if (exercise.recording_mode == if (exercise.recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS) { TRAINLOG_RECORDING_CONTINUOUS) {
int duration_minutes = 30; int duration_minutes = 30;
@ -6005,6 +6061,7 @@ static bool draft_lookup_exercise(
static bool draft_build_exercise( static bool draft_build_exercise(
TrainlogDatabase *database, TrainlogDatabase *database,
TrainlogSessionType session_type,
TrainlogSessionDraftExercise *draft, TrainlogSessionDraftExercise *draft,
const char *preserved_notes const char *preserved_notes
) )
@ -6031,6 +6088,7 @@ static bool draft_build_exercise(
if (!build_session_exercise( if (!build_session_exercise(
database, database,
session_type,
&input, &input,
sets, sets,
MAX_SETS_PER_EXERCISE MAX_SETS_PER_EXERCISE
@ -6155,7 +6213,21 @@ static void draft_set_summary(
return; return;
} }
if (draft->input.load_mode == if (draft->input.has_max_weight) {
char weight[32];
format_compact_max_weight(
draft->input.max_weight_kg,
weight,
sizeof(weight)
);
(void)snprintf(
output,
output_size,
"Max %s kg",
weight
);
} else if (draft->input.load_mode ==
TRAINLOG_LOAD_EXTERNAL) { TRAINLOG_LOAD_EXTERNAL) {
(void)snprintf( (void)snprintf(
output, output,
@ -6456,6 +6528,7 @@ static bool edit_session_draft(
if (draft_build_exercise( if (draft_build_exercise(
database, database,
session_type,
&drafts[*count], &drafts[*count],
NULL NULL
)) { )) {
@ -6475,9 +6548,18 @@ static bool edit_session_draft(
if (draft_build_exercise( if (draft_build_exercise(
database, database,
session_type,
&replacement, &replacement,
drafts[selected].notes drafts[selected].notes
)) { )) {
/* INVARIANT: editing replaces values, never occurrence
* identity. Sync idempotency depends on stable entry_id. */
(void)snprintf(
replacement.input.entry_id,
sizeof(replacement.input.entry_id),
"%s",
drafts[selected].input.entry_id
);
drafts[selected] = drafts[selected] =
replacement; replacement;
@ -6593,6 +6675,11 @@ static bool load_persisted_draft(
draft->tracking_mode = draft->tracking_mode =
record->tracking_mode; record->tracking_mode;
draft->input.recording_mode =
record->recording_mode;
draft->input.data_fields =
record->data_fields;
draft->input.load_mode = draft->input.load_mode =
record->load_mode; record->load_mode;
@ -6614,6 +6701,35 @@ static bool load_persisted_draft(
draft->input.target_weight_kg = draft->input.target_weight_kg =
record->target_weight_kg; record->target_weight_kg;
draft->input.has_max_weight =
record->has_max_weight != 0;
draft->input.max_weight_kg =
record->max_weight_kg;
draft->input.continuous_duration_seconds =
record->continuous_duration_seconds;
draft->input.continuous_has_speed =
record->has_continuous_speed != 0;
draft->input.continuous_speed_kmh =
record->continuous_speed_kmh;
draft->input.continuous_has_distance =
record->has_continuous_distance != 0;
draft->input.continuous_distance_km =
record->continuous_distance_km;
(void)snprintf(
draft->input.entry_id,
sizeof(draft->input.entry_id),
"%s",
record->entry_id
);
(void)snprintf(
draft->input.equipment_id,
sizeof(draft->input.equipment_id),
"%s",
record->equipment_id
);
(void)snprintf( (void)snprintf(
draft->notes, draft->notes,
sizeof(draft->notes), sizeof(draft->notes),
@ -6811,6 +6927,7 @@ static void screen_new_session(
*/ */
if (draft_build_exercise( if (draft_build_exercise(
database, database,
session_type,
&drafts[0], &drafts[0],
NULL NULL
)) { )) {
@ -6975,6 +7092,110 @@ static const char *session_detail_load_label(TrainlogLoadMode mode)
} }
} }
static void format_compact_max_weight(
double value,
char *output,
size_t output_size
)
{
size_t length;
if (output == NULL || output_size == 0U) {
return;
}
(void)snprintf(output, output_size, "%.2f", value);
length = strlen(output);
while (length > 0U && output[length - 1U] == '0') {
output[--length] = '\0';
}
if (length > 0U && output[length - 1U] == '.') {
output[--length] = '\0';
}
}
static void draw_max_test_table(
TrainlogDatabase *database,
const TrainlogPersistedExerciseDetail *exercises,
size_t count,
size_t selected,
bool decorated
)
{
int header_row = decorated ? 18 : 7;
int first_row = header_row + 1;
int last_row = trainlog_terminal_rows(tui_terminal) - 3;
size_t visible = last_row >= first_row
? (size_t)(last_row - first_row + 1)
: 1U;
size_t start = selected >= visible
? selected - visible + 1U
: 0U;
size_t end = start + visible < count ? start + visible : count;
size_t index;
size_t max_count = 0U;
int column = decorated ? 5 : 4;
for (index = 0U; index < count; ++index) {
if (exercises[index].has_max_weight != 0) {
++max_count;
}
}
trainlog_terminal_style_on(tui_terminal,
TRAINLOG_TEXT_BOLD |
trainlog_theme_style(TRAINLOG_COLOR_ACCENT));
trainlog_terminal_printf(tui_terminal, decorated ? 16 : 6, column,
"Test de max — %zu résultat(s) MAX", max_count);
trainlog_terminal_style_off(tui_terminal,
TRAINLOG_TEXT_BOLD |
trainlog_theme_style(TRAINLOG_COLOR_ACCENT));
trainlog_terminal_printf(tui_terminal, header_row, column,
decorated
? "%-28s %-30s %10s"
: "%-20s %-27s %12s",
"Exercice", "Machine", "Max");
for (index = start; index < end; ++index) {
TrainlogResolvedEquipment resolved;
const char *equipment_label = "";
char max_text[32] = "";
char max_label[40] = "";
if (exercises[index].equipment_id[0] != '\0') {
equipment_label =
trainlog_database_resolve_equipment(database,
exercises[index].equipment_id, &resolved) ==
TRAINLOG_STATUS_OK
? resolved.display_name
: exercises[index].equipment_id;
}
if (exercises[index].has_max_weight != 0) {
format_compact_max_weight(exercises[index].max_weight_kg,
max_text, sizeof(max_text));
(void)snprintf(max_label, sizeof(max_label), "%s kg", max_text);
}
if (index == selected) {
trainlog_terminal_style_on(tui_terminal,
TRAINLOG_TEXT_REVERSE |
trainlog_theme_style(TRAINLOG_COLOR_SUCCESS));
}
trainlog_terminal_printf(tui_terminal,
first_row + (int)(index - start), column,
decorated
? "%-28.28s %-30.30s %10.10s"
: "%-20.20s %-27.27s %12.12s",
exercises[index].name, equipment_label, max_label);
if (index == selected) {
trainlog_terminal_style_off(tui_terminal,
TRAINLOG_TEXT_REVERSE |
trainlog_theme_style(TRAINLOG_COLOR_SUCCESS));
}
}
}
static void screen_session_detail( static void screen_session_detail(
TrainlogDatabase *database, TrainlogDatabase *database,
const char *session_id const char *session_id
@ -7089,6 +7310,10 @@ static void screen_session_detail(
session.session_type session.session_type
) )
); );
} else if (session.session_type == TRAINLOG_SESSION_MAX_TEST) {
/* CONTRACT: max history is exercise-indexed; equipment is only
* rendered as occurrence context and never owns the value. */
draw_max_test_table(database, exercises, count, selected, decorated);
} else { } else {
draw_shell( draw_shell(
"TRAINLOG — Détail séance", "TRAINLOG — Détail séance",
@ -7149,7 +7374,43 @@ static void screen_session_detail(
exercise->entry_id, exercise->entry_id,
equipment_label); equipment_label);
if (exercise->recording_mode == if (exercise->has_max_weight != 0) {
int title_row = decorated ? 16 : 6;
int header_row = decorated ? 20 : 10;
int value_row = decorated ? 21 : 11;
trainlog_terminal_style_on(tui_terminal,
TRAINLOG_TEXT_BOLD |
trainlog_theme_style(TRAINLOG_COLOR_ACCENT));
trainlog_terminal_printf(tui_terminal,
title_row,
decorated ? 5 : 4,
"Exercice %zu/%zu — Test de max",
selected + 1U,
count);
trainlog_terminal_style_off(tui_terminal,
TRAINLOG_TEXT_BOLD |
trainlog_theme_style(TRAINLOG_COLOR_ACCENT));
trainlog_terminal_printf(tui_terminal,
header_row,
decorated ? 5 : 4,
"%-28s %-30s %10s",
"Exercice", "Machine", "Max");
trainlog_terminal_style_on(tui_terminal,
TRAINLOG_TEXT_BOLD |
trainlog_theme_style(TRAINLOG_COLOR_SUCCESS));
trainlog_terminal_printf(tui_terminal,
value_row,
decorated ? 5 : 4,
"%-28.28s %-30.30s %7.2f kg",
exercise->name,
equipment_label,
exercise->max_weight_kg);
trainlog_terminal_style_off(tui_terminal,
TRAINLOG_TEXT_BOLD |
trainlog_theme_style(TRAINLOG_COLOR_SUCCESS));
} else if (exercise->recording_mode ==
TRAINLOG_RECORDING_CONTINUOUS) { TRAINLOG_RECORDING_CONTINUOUS) {
char duration_text[64]; char duration_text[64];

View file

@ -51,7 +51,7 @@ static bool test_custom_equipment_round_trip(void)
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 8); CHECK(version == 9);
(void)memset(&custom, 0, sizeof(custom)); (void)memset(&custom, 0, sizeof(custom));
(void)snprintf(custom.equipment_id, sizeof(custom.equipment_id), (void)snprintf(custom.equipment_id, sizeof(custom.equipment_id),
"%s", "eq_123e4567-e89b-42d3-a456-426614174000"); "%s", "eq_123e4567-e89b-42d3-a456-426614174000");

View file

@ -0,0 +1,198 @@
/**
* @file test_max_results.c
* @brief Explicit measured-max persistence and bounded migration regressions.
*/
#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 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 void max_input(TrainlogSessionExerciseInput *input,
const char *entry_id, const char *exercise_id,
double weight)
{
(void)memset(input, 0, sizeof(*input));
(void)snprintf(input->entry_id, sizeof(input->entry_id), "%s", entry_id);
(void)snprintf(input->exercise_id, sizeof(input->exercise_id), "%s",
exercise_id);
(void)snprintf(input->equipment_id, sizeof(input->equipment_id), "%s",
"rear_delt_pec_fly");
input->recording_mode = TRAINLOG_RECORDING_SETS;
input->load_mode = TRAINLOG_LOAD_NONE;
input->has_max_weight = true;
input->max_weight_kg = weight;
}
static bool test_explicit_max_round_trip_and_identity(void)
{
TrainlogDatabase *database = NULL;
TrainlogSessionExerciseInput entries[3];
TrainlogSessionInput session;
TrainlogSessionSummary summary;
TrainlogPersistedExerciseDetail details[3];
TrainlogEditableExerciseRecord editable[3];
TrainlogExercisePerformancePoint points[2];
size_t count = 0U;
size_t set_count = 0U;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(add_exercise(database, "ex_pec", "Pec Fly"));
CHECK(add_exercise(database, "ex_rear", "Rear Delt Fly"));
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_walk", "Marche", "marche", TRAINLOG_TRACKING_DURATION,
TRAINLOG_RECORDING_CONTINUOUS, TRAINLOG_EXERCISE_DATA_SPEED_KMH) ==
TRAINLOG_STATUS_OK);
max_input(&entries[0], "sxe_pec", "ex_pec", 100.0);
max_input(&entries[1], "sxe_rear", "ex_rear", 86.0);
(void)memset(&entries[2], 0, sizeof(entries[2]));
(void)snprintf(entries[2].entry_id, sizeof(entries[2].entry_id), "%s",
"sxe_walk");
(void)snprintf(entries[2].exercise_id, sizeof(entries[2].exercise_id), "%s",
"ex_walk");
entries[2].recording_mode = TRAINLOG_RECORDING_CONTINUOUS;
entries[2].data_fields = TRAINLOG_EXERCISE_DATA_SPEED_KMH;
entries[2].load_mode = TRAINLOG_LOAD_NONE;
entries[2].continuous_duration_seconds = 600;
entries[2].continuous_has_speed = true;
entries[2].continuous_speed_kmh = 5.5;
(void)memset(&session, 0, sizeof(session));
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_explicit_max");
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
"2031-02-03T08:15:00+01:00");
session.session_type = TRAINLOG_SESSION_MAX_TEST;
session.exercises = entries;
session.exercise_count = 3U;
CHECK(trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_get_session_details(database, "se_explicit_max",
&summary, details, 3U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 3U);
CHECK(details[0].has_max_weight != 0 && details[0].max_weight_kg == 100.0);
CHECK(details[1].has_max_weight != 0 && details[1].max_weight_kg == 86.0);
CHECK(details[0].actual_set_count == 0U && details[1].actual_set_count == 0U);
CHECK(strcmp(details[0].entry_id, "sxe_pec") == 0);
CHECK(strcmp(details[0].equipment_id, details[1].equipment_id) == 0);
CHECK(details[2].continuous_duration_seconds == 600);
CHECK(details[2].has_continuous_speed != 0 &&
details[2].continuous_speed_kmh == 5.5);
CHECK(trainlog_database_load_session_editable(database, "se_explicit_max",
&summary, editable, 3U, &count, NULL, 0U, &set_count) ==
TRAINLOG_STATUS_OK);
CHECK(count == 3U && set_count == 0U);
CHECK(editable[2].continuous_duration_seconds == 600);
CHECK(editable[2].has_continuous_speed != 0 &&
editable[2].continuous_speed_kmh == 5.5);
CHECK(trainlog_database_list_exercise_performance(database, "ex_pec",
points, 2U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 1U && points[0].has_performance != 0);
CHECK(points[0].has_explicit_max != 0);
CHECK(points[0].actual_set_count == 0U && points[0].weight_kg == 100.0);
CHECK(strcmp(points[0].equipment_id, "rear_delt_pec_fly") == 0);
entries[0].max_weight_kg = 101.5;
CHECK(trainlog_database_replace_session_exercises(database,
"se_explicit_max", entries, 3U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_get_session_details(database, "se_explicit_max",
&summary, details, 3U, &count) == TRAINLOG_STATUS_OK);
CHECK(details[0].max_weight_kg == 101.5);
CHECK(strcmp(details[0].entry_id, "sxe_pec") == 0);
session.session_type = TRAINLOG_SESSION_TRAINING;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_invalid_training_max");
CHECK(trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_INVALID_ARGUMENT);
trainlog_database_close(database);
return true;
}
static bool test_v8_migration_refuses_to_guess_multiple_attempts(void)
{
static const char *const SQL =
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_type TEXT);"
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,"
"session_row_id INTEGER,recording_mode TEXT);"
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,"
"session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,"
"duration_seconds INTEGER,weight_kg REAL);"
"INSERT INTO sessions VALUES(1,'max_test');"
"INSERT INTO session_exercises VALUES(10,1,'sets');"
"INSERT INTO session_exercises VALUES(11,1,'sets');"
"INSERT INTO performed_sets VALUES(20,10,0,1,NULL,100.0);"
"INSERT INTO performed_sets VALUES(21,11,0,1,NULL,80.0);"
"INSERT INTO performed_sets VALUES(22,11,1,1,NULL,86.0);"
"PRAGMA user_version=8;";
char path[] = "/tmp/trainlog-max-v8-XXXXXX";
sqlite3 *raw = NULL;
sqlite3_stmt *statement = NULL;
TrainlogDatabase *database = NULL;
int fd = mkstemp(path);
CHECK(fd >= 0 && close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, SQL, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
trainlog_database_close(database);
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(raw,
"SELECT session_exercise_row_id,max_weight_kg FROM max_results;",
-1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 10);
CHECK(sqlite3_column_double(statement, 1) == 100.0);
CHECK(sqlite3_step(statement) == SQLITE_DONE);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(raw,
"SELECT position,reps,weight_kg FROM performed_sets "
"WHERE session_exercise_row_id=11 ORDER BY position;",
-1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW &&
sqlite3_column_double(statement, 2) == 80.0);
CHECK(sqlite3_step(statement) == SQLITE_ROW &&
sqlite3_column_double(statement, 2) == 86.0);
CHECK(sqlite3_step(statement) == SQLITE_DONE);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(unlink(path) == 0);
return true;
}
int main(void)
{
if (!test_explicit_max_round_trip_and_identity() ||
!test_v8_migration_refuses_to_guess_multiple_attempts()) {
return 1;
}
(void)printf("max results: PASS\n");
return 0;
}

View file

@ -187,7 +187,7 @@ static bool test_v4_to_current_preserves_session(void)
TRAINLOG_DATABASE_SCHEMA_VERSION TRAINLOG_DATABASE_SCHEMA_VERSION
); );
CHECK(version == 8); CHECK(version == 9);
CHECK( CHECK(
trainlog_database_get_session_details( trainlog_database_get_session_details(

View file

@ -103,7 +103,7 @@ static bool test_v7_migrates_and_v8_reopens(void)
CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 8); CHECK(version == 9);
trainlog_database_close(database); trainlog_database_close(database);
CHECK(verify_preserved_values(path)); CHECK(verify_preserved_values(path));
@ -145,7 +145,7 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
static bool test_newer_schema_has_application_diagnostic(void) static bool test_newer_schema_has_application_diagnostic(void)
{ {
char path[] = "/tmp/trainlog-schema-v9-XXXXXX"; char path[] = "/tmp/trainlog-schema-v10-XXXXXX";
char diagnostic[256]; char diagnostic[256];
sqlite3 *raw = NULL; sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL; TrainlogDatabase *database = NULL;
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
CHECK(fd >= 0); CHECK(fd >= 0);
CHECK(close(fd) == 0); CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=9;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_exec(raw, "PRAGMA user_version=10;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 9 is newer") != NULL); CHECK(strstr(diagnostic, "schema version 10 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0); CHECK(unlink(path) == 0);
return true; return true;