fix(program): add durable ordered reschedule candidate
This commit is contained in:
parent
c17394ece2
commit
6c0e7af2f0
80 changed files with 2032 additions and 132 deletions
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
## Unreleased — 0.1.7
|
## 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
|
Development opened after the stable v0.1.6 release. This line is restricted to
|
||||||
observed bug fixes, regression fixes, directly related small UI corrections,
|
observed bug fixes, regression fixes, directly related small UI corrections,
|
||||||
documentation corrections, robustness, and associated tests. It does not admit
|
documentation corrections, robustness, and associated tests. It does not admit
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
||||||
manifest.getInt("version") == VERSION &&
|
manifest.getInt("version") == VERSION &&
|
||||||
manifest.getString("package") == context.packageName &&
|
manifest.getString("package") == context.packageName &&
|
||||||
manifest.getBoolean("plaintext") &&
|
manifest.getBoolean("plaintext") &&
|
||||||
manifest.getInt("schema") in 17..33
|
manifest.getInt("schema") in 17..34
|
||||||
) {
|
) {
|
||||||
"Unsupported backup."
|
"Unsupported backup."
|
||||||
}
|
}
|
||||||
|
|
@ -319,7 +319,7 @@ internal class AndroidBackupService(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||||
require(db.version in 17..33) { "Unsupported Android schema ${db.version}." }
|
require(db.version in 17..34) { "Unsupported Android schema ${db.version}." }
|
||||||
return db.version
|
return db.version
|
||||||
} finally {
|
} finally {
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,7 @@ internal class SyncGenerationCoordinator(
|
||||||
ACK_RECOVERY_CAPABILITY,
|
ACK_RECOVERY_CAPABILITY,
|
||||||
"session-preparations-v2",
|
"session-preparations-v2",
|
||||||
"programs-v1",
|
"programs-v1",
|
||||||
|
"programs-v2",
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
(ANDROID_KINDS.values +
|
(ANDROID_KINDS.values +
|
||||||
Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
|
Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
|
||||||
Kind("trainlog-session-preparations", 2, "session-preparations-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 }
|
.map { it.format to it.version }
|
||||||
.toSet()
|
.toSet()
|
||||||
|
|
||||||
|
|
@ -773,6 +774,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
throw SyncGenerationException("artifact digest mismatch")
|
throw SyncGenerationException("artifact digest mismatch")
|
||||||
artifacts[item.getString("logical_name")] = bytes.toString(StandardCharsets.UTF_8)
|
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)
|
return ValidatedGeneration(manifest, sha256(raw), artifacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -947,6 +950,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
apply("programs-v1") {
|
apply("programs-v1") {
|
||||||
repository.applyProgramsV1Json(checkNotNull(a["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("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) }
|
||||||
apply("exercise-profile-state") {
|
apply("exercise-profile-state") {
|
||||||
repository.applyExerciseProfileStateJson(
|
repository.applyExerciseProfileStateJson(
|
||||||
|
|
|
||||||
|
|
@ -11825,7 +11825,11 @@ class TrainlogRepository(
|
||||||
* explicit tombstones are applied before live rows. INVARIANT: a durable tombstone always
|
* 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.
|
* 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 ||
|
if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 ||
|
||||||
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
|
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
|
||||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||||
|
|
@ -11853,7 +11857,7 @@ class TrainlogRepository(
|
||||||
try {
|
try {
|
||||||
if (!root.exact("format", "version", "generated_at", "programs", "deletions") ||
|
if (!root.exact("format", "version", "generated_at", "programs", "deletions") ||
|
||||||
root.optString("format") != "trainlog-programs" || root.opt("version") !is Int ||
|
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)
|
root.opt("programs") !is JSONArray || root.opt("deletions") !is JSONArray)
|
||||||
throw ProgramsImportInvalid("Unsupported Programs document.")
|
throw ProgramsImportInvalid("Unsupported Programs document.")
|
||||||
val programs = root.getJSONArray("programs")
|
val programs = root.getJSONArray("programs")
|
||||||
|
|
@ -11880,11 +11884,16 @@ class TrainlogRepository(
|
||||||
for (programIndex in 0 until programs.length()) {
|
for (programIndex in 0 until programs.length()) {
|
||||||
val program = programs.optJSONObject(programIndex)
|
val program = programs.optJSONObject(programIndex)
|
||||||
?: throw ProgramsImportInvalid("Invalid program.")
|
?: 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",
|
"program_id", "revision_id", "title", "note", "state", "start_date",
|
||||||
"end_date", "created_at", "updated_at", "source_format", "source_version",
|
"end_date", "created_at", "updated_at", "source_format", "source_version",
|
||||||
"source_payload_sha256", "sessions",
|
"source_payload_sha256", "sessions",
|
||||||
) ||
|
)
|
||||||
|
if (!programKeysValid ||
|
||||||
!bounded(program.opt("program_id"), 128) || !programIds.add(program.getString("program_id")) ||
|
!bounded(program.opt("program_id"), 128) || !programIds.add(program.getString("program_id")) ||
|
||||||
!bounded(program.opt("revision_id"), 128) || !bounded(program.opt("title"), 200) ||
|
!bounded(program.opt("revision_id"), 128) || !bounded(program.opt("title"), 200) ||
|
||||||
!nullableBounded(program.opt("note"), 4000) || program.optString("state") !in setOf("active", "archived") ||
|
!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("source_payload_sha256") as? String)?.matches(Regex("^[0-9a-f]{64}$")) != true ||
|
||||||
program.opt("sessions") !is JSONArray)
|
program.opt("sessions") !is JSONArray)
|
||||||
throw ProgramsImportInvalid("Invalid program metadata.")
|
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")
|
val sessions = program.getJSONArray("sessions")
|
||||||
if (sessions.length() !in 1..64) throw ProgramsImportInvalid("Invalid program session count.")
|
if (sessions.length() !in 1..64) throw ProgramsImportInvalid("Invalid program session count.")
|
||||||
for (sessionIndex in 0 until sessions.length()) {
|
for (sessionIndex in 0 until sessions.length()) {
|
||||||
val session = sessions.optJSONObject(sessionIndex)
|
val session = sessions.optJSONObject(sessionIndex)
|
||||||
?: throw ProgramsImportInvalid("Invalid program session.")
|
?: 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("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") ||
|
!bounded(session.opt("title"), 200) || session.optString("session_type") !in setOf("training", "max_test") ||
|
||||||
!date(session.opt("planned_for")) || !nullableBounded(session.opt("note"), 4000) ||
|
!date(session.opt("planned_for")) || !nullableBounded(session.opt("note"), 4000) ||
|
||||||
session.opt("occurrences") !is JSONArray)
|
session.opt("occurrences") !is JSONArray)
|
||||||
throw ProgramsImportInvalid("Invalid program session metadata.")
|
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")
|
val occurrences = session.getJSONArray("occurrences")
|
||||||
if (occurrences.length() !in 1..64) throw ProgramsImportInvalid("Invalid program occurrence count.")
|
if (occurrences.length() !in 1..64) throw ProgramsImportInvalid("Invalid program occurrence count.")
|
||||||
val entryIds = mutableSetOf<String>()
|
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
|
val db = database.writableDatabase
|
||||||
db.beginTransaction()
|
db.beginTransaction()
|
||||||
try {
|
try {
|
||||||
|
|
@ -11962,28 +11993,86 @@ class TrainlogRepository(
|
||||||
.use { it.moveToFirst() }
|
.use { it.moveToFirst() }
|
||||||
if (tombstoned) { skipped++; continue }
|
if (tombstoned) { skipped++; continue }
|
||||||
val digest = causalDigest(program)
|
val digest = causalDigest(program)
|
||||||
val existing = db.rawQuery("SELECT revision_id,snapshot_sha256 FROM synced_programs WHERE program_id=?", arrayOf(id))
|
val existing = db.rawQuery(
|
||||||
.use { cursor -> if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1) }
|
"SELECT revision_id,snapshot_sha256,projection_version,revision_sequence " +
|
||||||
if (existing?.first == program.getString("revision_id")) {
|
"FROM synced_programs WHERE program_id=?",
|
||||||
if (existing.second != digest) throw ProgramsImportInvalid("Program revision identity conflict.")
|
arrayOf(id),
|
||||||
skipped++
|
).use { cursor ->
|
||||||
continue
|
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.delete("synced_programs", "program_id=?", arrayOf(id))
|
||||||
db.execSQL(
|
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"),
|
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("state"), program.optStringOrNull("start_date"), program.optStringOrNull("end_date"),
|
||||||
program.getString("created_at"), program.getString("updated_at"), program.getString("source_format"),
|
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")
|
val sessions = program.getJSONArray("sessions")
|
||||||
for (sessionIndex in 0 until sessions.length()) {
|
for (sessionIndex in 0 until sessions.length()) {
|
||||||
val session = sessions.getJSONObject(sessionIndex)
|
val session = sessions.getJSONObject(sessionIndex)
|
||||||
val sessionId = session.getString("program_session_id")
|
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"),
|
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")
|
val entries = session.getJSONArray("occurrences")
|
||||||
for (entryIndex in 0 until entries.length()) {
|
for (entryIndex in 0 until entries.length()) {
|
||||||
val entry = entries.getJSONObject(entryIndex)
|
val entry = entries.getJSONObject(entryIndex)
|
||||||
|
|
@ -12052,7 +12141,7 @@ class TrainlogRepository(
|
||||||
} ?: return null
|
} ?: return null
|
||||||
val sessions = mutableListOf<SyncedProgramSession>()
|
val sessions = mutableListOf<SyncedProgramSession>()
|
||||||
db.rawQuery(
|
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=? " +
|
"FROM synced_program_sessions WHERE program_id=? " +
|
||||||
"ORDER BY position,program_session_id",
|
"ORDER BY position,program_session_id",
|
||||||
arrayOf(programId),
|
arrayOf(programId),
|
||||||
|
|
@ -12095,6 +12184,8 @@ class TrainlogRepository(
|
||||||
sessionCursor.getString(1),
|
sessionCursor.getString(1),
|
||||||
sessionCursor.getString(2),
|
sessionCursor.getString(2),
|
||||||
if (sessionCursor.isNull(3)) null else sessionCursor.getString(3),
|
if (sessionCursor.isNull(3)) null else sessionCursor.getString(3),
|
||||||
|
if (sessionCursor.isNull(5)) null else sessionCursor.getString(5),
|
||||||
|
sessionCursor.getString(6),
|
||||||
if (sessionCursor.isNull(4)) null else sessionCursor.getString(4),
|
if (sessionCursor.isNull(4)) null else sessionCursor.getString(4),
|
||||||
execution.first,
|
execution.first,
|
||||||
execution.second,
|
execution.second,
|
||||||
|
|
@ -12160,7 +12251,8 @@ class TrainlogRepository(
|
||||||
val available = db.rawQuery(
|
val available = db.rawQuery(
|
||||||
"SELECT s.session_type FROM synced_program_sessions s " +
|
"SELECT s.session_type FROM synced_program_sessions s " +
|
||||||
"JOIN synced_programs p ON p.program_id=s.program_id " +
|
"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),
|
arrayOf(programId, programSessionId),
|
||||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||||
?: return StartProgramSessionResult.NotAvailable
|
?: return StartProgramSessionResult.NotAvailable
|
||||||
|
|
@ -12454,7 +12546,7 @@ private class TrainlogDatabaseHelper(
|
||||||
appContext,
|
appContext,
|
||||||
databaseName,
|
databaseName,
|
||||||
null,
|
null,
|
||||||
33,
|
34,
|
||||||
) {
|
) {
|
||||||
override fun onConfigure(
|
override fun onConfigure(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -12808,6 +12900,34 @@ private class TrainlogDatabaseHelper(
|
||||||
createSleepImmutabilityTriggers(db)
|
createSleepImmutabilityTriggers(db)
|
||||||
version = 33
|
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) {
|
if (version != newVersion) {
|
||||||
error(
|
error(
|
||||||
|
|
@ -12824,7 +12944,9 @@ private class TrainlogDatabaseHelper(
|
||||||
note TEXT, state TEXT NOT NULL CHECK(state IN('active','archived')),
|
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,
|
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_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(),
|
)""".trimIndent(),
|
||||||
)
|
)
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
|
|
@ -12833,7 +12955,9 @@ private class TrainlogDatabaseHelper(
|
||||||
program_id TEXT NOT NULL REFERENCES synced_programs(program_id) ON DELETE CASCADE,
|
program_id TEXT NOT NULL REFERENCES synced_programs(program_id) ON DELETE CASCADE,
|
||||||
position INTEGER NOT NULL CHECK(position>=0), title TEXT NOT NULL,
|
position INTEGER NOT NULL CHECK(position>=0), title TEXT NOT NULL,
|
||||||
session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),
|
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(),
|
)""".trimIndent(),
|
||||||
)
|
)
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ data class SyncedProgramSession(
|
||||||
val title: String,
|
val title: String,
|
||||||
val sessionType: String,
|
val sessionType: String,
|
||||||
val plannedFor: String?,
|
val plannedFor: String?,
|
||||||
|
val currentFor: String?,
|
||||||
|
val planningState: String,
|
||||||
val note: String?,
|
val note: String?,
|
||||||
val executionState: ProgramSessionExecutionState,
|
val executionState: ProgramSessionExecutionState,
|
||||||
val executionSessionId: String?,
|
val executionSessionId: String?,
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,12 @@ fun ProgramDetailScreen(
|
||||||
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
|
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
|
||||||
program.note?.let { TrainlogInfo(it) }
|
program.note?.let { TrainlogInfo(it) }
|
||||||
val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED }
|
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) }
|
message?.let { TrainlogInfo(it) }
|
||||||
program.sessions.forEachIndexed { index, session ->
|
program.sessions.forEachIndexed { index, session ->
|
||||||
|
|
@ -145,12 +150,16 @@ fun ProgramDetailScreen(
|
||||||
color = LocalTrainlogColors.current.text,
|
color = LocalTrainlogColors.current.text,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
ProgramStateBadge(strings.getString(stateLabel), completed)
|
ProgramStateBadge(
|
||||||
|
if (session.planningState == "ceded" && !completed) "Créneau cédé"
|
||||||
|
else strings.getString(stateLabel),
|
||||||
|
completed,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
|
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
|
||||||
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),
|
strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size),
|
||||||
).joinToString(" · "),
|
).joinToString(" · "),
|
||||||
)
|
)
|
||||||
|
|
@ -164,7 +173,7 @@ fun ProgramDetailScreen(
|
||||||
Modifier.testTag("program-session-toggle-${session.programSessionId}"),
|
Modifier.testTag("program-session-toggle-${session.programSessionId}"),
|
||||||
)
|
)
|
||||||
when (session.executionState) {
|
when (session.executionState) {
|
||||||
ProgramSessionExecutionState.TODO -> TrainlogButton(
|
ProgramSessionExecutionState.TODO -> if (session.planningState == "active") TrainlogButton(
|
||||||
strings.getString(R.string.program_session_start),
|
strings.getString(R.string.program_session_start),
|
||||||
{ start(session.programSessionId) },
|
{ start(session.programSessionId) },
|
||||||
Modifier.testTag("program-session-start-${session.programSessionId}"),
|
Modifier.testTag("program-session-start-${session.programSessionId}"),
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
||||||
|
|
||||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||||
/* v13 intentionally adds the frozen machine identities and
|
/* v13 intentionally adds the frozen machine identities and
|
||||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||||
assertEquals(before, listOf(
|
assertEquals(before, listOf(
|
||||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
|
||||||
reopened.close()
|
reopened.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
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(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
||||||
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
||||||
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
|
||||||
context.getDatabasePath(name).path,
|
context.getDatabasePath(name).path,
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { assertEquals(33, it.version) }
|
).use { assertEquals(34, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
|
||||||
context.getDatabasePath(name).path,
|
context.getDatabasePath(name).path,
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { assertEquals(33, it.version) }
|
).use { assertEquals(34, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest {
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { db ->
|
).use { db ->
|
||||||
assertEquals(33, db.version)
|
assertEquals(34, db.version)
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
|
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
|
||||||
null,
|
null,
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest {
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { db ->
|
).use { db ->
|
||||||
assertEquals(33, db.version)
|
assertEquals(34, db.version)
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT session_type,session_kind FROM sessions",
|
"SELECT session_type,session_kind FROM sessions",
|
||||||
null,
|
null,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ class AndroidV30CardioCalibrationMigrationTest {
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { db ->
|
).use { db ->
|
||||||
assertEquals(33, db.version)
|
assertEquals(34, db.version)
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT COUNT(*) FROM exercises WHERE " +
|
"SELECT COUNT(*) FROM exercises WHERE " +
|
||||||
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +
|
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class AndroidV31CardioGuidanceMigrationTest {
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { db ->
|
).use { db ->
|
||||||
assertEquals(33, db.version)
|
assertEquals(34, db.version)
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
|
||||||
"'cardio_guidance_runs','cardio_guidance_phases'," +
|
"'cardio_guidance_runs','cardio_guidance_phases'," +
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ class AndroidV32SleepQuantityMigrationTest {
|
||||||
migrated.listSleepDiary()
|
migrated.listSleepDiary()
|
||||||
migrated.close()
|
migrated.close()
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
assertEquals(34, scalar(db, "PRAGMA user_version"))
|
||||||
assertEquals(
|
assertEquals(
|
||||||
1,
|
1,
|
||||||
scalar(
|
scalar(
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
||||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(33, cursor.getInt(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(34, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,67 @@ class ProgramsProjectionRepositoryTest {
|
||||||
.put("deletions", deletions)
|
.put("deletions", deletions)
|
||||||
.toString()
|
.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(
|
private fun preparationArtifact(
|
||||||
includeProgramProvenance: Boolean = true,
|
includeProgramProvenance: Boolean = true,
|
||||||
deliveryState: String = "pending",
|
deliveryState: String = "pending",
|
||||||
|
|
@ -211,6 +272,12 @@ class ProgramsProjectionRepositoryTest {
|
||||||
val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||||
assertEquals(programId, active.sourceProgramId)
|
assertEquals(programId, active.sourceProgramId)
|
||||||
assertEquals(programSessionId, active.sourceProgramSessionId)
|
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(
|
val performed = active.copy(
|
||||||
exercises = active.exercises.map { occurrence ->
|
exercises = active.exercises.map { occurrence ->
|
||||||
occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)))
|
occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)))
|
||||||
|
|
@ -218,6 +285,12 @@ class ProgramsProjectionRepositoryTest {
|
||||||
)
|
)
|
||||||
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
|
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
|
||||||
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
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(
|
assertEquals(
|
||||||
ProgramSessionExecutionState.COMPLETED,
|
ProgramSessionExecutionState.COMPLETED,
|
||||||
repository.getSyncedProgram(programId)!!.sessions.single().executionState,
|
repository.getSyncedProgram(programId)!!.sessions.single().executionState,
|
||||||
|
|
@ -238,6 +311,8 @@ class ProgramsProjectionRepositoryTest {
|
||||||
try {
|
try {
|
||||||
val detail = repository.listSyncedPrograms().single()
|
val detail = repository.listSyncedPrograms().single()
|
||||||
assertEquals(1, detail.sessionCount)
|
assertEquals(1, detail.sessionCount)
|
||||||
|
assertEquals("2026-10-01", repository.getSyncedProgram(detail.programId)!!
|
||||||
|
.sessions.single().currentFor)
|
||||||
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
|
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
|
||||||
assertEquals(1, repository.listSessions().size)
|
assertEquals(1, repository.listSessions().size)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
|
||||||
repository = TrainlogRepository(context, name)
|
repository = TrainlogRepository(context, name)
|
||||||
assertEquals(1, repository.listPreparedSessions().size)
|
assertEquals(1, repository.listPreparedSessions().size)
|
||||||
repository.close()
|
repository.close()
|
||||||
assertEquals("33", scalarText(name, "PRAGMA user_version"))
|
assertEquals("34", scalarText(name, "PRAGMA user_version"))
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"0",
|
"0",
|
||||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ class SleepDiaryRepositoryTest {
|
||||||
SQLiteDatabase.OPEN_READWRITE,
|
SQLiteDatabase.OPEN_READWRITE,
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
assertEquals(33, raw.version)
|
assertEquals(34, raw.version)
|
||||||
assertThrows(SQLiteException::class.java) {
|
assertThrows(SQLiteException::class.java) {
|
||||||
raw.execSQL(
|
raw.execSQL(
|
||||||
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",
|
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",
|
||||||
|
|
|
||||||
|
|
@ -878,7 +878,7 @@ class SyncGenerationServiceTest {
|
||||||
.use { db ->
|
.use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use {
|
db.rawQuery("PRAGMA user_version", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
assertEquals(33, it.getInt(0))
|
assertEquals(34, it.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -2382,7 +2382,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(33, cursor.getInt(0))
|
assertEquals(34, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -2509,7 +2509,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(33, cursor.getInt(0))
|
assertEquals(34, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,20 @@
|
||||||
# Android application
|
# 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
|
## Sleep Diary V1 and active V2 companion
|
||||||
|
|
||||||
Android schema v26 additively mirrors the Sleep Diary revision/event model for
|
Android schema v26 additively mirrors the Sleep Diary revision/event model for
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
# Current implementation state
|
# 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
|
Full-generation synchronization uses one generation/ACK business engine across
|
||||||
versioned transports. On the paired 0.1.4 deployment, automatic local selection
|
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
|
is Bluetooth Classic RFCOMM first and direct USB/MTP second. The desktop BlueZ
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,21 @@
|
||||||
# Desktop database
|
# 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)
|
## Causal heart-rate tail correction (schema v36, no migration)
|
||||||
|
|
||||||
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing
|
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing
|
||||||
|
|
|
||||||
|
|
@ -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
|
resurrected or prepared; an archived program cannot be prepared. The optional
|
||||||
`trainlog-programs` V1 synchronization companion is a separate PC-to-Android
|
`trainlog-programs` V1 synchronization companion is a separate PC-to-Android
|
||||||
projection and does not alter this import format.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
# Roadmap
|
# 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:
|
The controlled private rollout is complete for the paired daily installation:
|
||||||
physical libmtp, verified backups, signing continuity, migrations, correlated
|
physical libmtp, verified backups, signing continuity, migrations, correlated
|
||||||
ACKs, restart, and idempotent replay passed. Generation mode remains an
|
ACKs, restart, and idempotent replay passed. Generation mode remains an
|
||||||
|
|
|
||||||
|
|
@ -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-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
|
||||||
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals |
|
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals |
|
||||||
| PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones |
|
| PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones |
|
||||||
|
| 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 | `program-executions-v1.json` | optional `trainlog-program-executions` v1 companion: stable in-progress/completed provenance |
|
||||||
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
||||||
| Android -> full-generation daemon | `trainlog-sync-full-generation-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
|
not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation
|
||||||
semantics.
|
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
|
## Optional Program executions companion V1
|
||||||
|
|
||||||
`trainlog-program-executions` V1 is an Android-to-desktop full-generation
|
`trainlog-program-executions` V1 is an Android-to-desktop full-generation
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from pathlib import Path
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
sys.path.insert(0, str(ROOT / "tools"))
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
import export_programs
|
import export_programs
|
||||||
|
import export_programs_v2
|
||||||
|
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
|
|
@ -79,6 +80,41 @@ class ProgramsExchangeTest(unittest.TestCase):
|
||||||
"requested_at": "2026-09-18T12:00:00Z",
|
"requested_at": "2026-09-18T12:00:00Z",
|
||||||
}], payload["deletions"])
|
}], 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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ class DeploymentToolsTest(unittest.TestCase):
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
||||||
self.assertEqual(inventory["product_version"], "0.1.7")
|
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.assertEqual(inventory["android_schema"], 32)
|
||||||
self.assertIn("session-preparations-v2", inventory["protocols"])
|
self.assertIn("session-preparations-v2", inventory["protocols"])
|
||||||
self.assertIn("programs-v1", inventory["protocols"])
|
self.assertIn("programs-v1", inventory["protocols"])
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,58 @@ class GenerationTest(unittest.TestCase):
|
||||||
with closing(sqlite3.connect(destination)) as reopened:
|
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])
|
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):
|
def test_archived_acknowledged_fork_is_not_an_active_parent_tip(self):
|
||||||
active = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
active = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
archived = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
archived = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||||
|
|
|
||||||
|
|
@ -271,8 +271,8 @@ def main() -> None:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
root = load(args.input)
|
root = load(args.input)
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36, 37):
|
||||||
fail("desktop schema v33-v36 required")
|
fail("desktop schema v33-v37 required")
|
||||||
db.execute("BEGIN IMMEDIATE")
|
db.execute("BEGIN IMMEDIATE")
|
||||||
apply(db, root)
|
apply(db, root)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -395,8 +395,8 @@ def main() -> None:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
root = load(args.input)
|
root = load(args.input)
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36, 37):
|
||||||
fail("desktop schema v34-v36 required")
|
fail("desktop schema v34-v37 required")
|
||||||
db.execute("BEGIN IMMEDIATE")
|
db.execute("BEGIN IMMEDIATE")
|
||||||
apply(db, root)
|
apply(db, root)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ def digest(value) -> str:
|
||||||
|
|
||||||
def require_schema(db: sqlite3.Connection) -> None:
|
def require_schema(db: sqlite3.Connection) -> None:
|
||||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
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-v36 required, found v{version}")
|
raise CausalError(f"desktop schema v20-v37 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
||||||
|
|
||||||
def require_schema(connection):
|
def require_schema(connection):
|
||||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
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-v36 required, found v{version}")
|
raise LifecycleError(f"desktop schema v19-v37 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def import_document(connection, document, own_transaction=True):
|
def import_document(connection, document, own_transaction=True):
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
||||||
from trainlog_sqlite import configure_connection
|
from trainlog_sqlite import configure_connection
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 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 = (
|
MEASUREMENT_FIELDS = (
|
||||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||||
|
|
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
|
||||||
"""Build the raw export using only explicitly ordered SELECT statements."""
|
"""Build the raw export using only explicitly ordered SELECT statements."""
|
||||||
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||||
raise ValueError("schema desktop v15-v36 requis")
|
raise ValueError("schema desktop v15-v37 requis")
|
||||||
|
|
||||||
root = {
|
root = {
|
||||||
"format": "TRAINLOG_AI_EXPORT",
|
"format": "TRAINLOG_AI_EXPORT",
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
|
||||||
def ensure_publication_state(connection):
|
def ensure_publication_state(connection):
|
||||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||||
owned_transaction = not connection.in_transaction
|
owned_transaction = not connection.in_transaction
|
||||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 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:
|
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(
|
columns = {row[1] for row in connection.execute(
|
||||||
"PRAGMA table_info(ai_session_drafts)")}
|
"PRAGMA table_info(ai_session_drafts)")}
|
||||||
if "published_at" not in columns:
|
if "published_at" not in columns:
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
supported_versions = tuple(range(8, 37))
|
supported_versions = tuple(range(8, 38))
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v8 à v36 requis")
|
raise ValueError("schema desktop v8 à v37 requis")
|
||||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||||
known_equipment.update(row[0] for row in connection.execute(
|
known_equipment.update(row[0] for row in connection.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
supported_versions = tuple(range(8, 37))
|
supported_versions = tuple(range(8, 38))
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v8 à 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():
|
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")
|
raise ValueError("causal equipment protection requires the staged artifact")
|
||||||
equipment = [dict(row) for row in connection.execute(
|
equipment = [dict(row) for row in connection.execute(
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ def main():
|
||||||
con = connect_database(args.database)
|
con = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
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-v36 requis")
|
raise ValueError("schema desktop v12-v37 requis")
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
supported_versions = tuple(range(11, 37))
|
supported_versions = tuple(range(11, 38))
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v11-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():
|
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")
|
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||||
exercises = []
|
exercises = []
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ def main():
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 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}")
|
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||||
rows = con.execute("""
|
rows = con.execute("""
|
||||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ def main():
|
||||||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||||
# catalogue artifact is unchanged, but it must read the current
|
# catalogue artifact is unchanged, but it must read the current
|
||||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||||
if version not in range(8, 37):
|
if version not in range(8, 38):
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 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")
|
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
|
legacy_v2_schema = args.version == 2 and schema_version == 10
|
||||||
if schema_version not in supported_versions and not legacy_v2_schema:
|
if schema_version not in supported_versions and not legacy_v2_schema:
|
||||||
raise ValueError("schema desktop v11-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(
|
if not complete_causal_envelope and schema_version >= 20 and con.execute(
|
||||||
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
||||||
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")
|
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ def nullable(row, index):
|
||||||
|
|
||||||
def export_programs(database: Path) -> dict:
|
def export_programs(database: Path) -> dict:
|
||||||
with closing(connect_database(database)) as db:
|
with closing(connect_database(database)) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] 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")
|
raise ValueError("desktop schema v26 through v32 required")
|
||||||
programs = []
|
programs = []
|
||||||
for row in db.execute(
|
for row in db.execute(
|
||||||
|
|
|
||||||
59
tools/export_programs_v2.py
Normal file
59
tools/export_programs_v2.py
Normal 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()
|
||||||
|
|
@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
|
||||||
|
|
||||||
|
|
||||||
def build_export(connection: sqlite3.Connection) -> dict:
|
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:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("desktop schema v24, v25, v26 through v32 required")
|
raise ValueError("desktop schema v24, v25, v26 through v32 required")
|
||||||
deliveries = []
|
deliveries = []
|
||||||
|
|
|
||||||
|
|
@ -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()
|
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||||
db=connect_database(args.database)
|
db=connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
supported_versions = range(15, 37)
|
supported_versions = range(15, 38)
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v15-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")
|
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()
|
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()
|
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()
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,8 @@ def split_target(value: str) -> tuple[str, str]:
|
||||||
|
|
||||||
def require_schema(db: sqlite3.Connection) -> None:
|
def require_schema(db: sqlite3.Connection) -> None:
|
||||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in range(27, 37):
|
if version not in range(27, 38):
|
||||||
raise CorrectionError(f"heart-rate correction requires schema v27-v36, found v{version}")
|
raise CorrectionError(f"heart-rate correction requires schema v27-v37, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def _table_exists(db: sqlite3.Connection, table: str) -> bool:
|
def _table_exists(db: sqlite3.Connection, table: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -282,8 +282,8 @@ def main() -> None:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
root = load(args.input)
|
root = load(args.input)
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37):
|
||||||
fail("desktop schema v30-v36 required")
|
fail("desktop schema v30-v37 required")
|
||||||
db.execute("BEGIN IMMEDIATE")
|
db.execute("BEGIN IMMEDIATE")
|
||||||
apply(db, root)
|
apply(db, root)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
|
||||||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||||
any failure leaves no draft, child, or import-identity row behind.
|
any failure leaves no draft, child, or import-identity row behind.
|
||||||
"""
|
"""
|
||||||
supported_versions = range(18, 37)
|
supported_versions = range(18, 38)
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ImportFailure("schema desktop v18-v36 requis")
|
raise ImportFailure("schema desktop v18-v37 requis")
|
||||||
draft, entries = validate(payload, connection)
|
draft, entries = validate(payload, connection)
|
||||||
digest = canonical_digest(payload)
|
digest = canonical_digest(payload)
|
||||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
|
|
||||||
|
|
@ -206,9 +206,9 @@ def main():
|
||||||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
supported_versions = range(8, 37)
|
supported_versions = range(8, 38)
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v8 à v36 requis")
|
fail("schema desktop v8 à v37 requis")
|
||||||
proof_path = args.mobile_export
|
proof_path = args.mobile_export
|
||||||
if proof_path is None:
|
if proof_path is None:
|
||||||
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,9 @@ def validate(payload, reserved):
|
||||||
|
|
||||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||||
"""Apply validated definitions without owning the surrounding transaction."""
|
"""Apply validated definitions without owning the surrounding transaction."""
|
||||||
supported_versions = range(8, 37)
|
supported_versions = range(8, 38)
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v8 à 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():
|
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")
|
fail("causal equipment protection requires the staged artifact")
|
||||||
imported = skipped = 0
|
imported = skipped = 0
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,9 @@ def load(path):
|
||||||
|
|
||||||
def apply_aliases(con, aliases):
|
def apply_aliases(con, aliases):
|
||||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||||
supported_versions = range(12, 37)
|
supported_versions = range(12, 38)
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v12-v36 requis")
|
fail("schema desktop v12-v37 requis")
|
||||||
for source, canonical in aliases:
|
for source, canonical in aliases:
|
||||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
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}")
|
if target is None: fail(f"cible canonique absente: {canonical}")
|
||||||
|
|
|
||||||
|
|
@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
|
||||||
|
|
||||||
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||||
"""Apply validated zone rows without owning transaction boundaries."""
|
"""Apply validated zone rows without owning transaction boundaries."""
|
||||||
supported_versions = range(11, 37)
|
supported_versions = range(11, 38)
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ImportFailure("schema desktop v11-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")
|
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={}
|
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
||||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
# CONTRACT: resolve complete alias groups before the first write; direct
|
||||||
|
|
|
||||||
|
|
@ -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")
|
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||||
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
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-v36 requis")
|
fail("schema desktop v17-v37 requis")
|
||||||
applied=equal=ancestor=pending=0;seen=set()
|
applied=equal=ancestor=pending=0;seen=set()
|
||||||
for item in root["exercises"]:
|
for item in root["exercises"]:
|
||||||
validate(item);eid=item["exercise_id"]
|
validate(item);eid=item["exercise_id"]
|
||||||
|
|
|
||||||
|
|
@ -847,9 +847,9 @@ def require_supported_schema(connection):
|
||||||
|
|
||||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||||
# readable for legacy artifacts and are never made to fake that table.
|
# readable for legacy artifacts and are never made to fake that table.
|
||||||
if version not in range(5, 37):
|
if version not in range(5, 38):
|
||||||
raise ImportFailure(
|
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)
|
require_supported_schema(connection)
|
||||||
|
|
||||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
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 à v36")
|
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v37")
|
||||||
if schema_version >= 20:
|
if schema_version >= 20:
|
||||||
protected = {(row[0], row[1]) for row in connection.execute(
|
protected = {(row[0], row[1]) for row in connection.execute(
|
||||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
||||||
def apply_feedback(db,root,complete_causal_envelope=False):
|
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||||
db.execute("PRAGMA foreign_keys=ON")
|
db.execute("PRAGMA foreign_keys=ON")
|
||||||
supported_versions = range(15, 37)
|
supported_versions = range(15, 38)
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v15-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")
|
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()
|
seen=set()
|
||||||
for index,item in enumerate(root["exercise_feedback"]):
|
for index,item in enumerate(root["exercise_feedback"]):
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ RUNTIME_TOOLS = [
|
||||||
"post_sync_ai_drive.py",
|
"post_sync_ai_drive.py",
|
||||||
"export_session_preparations.py",
|
"export_session_preparations.py",
|
||||||
"export_programs.py",
|
"export_programs.py",
|
||||||
|
"export_programs_v2.py",
|
||||||
"program_execution_exchange.py",
|
"program_execution_exchange.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -120,7 +121,7 @@ def main() -> int:
|
||||||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||||
).strip(),
|
).strip(),
|
||||||
"product_version": "0.1.7",
|
"product_version": "0.1.7",
|
||||||
"desktop_schema": 36,
|
"desktop_schema": 37,
|
||||||
"android_schema": 32,
|
"android_schema": 32,
|
||||||
"protocols": [
|
"protocols": [
|
||||||
"mobile-export-v3",
|
"mobile-export-v3",
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ def load(path: Path) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
|
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")
|
fail("desktop schema v27 through v32 required")
|
||||||
added = 0
|
added = 0
|
||||||
advanced = 0
|
advanced = 0
|
||||||
|
|
|
||||||
|
|
@ -184,8 +184,8 @@ def main() -> None:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
root = load(args.input)
|
root = load(args.input)
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36, 37):
|
||||||
fail("desktop schema v31-v36 required")
|
fail("desktop schema v31-v37 required")
|
||||||
db.execute("BEGIN IMMEDIATE")
|
db.execute("BEGIN IMMEDIATE")
|
||||||
apply(db, root)
|
apply(db, root)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -637,8 +637,8 @@ def main() -> None:
|
||||||
import_command.add_argument("--database", required=True, type=Path)
|
import_command.add_argument("--database", required=True, type=Path)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37):
|
||||||
fail("desktop schema v30-v36 required")
|
fail("desktop schema v30-v37 required")
|
||||||
if args.command == "export":
|
if args.command == "export":
|
||||||
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
||||||
separators=(",", ":")), encoding="utf-8")
|
separators=(",", ":")), encoding="utf-8")
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ ARTIFACTS = (
|
||||||
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
|
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
|
||||||
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
||||||
"export_programs.py", ()),
|
"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", "trainlog-sleep-diary", 2, "sleep-diary-v2.json", False,
|
||||||
"sleep_diary_exchange.py", ("export",)),
|
"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:
|
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:
|
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:
|
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,
|
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"):
|
if not valid_id(consumer, "peer"):
|
||||||
raise GenerationError("invalid expected consumer")
|
raise GenerationError("invalid expected consumer")
|
||||||
generation = generation_id or "gen_" + str(uuid.uuid4())
|
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")
|
producer = peer_identity(source, "desktop")
|
||||||
source.commit()
|
source.commit()
|
||||||
source.backup(target)
|
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 = []
|
descriptors = []
|
||||||
total = 0
|
total = 0
|
||||||
for logical, fmt, version, filename, required, tool, extra in ARTIFACTS:
|
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
|
output = stage / filename
|
||||||
run_export(tool, extra, output, snapshot)
|
run_export(tool, extra, output, snapshot)
|
||||||
size, checksum = digest_file(output)
|
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()
|
existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone()
|
||||||
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
|
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
|
||||||
(operation["operation_id"], generation, 0 if existed else 1))
|
(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():
|
if programs_path.is_file():
|
||||||
captured_programs = strict_json(programs_path.read_bytes(), MAX_ARTIFACT)
|
captured_programs = strict_json(programs_path.read_bytes(), MAX_ARTIFACT)
|
||||||
for deletion in captured_programs["deletions"]:
|
for deletion in captured_programs["deletions"]:
|
||||||
|
|
|
||||||
|
|
@ -827,7 +827,12 @@ def main() -> int:
|
||||||
# diagnostics to the separately bounded stderr channel.
|
# diagnostics to the separately bounded stderr channel.
|
||||||
with redirect_stdout(sys.stderr):
|
with redirect_stdout(sys.stderr):
|
||||||
stage, manifest, digest = generation.capture_desktop(
|
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
|
del stage
|
||||||
published = generation.publish(
|
published = generation.publish(
|
||||||
|
|
@ -926,6 +931,14 @@ def main() -> int:
|
||||||
if inbound_row != ("consumed",) or outbound_row != ("acknowledged",):
|
if inbound_row != ("consumed",) or outbound_row != ("acknowledged",):
|
||||||
raise RuntimeError("generation evidence did not corroborate completion")
|
raise RuntimeError("generation evidence did not corroborate completion")
|
||||||
emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id)
|
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)
|
ai_post_sync = publish_ai_export_to_android(args, deadline)
|
||||||
report = {
|
report = {
|
||||||
"format": "trainlog-sync-worker-report",
|
"format": "trainlog-sync-worker-report",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
#include "trainlog/model.h"
|
#include "trainlog/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 36
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 37
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,4 +63,17 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
|
||||||
char **output_json,
|
char **output_json,
|
||||||
size_t *output_size);
|
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
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,7 @@ trainlog_core_sources = files(
|
||||||
'src/web_sessions.c',
|
'src/web_sessions.c',
|
||||||
'src/web_session_deletions.c',
|
'src/web_session_deletions.c',
|
||||||
'src/web_programs.c',
|
'src/web_programs.c',
|
||||||
|
'src/web_program_reschedule.c',
|
||||||
'src/web_sleep.c',
|
'src/web_sleep.c',
|
||||||
'src/web_exercises.c',
|
'src/web_exercises.c',
|
||||||
'src/web_equipment.c',
|
'src/web_equipment.c',
|
||||||
|
|
|
||||||
|
|
@ -1578,6 +1578,39 @@ static const char *const MIGRATE_V35_TO_V36_SQL =
|
||||||
"immutable');END;"
|
"immutable');END;"
|
||||||
"PRAGMA user_version=36;COMMIT;";
|
"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
|
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
|
||||||
* before medication capture joined the same unreleased migration. CONTRACT:
|
* before medication capture joined the same unreleased migration. CONTRACT:
|
||||||
* this additive repair is identical to the tail of v28 -> v29 and runs only
|
* 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 == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
|
||||||
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
|
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
|
||||||
version == 31 || version == 32 || version == 33 || version == 34 || version == 35 ||
|
version == 31 || version == 32 || version == 33 || version == 34 || version == 35 ||
|
||||||
version == 36) {
|
version == 36 || version == 37) {
|
||||||
status = TRAINLOG_STATUS_OK;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} else {
|
||||||
if (version == 1) {
|
if (version == 1) {
|
||||||
|
|
@ -2585,6 +2618,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
||||||
if (status == TRAINLOG_STATUS_OK && version < 36) {
|
if (status == TRAINLOG_STATUS_OK && version < 36) {
|
||||||
status = execute_sql(database, MIGRATE_V35_TO_V36_SQL);
|
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) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = ensure_v18_ai_draft_publication_state(database);
|
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) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
set_open_diagnostic(output_diagnostic,
|
set_open_diagnostic(output_diagnostic,
|
||||||
output_diagnostic_capacity,
|
output_diagnostic_capacity,
|
||||||
version == 0 ? "create schema v36" : "migrate database to schema v36",
|
version == 0 ? "create schema v37" : "migrate database to schema v37",
|
||||||
database->connection,
|
database->connection,
|
||||||
SQLITE_ERROR);
|
SQLITE_ERROR);
|
||||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||||
|
|
|
||||||
645
tui/src/web_program_reschedule.c
Normal file
645
tui/src/web_program_reschedule.c
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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, "title", statement, 2) ||
|
||||||
!add_column_text(document, session, "session_type", statement, 3) ||
|
!add_column_text(document, session, "session_type", statement, 3) ||
|
||||||
!add_column_text(document, session, "planned_for", statement, 4) ||
|
!add_column_text(document, session, "planned_for", statement, 4) ||
|
||||||
!add_column_text(document, session, "note", statement, 5) ||
|
!add_column_text(document, session, "current_for", statement, 5) ||
|
||||||
!add_column_text(document, session, "execution_state", statement, 6) ||
|
!add_column_text(document, session, "planning_state", statement, 6) ||
|
||||||
!add_column_text(document, session, "execution_session_id", statement, 7) ||
|
!add_column_text(document, session, "note", statement, 7) ||
|
||||||
!add_column_text(document, session, "execution_started_at", statement, 8) ||
|
!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_obj_add_val(document, session, "occurrences", entries) ||
|
||||||
!yyjson_mut_arr_add_val(sessions, session)) {
|
!yyjson_mut_arr_add_val(sessions, session)) {
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
|
@ -383,7 +385,9 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database,
|
||||||
yyjson_mut_val *sessions) {
|
yyjson_mut_val *sessions) {
|
||||||
static const char SESSION_SQL[] =
|
static const char SESSION_SQL[] =
|
||||||
"SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for,"
|
"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='in_progress' THEN 'in_progress' "
|
||||||
"WHEN pe.state='deleted' THEN 'deleted' "
|
"WHEN pe.state='deleted' THEN 'deleted' "
|
||||||
"WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE "
|
"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.
|
* CONTRACT: expose that timestamp only for completed executions.
|
||||||
* INVARIANT: the imported planned_for date and history are read only. */
|
* INVARIANT: the imported planned_for date and history are read only. */
|
||||||
"CASE WHEN pe.state='completed' THEN actual.started_at ELSE NULL END "
|
"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 "
|
"ON pe.program_session_id=ps.program_session_id "
|
||||||
"LEFT JOIN sessions actual ON actual.session_id=pe.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";
|
"WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id";
|
||||||
|
|
@ -1165,8 +1171,26 @@ static TrainlogStatus program_replay_archive(TrainlogDatabase *database,
|
||||||
result = SQLITE_OK;
|
result = SQLITE_OK;
|
||||||
}
|
}
|
||||||
(void)sqlite3_finalize(statement);
|
(void)sqlite3_finalize(statement);
|
||||||
|
if (result != SQLITE_OK || *found) {
|
||||||
return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
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,
|
static TrainlogStatus program_apply_archive(TrainlogDatabase *database,
|
||||||
const char *program_id,
|
const char *program_id,
|
||||||
|
|
@ -1357,7 +1381,9 @@ static ProgramDeleteReplay program_replay_delete(TrainlogDatabase *database,
|
||||||
}
|
}
|
||||||
|
|
||||||
result = sqlite3_prepare_v2(database->connection,
|
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,
|
-1,
|
||||||
&statement,
|
&statement,
|
||||||
NULL);
|
NULL);
|
||||||
|
|
@ -1609,7 +1635,7 @@ static char *program_build_preparation_body(yyjson_val *session,
|
||||||
command,
|
command,
|
||||||
root,
|
root,
|
||||||
"planned_for",
|
"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(
|
(void)yyjson_mut_obj_add_val(
|
||||||
command, root, "notes", yyjson_val_mut_copy(command, yyjson_obj_get(session, "note")));
|
command, root, "notes", yyjson_val_mut_copy(command, yyjson_obj_get(session, "note")));
|
||||||
(void)yyjson_mut_obj_add_strcpy(command, root, "editing_state", "draft");
|
(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);
|
yyjson_doc_free(document);
|
||||||
return TRAINLOG_STATUS_NOT_FOUND;
|
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);
|
body = program_build_preparation_body(session, program_id, program_session_id, &body_size);
|
||||||
yyjson_doc_free(document);
|
yyjson_doc_free(document);
|
||||||
if (body == NULL) {
|
if (body == NULL) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
||||||
}
|
}
|
||||||
if (status == TRAINLOG_STATUS_CONFLICT) {
|
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);
|
free(json);
|
||||||
return queue_json(
|
return result;
|
||||||
connection, MHD_HTTP_CONFLICT, "{\"error\":\"program_conflict\"}\n", NULL);
|
|
||||||
}
|
}
|
||||||
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
|
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);
|
free(json);
|
||||||
return queue_json(
|
return result;
|
||||||
connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_program\"}\n", NULL);
|
|
||||||
}
|
}
|
||||||
return queue_owned_sessions_json(connection, status, json, json_size);
|
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 PREFIX[] = "/api/v1/sessions/program/";
|
||||||
static const char ARCHIVE_SUFFIX[] = "/archive";
|
static const char ARCHIVE_SUFFIX[] = "/archive";
|
||||||
static const char DELETE_SUFFIX[] = "/delete";
|
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 *identity;
|
||||||
const char *sessions_marker;
|
const char *sessions_marker;
|
||||||
char *json = NULL;
|
char *json = NULL;
|
||||||
|
|
@ -950,6 +960,38 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
|
||||||
identity = url + strlen(PREFIX);
|
identity = url + strlen(PREFIX);
|
||||||
identity_length = strlen(identity);
|
identity_length = strlen(identity);
|
||||||
sessions_marker = strstr(identity, "/sessions/");
|
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) &&
|
if (identity_length > strlen(ARCHIVE_SUFFIX) &&
|
||||||
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
|
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
|
||||||
return handle_program_archive(context, connection, identity, identity_length, method);
|
return handle_program_archive(context, connection, identity, identity_length, method);
|
||||||
|
|
|
||||||
|
|
@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
|
||||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strncmp(diagnostic,
|
CHECK(strncmp(diagnostic,
|
||||||
"migrate database to schema v36: SQLite rc=",
|
"migrate database to schema v37: SQLite rc=",
|
||||||
strlen("migrate database to schema v36: SQLite rc=")) == 0);
|
strlen("migrate database to schema v37: SQLite rc=")) == 0);
|
||||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||||
|
|
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
|
||||||
CHECK(fd >= 0);
|
CHECK(fd >= 0);
|
||||||
CHECK(close(fd) == 0);
|
CHECK(close(fd) == 0);
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=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(sqlite3_close(raw) == SQLITE_OK);
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "schema version 37 is newer") != NULL);
|
CHECK(strstr(diagnostic, "schema version 38 is newer") != NULL);
|
||||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||||
CHECK(unlink(path) == 0);
|
CHECK(unlink(path) == 0);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "migrate database to schema v36") != NULL);
|
CHECK(strstr(diagnostic, "migrate database to schema v37") != NULL);
|
||||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||||
|
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
|
|
|
||||||
|
|
@ -608,10 +608,343 @@ static bool legacy_integer_weight_zero_prepares_editable_draft(void) {
|
||||||
return true;
|
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) {
|
int main(void) {
|
||||||
return import_archive_and_prepare() && strict_import_rejections() &&
|
return import_archive_and_prepare() && strict_import_rejections() &&
|
||||||
mixed_profile_prepare_regression() &&
|
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_SUCCESS
|
||||||
: EXIT_FAILURE;
|
: EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ export interface ProgramSession {
|
||||||
title: string
|
title: string
|
||||||
session_type: 'training' | 'max_test'
|
session_type: 'training' | 'max_test'
|
||||||
planned_for: string | null
|
planned_for: string | null
|
||||||
|
current_for: string | null
|
||||||
|
planning_state: 'active' | 'ceded'
|
||||||
note: string | null
|
note: string | null
|
||||||
execution_state: 'todo' | 'prepared' | 'in_progress' | 'completed' | 'deleted'
|
execution_state: 'todo' | 'prepared' | 'in_progress' | 'completed' | 'deleted'
|
||||||
execution_session_id: string | null
|
execution_session_id: string | null
|
||||||
|
|
@ -62,6 +64,30 @@ export interface ProgramDetail {
|
||||||
sessions: ProgramSession[]
|
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 {
|
export interface ProgramImportPreview {
|
||||||
api_version: 1
|
api_version: 1
|
||||||
program_id: string
|
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('')}`
|
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(
|
export async function fetchAllPrograms(
|
||||||
search = '',
|
search = '',
|
||||||
state = '',
|
state = '',
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ const api = vi.hoisted(() => ({
|
||||||
fetchAllPrograms: vi.fn(),
|
fetchAllPrograms: vi.fn(),
|
||||||
fetchProgram: vi.fn(),
|
fetchProgram: vi.fn(),
|
||||||
createPreparationFromProgram: vi.fn(),
|
createPreparationFromProgram: vi.fn(),
|
||||||
|
previewProgramReschedule: vi.fn(),
|
||||||
|
applyProgramReschedule: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../api/programs', async (importOriginal) => ({
|
vi.mock('../api/programs', async (importOriginal) => ({
|
||||||
|
|
@ -45,6 +47,8 @@ function session(
|
||||||
title,
|
title,
|
||||||
session_type: 'training',
|
session_type: 'training',
|
||||||
planned_for: plannedFor,
|
planned_for: plannedFor,
|
||||||
|
current_for: plannedFor,
|
||||||
|
planning_state: 'active',
|
||||||
note: null,
|
note: null,
|
||||||
execution_state: executionState,
|
execution_state: executionState,
|
||||||
execution_session_id: null,
|
execution_session_id: null,
|
||||||
|
|
@ -88,6 +92,8 @@ describe('ProgramsCalendarPage', () => {
|
||||||
api.fetchAllPrograms.mockReset()
|
api.fetchAllPrograms.mockReset()
|
||||||
api.fetchProgram.mockReset()
|
api.fetchProgram.mockReset()
|
||||||
api.createPreparationFromProgram.mockReset()
|
api.createPreparationFromProgram.mockReset()
|
||||||
|
api.previewProgramReschedule.mockReset()
|
||||||
|
api.applyProgramReschedule.mockReset()
|
||||||
api.fetchAllPrograms.mockResolvedValue([item()])
|
api.fetchAllPrograms.mockResolvedValue([item()])
|
||||||
api.fetchProgram.mockResolvedValue(detail())
|
api.fetchProgram.mockResolvedValue(detail())
|
||||||
})
|
})
|
||||||
|
|
@ -101,6 +107,53 @@ describe('ProgramsCalendarPage', () => {
|
||||||
expect(screen.queryByRole('combobox', { name: 'Programme actif' })).not.toBeInTheDocument()
|
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 () => {
|
it('shows the exact empty state and navigates to Sessions program administration', async () => {
|
||||||
api.fetchAllPrograms.mockResolvedValue([])
|
api.fetchAllPrograms.mockResolvedValue([])
|
||||||
const onNavigate = vi.fn()
|
const onNavigate = vi.fn()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
createPreparationFromProgram,
|
createPreparationFromProgram,
|
||||||
|
applyProgramReschedule,
|
||||||
fetchAllPrograms,
|
fetchAllPrograms,
|
||||||
fetchProgram,
|
fetchProgram,
|
||||||
|
newRescheduleOperationId,
|
||||||
|
previewProgramReschedule,
|
||||||
type ProgramDetail,
|
type ProgramDetail,
|
||||||
type ProgramListItem,
|
type ProgramListItem,
|
||||||
type ProgramSession,
|
type ProgramSession,
|
||||||
|
type RescheduleChoice,
|
||||||
|
type ReschedulePreview,
|
||||||
} from '../api/programs'
|
} from '../api/programs'
|
||||||
import { useDatePreferences } from '../presentation/DatePreferences'
|
import { useDatePreferences } from '../presentation/DatePreferences'
|
||||||
import { formatCivilDate, formatDate, parseCivilDate, validTimestampValue } from '../presentation/dateFormat'
|
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 completed link without a valid history timestamp must not masquerade as
|
||||||
// a workout performed on the imported plan date.
|
// a workout performed on the imported plan date.
|
||||||
return session.execution_state === 'completed'
|
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[] {
|
function calendarDays(program: ProgramDetail): CalendarDay[] {
|
||||||
|
|
@ -126,7 +132,13 @@ function ProgramSessionCard({ programId, session, pending, preparationId, onPrep
|
||||||
const actualDate = actualExecutionDate(session)
|
const actualDate = actualExecutionDate(session)
|
||||||
return <article className={`program-calendar-session program-calendar-state-${session.execution_state}`}>
|
return <article className={`program-calendar-session program-calendar-state-${session.execution_state}`}>
|
||||||
<h3>{session.title}</h3>
|
<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">
|
{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)}
|
Prévue le {session.planned_for === null ? 'date non définie' : formatCivilDate(session.planned_for, dateFormat)}
|
||||||
{' · '}effectuée le {formatCivilDate(actualDate, dateFormat)}
|
{' · '}effectuée le {formatCivilDate(actualDate, dateFormat)}
|
||||||
|
|
@ -134,7 +146,8 @@ function ProgramSessionCard({ programId, session, pending, preparationId, onPrep
|
||||||
{session.execution_state === 'completed' && actualDate === null &&
|
{session.execution_state === 'completed' && actualDate === null &&
|
||||||
<p className="program-calendar-date-note">Date réelle indisponible</p>}
|
<p className="program-calendar-date-note">Date réelle indisponible</p>}
|
||||||
<div className="program-calendar-action">
|
<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)}>
|
onClick={() => onPrepare(programId, session)}>
|
||||||
{pending ? 'Préparation…' : 'Préparer'}
|
{pending ? 'Préparation…' : 'Préparer'}
|
||||||
</button>}
|
</button>}
|
||||||
|
|
@ -156,6 +169,14 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
|
||||||
const [pendingSessions, setPendingSessions] = useState<Set<string>>(new Set())
|
const [pendingSessions, setPendingSessions] = useState<Set<string>>(new Set())
|
||||||
const [preparationIds, setPreparationIds] = useState<Record<string, string>>({})
|
const [preparationIds, setPreparationIds] = useState<Record<string, string>>({})
|
||||||
const [announcement, setAnnouncement] = useState('')
|
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 preparing = useRef(new Set<string>())
|
||||||
const mounted = useRef(true)
|
const mounted = useRef(true)
|
||||||
const selectedIdRef = useRef('')
|
const selectedIdRef = useRef('')
|
||||||
|
|
@ -200,6 +221,11 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
|
||||||
setDetailPending(true)
|
setDetailPending(true)
|
||||||
setDetailError('')
|
setDetailError('')
|
||||||
setAnnouncement('')
|
setAnnouncement('')
|
||||||
|
setReschedulePreview(null)
|
||||||
|
setRescheduleStart('')
|
||||||
|
setRescheduleThrough('')
|
||||||
|
setRescheduleDate('')
|
||||||
|
setCededIds([])
|
||||||
setPreparationIds({})
|
setPreparationIds({})
|
||||||
fetchProgram(selectedId, controller.signal).then((value) => {
|
fetchProgram(selectedId, controller.signal).then((value) => {
|
||||||
if (isCurrent()) setDetail(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 days = useMemo(() => detail === null ? [] : calendarDays(detail), [detail])
|
||||||
const undatedSessions = detail?.sessions.filter((session) => dateValue(displayedDate(session)) === null) ?? []
|
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 prepare = async (programId: string, session: ProgramSession) => {
|
||||||
const sessionId = session.program_session_id
|
const sessionId = session.program_session_id
|
||||||
|
|
@ -316,6 +390,54 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string
|
||||||
</ul>
|
</ul>
|
||||||
</header>
|
</header>
|
||||||
<p className="program-calendar-announcement" aria-live="polite">{announcement}</p>
|
<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}
|
{days.length > 0 && <div className="program-calendar-scroll" tabIndex={0}
|
||||||
aria-label="Calendrier hebdomadaire du programme">
|
aria-label="Calendrier hebdomadaire du programme">
|
||||||
<div className="program-calendar-grid">
|
<div className="program-calendar-grid">
|
||||||
|
|
|
||||||
|
|
@ -1978,6 +1978,43 @@ h2 {
|
||||||
color: var(--subtext-0);
|
color: var(--subtext-0);
|
||||||
font-size: var(--font-size-sm);
|
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 {
|
.program-calendar-scroll {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue