fix(program): add durable ordered reschedule candidate

This commit is contained in:
fy59 2026-10-01 07:00:14 +02:00
parent c17394ece2
commit 6c0e7af2f0
80 changed files with 2032 additions and 132 deletions

View file

@ -2,6 +2,12 @@
## Unreleased — 0.1.7
The isolated `trainlog-program-recalage-v1` candidate adds an explicit
preview-and-confirm cascade for unfinished Program sessions. It retains source
and actual dates, stores absolute current dates and ceded-slot state, and
selects a separate Programs V2 Android projection by capability. This candidate
is not merged into the stable installation or deployed to Android.
Development opened after the stable v0.1.6 release. This line is restricted to
observed bug fixes, regression fixes, directly related small UI corrections,
documentation corrections, robustness, and associated tests. It does not admit

View file

@ -225,7 +225,7 @@ internal class AndroidBackupService(
manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..33
manifest.getInt("schema") in 17..34
) {
"Unsupported backup."
}
@ -319,7 +319,7 @@ internal class AndroidBackupService(
}
)
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
require(db.version in 17..33) { "Unsupported Android schema ${db.version}." }
require(db.version in 17..34) { "Unsupported Android schema ${db.version}." }
return db.version
} finally {
db.close()

View file

@ -370,6 +370,7 @@ internal class SyncGenerationCoordinator(
ACK_RECOVERY_CAPABILITY,
"session-preparations-v2",
"programs-v1",
"programs-v2",
)
),
)

View file

@ -86,7 +86,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
(ANDROID_KINDS.values +
Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false) +
Kind("trainlog-programs", 1, "programs-v1.json", false))
Kind("trainlog-programs", 1, "programs-v1.json", false) +
Kind("trainlog-programs", 2, "programs-v2.json", false))
.map { it.format to it.version }
.toSet()
@ -773,6 +774,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
throw SyncGenerationException("artifact digest mismatch")
artifacts[item.getString("logical_name")] = bytes.toString(StandardCharsets.UTF_8)
}
if ("programs-v1" in artifacts && "programs-v2" in artifacts)
throw SyncGenerationException("multiple Program revisions in one generation")
return ValidatedGeneration(manifest, sha256(raw), artifacts)
}
@ -947,6 +950,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
apply("programs-v1") {
repository.applyProgramsV1Json(checkNotNull(a["programs-v1"]))
}
if ("programs-v2" in a)
apply("programs-v2") {
repository.applyProgramsV2Json(checkNotNull(a["programs-v2"]))
}
apply("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) }
apply("exercise-profile-state") {
repository.applyExerciseProfileStateJson(

View file

@ -11825,7 +11825,11 @@ class TrainlogRepository(
* explicit tombstones are applied before live rows. INVARIANT: a durable tombstone always
* wins over a later stale snapshot and never touches drafts, preparations, history or catalog.
*/
fun applyProgramsV1Json(json: String): ProgramsImportResult {
fun applyProgramsV1Json(json: String): ProgramsImportResult = applyProgramsJson(json, 1)
fun applyProgramsV2Json(json: String): ProgramsImportResult = applyProgramsJson(json, 2)
private fun applyProgramsJson(json: String, documentVersion: Int): ProgramsImportResult {
if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 ||
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
val root = try { JSONObject(json) } catch (_: Exception) {
@ -11853,7 +11857,7 @@ class TrainlogRepository(
try {
if (!root.exact("format", "version", "generated_at", "programs", "deletions") ||
root.optString("format") != "trainlog-programs" || root.opt("version") !is Int ||
root.getInt("version") != 1 || !timestamp(root.opt("generated_at")) ||
root.getInt("version") != documentVersion || !timestamp(root.opt("generated_at")) ||
root.opt("programs") !is JSONArray || root.opt("deletions") !is JSONArray)
throw ProgramsImportInvalid("Unsupported Programs document.")
val programs = root.getJSONArray("programs")
@ -11880,11 +11884,16 @@ class TrainlogRepository(
for (programIndex in 0 until programs.length()) {
val program = programs.optJSONObject(programIndex)
?: throw ProgramsImportInvalid("Invalid program.")
if (!program.exact(
val programKeysValid = if (documentVersion == 2) program.exact(
"program_id", "revision_id", "revision_sequence", "title", "note", "state", "start_date",
"end_date", "created_at", "updated_at", "source_format", "source_version",
"source_payload_sha256", "sessions",
) else program.exact(
"program_id", "revision_id", "title", "note", "state", "start_date",
"end_date", "created_at", "updated_at", "source_format", "source_version",
"source_payload_sha256", "sessions",
) ||
)
if (!programKeysValid ||
!bounded(program.opt("program_id"), 128) || !programIds.add(program.getString("program_id")) ||
!bounded(program.opt("revision_id"), 128) || !bounded(program.opt("title"), 200) ||
!nullableBounded(program.opt("note"), 4000) || program.optString("state") !in setOf("active", "archived") ||
@ -11894,17 +11903,33 @@ class TrainlogRepository(
(program.opt("source_payload_sha256") as? String)?.matches(Regex("^[0-9a-f]{64}$")) != true ||
program.opt("sessions") !is JSONArray)
throw ProgramsImportInvalid("Invalid program metadata.")
if (documentVersion == 2 && !integer(program.opt("revision_sequence"), 0, Int.MAX_VALUE))
throw ProgramsImportInvalid("Invalid Program revision sequence.")
val sessions = program.getJSONArray("sessions")
if (sessions.length() !in 1..64) throw ProgramsImportInvalid("Invalid program session count.")
for (sessionIndex in 0 until sessions.length()) {
val session = sessions.optJSONObject(sessionIndex)
?: throw ProgramsImportInvalid("Invalid program session.")
if (!session.exact("program_session_id", "title", "session_type", "planned_for", "note", "occurrences") ||
val validKeys = if (documentVersion == 2) session.exact(
"program_session_id", "position", "title", "session_type", "planned_for",
"current_for", "planning_state", "note", "occurrences",
) else session.exact(
"program_session_id", "title", "session_type", "planned_for", "note", "occurrences",
)
if (!validKeys ||
!bounded(session.opt("program_session_id"), 128) || !sessionIds.add(session.getString("program_session_id")) ||
!bounded(session.opt("title"), 200) || session.optString("session_type") !in setOf("training", "max_test") ||
!date(session.opt("planned_for")) || !nullableBounded(session.opt("note"), 4000) ||
session.opt("occurrences") !is JSONArray)
throw ProgramsImportInvalid("Invalid program session metadata.")
if (documentVersion == 2 &&
(!integer(session.opt("position"), 0, 255) || session.getInt("position") != sessionIndex ||
!date(session.opt("current_for")) ||
session.optString("planning_state") !in setOf("active", "ceded") ||
(session.optString("planning_state") == "ceded" && !session.isNull("current_for")) ||
(session.optString("planning_state") == "active" &&
session.isNull("current_for") && !session.isNull("planned_for"))))
throw ProgramsImportInvalid("Invalid Program planning state.")
val occurrences = session.getJSONArray("occurrences")
if (occurrences.length() !in 1..64) throw ProgramsImportInvalid("Invalid program occurrence count.")
val entryIds = mutableSetOf<String>()
@ -11930,6 +11955,12 @@ class TrainlogRepository(
}
}
data class ExistingProjection(
val revisionId: String,
val digest: String,
val version: Int,
val sequence: Long,
)
val db = database.writableDatabase
db.beginTransaction()
try {
@ -11962,28 +11993,86 @@ class TrainlogRepository(
.use { it.moveToFirst() }
if (tombstoned) { skipped++; continue }
val digest = causalDigest(program)
val existing = db.rawQuery("SELECT revision_id,snapshot_sha256 FROM synced_programs WHERE program_id=?", arrayOf(id))
.use { cursor -> if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1) }
if (existing?.first == program.getString("revision_id")) {
if (existing.second != digest) throw ProgramsImportInvalid("Program revision identity conflict.")
skipped++
continue
val existing = db.rawQuery(
"SELECT revision_id,snapshot_sha256,projection_version,revision_sequence " +
"FROM synced_programs WHERE program_id=?",
arrayOf(id),
).use { cursor ->
if (!cursor.moveToFirst()) null else ExistingProjection(
cursor.getString(0), cursor.getString(1), cursor.getInt(2), cursor.getLong(3),
)
}
val sequence = if (documentVersion == 2) program.getLong("revision_sequence") else 0L
// WHY: a later V1 artifact cannot erase current dates or a
// ceded slot after this phone has accepted Programs V2.
if (existing != null && documentVersion < existing.version)
throw ProgramsImportInvalid("Programs V2 capability required.")
if (existing != null && documentVersion == 2 &&
(sequence < existing.sequence ||
(sequence == existing.sequence && existing.version == 2 &&
existing.revisionId != program.getString("revision_id"))))
throw ProgramsImportInvalid("Stale Program revision sequence.")
if (existing?.revisionId == program.getString("revision_id")) {
if (existing.digest == digest) { skipped++; continue }
if (documentVersion != 2 || existing.version != 1 ||
existing.digest != causalDigest(JSONObject(program.toString()).apply {
remove("revision_sequence")
getJSONArray("sessions").let { rows ->
for (sessionIndex in 0 until rows.length()) {
rows.getJSONObject(sessionIndex).apply {
remove("position")
remove("current_for")
remove("planning_state")
}
}
}
})) throw ProgramsImportInvalid("Program revision identity conflict.")
}
if (documentVersion == 2) {
val activeProgramSession = db.rawQuery(
"SELECT source_program_session_id FROM active_session_draft " +
"WHERE id=1 AND source_program_id=?",
arrayOf(id),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
if (activeProgramSession != null) {
val newSessions = program.getJSONArray("sessions")
for (sessionIndex in 0 until newSessions.length()) {
val candidate = newSessions.getJSONObject(sessionIndex)
if (candidate.getString("program_session_id") == activeProgramSession) {
val existingPlan = db.rawQuery(
"SELECT current_for,planning_state FROM synced_program_sessions " +
"WHERE program_session_id=?",
arrayOf(activeProgramSession),
).use { cursor ->
if (!cursor.moveToFirst()) null else
(if (cursor.isNull(0)) null else cursor.getString(0)) to cursor.getString(1)
}
if (existingPlan != null &&
(existingPlan.first != candidate.optStringOrNull("current_for") ||
existingPlan.second != candidate.getString("planning_state")))
throw ProgramsImportInvalid("Active Program draft blocks reschedule.")
}
}
}
}
db.delete("synced_programs", "program_id=?", arrayOf(id))
db.execSQL(
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
arrayOf<Any?>(id, program.getString("revision_id"), program.getString("title"), program.optStringOrNull("note"),
program.getString("state"), program.optStringOrNull("start_date"), program.optStringOrNull("end_date"),
program.getString("created_at"), program.getString("updated_at"), program.getString("source_format"),
program.getInt("source_version"), program.getString("source_payload_sha256"), digest),
program.getInt("source_version"), program.getString("source_payload_sha256"), digest,
documentVersion, sequence),
)
val sessions = program.getJSONArray("sessions")
for (sessionIndex in 0 until sessions.length()) {
val session = sessions.getJSONObject(sessionIndex)
val sessionId = session.getString("program_session_id")
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?)", arrayOf<Any?>(
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?,?,?)", arrayOf<Any?>(
sessionId, id, sessionIndex, session.getString("title"), session.getString("session_type"),
session.optStringOrNull("planned_for"), session.optStringOrNull("note")))
session.optStringOrNull("planned_for"), session.optStringOrNull("note"),
if (documentVersion == 2) session.optStringOrNull("current_for") else session.optStringOrNull("planned_for"),
if (documentVersion == 2) session.getString("planning_state") else "active"))
val entries = session.getJSONArray("occurrences")
for (entryIndex in 0 until entries.length()) {
val entry = entries.getJSONObject(entryIndex)
@ -12052,7 +12141,7 @@ class TrainlogRepository(
} ?: return null
val sessions = mutableListOf<SyncedProgramSession>()
db.rawQuery(
"SELECT program_session_id,title,session_type,planned_for,note " +
"SELECT program_session_id,title,session_type,planned_for,note,current_for,planning_state " +
"FROM synced_program_sessions WHERE program_id=? " +
"ORDER BY position,program_session_id",
arrayOf(programId),
@ -12095,6 +12184,8 @@ class TrainlogRepository(
sessionCursor.getString(1),
sessionCursor.getString(2),
if (sessionCursor.isNull(3)) null else sessionCursor.getString(3),
if (sessionCursor.isNull(5)) null else sessionCursor.getString(5),
sessionCursor.getString(6),
if (sessionCursor.isNull(4)) null else sessionCursor.getString(4),
execution.first,
execution.second,
@ -12160,7 +12251,8 @@ class TrainlogRepository(
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'",
"WHERE p.program_id=? AND s.program_session_id=? AND p.state='active' " +
"AND s.planning_state='active'",
arrayOf(programId, programSessionId),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
?: return StartProgramSessionResult.NotAvailable
@ -12454,7 +12546,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
33,
34,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -12808,6 +12900,34 @@ private class TrainlogDatabaseHelper(
createSleepImmutabilityTriggers(db)
version = 33
}
if (version < 34 && newVersion >= 34) {
/* WHY: V1 retains the source date; V2 needs an independent current
* target and a durable ceded-slot state. INVARIANT: no local draft
* or completed workout is rewritten by the projection migration. */
if (!tableHasColumn(db, "synced_programs", "projection_version")) {
db.execSQL(
"ALTER TABLE synced_programs ADD COLUMN projection_version INTEGER NOT NULL " +
"DEFAULT 1 CHECK(projection_version IN(1,2))",
)
}
if (!tableHasColumn(db, "synced_programs", "revision_sequence")) {
db.execSQL(
"ALTER TABLE synced_programs ADD COLUMN revision_sequence INTEGER NOT NULL " +
"DEFAULT 0 CHECK(revision_sequence>=0)",
)
}
if (!tableHasColumn(db, "synced_program_sessions", "current_for")) {
db.execSQL("ALTER TABLE synced_program_sessions ADD COLUMN current_for TEXT")
db.execSQL("UPDATE synced_program_sessions SET current_for=planned_for")
}
if (!tableHasColumn(db, "synced_program_sessions", "planning_state")) {
db.execSQL(
"ALTER TABLE synced_program_sessions ADD COLUMN planning_state TEXT NOT NULL " +
"DEFAULT 'active' CHECK(planning_state IN('active','ceded'))",
)
}
version = 34
}
if (version != newVersion) {
error(
@ -12824,7 +12944,9 @@ private class TrainlogDatabaseHelper(
note TEXT, state TEXT NOT NULL CHECK(state IN('active','archived')),
start_date TEXT, end_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
source_format TEXT NOT NULL, source_version INTEGER NOT NULL,
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL,
projection_version INTEGER NOT NULL DEFAULT 1 CHECK(projection_version IN(1,2)),
revision_sequence INTEGER NOT NULL DEFAULT 0 CHECK(revision_sequence>=0)
)""".trimIndent(),
)
db.execSQL(
@ -12833,7 +12955,9 @@ private class TrainlogDatabaseHelper(
program_id TEXT NOT NULL REFERENCES synced_programs(program_id) ON DELETE CASCADE,
position INTEGER NOT NULL CHECK(position>=0), title TEXT NOT NULL,
session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),
planned_for TEXT, note TEXT, UNIQUE(program_id,position)
planned_for TEXT, note TEXT, current_for TEXT,
planning_state TEXT NOT NULL DEFAULT 'active' CHECK(planning_state IN('active','ceded')),
UNIQUE(program_id,position)
)""".trimIndent(),
)
db.execSQL(

View file

@ -25,6 +25,8 @@ data class SyncedProgramSession(
val title: String,
val sessionType: String,
val plannedFor: String?,
val currentFor: String?,
val planningState: String,
val note: String?,
val executionState: ProgramSessionExecutionState,
val executionSessionId: String?,

View file

@ -118,7 +118,12 @@ fun ProgramDetailScreen(
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
program.note?.let { TrainlogInfo(it) }
val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED }
TrainlogInfo("$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted)
val ceded = program.sessions.count { it.planningState == "ceded" }
TrainlogInfo(
"$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}" +
if (ceded > 0) " · $ceded créneau(x) cédé(s)" else "",
if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted,
)
}
message?.let { TrainlogInfo(it) }
program.sessions.forEachIndexed { index, session ->
@ -145,12 +150,16 @@ fun ProgramDetailScreen(
color = LocalTrainlogColors.current.text,
fontWeight = FontWeight.Bold,
)
ProgramStateBadge(strings.getString(stateLabel), completed)
ProgramStateBadge(
if (session.planningState == "ceded" && !completed) "Créneau cédé"
else strings.getString(stateLabel),
completed,
)
}
TrainlogInfo(
listOfNotNull(
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
session.currentFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size),
).joinToString(" · "),
)
@ -164,7 +173,7 @@ fun ProgramDetailScreen(
Modifier.testTag("program-session-toggle-${session.programSessionId}"),
)
when (session.executionState) {
ProgramSessionExecutionState.TODO -> TrainlogButton(
ProgramSessionExecutionState.TODO -> if (session.planningState == "active") TrainlogButton(
strings.getString(R.string.program_session_start),
{ start(session.programSessionId) },
Modifier.testTag("program-session-start-${session.programSessionId}"),

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(33, scalar(db, "PRAGMA user_version"))
assertEquals(34, scalar(db, "PRAGMA user_version"))
for (table in listOf("session_exercises", "draft_session_exercises")) {
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))

View file

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

View file

@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(33, scalar(db, "PRAGMA user_version"))
assertEquals(34, scalar(db, "PRAGMA user_version"))
assertEquals(before, listOf(
scalar(db, "SELECT COUNT(*) FROM sessions"),
scalar(db, "SELECT COUNT(*) FROM performed_sets"),

View file

@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
reopened.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(33, scalar(db, "PRAGMA user_version"))
assertEquals(34, 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 ->

View file

@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(33, it.version) }
).use { assertEquals(34, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(33, it.version) }
).use { assertEquals(34, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest {
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
assertEquals(34, db.version)
db.rawQuery(
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
null,

View file

@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest {
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
assertEquals(34, db.version)
db.rawQuery(
"SELECT session_type,session_kind FROM sessions",
null,

View file

@ -41,7 +41,7 @@ class AndroidV30CardioCalibrationMigrationTest {
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
assertEquals(34, db.version)
db.rawQuery(
"SELECT COUNT(*) FROM exercises WHERE " +
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +

View file

@ -42,7 +42,7 @@ class AndroidV31CardioGuidanceMigrationTest {
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
assertEquals(34, db.version)
db.rawQuery(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
"'cardio_guidance_runs','cardio_guidance_phases'," +

View file

@ -59,7 +59,7 @@ class AndroidV32SleepQuantityMigrationTest {
migrated.listSleepDiary()
migrated.close()
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(33, scalar(db, "PRAGMA user_version"))
assertEquals(34, scalar(db, "PRAGMA user_version"))
assertEquals(
1,
scalar(

View file

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

View file

@ -70,6 +70,67 @@ class ProgramsProjectionRepositoryTest {
.put("deletions", deletions)
.toString()
private fun artifactV2(
program: JSONObject,
sequence: Int = 0,
currentFor: String = "2026-10-01",
): String {
program.put("revision_sequence", sequence)
val session = program.getJSONArray("sessions").getJSONObject(0)
session.put("position", 0)
session.put("current_for", currentFor)
session.put("planning_state", "active")
return JSONObject()
.put("format", "trainlog-programs")
.put("version", 2)
.put("generated_at", "2026-09-18T12:00:00Z")
.put("programs", JSONArray().put(program))
.put("deletions", JSONArray())
.toString()
}
@Test
fun v2UpgradePreservesCurrentDateAndRejectsLegacyDowngrade() {
val name = "program-v2-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
)
val upgraded = program()
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV2Json(artifactV2(upgraded)),
)
assertEquals("2026-10-01", repository.getSyncedProgram(upgraded.getString("program_id"))!!
.sessions.single().currentFor)
val stale = artifactV2(program())
val newer = program().put("revision_id", "pgr_99999999-9999-4999-8999-999999999999")
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV2Json(artifactV2(newer, 1, "2026-10-02")),
)
assertTrue(repository.applyProgramsV2Json(stale) is ProgramsImportResult.Invalid)
assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!!
.sessions.single().currentFor)
assertTrue(repository.applyProgramsV1Json(artifact(JSONArray().put(program())))
is ProgramsImportResult.Invalid)
assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!!
.sessions.single().currentFor)
} finally {
repository.close()
}
repository = TrainlogRepository(context, name)
try {
assertEquals("2026-10-02", repository.getSyncedProgram(program().getString("program_id"))!!
.sessions.single().currentFor)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
private fun preparationArtifact(
includeProgramProvenance: Boolean = true,
deliveryState: String = "pending",
@ -211,6 +272,12 @@ class ProgramsProjectionRepositoryTest {
val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(programId, active.sourceProgramId)
assertEquals(programSessionId, active.sourceProgramSessionId)
val changedPlan = program().apply {
put("revision_id", "pgr_99999999-9999-4999-8999-999999999999")
}
assertTrue(repository.applyProgramsV2Json(artifactV2(changedPlan))
is ProgramsImportResult.Invalid)
assertEquals(null, repository.getSyncedProgram(programId)!!.sessions.single().currentFor)
val performed = active.copy(
exercises = active.exercises.map { occurrence ->
occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)))
@ -218,6 +285,12 @@ class ProgramsProjectionRepositoryTest {
)
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV2Json(artifactV2(changedPlan)),
)
assertEquals("2026-10-01", repository.getSyncedProgram(programId)!!
.sessions.single().currentFor)
assertEquals(
ProgramSessionExecutionState.COMPLETED,
repository.getSyncedProgram(programId)!!.sessions.single().executionState,
@ -238,6 +311,8 @@ class ProgramsProjectionRepositoryTest {
try {
val detail = repository.listSyncedPrograms().single()
assertEquals(1, detail.sessionCount)
assertEquals("2026-10-01", repository.getSyncedProgram(detail.programId)!!
.sessions.single().currentFor)
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
assertEquals(1, repository.listSessions().size)
} finally {

View file

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

View file

@ -245,7 +245,7 @@ class SleepDiaryRepositoryTest {
SQLiteDatabase.OPEN_READWRITE,
)
try {
assertEquals(33, raw.version)
assertEquals(34, raw.version)
assertThrows(SQLiteException::class.java) {
raw.execSQL(
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",

View file

@ -878,7 +878,7 @@ class SyncGenerationServiceTest {
.use { db ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(33, it.getInt(0))
assertEquals(34, it.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())

View file

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

View file

@ -1,5 +1,20 @@
# Android application
## Candidate schema v34: read-only Programs V2 projection
The isolated Program rescheduling candidate additively stores a projection
version and revision sequence on synchronized Programs, plus `current_for` and
`planning_state` on synchronized Program sessions. The original `planned_for`
and all local completed sessions and drafts remain separate. Existing V1 rows
hydrate as active with their original date. Android still has no Program editor.
Programs V2 replaces one Program snapshot transactionally. A lower revision
sequence or a later Programs V1 snapshot cannot overwrite accepted V2 planning.
If the singleton active draft refers to a session whose current date or ceded
state changes, V2 import rejects the generation; it does not alter the draft.
The Program screen uses current dates for unfinished sessions and does not offer
Start on a ceded slot. This candidate is not installed on the personal phone.
## Sleep Diary V1 and active V2 companion
Android schema v26 additively mirrors the Sleep Diary revision/event model for

View file

@ -1,5 +1,16 @@
# Current implementation state
## Isolated Program rescheduling candidate
The `trainlog-program-recalage-v1` worktree contains a local candidate for
explicit ordered Program rescheduling, desktop schema v37, Android schema v34,
and the capability-selected `trainlog-programs` V2 projection. The stable
0.1.7 installation and personal Program have not been migrated or rescheduled.
The candidate passed local native, Web, Android JVM, build, and targeted
sanitizer validation. It still requires coordinated PC/Android deployment
before it is an operational feature. Completed-session calendar
dates in stable 0.1.7 continue to come from their linked factual sessions.
Full-generation synchronization uses one generation/ACK business engine across
versioned transports. On the paired 0.1.4 deployment, automatic local selection
is Bluetooth Classic RFCOMM first and direct USB/MTP second. The desktop BlueZ

View file

@ -1,5 +1,21 @@
# Desktop database
## Candidate schema v37: Program planning
Schema v37 is additive to the isolated Program rescheduling candidate. It
creates `program_session_planning` for absolute current dates and ceded-slot
state, `program_reschedule_operations` for exact request replay, and
`program_revision_sequences` for monotonically ordered Program snapshots.
A trigger increments the per-Program sequence whenever its revision changes,
including archive, reschedule and logical deletion. No date uniqueness or
historical session mutation is introduced. Missing planning rows mean the
original `planned_for` date and active planning state. The source format and
published Programs V1 artifact remain strict and unchanged.
This schema is present only in the candidate worktree/build. The stable
installation's database remains at its existing schema until a separately
authorized coordinated deployment and backup.
## Causal heart-rate tail correction (schema v36, no migration)
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing

View file

@ -68,3 +68,32 @@ source definition and derived preparations. A deleted program cannot be
resurrected or prepared; an archived program cannot be prepared. The optional
`trainlog-programs` V1 synchronization companion is a separate PC-to-Android
projection and does not alter this import format.
## Local candidate: explicit ordered rescheduling
The isolated `trainlog-program-recalage-v1` candidate adds a separate planning
state. `planned_for` remains the imported date. `current_for` is an absolute
current target for an active, unfinished session; `ceded` releases one selected
future slot without completing or deleting its session identity. Completed
cards still use the linked session's actual start timestamp. Positions from the
imported Program determine the cascade order; titles never determine priority.
The Web action selects a first and last unfinished session, a resumption date,
and the exact session identities whose slots are ceded. Its available days are
the dated original Program slots in that interval on or after resumption.
The preview lists original, prior current and proposed dates. It rejects a
missing slot, a date beyond the Program end, a completed/in-progress/prepared
session, or a stale revision. It does not invent rest-day or post-end slots.
An explicitly ceded session keeps its identity and remains visible as ceded.
Unchanged sessions in the selected range are displayed in the preview but need
no planning row. Other sessions and Programs are not moved. Multiple actual or
independent sessions may still share one civil day.
Confirmation binds the preview fingerprint to the current Program projection
and writes absolute dates, ceded states, the Program revision and a durable
operation response in one transaction. Exact operation replay returns that
response; changed content under the same operation ID conflicts. A failed
write rolls back every row. The candidate requires a recent acknowledged
Programs V2 generation for every previously contacted Android peer. A newly
started offline Android Program draft remains protected by Android's V2 import
rejection; the sync run then reports a conflict for explicit resolution.

View file

@ -1,5 +1,12 @@
# Roadmap
The isolated Program cascade candidate is implemented in the
`trainlog-program-recalage-v1` worktree and remains outside the stable 0.1.7
installation. Its next gate is a coordinated PC/Android deployment review,
followed by a newly calculated personal Program preview and separate user
confirmation. No personal reschedule or publication is implied by candidate
tests.
The controlled private rollout is complete for the paired daily installation:
physical libmtp, verified backups, signing continuity, migrations, correlated
ACKs, restart, and idempotent replay passed. Generation mode remains an

View file

@ -277,6 +277,7 @@ does not publish to it or let it override an artifact in the new endpoint.
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals |
| PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones |
| PC -> Android | `programs-v2.json` | candidate `trainlog-programs` v2 companion: original and current dates, ceded state, ordered revision sequence, and the same 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 -> full-generation daemon | `trainlog-sync-full-generation-request-v1.json` | `trainlog-sync-request` v1 |
@ -299,6 +300,33 @@ V3, `trainlog-pc-catalog` V1, and `trainlog-session-preparations` V2. It does
not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation
semantics.
### Isolated candidate: Programs V2 capability matrix
`trainlog-programs` V2 is a separate strict artifact selected only for a peer
advertising `programs-v2`. Its session adds `position`, nullable `current_for`
and `planning_state=active|ceded`; its Program adds `revision_sequence`.
`planned_for`, stable IDs, source entries and tombstones retain their V1
meaning. One generation contains exactly one Programs version. Android v34
stores the V2 projection transactionally and rejects a lower sequence, a
same-sequence different revision, a later V1 downgrade, or a changed plan for
its currently active Program draft. Execution facts return through the
unchanged Program-executions V1 companion and retain their actual timestamps.
| Desktop state | Android capability | Program result | Other domains |
|---|---|---|---|
| No reschedule | `programs-v1` only | V1 original plan | Normal generation |
| No reschedule | `programs-v2` | V2 projection; V1 is omitted | Normal generation |
| Rescheduled live Program | `programs-v2` | V2 absolute current plan | Normal generation |
| Rescheduled live Program | V1 only, absent or stale V2 | Program artifact omitted; run reports incomplete Program convergence after ACK | Other artifacts still synchronize |
| Program draft started offline after the last V2 ACK | `programs-v2` | Android rejects a changed plan without rewriting its draft; explicit conflict resolution is required | Generation rejection is durable |
The desktop commit requires that the latest generation to each previously
contacted Android peer was an acknowledged V2 Program generation no older than
five minutes. This bounds, but cannot eliminate, a phone-side race: Android's
active-draft check is the final guard. A failed or downgraded peer cannot be
reported as current for the Program domain. The existing generation, request,
ACK, tombstone, transport, Sleep and HR wire contracts are unchanged.
## Optional Program executions companion V1
`trainlog-program-executions` V1 is an Android-to-desktop full-generation

View file

@ -11,6 +11,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import export_programs
import export_programs_v2
SCHEMA = """
@ -79,6 +80,41 @@ class ProgramsExchangeTest(unittest.TestCase):
"requested_at": "2026-09-18T12:00:00Z",
}], payload["deletions"])
def test_v2_carries_current_date_and_ceded_state_without_changing_v1(self):
with closing(sqlite3.connect(self.database)) as db:
db.executescript("""
CREATE TABLE program_session_planning(
program_session_id TEXT PRIMARY KEY,current_for TEXT,
state TEXT,operation_id TEXT,updated_at TEXT);
CREATE TABLE program_revision_sequences(
program_id TEXT PRIMARY KEY,revision_sequence INTEGER NOT NULL);
INSERT INTO program_revision_sequences VALUES('pg_live',1);
INSERT INTO program_session_planning VALUES(
'pgs_live','2026-10-01','active','op_fixture','2026-09-30T12:00:00Z');
PRAGMA user_version=37;
""")
db.commit()
initial = export_programs.export_programs(self.database)
current = export_programs_v2.export_programs_v2(self.database)
self.assertEqual(1, initial["version"])
self.assertEqual(2, current["version"])
original = initial["programs"][0]["sessions"][0]
planned = current["programs"][0]["sessions"][0]
self.assertIsNone(original["planned_for"])
self.assertEqual(set(original), {
"program_session_id", "title", "session_type", "planned_for", "note", "occurrences"
})
self.assertEqual("2026-10-01", planned["current_for"])
self.assertEqual(1, current["programs"][0]["revision_sequence"])
self.assertEqual("active", planned["planning_state"])
self.assertEqual(0, planned["position"])
with closing(sqlite3.connect(self.database)) as db:
db.execute("UPDATE program_session_planning SET current_for=NULL,state='ceded'")
db.commit()
ceded = export_programs_v2.export_programs_v2(self.database)
self.assertIsNone(ceded["programs"][0]["sessions"][0]["current_for"])
self.assertEqual("ceded", ceded["programs"][0]["sessions"][0]["planning_state"])
if __name__ == "__main__":
unittest.main()

View file

@ -77,7 +77,7 @@ class DeploymentToolsTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.7")
self.assertEqual(inventory["desktop_schema"], 36)
self.assertEqual(inventory["desktop_schema"], 37)
self.assertEqual(inventory["android_schema"], 32)
self.assertIn("session-preparations-v2", inventory["protocols"])
self.assertIn("programs-v1", inventory["protocols"])

View file

@ -71,6 +71,58 @@ class GenerationTest(unittest.TestCase):
with closing(sqlite3.connect(destination)) as reopened:
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
def test_rescheduled_program_requires_v2_capability_before_publication(self):
with closing(sqlite3.connect(self.database)) as db:
db.executescript("""
CREATE TABLE program_session_planning(
program_session_id TEXT PRIMARY KEY,current_for TEXT,state TEXT);
CREATE TABLE programs(program_id TEXT PRIMARY KEY,deleted_at TEXT);
CREATE TABLE program_sessions(
program_session_id TEXT PRIMARY KEY,program_id TEXT);
INSERT INTO programs VALUES('pg_fixture',NULL);
INSERT INTO program_sessions VALUES('pgs_fixture','pg_fixture');
INSERT INTO program_session_planning VALUES('pgs_fixture','2026-10-01','active');
PRAGMA user_version=37;
""")
db.commit()
artifacts = (
("history", "trainlog-mobile-export", 4, "history.json", True,
"controlled.py", ()),
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
"export_programs.py", ()),
("programs-v2", "trainlog-programs", 2, "programs-v2.json", False,
"export_programs_v2.py", ()),
)
def exporter(tool, _extra, output, _snapshot):
if tool == "controlled.py":
output.write_text(json.dumps({"format": "trainlog-mobile-export", "version": 4}))
return
output.write_text(json.dumps({"format": "trainlog-programs",
"version": 2 if tool.endswith("_v2.py") else 1,
"programs": [], "deletions": []}))
with mock.patch.object(generation, "ARTIFACTS", artifacts), \
mock.patch.object(generation, "run_export", exporter):
with self.assertRaisesRegex(generation.GenerationError, "Programs V2 capability"):
generation.capture_desktop(self.database, self.root / "legacy", PEER_B, RUN, GEN)
self.assertFalse((self.root / "legacy" / "staging" / GEN).exists())
legacy_stage, legacy_manifest, _ = generation.capture_desktop(
self.database, self.root / "legacy-domain-only", PEER_B, RUN, GEN,
set(), True,
)
self.assertFalse((legacy_stage / "programs-v1.json").exists())
self.assertEqual(["history"], [a["logical_name"] for a in legacy_manifest["artifacts"]])
stage, manifest, _ = generation.capture_desktop(
self.database, self.root / "modern", PEER_B, RUN,
"gen_55555555-5555-4555-8555-555555555555",
{"programs-v2"},
)
self.assertTrue((stage / "programs-v2.json").is_file())
self.assertFalse((stage / "programs-v1.json").exists())
self.assertEqual(["history", "programs-v2"],
[a["logical_name"] for a in manifest["artifacts"]])
def test_archived_acknowledged_fork_is_not_an_active_parent_tip(self):
active = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
archived = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"

View file

@ -271,8 +271,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36):
fail("desktop schema v33-v36 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36, 37):
fail("desktop schema v33-v37 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

@ -395,8 +395,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36):
fail("desktop schema v34-v36 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36, 37):
fail("desktop schema v34-v37 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise CausalError(f"desktop schema v20-v36 required, found v{version}")
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
raise CausalError(f"desktop schema v20-v37 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):

View file

@ -182,8 +182,8 @@ def causal_payload(draft):
def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise LifecycleError(f"desktop schema v19-v36 required, found v{version}")
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
raise LifecycleError(f"desktop schema v19-v37 required, found v{version}")
def import_document(connection, document, own_transaction=True):

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
"""Build the raw export using only explicitly ordered SELECT statements."""
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
raise ValueError("schema desktop v15-v36 requis")
raise ValueError("schema desktop v15-v37 requis")
root = {
"format": "TRAINLOG_AI_EXPORT",

View file

@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v18-v36 requis")
raise ValueError("schema desktop v18-v37 requis")
columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns:

View file

@ -28,9 +28,9 @@ def main():
args = parser.parse_args()
connection = connect_database(args.database)
try:
supported_versions = tuple(range(8, 37))
supported_versions = tuple(range(8, 38))
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v36 requis")
raise ValueError("schema desktop v8 à v37 requis")
known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))

View file

@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(8, 37))
supported_versions = tuple(range(8, 38))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v36 requis")
raise ValueError("schema desktop v8 à v37 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():
raise ValueError("causal equipment protection requires the staged artifact")
equipment = [dict(row) for row in connection.execute(

View file

@ -17,8 +17,8 @@ def main():
con = connect_database(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise ValueError("schema desktop v12-v36 requis")
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
raise ValueError("schema desktop v12-v37 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY"

View file

@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(11, 37))
supported_versions = tuple(range(11, 38))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v36 requis")
raise ValueError("schema desktop v11-v37 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 ValueError("causal BODY ZONES protection requires the staged artifact")
exercises = []

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,

View file

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

View file

@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
supported_versions = range(11, 37)
supported_versions = range(11, 38)
legacy_v2_schema = args.version == 2 and schema_version == 10
if schema_version not in supported_versions and not legacy_v2_schema:
raise ValueError("schema desktop v11-v36 requis (v10 accepté pour export V2 explicite)")
raise ValueError("schema desktop v11-v37 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")

View file

@ -16,7 +16,7 @@ def nullable(row, index):
def export_programs(database: Path) -> dict:
with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
raise ValueError("desktop schema v26 through v32 required")
programs = []
for row in db.execute(

View file

@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Export the versioned desktop-owned Program planning projection."""
import argparse
import json
from contextlib import closing
from pathlib import Path
from export_programs import export_programs
from trainlog_sqlite import connect_database
def export_programs_v2(database: Path) -> dict:
# WHY: V1's strict session fields are frozen. V2 carries absolute current
# dates and ceded-slot state without changing the imported source date.
value = export_programs(database)
with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] != 37:
raise ValueError("desktop schema v37 required for Programs V2")
for program in value["programs"]:
sequence = db.execute(
"SELECT revision_sequence FROM program_revision_sequences WHERE program_id=?",
(program["program_id"],),
).fetchone()
program["revision_sequence"] = sequence[0] if sequence is not None else 0
planning = {
row[0]: (row[1], row[2])
for row in db.execute(
"SELECT p.program_session_id,p.current_for,p.state "
"FROM program_session_planning p JOIN program_sessions s "
"ON s.program_session_id=p.program_session_id WHERE s.program_id=?",
(program["program_id"],),
)
}
for position, session in enumerate(program["sessions"]):
current, state = planning.get(
session["program_session_id"], (session["planned_for"], "active")
)
session["position"] = position
session["current_for"] = current
session["planning_state"] = state
value["version"] = 2
return value
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, required=True)
args = parser.parse_args()
args.output.write_text(
json.dumps(export_programs_v2(args.database), ensure_ascii=False,
sort_keys=True, separators=(",", ":")),
encoding="utf-8",
)
if __name__ == "__main__":
main()

View file

@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("desktop schema v24, v25, v26 through v32 required")
deliveries = []

View file

@ -10,9 +10,9 @@ def main(complete_causal_envelope=False):
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database)
try:
supported_versions = range(15, 37)
supported_versions = range(15, 38)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v36 requis")
raise ValueError("schema desktop v15-v37 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")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()

View file

@ -61,8 +61,8 @@ def split_target(value: str) -> tuple[str, str]:
def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in range(27, 37):
raise CorrectionError(f"heart-rate correction requires schema v27-v36, found v{version}")
if version not in range(27, 38):
raise CorrectionError(f"heart-rate correction requires schema v27-v37, found v{version}")
def _table_exists(db: sqlite3.Connection, table: str) -> bool:

View file

@ -282,8 +282,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
fail("desktop schema v30-v36 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37):
fail("desktop schema v30-v37 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind.
"""
supported_versions = range(18, 37)
supported_versions = range(18, 38)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v36 requis")
raise ImportFailure("schema desktop v18-v37 requis")
draft, entries = validate(payload, connection)
digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

View file

@ -206,9 +206,9 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database)
try:
supported_versions = range(8, 37)
supported_versions = range(8, 38)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v36 requis")
fail("schema desktop v8 à v37 requis")
proof_path = args.mobile_export
if proof_path is None:
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")

View file

@ -53,9 +53,9 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction."""
supported_versions = range(8, 37)
supported_versions = range(8, 38)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v36 requis")
fail("schema desktop v8 à v37 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():
fail("causal equipment protection requires the staged artifact")
imported = skipped = 0

View file

@ -56,9 +56,9 @@ def load(path):
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 37)
supported_versions = range(12, 38)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v36 requis")
fail("schema desktop v12-v37 requis")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
if target is None: fail(f"cible canonique absente: {canonical}")

View file

@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries."""
supported_versions = range(11, 37)
supported_versions = range(11, 38)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v36 requis")
raise ImportFailure("schema desktop v11-v37 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")
connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct

View file

@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
fail("schema desktop v17-v36 requis")
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
fail("schema desktop v17-v37 requis")
applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]:
validate(item);eid=item["exercise_id"]

View file

@ -847,9 +847,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in range(5, 37):
if version not in range(5, 38):
raise ImportFailure(
f"base desktop schema v5 à v36 attendue, version trouvée: {version}"
f"base desktop schema v5 à v37 attendue, version trouvée: {version}"
)
@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en
require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v36")
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v37")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}

View file

@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON")
supported_versions = range(15, 37)
supported_versions = range(15, 38)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v36 requis")
fail("schema desktop v15-v37 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")
seen=set()
for index,item in enumerate(root["exercise_feedback"]):

View file

@ -53,6 +53,7 @@ RUNTIME_TOOLS = [
"post_sync_ai_drive.py",
"export_session_preparations.py",
"export_programs.py",
"export_programs_v2.py",
"program_execution_exchange.py",
]
@ -120,7 +121,7 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.7",
"desktop_schema": 36,
"desktop_schema": 37,
"android_schema": 32,
"protocols": [
"mobile-export-v3",

View file

@ -87,7 +87,7 @@ def load(path: Path) -> dict:
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
fail("desktop schema v27 through v32 required")
added = 0
advanced = 0

View file

@ -184,8 +184,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36):
fail("desktop schema v31-v36 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36, 37):
fail("desktop schema v31-v37 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

@ -637,8 +637,8 @@ def main() -> None:
import_command.add_argument("--database", required=True, type=Path)
args = parser.parse_args()
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
fail("desktop schema v30-v36 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37):
fail("desktop schema v30-v37 required")
if args.command == "export":
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
separators=(",", ":")), encoding="utf-8")

View file

@ -82,6 +82,8 @@ ARTIFACTS = (
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
"export_programs.py", ()),
("programs-v2", "trainlog-programs", 2, "programs-v2.json", False,
"export_programs_v2.py", ()),
("sleep-diary", "trainlog-sleep-diary", 2, "sleep-diary-v2.json", False,
"sleep_diary_exchange.py", ("export",)),
)
@ -221,9 +223,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24 through v36 required")
raise GenerationError("desktop schema v24 through v37 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
@ -366,7 +368,9 @@ def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path)
def capture_desktop(database: Path, owned_root: Path, consumer: str,
run_id: str | None = None, generation_id: str | None = None):
run_id: str | None = None, generation_id: str | None = None,
peer_capabilities: set[str] | None = None,
allow_program_skip: bool = False):
if not valid_id(consumer, "peer"):
raise GenerationError("invalid expected consumer")
generation = generation_id or "gen_" + str(uuid.uuid4())
@ -405,9 +409,35 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
producer = peer_identity(source, "desktop")
source.commit()
source.backup(target)
with closing(connect_database(snapshot)) as planning_db:
has_planning_table = planning_db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' "
"AND name='program_session_planning'"
).fetchone() is not None
has_reschedule = has_planning_table and planning_db.execute(
"SELECT 1 FROM program_session_planning plan "
"JOIN program_sessions session ON session.program_session_id=plan.program_session_id "
"JOIN programs program ON program.program_id=session.program_id "
"WHERE program.deleted_at IS NULL LIMIT 1"
).fetchone() is not None
supports_v2 = peer_capabilities is not None and "programs-v2" in peer_capabilities
# CONTRACT: one generation contains exactly one Program revision per
# identity. A V1-only peer cannot preserve a changed current schedule.
# INVARIANT: reject before publication rather than claiming convergence.
skip_programs = has_reschedule and not supports_v2
if skip_programs and not allow_program_skip:
raise GenerationError("Programs V2 capability required for rescheduled programs")
if supports_v2 and not has_planning_table:
raise GenerationError("Programs V2 requires desktop schema v37")
descriptors = []
total = 0
for logical, fmt, version, filename, required, tool, extra in ARTIFACTS:
if skip_programs and logical in ("programs-v1", "programs-v2"):
continue
if logical == "programs-v1" and supports_v2:
continue
if logical == "programs-v2" and not supports_v2:
continue
output = stage / filename
run_export(tool, extra, output, snapshot)
size, checksum = digest_file(output)
@ -521,7 +551,7 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone()
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
(operation["operation_id"], generation, 0 if existed else 1))
programs_path = stage / "programs-v1.json"
programs_path = stage / ("programs-v2.json" if supports_v2 else "programs-v1.json")
if programs_path.is_file():
captured_programs = strict_json(programs_path.read_bytes(), MAX_ARTIFACT)
for deletion in captured_programs["deletions"]:

View file

@ -827,8 +827,13 @@ def main() -> int:
# diagnostics to the separately bounded stderr channel.
with redirect_stdout(sys.stderr):
stage, manifest, digest = generation.capture_desktop(
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id,
set(peer["capabilities"]), True,
)
program_domain_incompatible = not any(
artifact["logical_name"] in ("programs-v1", "programs-v2")
for artifact in manifest["artifacts"]
)
del stage
published = generation.publish(
args.database, outgoing_id, args.transport_root / "desktop-objects"
@ -926,6 +931,14 @@ def main() -> int:
if inbound_row != ("consumed",) or outbound_row != ("acknowledged",):
raise RuntimeError("generation evidence did not corroborate completion")
emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id)
if program_domain_incompatible:
# WHY: the old peer cannot retain the new absolute schedule, but its
# unrelated domains can still converge through the same generation.
# CONTRACT: never report whole-run success while its Program view is stale.
raise RuntimeError(
"Programs V2 capability required: other domains synchronized, "
"Program planning not published to this peer"
)
ai_post_sync = publish_ai_export_to_android(args, deadline)
report = {
"format": "trainlog-sync-worker-report",

View file

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

View file

@ -63,4 +63,17 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
char **output_json,
size_t *output_size);
/* WHY: original Program dates are immutable source evidence. CONTRACT: the
* preview is read only; commit requires its exact fact/choice fingerprint,
* current Program revision and a stable operation ID. INVARIANT: one commit
* changes absolute planning dates in one transaction, never history rows. */
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
const char *program_id,
const char *operation_id,
const char *body,
size_t body_size,
bool commit,
char **output_json,
size_t *output_size);
#endif

View file

@ -239,6 +239,7 @@ trainlog_core_sources = files(
'src/web_sessions.c',
'src/web_session_deletions.c',
'src/web_programs.c',
'src/web_program_reschedule.c',
'src/web_sleep.c',
'src/web_exercises.c',
'src/web_equipment.c',

View file

@ -1578,6 +1578,39 @@ static const char *const MIGRATE_V35_TO_V36_SQL =
"immutable');END;"
"PRAGMA user_version=36;COMMIT;";
/* WHY: a Program's imported date is evidence, while an explicit reschedule
* owns its current target and an optional relinquishment of a future slot.
* CONTRACT: absent planning rows retain the original date and active state;
* operation identities bind replay to an exact request digest. INVARIANT:
* migration changes no execution, preparation, source date, or history row. */
static const char *const MIGRATE_V36_TO_V37_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE IF NOT EXISTS program_session_planning("
"program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id) "
"ON DELETE RESTRICT,"
"current_for TEXT,"
"state TEXT NOT NULL CHECK(state IN('active','ceded')),"
"operation_id TEXT NOT NULL,updated_at TEXT NOT NULL,"
"CHECK((state='active' AND current_for IS NOT NULL) OR "
"(state='ceded' AND current_for IS NULL)));"
"CREATE TABLE IF NOT EXISTS program_reschedule_operations("
"operation_id TEXT PRIMARY KEY,"
"program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT,"
"request_sha256 TEXT NOT NULL CHECK(length(request_sha256)=64),"
"expected_revision TEXT NOT NULL,"
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
"CREATE INDEX IF NOT EXISTS program_reschedule_operations_program "
"ON program_reschedule_operations(program_id,created_at);"
"CREATE TABLE IF NOT EXISTS program_revision_sequences("
"program_id TEXT PRIMARY KEY REFERENCES programs(program_id) ON DELETE RESTRICT,"
"revision_sequence INTEGER NOT NULL CHECK(revision_sequence>=1));"
"CREATE TRIGGER IF NOT EXISTS programs_revision_sequence AFTER UPDATE OF revision_id "
"ON programs WHEN OLD.revision_id<>NEW.revision_id BEGIN "
"INSERT INTO program_revision_sequences(program_id,revision_sequence) "
"VALUES(NEW.program_id,1) ON CONFLICT(program_id) DO UPDATE SET "
"revision_sequence=revision_sequence+1;END;"
"PRAGMA user_version=37;COMMIT;";
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
* before medication capture joined the same unreleased migration. CONTRACT:
* this additive repair is identical to the tail of v28 -> v29 and runs only
@ -2413,7 +2446,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
version == 31 || version == 32 || version == 33 || version == 34 || version == 35 ||
version == 36) {
version == 36 || version == 37) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -2585,6 +2618,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK && version < 36) {
status = execute_sql(database, MIGRATE_V35_TO_V36_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 37) {
status = execute_sql(database, MIGRATE_V36_TO_V37_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -2604,7 +2640,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status != TRAINLOG_STATUS_OK) {
set_open_diagnostic(output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v36" : "migrate database to schema v36",
version == 0 ? "create schema v37" : "migrate database to schema v37",
database->connection,
SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -0,0 +1,645 @@
#include "trainlog/web_programs.h"
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <openssl/evp.h>
#include <sqlite3.h>
#include <yyjson.h>
#include "database_internal.h"
#include "trainlog/id.h"
typedef struct PlanningRow {
const char *id;
const char *original;
const char *current;
const char *execution;
const char *planning;
const char *assigned;
bool cede;
} PlanningRow;
static TrainlogStatus
planning_error(const char *reason, TrainlogStatus status, char **output, size_t *size) {
size_t length = strlen(reason) + 16U;
*output = malloc(length);
if (*output == NULL) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
*size = (size_t)snprintf(*output, length, "{\"error\":\"%s\"}", reason);
return status;
}
static bool planning_digest(
const char *first, size_t first_size, const char *second, size_t second_size, char output[65]) {
EVP_MD_CTX *context = EVP_MD_CTX_new();
unsigned char digest[32];
unsigned int length = 0U;
bool okay = context != NULL && EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1 &&
EVP_DigestUpdate(context, first, first_size) == 1 &&
EVP_DigestUpdate(context, second, second_size) == 1 &&
EVP_DigestFinal_ex(context, digest, &length) == 1 && length == 32U;
EVP_MD_CTX_free(context);
if (!okay) {
return false;
}
for (size_t index = 0U; index < 32U; ++index) {
(void)snprintf(output + index * 2U, 3U, "%02x", digest[index]);
}
output[64] = '\0';
return true;
}
static bool valid_civil_date(const char *value) {
unsigned year, month, day;
int days;
if (value == NULL || strlen(value) != 10U || value[4] != '-' || value[7] != '-' ||
sscanf(value, "%4u-%2u-%2u", &year, &month, &day) != 3 || year < 1900U || year > 9999U ||
month < 1U || month > 12U) {
return false;
}
for (size_t index = 0U; index < 10U; ++index) {
if (index != 4U && index != 7U && (value[index] < '0' || value[index] > '9')) {
return false;
}
}
static const int MONTH_DAYS[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
days = MONTH_DAYS[month - 1U];
if (month == 2U && (year % 4U == 0U && (year % 100U != 0U || year % 400U == 0U))) {
days = 29;
}
return day >= 1U && day <= (unsigned)days;
}
static bool
add_nullable(yyjson_mut_doc *document, yyjson_mut_val *object, const char *key, const char *value) {
return value == NULL ? yyjson_mut_obj_add_null(document, object, key)
: yyjson_mut_obj_add_strcpy(document, object, key, value);
}
static bool request_valid(yyjson_val *root, bool commit) {
yyjson_obj_iter iterator;
yyjson_val *key;
size_t count = 0U;
if (!yyjson_is_obj(root) || yyjson_obj_size(root) != (commit ? 6U : 5U)) {
return false;
}
iterator = yyjson_obj_iter_with(root);
while ((key = yyjson_obj_iter_next(&iterator)) != NULL) {
const char *name = yyjson_get_str(key);
if (strcmp(name, "start_session_id") != 0 && strcmp(name, "through_session_id") != 0 &&
strcmp(name, "start_date") != 0 && strcmp(name, "ceded_session_ids") != 0 &&
strcmp(name, "expected_revision") != 0 &&
!(commit && strcmp(name, "preview_sha256") == 0)) {
return false;
}
++count;
}
return count == (commit ? 6U : 5U) && yyjson_is_str(yyjson_obj_get(root, "start_session_id")) &&
yyjson_is_str(yyjson_obj_get(root, "through_session_id")) &&
yyjson_is_str(yyjson_obj_get(root, "expected_revision")) &&
yyjson_get_len(yyjson_obj_get(root, "start_session_id")) <= 128U &&
yyjson_get_len(yyjson_obj_get(root, "through_session_id")) <= 128U &&
yyjson_get_len(yyjson_obj_get(root, "expected_revision")) <= 128U &&
valid_civil_date(yyjson_get_str(yyjson_obj_get(root, "start_date"))) &&
yyjson_is_arr(yyjson_obj_get(root, "ceded_session_ids")) &&
yyjson_arr_size(yyjson_obj_get(root, "ceded_session_ids")) <= 64U &&
(!commit || (yyjson_is_str(yyjson_obj_get(root, "preview_sha256")) &&
yyjson_get_len(yyjson_obj_get(root, "preview_sha256")) == 64U));
}
static bool has_preparation(TrainlogDatabase *database, const char *session_id) {
sqlite3_stmt *statement = NULL;
bool present = true;
if (sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM session_preparations WHERE "
"source_program_session_id=? AND withdrawn_at IS NULL LIMIT 1",
-1,
&statement,
NULL) == SQLITE_OK &&
sqlite3_bind_text(statement, 1, session_id, -1, SQLITE_TRANSIENT) == SQLITE_OK) {
present = sqlite3_step(statement) != SQLITE_DONE;
}
(void)sqlite3_finalize(statement);
return present;
}
static TrainlogStatus peer_planning_fresh(TrainlogDatabase *database) {
static const char SQL[] =
"SELECT EXISTS(SELECT 1 FROM "
"(SELECT DISTINCT consumer_peer_id FROM sync_generations "
"WHERE producer_kind='desktop') peer WHERE "
"NOT EXISTS(SELECT 1 FROM sync_generations generation "
"JOIN sync_generation_artifacts artifact "
"ON artifact.generation_id=generation.generation_id "
"WHERE generation.generation_id=(SELECT latest.generation_id "
"FROM sync_generations latest WHERE latest.consumer_peer_id=peer.consumer_peer_id "
"AND latest.producer_kind='desktop' "
"ORDER BY julianday(latest.generated_at) DESC,latest.generation_id DESC LIMIT 1) "
"AND generation.status='acknowledged' AND artifact.logical_name='programs-v2' "
"AND julianday(generation.acknowledged_at)>=julianday('now','-5 minutes')))";
sqlite3_stmt *statement = NULL;
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) {
status =
sqlite3_column_int(statement, 0) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_CONFLICT;
}
(void)sqlite3_finalize(statement);
return status;
}
static TrainlogStatus build_preview(TrainlogDatabase *database,
const char *program_id,
yyjson_val *request,
char **output,
size_t *output_size) {
char *detail = NULL;
size_t detail_size = 0U;
yyjson_doc *source = NULL;
yyjson_mut_doc *response = NULL;
yyjson_val *program;
yyjson_val *sessions;
yyjson_val *ceded;
yyjson_mut_val *root;
yyjson_mut_val *moves;
PlanningRow rows[64] = {0};
const char *slots[64] = {0};
size_t first = 64U;
size_t last = 64U;
size_t count;
size_t slot_count = 0U;
size_t next_slot = 0U;
char fingerprint[65];
char *choices = NULL;
size_t choices_size = 0U;
TrainlogStatus status =
trainlog_web_programs_detail_json(database, program_id, &detail, &detail_size);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
source = yyjson_read(detail, detail_size, YYJSON_READ_NOFLAG);
program = source == NULL ? NULL : yyjson_doc_get_root(source);
sessions = program == NULL ? NULL : yyjson_obj_get(program, "sessions");
ceded = yyjson_obj_get(request, "ceded_session_ids");
count = yyjson_arr_size(sessions);
if (program == NULL || count > 64U ||
!yyjson_equals_str(yyjson_obj_get(program, "state"), "active") ||
!yyjson_equals_str(yyjson_obj_get(program, "revision_id"),
yyjson_get_str(yyjson_obj_get(request, "expected_revision"))) ||
!yyjson_is_str(yyjson_obj_get(program, "end_date"))) {
status = planning_error(
"stale_or_ineligible_program", TRAINLOG_STATUS_CONFLICT, output, output_size);
goto done;
}
for (size_t index = 0U; index < count; ++index) {
yyjson_val *session = yyjson_arr_get(sessions, index);
rows[index].id = yyjson_get_str(yyjson_obj_get(session, "program_session_id"));
rows[index].original = yyjson_get_str(yyjson_obj_get(session, "planned_for"));
rows[index].current = yyjson_get_str(yyjson_obj_get(session, "current_for"));
rows[index].execution = yyjson_get_str(yyjson_obj_get(session, "execution_state"));
rows[index].planning = yyjson_get_str(yyjson_obj_get(session, "planning_state"));
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "start_session_id"))) ==
0) {
first = index;
}
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "through_session_id"))) ==
0) {
last = index;
}
}
if (first >= count || last >= count || first > last ||
strcmp(yyjson_get_str(yyjson_obj_get(request, "start_date")),
yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
status = planning_error(
"invalid_selection", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
goto done;
}
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
yyjson_val *id = yyjson_arr_get(ceded, index);
bool matched = false;
if (!yyjson_is_str(id) || yyjson_get_len(id) > 128U) {
status = planning_error(
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
goto done;
}
for (size_t candidate = first; candidate <= last; ++candidate) {
if (strcmp(rows[candidate].id, yyjson_get_str(id)) == 0 && !rows[candidate].cede) {
rows[candidate].cede = true;
matched = true;
break;
}
}
if (!matched) {
status = planning_error(
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
goto done;
}
}
for (size_t index = first; index <= last; ++index) {
if (strcmp(rows[index].execution, "todo") != 0 ||
has_preparation(database, rows[index].id) || rows[index].original == NULL ||
!valid_civil_date(rows[index].original)) {
status = planning_error(
"prepared_or_executed_session", TRAINLOG_STATUS_CONFLICT, output, output_size);
goto done;
}
if (strcmp(rows[index].planning, "ceded") == 0 && !rows[index].cede) {
status = planning_error(
"ceded_session_requires_selection", TRAINLOG_STATUS_CONFLICT, output, output_size);
goto done;
}
// WHY: a second explicit decision starts from the durable current
// schedule. A ceded identity has no current date, so its original slot
// is available only when the user selects that identity again.
const char *slot = rows[index].current == NULL ? rows[index].original : rows[index].current;
if (strcmp(slot, yyjson_get_str(yyjson_obj_get(request, "start_date"))) >= 0) {
slots[slot_count++] = slot;
}
}
// Selected Program slots define permitted days; no rest day is synthesized.
// Position order may differ from dates, so sort candidate dates explicitly.
for (size_t outer = 0U; outer < slot_count; ++outer) {
for (size_t inner = outer + 1U; inner < slot_count; ++inner) {
if (strcmp(slots[inner], slots[outer]) < 0) {
const char *swap = slots[outer];
slots[outer] = slots[inner];
slots[inner] = swap;
}
}
}
// A previously ceded original day may now be occupied by an active
// session. It remains one calendar slot for this operation.
size_t unique_slots = 0U;
for (size_t index = 0U; index < slot_count; ++index) {
if (unique_slots == 0U || strcmp(slots[index], slots[unique_slots - 1U]) != 0) {
slots[unique_slots++] = slots[index];
}
}
slot_count = unique_slots;
for (size_t index = first; index <= last; ++index) {
if (rows[index].cede) {
continue;
}
if (next_slot >= slot_count ||
strcmp(slots[next_slot], yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
status = planning_error("insufficient_or_duplicate_slots",
TRAINLOG_STATUS_INVALID_ARGUMENT,
output,
output_size);
goto done;
}
rows[index].assigned = slots[next_slot++];
}
// CONTRACT: the choice digest includes stable identities in their supplied
// order. The fact digest includes the complete current Program projection.
{
yyjson_mut_doc *choice_document = yyjson_mut_doc_new(NULL);
yyjson_mut_val *choice_root =
choice_document == NULL ? NULL : yyjson_mut_obj(choice_document);
if (choice_document == NULL || choice_root == NULL) {
yyjson_mut_doc_free(choice_document);
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
yyjson_mut_doc_set_root(choice_document, choice_root);
(void)yyjson_mut_obj_add_strcpy(
choice_document,
choice_root,
"start_session_id",
yyjson_get_str(yyjson_obj_get(request, "start_session_id")));
(void)yyjson_mut_obj_add_strcpy(
choice_document,
choice_root,
"through_session_id",
yyjson_get_str(yyjson_obj_get(request, "through_session_id")));
(void)yyjson_mut_obj_add_strcpy(choice_document,
choice_root,
"start_date",
yyjson_get_str(yyjson_obj_get(request, "start_date")));
yyjson_mut_val *ids = yyjson_mut_arr(choice_document);
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
(void)yyjson_mut_arr_add_strcpy(
choice_document, ids, yyjson_get_str(yyjson_arr_get(ceded, index)));
}
(void)yyjson_mut_obj_add_val(choice_document, choice_root, "ceded_session_ids", ids);
choices = yyjson_mut_write(choice_document, YYJSON_WRITE_NOFLAG, &choices_size);
yyjson_mut_doc_free(choice_document);
}
if (choices == NULL ||
!planning_digest(detail, detail_size, choices, choices_size, fingerprint)) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
response = yyjson_mut_doc_new(NULL);
root = response == NULL ? NULL : yyjson_mut_obj(response);
moves = response == NULL ? NULL : yyjson_mut_arr(response);
if (response == NULL || root == NULL || moves == NULL) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
yyjson_mut_doc_set_root(response, root);
(void)yyjson_mut_obj_add_uint(response, root, "api_version", 1U);
(void)yyjson_mut_obj_add_strcpy(response, root, "program_id", program_id);
(void)yyjson_mut_obj_add_strcpy(
response, root, "revision_id", yyjson_get_str(yyjson_obj_get(program, "revision_id")));
(void)yyjson_mut_obj_add_strcpy(response, root, "preview_sha256", fingerprint);
(void)yyjson_mut_obj_add_val(response, root, "moves", moves);
for (size_t index = first; index <= last; ++index) {
yyjson_mut_val *move = yyjson_mut_obj(response);
(void)yyjson_mut_obj_add_strcpy(response, move, "program_session_id", rows[index].id);
(void)add_nullable(response, move, "original_for", rows[index].original);
(void)add_nullable(response, move, "old_for", rows[index].current);
(void)add_nullable(response, move, "new_for", rows[index].assigned);
const char *change =
rows[index].cede ? "ceded"
: rows[index].current != NULL && strcmp(rows[index].current, rows[index].assigned) == 0
? "unchanged"
: "rescheduled";
(void)yyjson_mut_obj_add_strcpy(response, move, "change", change);
(void)yyjson_mut_arr_add_val(moves, move);
}
*output = yyjson_mut_write(response, YYJSON_WRITE_NOFLAG, output_size);
status = *output == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
done:
free(choices);
yyjson_mut_doc_free(response);
yyjson_doc_free(source);
free(detail);
return status;
}
static TrainlogStatus apply_preview(TrainlogDatabase *database,
const char *operation_id,
const char *program_id,
const char *expected_revision,
const char *request_digest,
const char *preview,
char **output,
size_t *output_size) {
yyjson_doc *document = yyjson_read(preview, strlen(preview), YYJSON_READ_NOFLAG);
yyjson_val *root = document == NULL ? NULL : yyjson_doc_get_root(document);
yyjson_val *moves = root == NULL ? NULL : yyjson_obj_get(root, "moves");
sqlite3_stmt *statement = NULL;
char revision[80];
char now[32];
time_t clock_now = time(NULL);
struct tm utc;
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
if (moves == NULL || clock_now == (time_t)-1 || gmtime_r(&clock_now, &utc) == NULL ||
strftime(now, sizeof(now), "%Y-%m-%dT%H:%M:%SZ", &utc) == 0U ||
trainlog_id_generate("pgr", revision, sizeof(revision)) != TRAINLOG_STATUS_OK) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
if (sqlite3_prepare_v2(database->connection,
"UPDATE programs SET revision_id=?,updated_at=? WHERE program_id=? "
"AND revision_id=? AND deleted_at IS NULL AND state='active'",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
status = TRAINLOG_STATUS_CONFLICT;
goto done;
}
(void)sqlite3_finalize(statement);
statement = NULL;
if (sqlite3_prepare_v2(database->connection,
"INSERT INTO program_session_planning "
"(program_session_id,current_for,state,operation_id,updated_at) "
"VALUES(?,?,?,?,?) ON CONFLICT(program_session_id) DO UPDATE SET "
"current_for=excluded.current_for,state=excluded.state,"
"operation_id=excluded.operation_id,updated_at=excluded.updated_at",
-1,
&statement,
NULL) != SQLITE_OK) {
goto done;
}
for (size_t index = 0U; index < yyjson_arr_size(moves); ++index) {
yyjson_val *move = yyjson_arr_get(moves, index);
yyjson_val *new_for = yyjson_obj_get(move, "new_for");
if (yyjson_equals_str(yyjson_obj_get(move, "change"), "unchanged")) {
continue;
}
sqlite3_reset(statement);
sqlite3_clear_bindings(statement);
if (sqlite3_bind_text(statement,
1,
yyjson_get_str(yyjson_obj_get(move, "program_session_id")),
-1,
SQLITE_TRANSIENT) != SQLITE_OK ||
(yyjson_is_null(new_for)
? sqlite3_bind_null(statement, 2)
: sqlite3_bind_text(
statement, 2, yyjson_get_str(new_for), -1, SQLITE_TRANSIENT)) != SQLITE_OK ||
sqlite3_bind_text(statement,
3,
yyjson_equals_str(yyjson_obj_get(move, "change"), "ceded") ? "ceded"
: "active",
-1,
SQLITE_STATIC) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE) {
goto done;
}
}
(void)sqlite3_finalize(statement);
statement = NULL;
{
size_t length = strlen(preview) + strlen(revision) + 52U;
*output = malloc(length);
if (*output == NULL) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
*output_size = (size_t)snprintf(*output,
length,
"%.*s,\"applied\":true,\"new_revision_id\":\"%s\"}",
(int)strlen(preview) - 1,
preview,
revision);
}
if (sqlite3_prepare_v2(database->connection,
"INSERT INTO program_reschedule_operations VALUES(?,?,?,?,?,?)",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, request_digest, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 5, *output, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 6, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE) {
goto done;
}
status = TRAINLOG_STATUS_OK;
done:
(void)sqlite3_finalize(statement);
yyjson_doc_free(document);
return status;
}
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
const char *program_id,
const char *operation_id,
const char *body,
size_t body_size,
bool commit,
char **output_json,
size_t *output_size) {
yyjson_doc *request_doc = NULL;
yyjson_val *request;
sqlite3_stmt *statement = NULL;
char request_digest[65];
char *preview = NULL;
size_t preview_size = 0U;
TrainlogStatus status;
if (database == NULL || program_id == NULL || body == NULL || output_json == NULL ||
output_size == NULL || (commit && (operation_id == NULL || operation_id[0] == '\0')) ||
body_size > 16384U || (operation_id != NULL && strlen(operation_id) > 128U)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
request_doc = yyjson_read(body, body_size, YYJSON_READ_NOFLAG);
request = request_doc == NULL ? NULL : yyjson_doc_get_root(request_doc);
if (!request_valid(request, commit) ||
!planning_digest(body, body_size, "", 0U, request_digest)) {
yyjson_doc_free(request_doc);
return planning_error("invalid_reschedule_request",
TRAINLOG_STATUS_INVALID_ARGUMENT,
output_json,
output_size);
}
if (sqlite3_exec(
database->connection, commit ? "BEGIN IMMEDIATE" : "BEGIN", NULL, NULL, NULL) !=
SQLITE_OK) {
yyjson_doc_free(request_doc);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (commit) {
if (sqlite3_prepare_v2(database->connection,
"SELECT program_id,request_sha256,response_json FROM "
"program_reschedule_operations WHERE operation_id=?",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
}
if (sqlite3_step(statement) == SQLITE_ROW) {
const char *known_program = (const char *)sqlite3_column_text(statement, 0);
const char *known_digest = (const char *)sqlite3_column_text(statement, 1);
const char *saved = (const char *)sqlite3_column_text(statement, 2);
if (known_program != NULL && known_digest != NULL && saved != NULL &&
strcmp(known_program, program_id) == 0 &&
strcmp(known_digest, request_digest) == 0) {
*output_json = strdup(saved);
*output_size = *output_json == NULL ? 0U : strlen(*output_json);
status = *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
} else {
status = planning_error("operation_identity_conflict",
TRAINLOG_STATUS_CONFLICT,
output_json,
output_size);
}
goto finish;
}
(void)sqlite3_finalize(statement);
statement = NULL;
if (sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_requests WHERE request_id=? "
"UNION ALL SELECT 1 FROM program_deletions WHERE request_id=?",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
}
int cross_command = sqlite3_step(statement);
(void)sqlite3_finalize(statement);
statement = NULL;
if (cross_command != SQLITE_DONE) {
status = cross_command == SQLITE_ROW ? planning_error("operation_identity_conflict",
TRAINLOG_STATUS_CONFLICT,
output_json,
output_size)
: TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
}
// WHY: the desktop's last imported draft snapshot cannot prove that
// Android stayed idle while offline. A recent acknowledged V2 Program
// generation is required; Android independently rejects a changed
// schedule if a Program draft starts after that acknowledgement.
status = peer_planning_fresh(database);
if (status != TRAINLOG_STATUS_OK) {
if (status == TRAINLOG_STATUS_CONFLICT) {
status = planning_error("program_peer_not_fresh", status, output_json, output_size);
}
goto finish;
}
}
status = build_preview(database, program_id, request, &preview, &preview_size);
if (status != TRAINLOG_STATUS_OK) {
if (preview != NULL) {
*output_json = preview;
*output_size = preview_size;
preview = NULL;
}
goto finish;
}
if (!commit) {
*output_json = preview;
*output_size = preview_size;
preview = NULL;
goto finish;
}
{
yyjson_doc *preview_doc = yyjson_read(preview, preview_size, YYJSON_READ_NOFLAG);
yyjson_val *preview_root = preview_doc == NULL ? NULL : yyjson_doc_get_root(preview_doc);
const char *fingerprint = yyjson_get_str(yyjson_obj_get(preview_root, "preview_sha256"));
if (fingerprint == NULL ||
strcmp(fingerprint, yyjson_get_str(yyjson_obj_get(request, "preview_sha256"))) != 0) {
status = planning_error(
"stale_reschedule_preview", TRAINLOG_STATUS_CONFLICT, output_json, output_size);
} else {
status = apply_preview(database,
operation_id,
program_id,
yyjson_get_str(yyjson_obj_get(request, "expected_revision")),
request_digest,
preview,
output_json,
output_size);
}
yyjson_doc_free(preview_doc);
}
finish:
(void)sqlite3_finalize(statement);
free(preview);
yyjson_doc_free(request_doc);
if (status == TRAINLOG_STATUS_OK && commit) {
if (sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
return status;
}
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
if (status != TRAINLOG_STATUS_OK && *output_json == NULL) {
return planning_error("reschedule_unavailable", status, output_json, output_size);
}
return status;
}

View file

@ -365,10 +365,12 @@ static TrainlogStatus add_program_session_json(TrainlogDatabase *database,
!add_column_text(document, session, "title", statement, 2) ||
!add_column_text(document, session, "session_type", statement, 3) ||
!add_column_text(document, session, "planned_for", statement, 4) ||
!add_column_text(document, session, "note", statement, 5) ||
!add_column_text(document, session, "execution_state", statement, 6) ||
!add_column_text(document, session, "execution_session_id", statement, 7) ||
!add_column_text(document, session, "execution_started_at", statement, 8) ||
!add_column_text(document, session, "current_for", statement, 5) ||
!add_column_text(document, session, "planning_state", statement, 6) ||
!add_column_text(document, session, "note", statement, 7) ||
!add_column_text(document, session, "execution_state", statement, 8) ||
!add_column_text(document, session, "execution_session_id", statement, 9) ||
!add_column_text(document, session, "execution_started_at", statement, 10) ||
!yyjson_mut_obj_add_val(document, session, "occurrences", entries) ||
!yyjson_mut_arr_add_val(sessions, session)) {
return TRAINLOG_STATUS_DATABASE_ERROR;
@ -383,7 +385,9 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database,
yyjson_mut_val *sessions) {
static const char SESSION_SQL[] =
"SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for,"
"ps.note,CASE WHEN pe.state='completed' THEN 'completed' "
"CASE WHEN pp.state='ceded' THEN NULL ELSE COALESCE(pp.current_for,ps.planned_for) END,"
"COALESCE(pp.state,'active'),ps.note,"
"CASE WHEN pe.state='completed' THEN 'completed' "
"WHEN pe.state='in_progress' THEN 'in_progress' "
"WHEN pe.state='deleted' THEN 'deleted' "
"WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE "
@ -394,7 +398,9 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database,
* CONTRACT: expose that timestamp only for completed executions.
* INVARIANT: the imported planned_for date and history are read only. */
"CASE WHEN pe.state='completed' THEN actual.started_at ELSE NULL END "
"FROM program_sessions ps LEFT JOIN program_session_executions pe "
"FROM program_sessions ps LEFT JOIN program_session_planning pp "
"ON pp.program_session_id=ps.program_session_id "
"LEFT JOIN program_session_executions pe "
"ON pe.program_session_id=ps.program_session_id "
"LEFT JOIN sessions actual ON actual.session_id=pe.session_id "
"WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id";
@ -1165,7 +1171,25 @@ static TrainlogStatus program_replay_archive(TrainlogDatabase *database,
result = SQLITE_OK;
}
(void)sqlite3_finalize(statement);
return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
if (result != SQLITE_OK || *found) {
return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
}
statement = NULL;
result = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_reschedule_operations WHERE operation_id=?",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
(void)sqlite3_finalize(statement);
return result == SQLITE_ROW ? TRAINLOG_STATUS_CONFLICT
: result == SQLITE_DONE ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus program_apply_archive(TrainlogDatabase *database,
@ -1357,7 +1381,9 @@ static ProgramDeleteReplay program_replay_delete(TrainlogDatabase *database,
}
result = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_requests WHERE request_id=?1",
"SELECT 1 FROM program_requests WHERE request_id=?1 "
"UNION ALL SELECT 1 FROM program_reschedule_operations "
"WHERE operation_id=?1",
-1,
&statement,
NULL);
@ -1609,7 +1635,7 @@ static char *program_build_preparation_body(yyjson_val *session,
command,
root,
"planned_for",
yyjson_val_mut_copy(command, yyjson_obj_get(session, "planned_for")));
yyjson_val_mut_copy(command, yyjson_obj_get(session, "current_for")));
(void)yyjson_mut_obj_add_val(
command, root, "notes", yyjson_val_mut_copy(command, yyjson_obj_get(session, "note")));
(void)yyjson_mut_obj_add_strcpy(command, root, "editing_state", "draft");
@ -1652,6 +1678,10 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
yyjson_doc_free(document);
return TRAINLOG_STATUS_NOT_FOUND;
}
if (!yyjson_equals_str(yyjson_obj_get(session, "planning_state"), "active")) {
yyjson_doc_free(document);
return TRAINLOG_STATUS_CONFLICT;
}
body = program_build_preparation_body(session, program_id, program_session_id, &body_size);
yyjson_doc_free(document);
if (body == NULL) {

View file

@ -705,14 +705,22 @@ static enum MHD_Result queue_owned_programs_json(struct MHD_Connection *connecti
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
}
if (status == TRAINLOG_STATUS_CONFLICT) {
enum MHD_Result result =
queue_json(connection,
MHD_HTTP_CONFLICT,
json == NULL ? "{\"error\":\"program_conflict\"}\n" : json,
NULL);
free(json);
return queue_json(
connection, MHD_HTTP_CONFLICT, "{\"error\":\"program_conflict\"}\n", NULL);
return result;
}
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
enum MHD_Result result =
queue_json(connection,
MHD_HTTP_UNPROCESSABLE_CONTENT,
json == NULL ? "{\"error\":\"invalid_program\"}\n" : json,
NULL);
free(json);
return queue_json(
connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_program\"}\n", NULL);
return result;
}
return queue_owned_sessions_json(connection, status, json, json_size);
}
@ -930,6 +938,8 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
static const char PREFIX[] = "/api/v1/sessions/program/";
static const char ARCHIVE_SUFFIX[] = "/archive";
static const char DELETE_SUFFIX[] = "/delete";
static const char PREVIEW_SUFFIX[] = "/reschedule/preview";
static const char APPLY_SUFFIX[] = "/reschedule/apply";
const char *identity;
const char *sessions_marker;
char *json = NULL;
@ -950,6 +960,38 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
identity = url + strlen(PREFIX);
identity_length = strlen(identity);
sessions_marker = strstr(identity, "/sessions/");
if ((identity_length > strlen(PREVIEW_SUFFIX) &&
strcmp(identity + identity_length - strlen(PREVIEW_SUFFIX), PREVIEW_SUFFIX) == 0) ||
(identity_length > strlen(APPLY_SUFFIX) &&
strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0)) {
bool commit = strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0;
size_t suffix_length = commit ? strlen(APPLY_SUFFIX) : strlen(PREVIEW_SUFFIX);
char program_id[TRAINLOG_ID_MAX + 1U];
const char *operation_id =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"POST");
}
if (!program_mutation_allowed(context, connection) ||
!copy_path_component(
program_id, sizeof(program_id), identity, identity_length - suffix_length) ||
(commit && (operation_id == NULL || operation_id[0] == '\0'))) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
status = trainlog_web_programs_reschedule_json(context->database,
program_id,
operation_id,
request->body,
request->body_size,
commit,
&json,
&json_size);
return queue_owned_programs_json(connection, status, json, json_size);
}
if (identity_length > strlen(ARCHIVE_SUFFIX) &&
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
return handle_program_archive(context, connection, identity, identity_length, method);

View file

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

View file

@ -608,10 +608,343 @@ static bool legacy_integer_weight_zero_prepares_editable_draft(void) {
return true;
}
static bool ordered_reschedule_is_atomic_and_idempotent(void) {
static const char *const DATES[] = {
"2026-09-21", "2026-09-22", "2026-09-23", "2026-09-24", "2026-09-25", "2026-09-26",
"2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01", "2026-10-02", "2026-10-03",
"2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09", "2026-10-10",
"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17",
};
static const char REQUEST[] = "{\"start_session_id\":\"pgs_fixture_07\","
"\"through_session_id\":\"pgs_fixture_22\","
"\"start_date\":\"2026-10-01\","
"\"ceded_session_ids\":[\"pgs_fixture_11\",\"pgs_fixture_17\"],"
"\"expected_revision\":\"pgr_fixture\"}";
TrainlogDatabase *database = NULL;
char *preview = NULL;
char *applied = NULL;
char *replayed = NULL;
char *request = NULL;
size_t size = 0U;
size_t applied_size = 0U;
char fingerprint[80];
char sql[1024];
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO programs(program_id,title,state,start_date,end_date,"
"created_at,updated_at,revision_id,source_format,source_version,"
"source_payload_sha256) VALUES('pg_fixture','Fixture','active',"
"'2026-09-21','2026-10-18','2026-09-20T00:00:00Z',"
"'2026-09-20T00:00:00Z','pgr_fixture','trainlog-program',1,"
"'0000000000000000000000000000000000000000000000000000000000000000')",
NULL,
NULL,
NULL) == SQLITE_OK);
for (size_t index = 0U; index < 24U; ++index) {
(void)snprintf(sql,
sizeof(sql),
"INSERT INTO program_sessions VALUES('pgs_fixture_%02zu','pg_fixture',"
"%zu,'Session %zu','training','%s',NULL)",
index,
index,
index,
DATES[index]);
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
if (index < 7U) {
const char *actual = index == 5U ? "2026-09-28T09:00:00+02:00"
: index == 6U ? "2026-09-30T17:00:00+02:00"
: "2026-09-21T09:00:00+02:00";
(void)snprintf(sql,
sizeof(sql),
"INSERT INTO sessions(session_id,started_at) "
"VALUES('se_fixture_%02zu','%s')",
index,
actual);
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
(void)snprintf(sql,
sizeof(sql),
"INSERT INTO program_session_executions VALUES("
"'pgs_fixture_%02zu','pg_fixture','se_fixture_%02zu',"
"'completed','2026-09-30T00:00:00Z')",
index,
index);
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
}
}
CHECK(sqlite3_exec(database->connection,
"INSERT INTO sessions(session_id,started_at) "
"VALUES('se_evening_stretch','2026-10-01T20:00:00+02:00')",
NULL,
NULL,
NULL) == SQLITE_OK);
char *invalid_date = replace_once(REQUEST, "2026-10-01", "2026-02-30");
CHECK(invalid_date != NULL);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
NULL,
invalid_date,
strlen(invalid_date),
false,
&replayed,
&size) == TRAINLOG_STATUS_INVALID_ARGUMENT);
free(invalid_date);
free(replayed);
replayed = NULL;
char *too_late = replace_once(REQUEST, "2026-10-01", "2026-10-16");
CHECK(too_late != NULL);
CHECK(trainlog_web_programs_reschedule_json(
database, "pg_fixture", NULL, too_late, strlen(too_late), false, &replayed, &size) ==
TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(strstr(replayed, "insufficient_or_duplicate_slots") != NULL);
free(too_late);
free(replayed);
replayed = NULL;
// The DST transition on October 25 is a rest day in this fixture.
CHECK(sqlite3_exec(database->connection,
"INSERT INTO programs(program_id,title,state,start_date,end_date,"
"created_at,updated_at,revision_id,source_format,source_version,"
"source_payload_sha256) VALUES('pg_dst','DST','active','2026-10-24',"
"'2026-10-27','2026-10-20T00:00:00Z','2026-10-20T00:00:00Z',"
"'pgr_dst','trainlog-program',1,"
"'0000000000000000000000000000000000000000000000000000000000000000');"
"INSERT INTO program_sessions VALUES"
"('pgs_dst_0','pg_dst',0,'Before','training','2026-10-24',NULL),"
"('pgs_dst_1','pg_dst',1,'After','training','2026-10-26',NULL),"
"('pgs_dst_2','pg_dst',2,'Next','training','2026-10-27',NULL)",
NULL,
NULL,
NULL) == SQLITE_OK);
static const char DST_REQUEST[] =
"{\"start_session_id\":\"pgs_dst_0\",\"through_session_id\":\"pgs_dst_2\","
"\"start_date\":\"2026-10-24\",\"ceded_session_ids\":[],"
"\"expected_revision\":\"pgr_dst\"}";
CHECK(
trainlog_web_programs_reschedule_json(
database, "pg_dst", NULL, DST_REQUEST, strlen(DST_REQUEST), false, &replayed, &size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(replayed, "\"new_for\":\"2026-10-26\"") != NULL);
CHECK(strstr(replayed, "2026-10-25") == NULL);
free(replayed);
replayed = NULL;
CHECK(trainlog_web_programs_reschedule_json(
database, "pg_fixture", NULL, REQUEST, strlen(REQUEST), false, &preview, &size) ==
TRAINLOG_STATUS_OK);
CHECK(extract_string(preview, "preview_sha256", fingerprint, sizeof(fingerprint)));
CHECK(strstr(preview,
"\"program_session_id\":\"pgs_fixture_07\","
"\"original_for\":\"2026-09-29\","
"\"old_for\":\"2026-09-29\","
"\"new_for\":\"2026-10-01\"") != NULL);
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 0);
request = malloc(strlen(REQUEST) + strlen(fingerprint) + 32U);
CHECK(request != NULL);
(void)snprintf(request,
strlen(REQUEST) + strlen(fingerprint) + 32U,
"%.*s,\"preview_sha256\":\"%s\"}",
(int)strlen(REQUEST) - 1,
REQUEST,
fingerprint);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO sync_generations(generation_id,run_id,producer_peer_id,"
"consumer_peer_id,producer_kind,generated_at,manifest_sha256,status,"
"manifest_json,staging_path) VALUES('gen_fixture','sy_fixture',"
"'peer_desktop','peer_phone','desktop',"
"strftime('%Y-%m-%dT%H:%M:%SZ','now'),'digest','captured','{}','/tmp')",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO sync_generation_artifacts VALUES('gen_fixture','programs-v2',"
"'trainlog-programs',2,'programs-v2.json',0,'digest',0)",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
"op_fixture",
request,
strlen(request),
true,
&replayed,
&size) == TRAINLOG_STATUS_CONFLICT);
CHECK(strstr(replayed, "program_peer_not_fresh") != NULL);
free(replayed);
replayed = NULL;
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 0);
CHECK(sqlite3_exec(database->connection,
"UPDATE sync_generations SET status='acknowledged',"
"acknowledged_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') "
"WHERE generation_id='gen_fixture'",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
"op_fixture",
request,
strlen(request),
true,
&applied,
&applied_size) == TRAINLOG_STATUS_OK);
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 11);
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning WHERE state='ceded'") ==
2);
CHECK(scalar(database,
"SELECT COUNT(DISTINCT current_for) FROM program_session_planning "
"WHERE state='active'") == 9);
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_session_executions "
"WHERE state='completed'") == 7);
CHECK(scalar(database,
"SELECT COUNT(*) FROM sessions WHERE "
"session_id='se_fixture_05' AND started_at='2026-09-28T09:00:00+02:00'") == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM sessions WHERE "
"session_id='se_fixture_06' AND started_at='2026-09-30T17:00:00+02:00'") == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM sessions WHERE "
"session_id='se_evening_stretch' AND started_at='2026-10-01T20:00:00+02:00'") ==
1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_sessions WHERE "
"planned_for='2026-09-29' AND program_session_id='pgs_fixture_07'") == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_session_planning WHERE "
"program_session_id='pgs_fixture_09' AND current_for='2026-10-03'") == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_session_planning WHERE "
"program_session_id='pgs_fixture_22'") == 0);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
"op_fixture",
request,
strlen(request),
true,
&replayed,
&size) == TRAINLOG_STATUS_OK);
CHECK(strcmp(applied, replayed) == 0);
CHECK(scalar(database, "SELECT COUNT(*) FROM program_reschedule_operations") == 1);
free(replayed);
replayed = NULL;
char *different_request = replace_once(request, "2026-10-01", "2026-10-02");
CHECK(different_request != NULL);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
"op_fixture",
different_request,
strlen(different_request),
true,
&replayed,
&size) == TRAINLOG_STATUS_CONFLICT);
free(different_request);
free(replayed);
replayed = NULL;
char new_revision[80];
char next_fingerprint[80];
CHECK(extract_string(applied, "new_revision_id", new_revision, sizeof(new_revision)));
CHECK(scalar(database,
"SELECT revision_sequence FROM program_revision_sequences "
"WHERE program_id='pg_fixture'") == 1);
char second_choice[512];
(void)snprintf(second_choice,
sizeof(second_choice),
"{\"start_session_id\":\"pgs_fixture_07\","
"\"through_session_id\":\"pgs_fixture_09\","
"\"start_date\":\"2026-10-01\",\"ceded_session_ids\":[],"
"\"expected_revision\":\"%s\"}",
new_revision);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
NULL,
second_choice,
strlen(second_choice),
false,
&replayed,
&size) == TRAINLOG_STATUS_OK);
CHECK(strstr(replayed,
"\"program_session_id\":\"pgs_fixture_09\","
"\"original_for\":\"2026-10-01\","
"\"old_for\":\"2026-10-03\","
"\"new_for\":\"2026-10-03\"") != NULL);
free(replayed);
replayed = NULL;
char *next_choice = replace_once(REQUEST, "pgr_fixture", new_revision);
CHECK(next_choice != NULL);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
NULL,
next_choice,
strlen(next_choice),
false,
&replayed,
&size) == TRAINLOG_STATUS_OK);
CHECK(extract_string(replayed, "preview_sha256", next_fingerprint, sizeof(next_fingerprint)));
free(replayed);
replayed = NULL;
char *next_request = malloc(strlen(next_choice) + strlen(next_fingerprint) + 32U);
CHECK(next_request != NULL);
(void)snprintf(next_request,
strlen(next_choice) + strlen(next_fingerprint) + 32U,
"%.*s,\"preview_sha256\":\"%s\"}",
(int)strlen(next_choice) - 1,
next_choice,
next_fingerprint);
CHECK(sqlite3_exec(database->connection,
"CREATE TRIGGER fail_reschedule_fixture BEFORE INSERT ON "
"program_reschedule_operations WHEN NEW.operation_id='op_failure' "
"BEGIN SELECT RAISE(ABORT,'fixture failure'); END",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
"op_failure",
next_request,
strlen(next_request),
true,
&replayed,
&size) == TRAINLOG_STATUS_DATABASE_ERROR);
free(replayed);
replayed = NULL;
CHECK(scalar(database, "SELECT COUNT(*) FROM program_reschedule_operations") == 1);
(void)snprintf(sql,
sizeof(sql),
"SELECT COUNT(*) FROM programs WHERE program_id='pg_fixture' "
"AND revision_id='%s'",
new_revision);
CHECK(scalar(database, sql) == 1);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_executions VALUES("
"'pgs_fixture_07','pg_fixture','se_late','completed',"
"'2026-10-01T10:00:00Z')",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
"op_stale",
next_request,
strlen(next_request),
true,
&replayed,
&size) == TRAINLOG_STATUS_CONFLICT);
CHECK(strstr(replayed, "prepared_or_executed_session") != NULL);
free(replayed);
free(next_request);
free(next_choice);
free(applied);
free(preview);
free(request);
trainlog_database_close(database);
return true;
}
int main(void) {
return import_archive_and_prepare() && strict_import_rejections() &&
mixed_profile_prepare_regression() &&
legacy_integer_weight_zero_prepares_editable_draft()
legacy_integer_weight_zero_prepares_editable_draft() &&
ordered_reschedule_is_atomic_and_idempotent()
? EXIT_SUCCESS
: EXIT_FAILURE;
}

View file

@ -38,6 +38,8 @@ export interface ProgramSession {
title: string
session_type: 'training' | 'max_test'
planned_for: string | null
current_for: string | null
planning_state: 'active' | 'ceded'
note: string | null
execution_state: 'todo' | 'prepared' | 'in_progress' | 'completed' | 'deleted'
execution_session_id: string | null
@ -62,6 +64,30 @@ export interface ProgramDetail {
sessions: ProgramSession[]
}
export interface RescheduleChoice {
start_session_id: string
through_session_id: string
start_date: string
ceded_session_ids: string[]
expected_revision: string
}
export interface RescheduleMove {
program_session_id: string
original_for: string | null
old_for: string | null
new_for: string | null
change: 'rescheduled' | 'ceded' | 'unchanged'
}
export interface ReschedulePreview {
api_version: 1
program_id: string
revision_id: string
preview_sha256: string
moves: RescheduleMove[]
}
export interface ProgramImportPreview {
api_version: 1
program_id: string
@ -106,6 +132,47 @@ function requestId(prefix = 'web'): string {
return `${prefix}_${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`
}
export function newRescheduleOperationId(): string {
return requestId('pr')
}
async function rescheduleRequest(
programId: string,
mode: 'preview' | 'apply',
choice: RescheduleChoice & { preview_sha256?: string },
operationId?: string,
): Promise<ReschedulePreview> {
const csrf = await mutationCsrfToken()
const response = await fetch(
`/api/v1/sessions/program/${encodeURIComponent(programId)}/reschedule/${mode}`,
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf,
'X-Trainlog-Request-ID': operationId ?? requestId('pr'),
},
body: JSON.stringify(choice),
},
)
if (!response.ok) throw new Error(await errorReason(response))
const value: unknown = await response.json()
if (!object(value) || value.api_version !== 1 || value.program_id !== programId ||
typeof value.revision_id !== 'string' || typeof value.preview_sha256 !== 'string' ||
!Array.isArray(value.moves)) throw new TypeError('aperçu de recalage invalide')
return value as unknown as ReschedulePreview
}
export function previewProgramReschedule(programId: string, choice: RescheduleChoice) {
return rescheduleRequest(programId, 'preview', choice)
}
export function applyProgramReschedule(
programId: string, choice: RescheduleChoice, previewSha256: string, operationId: string,
) {
return rescheduleRequest(programId, 'apply', { ...choice, preview_sha256: previewSha256 }, operationId)
}
export async function fetchAllPrograms(
search = '',
state = '',

View file

@ -9,6 +9,8 @@ const api = vi.hoisted(() => ({
fetchAllPrograms: vi.fn(),
fetchProgram: vi.fn(),
createPreparationFromProgram: vi.fn(),
previewProgramReschedule: vi.fn(),
applyProgramReschedule: vi.fn(),
}))
vi.mock('../api/programs', async (importOriginal) => ({
@ -45,6 +47,8 @@ function session(
title,
session_type: 'training',
planned_for: plannedFor,
current_for: plannedFor,
planning_state: 'active',
note: null,
execution_state: executionState,
execution_session_id: null,
@ -88,6 +92,8 @@ describe('ProgramsCalendarPage', () => {
api.fetchAllPrograms.mockReset()
api.fetchProgram.mockReset()
api.createPreparationFromProgram.mockReset()
api.previewProgramReschedule.mockReset()
api.applyProgramReschedule.mockReset()
api.fetchAllPrograms.mockResolvedValue([item()])
api.fetchProgram.mockResolvedValue(detail())
})
@ -101,6 +107,53 @@ describe('ProgramsCalendarPage', () => {
expect(screen.queryByRole('combobox', { name: 'Programme actif' })).not.toBeInTheDocument()
})
it('keeps distinct cards on one date and labels a ceded slot separately', async () => {
const main = session('pgs_main', 'Séance principale', '2026-09-18')
const evening = session('pgs_evening', 'Étirements du soir', '2026-09-18')
const ceded = { ...session('pgs_ceded', 'Séance écartée', '2026-09-19'),
current_for: null, planning_state: 'ceded' as const }
api.fetchProgram.mockResolvedValue(detail({ sessions: [main, evening, ceded] }))
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
const friday = await screen.findByLabelText('Ven 18/09/2026')
expect(within(friday).getAllByRole('article')).toHaveLength(2)
expect(within(friday).getByText('Séance principale')).toBeInTheDocument()
expect(within(friday).getByText('Étirements du soir')).toBeInTheDocument()
const saturday = screen.getByLabelText('Sam 19/09/2026')
expect(within(saturday).getByText('Créneau cédé')).toBeInTheDocument()
expect(within(saturday).queryByRole('button', { name: 'Préparer' })).not.toBeInTheDocument()
})
it('previews ordered changes before one confirmation', async () => {
const before = detail({ sessions: [
session('pgs_b', 'B', '2026-09-18'),
session('pgs_c', 'C', '2026-09-19'),
] })
api.fetchProgram.mockResolvedValue(before)
api.previewProgramReschedule.mockResolvedValue({
api_version: 1, program_id: 'pg_one', revision_id: 'pgr_one',
preview_sha256: 'f'.repeat(64), moves: [
{ program_session_id: 'pgs_b', original_for: '2026-09-18',
old_for: '2026-09-18', new_for: '2026-09-19', change: 'rescheduled' },
{ program_session_id: 'pgs_c', original_for: '2026-09-19',
old_for: '2026-09-19', new_for: null, change: 'ceded' },
],
})
api.applyProgramReschedule.mockResolvedValue({})
render(<ProgramsCalendarPage onNavigate={vi.fn()} />)
await screen.findByRole('heading', { level: 1, name: 'Cycle force' })
fireEvent.change(screen.getByLabelText('Première séance'), { target: { value: 'pgs_b' } })
fireEvent.change(screen.getByLabelText('Dernière séance'), { target: { value: 'pgs_c' } })
fireEvent.change(screen.getByLabelText('Date de reprise'), { target: { value: '2026-09-19' } })
fireEvent.click(screen.getByLabelText(/C \(2026-09-19\)/))
fireEvent.click(screen.getByRole('button', { name: 'Calculer l’aperçu' }))
expect(await screen.findByRole('heading', { name: 'Aperçu avant application' })).toBeInTheDocument()
expect(api.applyProgramReschedule).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Confirmer ce recalage' }))
await waitFor(() => expect(api.applyProgramReschedule).toHaveBeenCalledTimes(1))
expect(api.applyProgramReschedule.mock.calls[0][2]).toBe('f'.repeat(64))
})
it('shows the exact empty state and navigates to Sessions program administration', async () => {
api.fetchAllPrograms.mockResolvedValue([])
const onNavigate = vi.fn()

View file

@ -1,11 +1,16 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
createPreparationFromProgram,
applyProgramReschedule,
fetchAllPrograms,
fetchProgram,
newRescheduleOperationId,
previewProgramReschedule,
type ProgramDetail,
type ProgramListItem,
type ProgramSession,
type RescheduleChoice,
type ReschedulePreview,
} from '../api/programs'
import { useDatePreferences } from '../presentation/DatePreferences'
import { formatCivilDate, formatDate, parseCivilDate, validTimestampValue } from '../presentation/dateFormat'
@ -59,7 +64,8 @@ function displayedDate(session: ProgramSession): string | null {
// A completed link without a valid history timestamp must not masquerade as
// a workout performed on the imported plan date.
return session.execution_state === 'completed'
? actualExecutionDate(session) : session.planned_for
? actualExecutionDate(session)
: session.planning_state === 'ceded' ? session.planned_for : session.current_for
}
function calendarDays(program: ProgramDetail): CalendarDay[] {
@ -126,7 +132,13 @@ function ProgramSessionCard({ programId, session, pending, preparationId, onPrep
const actualDate = actualExecutionDate(session)
return <article className={`program-calendar-session program-calendar-state-${session.execution_state}`}>
<h3>{session.title}</h3>
<span className="program-calendar-state-label">{stateLabels[session.execution_state]}</span>
<span className="program-calendar-state-label">{session.planning_state === 'ceded' &&
session.execution_state !== 'completed' ? 'Créneau cédé' : stateLabels[session.execution_state]}</span>
{session.planning_state === 'active' && session.current_for !== session.planned_for &&
session.execution_state !== 'completed' && <p className="program-calendar-date-note">
Recalée du {session.planned_for === null ? 'jour initial non défini' : formatCivilDate(session.planned_for, dateFormat)}
{' au '}{session.current_for === null ? 'jour non défini' : formatCivilDate(session.current_for, dateFormat)}
</p>}
{actualDate !== null && actualDate !== session.planned_for && <p className="program-calendar-date-note">
Prévue le {session.planned_for === null ? 'date non définie' : formatCivilDate(session.planned_for, dateFormat)}
{' · '}effectuée le {formatCivilDate(actualDate, dateFormat)}
@ -134,7 +146,8 @@ function ProgramSessionCard({ programId, session, pending, preparationId, onPrep
{session.execution_state === 'completed' && actualDate === null &&
<p className="program-calendar-date-note">Date réelle indisponible</p>}
<div className="program-calendar-action">
{session.execution_state === 'todo' && <button type="button" disabled={pending}
{session.execution_state === 'todo' && session.planning_state === 'active' &&
<button type="button" disabled={pending}
onClick={() => onPrepare(programId, session)}>
{pending ? 'Préparation…' : 'Préparer'}
</button>}
@ -156,6 +169,14 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
const [pendingSessions, setPendingSessions] = useState<Set<string>>(new Set())
const [preparationIds, setPreparationIds] = useState<Record<string, string>>({})
const [announcement, setAnnouncement] = useState('')
const [rescheduleStart, setRescheduleStart] = useState('')
const [rescheduleThrough, setRescheduleThrough] = useState('')
const [rescheduleDate, setRescheduleDate] = useState('')
const [cededIds, setCededIds] = useState<string[]>([])
const [reschedulePreview, setReschedulePreview] = useState<{
choice: RescheduleChoice, result: ReschedulePreview, operationId: string
} | null>(null)
const [reschedulePending, setReschedulePending] = useState(false)
const preparing = useRef(new Set<string>())
const mounted = useRef(true)
const selectedIdRef = useRef('')
@ -200,6 +221,11 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
setDetailPending(true)
setDetailError('')
setAnnouncement('')
setReschedulePreview(null)
setRescheduleStart('')
setRescheduleThrough('')
setRescheduleDate('')
setCededIds([])
setPreparationIds({})
fetchProgram(selectedId, controller.signal).then((value) => {
if (isCurrent()) setDetail(value)
@ -215,6 +241,54 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
const days = useMemo(() => detail === null ? [] : calendarDays(detail), [detail])
const undatedSessions = detail?.sessions.filter((session) => dateValue(displayedDate(session)) === null) ?? []
const openSessions = detail?.sessions.filter((session) => session.execution_state === 'todo') ?? []
const previewReschedule = async () => {
if (!detail || !rescheduleStart || !rescheduleThrough || !rescheduleDate) return
const programId = detail.program_id
const generation = selectionGeneration.current
const choice: RescheduleChoice = {
start_session_id: rescheduleStart,
through_session_id: rescheduleThrough,
start_date: rescheduleDate,
ceded_session_ids: cededIds,
expected_revision: detail.revision_id,
}
setReschedulePending(true)
setReschedulePreview(null)
try {
const result = await previewProgramReschedule(programId, choice)
if (mounted.current && selectionGeneration.current === generation) {
setReschedulePreview({ choice, result, operationId: newRescheduleOperationId() })
setAnnouncement('Aperçu calculé. Vérifiez les dates avant de confirmer.')
}
} catch (reason) {
setAnnouncement(`Recalage impossible : ${errorMessage(reason, 'erreur inconnue')}`)
} finally {
setReschedulePending(false)
}
}
const confirmReschedule = async () => {
if (!detail || !reschedulePreview) return
const { choice, result, operationId } = reschedulePreview
const programId = detail.program_id
const generation = selectionGeneration.current
setReschedulePending(true)
try {
await applyProgramReschedule(programId, choice, result.preview_sha256, operationId)
const reread = await fetchProgram(programId)
if (mounted.current && selectionGeneration.current === generation) {
setDetail(reread)
setReschedulePreview(null)
setAnnouncement('Recalage enregistré. La synchronisation Android reste à vérifier.')
}
} catch (reason) {
setAnnouncement(`Recalage non confirmé : ${errorMessage(reason, 'erreur inconnue')}`)
} finally {
setReschedulePending(false)
}
}
const prepare = async (programId: string, session: ProgramSession) => {
const sessionId = session.program_session_id
@ -316,6 +390,54 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
</ul>
</header>
<p className="program-calendar-announcement" aria-live="polite">{announcement}</p>
<section className="program-calendar-reschedule" aria-label="Recaler le programme">
<h2>Recaler la suite</h2>
<p>Choisissez les séances concernées dans leur ordre, la reprise et les créneaux cédés.
L’aperçu utilise les dates de créneaux déjà inscrites au programme.</p>
<div className="program-calendar-reschedule-fields">
<label>Première séance<select value={rescheduleStart} onChange={(event) => {
setRescheduleStart(event.target.value); setReschedulePreview(null)
}}><option value="">Choisir</option>{openSessions.map((session) =>
<option key={session.program_session_id} value={session.program_session_id}>
Début : {session.title}</option>)}</select></label>
<label>Dernière séance<select value={rescheduleThrough} onChange={(event) => {
setRescheduleThrough(event.target.value); setReschedulePreview(null)
}}><option value="">Choisir</option>{openSessions.map((session) =>
<option key={session.program_session_id} value={session.program_session_id}>
Fin : {session.title}</option>)}</select></label>
<label>Date de reprise<input type="date" value={rescheduleDate}
min={detail.start_date ?? undefined} max={detail.end_date ?? undefined}
onChange={(event) => { setRescheduleDate(event.target.value); setReschedulePreview(null) }} /></label>
</div>
<fieldset><legend>Séances qui cèdent leur créneau</legend>
{openSessions.map((session) => <label key={session.program_session_id}>
<input type="checkbox" checked={cededIds.includes(session.program_session_id)}
onChange={(event) => {
setCededIds((current) => event.target.checked
? [...current, session.program_session_id]
: current.filter((id) => id !== session.program_session_id))
setReschedulePreview(null)
}} />{session.title} ({session.planned_for ?? 'sans date'})
</label>)}
</fieldset>
<button type="button" disabled={reschedulePending || !rescheduleStart ||
!rescheduleThrough || !rescheduleDate} onClick={previewReschedule}>Calculer l’aperçu</button>
{reschedulePreview && <div className="program-calendar-reschedule-preview">
<h3>Aperçu avant application</h3>
<table><thead><tr><th>Séance</th><th>Date initiale</th><th>Date actuelle</th>
<th>Nouvelle date</th><th>Changement</th></tr></thead><tbody>
{reschedulePreview.result.moves.map((move) => <tr key={move.program_session_id}>
<td>{detail.sessions.find((session) => session.program_session_id === move.program_session_id)?.title}</td>
<td>{move.original_for ?? '—'}</td><td>{move.old_for ?? '—'}</td>
<td>{move.new_for ?? '—'}</td>
<td>{move.change === 'ceded' ? 'Créneau cédé'
: move.change === 'unchanged' ? 'Inchangée' : 'Recalée'}</td>
</tr>)}
</tbody></table>
<button type="button" disabled={reschedulePending} onClick={confirmReschedule}>
Confirmer ce recalage</button>
</div>}
</section>
{days.length > 0 && <div className="program-calendar-scroll" tabIndex={0}
aria-label="Calendrier hebdomadaire du programme">
<div className="program-calendar-grid">

View file

@ -1978,6 +1978,43 @@ h2 {
color: var(--subtext-0);
font-size: var(--font-size-sm);
}
.program-calendar-reschedule {
margin: 0 0 1rem;
padding: 1rem;
border: 1px solid var(--surface-1);
border-radius: 0.75rem;
}
.program-calendar-reschedule-fields {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.program-calendar-reschedule-fields label {
display: grid;
gap: 0.25rem;
min-width: min(100%, 12rem);
}
.program-calendar-reschedule fieldset {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
margin: 0.75rem 0;
}
.program-calendar-reschedule-preview {
max-width: 100%;
overflow-x: auto;
margin-top: 1rem;
}
.program-calendar-reschedule-preview table {
width: 100%;
border-collapse: collapse;
}
.program-calendar-reschedule-preview th,
.program-calendar-reschedule-preview td {
padding: 0.35rem;
text-align: left;
border-bottom: 1px solid var(--surface-1);
}
.program-calendar-scroll {
max-width: 100%;
overflow-x: auto;