From 6c0e7af2f0abdf1d75630e6228ac6f0d135d7946 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 1 Oct 2026 07:00:14 +0200 Subject: [PATCH] fix(program): add durable ordered reschedule candidate --- CHANGELOG.md | 6 + .../trainlog/data/AndroidBackupService.kt | 4 +- .../SyncGenerationForegroundCoordinator.kt | 1 + .../trainlog/data/SyncGenerationService.kt | 9 +- .../trainlog/data/TrainlogRepository.kt | 164 ++++- .../trainlog/model/ProgramModels.kt | 2 + .../labfytools/trainlog/ui/ProgramScreens.kt | 17 +- .../data/AndroidV10PlanningMigrationTest.kt | 2 +- .../AndroidV11ExerciseAliasMigrationTest.kt | 2 +- .../AndroidV16AiSessionDraftMigrationTest.kt | 2 +- ...AndroidV25ProgramExecutionMigrationTest.kt | 2 +- .../data/AndroidV26SleepMigrationTest.kt | 2 +- .../data/AndroidV27HeartRateMigrationTest.kt | 2 +- .../AndroidV28SessionTimelineMigrationTest.kt | 2 +- .../AndroidV29CardioSessionMigrationTest.kt | 2 +- ...ndroidV30CardioCalibrationMigrationTest.kt | 2 +- .../AndroidV31CardioGuidanceMigrationTest.kt | 2 +- .../AndroidV32SleepQuantityMigrationTest.kt | 2 +- .../labfytools/trainlog/data/BodyZonesTest.kt | 2 +- .../data/ProgramsProjectionRepositoryTest.kt | 75 ++ .../data/SessionPreparationExchangeTest.kt | 2 +- .../trainlog/data/SleepDiaryRepositoryTest.kt | 2 +- .../data/SyncGenerationServiceTest.kt | 2 +- .../data/TrainlogRepositoryDraftTest.kt | 4 +- docs/android.md | 15 + docs/current_state.md | 11 + docs/database.md | 16 + docs/program_format_v1.md | 29 + docs/roadmap.md | 7 + docs/sync_exchange.md | 28 + tests/test_programs_exchange.py | 36 + tests/test_sync_deployment_tools.py | 2 +- tests/test_sync_generation_exchange.py | 52 ++ tools/cardio_calibration_exchange.py | 4 +- tools/cardio_guidance_exchange.py | 4 +- tools/causal_delete_exchange.py | 4 +- tools/execution_draft_exchange.py | 4 +- tools/export_ai_history.py | 4 +- tools/export_ai_session_drafts.py | 4 +- tools/export_equipment_associations.py | 4 +- tools/export_equipment_definitions.py | 4 +- tools/export_exercise_aliases.py | 4 +- tools/export_exercise_body_zones.py | 4 +- tools/export_exercise_profile_state.py | 2 +- tools/export_pc_catalog.py | 2 +- tools/export_pc_mobile.py | 6 +- tools/export_programs.py | 2 +- tools/export_programs_v2.py | 59 ++ tools/export_session_preparations.py | 2 +- tools/export_training_feedback.py | 4 +- tools/heart_rate_correction_exchange.py | 4 +- tools/heart_rate_exchange.py | 4 +- tools/import_ai_session_draft.py | 4 +- tools/import_equipment_associations.py | 4 +- tools/import_equipment_definitions.py | 4 +- tools/import_exercise_aliases.py | 4 +- tools/import_exercise_body_zones.py | 4 +- tools/import_exercise_profile_state.py | 4 +- tools/import_mobile_export.py | 8 +- tools/import_training_feedback.py | 4 +- tools/package_sync_candidate.py | 3 +- tools/program_execution_exchange.py | 2 +- tools/session_timeline_exchange.py | 4 +- tools/sleep_diary_exchange.py | 4 +- tools/sync_generation_exchange.py | 38 +- tools/sync_peer_worker.py | 15 +- tui/include/trainlog/database.h | 2 +- tui/include/trainlog/web_programs.h | 13 + tui/meson.build | 1 + tui/src/database.c | 40 +- tui/src/web_program_reschedule.c | 645 ++++++++++++++++++ tui/src/web_programs.c | 48 +- tui/src/web_server.c | 50 +- tui/tests/test_schema_v7_migration.c | 8 +- tui/tests/test_schema_v9_migration.c | 2 +- tui/tests/test_web_programs.c | 335 ++++++++- web/src/api/programs.ts | 67 ++ web/src/routes/ProgramsCalendarPage.test.tsx | 53 ++ web/src/routes/ProgramsCalendarPage.tsx | 128 +++- web/src/theme/app.css | 37 + 80 files changed, 2032 insertions(+), 132 deletions(-) create mode 100644 tools/export_programs_v2.py create mode 100644 tui/src/web_program_reschedule.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 7731aea..cbb838f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased — 0.1.7 +The isolated `trainlog-program-recalage-v1` candidate adds an explicit +preview-and-confirm cascade for unfinished Program sessions. It retains source +and actual dates, stores absolute current dates and ceded-slot state, and +selects a separate Programs V2 Android projection by capability. This candidate +is not merged into the stable installation or deployed to Android. + Development opened after the stable v0.1.6 release. This line is restricted to observed bug fixes, regression fixes, directly related small UI corrections, documentation corrections, robustness, and associated tests. It does not admit diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt index 7f9a335..c276295 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt @@ -225,7 +225,7 @@ internal class AndroidBackupService( manifest.getInt("version") == VERSION && manifest.getString("package") == context.packageName && manifest.getBoolean("plaintext") && - manifest.getInt("schema") in 17..33 + manifest.getInt("schema") in 17..34 ) { "Unsupported backup." } @@ -319,7 +319,7 @@ internal class AndroidBackupService( } ) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) - require(db.version in 17..33) { "Unsupported Android schema ${db.version}." } + require(db.version in 17..34) { "Unsupported Android schema ${db.version}." } return db.version } finally { db.close() diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt index a4f283f..cf95d22 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt @@ -370,6 +370,7 @@ internal class SyncGenerationCoordinator( ACK_RECOVERY_CAPABILITY, "session-preparations-v2", "programs-v1", + "programs-v2", ) ), ) diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt index d5db3db..72b9338 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt @@ -86,7 +86,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) (ANDROID_KINDS.values + Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) + Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false) + - Kind("trainlog-programs", 1, "programs-v1.json", false)) + Kind("trainlog-programs", 1, "programs-v1.json", false) + + Kind("trainlog-programs", 2, "programs-v2.json", false)) .map { it.format to it.version } .toSet() @@ -773,6 +774,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) throw SyncGenerationException("artifact digest mismatch") artifacts[item.getString("logical_name")] = bytes.toString(StandardCharsets.UTF_8) } + if ("programs-v1" in artifacts && "programs-v2" in artifacts) + throw SyncGenerationException("multiple Program revisions in one generation") return ValidatedGeneration(manifest, sha256(raw), artifacts) } @@ -947,6 +950,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) apply("programs-v1") { repository.applyProgramsV1Json(checkNotNull(a["programs-v1"])) } + if ("programs-v2" in a) + apply("programs-v2") { + repository.applyProgramsV2Json(checkNotNull(a["programs-v2"])) + } apply("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) } apply("exercise-profile-state") { repository.applyExerciseProfileStateJson( diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 5b158dc..52c17c9 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -11825,7 +11825,11 @@ class TrainlogRepository( * explicit tombstones are applied before live rows. INVARIANT: a durable tombstone always * wins over a later stale snapshot and never touches drafts, preparations, history or catalog. */ - fun applyProgramsV1Json(json: String): ProgramsImportResult { + fun applyProgramsV1Json(json: String): ProgramsImportResult = applyProgramsJson(json, 1) + + fun applyProgramsV2Json(json: String): ProgramsImportResult = applyProgramsJson(json, 2) + + private fun applyProgramsJson(json: String, documentVersion: Int): ProgramsImportResult { if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 || !jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.") val root = try { JSONObject(json) } catch (_: Exception) { @@ -11853,7 +11857,7 @@ class TrainlogRepository( try { if (!root.exact("format", "version", "generated_at", "programs", "deletions") || root.optString("format") != "trainlog-programs" || root.opt("version") !is Int || - root.getInt("version") != 1 || !timestamp(root.opt("generated_at")) || + root.getInt("version") != documentVersion || !timestamp(root.opt("generated_at")) || root.opt("programs") !is JSONArray || root.opt("deletions") !is JSONArray) throw ProgramsImportInvalid("Unsupported Programs document.") val programs = root.getJSONArray("programs") @@ -11880,11 +11884,16 @@ class TrainlogRepository( for (programIndex in 0 until programs.length()) { val program = programs.optJSONObject(programIndex) ?: throw ProgramsImportInvalid("Invalid program.") - if (!program.exact( + val programKeysValid = if (documentVersion == 2) program.exact( + "program_id", "revision_id", "revision_sequence", "title", "note", "state", "start_date", + "end_date", "created_at", "updated_at", "source_format", "source_version", + "source_payload_sha256", "sessions", + ) else program.exact( "program_id", "revision_id", "title", "note", "state", "start_date", "end_date", "created_at", "updated_at", "source_format", "source_version", "source_payload_sha256", "sessions", - ) || + ) + if (!programKeysValid || !bounded(program.opt("program_id"), 128) || !programIds.add(program.getString("program_id")) || !bounded(program.opt("revision_id"), 128) || !bounded(program.opt("title"), 200) || !nullableBounded(program.opt("note"), 4000) || program.optString("state") !in setOf("active", "archived") || @@ -11894,17 +11903,33 @@ class TrainlogRepository( (program.opt("source_payload_sha256") as? String)?.matches(Regex("^[0-9a-f]{64}$")) != true || program.opt("sessions") !is JSONArray) throw ProgramsImportInvalid("Invalid program metadata.") + if (documentVersion == 2 && !integer(program.opt("revision_sequence"), 0, Int.MAX_VALUE)) + throw ProgramsImportInvalid("Invalid Program revision sequence.") val sessions = program.getJSONArray("sessions") if (sessions.length() !in 1..64) throw ProgramsImportInvalid("Invalid program session count.") for (sessionIndex in 0 until sessions.length()) { val session = sessions.optJSONObject(sessionIndex) ?: throw ProgramsImportInvalid("Invalid program session.") - if (!session.exact("program_session_id", "title", "session_type", "planned_for", "note", "occurrences") || + val validKeys = if (documentVersion == 2) session.exact( + "program_session_id", "position", "title", "session_type", "planned_for", + "current_for", "planning_state", "note", "occurrences", + ) else session.exact( + "program_session_id", "title", "session_type", "planned_for", "note", "occurrences", + ) + if (!validKeys || !bounded(session.opt("program_session_id"), 128) || !sessionIds.add(session.getString("program_session_id")) || !bounded(session.opt("title"), 200) || session.optString("session_type") !in setOf("training", "max_test") || !date(session.opt("planned_for")) || !nullableBounded(session.opt("note"), 4000) || session.opt("occurrences") !is JSONArray) throw ProgramsImportInvalid("Invalid program session metadata.") + if (documentVersion == 2 && + (!integer(session.opt("position"), 0, 255) || session.getInt("position") != sessionIndex || + !date(session.opt("current_for")) || + session.optString("planning_state") !in setOf("active", "ceded") || + (session.optString("planning_state") == "ceded" && !session.isNull("current_for")) || + (session.optString("planning_state") == "active" && + session.isNull("current_for") && !session.isNull("planned_for")))) + throw ProgramsImportInvalid("Invalid Program planning state.") val occurrences = session.getJSONArray("occurrences") if (occurrences.length() !in 1..64) throw ProgramsImportInvalid("Invalid program occurrence count.") val entryIds = mutableSetOf() @@ -11930,6 +11955,12 @@ class TrainlogRepository( } } + data class ExistingProjection( + val revisionId: String, + val digest: String, + val version: Int, + val sequence: Long, + ) val db = database.writableDatabase db.beginTransaction() try { @@ -11962,28 +11993,86 @@ class TrainlogRepository( .use { it.moveToFirst() } if (tombstoned) { skipped++; continue } val digest = causalDigest(program) - val existing = db.rawQuery("SELECT revision_id,snapshot_sha256 FROM synced_programs WHERE program_id=?", arrayOf(id)) - .use { cursor -> if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1) } - if (existing?.first == program.getString("revision_id")) { - if (existing.second != digest) throw ProgramsImportInvalid("Program revision identity conflict.") - skipped++ - continue + val existing = db.rawQuery( + "SELECT revision_id,snapshot_sha256,projection_version,revision_sequence " + + "FROM synced_programs WHERE program_id=?", + arrayOf(id), + ).use { cursor -> + if (!cursor.moveToFirst()) null else ExistingProjection( + cursor.getString(0), cursor.getString(1), cursor.getInt(2), cursor.getLong(3), + ) + } + val sequence = if (documentVersion == 2) program.getLong("revision_sequence") else 0L + // WHY: a later V1 artifact cannot erase current dates or a + // ceded slot after this phone has accepted Programs V2. + if (existing != null && documentVersion < existing.version) + throw ProgramsImportInvalid("Programs V2 capability required.") + if (existing != null && documentVersion == 2 && + (sequence < existing.sequence || + (sequence == existing.sequence && existing.version == 2 && + existing.revisionId != program.getString("revision_id")))) + throw ProgramsImportInvalid("Stale Program revision sequence.") + if (existing?.revisionId == program.getString("revision_id")) { + if (existing.digest == digest) { skipped++; continue } + if (documentVersion != 2 || existing.version != 1 || + existing.digest != causalDigest(JSONObject(program.toString()).apply { + remove("revision_sequence") + getJSONArray("sessions").let { rows -> + for (sessionIndex in 0 until rows.length()) { + rows.getJSONObject(sessionIndex).apply { + remove("position") + remove("current_for") + remove("planning_state") + } + } + } + })) throw ProgramsImportInvalid("Program revision identity conflict.") + } + if (documentVersion == 2) { + val activeProgramSession = db.rawQuery( + "SELECT source_program_session_id FROM active_session_draft " + + "WHERE id=1 AND source_program_id=?", + arrayOf(id), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + if (activeProgramSession != null) { + val newSessions = program.getJSONArray("sessions") + for (sessionIndex in 0 until newSessions.length()) { + val candidate = newSessions.getJSONObject(sessionIndex) + if (candidate.getString("program_session_id") == activeProgramSession) { + val existingPlan = db.rawQuery( + "SELECT current_for,planning_state FROM synced_program_sessions " + + "WHERE program_session_id=?", + arrayOf(activeProgramSession), + ).use { cursor -> + if (!cursor.moveToFirst()) null else + (if (cursor.isNull(0)) null else cursor.getString(0)) to cursor.getString(1) + } + if (existingPlan != null && + (existingPlan.first != candidate.optStringOrNull("current_for") || + existingPlan.second != candidate.getString("planning_state"))) + throw ProgramsImportInvalid("Active Program draft blocks reschedule.") + } + } + } } db.delete("synced_programs", "program_id=?", arrayOf(id)) db.execSQL( - "INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", arrayOf(id, program.getString("revision_id"), program.getString("title"), program.optStringOrNull("note"), program.getString("state"), program.optStringOrNull("start_date"), program.optStringOrNull("end_date"), program.getString("created_at"), program.getString("updated_at"), program.getString("source_format"), - program.getInt("source_version"), program.getString("source_payload_sha256"), digest), + program.getInt("source_version"), program.getString("source_payload_sha256"), digest, + documentVersion, sequence), ) val sessions = program.getJSONArray("sessions") for (sessionIndex in 0 until sessions.length()) { val session = sessions.getJSONObject(sessionIndex) val sessionId = session.getString("program_session_id") - db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?)", arrayOf( + db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?,?,?)", arrayOf( sessionId, id, sessionIndex, session.getString("title"), session.getString("session_type"), - session.optStringOrNull("planned_for"), session.optStringOrNull("note"))) + session.optStringOrNull("planned_for"), session.optStringOrNull("note"), + if (documentVersion == 2) session.optStringOrNull("current_for") else session.optStringOrNull("planned_for"), + if (documentVersion == 2) session.getString("planning_state") else "active")) val entries = session.getJSONArray("occurrences") for (entryIndex in 0 until entries.length()) { val entry = entries.getJSONObject(entryIndex) @@ -12052,7 +12141,7 @@ class TrainlogRepository( } ?: return null val sessions = mutableListOf() db.rawQuery( - "SELECT program_session_id,title,session_type,planned_for,note " + + "SELECT program_session_id,title,session_type,planned_for,note,current_for,planning_state " + "FROM synced_program_sessions WHERE program_id=? " + "ORDER BY position,program_session_id", arrayOf(programId), @@ -12095,6 +12184,8 @@ class TrainlogRepository( sessionCursor.getString(1), sessionCursor.getString(2), if (sessionCursor.isNull(3)) null else sessionCursor.getString(3), + if (sessionCursor.isNull(5)) null else sessionCursor.getString(5), + sessionCursor.getString(6), if (sessionCursor.isNull(4)) null else sessionCursor.getString(4), execution.first, execution.second, @@ -12160,7 +12251,8 @@ class TrainlogRepository( val available = db.rawQuery( "SELECT s.session_type FROM synced_program_sessions s " + "JOIN synced_programs p ON p.program_id=s.program_id " + - "WHERE p.program_id=? AND s.program_session_id=? AND p.state='active'", + "WHERE p.program_id=? AND s.program_session_id=? AND p.state='active' " + + "AND s.planning_state='active'", arrayOf(programId, programSessionId), ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } ?: return StartProgramSessionResult.NotAvailable @@ -12454,7 +12546,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 33, + 34, ) { override fun onConfigure( db: SQLiteDatabase, @@ -12808,6 +12900,34 @@ private class TrainlogDatabaseHelper( createSleepImmutabilityTriggers(db) version = 33 } + if (version < 34 && newVersion >= 34) { + /* WHY: V1 retains the source date; V2 needs an independent current + * target and a durable ceded-slot state. INVARIANT: no local draft + * or completed workout is rewritten by the projection migration. */ + if (!tableHasColumn(db, "synced_programs", "projection_version")) { + db.execSQL( + "ALTER TABLE synced_programs ADD COLUMN projection_version INTEGER NOT NULL " + + "DEFAULT 1 CHECK(projection_version IN(1,2))", + ) + } + if (!tableHasColumn(db, "synced_programs", "revision_sequence")) { + db.execSQL( + "ALTER TABLE synced_programs ADD COLUMN revision_sequence INTEGER NOT NULL " + + "DEFAULT 0 CHECK(revision_sequence>=0)", + ) + } + if (!tableHasColumn(db, "synced_program_sessions", "current_for")) { + db.execSQL("ALTER TABLE synced_program_sessions ADD COLUMN current_for TEXT") + db.execSQL("UPDATE synced_program_sessions SET current_for=planned_for") + } + if (!tableHasColumn(db, "synced_program_sessions", "planning_state")) { + db.execSQL( + "ALTER TABLE synced_program_sessions ADD COLUMN planning_state TEXT NOT NULL " + + "DEFAULT 'active' CHECK(planning_state IN('active','ceded'))", + ) + } + version = 34 + } if (version != newVersion) { error( @@ -12824,7 +12944,9 @@ private class TrainlogDatabaseHelper( note TEXT, state TEXT NOT NULL CHECK(state IN('active','archived')), start_date TEXT, end_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, source_format TEXT NOT NULL, source_version INTEGER NOT NULL, - source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL + source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL, + projection_version INTEGER NOT NULL DEFAULT 1 CHECK(projection_version IN(1,2)), + revision_sequence INTEGER NOT NULL DEFAULT 0 CHECK(revision_sequence>=0) )""".trimIndent(), ) db.execSQL( @@ -12833,7 +12955,9 @@ private class TrainlogDatabaseHelper( program_id TEXT NOT NULL REFERENCES synced_programs(program_id) ON DELETE CASCADE, position INTEGER NOT NULL CHECK(position>=0), title TEXT NOT NULL, session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')), - planned_for TEXT, note TEXT, UNIQUE(program_id,position) + planned_for TEXT, note TEXT, current_for TEXT, + planning_state TEXT NOT NULL DEFAULT 'active' CHECK(planning_state IN('active','ceded')), + UNIQUE(program_id,position) )""".trimIndent(), ) db.execSQL( diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/ProgramModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/ProgramModels.kt index de34a13..3c0f97b 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/ProgramModels.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/ProgramModels.kt @@ -25,6 +25,8 @@ data class SyncedProgramSession( val title: String, val sessionType: String, val plannedFor: String?, + val currentFor: String?, + val planningState: String, val note: String?, val executionState: ProgramSessionExecutionState, val executionSessionId: String?, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/ProgramScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/ProgramScreens.kt index 480db80..22b6823 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/ProgramScreens.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/ProgramScreens.kt @@ -118,7 +118,12 @@ fun ProgramDetailScreen( program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) } program.note?.let { TrainlogInfo(it) } val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED } - TrainlogInfo("$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted) + val ceded = program.sessions.count { it.planningState == "ceded" } + TrainlogInfo( + "$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}" + + if (ceded > 0) " · $ceded créneau(x) cédé(s)" else "", + if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted, + ) } message?.let { TrainlogInfo(it) } program.sessions.forEachIndexed { index, session -> @@ -145,12 +150,16 @@ fun ProgramDetailScreen( color = LocalTrainlogColors.current.text, fontWeight = FontWeight.Bold, ) - ProgramStateBadge(strings.getString(stateLabel), completed) + ProgramStateBadge( + if (session.planningState == "ceded" && !completed) "Créneau cédé" + else strings.getString(stateLabel), + completed, + ) } TrainlogInfo( listOfNotNull( strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training), - session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) }, + session.currentFor?.let { strings.getString(R.string.planned_for, formatDate(it)) }, strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size), ).joinToString(" · "), ) @@ -164,7 +173,7 @@ fun ProgramDetailScreen( Modifier.testTag("program-session-toggle-${session.programSessionId}"), ) when (session.executionState) { - ProgramSessionExecutionState.TODO -> TrainlogButton( + ProgramSessionExecutionState.TODO -> if (session.planningState == "active") TrainlogButton( strings.getString(R.string.program_session_start), { start(session.programSessionId) }, Modifier.testTag("program-session-start-${session.programSessionId}"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index f928503..e177872 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(33, scalar(db, "PRAGMA user_version")) + assertEquals(34, scalar(db, "PRAGMA user_version")) for (table in listOf("session_exercises", "draft_session_exercises")) { assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index 4563ef4..cfbf751 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(33, scalar(db, "PRAGMA user_version")) + assertEquals(34, scalar(db, "PRAGMA user_version")) /* v13 intentionally adds the frozen machine identities and * exercise-owned metadata; all non-exercise facts stay exact. */ val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt index 639ce54..2815eec 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(33, scalar(db, "PRAGMA user_version")) + assertEquals(34, scalar(db, "PRAGMA user_version")) assertEquals(before, listOf( scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM performed_sets"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt index 19e5357..252179f 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest { reopened.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(33, scalar(db, "PRAGMA user_version")) + assertEquals(34, scalar(db, "PRAGMA user_version")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL")) assertEquals("ok", text(db, "PRAGMA integrity_check")) db.rawQuery("PRAGMA foreign_key_check", null).use { cursor -> diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt index e079c34..256d84d 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt @@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest { context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY, - ).use { assertEquals(33, it.version) } + ).use { assertEquals(34, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt index e7bc70e..bea3027 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt @@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest { context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY, - ).use { assertEquals(33, it.version) } + ).use { assertEquals(34, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt index a126551..ca06070 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest { null, SQLiteDatabase.OPEN_READONLY, ).use { db -> - assertEquals(33, db.version) + assertEquals(34, db.version) db.rawQuery( "SELECT session_id,started_at,ended_at FROM session_timeline_sessions", null, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt index 3639dd9..5ddb07c 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt @@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest { null, SQLiteDatabase.OPEN_READONLY, ).use { db -> - assertEquals(33, db.version) + assertEquals(34, db.version) db.rawQuery( "SELECT session_type,session_kind FROM sessions", null, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt index 5a1f26e..cf05bbc 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt @@ -41,7 +41,7 @@ class AndroidV30CardioCalibrationMigrationTest { null, SQLiteDatabase.OPEN_READONLY, ).use { db -> - assertEquals(33, db.version) + assertEquals(34, db.version) db.rawQuery( "SELECT COUNT(*) FROM exercises WHERE " + "exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " + diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt index 18e53aa..738ef17 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt @@ -42,7 +42,7 @@ class AndroidV31CardioGuidanceMigrationTest { null, SQLiteDatabase.OPEN_READONLY, ).use { db -> - assertEquals(33, db.version) + assertEquals(34, db.version) db.rawQuery( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" + "'cardio_guidance_runs','cardio_guidance_phases'," + diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV32SleepQuantityMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV32SleepQuantityMigrationTest.kt index 18d866b..c37cfd9 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV32SleepQuantityMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV32SleepQuantityMigrationTest.kt @@ -59,7 +59,7 @@ class AndroidV32SleepQuantityMigrationTest { migrated.listSleepDiary() migrated.close() SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(33, scalar(db, "PRAGMA user_version")) + assertEquals(34, scalar(db, "PRAGMA user_version")) assertEquals( 1, scalar( diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index 1629323..e8108f3 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -328,7 +328,7 @@ class BodyZonesTest { SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY).use { db -> db.rawQuery("PRAGMA user_version", null).use { cursor -> - assertTrue(cursor.moveToFirst()); assertEquals(33, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(34, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt index 77f477f..70b83e7 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt @@ -70,6 +70,67 @@ class ProgramsProjectionRepositoryTest { .put("deletions", deletions) .toString() + private fun artifactV2( + program: JSONObject, + sequence: Int = 0, + currentFor: String = "2026-10-01", + ): String { + program.put("revision_sequence", sequence) + val session = program.getJSONArray("sessions").getJSONObject(0) + session.put("position", 0) + session.put("current_for", currentFor) + session.put("planning_state", "active") + return JSONObject() + .put("format", "trainlog-programs") + .put("version", 2) + .put("generated_at", "2026-09-18T12:00:00Z") + .put("programs", JSONArray().put(program)) + .put("deletions", JSONArray()) + .toString() + } + + @Test + fun v2UpgradePreservesCurrentDateAndRejectsLegacyDowngrade() { + val name = "program-v2-${UUID.randomUUID()}.db" + var repository = TrainlogRepository(context, name) + try { + assertEquals( + ProgramsImportResult.Applied(1, 0, 0), + repository.applyProgramsV1Json(artifact(JSONArray().put(program()))), + ) + val upgraded = program() + assertEquals( + ProgramsImportResult.Applied(1, 0, 0), + repository.applyProgramsV2Json(artifactV2(upgraded)), + ) + assertEquals("2026-10-01", repository.getSyncedProgram(upgraded.getString("program_id"))!! + .sessions.single().currentFor) + val stale = artifactV2(program()) + val newer = program().put("revision_id", "pgr_99999999-9999-4999-8999-999999999999") + assertEquals( + ProgramsImportResult.Applied(1, 0, 0), + repository.applyProgramsV2Json(artifactV2(newer, 1, "2026-10-02")), + ) + assertTrue(repository.applyProgramsV2Json(stale) is ProgramsImportResult.Invalid) + assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!! + .sessions.single().currentFor) + assertTrue(repository.applyProgramsV1Json(artifact(JSONArray().put(program()))) + is ProgramsImportResult.Invalid) + assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!! + .sessions.single().currentFor) + } finally { + repository.close() + } + repository = TrainlogRepository(context, name) + try { + assertEquals("2026-10-02", repository.getSyncedProgram(program().getString("program_id"))!! + .sessions.single().currentFor) + } finally { + repository.close() + context.deleteDatabase(name) + } + } + private fun preparationArtifact( includeProgramProvenance: Boolean = true, deliveryState: String = "pending", @@ -211,6 +272,12 @@ class ProgramsProjectionRepositoryTest { val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft assertEquals(programId, active.sourceProgramId) assertEquals(programSessionId, active.sourceProgramSessionId) + val changedPlan = program().apply { + put("revision_id", "pgr_99999999-9999-4999-8999-999999999999") + } + assertTrue(repository.applyProgramsV2Json(artifactV2(changedPlan)) + is ProgramsImportResult.Invalid) + assertEquals(null, repository.getSyncedProgram(programId)!!.sessions.single().currentFor) val performed = active.copy( exercises = active.exercises.map { occurrence -> occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0))) @@ -218,6 +285,12 @@ class ProgramsProjectionRepositoryTest { ) assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed)) assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved) + assertEquals( + ProgramsImportResult.Applied(1, 0, 0), + repository.applyProgramsV2Json(artifactV2(changedPlan)), + ) + assertEquals("2026-10-01", repository.getSyncedProgram(programId)!! + .sessions.single().currentFor) assertEquals( ProgramSessionExecutionState.COMPLETED, repository.getSyncedProgram(programId)!!.sessions.single().executionState, @@ -238,6 +311,8 @@ class ProgramsProjectionRepositoryTest { try { val detail = repository.listSyncedPrograms().single() assertEquals(1, detail.sessionCount) + assertEquals("2026-10-01", repository.getSyncedProgram(detail.programId)!! + .sessions.single().currentFor) assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None) assertEquals(1, repository.listSessions().size) } finally { diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt index 45de1cb..4e05938 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt @@ -360,7 +360,7 @@ class SessionPreparationExchangeTest { repository = TrainlogRepository(context, name) assertEquals(1, repository.listPreparedSessions().size) repository.close() - assertEquals("33", scalarText(name, "PRAGMA user_version")) + assertEquals("34", scalarText(name, "PRAGMA user_version")) assertEquals( "0", scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt index f844a77..8e2dcd7 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt @@ -245,7 +245,7 @@ class SleepDiaryRepositoryTest { SQLiteDatabase.OPEN_READWRITE, ) try { - assertEquals(33, raw.version) + assertEquals(34, raw.version) assertThrows(SQLiteException::class.java) { raw.execSQL( "UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?", diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index ae13cf8..5c953e5 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -878,7 +878,7 @@ class SyncGenerationServiceTest { .use { db -> db.rawQuery("PRAGMA user_version", null).use { assertTrue(it.moveToFirst()) - assertEquals(33, it.getInt(0)) + assertEquals(34, it.getInt(0)) } db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { assertTrue(it.moveToFirst()) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 5090980..24de849 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -2382,7 +2382,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(33, cursor.getInt(0)) + assertEquals(34, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -2509,7 +2509,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(33, cursor.getInt(0)) + assertEquals(34, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/docs/android.md b/docs/android.md index 351ee8e..1b54f5c 100644 --- a/docs/android.md +++ b/docs/android.md @@ -1,5 +1,20 @@ # Android application +## Candidate schema v34: read-only Programs V2 projection + +The isolated Program rescheduling candidate additively stores a projection +version and revision sequence on synchronized Programs, plus `current_for` and +`planning_state` on synchronized Program sessions. The original `planned_for` +and all local completed sessions and drafts remain separate. Existing V1 rows +hydrate as active with their original date. Android still has no Program editor. + +Programs V2 replaces one Program snapshot transactionally. A lower revision +sequence or a later Programs V1 snapshot cannot overwrite accepted V2 planning. +If the singleton active draft refers to a session whose current date or ceded +state changes, V2 import rejects the generation; it does not alter the draft. +The Program screen uses current dates for unfinished sessions and does not offer +Start on a ceded slot. This candidate is not installed on the personal phone. + ## Sleep Diary V1 and active V2 companion Android schema v26 additively mirrors the Sleep Diary revision/event model for diff --git a/docs/current_state.md b/docs/current_state.md index 30b847c..36eac80 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -1,5 +1,16 @@ # Current implementation state +## Isolated Program rescheduling candidate + +The `trainlog-program-recalage-v1` worktree contains a local candidate for +explicit ordered Program rescheduling, desktop schema v37, Android schema v34, +and the capability-selected `trainlog-programs` V2 projection. The stable +0.1.7 installation and personal Program have not been migrated or rescheduled. +The candidate passed local native, Web, Android JVM, build, and targeted +sanitizer validation. It still requires coordinated PC/Android deployment +before it is an operational feature. Completed-session calendar +dates in stable 0.1.7 continue to come from their linked factual sessions. + Full-generation synchronization uses one generation/ACK business engine across versioned transports. On the paired 0.1.4 deployment, automatic local selection is Bluetooth Classic RFCOMM first and direct USB/MTP second. The desktop BlueZ diff --git a/docs/database.md b/docs/database.md index 09dc986..95cd7a9 100644 --- a/docs/database.md +++ b/docs/database.md @@ -1,5 +1,21 @@ # Desktop database +## Candidate schema v37: Program planning + +Schema v37 is additive to the isolated Program rescheduling candidate. It +creates `program_session_planning` for absolute current dates and ceded-slot +state, `program_reschedule_operations` for exact request replay, and +`program_revision_sequences` for monotonically ordered Program snapshots. +A trigger increments the per-Program sequence whenever its revision changes, +including archive, reschedule and logical deletion. No date uniqueness or +historical session mutation is introduced. Missing planning rows mean the +original `planned_for` date and active planning state. The source format and +published Programs V1 artifact remain strict and unchanged. + +This schema is present only in the candidate worktree/build. The stable +installation's database remains at its existing schema until a separately +authorized coordinated deployment and backup. + ## Causal heart-rate tail correction (schema v36, no migration) The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing diff --git a/docs/program_format_v1.md b/docs/program_format_v1.md index e7ebcbe..544fa10 100644 --- a/docs/program_format_v1.md +++ b/docs/program_format_v1.md @@ -68,3 +68,32 @@ source definition and derived preparations. A deleted program cannot be resurrected or prepared; an archived program cannot be prepared. The optional `trainlog-programs` V1 synchronization companion is a separate PC-to-Android projection and does not alter this import format. + +## Local candidate: explicit ordered rescheduling + +The isolated `trainlog-program-recalage-v1` candidate adds a separate planning +state. `planned_for` remains the imported date. `current_for` is an absolute +current target for an active, unfinished session; `ceded` releases one selected +future slot without completing or deleting its session identity. Completed +cards still use the linked session's actual start timestamp. Positions from the +imported Program determine the cascade order; titles never determine priority. + +The Web action selects a first and last unfinished session, a resumption date, +and the exact session identities whose slots are ceded. Its available days are +the dated original Program slots in that interval on or after resumption. +The preview lists original, prior current and proposed dates. It rejects a +missing slot, a date beyond the Program end, a completed/in-progress/prepared +session, or a stale revision. It does not invent rest-day or post-end slots. +An explicitly ceded session keeps its identity and remains visible as ceded. +Unchanged sessions in the selected range are displayed in the preview but need +no planning row. Other sessions and Programs are not moved. Multiple actual or +independent sessions may still share one civil day. + +Confirmation binds the preview fingerprint to the current Program projection +and writes absolute dates, ceded states, the Program revision and a durable +operation response in one transaction. Exact operation replay returns that +response; changed content under the same operation ID conflicts. A failed +write rolls back every row. The candidate requires a recent acknowledged +Programs V2 generation for every previously contacted Android peer. A newly +started offline Android Program draft remains protected by Android's V2 import +rejection; the sync run then reports a conflict for explicit resolution. diff --git a/docs/roadmap.md b/docs/roadmap.md index d13a2d3..5de7797 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,12 @@ # Roadmap +The isolated Program cascade candidate is implemented in the +`trainlog-program-recalage-v1` worktree and remains outside the stable 0.1.7 +installation. Its next gate is a coordinated PC/Android deployment review, +followed by a newly calculated personal Program preview and separate user +confirmation. No personal reschedule or publication is implied by candidate +tests. + The controlled private rollout is complete for the paired daily installation: physical libmtp, verified backups, signing continuity, migrations, correlated ACKs, restart, and idempotent replay passed. Generation mode remains an diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 68b8661..4a86940 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -277,6 +277,7 @@ does not publish to it or let it override an artifact in the new endpoint. | PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 | | PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals | | PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones | +| PC -> Android | `programs-v2.json` | candidate `trainlog-programs` v2 companion: original and current dates, ceded state, ordered revision sequence, and the same deletion tombstones | | Android -> PC | `program-executions-v1.json` | optional `trainlog-program-executions` v1 companion: stable in-progress/completed provenance | | Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 | | Android -> full-generation daemon | `trainlog-sync-full-generation-request-v1.json` | `trainlog-sync-request` v1 | @@ -299,6 +300,33 @@ V3, `trainlog-pc-catalog` V1, and `trainlog-session-preparations` V2. It does not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation semantics. +### Isolated candidate: Programs V2 capability matrix + +`trainlog-programs` V2 is a separate strict artifact selected only for a peer +advertising `programs-v2`. Its session adds `position`, nullable `current_for` +and `planning_state=active|ceded`; its Program adds `revision_sequence`. +`planned_for`, stable IDs, source entries and tombstones retain their V1 +meaning. One generation contains exactly one Programs version. Android v34 +stores the V2 projection transactionally and rejects a lower sequence, a +same-sequence different revision, a later V1 downgrade, or a changed plan for +its currently active Program draft. Execution facts return through the +unchanged Program-executions V1 companion and retain their actual timestamps. + +| Desktop state | Android capability | Program result | Other domains | +|---|---|---|---| +| No reschedule | `programs-v1` only | V1 original plan | Normal generation | +| No reschedule | `programs-v2` | V2 projection; V1 is omitted | Normal generation | +| Rescheduled live Program | `programs-v2` | V2 absolute current plan | Normal generation | +| Rescheduled live Program | V1 only, absent or stale V2 | Program artifact omitted; run reports incomplete Program convergence after ACK | Other artifacts still synchronize | +| Program draft started offline after the last V2 ACK | `programs-v2` | Android rejects a changed plan without rewriting its draft; explicit conflict resolution is required | Generation rejection is durable | + +The desktop commit requires that the latest generation to each previously +contacted Android peer was an acknowledged V2 Program generation no older than +five minutes. This bounds, but cannot eliminate, a phone-side race: Android's +active-draft check is the final guard. A failed or downgraded peer cannot be +reported as current for the Program domain. The existing generation, request, +ACK, tombstone, transport, Sleep and HR wire contracts are unchanged. + ## Optional Program executions companion V1 `trainlog-program-executions` V1 is an Android-to-desktop full-generation diff --git a/tests/test_programs_exchange.py b/tests/test_programs_exchange.py index feac1c5..a55724d 100644 --- a/tests/test_programs_exchange.py +++ b/tests/test_programs_exchange.py @@ -11,6 +11,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "tools")) import export_programs +import export_programs_v2 SCHEMA = """ @@ -79,6 +80,41 @@ class ProgramsExchangeTest(unittest.TestCase): "requested_at": "2026-09-18T12:00:00Z", }], payload["deletions"]) + def test_v2_carries_current_date_and_ceded_state_without_changing_v1(self): + with closing(sqlite3.connect(self.database)) as db: + db.executescript(""" + CREATE TABLE program_session_planning( + program_session_id TEXT PRIMARY KEY,current_for TEXT, + state TEXT,operation_id TEXT,updated_at TEXT); + CREATE TABLE program_revision_sequences( + program_id TEXT PRIMARY KEY,revision_sequence INTEGER NOT NULL); + INSERT INTO program_revision_sequences VALUES('pg_live',1); + INSERT INTO program_session_planning VALUES( + 'pgs_live','2026-10-01','active','op_fixture','2026-09-30T12:00:00Z'); + PRAGMA user_version=37; + """) + db.commit() + initial = export_programs.export_programs(self.database) + current = export_programs_v2.export_programs_v2(self.database) + self.assertEqual(1, initial["version"]) + self.assertEqual(2, current["version"]) + original = initial["programs"][0]["sessions"][0] + planned = current["programs"][0]["sessions"][0] + self.assertIsNone(original["planned_for"]) + self.assertEqual(set(original), { + "program_session_id", "title", "session_type", "planned_for", "note", "occurrences" + }) + self.assertEqual("2026-10-01", planned["current_for"]) + self.assertEqual(1, current["programs"][0]["revision_sequence"]) + self.assertEqual("active", planned["planning_state"]) + self.assertEqual(0, planned["position"]) + with closing(sqlite3.connect(self.database)) as db: + db.execute("UPDATE program_session_planning SET current_for=NULL,state='ceded'") + db.commit() + ceded = export_programs_v2.export_programs_v2(self.database) + self.assertIsNone(ceded["programs"][0]["sessions"][0]["current_for"]) + self.assertEqual("ceded", ceded["programs"][0]["sessions"][0]["planning_state"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sync_deployment_tools.py b/tests/test_sync_deployment_tools.py index 6b60915..b061167 100644 --- a/tests/test_sync_deployment_tools.py +++ b/tests/test_sync_deployment_tools.py @@ -77,7 +77,7 @@ class DeploymentToolsTest(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) inventory = json.loads((output / "candidate-inventory.json").read_text()) self.assertEqual(inventory["product_version"], "0.1.7") - self.assertEqual(inventory["desktop_schema"], 36) + self.assertEqual(inventory["desktop_schema"], 37) self.assertEqual(inventory["android_schema"], 32) self.assertIn("session-preparations-v2", inventory["protocols"]) self.assertIn("programs-v1", inventory["protocols"]) diff --git a/tests/test_sync_generation_exchange.py b/tests/test_sync_generation_exchange.py index 10237dd..3691565 100644 --- a/tests/test_sync_generation_exchange.py +++ b/tests/test_sync_generation_exchange.py @@ -71,6 +71,58 @@ class GenerationTest(unittest.TestCase): with closing(sqlite3.connect(destination)) as reopened: self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0]) + def test_rescheduled_program_requires_v2_capability_before_publication(self): + with closing(sqlite3.connect(self.database)) as db: + db.executescript(""" + CREATE TABLE program_session_planning( + program_session_id TEXT PRIMARY KEY,current_for TEXT,state TEXT); + CREATE TABLE programs(program_id TEXT PRIMARY KEY,deleted_at TEXT); + CREATE TABLE program_sessions( + program_session_id TEXT PRIMARY KEY,program_id TEXT); + INSERT INTO programs VALUES('pg_fixture',NULL); + INSERT INTO program_sessions VALUES('pgs_fixture','pg_fixture'); + INSERT INTO program_session_planning VALUES('pgs_fixture','2026-10-01','active'); + PRAGMA user_version=37; + """) + db.commit() + artifacts = ( + ("history", "trainlog-mobile-export", 4, "history.json", True, + "controlled.py", ()), + ("programs-v1", "trainlog-programs", 1, "programs-v1.json", False, + "export_programs.py", ()), + ("programs-v2", "trainlog-programs", 2, "programs-v2.json", False, + "export_programs_v2.py", ()), + ) + + def exporter(tool, _extra, output, _snapshot): + if tool == "controlled.py": + output.write_text(json.dumps({"format": "trainlog-mobile-export", "version": 4})) + return + output.write_text(json.dumps({"format": "trainlog-programs", + "version": 2 if tool.endswith("_v2.py") else 1, + "programs": [], "deletions": []})) + + with mock.patch.object(generation, "ARTIFACTS", artifacts), \ + mock.patch.object(generation, "run_export", exporter): + with self.assertRaisesRegex(generation.GenerationError, "Programs V2 capability"): + generation.capture_desktop(self.database, self.root / "legacy", PEER_B, RUN, GEN) + self.assertFalse((self.root / "legacy" / "staging" / GEN).exists()) + legacy_stage, legacy_manifest, _ = generation.capture_desktop( + self.database, self.root / "legacy-domain-only", PEER_B, RUN, GEN, + set(), True, + ) + self.assertFalse((legacy_stage / "programs-v1.json").exists()) + self.assertEqual(["history"], [a["logical_name"] for a in legacy_manifest["artifacts"]]) + stage, manifest, _ = generation.capture_desktop( + self.database, self.root / "modern", PEER_B, RUN, + "gen_55555555-5555-4555-8555-555555555555", + {"programs-v2"}, + ) + self.assertTrue((stage / "programs-v2.json").is_file()) + self.assertFalse((stage / "programs-v1.json").exists()) + self.assertEqual(["history", "programs-v2"], + [a["logical_name"] for a in manifest["artifacts"]]) + def test_archived_acknowledged_fork_is_not_an_active_parent_tip(self): active = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" archived = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" diff --git a/tools/cardio_calibration_exchange.py b/tools/cardio_calibration_exchange.py index 742ab4e..64fbbcd 100644 --- a/tools/cardio_calibration_exchange.py +++ b/tools/cardio_calibration_exchange.py @@ -271,8 +271,8 @@ def main() -> None: args = parser.parse_args() root = load(args.input) with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36): - fail("desktop schema v33-v36 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36, 37): + fail("desktop schema v33-v37 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/cardio_guidance_exchange.py b/tools/cardio_guidance_exchange.py index 305417a..3b9e1e4 100644 --- a/tools/cardio_guidance_exchange.py +++ b/tools/cardio_guidance_exchange.py @@ -395,8 +395,8 @@ def main() -> None: args = parser.parse_args() root = load(args.input) with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36): - fail("desktop schema v34-v36 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36, 37): + fail("desktop schema v34-v37 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py index 33fb85f..36ddcf9 100644 --- a/tools/causal_delete_exchange.py +++ b/tools/causal_delete_exchange.py @@ -38,8 +38,8 @@ def digest(value) -> str: def require_schema(db: sqlite3.Connection) -> None: version = db.execute("PRAGMA user_version").fetchone()[0] - if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): - raise CausalError(f"desktop schema v20-v36 required, found v{version}") + if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): + raise CausalError(f"desktop schema v20-v37 required, found v{version}") def target_snapshot(db: sqlite3.Connection, kind: str, target: str): diff --git a/tools/execution_draft_exchange.py b/tools/execution_draft_exchange.py index 044e310..f14246d 100644 --- a/tools/execution_draft_exchange.py +++ b/tools/execution_draft_exchange.py @@ -182,8 +182,8 @@ def causal_payload(draft): def require_schema(connection): version = connection.execute("PRAGMA user_version").fetchone()[0] - if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): - raise LifecycleError(f"desktop schema v19-v36 required, found v{version}") + if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): + raise LifecycleError(f"desktop schema v19-v37 required, found v{version}") def import_document(connection, document, own_transaction=True): diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index 95a70e6..03a58c9 100644 --- a/tools/export_ai_history.py +++ b/tools/export_ai_history.py @@ -12,7 +12,7 @@ from pathlib import Path from trainlog_sqlite import configure_connection -SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36) +SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37) MEASUREMENT_FIELDS = ( "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", @@ -125,7 +125,7 @@ def build_export(connection, generated_at): """Build the raw export using only explicitly ordered SELECT statements.""" schema_version = connection.execute("PRAGMA user_version").fetchone()[0] if schema_version not in SUPPORTED_SCHEMA_VERSIONS: - raise ValueError("schema desktop v15-v36 requis") + raise ValueError("schema desktop v15-v37 requis") root = { "format": "TRAINLOG_AI_EXPORT", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index 4a2bd1b..6f335b9 100644 --- a/tools/export_ai_session_drafts.py +++ b/tools/export_ai_session_drafts.py @@ -20,9 +20,9 @@ ID_PREFIX = "aid_" def ensure_publication_state(connection): """Install the additive v18 cursor for direct syncd/tool invocations.""" owned_transaction = not connection.in_transaction - supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36) + supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v18-v36 requis") + raise ValueError("schema desktop v18-v37 requis") columns = {row[1] for row in connection.execute( "PRAGMA table_info(ai_session_drafts)")} if "published_at" not in columns: diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 98dc379..bc5f268 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -28,9 +28,9 @@ def main(): args = parser.parse_args() connection = connect_database(args.database) try: - supported_versions = tuple(range(8, 37)) + supported_versions = tuple(range(8, 38)) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v36 requis") + raise ValueError("schema desktop v8 à v37 requis") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index 6065131..38fb5ef 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -24,9 +24,9 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(8, 37)) + supported_versions = tuple(range(8, 38)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v36 requis") + raise ValueError("schema desktop v8 à v37 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal equipment protection requires the staged artifact") equipment = [dict(row) for row in connection.execute( diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index 303fa2c..8205a07 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -17,8 +17,8 @@ def main(): con = connect_database(args.database) try: if con.execute("PRAGMA user_version").fetchone()[0] not in ( - 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): - raise ValueError("schema desktop v12-v36 requis") + 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): + raise ValueError("schema desktop v12-v37 requis") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "ORDER BY source_exercise_id COLLATE BINARY" diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index dad2c0e..db7ae24 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -41,9 +41,9 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(11, 37)) + supported_versions = tuple(range(11, 38)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v11-v36 requis") + raise ValueError("schema desktop v11-v37 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal BODY ZONES protection requires the staged artifact") exercises = [] diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index 9382d47..26bd187 100644 --- a/tools/export_exercise_profile_state.py +++ b/tools/export_exercise_profile_state.py @@ -25,7 +25,7 @@ def main(): con.row_factory = sqlite3.Row try: version = con.execute("PRAGMA user_version").fetchone()[0] - if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): + if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") rows = con.execute(""" SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 603a083..84a3282 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -71,7 +71,7 @@ def main(): # CONTRACT: v8 adds only desktop-local custom equipment. The PC # catalogue artifact is unchanged, but it must read the current # canonical desktop schema rather than accept a stale pre-v8 database. - if version not in range(8, 37): + if version not in range(8, 38): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 58e4692..c7ff02e 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -83,12 +83,12 @@ def main(complete_causal_envelope=False): con.row_factory = sqlite3.Row try: schema_version = con.execute("PRAGMA user_version").fetchone()[0] - if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): + if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") - supported_versions = range(11, 37) + supported_versions = range(11, 38) legacy_v2_schema = args.version == 2 and schema_version == 10 if schema_version not in supported_versions and not legacy_v2_schema: - raise ValueError("schema desktop v11-v36 requis (v10 accepté pour export V2 explicite)") + raise ValueError("schema desktop v11-v37 requis (v10 accepté pour export V2 explicite)") if not complete_causal_envelope and schema_version >= 20 and con.execute( "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone(): raise ValueError("causal protection refuses a mobile snapshot that omits tombstones") diff --git a/tools/export_programs.py b/tools/export_programs.py index 87b11ec..514be43 100644 --- a/tools/export_programs.py +++ b/tools/export_programs.py @@ -16,7 +16,7 @@ def nullable(row, index): def export_programs(database: Path) -> dict: with closing(connect_database(database)) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): + if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): raise ValueError("desktop schema v26 through v32 required") programs = [] for row in db.execute( diff --git a/tools/export_programs_v2.py b/tools/export_programs_v2.py new file mode 100644 index 0000000..1dc7c65 --- /dev/null +++ b/tools/export_programs_v2.py @@ -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() diff --git a/tools/export_session_preparations.py b/tools/export_session_preparations.py index 0e1e782..8cac033 100644 --- a/tools/export_session_preparations.py +++ b/tools/export_session_preparations.py @@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128 def build_export(connection: sqlite3.Connection) -> dict: - supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36) + supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise ValueError("desktop schema v24, v25, v26 through v32 required") deliveries = [] diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index 3fa6948..a829ae8 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -10,9 +10,9 @@ def main(complete_causal_envelope=False): parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args() db=connect_database(args.database) try: - supported_versions = range(15, 37) + supported_versions = range(15, 38) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v15-v36 requis") + raise ValueError("schema desktop v15-v37 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact") roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall() followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall() diff --git a/tools/heart_rate_correction_exchange.py b/tools/heart_rate_correction_exchange.py index 9a0127c..5db973a 100644 --- a/tools/heart_rate_correction_exchange.py +++ b/tools/heart_rate_correction_exchange.py @@ -61,8 +61,8 @@ def split_target(value: str) -> tuple[str, str]: def require_schema(db: sqlite3.Connection) -> None: version = db.execute("PRAGMA user_version").fetchone()[0] - if version not in range(27, 37): - raise CorrectionError(f"heart-rate correction requires schema v27-v36, found v{version}") + if version not in range(27, 38): + raise CorrectionError(f"heart-rate correction requires schema v27-v37, found v{version}") def _table_exists(db: sqlite3.Connection, table: str) -> bool: diff --git a/tools/heart_rate_exchange.py b/tools/heart_rate_exchange.py index 41735f9..c511203 100644 --- a/tools/heart_rate_exchange.py +++ b/tools/heart_rate_exchange.py @@ -282,8 +282,8 @@ def main() -> None: args = parser.parse_args() root = load(args.input) with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36): - fail("desktop schema v30-v36 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37): + fail("desktop schema v30-v37 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/import_ai_session_draft.py b/tools/import_ai_session_draft.py index 10d357b..e2343c0 100644 --- a/tools/import_ai_session_draft.py +++ b/tools/import_ai_session_draft.py @@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None): INVARIANT: validation and every child insert share one IMMEDIATE transaction; any failure leaves no draft, child, or import-identity row behind. """ - supported_versions = range(18, 37) + supported_versions = range(18, 38) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v18-v36 requis") + raise ImportFailure("schema desktop v18-v37 requis") draft, entries = validate(payload, connection) digest = canonical_digest(payload) imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index 10fd5e3..90358fc 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -206,9 +206,9 @@ def main(): payload = json.loads(args.artifact.read_text(encoding="utf-8")) connection = connect_database(args.database) try: - supported_versions = range(8, 37) + supported_versions = range(8, 38) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - fail("schema desktop v8 à v36 requis") + fail("schema desktop v8 à v37 requis") proof_path = args.mobile_export if proof_path is None: candidate = args.artifact.with_name("trainlog-mobile-export-v2.json") diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 0ad905b..1b2b6ac 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -53,9 +53,9 @@ def validate(payload, reserved): def apply_definitions(connection, definitions, complete_causal_envelope=False): """Apply validated definitions without owning the surrounding transaction.""" - supported_versions = range(8, 37) + supported_versions = range(8, 38) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v8 à v36 requis") + fail("schema desktop v8 à v37 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): fail("causal equipment protection requires the staged artifact") imported = skipped = 0 diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index ec0ae68..c335c7f 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -56,9 +56,9 @@ def load(path): def apply_aliases(con, aliases): """Apply parsed aliases inside the caller-owned transaction.""" - supported_versions = range(12, 37) + supported_versions = range(12, 38) if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v12-v36 requis") + fail("schema desktop v12-v37 requis") for source, canonical in aliases: target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() if target is None: fail(f"cible canonique absente: {canonical}") diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index 9bbe6d4..eff3204 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary): def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False): """Apply validated zone rows without owning transaction boundaries.""" - supported_versions = range(11, 37) + supported_versions = range(11, 38) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v11-v36 requis") + raise ImportFailure("schema desktop v11-v37 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact") connection.execute("PRAGMA foreign_keys=ON");grouped={} # CONTRACT: resolve complete alias groups before the first write; direct diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index db793d0..0304355 100644 --- a/tools/import_exercise_profile_state.py +++ b/tools/import_exercise_profile_state.py @@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False): if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide") parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row if con.execute("PRAGMA user_version").fetchone()[0] not in ( - 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): - fail("schema desktop v17-v36 requis") + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): + fail("schema desktop v17-v37 requis") applied=equal=ancestor=pending=0;seen=set() for item in root["exercises"]: validate(item);eid=item["exercise_id"] diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 2271db6..7d1f99b 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -847,9 +847,9 @@ def require_supported_schema(connection): # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # readable for legacy artifacts and are never made to fake that table. - if version not in range(5, 37): + if version not in range(5, 38): raise ImportFailure( - f"base desktop schema v5 à v36 attendue, version trouvée: {version}" + f"base desktop schema v5 à v37 attendue, version trouvée: {version}" ) @@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en require_supported_schema(connection) schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] - if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): - raise ImportFailure("mobile V4 exige le schéma desktop v19 à v36") + if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): + raise ImportFailure("mobile V4 exige le schéma desktop v19 à v37") if schema_version >= 20: protected = {(row[0], row[1]) for row in connection.execute( "SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index eaec2dc..4d8988c 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions): def apply_feedback(db,root,complete_causal_envelope=False): roots_added=roots_skipped=revisions_added=revisions_skipped=0 db.execute("PRAGMA foreign_keys=ON") - supported_versions = range(15, 37) + supported_versions = range(15, 38) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v15-v36 requis") + fail("schema desktop v15-v37 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact") seen=set() for index,item in enumerate(root["exercise_feedback"]): diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py index baea370..5707d1f 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -53,6 +53,7 @@ RUNTIME_TOOLS = [ "post_sync_ai_drive.py", "export_session_preparations.py", "export_programs.py", + "export_programs_v2.py", "program_execution_exchange.py", ] @@ -120,7 +121,7 @@ def main() -> int: ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ).strip(), "product_version": "0.1.7", - "desktop_schema": 36, + "desktop_schema": 37, "android_schema": 32, "protocols": [ "mobile-export-v3", diff --git a/tools/program_execution_exchange.py b/tools/program_execution_exchange.py index 1d40d06..a8bff46 100644 --- a/tools/program_execution_exchange.py +++ b/tools/program_execution_exchange.py @@ -87,7 +87,7 @@ def load(path: Path) -> dict: def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]: - if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36): + if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37): fail("desktop schema v27 through v32 required") added = 0 advanced = 0 diff --git a/tools/session_timeline_exchange.py b/tools/session_timeline_exchange.py index e69f369..bc26d0f 100644 --- a/tools/session_timeline_exchange.py +++ b/tools/session_timeline_exchange.py @@ -184,8 +184,8 @@ def main() -> None: args = parser.parse_args() root = load(args.input) with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36): - fail("desktop schema v31-v36 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36, 37): + fail("desktop schema v31-v37 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/sleep_diary_exchange.py b/tools/sleep_diary_exchange.py index 1538f4a..fbe5914 100644 --- a/tools/sleep_diary_exchange.py +++ b/tools/sleep_diary_exchange.py @@ -637,8 +637,8 @@ def main() -> None: import_command.add_argument("--database", required=True, type=Path) args = parser.parse_args() with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36): - fail("desktop schema v30-v36 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37): + fail("desktop schema v30-v37 required") if args.command == "export": args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True, separators=(",", ":")), encoding="utf-8") diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index 4144a6c..c6a657f 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -82,6 +82,8 @@ ARTIFACTS = ( "session-preparations-v2.json", False, "export_session_preparations.py", ()), ("programs-v1", "trainlog-programs", 1, "programs-v1.json", False, "export_programs.py", ()), + ("programs-v2", "trainlog-programs", 2, "programs-v2.json", False, + "export_programs_v2.py", ()), ("sleep-diary", "trainlog-sleep-diary", 2, "sleep-diary-v2.json", False, "sleep_diary_exchange.py", ("export",)), ) @@ -221,9 +223,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None): def require_schema(db: sqlite3.Connection) -> None: - supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36) + supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise GenerationError("desktop schema v24 through v36 required") + raise GenerationError("desktop schema v24 through v37 required") def peer_identity(db: sqlite3.Connection, kind: str) -> str: @@ -366,7 +368,9 @@ def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path) def capture_desktop(database: Path, owned_root: Path, consumer: str, - run_id: str | None = None, generation_id: str | None = None): + run_id: str | None = None, generation_id: str | None = None, + peer_capabilities: set[str] | None = None, + allow_program_skip: bool = False): if not valid_id(consumer, "peer"): raise GenerationError("invalid expected consumer") generation = generation_id or "gen_" + str(uuid.uuid4()) @@ -405,9 +409,35 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str, producer = peer_identity(source, "desktop") source.commit() source.backup(target) + with closing(connect_database(snapshot)) as planning_db: + has_planning_table = planning_db.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' " + "AND name='program_session_planning'" + ).fetchone() is not None + has_reschedule = has_planning_table and planning_db.execute( + "SELECT 1 FROM program_session_planning plan " + "JOIN program_sessions session ON session.program_session_id=plan.program_session_id " + "JOIN programs program ON program.program_id=session.program_id " + "WHERE program.deleted_at IS NULL LIMIT 1" + ).fetchone() is not None + supports_v2 = peer_capabilities is not None and "programs-v2" in peer_capabilities + # CONTRACT: one generation contains exactly one Program revision per + # identity. A V1-only peer cannot preserve a changed current schedule. + # INVARIANT: reject before publication rather than claiming convergence. + skip_programs = has_reschedule and not supports_v2 + if skip_programs and not allow_program_skip: + raise GenerationError("Programs V2 capability required for rescheduled programs") + if supports_v2 and not has_planning_table: + raise GenerationError("Programs V2 requires desktop schema v37") descriptors = [] total = 0 for logical, fmt, version, filename, required, tool, extra in ARTIFACTS: + if skip_programs and logical in ("programs-v1", "programs-v2"): + continue + if logical == "programs-v1" and supports_v2: + continue + if logical == "programs-v2" and not supports_v2: + continue output = stage / filename run_export(tool, extra, output, snapshot) size, checksum = digest_file(output) @@ -521,7 +551,7 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str, existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone() db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)", (operation["operation_id"], generation, 0 if existed else 1)) - programs_path = stage / "programs-v1.json" + programs_path = stage / ("programs-v2.json" if supports_v2 else "programs-v1.json") if programs_path.is_file(): captured_programs = strict_json(programs_path.read_bytes(), MAX_ARTIFACT) for deletion in captured_programs["deletions"]: diff --git a/tools/sync_peer_worker.py b/tools/sync_peer_worker.py index 3324c2b..f21af32 100644 --- a/tools/sync_peer_worker.py +++ b/tools/sync_peer_worker.py @@ -827,8 +827,13 @@ def main() -> int: # diagnostics to the separately bounded stderr channel. with redirect_stdout(sys.stderr): stage, manifest, digest = generation.capture_desktop( - args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id + args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id, + set(peer["capabilities"]), True, ) + program_domain_incompatible = not any( + artifact["logical_name"] in ("programs-v1", "programs-v2") + for artifact in manifest["artifacts"] + ) del stage published = generation.publish( args.database, outgoing_id, args.transport_root / "desktop-objects" @@ -926,6 +931,14 @@ def main() -> int: if inbound_row != ("consumed",) or outbound_row != ("acknowledged",): raise RuntimeError("generation evidence did not corroborate completion") emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id) + if program_domain_incompatible: + # WHY: the old peer cannot retain the new absolute schedule, but its + # unrelated domains can still converge through the same generation. + # CONTRACT: never report whole-run success while its Program view is stale. + raise RuntimeError( + "Programs V2 capability required: other domains synchronized, " + "Program planning not published to this peer" + ) ai_post_sync = publish_ai_export_to_android(args, deadline) report = { "format": "trainlog-sync-worker-report", diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 3a46c57..9679355 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -17,7 +17,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 36 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 37 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/include/trainlog/web_programs.h b/tui/include/trainlog/web_programs.h index c6d4c4a..7e380f3 100644 --- a/tui/include/trainlog/web_programs.h +++ b/tui/include/trainlog/web_programs.h @@ -63,4 +63,17 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database, char **output_json, size_t *output_size); +/* WHY: original Program dates are immutable source evidence. CONTRACT: the + * preview is read only; commit requires its exact fact/choice fingerprint, + * current Program revision and a stable operation ID. INVARIANT: one commit + * changes absolute planning dates in one transaction, never history rows. */ +TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database, + const char *program_id, + const char *operation_id, + const char *body, + size_t body_size, + bool commit, + char **output_json, + size_t *output_size); + #endif diff --git a/tui/meson.build b/tui/meson.build index 7885ff6..24fcb6e 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -239,6 +239,7 @@ trainlog_core_sources = files( 'src/web_sessions.c', 'src/web_session_deletions.c', 'src/web_programs.c', + 'src/web_program_reschedule.c', 'src/web_sleep.c', 'src/web_exercises.c', 'src/web_equipment.c', diff --git a/tui/src/database.c b/tui/src/database.c index d3edac4..e3a0fc5 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -1578,6 +1578,39 @@ static const char *const MIGRATE_V35_TO_V36_SQL = "immutable');END;" "PRAGMA user_version=36;COMMIT;"; +/* WHY: a Program's imported date is evidence, while an explicit reschedule + * owns its current target and an optional relinquishment of a future slot. + * CONTRACT: absent planning rows retain the original date and active state; + * operation identities bind replay to an exact request digest. INVARIANT: + * migration changes no execution, preparation, source date, or history row. */ +static const char *const MIGRATE_V36_TO_V37_SQL = + "BEGIN IMMEDIATE;" + "CREATE TABLE IF NOT EXISTS program_session_planning(" + "program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id) " + "ON DELETE RESTRICT," + "current_for TEXT," + "state TEXT NOT NULL CHECK(state IN('active','ceded'))," + "operation_id TEXT NOT NULL,updated_at TEXT NOT NULL," + "CHECK((state='active' AND current_for IS NOT NULL) OR " + "(state='ceded' AND current_for IS NULL)));" + "CREATE TABLE IF NOT EXISTS program_reschedule_operations(" + "operation_id TEXT PRIMARY KEY," + "program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT," + "request_sha256 TEXT NOT NULL CHECK(length(request_sha256)=64)," + "expected_revision TEXT NOT NULL," + "response_json TEXT NOT NULL,created_at TEXT NOT NULL);" + "CREATE INDEX IF NOT EXISTS program_reschedule_operations_program " + "ON program_reschedule_operations(program_id,created_at);" + "CREATE TABLE IF NOT EXISTS program_revision_sequences(" + "program_id TEXT PRIMARY KEY REFERENCES programs(program_id) ON DELETE RESTRICT," + "revision_sequence INTEGER NOT NULL CHECK(revision_sequence>=1));" + "CREATE TRIGGER IF NOT EXISTS programs_revision_sequence AFTER UPDATE OF revision_id " + "ON programs WHEN OLD.revision_id<>NEW.revision_id BEGIN " + "INSERT INTO program_revision_sequences(program_id,revision_sequence) " + "VALUES(NEW.program_id,1) ON CONFLICT(program_id) DO UPDATE SET " + "revision_sequence=revision_sequence+1;END;" + "PRAGMA user_version=37;COMMIT;"; + /* WHY: schema v29 was already opened on the private 0.1.4 review installation * before medication capture joined the same unreleased migration. CONTRACT: * this additive repair is identical to the tail of v28 -> v29 and runs only @@ -2413,7 +2446,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, version == 21 || version == 22 || version == 23 || version == 24 || version == 25 || version == 26 || version == 27 || version == 28 || version == 29 || version == 30 || version == 31 || version == 32 || version == 33 || version == 34 || version == 35 || - version == 36) { + version == 36 || version == 37) { status = TRAINLOG_STATUS_OK; } else { if (version == 1) { @@ -2585,6 +2618,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status == TRAINLOG_STATUS_OK && version < 36) { status = execute_sql(database, MIGRATE_V35_TO_V36_SQL); } + if (status == TRAINLOG_STATUS_OK && version < 37) { + status = execute_sql(database, MIGRATE_V36_TO_V37_SQL); + } if (status == TRAINLOG_STATUS_OK) { status = ensure_v18_ai_draft_publication_state(database); } @@ -2604,7 +2640,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status != TRAINLOG_STATUS_OK) { set_open_diagnostic(output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v36" : "migrate database to schema v36", + version == 0 ? "create schema v37" : "migrate database to schema v37", database->connection, SQLITE_ERROR); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); diff --git a/tui/src/web_program_reschedule.c b/tui/src/web_program_reschedule.c new file mode 100644 index 0000000..5bce888 --- /dev/null +++ b/tui/src/web_program_reschedule.c @@ -0,0 +1,645 @@ +#include "trainlog/web_programs.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#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; +} diff --git a/tui/src/web_programs.c b/tui/src/web_programs.c index d01b608..4aff46f 100644 --- a/tui/src/web_programs.c +++ b/tui/src/web_programs.c @@ -365,10 +365,12 @@ static TrainlogStatus add_program_session_json(TrainlogDatabase *database, !add_column_text(document, session, "title", statement, 2) || !add_column_text(document, session, "session_type", statement, 3) || !add_column_text(document, session, "planned_for", statement, 4) || - !add_column_text(document, session, "note", statement, 5) || - !add_column_text(document, session, "execution_state", statement, 6) || - !add_column_text(document, session, "execution_session_id", statement, 7) || - !add_column_text(document, session, "execution_started_at", statement, 8) || + !add_column_text(document, session, "current_for", statement, 5) || + !add_column_text(document, session, "planning_state", statement, 6) || + !add_column_text(document, session, "note", statement, 7) || + !add_column_text(document, session, "execution_state", statement, 8) || + !add_column_text(document, session, "execution_session_id", statement, 9) || + !add_column_text(document, session, "execution_started_at", statement, 10) || !yyjson_mut_obj_add_val(document, session, "occurrences", entries) || !yyjson_mut_arr_add_val(sessions, session)) { return TRAINLOG_STATUS_DATABASE_ERROR; @@ -383,7 +385,9 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database, yyjson_mut_val *sessions) { static const char SESSION_SQL[] = "SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for," - "ps.note,CASE WHEN pe.state='completed' THEN 'completed' " + "CASE WHEN pp.state='ceded' THEN NULL ELSE COALESCE(pp.current_for,ps.planned_for) END," + "COALESCE(pp.state,'active'),ps.note," + "CASE WHEN pe.state='completed' THEN 'completed' " "WHEN pe.state='in_progress' THEN 'in_progress' " "WHEN pe.state='deleted' THEN 'deleted' " "WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE " @@ -394,7 +398,9 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database, * CONTRACT: expose that timestamp only for completed executions. * INVARIANT: the imported planned_for date and history are read only. */ "CASE WHEN pe.state='completed' THEN actual.started_at ELSE NULL END " - "FROM program_sessions ps LEFT JOIN program_session_executions pe " + "FROM program_sessions ps LEFT JOIN program_session_planning pp " + "ON pp.program_session_id=ps.program_session_id " + "LEFT JOIN program_session_executions pe " "ON pe.program_session_id=ps.program_session_id " "LEFT JOIN sessions actual ON actual.session_id=pe.session_id " "WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id"; @@ -1165,7 +1171,25 @@ static TrainlogStatus program_replay_archive(TrainlogDatabase *database, result = SQLITE_OK; } (void)sqlite3_finalize(statement); - return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; + if (result != SQLITE_OK || *found) { + return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; + } + statement = NULL; + result = sqlite3_prepare_v2(database->connection, + "SELECT 1 FROM program_reschedule_operations WHERE operation_id=?", + -1, + &statement, + NULL); + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_step(statement); + } + (void)sqlite3_finalize(statement); + return result == SQLITE_ROW ? TRAINLOG_STATUS_CONFLICT + : result == SQLITE_DONE ? TRAINLOG_STATUS_OK + : TRAINLOG_STATUS_DATABASE_ERROR; } static TrainlogStatus program_apply_archive(TrainlogDatabase *database, @@ -1357,7 +1381,9 @@ static ProgramDeleteReplay program_replay_delete(TrainlogDatabase *database, } result = sqlite3_prepare_v2(database->connection, - "SELECT 1 FROM program_requests WHERE request_id=?1", + "SELECT 1 FROM program_requests WHERE request_id=?1 " + "UNION ALL SELECT 1 FROM program_reschedule_operations " + "WHERE operation_id=?1", -1, &statement, NULL); @@ -1609,7 +1635,7 @@ static char *program_build_preparation_body(yyjson_val *session, command, root, "planned_for", - yyjson_val_mut_copy(command, yyjson_obj_get(session, "planned_for"))); + yyjson_val_mut_copy(command, yyjson_obj_get(session, "current_for"))); (void)yyjson_mut_obj_add_val( command, root, "notes", yyjson_val_mut_copy(command, yyjson_obj_get(session, "note"))); (void)yyjson_mut_obj_add_strcpy(command, root, "editing_state", "draft"); @@ -1652,6 +1678,10 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database, yyjson_doc_free(document); return TRAINLOG_STATUS_NOT_FOUND; } + if (!yyjson_equals_str(yyjson_obj_get(session, "planning_state"), "active")) { + yyjson_doc_free(document); + return TRAINLOG_STATUS_CONFLICT; + } body = program_build_preparation_body(session, program_id, program_session_id, &body_size); yyjson_doc_free(document); if (body == NULL) { diff --git a/tui/src/web_server.c b/tui/src/web_server.c index a515e20..204568f 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -705,14 +705,22 @@ static enum MHD_Result queue_owned_programs_json(struct MHD_Connection *connecti return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL); } if (status == TRAINLOG_STATUS_CONFLICT) { + enum MHD_Result result = + queue_json(connection, + MHD_HTTP_CONFLICT, + json == NULL ? "{\"error\":\"program_conflict\"}\n" : json, + NULL); free(json); - return queue_json( - connection, MHD_HTTP_CONFLICT, "{\"error\":\"program_conflict\"}\n", NULL); + return result; } if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) { + enum MHD_Result result = + queue_json(connection, + MHD_HTTP_UNPROCESSABLE_CONTENT, + json == NULL ? "{\"error\":\"invalid_program\"}\n" : json, + NULL); free(json); - return queue_json( - connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_program\"}\n", NULL); + return result; } return queue_owned_sessions_json(connection, status, json, json_size); } @@ -930,6 +938,8 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context, static const char PREFIX[] = "/api/v1/sessions/program/"; static const char ARCHIVE_SUFFIX[] = "/archive"; static const char DELETE_SUFFIX[] = "/delete"; + static const char PREVIEW_SUFFIX[] = "/reschedule/preview"; + static const char APPLY_SUFFIX[] = "/reschedule/apply"; const char *identity; const char *sessions_marker; char *json = NULL; @@ -950,6 +960,38 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context, identity = url + strlen(PREFIX); identity_length = strlen(identity); sessions_marker = strstr(identity, "/sessions/"); + if ((identity_length > strlen(PREVIEW_SUFFIX) && + strcmp(identity + identity_length - strlen(PREVIEW_SUFFIX), PREVIEW_SUFFIX) == 0) || + (identity_length > strlen(APPLY_SUFFIX) && + strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0)) { + bool commit = strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0; + size_t suffix_length = commit ? strlen(APPLY_SUFFIX) : strlen(PREVIEW_SUFFIX); + char program_id[TRAINLOG_ID_MAX + 1U]; + const char *operation_id = + MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID"); + if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + "POST"); + } + if (!program_mutation_allowed(context, connection) || + !copy_path_component( + program_id, sizeof(program_id), identity, identity_length - suffix_length) || + (commit && (operation_id == NULL || operation_id[0] == '\0'))) { + return queue_json( + connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL); + } + status = trainlog_web_programs_reschedule_json(context->database, + program_id, + operation_id, + request->body, + request->body_size, + commit, + &json, + &json_size); + return queue_owned_programs_json(connection, status, json, json_size); + } if (identity_length > strlen(ARCHIVE_SUFFIX) && strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) { return handle_program_archive(context, connection, identity, identity_length, method); diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 89ff61b..35dc68e 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) { TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); CHECK(strncmp(diagnostic, - "migrate database to schema v36: SQLite rc=", - strlen("migrate database to schema v36: SQLite rc=")) == 0); + "migrate database to schema v37: SQLite rc=", + strlen("migrate database to schema v37: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) { CHECK(fd >= 0); CHECK(close(fd) == 0); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(sqlite3_exec(raw, "PRAGMA user_version=37;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=38;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); CHECK(database == NULL); - CHECK(strstr(diagnostic, "schema version 37 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 38 is newer") != NULL); CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 2f3973a..3e9570e 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) { CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strstr(diagnostic, "migrate database to schema v36") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v37") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); diff --git a/tui/tests/test_web_programs.c b/tui/tests/test_web_programs.c index 14fb1bc..cede6c9 100644 --- a/tui/tests/test_web_programs.c +++ b/tui/tests/test_web_programs.c @@ -608,10 +608,343 @@ static bool legacy_integer_weight_zero_prepares_editable_draft(void) { return true; } +static bool ordered_reschedule_is_atomic_and_idempotent(void) { + static const char *const DATES[] = { + "2026-09-21", "2026-09-22", "2026-09-23", "2026-09-24", "2026-09-25", "2026-09-26", + "2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01", "2026-10-02", "2026-10-03", + "2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09", "2026-10-10", + "2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", + }; + static const char REQUEST[] = "{\"start_session_id\":\"pgs_fixture_07\"," + "\"through_session_id\":\"pgs_fixture_22\"," + "\"start_date\":\"2026-10-01\"," + "\"ceded_session_ids\":[\"pgs_fixture_11\",\"pgs_fixture_17\"]," + "\"expected_revision\":\"pgr_fixture\"}"; + TrainlogDatabase *database = NULL; + char *preview = NULL; + char *applied = NULL; + char *replayed = NULL; + char *request = NULL; + size_t size = 0U; + size_t applied_size = 0U; + char fingerprint[80]; + char sql[1024]; + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(sqlite3_exec(database->connection, + "INSERT INTO programs(program_id,title,state,start_date,end_date," + "created_at,updated_at,revision_id,source_format,source_version," + "source_payload_sha256) VALUES('pg_fixture','Fixture','active'," + "'2026-09-21','2026-10-18','2026-09-20T00:00:00Z'," + "'2026-09-20T00:00:00Z','pgr_fixture','trainlog-program',1," + "'0000000000000000000000000000000000000000000000000000000000000000')", + NULL, + NULL, + NULL) == SQLITE_OK); + for (size_t index = 0U; index < 24U; ++index) { + (void)snprintf(sql, + sizeof(sql), + "INSERT INTO program_sessions VALUES('pgs_fixture_%02zu','pg_fixture'," + "%zu,'Session %zu','training','%s',NULL)", + index, + index, + index, + DATES[index]); + CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK); + if (index < 7U) { + const char *actual = index == 5U ? "2026-09-28T09:00:00+02:00" + : index == 6U ? "2026-09-30T17:00:00+02:00" + : "2026-09-21T09:00:00+02:00"; + (void)snprintf(sql, + sizeof(sql), + "INSERT INTO sessions(session_id,started_at) " + "VALUES('se_fixture_%02zu','%s')", + index, + actual); + CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK); + (void)snprintf(sql, + sizeof(sql), + "INSERT INTO program_session_executions VALUES(" + "'pgs_fixture_%02zu','pg_fixture','se_fixture_%02zu'," + "'completed','2026-09-30T00:00:00Z')", + index, + index); + CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK); + } + } + CHECK(sqlite3_exec(database->connection, + "INSERT INTO sessions(session_id,started_at) " + "VALUES('se_evening_stretch','2026-10-01T20:00:00+02:00')", + NULL, + NULL, + NULL) == SQLITE_OK); + char *invalid_date = replace_once(REQUEST, "2026-10-01", "2026-02-30"); + CHECK(invalid_date != NULL); + CHECK(trainlog_web_programs_reschedule_json(database, + "pg_fixture", + NULL, + invalid_date, + strlen(invalid_date), + false, + &replayed, + &size) == TRAINLOG_STATUS_INVALID_ARGUMENT); + free(invalid_date); + free(replayed); + replayed = NULL; + char *too_late = replace_once(REQUEST, "2026-10-01", "2026-10-16"); + CHECK(too_late != NULL); + CHECK(trainlog_web_programs_reschedule_json( + database, "pg_fixture", NULL, too_late, strlen(too_late), false, &replayed, &size) == + TRAINLOG_STATUS_INVALID_ARGUMENT); + CHECK(strstr(replayed, "insufficient_or_duplicate_slots") != NULL); + free(too_late); + free(replayed); + replayed = NULL; + // The DST transition on October 25 is a rest day in this fixture. + CHECK(sqlite3_exec(database->connection, + "INSERT INTO programs(program_id,title,state,start_date,end_date," + "created_at,updated_at,revision_id,source_format,source_version," + "source_payload_sha256) VALUES('pg_dst','DST','active','2026-10-24'," + "'2026-10-27','2026-10-20T00:00:00Z','2026-10-20T00:00:00Z'," + "'pgr_dst','trainlog-program',1," + "'0000000000000000000000000000000000000000000000000000000000000000');" + "INSERT INTO program_sessions VALUES" + "('pgs_dst_0','pg_dst',0,'Before','training','2026-10-24',NULL)," + "('pgs_dst_1','pg_dst',1,'After','training','2026-10-26',NULL)," + "('pgs_dst_2','pg_dst',2,'Next','training','2026-10-27',NULL)", + NULL, + NULL, + NULL) == SQLITE_OK); + static const char DST_REQUEST[] = + "{\"start_session_id\":\"pgs_dst_0\",\"through_session_id\":\"pgs_dst_2\"," + "\"start_date\":\"2026-10-24\",\"ceded_session_ids\":[]," + "\"expected_revision\":\"pgr_dst\"}"; + CHECK( + trainlog_web_programs_reschedule_json( + database, "pg_dst", NULL, DST_REQUEST, strlen(DST_REQUEST), false, &replayed, &size) == + TRAINLOG_STATUS_OK); + CHECK(strstr(replayed, "\"new_for\":\"2026-10-26\"") != NULL); + CHECK(strstr(replayed, "2026-10-25") == NULL); + free(replayed); + replayed = NULL; + CHECK(trainlog_web_programs_reschedule_json( + database, "pg_fixture", NULL, REQUEST, strlen(REQUEST), false, &preview, &size) == + TRAINLOG_STATUS_OK); + CHECK(extract_string(preview, "preview_sha256", fingerprint, sizeof(fingerprint))); + CHECK(strstr(preview, + "\"program_session_id\":\"pgs_fixture_07\"," + "\"original_for\":\"2026-09-29\"," + "\"old_for\":\"2026-09-29\"," + "\"new_for\":\"2026-10-01\"") != NULL); + CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 0); + request = malloc(strlen(REQUEST) + strlen(fingerprint) + 32U); + CHECK(request != NULL); + (void)snprintf(request, + strlen(REQUEST) + strlen(fingerprint) + 32U, + "%.*s,\"preview_sha256\":\"%s\"}", + (int)strlen(REQUEST) - 1, + REQUEST, + fingerprint); + CHECK(sqlite3_exec(database->connection, + "INSERT INTO sync_generations(generation_id,run_id,producer_peer_id," + "consumer_peer_id,producer_kind,generated_at,manifest_sha256,status," + "manifest_json,staging_path) VALUES('gen_fixture','sy_fixture'," + "'peer_desktop','peer_phone','desktop'," + "strftime('%Y-%m-%dT%H:%M:%SZ','now'),'digest','captured','{}','/tmp')", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(sqlite3_exec(database->connection, + "INSERT INTO sync_generation_artifacts VALUES('gen_fixture','programs-v2'," + "'trainlog-programs',2,'programs-v2.json',0,'digest',0)", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(trainlog_web_programs_reschedule_json(database, + "pg_fixture", + "op_fixture", + request, + strlen(request), + true, + &replayed, + &size) == TRAINLOG_STATUS_CONFLICT); + CHECK(strstr(replayed, "program_peer_not_fresh") != NULL); + free(replayed); + replayed = NULL; + CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 0); + CHECK(sqlite3_exec(database->connection, + "UPDATE sync_generations SET status='acknowledged'," + "acknowledged_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') " + "WHERE generation_id='gen_fixture'", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(trainlog_web_programs_reschedule_json(database, + "pg_fixture", + "op_fixture", + request, + strlen(request), + true, + &applied, + &applied_size) == TRAINLOG_STATUS_OK); + CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 11); + CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning WHERE state='ceded'") == + 2); + CHECK(scalar(database, + "SELECT COUNT(DISTINCT current_for) FROM program_session_planning " + "WHERE state='active'") == 9); + CHECK(scalar(database, + "SELECT COUNT(*) FROM program_session_executions " + "WHERE state='completed'") == 7); + CHECK(scalar(database, + "SELECT COUNT(*) FROM sessions WHERE " + "session_id='se_fixture_05' AND started_at='2026-09-28T09:00:00+02:00'") == 1); + CHECK(scalar(database, + "SELECT COUNT(*) FROM sessions WHERE " + "session_id='se_fixture_06' AND started_at='2026-09-30T17:00:00+02:00'") == 1); + CHECK(scalar(database, + "SELECT COUNT(*) FROM sessions WHERE " + "session_id='se_evening_stretch' AND started_at='2026-10-01T20:00:00+02:00'") == + 1); + CHECK(scalar(database, + "SELECT COUNT(*) FROM program_sessions WHERE " + "planned_for='2026-09-29' AND program_session_id='pgs_fixture_07'") == 1); + CHECK(scalar(database, + "SELECT COUNT(*) FROM program_session_planning WHERE " + "program_session_id='pgs_fixture_09' AND current_for='2026-10-03'") == 1); + CHECK(scalar(database, + "SELECT COUNT(*) FROM program_session_planning WHERE " + "program_session_id='pgs_fixture_22'") == 0); + CHECK(trainlog_web_programs_reschedule_json(database, + "pg_fixture", + "op_fixture", + request, + strlen(request), + true, + &replayed, + &size) == TRAINLOG_STATUS_OK); + CHECK(strcmp(applied, replayed) == 0); + CHECK(scalar(database, "SELECT COUNT(*) FROM program_reschedule_operations") == 1); + free(replayed); + replayed = NULL; + char *different_request = replace_once(request, "2026-10-01", "2026-10-02"); + CHECK(different_request != NULL); + CHECK(trainlog_web_programs_reschedule_json(database, + "pg_fixture", + "op_fixture", + different_request, + strlen(different_request), + true, + &replayed, + &size) == TRAINLOG_STATUS_CONFLICT); + free(different_request); + free(replayed); + replayed = NULL; + char new_revision[80]; + char next_fingerprint[80]; + CHECK(extract_string(applied, "new_revision_id", new_revision, sizeof(new_revision))); + CHECK(scalar(database, + "SELECT revision_sequence FROM program_revision_sequences " + "WHERE program_id='pg_fixture'") == 1); + char second_choice[512]; + (void)snprintf(second_choice, + sizeof(second_choice), + "{\"start_session_id\":\"pgs_fixture_07\"," + "\"through_session_id\":\"pgs_fixture_09\"," + "\"start_date\":\"2026-10-01\",\"ceded_session_ids\":[]," + "\"expected_revision\":\"%s\"}", + new_revision); + CHECK(trainlog_web_programs_reschedule_json(database, + "pg_fixture", + NULL, + second_choice, + strlen(second_choice), + false, + &replayed, + &size) == TRAINLOG_STATUS_OK); + CHECK(strstr(replayed, + "\"program_session_id\":\"pgs_fixture_09\"," + "\"original_for\":\"2026-10-01\"," + "\"old_for\":\"2026-10-03\"," + "\"new_for\":\"2026-10-03\"") != NULL); + free(replayed); + replayed = NULL; + char *next_choice = replace_once(REQUEST, "pgr_fixture", new_revision); + CHECK(next_choice != NULL); + CHECK(trainlog_web_programs_reschedule_json(database, + "pg_fixture", + NULL, + next_choice, + strlen(next_choice), + false, + &replayed, + &size) == TRAINLOG_STATUS_OK); + CHECK(extract_string(replayed, "preview_sha256", next_fingerprint, sizeof(next_fingerprint))); + free(replayed); + replayed = NULL; + char *next_request = malloc(strlen(next_choice) + strlen(next_fingerprint) + 32U); + CHECK(next_request != NULL); + (void)snprintf(next_request, + strlen(next_choice) + strlen(next_fingerprint) + 32U, + "%.*s,\"preview_sha256\":\"%s\"}", + (int)strlen(next_choice) - 1, + next_choice, + next_fingerprint); + CHECK(sqlite3_exec(database->connection, + "CREATE TRIGGER fail_reschedule_fixture BEFORE INSERT ON " + "program_reschedule_operations WHEN NEW.operation_id='op_failure' " + "BEGIN SELECT RAISE(ABORT,'fixture failure'); END", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(trainlog_web_programs_reschedule_json(database, + "pg_fixture", + "op_failure", + next_request, + strlen(next_request), + true, + &replayed, + &size) == TRAINLOG_STATUS_DATABASE_ERROR); + free(replayed); + replayed = NULL; + CHECK(scalar(database, "SELECT COUNT(*) FROM program_reschedule_operations") == 1); + (void)snprintf(sql, + sizeof(sql), + "SELECT COUNT(*) FROM programs WHERE program_id='pg_fixture' " + "AND revision_id='%s'", + new_revision); + CHECK(scalar(database, sql) == 1); + CHECK(sqlite3_exec(database->connection, + "INSERT INTO program_session_executions VALUES(" + "'pgs_fixture_07','pg_fixture','se_late','completed'," + "'2026-10-01T10:00:00Z')", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(trainlog_web_programs_reschedule_json(database, + "pg_fixture", + "op_stale", + next_request, + strlen(next_request), + true, + &replayed, + &size) == TRAINLOG_STATUS_CONFLICT); + CHECK(strstr(replayed, "prepared_or_executed_session") != NULL); + free(replayed); + free(next_request); + free(next_choice); + free(applied); + free(preview); + free(request); + trainlog_database_close(database); + return true; +} + int main(void) { return import_archive_and_prepare() && strict_import_rejections() && mixed_profile_prepare_regression() && - legacy_integer_weight_zero_prepares_editable_draft() + legacy_integer_weight_zero_prepares_editable_draft() && + ordered_reschedule_is_atomic_and_idempotent() ? EXIT_SUCCESS : EXIT_FAILURE; } diff --git a/web/src/api/programs.ts b/web/src/api/programs.ts index 1186814..40c0885 100644 --- a/web/src/api/programs.ts +++ b/web/src/api/programs.ts @@ -38,6 +38,8 @@ export interface ProgramSession { title: string session_type: 'training' | 'max_test' planned_for: string | null + current_for: string | null + planning_state: 'active' | 'ceded' note: string | null execution_state: 'todo' | 'prepared' | 'in_progress' | 'completed' | 'deleted' execution_session_id: string | null @@ -62,6 +64,30 @@ export interface ProgramDetail { sessions: ProgramSession[] } +export interface RescheduleChoice { + start_session_id: string + through_session_id: string + start_date: string + ceded_session_ids: string[] + expected_revision: string +} + +export interface RescheduleMove { + program_session_id: string + original_for: string | null + old_for: string | null + new_for: string | null + change: 'rescheduled' | 'ceded' | 'unchanged' +} + +export interface ReschedulePreview { + api_version: 1 + program_id: string + revision_id: string + preview_sha256: string + moves: RescheduleMove[] +} + export interface ProgramImportPreview { api_version: 1 program_id: string @@ -106,6 +132,47 @@ function requestId(prefix = 'web'): string { return `${prefix}_${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}` } +export function newRescheduleOperationId(): string { + return requestId('pr') +} + +async function rescheduleRequest( + programId: string, + mode: 'preview' | 'apply', + choice: RescheduleChoice & { preview_sha256?: string }, + operationId?: string, +): Promise { + const csrf = await mutationCsrfToken() + const response = await fetch( + `/api/v1/sessions/program/${encodeURIComponent(programId)}/reschedule/${mode}`, + { + method: 'POST', + headers: { + Accept: 'application/json', 'Content-Type': 'application/json', + 'X-Trainlog-CSRF-Token': csrf, + 'X-Trainlog-Request-ID': operationId ?? requestId('pr'), + }, + body: JSON.stringify(choice), + }, + ) + if (!response.ok) throw new Error(await errorReason(response)) + const value: unknown = await response.json() + if (!object(value) || value.api_version !== 1 || value.program_id !== programId || + typeof value.revision_id !== 'string' || typeof value.preview_sha256 !== 'string' || + !Array.isArray(value.moves)) throw new TypeError('aperçu de recalage invalide') + return value as unknown as ReschedulePreview +} + +export function previewProgramReschedule(programId: string, choice: RescheduleChoice) { + return rescheduleRequest(programId, 'preview', choice) +} + +export function applyProgramReschedule( + programId: string, choice: RescheduleChoice, previewSha256: string, operationId: string, +) { + return rescheduleRequest(programId, 'apply', { ...choice, preview_sha256: previewSha256 }, operationId) +} + export async function fetchAllPrograms( search = '', state = '', diff --git a/web/src/routes/ProgramsCalendarPage.test.tsx b/web/src/routes/ProgramsCalendarPage.test.tsx index 45a1eca..2c7d099 100644 --- a/web/src/routes/ProgramsCalendarPage.test.tsx +++ b/web/src/routes/ProgramsCalendarPage.test.tsx @@ -9,6 +9,8 @@ const api = vi.hoisted(() => ({ fetchAllPrograms: vi.fn(), fetchProgram: vi.fn(), createPreparationFromProgram: vi.fn(), + previewProgramReschedule: vi.fn(), + applyProgramReschedule: vi.fn(), })) vi.mock('../api/programs', async (importOriginal) => ({ @@ -45,6 +47,8 @@ function session( title, session_type: 'training', planned_for: plannedFor, + current_for: plannedFor, + planning_state: 'active', note: null, execution_state: executionState, execution_session_id: null, @@ -88,6 +92,8 @@ describe('ProgramsCalendarPage', () => { api.fetchAllPrograms.mockReset() api.fetchProgram.mockReset() api.createPreparationFromProgram.mockReset() + api.previewProgramReschedule.mockReset() + api.applyProgramReschedule.mockReset() api.fetchAllPrograms.mockResolvedValue([item()]) api.fetchProgram.mockResolvedValue(detail()) }) @@ -101,6 +107,53 @@ describe('ProgramsCalendarPage', () => { expect(screen.queryByRole('combobox', { name: 'Programme actif' })).not.toBeInTheDocument() }) + it('keeps distinct cards on one date and labels a ceded slot separately', async () => { + const main = session('pgs_main', 'Séance principale', '2026-09-18') + const evening = session('pgs_evening', 'Étirements du soir', '2026-09-18') + const ceded = { ...session('pgs_ceded', 'Séance écartée', '2026-09-19'), + current_for: null, planning_state: 'ceded' as const } + api.fetchProgram.mockResolvedValue(detail({ sessions: [main, evening, ceded] })) + render() + + 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() + await screen.findByRole('heading', { level: 1, name: 'Cycle force' }) + fireEvent.change(screen.getByLabelText('Première séance'), { target: { value: 'pgs_b' } }) + fireEvent.change(screen.getByLabelText('Dernière séance'), { target: { value: 'pgs_c' } }) + fireEvent.change(screen.getByLabelText('Date de reprise'), { target: { value: '2026-09-19' } }) + fireEvent.click(screen.getByLabelText(/C \(2026-09-19\)/)) + fireEvent.click(screen.getByRole('button', { name: 'Calculer l’aperçu' })) + expect(await screen.findByRole('heading', { name: 'Aperçu avant application' })).toBeInTheDocument() + expect(api.applyProgramReschedule).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Confirmer ce recalage' })) + await waitFor(() => expect(api.applyProgramReschedule).toHaveBeenCalledTimes(1)) + expect(api.applyProgramReschedule.mock.calls[0][2]).toBe('f'.repeat(64)) + }) + it('shows the exact empty state and navigates to Sessions program administration', async () => { api.fetchAllPrograms.mockResolvedValue([]) const onNavigate = vi.fn() diff --git a/web/src/routes/ProgramsCalendarPage.tsx b/web/src/routes/ProgramsCalendarPage.tsx index f5bbc33..a1b5039 100644 --- a/web/src/routes/ProgramsCalendarPage.tsx +++ b/web/src/routes/ProgramsCalendarPage.tsx @@ -1,11 +1,16 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { createPreparationFromProgram, + applyProgramReschedule, fetchAllPrograms, fetchProgram, + newRescheduleOperationId, + previewProgramReschedule, type ProgramDetail, type ProgramListItem, type ProgramSession, + type RescheduleChoice, + type ReschedulePreview, } from '../api/programs' import { useDatePreferences } from '../presentation/DatePreferences' import { formatCivilDate, formatDate, parseCivilDate, validTimestampValue } from '../presentation/dateFormat' @@ -59,7 +64,8 @@ function displayedDate(session: ProgramSession): string | null { // A completed link without a valid history timestamp must not masquerade as // a workout performed on the imported plan date. return session.execution_state === 'completed' - ? actualExecutionDate(session) : session.planned_for + ? actualExecutionDate(session) + : session.planning_state === 'ceded' ? session.planned_for : session.current_for } function calendarDays(program: ProgramDetail): CalendarDay[] { @@ -126,7 +132,13 @@ function ProgramSessionCard({ programId, session, pending, preparationId, onPrep const actualDate = actualExecutionDate(session) return

{session.title}

- {stateLabels[session.execution_state]} + {session.planning_state === 'ceded' && + session.execution_state !== 'completed' ? 'Créneau cédé' : stateLabels[session.execution_state]} + {session.planning_state === 'active' && session.current_for !== session.planned_for && + session.execution_state !== 'completed' &&

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

} {actualDate !== null && actualDate !== session.planned_for &&

Prévue le {session.planned_for === null ? 'date non définie' : formatCivilDate(session.planned_for, dateFormat)} {' · '}effectuée le {formatCivilDate(actualDate, dateFormat)} @@ -134,7 +146,8 @@ function ProgramSessionCard({ programId, session, pending, preparationId, onPrep {session.execution_state === 'completed' && actualDate === null &&

Date réelle indisponible

}
- {session.execution_state === 'todo' && } @@ -156,6 +169,14 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string const [pendingSessions, setPendingSessions] = useState>(new Set()) const [preparationIds, setPreparationIds] = useState>({}) const [announcement, setAnnouncement] = useState('') + const [rescheduleStart, setRescheduleStart] = useState('') + const [rescheduleThrough, setRescheduleThrough] = useState('') + const [rescheduleDate, setRescheduleDate] = useState('') + const [cededIds, setCededIds] = useState([]) + const [reschedulePreview, setReschedulePreview] = useState<{ + choice: RescheduleChoice, result: ReschedulePreview, operationId: string + } | null>(null) + const [reschedulePending, setReschedulePending] = useState(false) const preparing = useRef(new Set()) const mounted = useRef(true) const selectedIdRef = useRef('') @@ -200,6 +221,11 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string setDetailPending(true) setDetailError('') setAnnouncement('') + setReschedulePreview(null) + setRescheduleStart('') + setRescheduleThrough('') + setRescheduleDate('') + setCededIds([]) setPreparationIds({}) fetchProgram(selectedId, controller.signal).then((value) => { if (isCurrent()) setDetail(value) @@ -215,6 +241,54 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string const days = useMemo(() => detail === null ? [] : calendarDays(detail), [detail]) const undatedSessions = detail?.sessions.filter((session) => dateValue(displayedDate(session)) === null) ?? [] + const openSessions = detail?.sessions.filter((session) => session.execution_state === 'todo') ?? [] + + const previewReschedule = async () => { + if (!detail || !rescheduleStart || !rescheduleThrough || !rescheduleDate) return + const programId = detail.program_id + const generation = selectionGeneration.current + const choice: RescheduleChoice = { + start_session_id: rescheduleStart, + through_session_id: rescheduleThrough, + start_date: rescheduleDate, + ceded_session_ids: cededIds, + expected_revision: detail.revision_id, + } + setReschedulePending(true) + setReschedulePreview(null) + try { + const result = await previewProgramReschedule(programId, choice) + if (mounted.current && selectionGeneration.current === generation) { + setReschedulePreview({ choice, result, operationId: newRescheduleOperationId() }) + setAnnouncement('Aperçu calculé. Vérifiez les dates avant de confirmer.') + } + } catch (reason) { + setAnnouncement(`Recalage impossible : ${errorMessage(reason, 'erreur inconnue')}`) + } finally { + setReschedulePending(false) + } + } + + const confirmReschedule = async () => { + if (!detail || !reschedulePreview) return + const { choice, result, operationId } = reschedulePreview + const programId = detail.program_id + const generation = selectionGeneration.current + setReschedulePending(true) + try { + await applyProgramReschedule(programId, choice, result.preview_sha256, operationId) + const reread = await fetchProgram(programId) + if (mounted.current && selectionGeneration.current === generation) { + setDetail(reread) + setReschedulePreview(null) + setAnnouncement('Recalage enregistré. La synchronisation Android reste à vérifier.') + } + } catch (reason) { + setAnnouncement(`Recalage non confirmé : ${errorMessage(reason, 'erreur inconnue')}`) + } finally { + setReschedulePending(false) + } + } const prepare = async (programId: string, session: ProgramSession) => { const sessionId = session.program_session_id @@ -316,6 +390,54 @@ export function ProgramsCalendarPage({ onNavigate }: { onNavigate: (path: string

{announcement}

+
+

Recaler la suite

+

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.

+
+ + + +
+
Séances qui cèdent leur créneau + {openSessions.map((session) => )} +
+ + {reschedulePreview &&
+

Aperçu avant application

+ + + {reschedulePreview.result.moves.map((move) => + + + + + )} +
SéanceDate initialeDate actuelleNouvelle dateChangement
{detail.sessions.find((session) => session.program_session_id === move.program_session_id)?.title}{move.original_for ?? '—'}{move.old_for ?? '—'}{move.new_for ?? '—'}{move.change === 'ceded' ? 'Créneau cédé' + : move.change === 'unchanged' ? 'Inchangée' : 'Recalée'}
+ +
} +
{days.length > 0 &&
diff --git a/web/src/theme/app.css b/web/src/theme/app.css index 24e878b..4b064e4 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -1978,6 +1978,43 @@ h2 { color: var(--subtext-0); font-size: var(--font-size-sm); } +.program-calendar-reschedule { + margin: 0 0 1rem; + padding: 1rem; + border: 1px solid var(--surface-1); + border-radius: 0.75rem; +} +.program-calendar-reschedule-fields { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} +.program-calendar-reschedule-fields label { + display: grid; + gap: 0.25rem; + min-width: min(100%, 12rem); +} +.program-calendar-reschedule fieldset { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + margin: 0.75rem 0; +} +.program-calendar-reschedule-preview { + max-width: 100%; + overflow-x: auto; + margin-top: 1rem; +} +.program-calendar-reschedule-preview table { + width: 100%; + border-collapse: collapse; +} +.program-calendar-reschedule-preview th, +.program-calendar-reschedule-preview td { + padding: 0.35rem; + text-align: left; + border-bottom: 1px solid var(--surface-1); +} .program-calendar-scroll { max-width: 100%; overflow-x: auto;