diff --git a/AGENTS.md b/AGENTS.md index 9ba7998..73ad8cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6235beb..7a769fe 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index 9881557..e7bb124 100644 --- a/README.md +++ b/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_` 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 ``` diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 7bfa263..279eb29 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -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 } - return MobileSessionImportResult.Invalid("Conflit de contenu pour la séance $sessionId") + if (!pcResumedMaxUpdateIsSafe(db, existingRowId, session)) { + return MobileSessionImportResult.Invalid("Conflit de contenu pour la séance $sessionId") + } + /* 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 = run { + 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>() + 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 { + val output = mutableListOf() + 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>() + 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, ) { diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt index 7f223c0..1485431 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt @@ -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 = 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 = 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 = emptyList(), val continuousDurationSeconds: Int = 0, val speedKmh: Double? = null, @@ -93,3 +106,11 @@ data class SessionDetail( val summary: SessionSummary, val exercises: List, ) + +data class LatestExerciseMax( + val exerciseId: String, + val exerciseName: String, + val maxWeightKg: Double, + val startedAt: String, + val equipmentDisplayName: String? = null, +) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt index a5c8415..2ba7284 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt @@ -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, + ) + } + } + } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt index 141ef93..d4dc2fa 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt @@ -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(null) } var equipmentQuery by remember(sessionId) { mutableStateOf("") } + var resumeMessage by remember(sessionId) { mutableStateOf(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 ) { diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index 523acbc..837894b 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -312,28 +312,30 @@ fun SessionScreen( }, ) - TrainlogAction( - label = - "Retirer ${draft.exercise.name}", - description = - "Supprimer cet exercice de la séance en cours.", - accent = - colors.error, - onClick = { - persistDraft( - currentDraft.copy( - exercises = - currentDraft.exercises - .filterIndexed { - itemIndex, - _ -> - itemIndex != index - } - ), - "Exercice retiré de la séance.", - ) - }, - ) + if (currentDraft.sourceSessionId == null) { + TrainlogAction( + label = + "Retirer ${draft.exercise.name}", + description = + "Supprimer cet exercice de la séance en cours.", + accent = + colors.error, + onClick = { + persistDraft( + currentDraft.copy( + exercises = + currentDraft.exercises + .filterIndexed { + itemIndex, + _ -> + itemIndex != index + } + ), + "Exercice retiré de la séance.", + ) + }, + ) + } } } } @@ -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 = - "Valeurs invalides." + 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? { 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 { diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index 76c0988..0992568 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -285,6 +285,10 @@ fun TrainlogApp( screen = TrainlogScreenId.HISTORY }, + onResumeMaxTest = { + draftRevision += 1 + screen = TrainlogScreenId.SESSION + }, ) TrainlogScreenId.SYNC -> diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index f6df933..0ce7a6f 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -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(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(rows[0], 0, 1, 100.0), + ) + db.execSQL( + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) VALUES(?,?,?,?);", + arrayOf(rows[1], 0, 1, 80.0), + ) + db.execSQL( + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) VALUES(?,?,?,?);", + arrayOf(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 } } diff --git a/docs/android.md b/docs/android.md index 3d21b49..f548bf6 100644 --- a/docs/android.md +++ b/docs/android.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 7908dad..b18b9a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/current_state.md b/docs/current_state.md index 35e07dd..4de87fb 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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. diff --git a/docs/database.md b/docs/database.md index a9e1659..039d2f8 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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. diff --git a/docs/exercise_data_model.md b/docs/exercise_data_model.md index 82ea1da..e3245a4 100644 --- a/docs/exercise_data_model.md +++ b/docs/exercise_data_model.md @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 1ab2a8b..b2b3ca3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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: diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index fa89917..d9403b0 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -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. diff --git a/docs/tests.md b/docs/tests.md index 59e3fb1..47a8548 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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 diff --git a/docs/tui.md b/docs/tui.md index d4423ce..07aa1c1 100644 --- a/docs/tui.md +++ b/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 diff --git a/tests/test_exercise_reconciliation.py b/tests/test_exercise_reconciliation.py index 6b2f682..56c6c08 100644 --- a/tests/test_exercise_reconciliation.py +++ b/tests/test_exercise_reconciliation.py @@ -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; """ diff --git a/tests/test_max_sync.py b/tests/test_max_sync.py new file mode 100644 index 0000000..3a34d1d --- /dev/null +++ b/tests/test_max_sync.py @@ -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() diff --git a/tests/test_mobile_import_multi_occurrence.py b/tests/test_mobile_import_multi_occurrence.py index a5c9150..a9276bf 100644 --- a/tests/test_mobile_import_multi_occurrence.py +++ b/tests/test_mobile_import_multi_occurrence.py @@ -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(): diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 4988ab3..35b8cc2 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -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")) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index 1306c67..a552c05 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -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")] diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 3b84f71..3f3903c 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -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}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 8f798fa..334c7d3 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -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} diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index 781f8a9..3fbd9e0 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -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")) diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 9d6b713..32b2c3d 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -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: diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index b0486fa..5548fe5 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -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;" ) diff --git a/tools/validate_json.py b/tools/validate_json.py index f3e2b90..7db0560 100755 --- a/tools/validate_json.py +++ b/tools/validate_json.py @@ -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", diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 26540cd..37a20ee 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -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; diff --git a/tui/include/trainlog/model.h b/tui/include/trainlog/model.h index 6ead7c0..e249bd1 100644 --- a/tui/include/trainlog/model.h +++ b/tui/include/trainlog/model.h @@ -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; diff --git a/tui/meson.build b/tui/meson.build index 73272a6..71ca8cb 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -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, diff --git a/tui/src/database.c b/tui/src/database.c index 7000299..7e31da2 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -8,6 +8,7 @@ #include "trainlog/equipment_catalog.h" #include "trainlog/id.h" +#include #include #include #include @@ -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), diff --git a/tui/src/tui.c b/tui/src/tui.c index 5785a03..5feaa91 100644 --- a/tui/src/tui.c +++ b/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]; diff --git a/tui/tests/test_custom_equipment.c b/tui/tests/test_custom_equipment.c index a7ba85a..b9bbf58 100644 --- a/tui/tests/test_custom_equipment.c +++ b/tui/tests/test_custom_equipment.c @@ -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"); diff --git a/tui/tests/test_max_results.c b/tui/tests/test_max_results.c new file mode 100644 index 0000000..cd73671 --- /dev/null +++ b/tui/tests/test_max_results.c @@ -0,0 +1,198 @@ +/** + * @file test_max_results.c + * @brief Explicit measured-max persistence and bounded migration regressions. + */ + +#include +#include +#include +#include +#include + +#include + +#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; +} diff --git a/tui/tests/test_schema_v5_migration.c b/tui/tests/test_schema_v5_migration.c index beed04e..78b289d 100644 --- a/tui/tests/test_schema_v5_migration.c +++ b/tui/tests/test_schema_v5_migration.c @@ -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( diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 40eb286..57b8c2f 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -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;