feat(max): add explicit max test results and migrate legacy 1x1 entries
This commit is contained in:
parent
8066b39ce3
commit
56535704ca
39 changed files with 2161 additions and 238 deletions
|
|
@ -164,7 +164,7 @@ It is not the canonical analytics store.
|
|||
The Android UI is driven by exercise metadata, never by exercise-name
|
||||
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,
|
||||
backgrounding and process death never delete the draft. Home offers explicit
|
||||
resume; whole-draft discard requires confirmation. Final completed-session
|
||||
|
|
@ -220,7 +220,8 @@ Desktop SQLite schema is versioned with:
|
|||
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
|
||||
regression coverage.
|
||||
|
|
|
|||
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -9,6 +9,19 @@ Detailed implementation chronology remains available in Git history and
|
|||
|
||||
### 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
|
||||
`catalog/equipment-v1.json`, browse/search/detail views, and local custom
|
||||
equipment creation/selection; occurrence links resolve supplied or local
|
||||
|
|
@ -74,6 +87,9 @@ Detailed implementation chronology remains available in Git history and
|
|||
|
||||
### 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
|
||||
permitting custom definitions with `load_semantics = none`; Android's v7 ->
|
||||
v8 migration is non-destructive;
|
||||
|
|
@ -150,14 +166,16 @@ Current validated baseline:
|
|||
```text
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
|
||||
DESKTOP_SCHEMA_V8=PASS
|
||||
DESKTOP_TESTS=32/32 PASS
|
||||
DESKTOP_SCHEMA_V9=PASS
|
||||
DESKTOP_TESTS=34/34 PASS
|
||||
|
||||
ANDROID_BUILD=PASS
|
||||
ANDROID_LOCAL_WORKFLOWS=PASS
|
||||
ANDROID_LOCAL_DATABASE_V8=PASS
|
||||
ANDROID_LOCAL_DATABASE_V9=PASS
|
||||
ANDROID_TEST_DEBUG_UNIT=PASS
|
||||
ANDROID_SESSION_DRAFT_V1=PASS
|
||||
ANDROID_MAX_V9_REAL_DATA_MIGRATION=PASS
|
||||
ANDROID_MAX_V9_INSTALL_ADB=PASS
|
||||
|
||||
USB_MTP_DETECTION=PASS
|
||||
MTP_HARDWARE_ROUNDTRIP=HISTORICAL_PASS
|
||||
|
|
@ -177,6 +195,7 @@ MULTI_OCCURRENCE_SESSION_V2=PASS
|
|||
EQUIPMENT_ASSOCIATIONS_V2=PASS
|
||||
EQUIPMENT_DEFINITIONS_V1=PASS
|
||||
EXERCISE_RECONCILIATION_V2=PASS
|
||||
EXPLICIT_MAX_RESULTS_V1=PASS
|
||||
```
|
||||
|
||||
### Measured max v1
|
||||
|
|
|
|||
33
README.md
33
README.md
|
|
@ -16,9 +16,9 @@ desktop.
|
|||
```text
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
|
||||
DESKTOP_SCHEMA_V8=PASS
|
||||
DESKTOP_SCHEMA_V9=PASS
|
||||
ANDROID_LOCAL_WORKFLOWS=PASS
|
||||
ANDROID_LOCAL_DATABASE_V8=PASS
|
||||
ANDROID_LOCAL_DATABASE_V9=PASS
|
||||
ANDROID_SESSION_DRAFT_V1=PASS
|
||||
EXERCISE_EDIT_V1=PASS
|
||||
ANDROID_BANNER_PARITY_V1=PASS
|
||||
|
|
@ -36,8 +36,9 @@ MULTI_OCCURRENCE_SESSION_V2=PASS
|
|||
EQUIPMENT_ASSOCIATIONS_V2=PASS
|
||||
EQUIPMENT_DEFINITIONS_V1=PASS
|
||||
EXERCISE_RECONCILIATION_V2=PASS
|
||||
EXPLICIT_MAX_RESULTS_V1=PASS
|
||||
|
||||
DESKTOP_TESTS=32/32 PASS
|
||||
DESKTOP_TESTS=34/34 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
|
||||
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
|
||||
only when recording/tracking modes match, every other known invariant is
|
||||
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
|
||||
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).
|
||||
|
||||
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
|
||||
identities, and custom-equipment definition support without recreating completed
|
||||
history or the active draft.
|
||||
identities, custom-equipment definition support, explicit MAX results and
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
MEASURED_MAX_V1=PASS
|
||||
WORKING_LOAD_PERCENTAGES=PASS
|
||||
ANDROID_MAX_TEST_SESSION=PASS
|
||||
EXPLICIT_MAX_RESULTS_V1=PASS
|
||||
```
|
||||
|
||||
## Body analytics
|
||||
|
|
@ -283,5 +296,5 @@ BODY_ANALYTICS_V1=PASS
|
|||
BODY_COMPOSITION_ESTIMATE=PASS
|
||||
BODY_PROPORTION_RATIOS=PASS
|
||||
BODY_SYMMETRY_ANALYTICS=PASS
|
||||
DESKTOP_TESTS=32/32 PASS
|
||||
DESKTOP_TESTS=34/34 PASS
|
||||
```
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.labfytools.trainlog.model.BodyObservationSummary
|
|||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
import com.labfytools.trainlog.model.ExerciseProfile
|
||||
import com.labfytools.trainlog.model.ExerciseEditInput
|
||||
import com.labfytools.trainlog.model.LatestExerciseMax
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionDraft
|
||||
|
|
@ -98,6 +99,7 @@ sealed interface MobileSessionImportResult {
|
|||
val sessionsSkipped: Int,
|
||||
val bodyObservationsAdded: Int,
|
||||
val bodyObservationsSkipped: Int,
|
||||
val sessionsUpdated: Int = 0,
|
||||
) : MobileSessionImportResult
|
||||
data class Invalid(val message: String) : MobileSessionImportResult
|
||||
data object DatabaseError : MobileSessionImportResult
|
||||
|
|
@ -536,8 +538,10 @@ class TrainlogRepository(
|
|||
): ActiveDraftMutationResult {
|
||||
if (
|
||||
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
|
||||
* are valid; only their stable occurrence IDs must be unique. */
|
||||
draft.exercises
|
||||
|
|
@ -555,6 +559,8 @@ class TrainlogRepository(
|
|||
listOf(
|
||||
draft.form.setCountText,
|
||||
draft.form.repsText,
|
||||
draft.form.weightText,
|
||||
draft.form.maxWeightText,
|
||||
draft.form.durationText,
|
||||
draft.form.speedText,
|
||||
draft.form.distanceText,
|
||||
|
|
@ -642,7 +648,7 @@ class TrainlogRepository(
|
|||
if (
|
||||
completed.exercises.isEmpty() ||
|
||||
completed.exercises.any {
|
||||
!validateSessionExercise(it)
|
||||
!validateSessionExercise(it, completed.sessionType)
|
||||
}
|
||||
) {
|
||||
db.endTransaction()
|
||||
|
|
@ -656,6 +662,7 @@ class TrainlogRepository(
|
|||
insertCompletedSession(
|
||||
db,
|
||||
completed,
|
||||
active.draft.sourceSessionId,
|
||||
)
|
||||
|
||||
/*
|
||||
|
|
@ -704,7 +711,7 @@ class TrainlogRepository(
|
|||
if (
|
||||
draft.exercises.isEmpty() ||
|
||||
draft.exercises.any {
|
||||
!validateSessionExercise(it)
|
||||
!validateSessionExercise(it, draft.sessionType)
|
||||
}
|
||||
) {
|
||||
return SaveSessionResult.Invalid
|
||||
|
|
@ -815,7 +822,16 @@ class TrainlogRepository(
|
|||
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 ==
|
||||
RecordingMode.CONTINUOUS
|
||||
|
|
@ -955,6 +971,71 @@ class TrainlogRepository(
|
|||
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()
|
||||
root.put("format", "trainlog-mobile-export")
|
||||
root.put("version", 1)
|
||||
root.put("version", version)
|
||||
root.put("generated_at", OffsetDateTime.now().toString())
|
||||
|
||||
val exerciseArray = JSONArray()
|
||||
|
|
@ -1589,8 +1673,11 @@ class TrainlogRepository(
|
|||
.put("session_type", sessions.getString(3))
|
||||
val sessionExercises = JSONArray()
|
||||
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 " +
|
||||
"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;",
|
||||
arrayOf(sessionRowId.toString()),
|
||||
).use { exerciseCursor ->
|
||||
|
|
@ -1606,7 +1693,20 @@ class TrainlogRepository(
|
|||
.put("data_fields", exerciseCursor.getInt(5))
|
||||
.put("load_mode", "none")
|
||||
.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(
|
||||
"continuous_activity",
|
||||
arrayOf("duration_seconds", "speed_kmh", "distance_km"),
|
||||
|
|
@ -1631,7 +1731,7 @@ class TrainlogRepository(
|
|||
val sets = JSONArray()
|
||||
db.query(
|
||||
"performed_sets",
|
||||
arrayOf("reps", "duration_seconds"),
|
||||
arrayOf("reps", "duration_seconds", "weight_kg"),
|
||||
"session_exercise_row_id = ?",
|
||||
arrayOf(sessionExerciseRowId.toString()),
|
||||
null, null, "position ASC",
|
||||
|
|
@ -1643,6 +1743,9 @@ class TrainlogRepository(
|
|||
} else {
|
||||
set.put("duration_seconds", setCursor.getInt(1))
|
||||
}
|
||||
if (version == 2 && !setCursor.isNull(2)) {
|
||||
set.put("weight_kg", setCursor.getDouble(2))
|
||||
}
|
||||
sets.put(set)
|
||||
}
|
||||
}
|
||||
|
|
@ -1691,44 +1794,7 @@ class TrainlogRepository(
|
|||
* session occurrence carries its durable Android entry_id, ordering,
|
||||
* equipment and actual load so repeated catalogue exercises round-trip.
|
||||
*/
|
||||
fun buildMobileExportV2Json(): String {
|
||||
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()
|
||||
}
|
||||
fun buildMobileExportV2Json(): String = buildMobileExport(2)
|
||||
|
||||
/** Apply the same V2 session artifact emitted by desktop, keyed by entry_id. */
|
||||
fun applyPcMobileExportV2Json(json: String): MobileSessionImportResult {
|
||||
|
|
@ -1740,6 +1806,7 @@ class TrainlogRepository(
|
|||
val db = database.writableDatabase
|
||||
var sessionsAdded = 0
|
||||
var sessionsSkipped = 0
|
||||
var sessionsUpdated = 0
|
||||
var bodyObservationsAdded = 0
|
||||
var bodyObservationsSkipped = 0
|
||||
return try {
|
||||
|
|
@ -1775,9 +1842,19 @@ class TrainlogRepository(
|
|||
sessionsSkipped += 1
|
||||
continue
|
||||
}
|
||||
if (!pcResumedMaxUpdateIsSafe(db, existingRowId, session)) {
|
||||
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) }
|
||||
db.insertOrThrow("sessions", null, values)
|
||||
}
|
||||
|
|
@ -1809,7 +1886,12 @@ class TrainlogRepository(
|
|||
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
|
||||
}
|
||||
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.")
|
||||
db.insertOrThrow("continuous_activity", null, ContentValues().apply {
|
||||
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")
|
||||
?: return MobileSessionImportResult.Invalid("Mesures corporelles manquantes.")
|
||||
|
|
@ -1868,6 +1950,7 @@ class TrainlogRepository(
|
|||
sessionsSkipped = sessionsSkipped,
|
||||
bodyObservationsAdded = bodyObservationsAdded,
|
||||
bodyObservationsSkipped = bodyObservationsSkipped,
|
||||
sessionsUpdated = sessionsUpdated,
|
||||
)
|
||||
} catch (_: Exception) { MobileSessionImportResult.DatabaseError
|
||||
} 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 recording = entry.value("recording_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 exerciseId = entry.value("exercise_id")
|
||||
val positionValue = entry.value("position")
|
||||
|
|
@ -1931,6 +2019,11 @@ class TrainlogRepository(
|
|||
!(entry.value("equipment_id") === JSONObject.NULL || entry.value("equipment_id").isNonemptyJsonString())) {
|
||||
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]
|
||||
?: return "Exercice de séance V2 absent du catalogue."
|
||||
val entryFields = entry.getInt("data_fields")
|
||||
|
|
@ -1943,7 +2036,10 @@ class TrainlogRepository(
|
|||
* rewrite an older occurrence snapshot. */
|
||||
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 allowed = setOf("duration_seconds", "speed_kmh", "distance_km")
|
||||
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),
|
||||
if (item.isNull("equipment_id")) null else item.optString("equipment_id"))
|
||||
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 current = db.rawQuery("SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity WHERE session_exercise_row_id=?",
|
||||
arrayOf(rows[index].toString())).use { cursor ->
|
||||
|
|
@ -2068,6 +2170,43 @@ class TrainlogRepository(
|
|||
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
|
||||
* export v1. Version 2 keys an occurrence by `(session_id, entry_id)`;
|
||||
|
|
@ -2553,7 +2692,9 @@ class TrainlogRepository(
|
|||
se.recording_mode,
|
||||
se.tracking_mode,
|
||||
se.data_fields,
|
||||
eq.display_name
|
||||
eq.equipment_id,
|
||||
eq.display_name,
|
||||
mr.max_weight_kg
|
||||
FROM session_exercises AS se
|
||||
JOIN sessions AS s
|
||||
ON s.id = se.session_row_id
|
||||
|
|
@ -2561,6 +2702,8 @@ class TrainlogRepository(
|
|||
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 s.session_id = ?
|
||||
ORDER BY se.position ASC;
|
||||
""".trimIndent(),
|
||||
|
|
@ -2598,9 +2741,24 @@ class TrainlogRepository(
|
|||
|
||||
val dataFields =
|
||||
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 ==
|
||||
RecordingMode.CONTINUOUS
|
||||
) {
|
||||
|
|
@ -2637,6 +2795,7 @@ class TrainlogRepository(
|
|||
exerciseId = exerciseId,
|
||||
exerciseName =
|
||||
name,
|
||||
equipmentId = equipmentId,
|
||||
equipmentDisplayName = equipmentDisplayName,
|
||||
recordingMode =
|
||||
recording,
|
||||
|
|
@ -2728,6 +2887,7 @@ class TrainlogRepository(
|
|||
exerciseId = exerciseId,
|
||||
exerciseName =
|
||||
name,
|
||||
equipmentId = equipmentId,
|
||||
equipmentDisplayName = equipmentDisplayName,
|
||||
recordingMode =
|
||||
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(
|
||||
sessionId: String,
|
||||
entryId: String,
|
||||
|
|
@ -2783,6 +2988,9 @@ class TrainlogRepository(
|
|||
d.updated_at,
|
||||
d.selected_exercise_label,
|
||||
d.selected_equipment_id,
|
||||
d.weight_text,
|
||||
d.max_weight_text,
|
||||
d.source_session_id,
|
||||
e.exercise_id,
|
||||
e.name,
|
||||
e.normalized_name,
|
||||
|
|
@ -2800,15 +3008,15 @@ class TrainlogRepository(
|
|||
null
|
||||
} else {
|
||||
val missingSelection =
|
||||
cursor.isNull(9) &&
|
||||
cursor.isNull(12) &&
|
||||
!cursor.isNull(7)
|
||||
val selected =
|
||||
if (cursor.isNull(9)) {
|
||||
if (cursor.isNull(12)) {
|
||||
null
|
||||
} else {
|
||||
exerciseProfileFromCursor(
|
||||
cursor,
|
||||
9,
|
||||
12,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -2820,12 +3028,15 @@ class TrainlogRepository(
|
|||
form = SessionDraftForm(
|
||||
selectedExercise = selected,
|
||||
selectedEquipmentId = if (cursor.isNull(8)) null else cursor.getString(8),
|
||||
weightText = cursor.getString(9),
|
||||
maxWeightText = cursor.getString(10),
|
||||
setCountText = cursor.getString(1),
|
||||
repsText = cursor.getString(2),
|
||||
durationText = cursor.getString(3),
|
||||
speedText = cursor.getString(4),
|
||||
distanceText = cursor.getString(5),
|
||||
),
|
||||
sourceSessionId = if (cursor.isNull(11)) null else cursor.getString(11),
|
||||
updatedAt =
|
||||
cursor.getString(6),
|
||||
warning =
|
||||
|
|
@ -2855,12 +3066,15 @@ class TrainlogRepository(
|
|||
de.tracking_mode,
|
||||
de.data_fields,
|
||||
eq.equipment_id,
|
||||
de.entry_id
|
||||
de.entry_id,
|
||||
mr.max_weight_kg
|
||||
FROM draft_session_exercises AS de
|
||||
JOIN exercises AS e
|
||||
ON e.id = de.exercise_row_id
|
||||
LEFT JOIN equipment AS eq
|
||||
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 = ?
|
||||
ORDER BY de.position ASC;
|
||||
""".trimIndent(),
|
||||
|
|
@ -2888,8 +3102,17 @@ class TrainlogRepository(
|
|||
)
|
||||
val equipmentId = if (cursor.isNull(7)) null else cursor.getString(7)
|
||||
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 ==
|
||||
RecordingMode.CONTINUOUS
|
||||
) {
|
||||
|
|
@ -2982,6 +3205,7 @@ class TrainlogRepository(
|
|||
draft = ActiveSessionDraft(
|
||||
exercises = exercises,
|
||||
sessionType = header.sessionType,
|
||||
sourceSessionId = header.sourceSessionId,
|
||||
form = header.form,
|
||||
updatedAt = header.updatedAt,
|
||||
),
|
||||
|
|
@ -3005,7 +3229,10 @@ class TrainlogRepository(
|
|||
val values =
|
||||
ContentValues().apply {
|
||||
put("session_type", draft.sessionType.wireValue)
|
||||
putOptionalString("source_session_id", draft.sourceSessionId)
|
||||
putOptionalString("selected_equipment_id", draft.form.selectedEquipmentId)
|
||||
put("weight_text", draft.form.weightText)
|
||||
put("max_weight_text", draft.form.maxWeightText)
|
||||
if (selectedRowId == null) {
|
||||
putNull("selected_exercise_row_id")
|
||||
putNull("selected_exercise_label")
|
||||
|
|
@ -3091,7 +3318,16 @@ class TrainlogRepository(
|
|||
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 ==
|
||||
RecordingMode.CONTINUOUS
|
||||
) {
|
||||
|
|
@ -3154,24 +3390,43 @@ class TrainlogRepository(
|
|||
private fun insertCompletedSession(
|
||||
db: SQLiteDatabase,
|
||||
draft: SessionDraft,
|
||||
sourceSessionId: String? = null,
|
||||
): String {
|
||||
val sessionId =
|
||||
"se_" + UUID.randomUUID().toString()
|
||||
/* Preserve the existing Android meaning: started_at is assigned when
|
||||
* the completed session is saved, not when its draft is first opened. */
|
||||
val startedAt = OffsetDateTime.now().toString()
|
||||
val sessionValues =
|
||||
ContentValues().apply {
|
||||
val sessionId: String
|
||||
val sessionRowId: Long
|
||||
if (sourceSessionId == null) {
|
||||
sessionId = "se_" + UUID.randomUUID().toString()
|
||||
/* Preserve the existing Android meaning: started_at is assigned
|
||||
* when a new completed session is saved. */
|
||||
val sessionValues = ContentValues().apply {
|
||||
put("session_id", sessionId)
|
||||
put("started_at", startedAt)
|
||||
put("started_at", OffsetDateTime.now().toString())
|
||||
put("session_type", draft.sessionType.wireValue)
|
||||
}
|
||||
val sessionRowId =
|
||||
db.insertOrThrow(
|
||||
"sessions",
|
||||
null,
|
||||
sessionValues,
|
||||
sessionRowId = db.insertOrThrow("sessions", null, sessionValues)
|
||||
} else {
|
||||
check(draft.sessionType == SessionType.MAX_TEST) {
|
||||
"Seul un Test max peut remplacer une séance reprise."
|
||||
}
|
||||
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 {
|
||||
exerciseIndex,
|
||||
|
|
@ -3212,7 +3467,16 @@ class TrainlogRepository(
|
|||
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 ==
|
||||
RecordingMode.CONTINUOUS
|
||||
) {
|
||||
|
|
@ -3261,6 +3525,31 @@ class TrainlogRepository(
|
|||
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(
|
||||
cursor: android.database.Cursor,
|
||||
offset: Int,
|
||||
|
|
@ -3301,6 +3590,7 @@ class TrainlogRepository(
|
|||
private data class ActiveDraftHeader(
|
||||
val sessionType: SessionType,
|
||||
val form: SessionDraftForm,
|
||||
val sourceSessionId: String?,
|
||||
val updatedAt: String,
|
||||
val warning: String?,
|
||||
)
|
||||
|
|
@ -3312,7 +3602,21 @@ class TrainlogRepository(
|
|||
|
||||
private fun validateSessionExercise(
|
||||
draft: SessionExerciseDraft,
|
||||
sessionType: SessionType,
|
||||
): 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 (
|
||||
draft.exercise.recordingMode
|
||||
) {
|
||||
|
|
@ -3469,7 +3773,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
8,
|
||||
9,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -3567,6 +3871,11 @@ private class TrainlogDatabaseHelper(
|
|||
version = 8
|
||||
}
|
||||
|
||||
if (version < 9 && newVersion >= 9) {
|
||||
migrateExplicitMaxResultsToVersionNine(db)
|
||||
version = 9
|
||||
}
|
||||
|
||||
if (version != newVersion) {
|
||||
error(
|
||||
"Unsupported Android DB upgrade " +
|
||||
|
|
@ -3866,6 +4175,18 @@ private class TrainlogDatabaseHelper(
|
|||
);
|
||||
""".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,
|
||||
selected_exercise_label 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,
|
||||
reps_text TEXT NOT NULL,
|
||||
duration_text TEXT NOT NULL,
|
||||
|
|
@ -4047,6 +4371,18 @@ private class TrainlogDatabaseHelper(
|
|||
);
|
||||
""".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(
|
||||
|
|
@ -4106,6 +4442,12 @@ private class TrainlogDatabaseHelper(
|
|||
) {
|
||||
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 exercise_equipment RENAME TO 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;")
|
||||
}
|
||||
|
||||
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(
|
||||
db: SQLiteDatabase,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ data class SessionExerciseDraft(
|
|||
val exercise: ExerciseProfile,
|
||||
/** Stable canonical equipment ID selected for this occurrence, if any. */
|
||||
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 continuousDurationSeconds: Int = 0,
|
||||
val speedKmh: Double? = null,
|
||||
|
|
@ -47,6 +53,8 @@ data class SessionDraftForm(
|
|||
val editingExerciseIndex: Int? = null,
|
||||
val editingEntryId: 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 repsText: String = "3x10",
|
||||
val weightText: String = "",
|
||||
|
|
@ -63,6 +71,8 @@ data class SessionDraftForm(
|
|||
data class ActiveSessionDraft(
|
||||
val exercises: List<SessionExerciseDraft> = emptyList(),
|
||||
val sessionType: SessionType = SessionType.TRAINING,
|
||||
/** Existing max_test session updated atomically on finalization, if any. */
|
||||
val sourceSessionId: String? = null,
|
||||
val form: SessionDraftForm = SessionDraftForm(),
|
||||
val updatedAt: String = "",
|
||||
)
|
||||
|
|
@ -79,10 +89,13 @@ data class SessionExerciseDetail(
|
|||
val entryId: String,
|
||||
val exerciseId: String,
|
||||
val exerciseName: String,
|
||||
val equipmentId: String? = null,
|
||||
val equipmentDisplayName: String? = null,
|
||||
val recordingMode: RecordingMode,
|
||||
val trackingMode: TrackingMode,
|
||||
val dataFields: Int,
|
||||
/** Explicit max result; null also represents a preserved legacy max entry. */
|
||||
val maxWeightKg: Double? = null,
|
||||
val sets: List<SessionSetDraft> = emptyList(),
|
||||
val continuousDurationSeconds: Int = 0,
|
||||
val speedKmh: Double? = null,
|
||||
|
|
@ -93,3 +106,11 @@ data class SessionDetail(
|
|||
val summary: SessionSummary,
|
||||
val exercises: List<SessionExerciseDetail>,
|
||||
)
|
||||
|
||||
data class LatestExerciseMax(
|
||||
val exerciseId: String,
|
||||
val exerciseName: String,
|
||||
val maxWeightKg: Double,
|
||||
val startedAt: String,
|
||||
val equipmentDisplayName: String? = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ fun HistoryScreen(
|
|||
remember {
|
||||
repository.listSessions()
|
||||
}
|
||||
val latestMaxima =
|
||||
remember {
|
||||
repository.listLatestExerciseMaxima()
|
||||
}
|
||||
|
||||
TrainlogScreen(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionExerciseDetail
|
||||
|
|
@ -18,6 +19,7 @@ fun SessionDetailScreen(
|
|||
repository: TrainlogRepository,
|
||||
sessionId: String?,
|
||||
onBack: () -> Unit,
|
||||
onResumeMaxTest: () -> Unit,
|
||||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
|
|
@ -25,6 +27,7 @@ fun SessionDetailScreen(
|
|||
var revision by remember(sessionId) { mutableStateOf(0) }
|
||||
var editingEntryId by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
var equipmentQuery by remember(sessionId) { mutableStateOf("") }
|
||||
var resumeMessage by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
|
||||
val detail =
|
||||
remember(sessionId, revision) {
|
||||
|
|
@ -93,6 +96,21 @@ fun SessionDetailScreen(
|
|||
TrainlogInfo(
|
||||
"${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
|
||||
|
|
@ -143,7 +161,14 @@ fun SessionDetailScreen(
|
|||
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 ==
|
||||
RecordingMode.CONTINUOUS
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@ fun SessionScreen(
|
|||
},
|
||||
)
|
||||
|
||||
if (currentDraft.sourceSessionId == null) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Retirer ${draft.exercise.name}",
|
||||
|
|
@ -337,6 +338,7 @@ fun SessionScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val editingExercise =
|
||||
currentDraft.form.selectedExercise
|
||||
|
|
@ -344,10 +346,12 @@ fun SessionScreen(
|
|||
if (editingExercise != null) {
|
||||
SessionExerciseForm(
|
||||
key =
|
||||
editingExercise.exerciseId,
|
||||
"${editingExercise.exerciseId}:${currentDraft.sessionType.wireValue}:" +
|
||||
currentDraft.form.editingEntryId.orEmpty(),
|
||||
repository = repository,
|
||||
exercise =
|
||||
editingExercise,
|
||||
sessionType = currentDraft.sessionType,
|
||||
initialForm =
|
||||
currentDraft.form,
|
||||
onFormChanged = {
|
||||
|
|
@ -688,6 +692,7 @@ private fun SessionExerciseForm(
|
|||
key: String,
|
||||
repository: TrainlogRepository,
|
||||
exercise: ExerciseProfile,
|
||||
sessionType: SessionType,
|
||||
initialForm: SessionDraftForm,
|
||||
onFormChanged: (SessionDraftForm) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
|
|
@ -719,6 +724,11 @@ private fun SessionExerciseForm(
|
|||
)
|
||||
}
|
||||
|
||||
var maxWeightText by
|
||||
remember(key) {
|
||||
mutableStateOf(initialForm.maxWeightText)
|
||||
}
|
||||
|
||||
var durationText by
|
||||
remember(key) {
|
||||
mutableStateOf(
|
||||
|
|
@ -760,10 +770,7 @@ private fun SessionExerciseForm(
|
|||
"SAISIE — ${exercise.name}"
|
||||
) {
|
||||
TrainlogInfo(
|
||||
text =
|
||||
exerciseProfileLabel(
|
||||
exercise
|
||||
),
|
||||
text = if (sessionType == SessionType.MAX_TEST) "TEST MAX" else exerciseProfileLabel(exercise),
|
||||
color = colors.accent,
|
||||
)
|
||||
|
||||
|
|
@ -787,7 +794,7 @@ private fun SessionExerciseForm(
|
|||
customEquipmentName = ""
|
||||
equipmentRevision += 1
|
||||
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.Conflict -> error = "Cette machine existe déjà."
|
||||
|
|
@ -803,7 +810,7 @@ private fun SessionExerciseForm(
|
|||
accent = colors.success,
|
||||
onClick = {
|
||||
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,
|
||||
onClick = {
|
||||
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 ==
|
||||
RecordingMode.SETS
|
||||
) {
|
||||
|
|
@ -1021,6 +1048,7 @@ private fun SessionExerciseForm(
|
|||
buildSessionExerciseDraft(
|
||||
exercise =
|
||||
exercise,
|
||||
sessionType = sessionType,
|
||||
setCountText =
|
||||
setCountText,
|
||||
repsText =
|
||||
|
|
@ -1033,12 +1061,17 @@ private fun SessionExerciseForm(
|
|||
distanceText,
|
||||
equipmentId = selectedEquipmentId,
|
||||
weightText = weightText,
|
||||
maxWeightText = maxWeightText,
|
||||
entryId = initialForm.editingEntryId,
|
||||
)
|
||||
|
||||
if (draft == null) {
|
||||
error =
|
||||
if (sessionType == SessionType.MAX_TEST) {
|
||||
"Saisissez un poids max strictement positif (ex. 100 ou 86,5)."
|
||||
} else {
|
||||
"Valeurs invalides."
|
||||
}
|
||||
} else {
|
||||
onAdd(draft)
|
||||
}
|
||||
|
|
@ -1077,6 +1110,7 @@ private fun currentForm(
|
|||
distanceText: String,
|
||||
equipmentId: String? = null,
|
||||
weightText: String = "",
|
||||
maxWeightText: String = "",
|
||||
): SessionDraftForm =
|
||||
SessionDraftForm(
|
||||
selectedExercise = exercise,
|
||||
|
|
@ -1084,6 +1118,7 @@ private fun currentForm(
|
|||
setCountText = setCountText,
|
||||
repsText = repsText,
|
||||
weightText = weightText,
|
||||
maxWeightText = maxWeightText,
|
||||
durationText = durationText,
|
||||
speedText = speedText,
|
||||
distanceText = distanceText,
|
||||
|
|
@ -1259,6 +1294,7 @@ private fun parseRepSequence(
|
|||
|
||||
private fun buildSessionExerciseDraft(
|
||||
exercise: ExerciseProfile,
|
||||
sessionType: SessionType,
|
||||
setCountText: String,
|
||||
repsText: String,
|
||||
durationText: String,
|
||||
|
|
@ -1266,8 +1302,22 @@ private fun buildSessionExerciseDraft(
|
|||
distanceText: String,
|
||||
equipmentId: String? = null,
|
||||
weightText: String = "",
|
||||
maxWeightText: String = "",
|
||||
entryId: String? = null,
|
||||
): 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 (
|
||||
exercise.recordingMode ==
|
||||
RecordingMode.CONTINUOUS
|
||||
|
|
@ -1404,6 +1454,7 @@ private fun formForExistingExercise(
|
|||
editingExerciseIndex = index,
|
||||
editingEntryId = draft.entryId,
|
||||
selectedEquipmentId = draft.equipmentId,
|
||||
maxWeightText = draft.maxWeightKg?.let(::formatMaxWeight).orEmpty(),
|
||||
setCountText = draft.sets.size.toString(),
|
||||
repsText = if (draft.exercise.trackingMode == TrackingMode.REPS) {
|
||||
draft.sets.joinToString(",") { it.reps.toString() }
|
||||
|
|
@ -1440,7 +1491,9 @@ private fun parseWeightSequence(text: String, count: Int): List<Double?>? {
|
|||
private fun draftSummary(
|
||||
draft: SessionExerciseDraft,
|
||||
): String {
|
||||
return if (
|
||||
return if (draft.maxWeightKg != null) {
|
||||
"${draft.exercise.name} · Max : ${formatMaxWeight(draft.maxWeightKg)} kg"
|
||||
} else if (
|
||||
draft.exercise.recordingMode ==
|
||||
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(
|
||||
exercise: ExerciseProfile,
|
||||
): String {
|
||||
|
|
|
|||
|
|
@ -285,6 +285,10 @@ fun TrainlogApp(
|
|||
screen =
|
||||
TrainlogScreenId.HISTORY
|
||||
},
|
||||
onResumeMaxTest = {
|
||||
draftRevision += 1
|
||||
screen = TrainlogScreenId.SESSION
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogScreenId.SYNC ->
|
||||
|
|
|
|||
|
|
@ -1383,7 +1383,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(8, cursor.getInt(0))
|
||||
assertEquals(9, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -1510,7 +1510,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
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 ->
|
||||
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 {
|
||||
return TrainlogRepository(context, databaseName).also { repository = it }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ result.
|
|||
|
||||
Before applying the PC catalog or its V2 artifacts, Android applies
|
||||
`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`.
|
||||
|
||||
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
|
||||
from large repetition or duration values.
|
||||
|
||||
Android's current session form still records the exercise data fields it
|
||||
supports. Measured-max classification on the desktop uses only actual values
|
||||
that were truly captured and synchronized.
|
||||
Its exercise form contains only the existing exercise search, optional
|
||||
machine/equipment selection, and `Poids max (kg)`. French decimal commas are
|
||||
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
|
||||
|
||||
|
|
@ -414,7 +428,7 @@ frozen desktop/Python normalization contract uses NFC, Unicode whitespace
|
|||
collapse and case folding without accent removal. Existing Marche/Leg press
|
||||
data is unaffected, but changing this safely requires an explicit Android
|
||||
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,
|
||||
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
|
||||
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
|
||||
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
|
||||
currently accepts known supplemental bits on either recording mode. Supplied
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ Continuous work is persisted separately from performed sets.
|
|||
|
||||
### 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
|
||||
occur more than once in one session without identity fusion.
|
||||
|
||||
|
|
@ -132,13 +132,17 @@ sessions
|
|||
session_exercises
|
||||
performed_sets
|
||||
continuous_activity
|
||||
max_results
|
||||
body_observations
|
||||
custom_equipment
|
||||
```
|
||||
|
||||
### 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
|
||||
coupled to the desktop schema.
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
|
|||
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
|
||||
DESKTOP_SCHEMA_V8=PASS
|
||||
ANDROID_LOCAL_DATABASE_V8=PASS
|
||||
DESKTOP_SCHEMA_V9=PASS
|
||||
ANDROID_LOCAL_DATABASE_V9=PASS
|
||||
ANDROID_SESSION_DRAFT_V1=PASS
|
||||
ANDROID_DRAFT_DURABLE=PASS
|
||||
ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS
|
||||
|
|
@ -32,6 +32,10 @@ EXERCISE_RENAME_STABLE_ID=PASS
|
|||
ANDROID_BANNER_PARITY_V1=PASS
|
||||
ANDROID_INSTALL_ADB=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
|
||||
CONTINUOUS_ACTIVITY=PASS
|
||||
|
|
@ -50,10 +54,12 @@ MULTI_OCCURRENCE_SESSION_V2=PASS
|
|||
EQUIPMENT_ASSOCIATIONS_V2=PASS
|
||||
EQUIPMENT_DEFINITIONS_V1=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
|
||||
HARDWARE_SYNC_VALIDATION=HISTORICAL_BASELINE_ONLY
|
||||
HARDWARE_SYNC_VALIDATION=PASS
|
||||
```
|
||||
|
||||
## Desktop
|
||||
|
|
@ -61,9 +67,9 @@ HARDWARE_SYNC_VALIDATION=HISTORICAL_BASELINE_ONLY
|
|||
Implemented:
|
||||
|
||||
- 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
|
||||
definitions;
|
||||
definitions, plus occurrence-owned `max_results`;
|
||||
- direct session entry;
|
||||
- persisted session detail and editing;
|
||||
- exercise removal from a session through transactional child replacement;
|
||||
|
|
@ -215,10 +221,10 @@ No mounted Android filesystem is required.
|
|||
Desktop:
|
||||
|
||||
```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
|
||||
import-contract validator PASS
|
||||
ASan/UBSan 32/32 Meson tests PASS
|
||||
ASan/UBSan 34/34 Meson tests PASS
|
||||
git diff --check PASS
|
||||
```
|
||||
|
||||
|
|
@ -257,6 +263,8 @@ ordering change was made by this corrective tranche.
|
|||
MEASURED_MAX_V1=PASS
|
||||
WORKING_LOAD_PERCENTAGES=PASS
|
||||
ANDROID_MAX_TEST_SESSION=PASS
|
||||
EXPLICIT_MAX_RESULTS_V1=PASS
|
||||
MAX_TEST_RESUME_STABLE_ID=PASS
|
||||
CURRENT_OPERATIONAL_CURSOR=REAL_DATA_BASELINE_V1
|
||||
NEXT_FEATURE=GYM_CATALOG_V1
|
||||
```
|
||||
|
|
@ -272,11 +280,22 @@ MEASURED_MAX_ONLY_FROM_MAX_TEST=PASS
|
|||
WORKING_LOAD_PERCENTAGES=PASS
|
||||
ASSISTANCE_DIRECTION_AWARE=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
|
||||
training is never promoted implicitly.
|
||||
A measured maximum belongs to an exercise occurrence in an explicit `max_test`
|
||||
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
|
||||
record compares max tests using the same load mode.
|
||||
|
|
@ -293,7 +312,7 @@ BODY_COMPOSITION_ESTIMATE=PASS
|
|||
BODY_PROPORTION_RATIOS=PASS
|
||||
BODY_SYMMETRY_ANALYTICS=PASS
|
||||
NO_ESTIMATE_PERSISTENCE=PASS
|
||||
DESKTOP_TESTS=32/32 PASS
|
||||
DESKTOP_TESTS=34/34 PASS
|
||||
```
|
||||
|
||||
Android remains capture-only for this feature.
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
## 1. Status
|
||||
|
||||
```text
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=8
|
||||
DATABASE_SCHEMA_V8=PASS
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=9
|
||||
DATABASE_SCHEMA_V9=PASS
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
```
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ PRAGMA user_version;
|
|||
Current value:
|
||||
|
||||
```text
|
||||
8
|
||||
9
|
||||
```
|
||||
|
||||
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`;
|
||||
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
|
||||
`user_version` is not an acceptable migration test.
|
||||
|
||||
|
|
@ -188,6 +194,19 @@ distance_km nullable
|
|||
|
||||
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`
|
||||
|
||||
```text
|
||||
|
|
@ -296,17 +315,21 @@ distance km
|
|||
|
||||
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
|
||||
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 |
|
||||
| --- | --- |
|
||||
| `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_performed_sets` | Ordered heterogeneous repetition or duration actuals |
|
||||
| `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 |
|
||||
| `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.
|
||||
|
||||
Migration tests cover historical v4 and v7 shapes rather than changing only
|
||||
`user_version`. The prior physical Samsung migration preserved every existing
|
||||
domain row, with successful integrity and foreign-key checks. Current
|
||||
reconciliation validation additionally uses coherent Android and desktop v8
|
||||
copies whose integrity and foreign keys are checked before and after import.
|
||||
SQLite files are never synchronization artifacts.
|
||||
`user_version`. The physical Samsung and desktop v9 migrations preserved every
|
||||
existing domain row after fresh coherent backups, with successful integrity and
|
||||
foreign-key checks. Reconciliation validation also uses coherent Android and
|
||||
desktop v9 copies before applying migrations to the real stores. SQLite files
|
||||
are never synchronization artifacts.
|
||||
|
||||
The schemas deliberately differ where ownership differs. Desktop sessions own
|
||||
`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
|
||||
instead of filtering or ranking it through those relations. The tables are
|
||||
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.
|
||||
|
||||
|
|
@ -356,21 +379,24 @@ Migration-specific regression coverage includes:
|
|||
schema_v5_migration
|
||||
schema_v7_migration
|
||||
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.
|
||||
|
||||
The existing `sessions.session_type = max_test` classification plus actual
|
||||
`performed_sets` are sufficient.
|
||||
Schema v9 persists a weight maximum in `max_results`, separate from
|
||||
`performed_sets`. A new max occurrence therefore contains no repetition or set
|
||||
count. Only `sessions.session_type = max_test` may own this row.
|
||||
|
||||
Exercise performance points carry the originating session type so the
|
||||
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
|
||||
training session
|
||||
|
|
@ -388,12 +414,14 @@ max_test + no load
|
|||
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
|
||||
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
|
||||
|
||||
|
|
@ -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
|
||||
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
|
||||
set rows. Schema v7 assigns stable `entry_id` values to completed and draft
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
by the user.
|
||||
|
|
|
|||
|
|
@ -236,9 +236,31 @@ Continuous activity must never be:
|
|||
|
||||
`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
|
||||
external
|
||||
|
|
|
|||
|
|
@ -14,19 +14,21 @@ GATE_1=PASS
|
|||
GATE_2=PASS
|
||||
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
DESKTOP_SCHEMA_V8=PASS
|
||||
ANDROID_LOCAL_DATABASE_V8=PASS
|
||||
DESKTOP_SCHEMA_V9=PASS
|
||||
ANDROID_LOCAL_DATABASE_V9=PASS
|
||||
|
||||
DIRECT_MTP_TRANSPORT=PASS
|
||||
BIDIRECTIONAL_SYNC_V1=PASS
|
||||
|
||||
VARIABLE_REPETITION_SETS=PASS
|
||||
MEASURED_MAX_V1=PASS
|
||||
EXPLICIT_MAX_RESULTS_V1=PASS
|
||||
MAX_TEST_RESUME_STABLE_ID=PASS
|
||||
BODY_ANALYTICS_V1=PASS
|
||||
EXERCISE_EDIT_V1=PASS
|
||||
ANDROID_BANNER_PARITY_V1=PASS
|
||||
|
||||
DESKTOP_TESTS=32/32 PASS
|
||||
DESKTOP_TESTS=34/34 PASS
|
||||
TUI_NOTCURSES_V1=PASS
|
||||
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
|
||||
NOTCURSES_TRUECOLOR_THEME=PASS
|
||||
|
|
@ -43,6 +45,7 @@ The current product baseline includes:
|
|||
- session history and editing;
|
||||
- body measurements and body history;
|
||||
- measured-max sessions and measured-max analysis;
|
||||
- explicit max-weight capture without synthetic sets, with stable-ID continuation;
|
||||
- desktop body analytics;
|
||||
- direct USB/MTP transport;
|
||||
- bidirectional Android/PC synchronization;
|
||||
|
|
@ -97,7 +100,7 @@ Expected baseline:
|
|||
- first real training sessions;
|
||||
- Android used for capture;
|
||||
- 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.
|
||||
|
||||
Gate:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ MULTI_OCCURRENCE_SESSION_V2=PASS
|
|||
EQUIPMENT_ASSOCIATIONS_V2=PASS
|
||||
EQUIPMENT_DEFINITIONS_V1=PASS
|
||||
EXERCISE_RECONCILIATION_V2=PASS
|
||||
EXPLICIT_MAX_RESULTS_V2=PASS
|
||||
|
||||
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
|
||||
```
|
||||
|
|
@ -132,6 +133,7 @@ entry_id stable occurrence identity
|
|||
position stable order within session
|
||||
equipment_id optional canonical equipment identity
|
||||
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,
|
||||
|
|
@ -181,6 +183,19 @@ continuous
|
|||
|
||||
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
|
||||
local during synchronization. Only successful atomic finalization makes a draft
|
||||
a completed exportable session. The v1 artifact has no draft fields or tables;
|
||||
|
|
@ -207,6 +222,7 @@ profile conflict rejection
|
|||
heterogeneous performed-set preservation
|
||||
targetless schema-v5 import when no true target exists
|
||||
continuous activity kept separate
|
||||
explicit max result kept separate from sets
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
Synchronization owns:
|
||||
|
|
@ -485,12 +509,14 @@ receipt publication/readback PASS
|
|||
multiple distinct Android request IDs PASS
|
||||
```
|
||||
|
||||
The current reconciliation checkpoint pulled the real Android v8 database and
|
||||
shared-storage artifacts through ADB for read-only inspection, then ran the
|
||||
production definition, V2 mobile, association, body and outbound exporters on a
|
||||
coherent desktop v8 copy. The second inbound/outbound replay was stable and both
|
||||
SQLite integrity checks passed. This is real-data importer/exporter evidence,
|
||||
not a claim that the current libmtp transport ran inside the sandbox.
|
||||
The current reconciliation checkpoint first ran the production definition, V2
|
||||
mobile, association, body and outbound exporters on coherent Android and
|
||||
desktop copies. The explicit-max migration was then applied to the real stores:
|
||||
the identified session retained all stable identities, converted eight
|
||||
unambiguous rows, retained its continuous warm-up, and passed integrity and
|
||||
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
|
||||
four production PC exporters into the production Android repository importers.
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ sync_direction
|
|||
sync_history
|
||||
sync_screen_action
|
||||
measured_max
|
||||
max_results
|
||||
max_sync
|
||||
body_analytics
|
||||
terminal_input_event_type_policy
|
||||
```
|
||||
|
|
@ -103,7 +105,7 @@ terminal_input_event_type_policy
|
|||
Validated current suite:
|
||||
|
||||
```text
|
||||
32/32 Meson tests PASS
|
||||
34/34 Meson tests PASS
|
||||
```
|
||||
|
||||
The desktop executable is additionally smoke-checked in isolated tmux PTYs at
|
||||
|
|
@ -120,7 +122,9 @@ Notable regression coverage:
|
|||
- continuous activity without fake sets;
|
||||
- repetition shorthand/list/pyramid parsing;
|
||||
- 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;
|
||||
- V2 explicit-max Android -> desktop -> Android replay and resumed same-session update;
|
||||
- Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a
|
||||
RELEASE event is consumed without creating a second navigation action.
|
||||
- targetless mobile SETS persistence;
|
||||
|
|
@ -315,7 +319,7 @@ Coverage proves:
|
|||
Validated current normal suite:
|
||||
|
||||
```text
|
||||
32/32 Meson tests PASS
|
||||
34/34 Meson tests PASS
|
||||
```
|
||||
|
||||
## 12. Body analytics regression
|
||||
|
|
@ -340,13 +344,13 @@ Coverage includes:
|
|||
Validated current normal suite:
|
||||
|
||||
```text
|
||||
32/32 Meson tests PASS
|
||||
34/34 Meson tests PASS
|
||||
```
|
||||
|
||||
## 13. Android session draft v1
|
||||
|
||||
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
|
||||
includes exercise
|
||||
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,
|
||||
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
|
||||
cd android
|
||||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
|
||||
|
|
|
|||
19
docs/tui.md
19
docs/tui.md
|
|
@ -327,11 +327,28 @@ meson test -C build --print-errorlogs
|
|||
Validated current normal suite:
|
||||
|
||||
```text
|
||||
32/32 Meson tests PASS
|
||||
34/34 Meson tests PASS
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ CREATE TABLE continuous_activity(
|
|||
session_exercise_row_id INTEGER NOT NULL UNIQUE REFERENCES session_exercises(id) ON DELETE CASCADE,
|
||||
duration_seconds INTEGER NOT NULL, speed_kmh REAL, distance_km REAL
|
||||
);
|
||||
CREATE TABLE 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 NOT NULL UNIQUE, observed_at TEXT NOT NULL,
|
||||
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_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
118
tests/test_max_sync.py
Normal 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()
|
||||
|
|
@ -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 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);
|
||||
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=8;
|
||||
PRAGMA user_version=9;
|
||||
"""
|
||||
|
||||
def payload():
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = sqlite3.connect(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] != 8:
|
||||
raise ValueError("schema desktop v8 requis")
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9):
|
||||
raise ValueError("schema desktop v8 ou v9 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ def main():
|
|||
connection = sqlite3.connect(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] != 8:
|
||||
raise ValueError("schema desktop v8 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9):
|
||||
raise ValueError("schema desktop v8 ou v9 requis")
|
||||
equipment = [dict(row) for row in connection.execute(
|
||||
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
||||
"FROM custom_equipment ORDER BY equipment_id")]
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ def main():
|
|||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||
if version != 8:
|
||||
if version not in (8, 9):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ def main():
|
|||
con = sqlite3.connect(args.database)
|
||||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] != 8:
|
||||
raise ValueError("schema desktop v8 requis")
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] != 9:
|
||||
raise ValueError("schema desktop v9 requis")
|
||||
known_equipment = supplied_equipment_ids()
|
||||
known_equipment.update(row[0] for row in con.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
|
|
@ -45,7 +45,7 @@ def main():
|
|||
payload["exercises"] = []
|
||||
# INVARIANT: tracking mode is catalogue metadata. v7 occurrences
|
||||
# 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"],)):
|
||||
# CONTRACT: references remain in mobile-export v2 unchanged;
|
||||
# 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"],
|
||||
"data_fields": entry["data_fields"], "load_mode": "none", "rest_seconds": 0,
|
||||
"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()
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -113,8 +113,8 @@ def main():
|
|||
fail("clés extension équipement invalides")
|
||||
connection = sqlite3.connect(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] != 8:
|
||||
fail("schema desktop v8 requis")
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9):
|
||||
fail("schema desktop v8 ou v9 requis")
|
||||
known = load_catalog(args.catalog)
|
||||
known.update(row[0] for row in connection.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ def main():
|
|||
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
|
||||
connection = sqlite3.connect(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] != 8:
|
||||
fail("schema desktop v8 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9):
|
||||
fail("schema desktop v8 ou v9 requis")
|
||||
imported = skipped = 0
|
||||
# Validate every same-ID row before inserting any definition.
|
||||
for definition in definitions:
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ 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 = {
|
||||
|
|
@ -405,13 +405,14 @@ def validate_session_exercise(
|
|||
item,
|
||||
label,
|
||||
known_exercise_ids,
|
||||
session_type,
|
||||
):
|
||||
is_v2 = "entry_id" in item or "position" in item or "equipment_id" in item
|
||||
require_exact_keys(
|
||||
item,
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
@ -469,6 +470,21 @@ def validate_session_exercise(
|
|||
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 "sets" in item:
|
||||
raise ImportFailure(
|
||||
|
|
@ -634,6 +650,7 @@ def validate_sessions(
|
|||
exercise,
|
||||
exercise_label,
|
||||
known_exercise_ids,
|
||||
session["session_type"],
|
||||
)
|
||||
|
||||
equipment_id = exercise.get("equipment_id")
|
||||
|
|
@ -738,11 +755,11 @@ def require_supported_schema(connection):
|
|||
"PRAGMA user_version;"
|
||||
).fetchone()[0]
|
||||
|
||||
# CONTRACT: desktop startup migrates canonical databases through v8; the
|
||||
# mobile-v2 tables used below retain their v7 shape in v8.
|
||||
if version not in (5, 6, 7, 8):
|
||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||
# readable for legacy artifacts and are never made to fake that table.
|
||||
if version not in (5, 6, 7, 8, 9):
|
||||
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(
|
||||
connection,
|
||||
payload,
|
||||
|
|
@ -1278,9 +1335,24 @@ def import_sessions(
|
|||
(session_row_id,)).fetchall()
|
||||
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)
|
||||
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
|
||||
# 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"])
|
||||
if not legacy and session_semantically_matches(
|
||||
connection,
|
||||
|
|
@ -1290,12 +1362,18 @@ def import_sessions(
|
|||
):
|
||||
report["sessions_skipped"] += 1
|
||||
continue
|
||||
if not legacy:
|
||||
if not legacy and not resumable_max:
|
||||
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
|
||||
# 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 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,))
|
||||
report["sessions_reconciled"] += 1
|
||||
else:
|
||||
|
|
@ -1359,7 +1437,15 @@ def import_sessions(
|
|||
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(
|
||||
connection,
|
||||
session_row_id,
|
||||
|
|
@ -1402,7 +1488,15 @@ def session_semantically_matches(
|
|||
item["recording_mode"], item["tracking_mode"],
|
||||
item["data_fields"], item.get("equipment_id")):
|
||||
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(
|
||||
"SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity "
|
||||
"WHERE session_exercise_row_id=?", (row[0],)).fetchone()
|
||||
|
|
@ -1544,6 +1638,15 @@ def run_import(
|
|||
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(
|
||||
"BEGIN IMMEDIATE;"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
#!/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
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
|
|
@ -29,7 +30,7 @@ INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid"
|
|||
|
||||
|
||||
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:
|
||||
|
|
@ -253,6 +254,15 @@ def validate_mobile_export_v2(document: Any) -> None:
|
|||
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:
|
||||
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"])
|
||||
def structural_errors(
|
||||
validator: jsonschema.Draft202012Validator,
|
||||
|
|
@ -347,7 +357,7 @@ def run_suite(validator: jsonschema.Draft202012Validator) -> int:
|
|||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
"""Parse command-line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate Trainlog v1 JSON structurally and semantically."
|
||||
description="Validate Trainlog v1 or mobile-export v2 JSON."
|
||||
)
|
||||
parser.add_argument(
|
||||
"paths",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 8
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 9
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
@ -188,6 +188,9 @@ typedef struct TrainlogPersistedExerciseDetail {
|
|||
int has_target_weight;
|
||||
double target_weight_kg;
|
||||
|
||||
int has_max_weight;
|
||||
double max_weight_kg;
|
||||
|
||||
int continuous_duration_seconds;
|
||||
int has_continuous_speed;
|
||||
double continuous_speed_kmh;
|
||||
|
|
@ -271,9 +274,12 @@ typedef struct TrainlogExercisePerformancePoint {
|
|||
TrainlogLoadMode load_mode;
|
||||
size_t actual_set_count;
|
||||
int has_performance;
|
||||
/* Explicit schema-v9 max: weight is the result; metric_value is internal. */
|
||||
int has_explicit_max;
|
||||
int metric_value;
|
||||
int has_weight;
|
||||
double weight_kg;
|
||||
char equipment_id[TRAINLOG_ID_MAX + 1U];
|
||||
} TrainlogExercisePerformancePoint;
|
||||
|
||||
/**
|
||||
|
|
@ -305,6 +311,8 @@ typedef struct TrainlogEditableExerciseRecord {
|
|||
char equipment_id[TRAINLOG_ID_MAX + 1U];
|
||||
char name[TRAINLOG_NAME_MAX + 1U];
|
||||
TrainlogTrackingMode tracking_mode;
|
||||
TrainlogRecordingMode recording_mode;
|
||||
TrainlogExerciseDataFields data_fields;
|
||||
TrainlogLoadMode load_mode;
|
||||
int rest_seconds;
|
||||
int target_sets;
|
||||
|
|
@ -312,6 +320,13 @@ typedef struct TrainlogEditableExerciseRecord {
|
|||
int target_duration_seconds;
|
||||
int has_target_weight;
|
||||
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];
|
||||
size_t set_offset;
|
||||
size_t set_count;
|
||||
|
|
|
|||
|
|
@ -75,6 +75,13 @@ typedef struct TrainlogSessionExerciseInput {
|
|||
bool target_has_weight;
|
||||
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;
|
||||
bool continuous_has_speed;
|
||||
double continuous_speed_kmh;
|
||||
|
|
|
|||
|
|
@ -196,6 +196,15 @@ test(
|
|||
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',
|
||||
'tests/test_session_edit.c',
|
||||
|
|
@ -379,6 +388,12 @@ test(
|
|||
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(
|
||||
'equipment_associations_exchange',
|
||||
python3_trainlog_tests,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
#include "trainlog/equipment_catalog.h"
|
||||
#include "trainlog/id.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.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'))"
|
||||
");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 =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"ALTER TABLE sessions "
|
||||
|
|
@ -667,10 +694,18 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
if (status == TRAINLOG_STATUS_OK) {
|
||||
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) {
|
||||
/* CONTRACT: v7 is the immediate historic schema and must open through
|
||||
* its lossless custom-equipment-table migration. */
|
||||
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 {
|
||||
if (version == 1) {
|
||||
status =
|
||||
|
|
@ -795,6 +830,9 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = execute_sql(database, MIGRATE_V7_TO_V8_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
@ -804,7 +842,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
set_open_diagnostic(
|
||||
output_diagnostic,
|
||||
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,
|
||||
SQLITE_ERROR
|
||||
);
|
||||
|
|
@ -1814,7 +1852,21 @@ static TrainlogStatus insert_session_exercise(
|
|||
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) {
|
||||
bool speed_required =
|
||||
(input->data_fields &
|
||||
|
|
@ -2240,9 +2292,50 @@ static TrainlogStatus insert_performed_set(
|
|||
: 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(
|
||||
TrainlogDatabase *database,
|
||||
sqlite3_int64 session_row_id,
|
||||
TrainlogSessionType session_type,
|
||||
const TrainlogSessionExerciseInput *exercises,
|
||||
size_t exercise_count
|
||||
)
|
||||
|
|
@ -2266,6 +2359,11 @@ static TrainlogStatus insert_session_children(
|
|||
size_t set_index;
|
||||
TrainlogStatus status;
|
||||
|
||||
if (exercise->has_max_weight &&
|
||||
session_type != TRAINLOG_SESSION_MAX_TEST) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
status = insert_session_exercise(
|
||||
database,
|
||||
session_row_id,
|
||||
|
|
@ -2278,6 +2376,18 @@ static TrainlogStatus insert_session_children(
|
|||
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 ==
|
||||
TRAINLOG_RECORDING_CONTINUOUS) {
|
||||
status = insert_continuous_activity(
|
||||
|
|
@ -2349,6 +2459,7 @@ TrainlogStatus trainlog_database_insert_session(
|
|||
status = insert_session_children(
|
||||
database,
|
||||
session_row_id,
|
||||
session->session_type,
|
||||
session->exercises,
|
||||
session->exercise_count
|
||||
);
|
||||
|
|
@ -2377,6 +2488,7 @@ TrainlogStatus trainlog_database_replace_session_exercises(
|
|||
{
|
||||
sqlite3_int64 session_row_id;
|
||||
sqlite3_stmt *statement = NULL;
|
||||
TrainlogSessionType session_type;
|
||||
TrainlogStatus status;
|
||||
int rc;
|
||||
|
||||
|
|
@ -2404,6 +2516,32 @@ TrainlogStatus trainlog_database_replace_session_exercises(
|
|||
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(
|
||||
database->connection,
|
||||
"DELETE FROM session_exercises "
|
||||
|
|
@ -2441,6 +2579,7 @@ TrainlogStatus trainlog_database_replace_session_exercises(
|
|||
status = insert_session_children(
|
||||
database,
|
||||
session_row_id,
|
||||
session_type,
|
||||
exercises,
|
||||
exercise_count
|
||||
);
|
||||
|
|
@ -3058,6 +3197,7 @@ TrainlogStatus trainlog_database_get_session_details(
|
|||
"COALESCE(se.target_duration_seconds, 0), "
|
||||
"se.target_weight_kg, "
|
||||
"ca.duration_seconds, ca.speed_kmh, ca.distance_km, "
|
||||
"mr.max_weight_kg, "
|
||||
"se.equipment_id, "
|
||||
"se.entry_id, "
|
||||
"se.id "
|
||||
|
|
@ -3068,6 +3208,8 @@ TrainlogStatus trainlog_database_get_session_details(
|
|||
" ON e.id = se.exercise_row_id "
|
||||
"LEFT JOIN continuous_activity AS ca "
|
||||
" 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 "
|
||||
"ORDER BY se.position ASC;";
|
||||
|
||||
|
|
@ -3230,10 +3372,10 @@ TrainlogStatus trainlog_database_get_session_details(
|
|||
sqlite3_column_text(exercises, 4);
|
||||
|
||||
sqlite3_int64 session_exercise_row_id =
|
||||
sqlite3_column_int64(exercises, 15);
|
||||
sqlite3_column_int64(exercises, 16);
|
||||
const unsigned char *equipment_id =
|
||||
sqlite3_column_text(exercises, 13);
|
||||
const unsigned char *entry_id = sqlite3_column_text(exercises, 14);
|
||||
sqlite3_column_text(exercises, 14);
|
||||
const unsigned char *entry_id = sqlite3_column_text(exercises, 15);
|
||||
|
||||
TrainlogStatus status;
|
||||
|
||||
|
|
@ -3334,7 +3476,22 @@ TrainlogStatus trainlog_database_get_session_details(
|
|||
)
|
||||
: 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) {
|
||||
bool speed_required =
|
||||
(detail->data_fields &
|
||||
|
|
@ -3761,7 +3918,9 @@ TrainlogStatus trainlog_database_list_exercise_performance(
|
|||
"ps.id, "
|
||||
"ps.reps, "
|
||||
"ps.duration_seconds, "
|
||||
"ps.weight_kg "
|
||||
"ps.weight_kg, "
|
||||
"mr.max_weight_kg, "
|
||||
"COALESCE(se.equipment_id, '') "
|
||||
"FROM session_exercises AS se "
|
||||
"JOIN sessions AS s "
|
||||
" ON s.id = se.session_row_id "
|
||||
|
|
@ -3769,11 +3928,13 @@ TrainlogStatus trainlog_database_list_exercise_performance(
|
|||
" ON e.id = se.exercise_row_id "
|
||||
"LEFT JOIN performed_sets AS ps "
|
||||
" 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 "
|
||||
"ORDER BY "
|
||||
"s.started_at DESC, "
|
||||
"s.id DESC, "
|
||||
"ps.position ASC;";
|
||||
"se.position ASC, ps.position ASC;";
|
||||
|
||||
sqlite3_stmt *statement = NULL;
|
||||
TrainlogExercisePerformancePoint *current = NULL;
|
||||
|
|
@ -3864,6 +4025,8 @@ TrainlogStatus trainlog_database_list_exercise_performance(
|
|||
);
|
||||
|
||||
bool new_session;
|
||||
const unsigned char *equipment_id =
|
||||
sqlite3_column_text(statement, 10);
|
||||
|
||||
if (
|
||||
session_id == NULL ||
|
||||
|
|
@ -3871,6 +4034,8 @@ TrainlogStatus trainlog_database_list_exercise_performance(
|
|||
session_type == NULL ||
|
||||
tracking_mode == NULL ||
|
||||
load_mode == NULL
|
||||
|| equipment_id == NULL
|
||||
|| (size_t)sqlite3_column_bytes(statement, 10) > TRAINLOG_ID_MAX
|
||||
) {
|
||||
(void)sqlite3_finalize(
|
||||
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 &&
|
||||
sqlite3_column_type(
|
||||
statement,
|
||||
|
|
@ -4013,12 +4200,16 @@ TrainlogStatus trainlog_database_list_exercise_performance(
|
|||
)
|
||||
) {
|
||||
current->has_performance = 1;
|
||||
current->has_explicit_max = 0;
|
||||
current->metric_value =
|
||||
metric_value;
|
||||
current->has_weight =
|
||||
has_weight;
|
||||
current->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), "
|
||||
"se.target_weight_kg, "
|
||||
"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 "
|
||||
"JOIN sessions AS s "
|
||||
"ON s.id = se.session_row_id "
|
||||
"JOIN exercises AS e "
|
||||
"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 "
|
||||
"ORDER BY se.position ASC;";
|
||||
|
||||
|
|
@ -4348,6 +4545,22 @@ TrainlogStatus trainlog_database_load_session_editable(
|
|||
(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 =
|
||||
detail_load_mode_from_text(
|
||||
(const char *)load
|
||||
|
|
@ -4391,6 +4604,35 @@ TrainlogStatus trainlog_database_load_session_editable(
|
|||
)
|
||||
: 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(
|
||||
record->notes,
|
||||
sizeof(record->notes),
|
||||
|
|
|
|||
271
tui/src/tui.c
271
tui/src/tui.c
|
|
@ -664,6 +664,12 @@ static void exercise_short_date(
|
|||
output[10] = '\0';
|
||||
}
|
||||
|
||||
static void format_compact_max_weight(
|
||||
double value,
|
||||
char *output,
|
||||
size_t output_size
|
||||
);
|
||||
|
||||
static void exercise_format_performance(
|
||||
const TrainlogExercisePerformancePoint *point,
|
||||
char *output,
|
||||
|
|
@ -686,6 +692,13 @@ static void exercise_format_performance(
|
|||
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 ==
|
||||
TRAINLOG_TRACKING_DURATION) {
|
||||
char duration[64];
|
||||
|
|
@ -1754,6 +1767,8 @@ static void screen_exercise_measured_max(
|
|||
char record_text[128];
|
||||
char current_date[11];
|
||||
char record_date[11];
|
||||
TrainlogResolvedEquipment current_equipment;
|
||||
const char *current_equipment_label = "aucun";
|
||||
|
||||
exercise_format_performance(
|
||||
&summary.current,
|
||||
|
|
@ -1777,13 +1792,23 @@ static void screen_exercise_measured_max(
|
|||
record_date
|
||||
);
|
||||
|
||||
if (summary.current.equipment_id[0] != '\0') {
|
||||
current_equipment_label =
|
||||
trainlog_database_resolve_equipment(database,
|
||||
summary.current.equipment_id, ¤t_equipment) ==
|
||||
TRAINLOG_STATUS_OK
|
||||
? current_equipment.display_name
|
||||
: summary.current.equipment_id;
|
||||
}
|
||||
|
||||
trainlog_terminal_printf(tui_terminal,
|
||||
summary_top + 3,
|
||||
5,
|
||||
"Actuel : %s · %.*s",
|
||||
"Actuel : %s · %s · Machine : %.*s",
|
||||
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,
|
||||
|
|
@ -5145,6 +5170,7 @@ static DashboardAction screen_dashboard(
|
|||
|
||||
static bool build_session_exercise(
|
||||
TrainlogDatabase *database,
|
||||
TrainlogSessionType session_type,
|
||||
TrainlogSessionExerciseInput *output,
|
||||
TrainlogSetInput *set_storage,
|
||||
size_t set_capacity
|
||||
|
|
@ -5194,6 +5220,36 @@ static bool build_session_exercise(
|
|||
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 ==
|
||||
TRAINLOG_RECORDING_CONTINUOUS) {
|
||||
int duration_minutes = 30;
|
||||
|
|
@ -6005,6 +6061,7 @@ static bool draft_lookup_exercise(
|
|||
|
||||
static bool draft_build_exercise(
|
||||
TrainlogDatabase *database,
|
||||
TrainlogSessionType session_type,
|
||||
TrainlogSessionDraftExercise *draft,
|
||||
const char *preserved_notes
|
||||
)
|
||||
|
|
@ -6031,6 +6088,7 @@ static bool draft_build_exercise(
|
|||
|
||||
if (!build_session_exercise(
|
||||
database,
|
||||
session_type,
|
||||
&input,
|
||||
sets,
|
||||
MAX_SETS_PER_EXERCISE
|
||||
|
|
@ -6155,7 +6213,21 @@ static void draft_set_summary(
|
|||
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) {
|
||||
(void)snprintf(
|
||||
output,
|
||||
|
|
@ -6456,6 +6528,7 @@ static bool edit_session_draft(
|
|||
|
||||
if (draft_build_exercise(
|
||||
database,
|
||||
session_type,
|
||||
&drafts[*count],
|
||||
NULL
|
||||
)) {
|
||||
|
|
@ -6475,9 +6548,18 @@ static bool edit_session_draft(
|
|||
|
||||
if (draft_build_exercise(
|
||||
database,
|
||||
session_type,
|
||||
&replacement,
|
||||
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] =
|
||||
replacement;
|
||||
|
||||
|
|
@ -6593,6 +6675,11 @@ static bool load_persisted_draft(
|
|||
draft->tracking_mode =
|
||||
record->tracking_mode;
|
||||
|
||||
draft->input.recording_mode =
|
||||
record->recording_mode;
|
||||
draft->input.data_fields =
|
||||
record->data_fields;
|
||||
|
||||
draft->input.load_mode =
|
||||
record->load_mode;
|
||||
|
||||
|
|
@ -6614,6 +6701,35 @@ static bool load_persisted_draft(
|
|||
draft->input.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(
|
||||
draft->notes,
|
||||
sizeof(draft->notes),
|
||||
|
|
@ -6811,6 +6927,7 @@ static void screen_new_session(
|
|||
*/
|
||||
if (draft_build_exercise(
|
||||
database,
|
||||
session_type,
|
||||
&drafts[0],
|
||||
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(
|
||||
TrainlogDatabase *database,
|
||||
const char *session_id
|
||||
|
|
@ -7089,6 +7310,10 @@ static void screen_session_detail(
|
|||
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 {
|
||||
draw_shell(
|
||||
"TRAINLOG — Détail séance",
|
||||
|
|
@ -7149,7 +7374,43 @@ static void screen_session_detail(
|
|||
exercise->entry_id,
|
||||
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) {
|
||||
char duration_text[64];
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ static bool test_custom_equipment_round_trip(void)
|
|||
|
||||
CHECK(trainlog_database_open(path, &database) == 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)snprintf(custom.equipment_id, sizeof(custom.equipment_id),
|
||||
"%s", "eq_123e4567-e89b-42d3-a456-426614174000");
|
||||
|
|
|
|||
198
tui/tests/test_max_results.c
Normal file
198
tui/tests/test_max_results.c
Normal 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;
|
||||
}
|
||||
|
|
@ -187,7 +187,7 @@ static bool test_v4_to_current_preserves_session(void)
|
|||
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||
);
|
||||
|
||||
CHECK(version == 8);
|
||||
CHECK(version == 9);
|
||||
|
||||
CHECK(
|
||||
trainlog_database_get_session_details(
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ static bool test_v7_migrates_and_v8_reopens(void)
|
|||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
|
||||
CHECK(version == 8);
|
||||
CHECK(version == 9);
|
||||
trainlog_database_close(database);
|
||||
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)
|
||||
{
|
||||
char path[] = "/tmp/trainlog-schema-v9-XXXXXX";
|
||||
char path[] = "/tmp/trainlog-schema-v10-XXXXXX";
|
||||
char diagnostic[256];
|
||||
sqlite3 *raw = NULL;
|
||||
TrainlogDatabase *database = NULL;
|
||||
|
|
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
|
|||
CHECK(fd >= 0);
|
||||
CHECK(close(fd) == 0);
|
||||
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(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||
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(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
Loading…
Reference in a new issue