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 2410e94..3f55256 100644 Binary files a/docs/reviews/evidence/web-programs-v1-card-desktop.png and b/docs/reviews/evidence/web-programs-v1-card-desktop.png differ 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) {