From 949ace63013feb714657f81cf809029276ec8e79 Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 18 Sep 2026 21:30:14 +0200 Subject: [PATCH] Add durable program session execution flow --- CHANGELOG.md | 10 + README.md | 2 +- .../trainlog/data/AndroidBackupService.kt | 4 +- .../trainlog/data/SyncGenerationService.kt | 4 +- .../trainlog/data/TrainlogRepository.kt | 274 +++++++++++++++++- .../trainlog/model/ProgramModels.kt | 8 + .../trainlog/model/SessionModels.kt | 3 + .../labfytools/trainlog/ui/ProgramScreens.kt | 74 ++++- .../labfytools/trainlog/ui/SectionScreens.kt | 2 + .../com/labfytools/trainlog/ui/TrainlogApp.kt | 5 +- .../app/src/main/res/values-en/strings.xml | 9 + android/app/src/main/res/values/strings.xml | 9 + .../data/AndroidV10PlanningMigrationTest.kt | 2 +- .../AndroidV11ExerciseAliasMigrationTest.kt | 2 +- .../AndroidV16AiSessionDraftMigrationTest.kt | 2 +- ...AndroidV25ProgramExecutionMigrationTest.kt | 93 ++++++ .../labfytools/trainlog/data/BodyZonesTest.kt | 2 +- .../data/BridgeBackupMigrationTest.kt | 2 +- .../data/ProgramsProjectionRepositoryTest.kt | 70 +++++ .../data/SessionPreparationExchangeTest.kt | 2 +- .../data/SyncGenerationServiceTest.kt | 59 +++- .../data/TrainlogRepositoryDraftTest.kt | 4 +- docs/android.md | 13 + docs/current_state.md | 13 +- docs/database.md | 16 +- .../evidence/web-programs-v1-card-desktop.png | Bin 115463 -> 115496 bytes .../program_execution_flow_v1_evidence.md | 44 +++ docs/roadmap.md | 2 +- docs/sync_exchange.md | 12 + docs/tests.md | 10 + docs/web_sessions.md | 6 + tests/test_program_execution_exchange.py | 100 +++++++ tests/test_sync_deployment_tools.py | 4 +- tests/test_web_sessions_browser.py | 1 + tools/causal_delete_exchange.py | 2 +- tools/execution_draft_exchange.py | 2 +- tools/export_ai_history.py | 2 +- tools/export_ai_session_drafts.py | 2 +- tools/export_equipment_associations.py | 2 +- tools/export_equipment_definitions.py | 2 +- tools/export_exercise_aliases.py | 2 +- tools/export_exercise_body_zones.py | 2 +- tools/export_exercise_profile_state.py | 2 +- tools/export_pc_catalog.py | 2 +- tools/export_pc_mobile.py | 4 +- tools/export_programs.py | 4 +- tools/export_session_preparations.py | 4 +- tools/export_training_feedback.py | 2 +- tools/import_ai_session_draft.py | 2 +- tools/import_equipment_associations.py | 2 +- tools/import_equipment_definitions.py | 2 +- tools/import_exercise_aliases.py | 2 +- tools/import_exercise_body_zones.py | 2 +- tools/import_exercise_profile_state.py | 2 +- tools/import_mobile_export.py | 4 +- tools/import_training_feedback.py | 2 +- tools/package_sync_candidate.py | 6 +- tools/program_execution_exchange.py | 163 +++++++++++ tools/sync_generation_exchange.py | 15 +- tui/include/trainlog/database.h | 2 +- tui/src/database.c | 26 +- tui/src/web_programs.c | 13 +- tui/tests/sync_generation_fixture.c | 17 ++ tui/tests/test_schema_v26_migration.c | 12 +- tui/tests/test_schema_v7_migration.c | 8 +- tui/tests/test_schema_v9_migration.c | 2 +- tui/tests/test_web_programs.c | 9 +- web/src/api/programs.ts | 2 + web/src/routes/ProgramsTab.test.tsx | 2 + web/src/routes/ProgramsTab.tsx | 16 +- web/src/theme/app.css | 4 + 71 files changed, 1126 insertions(+), 86 deletions(-) create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt create mode 100644 docs/reviews/program_execution_flow_v1_evidence.md create mode 100644 tests/test_program_execution_exchange.py create mode 100644 tools/program_execution_exchange.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b856566..024a0d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased — 0.1.2 +- Added the Program execution flow. Android schema v25 can start or resume one + Program session through the existing durable singleton, preserves stable + Program provenance on completion, and presents explicit `À faire`, `En cours` + and `Effectuée` states. Desktop schema v27 stores the corresponding unique + execution link. The optional `trainlog-program-executions` V1 full-generation + companion advances Web Program sessions from persisted facts without changing + mobile export V3, `programs-v1`, or `TRAINLOG_FORMAT_V1`. Web also exposes + `À faire`, `Préparée`, `En cours`, and `Effectuée` session states, while the + existing logical Program deletion and confirmation semantics remain intact. + - Completed the private coordinated deployment of `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1`. Desktop schema v26 adds terminal logical Program deletion with diff --git a/README.md b/README.md index 627b205..3fc3ec1 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ not contain cloud credentials and does not run `rclone`. As of 2026-09-18: - `TRAINLOG_FORMAT_V1=PASS/FROZEN`; -- desktop SQLite schema v26 and Android SQLite schema v24; +- desktop SQLite schema v27 and Android SQLite schema v25; - Notcurses is the only active desktop terminal backend; - direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's all-files access setting; diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt index e0d9bea..16457d8 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt @@ -219,7 +219,7 @@ internal class AndroidBackupService(private val context: Context) { manifest.getInt("version") == VERSION && manifest.getString("package") == context.packageName && manifest.getBoolean("plaintext") && - manifest.getInt("schema") in 17..24 + manifest.getInt("schema") in 17..25 ) { "Unsupported backup." } @@ -313,7 +313,7 @@ internal class AndroidBackupService(private val context: Context) { } ) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) - require(db.version in 17..24) { "Unsupported Android schema ${db.version}." } + require(db.version in 17..25) { "Unsupported Android schema ${db.version}." } return db.version } finally { db.close() diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt index a99dd19..52796ed 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt @@ -66,6 +66,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) "feedback" to Kind("trainlog-training-feedback", 2, "feedback-v2.json"), "causal-deletions" to Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"), + "program-executions" to + Kind("trainlog-program-executions", 1, "program-executions-v1.json", false), ) private val SUPPORTED = (ANDROID_KINDS.values + @@ -581,7 +583,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) if (total > MAX_GENERATION_BYTES) throw SyncGenerationException("generation exceeds bound") } - if (!ANDROID_KINDS.keys.all { it in names }) + if (!ANDROID_KINDS.filterValues { it.required }.keys.all { it in names }) throw SyncGenerationException("missing required domain") return root } 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 cf92f63..fe64263 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 @@ -42,6 +42,7 @@ import com.labfytools.trainlog.model.SyncedProgramDetail import com.labfytools.trainlog.model.SyncedProgramOccurrence import com.labfytools.trainlog.model.SyncedProgramSession import com.labfytools.trainlog.model.SyncedProgramSummary +import com.labfytools.trainlog.model.ProgramSessionExecutionState import org.json.JSONArray import org.json.JSONObject import java.text.Normalizer @@ -228,6 +229,15 @@ sealed interface ProgramsImportResult { data object DatabaseError : ProgramsImportResult } +sealed interface StartProgramSessionResult { + data object Started : StartProgramSessionResult + data object AlreadyActive : StartProgramSessionResult + data object AlreadyCompleted : StartProgramSessionResult + data object ExistingUnrelatedDraft : StartProgramSessionResult + data object NotAvailable : StartProgramSessionResult + data class Error(val message: String) : StartProgramSessionResult +} + private class SyncLifecycleConflict(val ownerId: String) : RuntimeException() private class ProgramsImportInvalid(message: String) : RuntimeException(message) @@ -486,16 +496,59 @@ class TrainlogRepository( jsonHasUniqueObjectKeys(json) /** Captures Android-owned generation artifacts from one read snapshot. */ - internal fun captureSyncGenerationArtifacts(afterFirstArtifact: (() -> Unit)? = null): Map = - inSyncGenerationTransaction { - val artifacts=linkedMapOf("catalog" to buildPcCatalogJson()) - afterFirstArtifact?.invoke() - artifacts["history"]=buildMobileExport(4, true);artifacts["execution-drafts"]=buildExecutionDraftExportV1Json() - artifacts["exercise-aliases"]=buildExerciseAliasesJson();artifacts["exercise-profile-state"]=buildExerciseProfileStateJson() - artifacts["equipment-definitions"]=buildEquipmentDefinitionsJson(true);artifacts["equipment-associations"]=buildEquipmentAssociationsJson() - artifacts["body-zones"]=buildExerciseBodyZonesJson(true);artifacts["feedback"]=buildTrainingFeedbackJson(true) - artifacts["causal-deletions"]=buildCausalDeletionExportV1Json();artifacts + internal fun captureSyncGenerationArtifacts( + afterFirstArtifact: (() -> Unit)? = null, + ): Map = inSyncGenerationTransaction { + val artifacts = linkedMapOf("catalog" to buildPcCatalogJson()) + afterFirstArtifact?.invoke() + artifacts["history"] = buildMobileExport(4, true) + artifacts["execution-drafts"] = buildExecutionDraftExportV1Json() + artifacts["exercise-aliases"] = buildExerciseAliasesJson() + artifacts["exercise-profile-state"] = buildExerciseProfileStateJson() + artifacts["equipment-definitions"] = buildEquipmentDefinitionsJson(true) + artifacts["equipment-associations"] = buildEquipmentAssociationsJson() + artifacts["body-zones"] = buildExerciseBodyZonesJson(true) + artifacts["feedback"] = buildTrainingFeedbackJson(true) + artifacts["causal-deletions"] = buildCausalDeletionExportV1Json() + artifacts["program-executions"] = buildProgramExecutionsV1Json() + artifacts + } + + /** + * Publishes only stable provenance facts; program definitions remain + * desktop-owned and are never reconstructed from Android session content. + */ + internal fun buildProgramExecutionsV1Json(): String { + val executions = JSONArray() + val db = database.readableDatabase + db.rawQuery( + "SELECT source_program_id,source_program_session_id,session_id,'completed'," + + "COALESCE(ended_at,started_at) FROM sessions " + + "WHERE source_program_id IS NOT NULL " + + "UNION ALL " + + "SELECT source_program_id,source_program_session_id,session_id,'in_progress'," + + "updated_at FROM active_session_draft WHERE source_program_id IS NOT NULL " + + "ORDER BY 1,2", + null, + ).use { cursor -> + while (cursor.moveToNext()) { + executions.put( + JSONObject() + .put("program_id", cursor.getString(0)) + .put("program_session_id", cursor.getString(1)) + .put("session_id", cursor.getString(2)) + .put("state", cursor.getString(3)) + .put("observed_at", cursor.getString(4)), + ) + } } + return JSONObject() + .put("format", "trainlog-program-executions") + .put("version", 1) + .put("generated_at", OffsetDateTime.now().toString()) + .put("executions", executions) + .toString() + } /** Direction-neutral stable-identity catalogue used before generation history. */ internal fun buildPcCatalogJson(): String { @@ -2305,6 +2358,9 @@ class TrainlogRepository( } || (draft.sourceSessionId != null && (draft.sourceSessionId.isBlank() || draft.sessionType != SessionType.MAX_TEST)) || + (draft.sourceProgramId == null) != (draft.sourceProgramSessionId == null) || + draft.sourceProgramId?.isBlank() == true || + draft.sourceProgramSessionId?.isBlank() == true || /* exercise_id identifies the catalogue movement. Repeated passages * are valid; only their stable occurrence IDs must be unique. */ draft.exercises @@ -2456,6 +2512,18 @@ class TrainlogRepository( forcedStartedAt = active.draft.startedAt, ) + /* INVARIANT: completion moves the exact Program provenance from + * the singleton draft to history in the same transaction. */ + db.execSQL( + "UPDATE sessions SET source_program_id=?," + + "source_program_session_id=? WHERE session_id=?", + arrayOf( + active.draft.sourceProgramId, + active.draft.sourceProgramSessionId, + sessionId, + ), + ) + /* CONTRACT: lifecycle transition changes table ownership, not the * stable note owner. Copy caches while causal state remains keyed * by session_id/entry_id; rollback covers every child. */ @@ -7095,7 +7163,9 @@ class TrainlogRepository( d.session_id, d.revision_id, d.parent_revision_id, - d.started_at + d.started_at, + d.source_program_id, + d.source_program_session_id FROM active_session_draft AS d LEFT JOIN exercises AS e ON e.id = d.selected_exercise_row_id @@ -7140,6 +7210,8 @@ class TrainlogRepository( revisionId = cursor.getString(19), parentRevisionId = if (cursor.isNull(20)) null else cursor.getString(20), startedAt = if (cursor.isNull(21)) null else cursor.getString(21), + sourceProgramId = if (cursor.isNull(22)) null else cursor.getString(22), + sourceProgramSessionId = if (cursor.isNull(23)) null else cursor.getString(23), updatedAt = cursor.getString(6), warning = @@ -7323,6 +7395,8 @@ class TrainlogRepository( exercises = exercises, sessionType = header.sessionType, sourceSessionId = header.sourceSessionId, + sourceProgramId = header.sourceProgramId, + sourceProgramSessionId = header.sourceProgramSessionId, form = header.form, updatedAt = header.updatedAt, ), @@ -7351,6 +7425,8 @@ class TrainlogRepository( put("revision_id", draft.revisionId) putOptionalString("parent_revision_id", draft.parentRevisionId) putOptionalString("source_session_id", draft.sourceSessionId) + putOptionalString("source_program_id", draft.sourceProgramId) + putOptionalString("source_program_session_id", draft.sourceProgramSessionId) putOptionalString("selected_equipment_id", draft.form.selectedEquipmentId) put("weight_text", draft.form.weightText) put("max_weight_text", draft.form.maxWeightText) @@ -7852,6 +7928,8 @@ class TrainlogRepository( val sessionType: SessionType, val form: SessionDraftForm, val sourceSessionId: String?, + val sourceProgramId: String?, + val sourceProgramSessionId: String?, val sessionId: String?, val revisionId: String, val parentRevisionId: String?, @@ -8339,12 +8417,15 @@ class TrainlogRepository( ) } } + val execution = programSessionExecution(db, programId, sessionId) sessions += SyncedProgramSession( sessionId, sessionCursor.getString(1), sessionCursor.getString(2), if (sessionCursor.isNull(3)) null else sessionCursor.getString(3), if (sessionCursor.isNull(4)) null else sessionCursor.getString(4), + execution.first, + execution.second, entries, ) } @@ -8360,6 +8441,141 @@ class TrainlogRepository( ) } + private fun programSessionExecution( + db: SQLiteDatabase, + programId: String, + programSessionId: String, + ): Pair { + val completed = db.rawQuery( + "SELECT session_id FROM sessions WHERE source_program_id=? AND " + + "source_program_session_id=? LIMIT 2", + arrayOf(programId, programSessionId), + ).use { cursor -> + if (!cursor.moveToFirst()) null else cursor.getString(0).also { + check(!cursor.moveToNext()) { "Duplicate Program session execution." } + } + } + if (completed != null) { + return ProgramSessionExecutionState.COMPLETED to completed + } + val active = db.rawQuery( + "SELECT session_id FROM active_session_draft WHERE id=1 AND " + + "source_program_id=? AND source_program_session_id=?", + arrayOf(programId, programSessionId), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + return if (active == null) { + ProgramSessionExecutionState.TODO to null + } else { + ProgramSessionExecutionState.IN_PROGRESS to active + } + } + + /** + * Starts exactly one Program session from stable provenance. + * + * The singleton draft and provenance lookup share one transaction. Replay + * returns the existing draft, completed provenance is terminal, and an + * unrelated draft is never overwritten. + */ + fun startSyncedProgramSession( + programId: String, + programSessionId: String, + ): StartProgramSessionResult { + val db = database.writableDatabase + return try { + db.beginTransaction() + try { + val available = db.rawQuery( + "SELECT s.session_type FROM synced_program_sessions s " + + "JOIN synced_programs p ON p.program_id=s.program_id " + + "WHERE p.program_id=? AND s.program_session_id=? AND p.state='active'", + arrayOf(programId, programSessionId), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + ?: return StartProgramSessionResult.NotAvailable + + val execution = programSessionExecution(db, programId, programSessionId) + if (execution.first == ProgramSessionExecutionState.COMPLETED) { + return StartProgramSessionResult.AlreadyCompleted + } + if (execution.first == ProgramSessionExecutionState.IN_PROGRESS) { + return StartProgramSessionResult.AlreadyActive + } + val occupied = db.rawQuery( + "SELECT 1 FROM active_session_draft WHERE id=1", + null, + ).use { it.moveToFirst() } + if (occupied) { + return StartProgramSessionResult.ExistingUnrelatedDraft + } + + val occurrences = readSyncedProgramOccurrences(db, programSessionId) + check(occurrences.isNotEmpty()) { "Program session has no executable occurrence." } + val type = if (available == SessionType.MAX_TEST.wireValue) { + SessionType.MAX_TEST + } else { + SessionType.TRAINING + } + persistActiveSessionDraft( + db, + ActiveSessionDraft( + sessionId = "se_${UUID.randomUUID()}", + revisionId = "dr_${UUID.randomUUID()}", + startedAt = OffsetDateTime.now().toString(), + exercises = occurrences, + sessionType = type, + sourceProgramId = programId, + sourceProgramSessionId = programSessionId, + ), + ) + db.setTransactionSuccessful() + StartProgramSessionResult.Started + } finally { + db.endTransaction() + } + } catch (error: Exception) { + StartProgramSessionResult.Error( + error.message ?: "Program session could not be started.", + ) + } + } + + private fun readSyncedProgramOccurrences( + db: SQLiteDatabase, + programSessionId: String, + ): List = buildList { + db.rawQuery( + "SELECT pe.entry_id,pe.exercise_id,pe.equipment_id,pe.load_mode," + + "pe.rest_seconds,pe.target_sets,pe.target_reps," + + "pe.target_duration_seconds,pe.target_weight_kg " + + "FROM synced_program_entries pe WHERE pe.program_session_id=? " + + "ORDER BY pe.position,pe.entry_id", + arrayOf(programSessionId), + ).use { cursor -> + while (cursor.moveToNext()) { + val exercise = readExerciseProfileExact(db, resolveExerciseId(db, cursor.getString(1))) + ?: error("Program exercise is unavailable: ${cursor.getString(1)}") + val equipmentId = if (cursor.isNull(2)) null else cursor.getString(2) + check(equipmentId == null || lookupEquipmentRowIdOrNull(db, equipmentId) != null) { + "Program equipment is unavailable: $equipmentId" + } + add( + SessionExerciseDraft( + exercise = exercise, + equipmentId = equipmentId, + plan = SessionExercisePlan( + sets = if (cursor.isNull(5)) 0 else cursor.getInt(5), + reps = if (cursor.isNull(6)) null else cursor.getInt(6), + durationSeconds = if (cursor.isNull(7)) null else cursor.getInt(7), + weightKg = if (cursor.isNull(8)) null else cursor.getDouble(8), + loadMode = SessionLoadMode.fromWire(cursor.getString(3)), + restSeconds = cursor.getInt(4), + ), + ), + ) + } + } + } + private fun normalizeName( value: String, ): String { @@ -8538,7 +8754,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 24, + 25, ) { override fun onConfigure( db: SQLiteDatabase, @@ -8587,6 +8803,7 @@ private class TrainlogDatabaseHelper( createSessionPreparationTables(db) createSessionPreparationWithdrawalTable(db) createSyncedProgramTables(db) + createProgramExecutionProvenance(db) seedEquipment(db) } @@ -8784,6 +9001,14 @@ private class TrainlogDatabaseHelper( createSyncedProgramTables(db) version = 24 } + if (version < 25 && newVersion >= 25) { + /* WHY: completion state must follow durable provenance, never a + * title, date, position, or content heuristic. CONTRACT: v25 adds + * nullable paired identities to the singleton draft and completed + * session. INVARIANT: migration creates no draft or workout. */ + createProgramExecutionProvenance(db) + version = 25 + } if (version != newVersion) { error( @@ -8833,6 +9058,23 @@ private class TrainlogDatabaseHelper( ) } + private fun createProgramExecutionProvenance(db: SQLiteDatabase) { + fun addColumn(table: String, name: String) { + if (!tableHasColumn(db, table, name)) { + db.execSQL("ALTER TABLE $table ADD COLUMN $name TEXT") + } + } + addColumn("active_session_draft", "source_program_id") + addColumn("active_session_draft", "source_program_session_id") + addColumn("sessions", "source_program_id") + addColumn("sessions", "source_program_session_id") + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS sessions_program_execution_v25 " + + "ON sessions(source_program_id,source_program_session_id) " + + "WHERE source_program_id IS NOT NULL", + ) + } + private fun createSyncDataLifecycleTables(db: SQLiteDatabase) { fun addColumn(table: String, name: String, declaration: String) { if (!tableHasColumn(db, table, name)) { @@ -9560,7 +9802,13 @@ private class TrainlogDatabaseHelper( 'training', 'max_test' ) - ) + ), + source_program_id TEXT, + source_program_session_id TEXT, + CHECK( + (source_program_id IS NULL) = + (source_program_session_id IS NULL) + ) ); """.trimIndent() ) @@ -9794,6 +10042,8 @@ private class TrainlogDatabaseHelper( session_id TEXT UNIQUE, revision_id TEXT NOT NULL DEFAULT 'dr_legacy_v1', parent_revision_id TEXT, + source_program_id TEXT, + source_program_session_id TEXT, notes TEXT CHECK(notes IS NULL OR length(CAST(notes AS BLOB)) <= 4096) ); """.trimIndent() diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/ProgramModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/ProgramModels.kt index faf5b7f..de34a13 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/ProgramModels.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/ProgramModels.kt @@ -26,9 +26,17 @@ data class SyncedProgramSession( val sessionType: String, val plannedFor: String?, val note: String?, + val executionState: ProgramSessionExecutionState, + val executionSessionId: String?, val occurrences: List, ) +enum class ProgramSessionExecutionState { + TODO, + IN_PROGRESS, + COMPLETED, +} + data class SyncedProgramOccurrence( val entryId: String, val exerciseId: String, 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 50e933d..049948e 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 @@ -112,6 +112,9 @@ data class ActiveSessionDraft( val sessionType: SessionType = SessionType.TRAINING, /** Existing max_test session updated atomically on finalization, if any. */ val sourceSessionId: String? = null, + /** Stable desktop-owned Program provenance; both values are present or absent. */ + val sourceProgramId: String? = null, + val sourceProgramSessionId: String? = null, val form: SessionDraftForm = SessionDraftForm(), val updatedAt: String = "", ) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/ProgramScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/ProgramScreens.kt index 15a94df..5d80c6d 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/ProgramScreens.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/ProgramScreens.kt @@ -1,10 +1,17 @@ package com.labfytools.trainlog.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import com.labfytools.trainlog.R +import com.labfytools.trainlog.data.StartProgramSessionResult import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.model.ProgramSessionExecutionState /** Presentation-only list for the desktop-owned Programs projection. */ @Composable @@ -37,11 +44,36 @@ fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) { } } -/** CONTRACT: this route exposes no start, edit, archive, or delete mutation. */ +/** Program definitions remain read-only; only the local execution draft mutates. */ @Composable -fun ProgramDetailScreen(repository: TrainlogRepository, programId: String) { +fun ProgramDetailScreen( + repository: TrainlogRepository, + programId: String, + onOpenActiveSession: () -> Unit, +) { val strings = localizedContext() - val program = repository.getSyncedProgram(programId) + var revision by remember { mutableIntStateOf(0) } + var message by remember { mutableStateOf(null) } + val program = remember(programId, revision) { repository.getSyncedProgram(programId) } + + fun start(programSessionId: String) { + when (val result = repository.startSyncedProgramSession(programId, programSessionId)) { + StartProgramSessionResult.Started, + StartProgramSessionResult.AlreadyActive -> onOpenActiveSession() + StartProgramSessionResult.AlreadyCompleted -> { + message = strings.getString(R.string.program_session_completed_detail) + revision++ + } + StartProgramSessionResult.ExistingUnrelatedDraft -> { + message = strings.getString(R.string.existing_draft_warning) + } + StartProgramSessionResult.NotAvailable -> { + message = strings.getString(R.string.program_session_unavailable) + } + is StartProgramSessionResult.Error -> message = result.message + } + } + TrainlogScreen(strings.getString(R.string.route_program_detail)) { if (program == null) { TrainlogInfo(strings.getString(R.string.program_not_found)) @@ -53,8 +85,17 @@ fun ProgramDetailScreen(repository: TrainlogRepository, programId: String) { program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) } program.note?.let { TrainlogInfo(it) } } + message?.let { TrainlogInfo(it) } program.sessions.forEachIndexed { index, session -> - TrainlogFrame("${index + 1}. ${session.title}") { + var expanded by remember(session.programSessionId) { mutableStateOf(false) } + val completed = session.executionState == ProgramSessionExecutionState.COMPLETED + TrainlogFrame("${index + 1}. ${session.title}", active = completed) { + val stateLabel = when (session.executionState) { + ProgramSessionExecutionState.TODO -> R.string.program_session_todo + ProgramSessionExecutionState.IN_PROGRESS -> R.string.program_session_in_progress + ProgramSessionExecutionState.COMPLETED -> R.string.program_session_completed + } + TrainlogInfo(strings.getString(stateLabel)) TrainlogInfo( listOfNotNull( strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training), @@ -62,7 +103,30 @@ fun ProgramDetailScreen(repository: TrainlogRepository, programId: String) { ).joinToString(" · "), ) session.note?.let { TrainlogInfo(it) } - session.occurrences.forEachIndexed { occurrenceIndex, occurrence -> + TrainlogButton( + strings.getString( + if (expanded) R.string.program_session_hide_occurrences + else R.string.program_session_show_occurrences, + ), + { expanded = !expanded }, + Modifier.testTag("program-session-toggle-${session.programSessionId}"), + ) + when (session.executionState) { + ProgramSessionExecutionState.TODO -> TrainlogButton( + strings.getString(R.string.program_session_start), + { start(session.programSessionId) }, + Modifier.testTag("program-session-start-${session.programSessionId}"), + ) + ProgramSessionExecutionState.IN_PROGRESS -> TrainlogButton( + strings.getString(R.string.program_session_resume), + onOpenActiveSession, + Modifier.testTag("program-session-resume-${session.programSessionId}"), + ) + ProgramSessionExecutionState.COMPLETED -> TrainlogInfo( + strings.getString(R.string.program_session_completed_detail), + ) + } + if (expanded) session.occurrences.forEachIndexed { occurrenceIndex, occurrence -> val target = occurrence.targetReps?.let { strings.getString(R.string.program_target_reps, it) } ?: occurrence.targetDurationSeconds?.let { strings.getString(R.string.program_target_duration, it) }.orEmpty() val sets = occurrence.targetSets?.let { strings.getString(R.string.program_target_sets, it) }.orEmpty() diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt index 1c23a95..7fd3992 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height @@ -68,6 +69,7 @@ fun SessionsHub( } TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory, Modifier.fillMaxWidth().testTag("sessions-history-action")) + Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing")) TrainlogButton(strings.getString(R.string.route_programs), onPrograms, Modifier.fillMaxWidth().testTag("sessions-programs-action")) } 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 b6d8db5..217f95d 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 @@ -125,7 +125,10 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S ) AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) } AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) } - is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) + is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) { + draftRevision++ + open(AppRoute.SessionEditor) + } is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) } is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved -> if (saved) exportSnapshot() diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml index eef469d..241ee7e 100644 --- a/android/app/src/main/res/values-en/strings.xml +++ b/android/app/src/main/res/values-en/strings.xml @@ -25,6 +25,15 @@ %1$d repetitions %1$d s %1$.1f kg + To do + In progress + Completed + Start + Resume session + This session has already been completed. + This session can no longer be started. + Show exercises + Hide exercises %d session%d sessions Session details Edit session diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 89c75be..f85160d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -25,6 +25,15 @@ %1$d répétitions %1$d s %1$.1f kg + À faire + En cours + Effectuée + Démarrer + Reprendre la séance + Cette séance a déjà été effectuée. + Cette séance ne peut plus être démarrée. + Voir les exercices + Masquer les exercices %d séance%d séances Détail de la séance Modifier la séance diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index e4680cd..7b3423a 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(24, scalar(db, "PRAGMA user_version")) + assertEquals(25, scalar(db, "PRAGMA user_version")) for (table in listOf("session_exercises", "draft_session_exercises")) { assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index 3873732..435ae91 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(24, scalar(db, "PRAGMA user_version")) + assertEquals(25, scalar(db, "PRAGMA user_version")) /* v13 intentionally adds the frozen machine identities and * exercise-owned metadata; all non-exercise facts stay exact. */ val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt index 3c1ddcc..5bdbc76 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(24, scalar(db, "PRAGMA user_version")) + assertEquals(25, scalar(db, "PRAGMA user_version")) assertEquals(before, listOf( scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM performed_sets"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt new file mode 100644 index 0000000..7f81195 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt @@ -0,0 +1,93 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.ActiveSessionDraft +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionExercisePlan +import com.labfytools.trainlog.model.TrackingMode +import java.util.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Additive v24 -> v25 provenance migration preserves the singleton draft. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AndroidV25ProgramExecutionMigrationTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun v24FixturePreservesDraftAndInventsNoProgramProvenance() { + val name = "program-execution-v24-${UUID.randomUUID()}.db" + val first = TrainlogRepository(context, name) + val exercise = ( + first.createExercise( + NewExerciseProfile( + "Migration fixture", + RecordingMode.SETS, + TrackingMode.REPS, + 0, + ), + ) as CreateExerciseResult.Created + ).exercise + assertEquals( + ActiveDraftMutationResult.Saved, + first.saveActiveSessionDraft( + ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = exercise, + plan = SessionExercisePlan(sets = 3, reps = 8), + ), + ), + ), + ), + ) + first.close() + + val path = context.getDatabasePath(name).path + SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { db -> + /* The published-unreleased fixture may already contain additive + * columns; lowering only user_version proves migration replay is safe. */ + db.execSQL("PRAGMA user_version=24") + } + + val reopened = TrainlogRepository(context, name) + try { + val draft = (reopened.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft + assertEquals(null, draft.sourceProgramId) + assertEquals(null, draft.sourceProgramSessionId) + } finally { + reopened.close() + } + SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> + assertEquals(25, scalar(db, "PRAGMA user_version")) + assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL")) + assertEquals("ok", text(db, "PRAGMA integrity_check")) + db.rawQuery("PRAGMA foreign_key_check", null).use { cursor -> + assertFalse(cursor.moveToFirst()) + } + } + context.deleteDatabase(name) + } + + private fun scalar(db: SQLiteDatabase, sql: String): Int = + db.rawQuery(sql, null).use { cursor -> + assertTrue(cursor.moveToFirst()) + cursor.getInt(0) + } + + private fun text(db: SQLiteDatabase, sql: String): String = + db.rawQuery(sql, null).use { cursor -> + assertTrue(cursor.moveToFirst()) + cursor.getString(0) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index 9de2ca0..3d58114 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -328,7 +328,7 @@ class BodyZonesTest { SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY).use { db -> db.rawQuery("PRAGMA user_version", null).use { cursor -> - assertTrue(cursor.moveToFirst()); assertEquals(24, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(25, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt index 0b487a8..fb3c822 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt @@ -43,7 +43,7 @@ class BridgeBackupMigrationTest { null, android.database.sqlite.SQLiteDatabase.OPEN_READONLY, ) - .use { assertEquals(24, it.version) } + .use { assertEquals(25, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt index e139d90..308ff26 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt @@ -3,6 +3,8 @@ package com.labfytools.trainlog.data import android.content.Context import androidx.test.core.app.ApplicationProvider import java.util.UUID +import com.labfytools.trainlog.model.ProgramSessionExecutionState +import com.labfytools.trainlog.model.SessionSetDraft import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertEquals @@ -124,4 +126,72 @@ class ProgramsProjectionRepositoryTest { context.deleteDatabase(name) } } + + @Test + fun programSessionStartsResumesCompletesAndKeepsProvenanceAcrossRestart() { + val name = "program-execution-${UUID.randomUUID()}.db" + var repository = TrainlogRepository(context, name) + try { + val catalog = JSONObject() + .put("format", "trainlog-pc-catalog") + .put("version", 1) + .put("exercises", JSONArray().put( + JSONObject() + .put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee") + .put("name", "Exercise fixture") + .put("recording_mode", "sets") + .put("tracking_mode", "reps") + .put("data_fields", 0), + )) + .toString() + assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied) + repository.applyProgramsV1Json(artifact(JSONArray().put(program()))) + val programId = "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + val programSessionId = "pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc" + + assertEquals( + StartProgramSessionResult.Started, + repository.startSyncedProgramSession(programId, programSessionId), + ) + assertEquals( + StartProgramSessionResult.AlreadyActive, + repository.startSyncedProgramSession(programId, programSessionId), + ) + val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft + assertEquals(programId, active.sourceProgramId) + assertEquals(programSessionId, active.sourceProgramSessionId) + val performed = active.copy( + exercises = active.exercises.map { occurrence -> + occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0))) + }, + ) + assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed)) + assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved) + assertEquals( + ProgramSessionExecutionState.COMPLETED, + repository.getSyncedProgram(programId)!!.sessions.single().executionState, + ) + assertEquals( + StartProgramSessionResult.AlreadyCompleted, + repository.startSyncedProgramSession(programId, programSessionId), + ) + val exported = JSONObject(repository.buildProgramExecutionsV1Json()) + val execution = exported.getJSONArray("executions").getJSONObject(0) + assertEquals("completed", execution.getString("state")) + assertEquals(programSessionId, execution.getString("program_session_id")) + } finally { + repository.close() + } + + repository = TrainlogRepository(context, name) + try { + val detail = repository.listSyncedPrograms().single() + assertEquals(1, detail.sessionCount) + assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None) + assertEquals(1, repository.listSessions().size) + } finally { + repository.close() + context.deleteDatabase(name) + } + } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt index b4b51e4..a016563 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt @@ -260,7 +260,7 @@ class SessionPreparationExchangeTest { repository = TrainlogRepository(context, name) assertEquals(1, repository.listPreparedSessions().size) repository.close() - assertEquals("24", scalarText(name, "PRAGMA user_version")) + assertEquals("25", scalarText(name, "PRAGMA user_version")) assertEquals( "0", scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index 01241eb..56d3579 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -149,6 +149,63 @@ class SyncGenerationServiceTest { acceptAndroidAck(liveGenerationId, liveAck).contains("ACK_ACCEPT=acknowledged") ) + val programSessionId = + "pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + assertEquals( + StartProgramSessionResult.Started, + android.startSyncedProgramSession(programId, programSessionId), + ) + val active = + (android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft + val performed = active.copy( + exercises = active.exercises.map { occurrence -> + occurrence.copy( + sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)), + ) + }, + ) + assertEquals( + ActiveDraftMutationResult.Saved, + android.saveActiveSessionDraft(performed), + ) + assertTrue(android.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved) + val completedSessionId = android.listSessions().single().sessionId + val androidGeneration = service.capture(root, desktopPeer) + val androidPublished = service.publish( + androidGeneration, + java.io.File(root, "android-objects"), + ) + val desktopAck = java.io.File(root, "android-program-execution-ack.json") + run( + "python3", + generationTool, + "consume-desktop", + androidPublished.absolutePath, + "--database", + desktopDb.absolutePath, + "--ack-output", + desktopAck.absolutePath, + ) + assertEquals( + "acknowledged", + service.acceptAcknowledgement(desktopAck.readBytes()), + ) + assertEquals( + "$programSessionId|$completedSessionId|completed", + run( + "sqlite3", + desktopDb.absolutePath, + "SELECT program_session_id || '|' || session_id || '|' || state " + + "FROM program_session_executions;", + ).trim(), + ) + val completedDetail = run( + fixture.absolutePath, + desktopDb.absolutePath, + "program-detail", + ) + assertTrue(completedDetail.contains("\"execution_state\":\"completed\"")) + val deleted = run( fixture.absolutePath, @@ -329,7 +386,7 @@ class SyncGenerationServiceTest { .use { db -> db.rawQuery("PRAGMA user_version", null).use { assertTrue(it.moveToFirst()) - assertEquals(24, it.getInt(0)) + assertEquals(25, it.getInt(0)) } db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { assertTrue(it.moveToFirst()) 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 038ebac..36f1652 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 @@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(24, cursor.getInt(0)) + assertEquals(25, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(24, cursor.getInt(0)) + assertEquals(25, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/docs/android.md b/docs/android.md index d75befa..83f47ac 100644 --- a/docs/android.md +++ b/docs/android.md @@ -23,6 +23,13 @@ Sessions without offering import, archive, delete, preparation, or other program mutation actions. The projection is separate from capture data, AI proposals, manual preparations, and the active-session singleton. +Android schema v25 adds paired nullable Program provenance to the active draft +and completed session. Starting a Program session allocates one ordinary stable +execution identity, copies targets without fabricating performed values, and +respects the existing singleton. Replay resumes the matching draft, refuses an +unrelated draft, and refuses a second execution after completion. Finalization +copies the same Program identities into completed history transactionally. + ## 1. Purpose The Android application is Trainlog's low-friction capture client. @@ -85,6 +92,12 @@ the projection and deletion state durably, and desktop records the correlated ACK before marking the tombstone acknowledged. It changes neither `TRAINLOG_FORMAT_V1`, mobile V3, catalog exchange, nor preparation exchange. +Android publishes the separate optional `trainlog-program-executions` V1 +companion in full generations. It contains only stable Program/session/execution +identities, `in_progress` or `completed`, and an observation timestamp. It does +not publish Program definitions and therefore does not transfer Program +ownership to Android. Older consumers may ignore this optional companion. + Completed session occurrences persist an `entry_id`; it is never regenerated for exchange. Android schema v14 added feedback roots and completion `ended_at` anchors; v15 adds immutable correction history and the separately versioned diff --git a/docs/current_state.md b/docs/current_state.md index ab3423d..51687fc 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -57,8 +57,8 @@ parallel implementation of its rules. | Boundary | Current state | |---|---| | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | -| Desktop SQLite | schema v26 | -| Android SQLite | schema v24 | +| Desktop SQLite | schema v27 | +| Android SQLite | schema v25 | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Desktop terminal backend | Notcurses only | | Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` | @@ -82,6 +82,7 @@ parallel implementation of its rules. | Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) | | Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) | | Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) | +| Program execution flow | `TRAINLOG_PROGRAM_EXECUTION_FLOW_V1=IMPLEMENTED_VALIDATED_AWAITING_ROLLOUT` | | Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` | | Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` | | Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` | @@ -272,6 +273,14 @@ snapshot plus unacknowledged tombstones under capability `programs-v1`; a durable correlated ACK records acknowledgment. It changes neither `TRAINLOG_FORMAT_V1`, mobile V3, catalog exchange, nor preparations. +Android schema v25 makes those projected sessions executable through the +existing durable singleton. Stable paired Program provenance survives draft +restart and completion. The optional Android-to-desktop +`trainlog-program-executions` V1 companion records `in_progress` or `completed` +against desktop schema v27 without transferring ownership of Program +definitions. Web and Android derive their session state from these persisted +identities; no display-field heuristic is used. + The private Programs rollout migrated desktop schema v25 to v26 and Android schema v23 to v24 after verified backups, then installed the signed non-debuggable versionCode 16 APK without uninstalling or clearing data. The diff --git a/docs/database.md b/docs/database.md index f667b62..ada232e 100644 --- a/docs/database.md +++ b/docs/database.md @@ -1,5 +1,15 @@ # Desktop database +## Schema v27: durable Program execution provenance + +Schema v27 adds `program_session_executions`, the stable link from one +desktop-owned Program session to its Android execution `session_id`. The state +advances only from `in_progress` to `completed`; a Program session and an +execution session are each unique. Completed links are accepted only after the +referenced completed session has been imported. The migration is additive, +transactional, creates no execution row, and preserves Program definitions, +preparations and history unchanged. + ## Schema v26: Programs presentation, Android projection, and logical deletion Schema v26 adds nullable `programs.deleted_at` and the terminal @@ -65,8 +75,8 @@ is a later, separately versioned migration. ## 1. Status ```text -TRAINLOG_DATABASE_SCHEMA_VERSION=26 -DATABASE_SCHEMA_V26=IMPLEMENTED +TRAINLOG_DATABASE_SCHEMA_VERSION=27 +DATABASE_SCHEMA_V27=IMPLEMENTED TRAINLOG_FORMAT_V1=FROZEN ``` @@ -85,7 +95,7 @@ PRAGMA user_version; Current value: ```text -26 +27 ``` The independent actual-set loads documented in the current desktop, Android diff --git a/docs/reviews/evidence/web-programs-v1-card-desktop.png b/docs/reviews/evidence/web-programs-v1-card-desktop.png index 2410e94c91a0288a02b6f68850dc40fcc50f459a..3f5525698910a7eaa3d6a8cfbe86d806cf91c785 100644 GIT binary patch delta 22601 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 z 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 C)H<91 diff --git a/docs/reviews/program_execution_flow_v1_evidence.md b/docs/reviews/program_execution_flow_v1_evidence.md new file mode 100644 index 0000000..7d1148e --- /dev/null +++ b/docs/reviews/program_execution_flow_v1_evidence.md @@ -0,0 +1,44 @@ +# Program execution flow V1 validation evidence + +Status: `IMPLEMENTED_VALIDATED_AWAITING_ROLLOUT` + +This record covers the isolated `program-execution-flow-v1` worktree. It does +not claim installation, user-database migration, service restart, device smoke +testing, tag creation, or public release. + +## Functional evidence + +- Android starts one projected Program session through the production durable + singleton, returns the same draft on replay, refuses an unrelated singleton, + and refuses a second execution after completion. +- Finalization retains the stable `program_id`, `program_session_id`, and + execution `session_id`; reopening preserves completion and creates no draft. +- A production full-generation scenario imports `programs-v1` on Android, + performs and finalizes the session, publishes `program-executions-v1`, imports + completed history and provenance on desktop, accepts the real correlated ACK, + and exposes Web `completed` before continuing through deletion and replay. +- Existing logical Program deletion, tombstone ACK, restart and + non-resurrection coverage remains in the same integrated scenario. + +## Validation evidence + +- Native Meson suite: 87/87 passed. +- ASan/UBSan Meson suite with Web assets: 89/89 passed. +- Android JVM suite: 231 passed, 5 intentionally skipped hardware cases. +- Android debug and private release builds passed; release lint passed. +- Web Vitest: 94/94 passed; TypeScript and production build passed. +- Real headless Firefox against the C server passed desktop and exact 390×844 + Programs flows, including readable state text and deletion confirmation. +- Populated desktop migration continues from v25 through v27 with identical + business projections, integrity `ok`, empty foreign-key check, and an empty + execution ledger. Android v24 to v25 migration preserves the active draft and + invents no Program provenance. +- JSON/import validators, `git diff --check`, and + `tools/check_changed_c_format.sh main` passed. + +## Candidate boundary + +The coordinated candidates are built only after the final source commit. Their +exact paths, source commit, hashes, Android package metadata and signing digest +belong in the consent-gate report so the archive inventory can bind to the exact +final HEAD without circular documentation changes. diff --git a/docs/roadmap.md b/docs/roadmap.md index 4ce9154..04dee0c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -24,7 +24,7 @@ distinction between measured values and estimates. ## Current baseline -Desktop schema v26, Android schema v24, Notcurses, direct +Desktop schema v27, Android schema v25, Notcurses, direct `Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session Generator V1 is hidden pending V2. AI session-draft diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 324e431..fde7aad 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -145,6 +145,7 @@ does not publish to it or let it override an artifact in the new endpoint. | PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 | | PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals | | PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones | +| Android -> PC | `program-executions-v1.json` | optional `trainlog-program-executions` v1 companion: stable in-progress/completed provenance | | Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 | | PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 | @@ -165,6 +166,17 @@ V3, `trainlog-pc-catalog` V1, and `trainlog-session-preparations` V2. It does not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation semantics. +## Optional Program executions companion V1 + +`trainlog-program-executions` V1 is an Android-to-desktop full-generation +companion. Android schema v25 emits at most one stable execution fact per +Program session. Desktop schema v27 validates that the Program/session pair +exists, imports completed history before accepting a completed fact, and +permits only the monotonic `in_progress` to `completed` transition. Exact replay +is idempotent; identity reuse or regression is a hard conflict. Because the +descriptor is optional, peers predating this companion keep exchanging all +existing required domains without a capability failure. + `trainlog-exercise-aliases` v1 is the separate EXERCISE_MERGE_V1 identity companion. Its root contains exactly `format`, `version`, and `aliases`; every entry contains exactly `source_exercise_id` and `canonical_exercise_id` using diff --git a/docs/tests.md b/docs/tests.md index 3b7f88b..26d7cbd 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -75,6 +75,16 @@ no duplication. Python Programs/synchronization suites cover the staged companion's full snapshot, unacknowledged tombstone publication, and ACK-driven acknowledgment without changing mobile V3, catalog, or preparation artifacts. +Program execution regressions additionally cover Android schema v24 to v25, +start/replay/singleton protection, target-only draft creation, completion, +restart persistence and companion serialization. The integrated generation +test uses the production desktop and Android producers/consumers to import a +Program, execute one session, import completed history and its V1 provenance, +acknowledge the correlated generation, expose Web `completed`, then continue +through Program deletion, restart and old-generation replay without duplication +or resurrection. The desktop populated migration test now continues through +schema v27 and proves the new execution ledger starts empty. + `test_web_sessions_browser.py` runs headless Firefox against the embedded production assets and C HTTP server with private temporary XDG roots. It creates only a synthetic ready preparation and delivery, verifies French then persisted diff --git a/docs/web_sessions.md b/docs/web_sessions.md index 1c4f41a..a2ca273 100644 --- a/docs/web_sessions.md +++ b/docs/web_sessions.md @@ -79,6 +79,12 @@ a preparation from one active program session copies targets and provenance into a new manual preparation but creates no delivery, execution or performed data. Archived or deleted programs cannot be prepared. +Desktop schema v27 enriches each Program session with a persisted execution +state. Web displays `À faire`, `Préparée`, `En cours`, or `Effectuée`; completed +and in-progress states come from stable Android execution provenance, while +prepared comes from a live derived preparation. No title, date, position or +content heuristic is used. + The discrete right-edge Program trash action is accessible and opens a confirmation dialog that names the program and its consequence. Focus moves into that dialog, Escape and Cancel perform no mutation, and focus returns to diff --git a/tests/test_program_execution_exchange.py b/tests/test_program_execution_exchange.py new file mode 100644 index 0000000..1951d39 --- /dev/null +++ b/tests/test_program_execution_exchange.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Program-execution companion validation and idempotence regressions.""" + +import json +import sqlite3 +import sys +import tempfile +import unittest +from contextlib import closing +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import program_execution_exchange + + +class ProgramExecutionExchangeTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.database = Path(self.temporary.name) / "trainlog.sqlite" + self.artifact = Path(self.temporary.name) / "program-executions-v1.json" + with closing(sqlite3.connect(self.database)) as db: + db.executescript(""" + PRAGMA foreign_keys=ON; + CREATE TABLE programs(program_id TEXT PRIMARY KEY); + CREATE TABLE program_sessions( + program_session_id TEXT PRIMARY KEY, + program_id TEXT NOT NULL REFERENCES programs(program_id)); + CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE); + CREATE TABLE program_session_executions( + program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id), + program_id TEXT NOT NULL REFERENCES programs(program_id), + session_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL, + observed_at TEXT NOT NULL); + PRAGMA user_version=27; + """) + db.execute("INSERT INTO programs VALUES(?)", ( + "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + )) + db.execute("INSERT INTO program_sessions VALUES(?,?)", ( + "pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + )) + db.execute("INSERT INTO sessions(session_id) VALUES(?)", ( + "se_cccccccc-cccc-4ccc-8ccc-cccccccccccc", + )) + db.commit() + + def tearDown(self): + self.temporary.cleanup() + + def document(self, state: str) -> dict: + return { + "format": "trainlog-program-executions", + "version": 1, + "generated_at": "2026-09-18T20:00:00Z", + "executions": [{ + "program_id": "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "program_session_id": "pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "session_id": "se_cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "state": state, + "observed_at": "2026-09-18T19:30:00Z", + }], + } + + def apply(self, state: str): + self.artifact.write_text(json.dumps(self.document(state)), encoding="utf-8") + root = program_execution_exchange.load(self.artifact) + with closing(sqlite3.connect(self.database)) as db: + result = program_execution_exchange.apply_executions(db, root) + db.commit() + row = db.execute( + "SELECT state,session_id FROM program_session_executions" + ).fetchone() + return result, row + + def test_progression_is_durable_and_replay_is_idempotent(self): + self.assertEqual(((1, 0, 0), ("in_progress", "se_cccccccc-cccc-4ccc-8ccc-cccccccccccc")), + self.apply("in_progress")) + self.assertEqual(((0, 0, 1), ("in_progress", "se_cccccccc-cccc-4ccc-8ccc-cccccccccccc")), + self.apply("in_progress")) + self.assertEqual(((0, 1, 0), ("completed", "se_cccccccc-cccc-4ccc-8ccc-cccccccccccc")), + self.apply("completed")) + self.assertEqual(((0, 0, 1), ("completed", "se_cccccccc-cccc-4ccc-8ccc-cccccccccccc")), + self.apply("completed")) + + def test_completed_requires_imported_history(self): + with closing(sqlite3.connect(self.database)) as db: + db.execute("DELETE FROM sessions") + db.commit() + self.artifact.write_text(json.dumps(self.document("completed")), encoding="utf-8") + root = program_execution_exchange.load(self.artifact) + with closing(sqlite3.connect(self.database)) as db: + with self.assertRaisesRegex(ValueError, "missing history"): + program_execution_exchange.apply_executions(db, root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sync_deployment_tools.py b/tests/test_sync_deployment_tools.py index 292be08..2d93c53 100644 --- a/tests/test_sync_deployment_tools.py +++ b/tests/test_sync_deployment_tools.py @@ -66,9 +66,11 @@ class DeploymentToolsTest(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) inventory = json.loads((output / "candidate-inventory.json").read_text()) self.assertEqual(inventory["product_version"], "0.1.2") - self.assertEqual(inventory["desktop_schema"], 26) + self.assertEqual(inventory["desktop_schema"], 27) + self.assertEqual(inventory["android_schema"], 25) self.assertIn("session-preparations-v2", inventory["protocols"]) self.assertIn("programs-v1", inventory["protocols"]) + self.assertIn("program-executions-v1", inventory["protocols"]) self.assertTrue((output / "bin/trainlog").is_file()) self.assertTrue((output / "bin/trainlog-sync-once").is_file()) self.assertTrue((output / "bin/trainlog-syncd").is_file()) diff --git a/tests/test_web_sessions_browser.py b/tests/test_web_sessions_browser.py index a0128cd..41af216 100644 --- a/tests/test_web_sessions_browser.py +++ b/tests/test_web_sessions_browser.py @@ -185,6 +185,7 @@ class BrowserSessionsTest(unittest.TestCase): detail = driver.find_element(By.CSS_SELECTOR, "main").text self.assertIn("PROGRAMME · ACTIF", detail) self.assertIn("Import trainlog-program v1", detail) + self.assertIn("À faire", detail) self.assertLess(detail.index("1. Séance A ordonnée"), detail.index("2. Séance B ordonnée")) driver.find_element( diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py index 1067405..0c76f8c 100644 --- a/tools/causal_delete_exchange.py +++ b/tools/causal_delete_exchange.py @@ -38,7 +38,7 @@ def digest(value) -> str: def require_schema(db: sqlite3.Connection) -> None: version = db.execute("PRAGMA user_version").fetchone()[0] - if version not in (20, 21, 22, 23, 24, 25, 26): + if version not in (20, 21, 22, 23, 24, 25, 26, 27): raise CausalError(f"desktop schema v20-v25 required, found v{version}") diff --git a/tools/execution_draft_exchange.py b/tools/execution_draft_exchange.py index 075a3b2..560dd7a 100644 --- a/tools/execution_draft_exchange.py +++ b/tools/execution_draft_exchange.py @@ -182,7 +182,7 @@ def causal_payload(draft): def require_schema(connection): version = connection.execute("PRAGMA user_version").fetchone()[0] - if version not in (19, 20, 21, 22, 23, 24, 25, 26): + if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27): raise LifecycleError(f"desktop schema v19-v25 required, found v{version}") diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index 308132e..d23c5ed 100644 --- a/tools/export_ai_history.py +++ b/tools/export_ai_history.py @@ -12,7 +12,7 @@ from pathlib import Path from trainlog_sqlite import configure_connection -SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26) +SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27) MEASUREMENT_FIELDS = ( "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index 4008967..c219ccc 100644 --- a/tools/export_ai_session_drafts.py +++ b/tools/export_ai_session_drafts.py @@ -20,7 +20,7 @@ ID_PREFIX = "aid_" def ensure_publication_state(connection): """Install the additive v18 cursor for direct syncd/tool invocations.""" owned_transaction = not connection.in_transaction - supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26) + supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise ValueError("schema desktop v18-v25 requis") columns = {row[1] for row in connection.execute( diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 525b75e..48af33f 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -28,7 +28,7 @@ def main(): args = parser.parse_args() connection = connect_database(args.database) try: - supported_versions = tuple(range(8, 27)) + supported_versions = tuple(range(8, 28)) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: raise ValueError("schema desktop v8 à v16 requis") known_equipment = load_supplied_equipment_ids(args.catalog) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index e5e6623..e33669d 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -24,7 +24,7 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(8, 27)) + supported_versions = tuple(range(8, 28)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise ValueError("schema desktop v8 à v16 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index 9349cd3..1908b3a 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -17,7 +17,7 @@ def main(): con = connect_database(args.database) try: if con.execute("PRAGMA user_version").fetchone()[0] not in ( - 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26): + 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27): raise ValueError("schema desktop v12-v25 requis") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index 882cb54..b4d92ce 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -41,7 +41,7 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(11, 27)) + supported_versions = tuple(range(11, 28)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise ValueError("schema desktop v11-v16 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index daa031d..cd0028d 100644 --- a/tools/export_exercise_profile_state.py +++ b/tools/export_exercise_profile_state.py @@ -25,7 +25,7 @@ def main(): con.row_factory = sqlite3.Row try: version = con.execute("PRAGMA user_version").fetchone()[0] - if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26): + if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27): raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") rows = con.execute(""" SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 52066f7..991cbdb 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -71,7 +71,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 not in range(8, 27): + if version not in range(8, 28): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 2a80152..564e7d8 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -83,9 +83,9 @@ def main(complete_causal_envelope=False): con.row_factory = sqlite3.Row try: schema_version = con.execute("PRAGMA user_version").fetchone()[0] - if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26): + if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27): raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") - supported_versions = range(11, 27) + supported_versions = range(11, 28) legacy_v2_schema = args.version == 2 and schema_version == 10 if schema_version not in supported_versions and not legacy_v2_schema: raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)") diff --git a/tools/export_programs.py b/tools/export_programs.py index 28ff5f5..52cd035 100644 --- a/tools/export_programs.py +++ b/tools/export_programs.py @@ -16,8 +16,8 @@ def nullable(row, index): def export_programs(database: Path) -> dict: with closing(connect_database(database)) as db: - if db.execute("PRAGMA user_version").fetchone()[0] != 26: - raise ValueError("desktop schema v26 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27): + raise ValueError("desktop schema v26 or v27 required") programs = [] for row in db.execute( "SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at," diff --git a/tools/export_session_preparations.py b/tools/export_session_preparations.py index 128eb1b..ad81ffa 100644 --- a/tools/export_session_preparations.py +++ b/tools/export_session_preparations.py @@ -17,9 +17,9 @@ MAX_WITHDRAWALS = 128 def build_export(connection: sqlite3.Connection) -> dict: - supported_versions = (24, 25, 26) + supported_versions = (24, 25, 26, 27) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("desktop schema v24, v25 or v26 required") + raise ValueError("desktop schema v24, v25, v26 or v27 required") deliveries = [] rows = connection.execute( "SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state," diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index cf8c51e..737a588 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -10,7 +10,7 @@ def main(complete_causal_envelope=False): parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args() db=connect_database(args.database) try: - supported_versions = range(15, 27) + supported_versions = range(15, 28) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise ValueError("schema desktop v15-v25 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact") diff --git a/tools/import_ai_session_draft.py b/tools/import_ai_session_draft.py index 564272a..fbe271e 100644 --- a/tools/import_ai_session_draft.py +++ b/tools/import_ai_session_draft.py @@ -193,7 +193,7 @@ def import_payload(connection, payload, imported_at=None): INVARIANT: validation and every child insert share one IMMEDIATE transaction; any failure leaves no draft, child, or import-identity row behind. """ - supported_versions = range(18, 27) + supported_versions = range(18, 28) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise ImportFailure("schema desktop v18-v25 requis") draft, entries = validate(payload, connection) diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index 60a849d..4794073 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -187,7 +187,7 @@ def main(): payload = json.loads(args.artifact.read_text(encoding="utf-8")) connection = connect_database(args.database) try: - supported_versions = range(8, 27) + supported_versions = range(8, 28) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: fail("schema desktop v8 à v21 requis") proof_path = args.mobile_export diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index e52cca7..097a0cd 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -53,7 +53,7 @@ def validate(payload, reserved): def apply_definitions(connection, definitions, complete_causal_envelope=False): """Apply validated definitions without owning the surrounding transaction.""" - supported_versions = range(8, 27) + supported_versions = range(8, 28) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: fail("schema desktop v8 à v21 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index 45674bc..b413c7a 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -56,7 +56,7 @@ def load(path): def apply_aliases(con, aliases): """Apply parsed aliases inside the caller-owned transaction.""" - supported_versions = range(12, 27) + supported_versions = range(12, 28) if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: fail("schema desktop v12-v21 requis") for source, canonical in aliases: diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index b68b091..ce821a1 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -151,7 +151,7 @@ def replace(connection, row_id, primary, secondary): def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False): """Apply validated zone rows without owning transaction boundaries.""" - supported_versions = range(11, 27) + supported_versions = range(11, 28) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise ImportFailure("schema desktop v11-v25 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact") diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index 2c2a603..e8ffe0d 100644 --- a/tools/import_exercise_profile_state.py +++ b/tools/import_exercise_profile_state.py @@ -50,7 +50,7 @@ def apply_profile_state(con, root, allow_pending=False): if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide") parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row if con.execute("PRAGMA user_version").fetchone()[0] not in ( - 17, 18, 19, 20, 21, 22, 23, 24, 25, 26): + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27): fail("schema desktop v17-v25 requis") applied=equal=ancestor=pending=0;seen=set() for item in root["exercises"]: diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 2cb4be9..ee33ebe 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -846,7 +846,7 @@ def require_supported_schema(connection): # 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 range(5, 27): + if version not in range(5, 28): raise ImportFailure( f"base desktop schema v5 à v25 attendue, version trouvée: {version}" ) @@ -1978,7 +1978,7 @@ def apply_payload(connection, payload, trace_exercises=False): require_supported_schema(connection) schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] - if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26): + if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27): raise ImportFailure("mobile V4 exige le schéma desktop v19 à v25") if schema_version >= 20: protected = {(row[0], row[1]) for row in connection.execute( diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index 460c7cc..25a2874 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -52,7 +52,7 @@ def merge_revisions(db,table,parent_column,parent_id,revisions): def apply_feedback(db,root,complete_causal_envelope=False): roots_added=roots_skipped=revisions_added=revisions_skipped=0 db.execute("PRAGMA foreign_keys=ON") - supported_versions = range(15, 27) + supported_versions = range(15, 28) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: fail("schema desktop v15-v25 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact") diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py index b005671..446d195 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -39,6 +39,7 @@ RUNTIME_TOOLS = [ "export_ai_session_drafts.py", "export_session_preparations.py", "export_programs.py", + "program_execution_exchange.py", ] @@ -97,8 +98,8 @@ def main() -> int: ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ).strip(), "product_version": "0.1.2", - "desktop_schema": 26, - "android_schema": 24, + "desktop_schema": 27, + "android_schema": 25, "protocols": [ "mobile-export-v3", "mobile-history-v4", @@ -110,6 +111,7 @@ def main() -> int: "session-preparations-v2", "ai-session-drafts-v2", "programs-v1", + "program-executions-v1", ], "entry_points": [ "bin/trainlog", diff --git a/tools/program_execution_exchange.py b/tools/program_execution_exchange.py new file mode 100644 index 0000000..c8462c6 --- /dev/null +++ b/tools/program_execution_exchange.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Validate and merge Android program-execution provenance companion V1.""" + +import argparse +import json +import re +import sqlite3 +from datetime import datetime +from pathlib import Path + +from trainlog_sqlite import connect_database + + +FORMAT = "trainlog-program-executions" +VERSION = 1 +MAX_BYTES = 4 * 1024 * 1024 +MAX_EXECUTIONS = 4096 +UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" +PROGRAM_ID = re.compile(rf"^pg_{UUID4}$") +PROGRAM_SESSION_ID = re.compile(rf"^pgs_{UUID4}$") +SESSION_ID = re.compile(rf"^se_{UUID4}$") + + +def fail(message: str) -> None: + raise ValueError(message) + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + fail(f"duplicate JSON field: {key}") + result[key] = value + return result + + +def timestamp(value: object) -> str: + if ( + not isinstance(value, str) + or "T" not in value + or re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", value) is None + ): + fail("invalid execution timestamp") + try: + datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + fail("invalid execution timestamp") + return value + + +def load(path: Path) -> dict: + if path.stat().st_size > MAX_BYTES: + fail("program execution artifact exceeds 4 MiB") + root = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object) + if not isinstance(root, dict) or set(root) != { + "format", "version", "generated_at", "executions" + }: + fail("invalid program execution envelope") + if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION: + fail("unsupported program execution format") + timestamp(root["generated_at"]) + values = root["executions"] + if not isinstance(values, list) or len(values) > MAX_EXECUTIONS: + fail("program executions are absent or out of bounds") + + seen_program_sessions = set() + seen_sessions = set() + for item in values: + if not isinstance(item, dict) or set(item) != { + "program_id", "program_session_id", "session_id", "state", "observed_at" + }: + fail("invalid program execution") + if not PROGRAM_ID.fullmatch(item["program_id"]): + fail("invalid program_id") + if not PROGRAM_SESSION_ID.fullmatch(item["program_session_id"]): + fail("invalid program_session_id") + if not SESSION_ID.fullmatch(item["session_id"]): + fail("invalid session_id") + if item["state"] not in ("in_progress", "completed"): + fail("invalid program execution state") + timestamp(item["observed_at"]) + if item["program_session_id"] in seen_program_sessions or item["session_id"] in seen_sessions: + fail("duplicate program execution identity") + seen_program_sessions.add(item["program_session_id"]) + seen_sessions.add(item["session_id"]) + return root + + +def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]: + if db.execute("PRAGMA user_version").fetchone()[0] != 27: + fail("desktop schema v27 required") + added = 0 + advanced = 0 + skipped = 0 + for item in root["executions"]: + program_session = db.execute( + "SELECT program_id FROM program_sessions WHERE program_session_id=?", + (item["program_session_id"],), + ).fetchone() + if program_session is None or program_session[0] != item["program_id"]: + fail("program execution references an unknown or mismatched session") + if item["state"] == "completed": + completed = db.execute( + "SELECT 1 FROM sessions WHERE session_id=?", (item["session_id"],) + ).fetchone() + if completed is None: + fail("completed program execution references missing history") + + existing = db.execute( + "SELECT program_id,session_id,state,observed_at " + "FROM program_session_executions WHERE program_session_id=?", + (item["program_session_id"],), + ).fetchone() + expected = ( + item["program_id"], item["session_id"], item["state"], item["observed_at"] + ) + if existing is None: + db.execute( + "INSERT INTO program_session_executions(" + "program_session_id,program_id,session_id,state,observed_at) " + "VALUES(?,?,?,?,?)", + (item["program_session_id"], *expected), + ) + added += 1 + elif tuple(existing) == expected: + skipped += 1 + elif existing[0] == item["program_id"] and existing[1] == item["session_id"] and \ + existing[2] == "in_progress" and item["state"] == "completed": + db.execute( + "UPDATE program_session_executions SET state='completed',observed_at=? " + "WHERE program_session_id=?", + (item["observed_at"], item["program_session_id"]), + ) + advanced += 1 + else: + fail("HARD CONFLICT for program execution provenance") + return added, advanced, skipped + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("artifact", type=Path) + parser.add_argument("--database", required=True, type=Path) + args = parser.parse_args() + root = load(args.artifact) + db = connect_database(args.database) + try: + db.execute("BEGIN IMMEDIATE") + result = apply_executions(db, root) + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + print( + "PROGRAM_EXECUTIONS_IMPORT=PASS " + f"added={result[0]} advanced={result[1]} skipped={result[2]}" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index 84dcb1c..9ad6a2e 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -28,6 +28,7 @@ import import_exercise_body_zones import import_exercise_profile_state import import_mobile_export import import_training_feedback +import program_execution_exchange import export_equipment_definitions import export_exercise_body_zones import export_pc_mobile @@ -75,7 +76,9 @@ ARTIFACTS = ( ("programs-v1", "trainlog-programs", 1, "programs-v1.json", False, "export_programs.py", ()), ) -SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} +SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | { + ("trainlog-program-executions", 1), +} class GenerationError(RuntimeError): @@ -203,9 +206,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None): def require_schema(db: sqlite3.Connection) -> None: - supported_versions = (24, 25, 26) + supported_versions = (24, 25, 26, 27) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise GenerationError("desktop schema v24, v25 or v26 required") + raise GenerationError("desktop schema v24, v25, v26 or v27 required") def peer_identity(db: sqlite3.Connection, kind: str) -> str: @@ -602,6 +605,10 @@ def consume_desktop(database: Path, directory: Path) -> dict: zones_payload = strict_json(required("body-zones").read_bytes(), MAX_ARTIFACT) parsed_zones = import_exercise_body_zones.parse_payload(zones_payload) feedback = import_training_feedback.load(required("feedback")) + program_executions = ( + program_execution_exchange.load(listed["program-executions"]) + if "program-executions" in listed else None + ) mobile_occurrences = import_equipment_associations.load_mobile_occurrences(required("history")) with closing(connect_database(database)) as db: @@ -633,6 +640,8 @@ def consume_desktop(database: Path, directory: Path) -> dict: import_exercise_body_zones.apply_body_zones(db, parsed_zones, {}, complete_causal_envelope=True) import_training_feedback.apply_feedback(db, feedback, complete_causal_envelope=True) execution_draft_exchange.import_document(db, drafts, own_transaction=False) + if program_executions is not None: + program_execution_exchange.apply_executions(db, program_executions) ack = record_consumed(db, manifest, manifest_digest) db.commit() return ack diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 3cd8004..354ebbf 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -17,7 +17,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 26 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 27 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/src/database.c b/tui/src/database.c index 9f652fb..8f030be 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -1230,6 +1230,25 @@ static const char *const MIGRATE_V25_TO_V26_SQL = "generation_id,deleted_at,request_id) WHERE acknowledged_at IS NULL;" "PRAGMA user_version=26;COMMIT;"; +/* WHY: Android can execute a desktop-owned program session offline, so the + * canonical desktop needs a durable stable-identity link after import. + * CONTRACT: rows describe execution provenance only and never own or mutate + * program definitions or completed-session history. INVARIANT: one program + * session has at most one execution and replaying the same fact is idempotent. */ +static const char *const MIGRATE_V26_TO_V27_SQL = + "BEGIN IMMEDIATE;" + "CREATE TABLE IF NOT EXISTS program_session_executions(" + "program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id) " + "ON DELETE RESTRICT," + "program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT," + "session_id TEXT NOT NULL UNIQUE," + "state TEXT NOT NULL CHECK(state IN('in_progress','completed')) ," + "observed_at TEXT NOT NULL," + "CHECK(length(program_id)>0 AND length(session_id)>0));" + "CREATE INDEX IF NOT EXISTS program_session_executions_program " + "ON program_session_executions(program_id,state);" + "PRAGMA user_version=27;COMMIT;"; + /* CONTRACT: migration fixtures may retain additive v25 columns while * deliberately lowering user_version. Add each provenance column only when * absent; a genuine v24 database still receives both before opening returns. */ @@ -2019,7 +2038,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, } else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 || version == 16 || version == 17 || version == 18 || version == 19 || version == 20 || version == 21 || version == 22 || version == 23 || version == 24 || version == 25 || - version == 26) { + version == 26 || version == 27) { status = TRAINLOG_STATUS_OK; } else { if (version == 1) { @@ -2155,6 +2174,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status == TRAINLOG_STATUS_OK && version < 26) { status = execute_sql(database, MIGRATE_V25_TO_V26_SQL); } + if (status == TRAINLOG_STATUS_OK && version < 27) { + status = execute_sql(database, MIGRATE_V26_TO_V27_SQL); + } if (status == TRAINLOG_STATUS_OK) { status = ensure_v18_ai_draft_publication_state(database); } @@ -2174,7 +2196,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status != TRAINLOG_STATUS_OK) { set_open_diagnostic(output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v26" : "migrate database to schema v26", + version == 0 ? "create schema v27" : "migrate database to schema v27", database->connection, SQLITE_ERROR); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); diff --git a/tui/src/web_programs.c b/tui/src/web_programs.c index dbb60d0..2f5cab1 100644 --- a/tui/src/web_programs.c +++ b/tui/src/web_programs.c @@ -366,6 +366,8 @@ static TrainlogStatus add_program_session_json(TrainlogDatabase *database, !add_column_text(document, session, "session_type", statement, 3) || !add_column_text(document, session, "planned_for", statement, 4) || !add_column_text(document, session, "note", statement, 5) || + !add_column_text(document, session, "execution_state", statement, 6) || + !add_column_text(document, session, "execution_session_id", statement, 7) || !yyjson_mut_obj_add_val(document, session, "occurrences", entries) || !yyjson_mut_arr_add_val(sessions, session)) { return TRAINLOG_STATUS_DATABASE_ERROR; @@ -379,8 +381,15 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *sessions) { static const char SESSION_SQL[] = - "SELECT program_session_id,position,title,session_type,planned_for,note FROM " - "program_sessions WHERE program_id=?1 ORDER BY position,program_session_id"; + "SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for," + "ps.note,CASE WHEN pe.state='completed' THEN 'completed' " + "WHEN pe.state='in_progress' THEN 'in_progress' " + "WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE " + "sp.source_program_session_id=ps.program_session_id AND sp.withdrawn_at IS NULL) " + "THEN 'prepared' ELSE 'todo' END,pe.session_id " + "FROM program_sessions ps LEFT JOIN program_session_executions pe " + "ON pe.program_session_id=ps.program_session_id " + "WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id"; sqlite3_stmt *session_statement = NULL; int step; diff --git a/tui/tests/sync_generation_fixture.c b/tui/tests/sync_generation_fixture.c index 28bc7c9..d3c7a17 100644 --- a/tui/tests/sync_generation_fixture.c +++ b/tui/tests/sync_generation_fixture.c @@ -74,6 +74,21 @@ delete_program(TrainlogDatabase *database, const char *expected_revision, const return 0; } +static int print_program_detail(TrainlogDatabase *database) { + char *response = NULL; + size_t response_size = 0U; + TrainlogStatus status = trainlog_web_programs_detail_json( + database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size); + + if (status != TRAINLOG_STATUS_OK) { + free(response); + return 6; + } + (void)printf("PROGRAM_DETAIL=%s\n", response); + free(response); + return 0; +} + static int create_session_fixture(TrainlogDatabase *database) { TrainlogSetInput set = {.reps = 9, .has_weight = true, .weight_kg = 32.5}; TrainlogSessionExerciseInput exercise; @@ -212,6 +227,8 @@ int main(int argc, char **argv) { result = create_session_fixture(database); } else if (argc == 3 && strcmp(argv[2], "program-create") == 0) { result = create_program(database); + } else if (argc == 3 && strcmp(argv[2], "program-detail") == 0) { + result = print_program_detail(database); } else if (argc == 5 && strcmp(argv[2], "program-delete") == 0) { result = delete_program(database, argv[3], argv[4]); } else { diff --git a/tui/tests/test_schema_v26_migration.c b/tui/tests/test_schema_v26_migration.c index 2c1b8a9..61e6cc5 100644 --- a/tui/tests/test_schema_v26_migration.c +++ b/tui/tests/test_schema_v26_migration.c @@ -286,6 +286,8 @@ static bool build_v25_fixture(const char *path) { "PRAGMA foreign_keys=OFF;" "DROP INDEX program_deletions_pending;" "DROP TABLE program_deletions;" + "DROP INDEX program_session_executions_program;" + "DROP TABLE program_session_executions;" "ALTER TABLE programs DROP COLUMN deleted_at;" "PRAGMA user_version=25;" "PRAGMA foreign_keys=ON;") || @@ -388,7 +390,8 @@ static int run_test(void) { static const ProjectionSpec UNCHANGED_SCHEMA[] = { {"schema", "SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' " - "AND name NOT IN('programs','program_deletions','program_deletions_pending') " + "AND name NOT IN('programs','program_deletions','program_deletions_pending'," + "'program_session_executions','program_session_executions_program') " "ORDER BY type,name"}, }; char path[] = "/tmp/trainlog-v26-populated-XXXXXX"; @@ -429,12 +432,12 @@ static int run_test(void) { CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 26); + CHECK(version == 27); trainlog_database_close(database); database = NULL; CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK); - CHECK(scalar(raw, "PRAGMA user_version") == 26); + CHECK(scalar(raw, "PRAGMA user_version") == 27); CHECK(capture_projection(raw, BUSINESS_PROJECTIONS, sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]), @@ -449,6 +452,7 @@ static int run_test(void) { CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0); CHECK(scalar(raw, "SELECT COUNT(*) FROM programs WHERE deleted_at IS NOT NULL") == 0); CHECK(scalar(raw, "SELECT COUNT(*) FROM program_deletions") == 0); + CHECK(scalar(raw, "SELECT COUNT(*) FROM program_session_executions") == 0); CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('programs') WHERE name='deleted_at' AND " "type='TEXT' AND \"notnull\"=0") == 1); @@ -458,7 +462,7 @@ static int run_test(void) { "name='program_deletions_pending')") == 2); CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('programs')") == 13); CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'") == - schema_object_count_before + 2); + schema_object_count_before + 4); ok = true; cleanup: if (!ok && diagnostic[0] != '\0') { diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 7af5d53..9c4b79e 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) { TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); CHECK(strncmp(diagnostic, - "migrate database to schema v26: SQLite rc=", - strlen("migrate database to schema v26: SQLite rc=")) == 0); + "migrate database to schema v27: SQLite rc=", + strlen("migrate database to schema v27: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -197,12 +197,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=27;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=28;", 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 27 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 28 is newer") != NULL); CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 5b3763d..5797add 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) { CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strstr(diagnostic, "migrate database to schema v26") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v27") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); diff --git a/tui/tests/test_web_programs.c b/tui/tests/test_web_programs.c index a9173c1..25f7c89 100644 --- a/tui/tests/test_web_programs.c +++ b/tui/tests/test_web_programs.c @@ -193,7 +193,7 @@ static bool import_archive_and_prepare(void) { char *second_entry; CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - CHECK(scalar(database, "PRAGMA user_version") == 26); + CHECK(scalar(database, "PRAGMA user_version") == 27); CHECK(scalar(database, "SELECT COUNT(*) FROM pragma_table_info('program_deletions') WHERE " "name IN('generation_id','acknowledged_at')") == 2); @@ -268,6 +268,7 @@ static bool import_archive_and_prepare(void) { TRAINLOG_STATUS_OK); CHECK(extract_string(response, "revision_id", revision, sizeof(revision))); CHECK(strstr(response, "Exercise fixture") == NULL); + CHECK(strstr(response, "\"execution_state\":\"todo\"") != NULL); free(response); response = NULL; @@ -283,6 +284,12 @@ static bool import_archive_and_prepare(void) { "WHERE source_program_id='pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'") == 1); free(response); response = NULL; + CHECK(trainlog_web_programs_detail_json( + database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) == + TRAINLOG_STATUS_OK); + CHECK(strstr(response, "\"execution_state\":\"prepared\"") != NULL); + free(response); + response = NULL; CHECK(trainlog_web_programs_archive_json(database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", diff --git a/web/src/api/programs.ts b/web/src/api/programs.ts index 9b000a6..cbef209 100644 --- a/web/src/api/programs.ts +++ b/web/src/api/programs.ts @@ -39,6 +39,8 @@ export interface ProgramSession { session_type: 'training' | 'max_test' planned_for: string | null note: string | null + execution_state: 'todo' | 'prepared' | 'in_progress' | 'completed' + execution_session_id: string | null occurrences: ProgramOccurrence[] } diff --git a/web/src/routes/ProgramsTab.test.tsx b/web/src/routes/ProgramsTab.test.tsx index 5cf5032..12b06ed 100644 --- a/web/src/routes/ProgramsTab.test.tsx +++ b/web/src/routes/ProgramsTab.test.tsx @@ -165,6 +165,8 @@ describe('Programs tab', () => { session_type: 'training', planned_for: null, note: null, + execution_state: 'todo', + execution_session_id: null, occurrences: [{ entry_id: 'pge_test', position: 0, diff --git a/web/src/routes/ProgramsTab.tsx b/web/src/routes/ProgramsTab.tsx index 401be14..16853b7 100644 --- a/web/src/routes/ProgramsTab.tsx +++ b/web/src/routes/ProgramsTab.tsx @@ -63,6 +63,12 @@ function ProgramDetailView({ programId, onBack, onChanged }: { setError(reason instanceof Error ? reason.message : 'Création impossible') } } + const executionLabel = (state: ProgramDetail['sessions'][number]['execution_state']) => ({ + todo: 'À faire', + prepared: 'Préparée', + in_progress: 'En cours', + completed: 'Effectuée', + })[state] return
- + >Créer une préparation} + {session.execution_state === 'prepared' && Préparation disponible} + {session.execution_state === 'in_progress' && Séance en cours sur Android} + {session.execution_state === 'completed' && ✓ Séance effectuée} )}
diff --git a/web/src/theme/app.css b/web/src/theme/app.css index 1ee7169..0497b70 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -172,6 +172,10 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett .program-session-list { padding: 0; list-style: none; } .program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; } .program-session-list p { margin: .35rem 0 0; color: var(--subtext-0); } +.program-execution-state { display: inline-flex; margin-left: .65rem; padding: .2rem .55rem; border-radius: 999px; font-size: .82rem; font-weight: 700; } +.program-execution-state.state-todo { background: var(--surface-1); color: var(--subtext-1); } +.program-execution-state.state-prepared, .program-execution-state.state-in_progress { background: color-mix(in srgb, var(--yellow) 18%, transparent); color: var(--yellow); } +.program-execution-state.state-completed { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); } .import-preview { margin: 1rem 0; padding: 1rem; border: 1px solid var(--blue); border-radius: .75rem; background: var(--mantle); } .visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } @media (max-width: 600px) {