Add durable program session execution flow

This commit is contained in:
fy59 2026-09-18 21:30:14 +02:00
parent db722588f8
commit 949ace6301
71 changed files with 1126 additions and 86 deletions

View file

@ -2,6 +2,16 @@
## Unreleased — 0.1.2 ## 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 - Completed the private coordinated deployment of
`TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1`. Desktop schema v26 adds `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1`. Desktop schema v26 adds
terminal logical Program deletion with terminal logical Program deletion with

View file

@ -320,7 +320,7 @@ not contain cloud credentials and does not run `rclone`.
As of 2026-09-18: As of 2026-09-18:
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`; - `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; - Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's - direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting; all-files access setting;

View file

@ -219,7 +219,7 @@ internal class AndroidBackupService(private val context: Context) {
manifest.getInt("version") == VERSION && manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName && manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") && manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..24 manifest.getInt("schema") in 17..25
) { ) {
"Unsupported backup." "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.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 return db.version
} finally { } finally {
db.close() db.close()

View file

@ -66,6 +66,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
"feedback" to Kind("trainlog-training-feedback", 2, "feedback-v2.json"), "feedback" to Kind("trainlog-training-feedback", 2, "feedback-v2.json"),
"causal-deletions" to "causal-deletions" to
Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"), 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 = private val SUPPORTED =
(ANDROID_KINDS.values + (ANDROID_KINDS.values +
@ -581,7 +583,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
if (total > MAX_GENERATION_BYTES) if (total > MAX_GENERATION_BYTES)
throw SyncGenerationException("generation exceeds bound") 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") throw SyncGenerationException("missing required domain")
return root return root
} }

View file

@ -42,6 +42,7 @@ import com.labfytools.trainlog.model.SyncedProgramDetail
import com.labfytools.trainlog.model.SyncedProgramOccurrence import com.labfytools.trainlog.model.SyncedProgramOccurrence
import com.labfytools.trainlog.model.SyncedProgramSession import com.labfytools.trainlog.model.SyncedProgramSession
import com.labfytools.trainlog.model.SyncedProgramSummary import com.labfytools.trainlog.model.SyncedProgramSummary
import com.labfytools.trainlog.model.ProgramSessionExecutionState
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.text.Normalizer import java.text.Normalizer
@ -228,6 +229,15 @@ sealed interface ProgramsImportResult {
data object DatabaseError : 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 SyncLifecycleConflict(val ownerId: String) : RuntimeException()
private class ProgramsImportInvalid(message: String) : RuntimeException(message) private class ProgramsImportInvalid(message: String) : RuntimeException(message)
@ -486,15 +496,58 @@ class TrainlogRepository(
jsonHasUniqueObjectKeys(json) jsonHasUniqueObjectKeys(json)
/** Captures Android-owned generation artifacts from one read snapshot. */ /** Captures Android-owned generation artifacts from one read snapshot. */
internal fun captureSyncGenerationArtifacts(afterFirstArtifact: (() -> Unit)? = null): Map<String, String> = internal fun captureSyncGenerationArtifacts(
inSyncGenerationTransaction { afterFirstArtifact: (() -> Unit)? = null,
val artifacts=linkedMapOf("catalog" to buildPcCatalogJson()) ): Map<String, String> = inSyncGenerationTransaction {
val artifacts = linkedMapOf("catalog" to buildPcCatalogJson())
afterFirstArtifact?.invoke() afterFirstArtifact?.invoke()
artifacts["history"]=buildMobileExport(4, true);artifacts["execution-drafts"]=buildExecutionDraftExportV1Json() artifacts["history"] = buildMobileExport(4, true)
artifacts["exercise-aliases"]=buildExerciseAliasesJson();artifacts["exercise-profile-state"]=buildExerciseProfileStateJson() artifacts["execution-drafts"] = buildExecutionDraftExportV1Json()
artifacts["equipment-definitions"]=buildEquipmentDefinitionsJson(true);artifacts["equipment-associations"]=buildEquipmentAssociationsJson() artifacts["exercise-aliases"] = buildExerciseAliasesJson()
artifacts["body-zones"]=buildExerciseBodyZonesJson(true);artifacts["feedback"]=buildTrainingFeedbackJson(true) artifacts["exercise-profile-state"] = buildExerciseProfileStateJson()
artifacts["causal-deletions"]=buildCausalDeletionExportV1Json();artifacts 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. */ /** Direction-neutral stable-identity catalogue used before generation history. */
@ -2305,6 +2358,9 @@ class TrainlogRepository(
} || } ||
(draft.sourceSessionId != null && (draft.sourceSessionId != null &&
(draft.sourceSessionId.isBlank() || draft.sessionType != SessionType.MAX_TEST)) || (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 /* exercise_id identifies the catalogue movement. Repeated passages
* are valid; only their stable occurrence IDs must be unique. */ * are valid; only their stable occurrence IDs must be unique. */
draft.exercises draft.exercises
@ -2456,6 +2512,18 @@ class TrainlogRepository(
forcedStartedAt = active.draft.startedAt, 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 /* CONTRACT: lifecycle transition changes table ownership, not the
* stable note owner. Copy caches while causal state remains keyed * stable note owner. Copy caches while causal state remains keyed
* by session_id/entry_id; rollback covers every child. */ * by session_id/entry_id; rollback covers every child. */
@ -7095,7 +7163,9 @@ class TrainlogRepository(
d.session_id, d.session_id,
d.revision_id, d.revision_id,
d.parent_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 FROM active_session_draft AS d
LEFT JOIN exercises AS e LEFT JOIN exercises AS e
ON e.id = d.selected_exercise_row_id ON e.id = d.selected_exercise_row_id
@ -7140,6 +7210,8 @@ class TrainlogRepository(
revisionId = cursor.getString(19), revisionId = cursor.getString(19),
parentRevisionId = if (cursor.isNull(20)) null else cursor.getString(20), parentRevisionId = if (cursor.isNull(20)) null else cursor.getString(20),
startedAt = if (cursor.isNull(21)) null else cursor.getString(21), 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 = updatedAt =
cursor.getString(6), cursor.getString(6),
warning = warning =
@ -7323,6 +7395,8 @@ class TrainlogRepository(
exercises = exercises, exercises = exercises,
sessionType = header.sessionType, sessionType = header.sessionType,
sourceSessionId = header.sourceSessionId, sourceSessionId = header.sourceSessionId,
sourceProgramId = header.sourceProgramId,
sourceProgramSessionId = header.sourceProgramSessionId,
form = header.form, form = header.form,
updatedAt = header.updatedAt, updatedAt = header.updatedAt,
), ),
@ -7351,6 +7425,8 @@ class TrainlogRepository(
put("revision_id", draft.revisionId) put("revision_id", draft.revisionId)
putOptionalString("parent_revision_id", draft.parentRevisionId) putOptionalString("parent_revision_id", draft.parentRevisionId)
putOptionalString("source_session_id", draft.sourceSessionId) 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) putOptionalString("selected_equipment_id", draft.form.selectedEquipmentId)
put("weight_text", draft.form.weightText) put("weight_text", draft.form.weightText)
put("max_weight_text", draft.form.maxWeightText) put("max_weight_text", draft.form.maxWeightText)
@ -7852,6 +7928,8 @@ class TrainlogRepository(
val sessionType: SessionType, val sessionType: SessionType,
val form: SessionDraftForm, val form: SessionDraftForm,
val sourceSessionId: String?, val sourceSessionId: String?,
val sourceProgramId: String?,
val sourceProgramSessionId: String?,
val sessionId: String?, val sessionId: String?,
val revisionId: String, val revisionId: String,
val parentRevisionId: String?, val parentRevisionId: String?,
@ -8339,12 +8417,15 @@ class TrainlogRepository(
) )
} }
} }
val execution = programSessionExecution(db, programId, sessionId)
sessions += SyncedProgramSession( sessions += SyncedProgramSession(
sessionId, sessionId,
sessionCursor.getString(1), sessionCursor.getString(1),
sessionCursor.getString(2), sessionCursor.getString(2),
if (sessionCursor.isNull(3)) null else sessionCursor.getString(3), if (sessionCursor.isNull(3)) null else sessionCursor.getString(3),
if (sessionCursor.isNull(4)) null else sessionCursor.getString(4), if (sessionCursor.isNull(4)) null else sessionCursor.getString(4),
execution.first,
execution.second,
entries, entries,
) )
} }
@ -8360,6 +8441,141 @@ class TrainlogRepository(
) )
} }
private fun programSessionExecution(
db: SQLiteDatabase,
programId: String,
programSessionId: String,
): Pair<ProgramSessionExecutionState, String?> {
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<SessionExerciseDraft> = 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( private fun normalizeName(
value: String, value: String,
): String { ): String {
@ -8538,7 +8754,7 @@ private class TrainlogDatabaseHelper(
appContext, appContext,
databaseName, databaseName,
null, null,
24, 25,
) { ) {
override fun onConfigure( override fun onConfigure(
db: SQLiteDatabase, db: SQLiteDatabase,
@ -8587,6 +8803,7 @@ private class TrainlogDatabaseHelper(
createSessionPreparationTables(db) createSessionPreparationTables(db)
createSessionPreparationWithdrawalTable(db) createSessionPreparationWithdrawalTable(db)
createSyncedProgramTables(db) createSyncedProgramTables(db)
createProgramExecutionProvenance(db)
seedEquipment(db) seedEquipment(db)
} }
@ -8784,6 +9001,14 @@ private class TrainlogDatabaseHelper(
createSyncedProgramTables(db) createSyncedProgramTables(db)
version = 24 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) { if (version != newVersion) {
error( 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) { private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {
fun addColumn(table: String, name: String, declaration: String) { fun addColumn(table: String, name: String, declaration: String) {
if (!tableHasColumn(db, table, name)) { if (!tableHasColumn(db, table, name)) {
@ -9560,6 +9802,12 @@ private class TrainlogDatabaseHelper(
'training', 'training',
'max_test' 'max_test'
) )
),
source_program_id TEXT,
source_program_session_id TEXT,
CHECK(
(source_program_id IS NULL) =
(source_program_session_id IS NULL)
) )
); );
""".trimIndent() """.trimIndent()
@ -9794,6 +10042,8 @@ private class TrainlogDatabaseHelper(
session_id TEXT UNIQUE, session_id TEXT UNIQUE,
revision_id TEXT NOT NULL DEFAULT 'dr_legacy_v1', revision_id TEXT NOT NULL DEFAULT 'dr_legacy_v1',
parent_revision_id TEXT, 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) notes TEXT CHECK(notes IS NULL OR length(CAST(notes AS BLOB)) <= 4096)
); );
""".trimIndent() """.trimIndent()

View file

@ -26,9 +26,17 @@ data class SyncedProgramSession(
val sessionType: String, val sessionType: String,
val plannedFor: String?, val plannedFor: String?,
val note: String?, val note: String?,
val executionState: ProgramSessionExecutionState,
val executionSessionId: String?,
val occurrences: List<SyncedProgramOccurrence>, val occurrences: List<SyncedProgramOccurrence>,
) )
enum class ProgramSessionExecutionState {
TODO,
IN_PROGRESS,
COMPLETED,
}
data class SyncedProgramOccurrence( data class SyncedProgramOccurrence(
val entryId: String, val entryId: String,
val exerciseId: String, val exerciseId: String,

View file

@ -112,6 +112,9 @@ data class ActiveSessionDraft(
val sessionType: SessionType = SessionType.TRAINING, val sessionType: SessionType = SessionType.TRAINING,
/** Existing max_test session updated atomically on finalization, if any. */ /** Existing max_test session updated atomically on finalization, if any. */
val sourceSessionId: String? = null, 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 form: SessionDraftForm = SessionDraftForm(),
val updatedAt: String = "", val updatedAt: String = "",
) )

View file

@ -1,10 +1,17 @@
package com.labfytools.trainlog.ui package com.labfytools.trainlog.ui
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import com.labfytools.trainlog.R import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.StartProgramSessionResult
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ProgramSessionExecutionState
/** Presentation-only list for the desktop-owned Programs projection. */ /** Presentation-only list for the desktop-owned Programs projection. */
@Composable @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 @Composable
fun ProgramDetailScreen(repository: TrainlogRepository, programId: String) { fun ProgramDetailScreen(
repository: TrainlogRepository,
programId: String,
onOpenActiveSession: () -> Unit,
) {
val strings = localizedContext() val strings = localizedContext()
val program = repository.getSyncedProgram(programId) var revision by remember { mutableIntStateOf(0) }
var message by remember { mutableStateOf<String?>(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)) { TrainlogScreen(strings.getString(R.string.route_program_detail)) {
if (program == null) { if (program == null) {
TrainlogInfo(strings.getString(R.string.program_not_found)) 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.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
program.note?.let { TrainlogInfo(it) } program.note?.let { TrainlogInfo(it) }
} }
message?.let { TrainlogInfo(it) }
program.sessions.forEachIndexed { index, session -> 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( TrainlogInfo(
listOfNotNull( listOfNotNull(
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training), 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(" · "), ).joinToString(" · "),
) )
session.note?.let { TrainlogInfo(it) } 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) } 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() ?: 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() val sets = occurrence.targetSets?.let { strings.getString(R.string.program_target_sets, it) }.orEmpty()

View file

@ -14,6 +14,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -68,6 +69,7 @@ fun SessionsHub(
} }
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory, TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
Modifier.fillMaxWidth().testTag("sessions-history-action")) Modifier.fillMaxWidth().testTag("sessions-history-action"))
Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing"))
TrainlogButton(strings.getString(R.string.route_programs), onPrograms, TrainlogButton(strings.getString(R.string.route_programs), onPrograms,
Modifier.fillMaxWidth().testTag("sessions-programs-action")) Modifier.fillMaxWidth().testTag("sessions-programs-action"))
} }

View file

@ -125,7 +125,10 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
) )
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) } AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(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.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) }
is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved -> is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved ->
if (saved) exportSnapshot() if (saved) exportSnapshot()

View file

@ -25,6 +25,15 @@
<string name="program_target_reps">%1$d repetitions</string> <string name="program_target_reps">%1$d repetitions</string>
<string name="program_target_duration">%1$d s</string> <string name="program_target_duration">%1$d s</string>
<string name="program_target_weight">%1$.1f kg</string> <string name="program_target_weight">%1$.1f kg</string>
<string name="program_session_todo">To do</string>
<string name="program_session_in_progress">In progress</string>
<string name="program_session_completed">Completed</string>
<string name="program_session_start">Start</string>
<string name="program_session_resume">Resume session</string>
<string name="program_session_completed_detail">This session has already been completed.</string>
<string name="program_session_unavailable">This session can no longer be started.</string>
<string name="program_session_show_occurrences">Show exercises</string>
<string name="program_session_hide_occurrences">Hide exercises</string>
<plurals name="program_session_count"><item quantity="one">%d session</item><item quantity="other">%d sessions</item></plurals> <plurals name="program_session_count"><item quantity="one">%d session</item><item quantity="other">%d sessions</item></plurals>
<string name="route_session_detail">Session details</string> <string name="route_session_detail">Session details</string>
<string name="route_session_correction">Edit session</string> <string name="route_session_correction">Edit session</string>

View file

@ -25,6 +25,15 @@
<string name="program_target_reps">%1$d répétitions</string> <string name="program_target_reps">%1$d répétitions</string>
<string name="program_target_duration">%1$d s</string> <string name="program_target_duration">%1$d s</string>
<string name="program_target_weight">%1$.1f kg</string> <string name="program_target_weight">%1$.1f kg</string>
<string name="program_session_todo">À faire</string>
<string name="program_session_in_progress">En cours</string>
<string name="program_session_completed">Effectuée</string>
<string name="program_session_start">Démarrer</string>
<string name="program_session_resume">Reprendre la séance</string>
<string name="program_session_completed_detail">Cette séance a déjà été effectuée.</string>
<string name="program_session_unavailable">Cette séance ne peut plus être démarrée.</string>
<string name="program_session_show_occurrences">Voir les exercices</string>
<string name="program_session_hide_occurrences">Masquer les exercices</string>
<plurals name="program_session_count"><item quantity="one">%d séance</item><item quantity="other">%d séances</item></plurals> <plurals name="program_session_count"><item quantity="one">%d séance</item><item quantity="other">%d séances</item></plurals>
<string name="route_session_detail">Détail de la séance</string> <string name="route_session_detail">Détail de la séance</string>
<string name="route_session_correction">Modifier la séance</string> <string name="route_session_correction">Modifier la séance</string>

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() } TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> 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")) { 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(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")) 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"))

View file

@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
migrated.close() migrated.close()
} }
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> 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 /* v13 intentionally adds the frozen machine identities and
* exercise-owned metadata; all non-exercise facts stay exact. */ * 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") } val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }

View file

@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
migrated.close() migrated.close()
} }
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> 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( assertEquals(before, listOf(
scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM sessions"),
scalar(db, "SELECT COUNT(*) FROM performed_sets"), scalar(db, "SELECT COUNT(*) FROM performed_sets"),

View file

@ -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)
}
}

View file

@ -328,7 +328,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor -> 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 -> db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
null, null,
android.database.sqlite.SQLiteDatabase.OPEN_READONLY, android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
) )
.use { assertEquals(24, it.version) } .use { assertEquals(25, it.version) }
} finally { } finally {
repository.close() repository.close()
context.deleteDatabase(name) context.deleteDatabase(name)

View file

@ -3,6 +3,8 @@ package com.labfytools.trainlog.data
import android.content.Context import android.content.Context
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import java.util.UUID import java.util.UUID
import com.labfytools.trainlog.model.ProgramSessionExecutionState
import com.labfytools.trainlog.model.SessionSetDraft
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -124,4 +126,72 @@ class ProgramsProjectionRepositoryTest {
context.deleteDatabase(name) 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)
}
}
} }

View file

@ -260,7 +260,7 @@ class SessionPreparationExchangeTest {
repository = TrainlogRepository(context, name) repository = TrainlogRepository(context, name)
assertEquals(1, repository.listPreparedSessions().size) assertEquals(1, repository.listPreparedSessions().size)
repository.close() repository.close()
assertEquals("24", scalarText(name, "PRAGMA user_version")) assertEquals("25", scalarText(name, "PRAGMA user_version"))
assertEquals( assertEquals(
"0", "0",
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"), scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),

View file

@ -149,6 +149,63 @@ class SyncGenerationServiceTest {
acceptAndroidAck(liveGenerationId, liveAck).contains("ACK_ACCEPT=acknowledged") 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 = val deleted =
run( run(
fixture.absolutePath, fixture.absolutePath,
@ -329,7 +386,7 @@ class SyncGenerationServiceTest {
.use { db -> .use { db ->
db.rawQuery("PRAGMA user_version", null).use { db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst()) assertTrue(it.moveToFirst())
assertEquals(24, it.getInt(0)) assertEquals(25, it.getInt(0))
} }
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst()) assertTrue(it.moveToFirst())

View file

@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
).use { db -> ).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor -> db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())
assertEquals(24, cursor.getInt(0)) assertEquals(25, cursor.getInt(0))
} }
db.rawQuery( db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
).use { db -> ).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor -> db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())
assertEquals(24, cursor.getInt(0)) assertEquals(25, cursor.getInt(0))
} }
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())

View file

@ -23,6 +23,13 @@ Sessions without offering import, archive, delete, preparation, or other
program mutation actions. The projection is separate from capture data, AI program mutation actions. The projection is separate from capture data, AI
proposals, manual preparations, and the active-session singleton. 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 ## 1. Purpose
The Android application is Trainlog's low-friction capture client. 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 ACK before marking the tombstone acknowledged. It changes neither
`TRAINLOG_FORMAT_V1`, mobile V3, catalog exchange, nor preparation exchange. `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 Completed session occurrences persist an `entry_id`; it is never regenerated
for exchange. Android schema v14 added feedback roots and completion `ended_at` for exchange. Android schema v14 added feedback roots and completion `ended_at`
anchors; v15 adds immutable correction history and the separately versioned anchors; v15 adds immutable correction history and the separately versioned

View file

@ -57,8 +57,8 @@ parallel implementation of its rules.
| Boundary | Current state | | Boundary | Current state |
|---|---| |---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v26 | | Desktop SQLite | schema v27 |
| Android SQLite | schema v24 | | Android SQLite | schema v25 |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only | | Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` | | 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 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) | | 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) | | 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` | | Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` |
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_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` | | 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 durable correlated ACK records acknowledgment. It changes neither
`TRAINLOG_FORMAT_V1`, mobile V3, catalog exchange, nor preparations. `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 The private Programs rollout migrated desktop schema v25 to v26 and Android
schema v23 to v24 after verified backups, then installed the signed schema v23 to v24 after verified backups, then installed the signed
non-debuggable versionCode 16 APK without uninstalling or clearing data. The non-debuggable versionCode 16 APK without uninstalling or clearing data. The

View file

@ -1,5 +1,15 @@
# Desktop database # 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: Programs presentation, Android projection, and logical deletion
Schema v26 adds nullable `programs.deleted_at` and the terminal Schema v26 adds nullable `programs.deleted_at` and the terminal
@ -65,8 +75,8 @@ is a later, separately versioned migration.
## 1. Status ## 1. Status
```text ```text
TRAINLOG_DATABASE_SCHEMA_VERSION=26 TRAINLOG_DATABASE_SCHEMA_VERSION=27
DATABASE_SCHEMA_V26=IMPLEMENTED DATABASE_SCHEMA_V27=IMPLEMENTED
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
``` ```
@ -85,7 +95,7 @@ PRAGMA user_version;
Current value: Current value:
```text ```text
26 27
``` ```
The independent actual-set loads documented in the current desktop, Android The independent actual-set loads documented in the current desktop, Android

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

View file

@ -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.

View file

@ -24,7 +24,7 @@ distinction between measured values and estimates.
## Current baseline ## 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 `Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
implemented. Session Generator V1 is hidden pending V2. AI session-draft implemented. Session Generator V1 is hidden pending V2. AI session-draft

View file

@ -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-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 | `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 | | 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 | | Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` 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 not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation
semantics. 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 `trainlog-exercise-aliases` v1 is the separate EXERCISE_MERGE_V1 identity
companion. Its root contains exactly `format`, `version`, and `aliases`; every companion. Its root contains exactly `format`, `version`, and `aliases`; every
entry contains exactly `source_exercise_id` and `canonical_exercise_id` using entry contains exactly `source_exercise_id` and `canonical_exercise_id` using

View file

@ -75,6 +75,16 @@ no duplication. Python Programs/synchronization suites cover the staged
companion's full snapshot, unacknowledged tombstone publication, and ACK-driven companion's full snapshot, unacknowledged tombstone publication, and ACK-driven
acknowledgment without changing mobile V3, catalog, or preparation artifacts. 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 `test_web_sessions_browser.py` runs headless Firefox against the embedded
production assets and C HTTP server with private temporary XDG roots. It creates production assets and C HTTP server with private temporary XDG roots. It creates
only a synthetic ready preparation and delivery, verifies French then persisted only a synthetic ready preparation and delivery, verifies French then persisted

View file

@ -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 into a new manual preparation but creates no delivery, execution or performed
data. Archived or deleted programs cannot be prepared. 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 The discrete right-edge Program trash action is accessible and opens a
confirmation dialog that names the program and its consequence. Focus moves confirmation dialog that names the program and its consequence. Focus moves
into that dialog, Escape and Cancel perform no mutation, and focus returns to into that dialog, Escape and Cancel perform no mutation, and focus returns to

View file

@ -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()

View file

@ -66,9 +66,11 @@ class DeploymentToolsTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text()) inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.2") 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("session-preparations-v2", inventory["protocols"])
self.assertIn("programs-v1", 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").is_file())
self.assertTrue((output / "bin/trainlog-sync-once").is_file()) self.assertTrue((output / "bin/trainlog-sync-once").is_file())
self.assertTrue((output / "bin/trainlog-syncd").is_file()) self.assertTrue((output / "bin/trainlog-syncd").is_file())

View file

@ -185,6 +185,7 @@ class BrowserSessionsTest(unittest.TestCase):
detail = driver.find_element(By.CSS_SELECTOR, "main").text detail = driver.find_element(By.CSS_SELECTOR, "main").text
self.assertIn("PROGRAMME · ACTIF", detail) self.assertIn("PROGRAMME · ACTIF", detail)
self.assertIn("Import trainlog-program v1", 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")) self.assertLess(detail.index("1. Séance A ordonnée"), detail.index("2. Séance B ordonnée"))
driver.find_element( driver.find_element(

View file

@ -38,7 +38,7 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None: def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0] 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}") raise CausalError(f"desktop schema v20-v25 required, found v{version}")

View file

@ -182,7 +182,7 @@ def causal_payload(draft):
def require_schema(connection): def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0] 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}") raise LifecycleError(f"desktop schema v19-v25 required, found v{version}")

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection 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 = ( MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",

View file

@ -20,7 +20,7 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection): def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations.""" """Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction 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: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v18-v25 requis") raise ValueError("schema desktop v18-v25 requis")
columns = {row[1] for row in connection.execute( columns = {row[1] for row in connection.execute(

View file

@ -28,7 +28,7 @@ def main():
args = parser.parse_args() args = parser.parse_args()
connection = connect_database(args.database) connection = connect_database(args.database)
try: 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: if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v16 requis") raise ValueError("schema desktop v8 à v16 requis")
known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment = load_supplied_equipment_ids(args.catalog)

View file

@ -24,7 +24,7 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database) connection = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: 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: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v16 requis") 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(): 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():

View file

@ -17,7 +17,7 @@ def main():
con = connect_database(args.database) con = connect_database(args.database)
try: try:
if con.execute("PRAGMA user_version").fetchone()[0] not in ( 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") raise ValueError("schema desktop v12-v25 requis")
rows = con.execute( rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "

View file

@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database) connection = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: 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: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v16 requis") 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(): 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():

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
try: try:
version = con.execute("PRAGMA user_version").fetchone()[0] 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}") raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute(""" rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,

View file

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

View file

@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
try: try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0] 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") 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 legacy_v2_schema = args.version == 2 and schema_version == 10
if schema_version not in supported_versions and not legacy_v2_schema: 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)") raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")

View file

@ -16,8 +16,8 @@ def nullable(row, index):
def export_programs(database: Path) -> dict: def export_programs(database: Path) -> dict:
with closing(connect_database(database)) as db: with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] != 26: if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27):
raise ValueError("desktop schema v26 required") raise ValueError("desktop schema v26 or v27 required")
programs = [] programs = []
for row in db.execute( for row in db.execute(
"SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at," "SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at,"

View file

@ -17,9 +17,9 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict: 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: 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 = [] deliveries = []
rows = connection.execute( rows = connection.execute(
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state," "SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"

View file

@ -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() 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) db=connect_database(args.database)
try: try:
supported_versions = range(15, 27) supported_versions = range(15, 28)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v25 requis") 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") 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")

View file

@ -193,7 +193,7 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction; INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind. 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: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v25 requis") raise ImportFailure("schema desktop v18-v25 requis")
draft, entries = validate(payload, connection) draft, entries = validate(payload, connection)

View file

@ -187,7 +187,7 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8")) payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database) connection = connect_database(args.database)
try: try:
supported_versions = range(8, 27) supported_versions = range(8, 28)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis") fail("schema desktop v8 à v21 requis")
proof_path = args.mobile_export proof_path = args.mobile_export

View file

@ -53,7 +53,7 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False): def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction.""" """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: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis") 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(): 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():

View file

@ -56,7 +56,7 @@ def load(path):
def apply_aliases(con, aliases): def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction.""" """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: if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v21 requis") fail("schema desktop v12-v21 requis")
for source, canonical in aliases: for source, canonical in aliases:

View file

@ -151,7 +151,7 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False): def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries.""" """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: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v25 requis") 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") 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")

View file

@ -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") 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 parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in ( 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") fail("schema desktop v17-v25 requis")
applied=equal=ancestor=pending=0;seen=set() applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]: for item in root["exercises"]:

View file

@ -846,7 +846,7 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table. # 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( raise ImportFailure(
f"base desktop schema v5 à v25 attendue, version trouvée: {version}" 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) require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] 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") raise ImportFailure("mobile V4 exige le schéma desktop v19 à v25")
if schema_version >= 20: if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute( protected = {(row[0], row[1]) for row in connection.execute(

View file

@ -52,7 +52,7 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False): def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0 roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON") 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: if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v25 requis") 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") 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")

View file

@ -39,6 +39,7 @@ RUNTIME_TOOLS = [
"export_ai_session_drafts.py", "export_ai_session_drafts.py",
"export_session_preparations.py", "export_session_preparations.py",
"export_programs.py", "export_programs.py",
"program_execution_exchange.py",
] ]
@ -97,8 +98,8 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(), ).strip(),
"product_version": "0.1.2", "product_version": "0.1.2",
"desktop_schema": 26, "desktop_schema": 27,
"android_schema": 24, "android_schema": 25,
"protocols": [ "protocols": [
"mobile-export-v3", "mobile-export-v3",
"mobile-history-v4", "mobile-history-v4",
@ -110,6 +111,7 @@ def main() -> int:
"session-preparations-v2", "session-preparations-v2",
"ai-session-drafts-v2", "ai-session-drafts-v2",
"programs-v1", "programs-v1",
"program-executions-v1",
], ],
"entry_points": [ "entry_points": [
"bin/trainlog", "bin/trainlog",

View file

@ -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()

View file

@ -28,6 +28,7 @@ import import_exercise_body_zones
import import_exercise_profile_state import import_exercise_profile_state
import import_mobile_export import import_mobile_export
import import_training_feedback import import_training_feedback
import program_execution_exchange
import export_equipment_definitions import export_equipment_definitions
import export_exercise_body_zones import export_exercise_body_zones
import export_pc_mobile import export_pc_mobile
@ -75,7 +76,9 @@ ARTIFACTS = (
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False, ("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
"export_programs.py", ()), "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): 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: 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: 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: 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) zones_payload = strict_json(required("body-zones").read_bytes(), MAX_ARTIFACT)
parsed_zones = import_exercise_body_zones.parse_payload(zones_payload) parsed_zones = import_exercise_body_zones.parse_payload(zones_payload)
feedback = import_training_feedback.load(required("feedback")) 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")) mobile_occurrences = import_equipment_associations.load_mobile_occurrences(required("history"))
with closing(connect_database(database)) as db: 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_exercise_body_zones.apply_body_zones(db, parsed_zones, {}, complete_causal_envelope=True)
import_training_feedback.apply_feedback(db, feedback, 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) 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) ack = record_consumed(db, manifest, manifest_digest)
db.commit() db.commit()
return ack return ack

View file

@ -17,7 +17,7 @@
#include "trainlog/model.h" #include "trainlog/model.h"
#include "trainlog/status.h" #include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 26 #define TRAINLOG_DATABASE_SCHEMA_VERSION 27
typedef struct TrainlogDatabase TrainlogDatabase; typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -1230,6 +1230,25 @@ static const char *const MIGRATE_V25_TO_V26_SQL =
"generation_id,deleted_at,request_id) WHERE acknowledged_at IS NULL;" "generation_id,deleted_at,request_id) WHERE acknowledged_at IS NULL;"
"PRAGMA user_version=26;COMMIT;"; "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 /* CONTRACT: migration fixtures may retain additive v25 columns while
* deliberately lowering user_version. Add each provenance column only when * deliberately lowering user_version. Add each provenance column only when
* absent; a genuine v24 database still receives both before opening returns. */ * 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 || } else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 || version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 || version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
version == 26) { version == 26 || version == 27) {
status = TRAINLOG_STATUS_OK; status = TRAINLOG_STATUS_OK;
} else { } else {
if (version == 1) { if (version == 1) {
@ -2155,6 +2174,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK && version < 26) { if (status == TRAINLOG_STATUS_OK && version < 26) {
status = execute_sql(database, MIGRATE_V25_TO_V26_SQL); 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) { if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database); 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) { if (status != TRAINLOG_STATUS_OK) {
set_open_diagnostic(output_diagnostic, set_open_diagnostic(output_diagnostic,
output_diagnostic_capacity, 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, database->connection,
SQLITE_ERROR); SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -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, "session_type", statement, 3) ||
!add_column_text(document, session, "planned_for", statement, 4) || !add_column_text(document, session, "planned_for", statement, 4) ||
!add_column_text(document, session, "note", statement, 5) || !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_obj_add_val(document, session, "occurrences", entries) ||
!yyjson_mut_arr_add_val(sessions, session)) { !yyjson_mut_arr_add_val(sessions, session)) {
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
@ -379,8 +381,15 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database,
yyjson_mut_doc *document, yyjson_mut_doc *document,
yyjson_mut_val *sessions) { yyjson_mut_val *sessions) {
static const char SESSION_SQL[] = static const char SESSION_SQL[] =
"SELECT program_session_id,position,title,session_type,planned_for,note FROM " "SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for,"
"program_sessions WHERE program_id=?1 ORDER BY position,program_session_id"; "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; sqlite3_stmt *session_statement = NULL;
int step; int step;

View file

@ -74,6 +74,21 @@ delete_program(TrainlogDatabase *database, const char *expected_revision, const
return 0; 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) { static int create_session_fixture(TrainlogDatabase *database) {
TrainlogSetInput set = {.reps = 9, .has_weight = true, .weight_kg = 32.5}; TrainlogSetInput set = {.reps = 9, .has_weight = true, .weight_kg = 32.5};
TrainlogSessionExerciseInput exercise; TrainlogSessionExerciseInput exercise;
@ -212,6 +227,8 @@ int main(int argc, char **argv) {
result = create_session_fixture(database); result = create_session_fixture(database);
} else if (argc == 3 && strcmp(argv[2], "program-create") == 0) { } else if (argc == 3 && strcmp(argv[2], "program-create") == 0) {
result = create_program(database); 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) { } else if (argc == 5 && strcmp(argv[2], "program-delete") == 0) {
result = delete_program(database, argv[3], argv[4]); result = delete_program(database, argv[3], argv[4]);
} else { } else {

View file

@ -286,6 +286,8 @@ static bool build_v25_fixture(const char *path) {
"PRAGMA foreign_keys=OFF;" "PRAGMA foreign_keys=OFF;"
"DROP INDEX program_deletions_pending;" "DROP INDEX program_deletions_pending;"
"DROP TABLE program_deletions;" "DROP TABLE program_deletions;"
"DROP INDEX program_session_executions_program;"
"DROP TABLE program_session_executions;"
"ALTER TABLE programs DROP COLUMN deleted_at;" "ALTER TABLE programs DROP COLUMN deleted_at;"
"PRAGMA user_version=25;" "PRAGMA user_version=25;"
"PRAGMA foreign_keys=ON;") || "PRAGMA foreign_keys=ON;") ||
@ -388,7 +390,8 @@ static int run_test(void) {
static const ProjectionSpec UNCHANGED_SCHEMA[] = { static const ProjectionSpec UNCHANGED_SCHEMA[] = {
{"schema", {"schema",
"SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' " "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"}, "ORDER BY type,name"},
}; };
char path[] = "/tmp/trainlog-v26-populated-XXXXXX"; 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)) == CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_OK); TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 26); CHECK(version == 27);
trainlog_database_close(database); trainlog_database_close(database);
database = NULL; database = NULL;
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK); 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, CHECK(capture_projection(raw,
BUSINESS_PROJECTIONS, BUSINESS_PROJECTIONS,
sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]), 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 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 programs WHERE deleted_at IS NOT NULL") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM program_deletions") == 0); CHECK(scalar(raw, "SELECT COUNT(*) FROM program_deletions") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM program_session_executions") == 0);
CHECK(scalar(raw, CHECK(scalar(raw,
"SELECT COUNT(*) FROM pragma_table_info('programs') WHERE name='deleted_at' AND " "SELECT COUNT(*) FROM pragma_table_info('programs') WHERE name='deleted_at' AND "
"type='TEXT' AND \"notnull\"=0") == 1); "type='TEXT' AND \"notnull\"=0") == 1);
@ -458,7 +462,7 @@ static int run_test(void) {
"name='program_deletions_pending')") == 2); "name='program_deletions_pending')") == 2);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('programs')") == 13); CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('programs')") == 13);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'") == 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; ok = true;
cleanup: cleanup:
if (!ok && diagnostic[0] != '\0') { if (!ok && diagnostic[0] != '\0') {

View file

@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
TRAINLOG_STATUS_DATABASE_ERROR); TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strncmp(diagnostic, CHECK(strncmp(diagnostic,
"migrate database to schema v26: SQLite rc=", "migrate database to schema v27: SQLite rc=",
strlen("migrate database to schema v26: SQLite rc=")) == 0); strlen("migrate database to schema v27: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL);
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
CHECK(fd >= 0); CHECK(fd >= 0);
CHECK(close(fd) == 0); CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=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(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL); 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(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0); CHECK(unlink(path) == 0);
return true; return true;

View file

@ -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)) == CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_DATABASE_ERROR); TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); 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(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);

View file

@ -193,7 +193,7 @@ static bool import_archive_and_prepare(void) {
char *second_entry; char *second_entry;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); 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, CHECK(scalar(database,
"SELECT COUNT(*) FROM pragma_table_info('program_deletions') WHERE " "SELECT COUNT(*) FROM pragma_table_info('program_deletions') WHERE "
"name IN('generation_id','acknowledged_at')") == 2); "name IN('generation_id','acknowledged_at')") == 2);
@ -268,6 +268,7 @@ static bool import_archive_and_prepare(void) {
TRAINLOG_STATUS_OK); TRAINLOG_STATUS_OK);
CHECK(extract_string(response, "revision_id", revision, sizeof(revision))); CHECK(extract_string(response, "revision_id", revision, sizeof(revision)));
CHECK(strstr(response, "Exercise fixture") == NULL); CHECK(strstr(response, "Exercise fixture") == NULL);
CHECK(strstr(response, "\"execution_state\":\"todo\"") != NULL);
free(response); free(response);
response = NULL; response = NULL;
@ -283,6 +284,12 @@ static bool import_archive_and_prepare(void) {
"WHERE source_program_id='pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'") == 1); "WHERE source_program_id='pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'") == 1);
free(response); free(response);
response = NULL; 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, CHECK(trainlog_web_programs_archive_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",

View file

@ -39,6 +39,8 @@ export interface ProgramSession {
session_type: 'training' | 'max_test' session_type: 'training' | 'max_test'
planned_for: string | null planned_for: string | null
note: string | null note: string | null
execution_state: 'todo' | 'prepared' | 'in_progress' | 'completed'
execution_session_id: string | null
occurrences: ProgramOccurrence[] occurrences: ProgramOccurrence[]
} }

View file

@ -165,6 +165,8 @@ describe('Programs tab', () => {
session_type: 'training', session_type: 'training',
planned_for: null, planned_for: null,
note: null, note: null,
execution_state: 'todo',
execution_session_id: null,
occurrences: [{ occurrences: [{
entry_id: 'pge_test', entry_id: 'pge_test',
position: 0, position: 0,

View file

@ -63,6 +63,12 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
setError(reason instanceof Error ? reason.message : 'Création impossible') 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 <article className="session-detail program-detail"> return <article className="session-detail program-detail">
<div className="sessions-toolbar"> <div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}> <button type="button" className="quiet-action" onClick={onBack}>
@ -97,6 +103,9 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
{program.sessions.map((session) => <li key={session.program_session_id}> {program.sessions.map((session) => <li key={session.program_session_id}>
<div> <div>
<strong>{session.position + 1}. {session.title}</strong> <strong>{session.position + 1}. {session.title}</strong>
<span className={`program-execution-state state-${session.execution_state}`}>
{executionLabel(session.execution_state)}
</span>
<p> <p>
{session.session_type === 'max_test' {session.session_type === 'max_test'
? 'Test MAX planifié' : 'Entraînement planifié'} ? 'Test MAX planifié' : 'Entraînement planifié'}
@ -118,11 +127,14 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
</li>)} </li>)}
</ol> </ol>
</div> </div>
<button {session.execution_state === 'todo' && <button
type="button" type="button"
className="primary-action" className="primary-action"
onClick={() => void prepare(session.program_session_id)} onClick={() => void prepare(session.program_session_id)}
>Créer une préparation</button> >Créer une préparation</button>}
{session.execution_state === 'prepared' && <span>Préparation disponible</span>}
{session.execution_state === 'in_progress' && <span>Séance en cours sur Android</span>}
{session.execution_state === 'completed' && <span>✓ Séance effectuée</span>}
</li>)} </li>)}
</ol> </ol>
</article> </article>

View file

@ -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 { 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 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-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); } .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; } .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) { @media (max-width: 600px) {