Add durable Web preparation withdrawal

This commit is contained in:
fy59 2026-09-18 15:05:51 +02:00
parent 0e7fb720c8
commit 4f0a5fee31
96 changed files with 2982 additions and 207 deletions

View file

@ -170,7 +170,7 @@ It is not the canonical analytics store.
The Android UI is driven by exercise metadata, never by exercise-name The Android UI is driven by exercise metadata, never by exercise-name
heuristics. heuristics.
Android local SQLite schema v20 retains the exactly-one durable active-session Android local SQLite schema v23 retains the exactly-one durable active-session
draft introduced in v4. draft introduced in v4.
Every meaningful draft/form mutation is persisted by the repository. Back, Every meaningful draft/form mutation is persisted by the repository. Back,
backgrounding and process death never delete the draft. Home offers explicit backgrounding and process death never delete the draft. Home offers explicit
@ -184,7 +184,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
durable flattened exercise-alias mapping used to resolve retired creator IDs. durable flattened exercise-alias mapping used to resolve retired creator IDs.
Desktop schema v12 owns the same alias compatibility boundary. Later additive Desktop schema v12 owns the same alias compatibility boundary. Later additive
desktop migrations reach v21 and Android migrations reach v20 without changing desktop migrations reach v24 and Android migrations reach v23 without changing
that identity contract. The active that identity contract. The active
completed-session exchange is the separate strict completed-session exchange is the separate strict
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1` `trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
@ -241,12 +241,13 @@ Desktop SQLite schema is versioned with:
PRAGMA user_version; PRAGMA user_version;
``` ```
The current desktop schema is v21. Schema v9 added an occurrence-owned explicit The current desktop schema is v24. Schema v9 added an occurrence-owned explicit
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
flattened exercise aliases; v13-v18 add machine metadata, feedback and flattened exercise aliases; v13-v18 add machine metadata, feedback and
revisions, occurrence tracking snapshots, causal profile state, and AI proposal revisions, occurrence tracking snapshots, causal profile state, and AI proposal
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
generation/ACK ledgers. None changes `TRAINLOG_FORMAT_V1`. generation/ACK ledgers; v22-v24 add generation archival, manual preparations,
and durable preparation withdrawal. None changes `TRAINLOG_FORMAT_V1`.
Every incompatible schema evolution requires an explicit migration and Every incompatible schema evolution requires an explicit migration and
regression coverage. regression coverage.

View file

@ -2,6 +2,22 @@
## Unreleased — 0.1.2 ## Unreleased — 0.1.2
- Corrected Web Sessions presentation with one descending cross-type order,
strict civil-date and zoned-timestamp formatting, contextual French states,
readable persisted proposal provenance, and an installation-local versioned
French/ISO date preference. The Dashboard prepared-item list, Footer, charts
and synchronization timestamps use the same presentation helpers without
changing machine values.
- Added revision-guarded, idempotent **Delete preparation** through the Core
service and protected loopback API. Desktop schema v24 retains an immutable
withdrawal ledger and all preparation/delivery evidence. The separate
`trainlog-session-preparations` V2 generation participant propagates exact
withdrawals; Android schema v23 cancels only pending deliveries, preserves
started executions and permanently prevents older delivery replay from
resurrecting a withdrawn preparation. V1 remains readable and
`TRAINLOG_FORMAT_V1` is unchanged.
- Implemented `TRAINLOG_WEB_SESSIONS_V1` with paged preparation, resume and - Implemented `TRAINLOG_WEB_SESSIONS_V1` with paged preparation, resume and
history views, stable details, optimistic/idempotent manual preparation, history views, stable details, optimistic/idempotent manual preparation,
explicit proposal derivation, desktop schema v23, Android schema v22 and the explicit proposal derivation, desktop schema v23, Android schema v22 and the

View file

@ -318,7 +318,7 @@ not contain cloud credentials and does not run `rclone`.
As of 2026-09-17: As of 2026-09-17:
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`; - `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
- desktop SQLite schema v23 and Android SQLite schema v22; - desktop SQLite schema v24 and Android SQLite schema v23;
- Notcurses is the only active desktop terminal backend; - Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's - direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting; all-files access setting;

View file

@ -219,7 +219,7 @@ internal class AndroidBackupService(private val context: Context) {
manifest.getInt("version") == VERSION && manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName && manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") && manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..22 manifest.getInt("schema") in 17..23
) { ) {
"Unsupported backup." "Unsupported backup."
} }
@ -313,7 +313,7 @@ internal class AndroidBackupService(private val context: Context) {
} }
) )
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
require(db.version in 17..22) { "Unsupported Android schema ${db.version}." } require(db.version in 17..23) { "Unsupported Android schema ${db.version}." }
return db.version return db.version
} finally { } finally {
db.close() db.close()

View file

@ -163,6 +163,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
"mobile-history-v4", "mobile-history-v4",
"execution-draft-v1", "execution-draft-v1",
"generation-archive-v1", "generation-archive-v1",
"session-preparations-v2",
) )
), ),
) )

View file

@ -70,7 +70,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
private val SUPPORTED = private val SUPPORTED =
(ANDROID_KINDS.values + (ANDROID_KINDS.values +
Kind("trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", false) + Kind("trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", false) +
Kind("trainlog-session-preparations", 1, "session-preparations-v1.json", false)) Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false))
.map { it.format to it.version } .map { it.format to it.version }
.toSet() .toSet()

View file

@ -1884,9 +1884,17 @@ class TrainlogRepository(
val root = try { JSONObject(json) } catch (_: Exception) { val root = try { JSONObject(json) } catch (_: Exception) {
return AiSessionDraftImportResult.Invalid("Artifact de préparations invalide.") return AiSessionDraftImportResult.Invalid("Artifact de préparations invalide.")
} }
if (!root.hasExactKeys(setOf("format", "version", "generated_at", "deliveries")) || val version = root.optInt("version")
root.optString("format") != "trainlog-session-preparations" || root.optInt("version") != 1) val rootKeys = if (version == 1) {
return AiSessionDraftImportResult.Invalid("Artifact de préparations v1 non supporté.") setOf("format", "version", "generated_at", "deliveries")
} else {
setOf("format", "version", "generated_at", "deliveries", "withdrawals")
}
if (!root.hasExactKeys(rootKeys) ||
root.optString("format") != "trainlog-session-preparations" ||
version !in setOf(1, 2)) {
return AiSessionDraftImportResult.Invalid("Artifact de préparations non supporté.")
}
val values = root.optJSONArray("deliveries") val values = root.optJSONArray("deliveries")
?: return AiSessionDraftImportResult.Invalid("Livraisons de préparations manquantes.") ?: return AiSessionDraftImportResult.Invalid("Livraisons de préparations manquantes.")
if (values.length() > 128) return AiSessionDraftImportResult.Invalid("Trop de préparations.") if (values.length() > 128) return AiSessionDraftImportResult.Invalid("Trop de préparations.")
@ -1904,8 +1912,10 @@ class TrainlogRepository(
val preparationId = delivery.getString("preparation_id") val preparationId = delivery.getString("preparation_id")
val revisionId = delivery.getString("revision_id") val revisionId = delivery.getString("revision_id")
val executionId = delivery.getString("execution_session_id") val executionId = delivery.getString("execution_session_id")
require(deliveryId.startsWith("spd_") && preparationId.startsWith("sp_") && require(stableUuidV4Id(deliveryId, "spd_") &&
revisionId.startsWith("spr_") && executionId.startsWith("se_")) { "Identité de préparation invalide." } stableUuidV4Id(preparationId, "sp_") &&
stableUuidV4Id(revisionId, "spr_") &&
stableUuidV4Id(executionId, "se_")) { "Identité de préparation invalide." }
require(delivery.getString("state") in setOf("pending", "remote_unknown")) { "État de livraison invalide." } require(delivery.getString("state") in setOf("pending", "remote_unknown")) { "État de livraison invalide." }
val occurrences = delivery.getJSONArray("occurrences") val occurrences = delivery.getJSONArray("occurrences")
require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." } require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." }
@ -1915,7 +1925,15 @@ class TrainlogRepository(
arrayOf(deliveryId), arrayOf(deliveryId),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) to cursor.getString(1) else null } ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) to cursor.getString(1) else null }
val canonical = delivery.toString() val canonical = delivery.toString()
if (existing == null) { val withdrawn = db.rawQuery(
"SELECT 1 FROM session_preparation_withdrawals WHERE preparation_id=?",
arrayOf(preparationId),
).use { cursor -> cursor.moveToFirst() }
if (existing == null && withdrawn) {
/* INVARIANT: an older generation cannot resurrect a
* delivery after its preparation withdrawal was consumed. */
skipped++
} else if (existing == null) {
db.insertOrThrow("session_preparation_deliveries", null, ContentValues().apply { db.insertOrThrow("session_preparation_deliveries", null, ContentValues().apply {
put("delivery_id", deliveryId); put("preparation_id", preparationId) put("delivery_id", deliveryId); put("preparation_id", preparationId)
put("revision_id", revisionId); put("execution_session_id", executionId) put("revision_id", revisionId); put("execution_session_id", executionId)
@ -1930,6 +1948,13 @@ class TrainlogRepository(
skipped++ skipped++
} }
} }
if (version == 2) {
val withdrawals = root.getJSONArray("withdrawals")
require(withdrawals.length() <= 128) { "Trop de retraits de préparations." }
for (withdrawalIndex in 0 until withdrawals.length()) {
applySessionPreparationWithdrawal(db, withdrawals.getJSONObject(withdrawalIndex))
}
}
AiSessionDraftImportResult.Applied(imported, skipped) AiSessionDraftImportResult.Applied(imported, skipped)
} catch (error: IllegalArgumentException) { } catch (error: IllegalArgumentException) {
AiSessionDraftImportResult.Invalid(error.message ?: "Préparation invalide.") AiSessionDraftImportResult.Invalid(error.message ?: "Préparation invalide.")
@ -1938,6 +1963,93 @@ class TrainlogRepository(
} }
} }
/**
* Applies one immutable preparation withdrawal inside the generation
* transaction. Pending deliveries become non-startable; started delivery
* rows and every execution record are preserved verbatim.
*/
private fun applySessionPreparationWithdrawal(db: SQLiteDatabase, withdrawal: JSONObject) {
require(withdrawal.hasExactKeys(setOf(
"withdrawal_id",
"preparation_id",
"revision_id",
"requested_at",
"deliveries",
))) { "Forme de retrait de préparation invalide." }
val withdrawalId = withdrawal.getString("withdrawal_id")
val preparationId = withdrawal.getString("preparation_id")
val revisionId = withdrawal.getString("revision_id")
require(stableUuidV4Id(withdrawalId, "spw_") &&
stableUuidV4Id(preparationId, "sp_") &&
stableUuidV4Id(revisionId, "spr_")) { "Identité de retrait de préparation invalide." }
val deliveries = withdrawal.getJSONArray("deliveries")
require(deliveries.length() <= 128) { "Trop de livraisons dans le retrait." }
val canonical = withdrawal.toString()
val existing = db.rawQuery(
"SELECT preparation_id,revision_id,payload_json FROM " +
"session_preparation_withdrawals WHERE withdrawal_id=?",
arrayOf(withdrawalId),
).use { cursor ->
if (!cursor.moveToFirst()) {
null
} else {
Triple(cursor.getString(0), cursor.getString(1), cursor.getString(2))
}
}
require(existing == null ||
(existing.first == preparationId && existing.second == revisionId &&
JSONObject(existing.third).toString() == canonical)) {
"Identité de retrait réutilisée avec un contenu différent."
}
var result = "no_matching_delivery"
for (deliveryIndex in 0 until deliveries.length()) {
val delivery = deliveries.getJSONObject(deliveryIndex)
require(delivery.hasExactKeys(setOf(
"delivery_id",
"revision_id",
"execution_session_id",
))) { "Livraison de retrait invalide." }
val deliveryId = delivery.getString("delivery_id")
val deliveryRevision = delivery.getString("revision_id")
val executionId = delivery.getString("execution_session_id")
require(stableUuidV4Id(deliveryId, "spd_") &&
stableUuidV4Id(deliveryRevision, "spr_") &&
stableUuidV4Id(executionId, "se_")) { "Identité de livraison de retrait invalide." }
val state = db.rawQuery(
"SELECT state FROM session_preparation_deliveries WHERE delivery_id=? AND " +
"preparation_id=? AND revision_id=? AND execution_session_id=?",
arrayOf(deliveryId, preparationId, deliveryRevision, executionId),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
when (state) {
"pending" -> {
db.execSQL(
"UPDATE session_preparation_deliveries SET state='cancelled' " +
"WHERE delivery_id=? AND state='pending'",
arrayOf(deliveryId),
)
if (result != "execution_preserved") {
result = "pending_cancelled"
}
}
"cancelled" -> if (result != "execution_preserved") {
result = "pending_cancelled"
}
"started" -> result = "execution_preserved"
}
}
if (existing == null) {
db.insertOrThrow("session_preparation_withdrawals", null, ContentValues().apply {
put("withdrawal_id", withdrawalId)
put("preparation_id", preparationId)
put("revision_id", revisionId)
put("requested_at", withdrawal.getString("requested_at"))
put("received_at", OffsetDateTime.now(java.time.ZoneOffset.UTC).toString())
put("result", result)
put("payload_json", canonical)
})
}
}
private fun parsePreparedOccurrences(db: SQLiteDatabase, values: JSONArray): List<SessionExerciseDraft> = private fun parsePreparedOccurrences(db: SQLiteDatabase, values: JSONArray): List<SessionExerciseDraft> =
buildList { buildList {
val identities = mutableSetOf<String>() val identities = mutableSetOf<String>()
@ -6156,6 +6268,13 @@ class TrainlogRepository(
finally{if(db.inTransaction())db.endTransaction()} finally{if(db.inTransaction())db.endTransaction()}
} }
private fun stableUuidV4Id(value: String, prefix: String): Boolean {
if (!value.startsWith(prefix)) return false
val suffix = value.removePrefix(prefix)
val parsed = runCatching { UUID.fromString(suffix) }.getOrNull() ?: return false
return parsed.version() == 4 && parsed.variant() == 2 && parsed.toString() == suffix
}
private fun stableFeedbackId(value:String,prefix:String):Boolean = value.startsWith(prefix) && private fun stableFeedbackId(value:String,prefix:String):Boolean = value.startsWith(prefix) &&
runCatching { UUID.fromString(value.removePrefix(prefix)) }.isSuccess runCatching { UUID.fromString(value.removePrefix(prefix)) }.isSuccess
private fun canonicalExerciseId(db:SQLiteDatabase,id:String):String? = db.rawQuery( private fun canonicalExerciseId(db:SQLiteDatabase,id:String):String? = db.rawQuery(
@ -8076,7 +8195,7 @@ private class TrainlogDatabaseHelper(
appContext, appContext,
databaseName, databaseName,
null, null,
22, 23,
) { ) {
override fun onConfigure( override fun onConfigure(
db: SQLiteDatabase, db: SQLiteDatabase,
@ -8123,6 +8242,7 @@ private class TrainlogDatabaseHelper(
createSyncGenerationTables(db) createSyncGenerationTables(db)
createSyncGenerationArchiveTable(db) createSyncGenerationArchiveTable(db)
createSessionPreparationTables(db) createSessionPreparationTables(db)
createSessionPreparationWithdrawalTable(db)
seedEquipment(db) seedEquipment(db)
} }
@ -8304,6 +8424,14 @@ private class TrainlogDatabaseHelper(
createSessionPreparationTables(db) createSessionPreparationTables(db)
version = 22 version = 22
} }
if (version < 23 && newVersion >= 23) {
/* WHY: snapshot omission cannot retract a preparation already
* received by Android. CONTRACT: v23 stores immutable withdrawal
* identity and the local application result. INVARIANT: no
* delivery, active draft, or completed session is removed. */
createSessionPreparationWithdrawalTable(db)
version = 23
}
if (version != newVersion) { if (version != newVersion) {
error( error(
@ -8423,6 +8551,17 @@ private class TrainlogDatabaseHelper(
"session_preparation_deliveries(preparation_id,revision_id);") "session_preparation_deliveries(preparation_id,revision_id);")
} }
private fun createSessionPreparationWithdrawalTable(db: SQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS session_preparation_withdrawals(" +
"withdrawal_id TEXT PRIMARY KEY,preparation_id TEXT NOT NULL UNIQUE," +
"revision_id TEXT NOT NULL,requested_at TEXT NOT NULL,received_at TEXT NOT NULL," +
"result TEXT NOT NULL CHECK(result IN(" +
"'pending_cancelled','execution_preserved','no_matching_delivery'))," +
"payload_json TEXT NOT NULL);",
)
}
private fun createAiSessionDraftTables(db: SQLiteDatabase) { private fun createAiSessionDraftTables(db: SQLiteDatabase) {
db.execSQL( db.execSQL(
"CREATE TABLE IF NOT EXISTS ai_session_drafts(" + "CREATE TABLE IF NOT EXISTS ai_session_drafts(" +

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
package com.labfytools.trainlog.data package com.labfytools.trainlog.data
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.RecordingMode
@ -62,6 +63,48 @@ class SessionPreparationExchangeTest {
.toString() .toString()
} }
private fun withdrawalArtifact(): String {
val delivery =
JSONObject()
.put("delivery_id", "spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc")
.put("execution_session_id", "se_dddddddd-dddd-4ddd-8ddd-dddddddddddd")
val withdrawal =
JSONObject()
.put("withdrawal_id", "spw_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
.put("preparation_id", "sp_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
.put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc")
.put("requested_at", "2026-09-18T12:00:00Z")
.put("deliveries", JSONArray().put(delivery))
return JSONObject()
.put("format", "trainlog-session-preparations")
.put("version", 2)
.put("generated_at", "2026-09-18T12:01:00Z")
.put("deliveries", JSONArray())
.put("withdrawals", JSONArray().put(withdrawal))
.toString()
}
private fun scalarText(name: String, sql: String): String {
val path = context.getDatabasePath(name)
return SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery(sql, null).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
}
}
}
private fun scalarInt(name: String, sql: String): Int {
val path = context.getDatabasePath(name)
return SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery(sql, null).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getInt(0)
}
}
}
@Test @Test
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() { fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
val name = "prepared-${System.nanoTime()}.db" val name = "prepared-${System.nanoTime()}.db"
@ -109,4 +152,183 @@ class SessionPreparationExchangeTest {
context.deleteDatabase(name) context.deleteDatabase(name)
} }
} }
@Test
fun withdrawalCancelsOnlyPendingDeliveryAndPreventsOlderReplayResurrection() {
val name = "prepared-withdraw-pending-${System.nanoTime()}.db"
val repository = TrainlogRepository(context, name)
try {
assertTrue(repository.createExercise(NewExerciseProfile(
name = "Test pending withdrawal",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
)) is CreateExerciseResult.Created)
val delivery = artifact(repository)
val withdrawal = withdrawalArtifact()
assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applySessionPreparationsJson(delivery))
val invalidWithdrawal = JSONObject(withdrawal)
invalidWithdrawal.getJSONArray("withdrawals").getJSONObject(0)
.put("withdrawal_id", "spw_invalid")
assertTrue(repository.applySessionPreparationsJson(invalidWithdrawal.toString()) is AiSessionDraftImportResult.Invalid)
assertEquals(1, repository.listPreparedSessions().size)
assertEquals(AiSessionDraftImportResult.Applied(0, 0), repository.applySessionPreparationsJson(withdrawal))
assertTrue(repository.listPreparedSessions().isEmpty())
assertEquals(
StartAiSessionDraftResult.NotPending,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
assertEquals(AiSessionDraftImportResult.Applied(0, 0), repository.applySessionPreparationsJson(withdrawal))
assertEquals(AiSessionDraftImportResult.Applied(0, 1), repository.applySessionPreparationsJson(delivery))
assertTrue(repository.listPreparedSessions().isEmpty())
repository.close()
assertEquals(
"cancelled",
scalarText(name, "SELECT state FROM session_preparation_deliveries"),
)
assertEquals(
"pending_cancelled",
scalarText(name, "SELECT result FROM session_preparation_withdrawals"),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun withdrawalPreservesStartedDeliveryAndActiveExecutionIdentity() {
val name = "prepared-withdraw-started-${System.nanoTime()}.db"
val repository = TrainlogRepository(context, name)
try {
assertTrue(repository.createExercise(NewExerciseProfile(
name = "Test started withdrawal",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
)) is CreateExerciseResult.Created)
assertEquals(
AiSessionDraftImportResult.Applied(1, 0),
repository.applySessionPreparationsJson(artifact(repository)),
)
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
val before = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(AiSessionDraftImportResult.Applied(0, 0), repository.applySessionPreparationsJson(withdrawalArtifact()))
val after = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(before.draft.sessionId, after.draft.sessionId)
assertEquals("se_dddddddd-dddd-4ddd-8ddd-dddddddddddd", after.draft.sessionId)
repository.close()
assertEquals(
"started",
scalarText(name, "SELECT state FROM session_preparation_deliveries"),
)
assertEquals(
"execution_preserved",
scalarText(name, "SELECT result FROM session_preparation_withdrawals"),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun version22MigrationAddsWithdrawalLedgerWithoutChangingDeliveries() {
val name = "prepared-withdraw-migration-${System.nanoTime()}.db"
var repository = TrainlogRepository(context, name)
try {
assertTrue(repository.createExercise(NewExerciseProfile(
name = "Test withdrawal migration",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
)) is CreateExerciseResult.Created)
val delivery = artifact(repository)
assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applySessionPreparationsJson(delivery))
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).absolutePath,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE session_preparation_withdrawals")
db.execSQL("PRAGMA user_version=22")
}
repository = TrainlogRepository(context, name)
assertEquals(1, repository.listPreparedSessions().size)
repository.close()
assertEquals("23", scalarText(name, "PRAGMA user_version"))
assertEquals(
"0",
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun withdrawalCancelsAllPendingTargetDeliveriesButPreservesStartedAndWitnessRows() {
val name = "prepared-withdraw-multiple-${System.nanoTime()}.db"
val repository = TrainlogRepository(context, name)
try {
assertTrue(repository.createExercise(NewExerciseProfile(
name = "Test multiple withdrawal",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
)) is CreateExerciseResult.Created)
val deliveryRoot = JSONObject(artifact(repository))
val targetTemplate = deliveryRoot.getJSONArray("deliveries").getJSONObject(0)
val secondTarget = JSONObject(targetTemplate.toString())
.put("delivery_id", "spd_11111111-1111-4111-8111-111111111111")
.put("execution_session_id", "se_11111111-1111-4111-8111-111111111111")
val witness = JSONObject(targetTemplate.toString())
.put("delivery_id", "spd_22222222-2222-4222-8222-222222222222")
.put("preparation_id", "sp_22222222-2222-4222-8222-222222222222")
.put("revision_id", "spr_22222222-2222-4222-8222-222222222222")
.put("execution_session_id", "se_22222222-2222-4222-8222-222222222222")
.put("title", "Préparation témoin")
deliveryRoot.getJSONArray("deliveries").put(secondTarget).put(witness)
assertEquals(
AiSessionDraftImportResult.Applied(3, 0),
repository.applySessionPreparationsJson(deliveryRoot.toString()),
)
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
val withdrawalRoot = JSONObject(withdrawalArtifact())
withdrawalRoot.getJSONArray("withdrawals").getJSONObject(0)
.getJSONArray("deliveries")
.put(
JSONObject()
.put("delivery_id", "spd_11111111-1111-4111-8111-111111111111")
.put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc")
.put("execution_session_id", "se_11111111-1111-4111-8111-111111111111"),
)
assertEquals(
AiSessionDraftImportResult.Applied(0, 0),
repository.applySessionPreparationsJson(withdrawalRoot.toString()),
)
assertEquals(
listOf("spd_22222222-2222-4222-8222-222222222222"),
repository.listPreparedSessions().map { it.deliveryId },
)
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals("se_dddddddd-dddd-4ddd-8ddd-dddddddddddd", active.draft.sessionId)
repository.close()
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='started'"))
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='cancelled'"))
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='pending'"))
assertEquals("execution_preserved", scalarText(name, "SELECT result FROM session_preparation_withdrawals"))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
} }

View file

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

View file

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

View file

@ -6,6 +6,14 @@ preparation is inert until explicit start; an occupied singleton is never
overwritten. Starting preserves the desktop-reserved execution `session_id`, overwritten. Starting preserves the desktop-reserved execution `session_id`,
copies targets only and creates no performed facts. copies targets only and creates no performed facts.
Android schema v23 adds the durable manual-preparation withdrawal ledger.
Consumption is part of the outer generation transaction. Exact pending
deliveries are changed to `cancelled`; exact started deliveries remain started,
and their active or completed execution data is untouched. The stored result is
`pending_cancelled`, `execution_preserved`, or `no_matching_delivery`.
Withdrawal replay is idempotent, and an older preparation delivery is skipped
once the withdrawal identity for that preparation exists.
## 1. Purpose ## 1. Purpose
The Android application is Trainlog's low-friction capture client. The Android application is Trainlog's low-friction capture client.

View file

@ -493,7 +493,9 @@ silently rendered as unclassified.
### Desktop ### Desktop
Desktop SQLite schema v23 is canonical long-term history. Its additive v18 -> Desktop SQLite schema v24 is canonical long-term history. Its additive v23 ->
v24 migration adds durable manual-preparation withdrawal identity without
deleting revisions, deliveries or workout facts. Its additive v18 ->
v19 migration stores causal notes and execution-draft lifecycle state without v19 migration stores causal notes and execution-draft lifecycle state without
activating a new transport. Its v15 -> v16 activating a new transport. Its v15 -> v16
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the

View file

@ -50,8 +50,8 @@ parallel implementation of its rules.
| Boundary | Current state | | Boundary | Current state |
|---|---| |---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v23 | | Desktop SQLite | schema v24 |
| Android SQLite | schema v22 | | Android SQLite | schema v23 |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only | | Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` | | Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` |
@ -222,11 +222,22 @@ resume and history views, stable details, optimistic manual-preparation writes,
explicit proposal derivation and generation-backed Android delivery. Analyse, explicit proposal derivation and generation-backed Android delivery. Analyse,
Programmes and Exercises remain explicit placeholders. Programmes and Exercises remain explicit placeholders.
Desktop schema v23 owns immutable manual-preparation revisions, stable ordered Desktop schema v24 owns immutable manual-preparation revisions, stable ordered
occurrences, persistent HTTP idempotency keys and delivery-to-execution identity. occurrences, persistent HTTP idempotency keys, delivery-to-execution identity,
Android schema v22 stores received preparations separately from AI proposals and durable revision-bound withdrawal records. Android schema v23 stores
and the active singleton. An exact correlated ACK is required before delivery received preparations and permanent withdrawal results separately from AI
is shown as acknowledged. proposals and the active singleton. Pending deliveries are cancelled by an
exact V2 withdrawal; started executions are preserved. An exact correlated ACK
is required before delivery or withdrawal is shown as acknowledged.
Sessions and the prepared-items Dashboard projection now share descending
planned-date presentation with timestamp and stable-identity tie-breaks. Web
dates default to French presentation, with an installation-local versioned ISO
preference outside business SQLite and synchronization. Contextual French state
labels and persisted proposal provenance replace raw enums and digest-only
presentation. The preparation detail exposes a protected, idempotent **Delete
preparation** action; withdrawn rows remain technically inspectable but cannot
be edited or delivered.
The controlled deployment preserved the installed Android signing identity and The controlled deployment preserved the installed Android signing identity and
advanced its private `versionCode` without uninstalling or clearing data. The advanced its private `versionCode` without uninstalling or clearing data. The
@ -237,9 +248,9 @@ performed fact, or active-draft replacement.
The Next Session tile no longer treats the frozen Dashboard The Next Session tile no longer treats the frozen Dashboard
`next_session.available` placeholder as a durable inventory. A separate bounded `next_session.available` placeholder as a durable inventory. A separate bounded
`GET /api/v1/prepared-items` Core projection distinguishes imported AI `GET /api/v1/prepared-items` Core projection distinguishes manual preparations,
proposals from active or pending execution drafts and exposes stable identity, imported AI proposals, and active or pending execution drafts and exposes stable
state, title/date, occurrence count, and provenance. Reload and later sync identity, state, title/date, occurrence count, and provenance. Reload and later sync
failure do not erase the last successfully read projection. Proposal targets failure do not erase the last successfully read projection. Proposal targets
remain planning data and never alter Dashboard actual-work metrics. remain planning data and never alter Dashboard actual-work metrics.

View file

@ -1,5 +1,16 @@
# Desktop database # Desktop database
## Schema v24: durable preparation withdrawal
Schema v24 adds nullable `session_preparations.withdrawn_at` and the immutable
`session_preparation_withdrawals` ledger. Each preparation has at most one
withdrawal identity, bound to the exact current revision, request timestamp,
generation and correlated ACK. The service transaction inserts this ledger row
and withdraws the preparation together. It does not delete or rewrite revision,
entry, delivery, proposal, execution, session, feedback or acknowledgement
evidence. A withdrawn preparation is excluded from active projections and
cannot be edited or delivered again.
## Schema v23: Web session preparations ## Schema v23: Web session preparations
Schema v23 adds `session_preparations`, immutable Schema v23 adds `session_preparations`, immutable

View file

@ -2,8 +2,19 @@
## Manual session preparations ## Manual session preparations
`trainlog-session-preparations` V1 has a strict root `format`, `version`, `trainlog-session-preparations` V2 has a strict root `format`, `version`,
`generated_at`, and bounded `deliveries` array. Each delivery contains stable `generated_at`, bounded `deliveries`, and bounded `withdrawals`. Delivery
entries retain the V1 shape. Each withdrawal contains its stable
`spw_<uuid-v4>` identity, the exact preparation and revision, request timestamp,
and the bounded exact delivery/revision/reserved-execution identities that the
Android consumer may affect. Only a matching local `pending` delivery becomes
`cancelled`; a matching `started` delivery and its execution are preserved.
The durable Android withdrawal row prevents an older V1 delivery or generation
from recreating a startable preparation.
The readable legacy `trainlog-session-preparations` V1 root has a strict
`format`, `version`, `generated_at`, and bounded `deliveries` array. Each
delivery contains stable
delivery, preparation, revision, reserved execution and occurrence identities, delivery, preparation, revision, reserved execution and occurrence identities,
immutable metadata, and at most 64 ordered target-only occurrences. Null target immutable metadata, and at most 64 ordered target-only occurrences. Null target
values remain distinct from zero. Performed sets, continuous results, MAX and values remain distinct from zero. Performed sets, continuous results, MAX and

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View file

@ -24,7 +24,7 @@ distinction between measured values and estimates.
## Current baseline ## Current baseline
Desktop schema v23, Android schema v22, Notcurses, direct Desktop schema v24, Android schema v23, Notcurses, direct
`Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body `Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
implemented. Session Generator V1 is hidden pending V2. AI session-draft implemented. Session Generator V1 is hidden pending V2. AI session-draft
@ -34,9 +34,11 @@ CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
contract, frozen interactive grid, versioned layout persistence and factual contract, frozen interactive grid, versioned layout persistence and factual
tile rendering exist. The Dashboard's bounded progression and BODY ZONES tile rendering exist. The Dashboard's bounded progression and BODY ZONES
visualizations and Sessions V1 are implemented; the other three Web routes remain placeholders. visualizations and Sessions V1 are implemented; the other three Web routes remain placeholders.
The corrective prepared-item projection and bounded phase-owned MTP outbox are The corrective prepared-item projection, bounded phase-owned MTP outbox,
implemented without opening the Sessions route, changing schemas, or changing Sessions presentation correction, local date preference and durable preparation
the frozen Dashboard and exchange contracts. withdrawal are implemented. The withdrawal uses the separate versioned
session-preparations V2 participant and does not change the frozen Dashboard,
mobile snapshot or `TRAINLOG_FORMAT_V1` contracts.
## Current cursor ## Current cursor

View file

@ -1,15 +1,26 @@
# Synchronization exchange # Synchronization exchange
## Manual session preparations V1 ## Manual session preparations V2
`trainlog-session-preparations` V1 is a separate optional desktop-to-Android `trainlog-session-preparations` V2 is a separate optional desktop-to-Android
generation artifact. It carries immutable ready revision snapshots and stable generation artifact. It carries immutable ready revision snapshots and stable
delivery, preparation, occurrence and reserved execution identities. It never delivery, preparation, occurrence and reserved execution identities, plus
immutable preparation-withdrawal operations. It never
overloads mobile export, AI proposals or execution-drafts V1. Android validates overloads mobile export, AI proposals or execution-drafts V1. Android validates
the complete artifact in the generation transaction and stores it as pending; the complete artifact in the generation transaction and stores it as pending;
start remains an explicit foreground action. Desktop records the exact start remains an explicit foreground action. Desktop records the exact
generation relation at capture and changes delivery state to acknowledged only generation relation at capture and changes delivery state to acknowledged only
after accepting the correlated durable consumption ACK. after accepting the correlated durable consumption ACK. It similarly records
the exact withdrawal-to-generation relation and acknowledges a withdrawal only
after Android's whole-generation commit.
V1 remains readable for delivery replay compatibility but cannot transport a
withdrawal. Both peers advertise `session-preparations-v2`; generation mode
refuses an older capability set instead of omitting the causal operation. A V2
withdrawal lists every desktop-known delivery for its preparation. Android
cancels only exact pending matches, preserves exact started matches, stores the
result as a tombstone, and refuses later resurrection from an old V1/V2
delivery. Neither result deletes an active draft or completed workout.
## Causal deletion safety gate ## Causal deletion safety gate
@ -29,7 +40,7 @@ available because availability is independently guarded by causal state.
## Machine-exercise Phase 1 compatibility ## Machine-exercise Phase 1 compatibility
Desktop schema v23 and Android schema v22 retain mobile export V3, readable V1/V2 imports, Desktop schema v24 and Android schema v23 retain mobile export V3, readable V1/V2 imports,
equipment definitions V1, equipment associations V2, exercise aliases V1, and equipment definitions V1, equipment associations V2, exercise aliases V1, and
the BODY ZONES companion without wire-format changes. Machine metadata is not the BODY ZONES companion without wire-format changes. Machine metadata is not
silently added to a frozen artifact: stable exercise IDs and canonical names silently added to a frozen artifact: stable exercise IDs and canonical names

View file

@ -2,13 +2,25 @@
`web_sessions` exercises production preparation creation, durable request `web_sessions` exercises production preparation creation, durable request
replay, immutable successor revisions, stale-revision conflict, duplicate replay, immutable successor revisions, stale-revision conflict, duplicate
exercise occurrences, list search and persisted detail serialization. The Web exercise occurrences, list search, persisted detail serialization, revision-
Vitest suite covers the shell and API parsers; the embedded frontend is also guarded withdrawal, conflicting idempotency-key reuse, active-list exclusion,
built into the canonical Meson binary. and preservation of proposal/revision/delivery evidence. The HTTP test covers
the real protected DELETE route and the versioned local preference GET/PUT,
including CSRF, ETag, persistence and invalid-file fallback. The Web Vitest
suite additionally covers cross-type global ordering beyond 32 rows, timestamp
offsets, stable ties, invalid/absent dates, contextual states, confirmation
cancel, the DELETE request and preference conflict behavior. Date tests run
under `Pacific/Honolulu` and `Pacific/Kiritimati`.
`SessionPreparationExchangeTest` covers Android import replay, occupied `SessionPreparationExchangeTest` covers Android import replay, occupied
singleton refusal, explicit start, reserved execution identity and absence of singleton refusal, explicit start, reserved execution identity, schema-v22 to
performed facts. `SyncGenerationServiceTest` and v23 migration, permanent withdrawal replay, pending cancellation, started
execution preservation, multi-delivery targeting, witness preservation and
absence of performed facts. `test_session_preparation_export.py` proves that a
withdrawn preparation is not redelivered, its exact evidence is exported in V2,
and an acknowledged withdrawal is not republished. Generation tests bind the
withdrawal to one immutable generation and acknowledge it only after the exact
durable ACK. `SyncGenerationServiceTest` and
`SyncDeploymentConversationTest` exercise the real optional artifact through `SyncDeploymentConversationTest` exercise the real optional artifact through
generation validation and 24 bidirectional conversations. It also asserts that generation validation and 24 bidirectional conversations. It also asserts that
every foreground conversation emits exactly one fresh daemon request signal; every foreground conversation emits exactly one fresh daemon request signal;
@ -29,6 +41,14 @@ The C MTP regression keeps a malformed retained generation beside the current
reference and proves that pull downloads only the referenced generation while reference and proves that pull downloads only the referenced generation while
still rejecting a truncated object inside that current generation. still rejecting a truncated object inside that current generation.
`test_web_sessions_browser.py` runs headless Firefox against the embedded
production assets and C HTTP server with private temporary XDG roots. It creates
only a synthetic ready preparation and delivery, verifies French then persisted
ISO presentation, captures desktop and an exact BiDi-emulated 390x844 viewport,
performs the real confirmation click, checks the schema-v24 withdrawal ledger
after reload, and validates the resulting V2 export evidence. Android repository
tests exercise the actual V2 consumer rather than a permissive protocol mock.
`web_prepared_items` exercises the production SQLite projection and JSON `web_prepared_items` exercises the production SQLite projection and JSON
serializer with a proposal and an execution draft, then proves finalized and serializer with a proposal and an execution draft, then proves finalized and
causally deleted execution drafts are absent. The HTTP test covers the causally deleted execution drafts are absent. The HTTP test covers the

View file

@ -12,10 +12,51 @@ equipment/load semantics and profile-compatible targets. Draft saves may remain
incomplete. Marking ready validates executable content; preparing for Android incomplete. Marking ready validates executable content; preparing for Android
then creates a distinct immutable delivery and reserved execution identity. then creates a distinct immutable delivery and reserved execution identity.
Every view has one stable descending presentation order. Preparation merges
manual preparations and proposals by valid planned civil date, then factual
`sort_timestamp`, then kind and stable identity. Undated or invalidly dated
items remain visible after valid dates. Resume uses the execution-draft recency
timestamp supplied by Core, and History uses the real start instant; both use
stable identity to break exact ties. Search and filters operate on the complete
paged result and reapply the same order. Late results from an older view or
query cannot replace the current list.
Web presentation defaults to French civil dates (`DD/MM/YYYY`) and 24-hour
times. A local **Display settings** dialog can select ISO dates. The versioned
preference is stored atomically with private permissions in
`$XDG_CONFIG_HOME/trainlog/web/preferences-v1.json`, falling back to
`$HOME/.config`; it is neither business data nor synchronized state. Missing
or invalid storage uses the French default, and invalid storage is reported.
Civil `YYYY-MM-DD` values are validated and never timezone-shifted. Zoned
timestamps preserve their instant and are presented in the browser timezone;
the original machine value remains in API data and `<time datetime>`.
Stored states remain wire-level enums, while Sessions presents contextual
French labels. A preparation derived from a proposal exposes the persisted
source identity and title as a stable deep link; the source digest remains a
technical detail and no title-based inference is performed.
Opening or deriving from an AI proposal never accepts, starts, deletes or Opening or deriving from an AI proposal never accepts, starts, deletes or
rewrites the source. Derivation records the exact proposal identity and imported rewrites the source. Derivation records the exact proposal identity and imported
payload fingerprint. The Android screen labels manual preparations separately payload fingerprint. The Android screen labels manual preparations separately
and refuses to overwrite an active draft. and refuses to overwrite an active draft.
**Delete preparation** is a durable logical withdrawal, not physical evidence
purging. The protected DELETE command requires Host/Origin/CSRF, a bounded
idempotency key and the exact current revision. One desktop transaction records
the immutable withdrawal and makes the preparation unavailable for editing,
new delivery, active Sessions lists and the Dashboard projection. Revisions,
delivery rows, ACK evidence, the source proposal and all execution identities
remain intact.
The separately versioned `trainlog-session-preparations` V2 artifact carries
unacknowledged withdrawals and the exact linked delivery identities. Android
schema v23 stores one permanent withdrawal result per preparation. Pending
deliveries become `cancelled`; started deliveries, the active draft and all
completed facts remain unchanged and are recorded as `execution_preserved`.
The tombstone rejects an older delivery replay, including after process restart.
Desktop marks a withdrawal acknowledged only after the correlated Android
generation ACK. Until then Web reports that Android synchronization is pending.
This V1 does not provide live Web capture, historical correction, automatic This V1 does not provide live Web capture, historical correction, automatic
training generation, Programs, Analysis or the standalone Exercises route. training generation, Programs, Analysis or the standalone Exercises route.

View file

@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Versioned preparation delivery and withdrawal export regressions."""
import sqlite3
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import export_session_preparations
class SessionPreparationExportTest(unittest.TestCase):
def setUp(self):
self.database = sqlite3.connect(":memory:")
self.database.executescript(
"""
CREATE TABLE session_preparations(
preparation_id TEXT PRIMARY KEY,
current_revision_id TEXT NOT NULL,
source_proposal_id TEXT,
source_payload_sha256 TEXT,
withdrawn_at TEXT
);
CREATE TABLE session_preparation_revisions(
revision_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
session_type TEXT NOT NULL,
planned_for TEXT,
notes TEXT
);
CREATE TABLE session_preparation_entries(
entry_id TEXT PRIMARY KEY,
revision_id TEXT NOT NULL,
position INTEGER NOT NULL,
exercise_id TEXT NOT NULL,
equipment_id TEXT,
recording_mode TEXT NOT NULL,
tracking_mode TEXT NOT NULL,
data_fields INTEGER NOT NULL,
load_mode TEXT NOT NULL,
rest_seconds INTEGER NOT NULL,
target_sets INTEGER,
target_reps INTEGER,
target_duration_seconds INTEGER,
target_weight_kg REAL,
notes TEXT
);
CREATE TABLE session_preparation_deliveries(
delivery_id TEXT PRIMARY KEY,
preparation_id TEXT NOT NULL,
revision_id TEXT NOT NULL,
execution_session_id TEXT NOT NULL,
state TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE session_preparation_withdrawals(
withdrawal_id TEXT PRIMARY KEY,
preparation_id TEXT NOT NULL,
revision_id TEXT NOT NULL,
requested_at TEXT NOT NULL,
generation_id TEXT,
acknowledged_at TEXT
);
PRAGMA user_version=24;
"""
)
def tearDown(self):
self.database.close()
def add_preparation(self, suffix: str, withdrawn: bool) -> None:
preparation = f"sp_{suffix}"
revision = f"spr_{suffix}"
delivery = f"spd_{suffix}"
execution = f"se_{suffix}"
self.database.execute(
"INSERT INTO session_preparations VALUES(?,?,?,?,?)",
(
preparation,
revision,
"aid_11111111-1111-4111-8111-111111111111",
"a" * 64,
"2026-09-18T12:00:00Z" if withdrawn else None,
),
)
self.database.execute(
"INSERT INTO session_preparation_revisions VALUES(?,?,?,?,?)",
(revision, "Préparation", "training", "2026-09-20", None),
)
self.database.execute(
"INSERT INTO session_preparation_entries VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
f"spe_{suffix}", revision, 0,
"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", None,
"sets", "reps", 0, "none", 60, 2, 8, None, None, None,
),
)
self.database.execute(
"INSERT INTO session_preparation_deliveries VALUES(?,?,?,?,?,?)",
(delivery, preparation, revision, execution, "pending", "2026-09-18T10:00:00Z"),
)
if withdrawn:
self.database.execute(
"INSERT INTO session_preparation_withdrawals VALUES(?,?,?,?,NULL,NULL)",
(f"spw_{suffix}", preparation, revision, "2026-09-18T12:00:00Z"),
)
self.database.commit()
def test_withdrawn_preparation_is_not_delivered_and_exports_exact_evidence(self):
kept = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
withdrawn = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
self.add_preparation(kept, withdrawn=False)
self.add_preparation(withdrawn, withdrawn=True)
payload = export_session_preparations.build_export(self.database)
self.assertEqual("trainlog-session-preparations", payload["format"])
self.assertEqual(2, payload["version"])
self.assertEqual([f"spd_{kept}"], [row["delivery_id"] for row in payload["deliveries"]])
self.assertEqual(1, len(payload["withdrawals"]))
removal = payload["withdrawals"][0]
self.assertEqual(f"spw_{withdrawn}", removal["withdrawal_id"])
self.assertEqual(f"sp_{withdrawn}", removal["preparation_id"])
self.assertEqual(
[{
"delivery_id": f"spd_{withdrawn}",
"revision_id": f"spr_{withdrawn}",
"execution_session_id": f"se_{withdrawn}",
}],
removal["deliveries"],
)
def test_acknowledged_withdrawal_is_not_republished(self):
suffix = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
self.add_preparation(suffix, withdrawn=True)
self.database.execute(
"UPDATE session_preparation_withdrawals SET acknowledged_at=?",
("2026-09-18T12:05:00Z",),
)
self.database.commit()
payload = export_session_preparations.build_export(self.database)
self.assertEqual([], payload["deliveries"])
self.assertEqual([], payload["withdrawals"])
if __name__ == "__main__":
unittest.main()

View file

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

View file

@ -24,8 +24,9 @@ CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,ru
CREATE TABLE sync_generation_archives(generation_id TEXT PRIMARY KEY,archive_path TEXT UNIQUE,manifest_sha256 TEXT,archive_sha256 TEXT,archived_at TEXT,audit_json TEXT); CREATE TABLE sync_generation_archives(generation_id TEXT PRIMARY KEY,archive_path TEXT UNIQUE,manifest_sha256 TEXT,archive_sha256 TEXT,archived_at TEXT,audit_json TEXT);
CREATE TABLE sync_causal_publications(operation_id TEXT,generation_id TEXT,first_emission INTEGER,PRIMARY KEY(operation_id,generation_id)); CREATE TABLE sync_causal_publications(operation_id TEXT,generation_id TEXT,first_emission INTEGER,PRIMARY KEY(operation_id,generation_id));
CREATE TABLE session_preparation_deliveries(delivery_id TEXT PRIMARY KEY,preparation_id TEXT,revision_id TEXT,execution_session_id TEXT,state TEXT,created_at TEXT,generation_id TEXT,acknowledged_at TEXT); CREATE TABLE session_preparation_deliveries(delivery_id TEXT PRIMARY KEY,preparation_id TEXT,revision_id TEXT,execution_session_id TEXT,state TEXT,created_at TEXT,generation_id TEXT,acknowledged_at TEXT);
CREATE TABLE session_preparations(preparation_id TEXT PRIMARY KEY,current_revision_id TEXT,delivery_state TEXT); CREATE TABLE session_preparations(preparation_id TEXT PRIMARY KEY,current_revision_id TEXT,delivery_state TEXT,withdrawn_at TEXT);
PRAGMA user_version=23; CREATE TABLE session_preparation_withdrawals(withdrawal_id TEXT PRIMARY KEY,preparation_id TEXT UNIQUE,revision_id TEXT,requested_at TEXT,generation_id TEXT,acknowledged_at TEXT);
PRAGMA user_version=24;
""" """
class GenerationTest(unittest.TestCase): class GenerationTest(unittest.TestCase):
@ -69,6 +70,72 @@ class GenerationTest(unittest.TestCase):
self.assertEqual("acknowledged",generation.accept_ack(self.database,ack_path));self.assertEqual("unchanged",generation.accept_ack(self.database,ack_path)) self.assertEqual("acknowledged",generation.accept_ack(self.database,ack_path));self.assertEqual("unchanged",generation.accept_ack(self.database,ack_path))
with closing(sqlite3.connect(destination)) as reopened: with closing(sqlite3.connect(destination)) as reopened:
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0]) self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
def test_withdrawal_generation_relation_is_durable_until_ack(self):
withdrawal = "spw_77777777-7777-4777-8777-777777777777"
preparation = "sp_88888888-8888-4888-8888-888888888888"
revision = "spr_99999999-9999-4999-8999-999999999999"
with closing(sqlite3.connect(self.database)) as db:
db.execute(
"INSERT INTO session_preparations VALUES(?,?,?,?)",
(preparation, revision, "acknowledged", "2026-09-18T12:00:00Z"),
)
db.execute(
"INSERT INTO session_preparation_withdrawals VALUES(?,?,?,?,NULL,NULL)",
(withdrawal, preparation, revision, "2026-09-18T12:00:00Z"),
)
db.commit()
artifacts = (("session-preparations", "trainlog-session-preparations", 2,
"session-preparations-v2.json", False, "controlled.py", ()),)
payload = {
"format": "trainlog-session-preparations",
"version": 2,
"generated_at": "2026-09-18T12:01:00Z",
"deliveries": [],
"withdrawals": [{
"withdrawal_id": withdrawal,
"preparation_id": preparation,
"revision_id": revision,
"requested_at": "2026-09-18T12:00:00Z",
"deliveries": [],
}],
}
def exporter(_tool, _extra, output, _snapshot):
output.write_text(json.dumps(payload))
with mock.patch.object(generation, "ARTIFACTS", artifacts), \
mock.patch.object(generation, "SUPPORTED", {("trainlog-session-preparations", 2)}), \
mock.patch.object(generation, "run_export", exporter):
stage, manifest, checksum = generation.capture_desktop(
self.database, self.root / "withdrawal", PEER_B, RUN, GEN,
)
self.assertTrue((stage / "session-preparations-v2.json").is_file())
with closing(sqlite3.connect(self.database)) as db:
self.assertEqual(
(GEN, None),
db.execute(
"SELECT generation_id,acknowledged_at FROM session_preparation_withdrawals",
).fetchone(),
)
db.execute("UPDATE sync_generations SET status='waiting_acknowledgement' WHERE generation_id=?", (GEN,))
db.commit()
ack = generation.ack_document(
RUN,
GEN,
manifest["producer"]["peer_id"],
PEER_B,
checksum,
"consumed",
generation.now(),
)
ack_path = self.root / "withdrawal-ack.json"
ack_path.write_bytes(generation.canonical(ack))
self.assertEqual("acknowledged", generation.accept_ack(self.database, ack_path))
with closing(sqlite3.connect(self.database)) as db:
self.assertIsNotNone(
db.execute(
"SELECT acknowledged_at FROM session_preparation_withdrawals",
).fetchone()[0],
)
def test_substitution_and_transaction_failure_leave_no_consumption(self): def test_substitution_and_transaction_failure_leave_no_consumption(self):
_,manifest,checksum=self.capture();published=self.publish() _,manifest,checksum=self.capture();published=self.publish()
(published/"history.json").write_text("{}") (published/"history.json").write_text("{}")

View file

@ -1,11 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Exercise Web Sessions through embedded production assets in real Firefox.""" """Exercise Web Sessions through embedded production assets in real Firefox."""
import json
import os import os
import signal import signal
import socket import socket
import sqlite3 import sqlite3
import subprocess import subprocess
import sys
import tempfile import tempfile
import time import time
import unittest import unittest
@ -23,7 +25,7 @@ EVIDENCE = ROOT / "docs" / "reviews" / "evidence"
class BrowserSessionsTest(unittest.TestCase): class BrowserSessionsTest(unittest.TestCase):
def test_create_reload_detail_and_mobile_editor(self): def test_create_preferences_reload_and_withdraw_by_real_click(self):
if not GECKO.is_file(): if not GECKO.is_file():
self.skipTest("pinned geckodriver unavailable") self.skipTest("pinned geckodriver unavailable")
@ -31,11 +33,14 @@ class BrowserSessionsTest(unittest.TestCase):
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.service import Service from selenium.webdriver.firefox.service import Service
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
import websocket
with tempfile.TemporaryDirectory(prefix="trainlog-web-sessions-") as directory: with tempfile.TemporaryDirectory(prefix="trainlog-web-sessions-") as directory:
root = Path(directory) root = Path(directory)
xdg = root / "data" xdg = root / "data"
config = root / "config"
xdg.mkdir() xdg.mkdir()
config.mkdir()
with socket.socket() as reserved: with socket.socket() as reserved:
reserved.bind(("127.0.0.1", 0)) reserved.bind(("127.0.0.1", 0))
port = reserved.getsockname()[1] port = reserved.getsockname()[1]
@ -43,7 +48,7 @@ class BrowserSessionsTest(unittest.TestCase):
environment.update( environment.update(
{ {
"XDG_DATA_HOME": str(xdg), "XDG_DATA_HOME": str(xdg),
"XDG_CONFIG_HOME": str(root / "config"), "XDG_CONFIG_HOME": str(config),
"XDG_CACHE_HOME": str(root / "cache"), "XDG_CACHE_HOME": str(root / "cache"),
} }
) )
@ -55,6 +60,7 @@ class BrowserSessionsTest(unittest.TestCase):
start_new_session=True, start_new_session=True,
) )
driver = None driver = None
bidi = None
try: try:
for _ in range(200): for _ in range(200):
with socket.socket() as probe: with socket.socket() as probe:
@ -64,33 +70,152 @@ class BrowserSessionsTest(unittest.TestCase):
options = webdriver.FirefoxOptions() options = webdriver.FirefoxOptions()
options.add_argument("-headless") options.add_argument("-headless")
options.set_capability("webSocketUrl", True)
driver = webdriver.Firefox(options=options, service=Service(str(GECKO))) driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
wait = WebDriverWait(driver, 15) wait = WebDriverWait(driver, 15)
driver.set_window_size(1440, 1000) bidi = websocket.create_connection(
driver.capabilities["webSocketUrl"], suppress_origin=True
)
command_id = 0
def bidi_command(method: str, parameters: dict):
nonlocal command_id
command_id += 1
bidi.send(json.dumps({
"id": command_id,
"method": method,
"params": parameters,
}))
while True:
response = json.loads(bidi.recv())
if response.get("id") != command_id:
continue
if response.get("type") != "success":
raise AssertionError(response)
return response["result"]
contexts = bidi_command("browsingContext.getTree", {})["contexts"]
self.assertEqual(len(contexts), 1)
context = contexts[0]["context"]
def set_viewport(width: int, height: int):
bidi_command(
"browsingContext.setViewport",
{
"context": context,
"viewport": {"width": width, "height": height},
"devicePixelRatio": 1,
},
)
wait.until(
lambda current: current.execute_script(
"return innerWidth===arguments[0]&&innerHeight===arguments[1]",
width,
height,
)
)
set_viewport(1440, 1000)
driver.get(f"http://127.0.0.1:{port}/seances") driver.get(f"http://127.0.0.1:{port}/seances")
wait.until(lambda current: "Aucun résultat" in current.page_source) wait.until(lambda current: "Aucun résultat" in current.page_source)
EVIDENCE.mkdir(parents=True, exist_ok=True) EVIDENCE.mkdir(parents=True, exist_ok=True)
driver.save_screenshot(str(EVIDENCE / "web-sessions-list-desktop.png")) self.assertTrue(driver.save_screenshot(
str(EVIDENCE / "web-sessions-list-desktop.png")
))
driver.find_element(By.XPATH, "//button[normalize-space()='Nouvelle séance']").click() driver.find_element(By.XPATH, "//button[normalize-space()='Nouvelle séance']").click()
wait.until(lambda current: "Nouvelle séance" in current.page_source) wait.until(lambda current: "Nouvelle séance" in current.page_source)
title = driver.find_element(By.XPATH, "//label[normalize-space()='Titre']/input") title = driver.find_element(By.XPATH, "//label[normalize-space()='Titre']/input")
title.send_keys("Séance navigateur éphémère") title.send_keys("Séance navigateur éphémère")
driver.set_window_size(390, 844) planned = driver.find_element(
driver.save_screenshot(str(EVIDENCE / "web-sessions-editor-mobile.png")) By.XPATH, "//label[normalize-space()='Date planifiée']/input"
save = driver.find_element(By.XPATH, "//button[normalize-space()='Enregistrer']") )
driver.execute_script(
"const setter=Object.getOwnPropertyDescriptor("
"HTMLInputElement.prototype,'value').set;"
"setter.call(arguments[0],'2026-09-18');"
"arguments[0].dispatchEvent(new Event('input',{bubbles:true}));"
"arguments[0].dispatchEvent(new Event('change',{bubbles:true}));",
planned,
)
catalog_choice = wait.until(
lambda current: current.find_element(
By.CSS_SELECTOR, ".catalog-results button"
)
)
catalog_choice.click()
set_viewport(390, 844)
self.assertTrue(driver.save_screenshot(
str(EVIDENCE / "web-sessions-editor-mobile.png")
))
save = driver.find_element(
By.XPATH,
"//button[normalize-space()='Marquer prête / Préparer pour Android']",
)
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", save) driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", save)
save.click() save.click()
wait.until(lambda current: "PRÉPARATION MANUELLE" in current.page_source) wait.until(lambda current: "PRÉPARATION MANUELLE" in current.page_source)
driver.refresh() driver.refresh()
wait.until(lambda current: "Séance navigateur éphémère" in current.page_source) wait.until(lambda current: "Séance navigateur éphémère" in current.page_source)
driver.set_window_size(1440, 1000) set_viewport(1440, 1000)
driver.save_screenshot(str(EVIDENCE / "web-sessions-detail-desktop.png")) self.assertTrue(driver.save_screenshot(
str(EVIDENCE / "web-sessions-detail-desktop.png")
))
self.assertIn("18/09/2026", driver.page_source)
driver.find_element(
By.XPATH, "//button[normalize-space()='Paramètres d’affichage']"
).click()
wait.until(lambda current: "Format des dates" in current.page_source)
driver.find_element(By.CSS_SELECTOR, "input[value='iso']").click()
wait.until(
lambda current: current.find_element(
By.CSS_SELECTOR, "main time"
).text == "2026-09-18"
)
driver.refresh()
wait.until(
lambda current: current.find_element(
By.CSS_SELECTOR, "main time"
).text == "2026-09-18"
)
preferences = (
config / "trainlog" / "web" / "preferences-v1.json"
).read_text(encoding="utf-8")
self.assertIn('"date_format":"iso"', preferences)
driver.find_element(
By.XPATH, "//button[normalize-space()='Supprimer la préparation']"
).click()
confirmation = wait.until(
lambda current: current.find_element(By.CSS_SELECTOR, "[role='alertdialog']")
)
self.assertIn("Séance navigateur éphémère", confirmation.text)
set_viewport(390, 844)
self.assertTrue(driver.save_screenshot(
str(EVIDENCE / "web-sessions-delete-confirmation-mobile.png")
))
confirmation.find_element(
By.XPATH, ".//button[normalize-space()='Supprimer la préparation']"
).click()
wait.until(
lambda current: "annulation Android à synchroniser" in current.page_source
)
set_viewport(1440, 1000)
self.assertTrue(driver.save_screenshot(
str(EVIDENCE / "web-sessions-withdrawn-detail-desktop.png")
))
driver.refresh()
wait.until(lambda current: "Supprimée" in current.page_source)
self.assertNotIn(
"Supprimer la préparation",
driver.find_element(By.CSS_SELECTOR, "main").text,
)
database_path = xdg / "trainlog" / "trainlog.db" database_path = xdg / "trainlog" / "trainlog.db"
with sqlite3.connect(database_path) as database: with sqlite3.connect(database_path) as database:
row = database.execute( row = database.execute(
"SELECT r.title, p.editing_state " "SELECT r.title, p.editing_state, p.withdrawn_at "
"FROM session_preparations p " "FROM session_preparations p "
"JOIN session_preparation_revisions r " "JOIN session_preparation_revisions r "
"ON r.revision_id = p.current_revision_id" "ON r.revision_id = p.current_revision_id"
@ -98,9 +223,37 @@ class BrowserSessionsTest(unittest.TestCase):
entry_count = database.execute( entry_count = database.execute(
"SELECT COUNT(*) FROM session_preparation_entries" "SELECT COUNT(*) FROM session_preparation_entries"
).fetchone()[0] ).fetchone()[0]
self.assertEqual(row, ("Séance navigateur éphémère", "draft")) withdrawal_count = database.execute(
self.assertEqual(entry_count, 0) "SELECT COUNT(*) FROM session_preparation_withdrawals"
).fetchone()[0]
delivery_count = database.execute(
"SELECT COUNT(*) FROM session_preparation_deliveries"
).fetchone()[0]
self.assertEqual(row[:2], ("Séance navigateur éphémère", "ready"))
self.assertIsNotNone(row[2])
self.assertEqual(entry_count, 1)
self.assertEqual(delivery_count, 1)
self.assertEqual(withdrawal_count, 1)
export_path = root / "session-preparations-v2.json"
subprocess.run(
[
sys.executable,
str(ROOT / "tools" / "export_session_preparations.py"),
str(export_path),
"--database",
str(database_path),
],
check=True,
capture_output=True,
text=True,
)
exported = json.loads(export_path.read_text(encoding="utf-8"))
self.assertEqual(exported["deliveries"], [])
self.assertEqual(len(exported["withdrawals"]), 1)
self.assertEqual(len(exported["withdrawals"][0]["deliveries"]), 1)
finally: finally:
if bidi is not None:
bidi.close()
if driver is not None: if driver is not None:
driver.quit() driver.quit()
os.killpg(server.pid, signal.SIGTERM) os.killpg(server.pid, signal.SIGTERM)

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None: def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0] version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21, 22, 23): if version not in (20, 21, 22, 23, 24):
raise CausalError(f"desktop schema v20-v23 required, found v{version}") raise CausalError(f"desktop schema v20-v24 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str): def target_snapshot(db: sqlite3.Connection, kind: str, target: str):

View file

@ -182,8 +182,8 @@ def causal_payload(draft):
def require_schema(connection): def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0] version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21, 22, 23): if version not in (19, 20, 21, 22, 23, 24):
raise LifecycleError(f"desktop schema v19-v23 required, found v{version}") raise LifecycleError(f"desktop schema v19-v24 required, found v{version}")
def import_document(connection, document, own_transaction=True): def import_document(connection, document, own_transaction=True):

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23) SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24)
MEASUREMENT_FIELDS = ( MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",

View file

@ -20,8 +20,8 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection): def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations.""" """Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction owned_transaction = not connection.in_transaction
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23): if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23, 24):
raise ValueError("schema desktop v18-v23 requis") raise ValueError("schema desktop v18-v24 requis")
columns = {row[1] for row in connection.execute( columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")} "PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns: if "published_at" not in columns:

View file

@ -28,7 +28,7 @@ def main():
args = parser.parse_args() args = parser.parse_args()
connection = connect_database(args.database) connection = connect_database(args.database)
try: try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23): if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
raise ValueError("schema desktop v8 à v16 requis") raise ValueError("schema desktop v8 à v16 requis")
known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute( known_equipment.update(row[0] for row in connection.execute(

View file

@ -24,7 +24,7 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database) connection = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23): if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
raise ValueError("schema desktop v8 à v16 requis") raise ValueError("schema desktop v8 à v16 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal equipment protection requires the staged artifact") raise ValueError("causal equipment protection requires the staged artifact")

View file

@ -17,8 +17,8 @@ def main():
con = connect_database(args.database) con = connect_database(args.database)
try: try:
if con.execute("PRAGMA user_version").fetchone()[0] not in ( if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23): 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
raise ValueError("schema desktop v12-v23 requis") raise ValueError("schema desktop v12-v24 requis")
rows = con.execute( rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY" "ORDER BY source_exercise_id COLLATE BINARY"

View file

@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database) connection = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23): if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
raise ValueError("schema desktop v11-v16 requis") raise ValueError("schema desktop v11-v16 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal BODY ZONES protection requires the staged artifact") raise ValueError("causal BODY ZONES protection requires the staged artifact")

View file

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

View file

@ -71,7 +71,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC # CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current # catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database. # canonical desktop schema rather than accept a stale pre-v8 database.
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23): if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
raise SystemExit( raise SystemExit(
"PC_CATALOG_EXPORT=FAIL " "PC_CATALOG_EXPORT=FAIL "
f"schema={version}" f"schema={version}"

View file

@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
try: try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0] schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23): if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23) and not (args.version == 2 and schema_version == 10): if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24) and not (args.version == 2 and schema_version == 10):
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)") raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute( if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone(): "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():

View file

@ -13,11 +13,12 @@ from trainlog_sqlite import connect_database
MAX_DELIVERIES = 128 MAX_DELIVERIES = 128
MAX_ENTRIES = 64 MAX_ENTRIES = 64
MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict: def build_export(connection: sqlite3.Connection) -> dict:
if connection.execute("PRAGMA user_version").fetchone()[0] != 23: if connection.execute("PRAGMA user_version").fetchone()[0] != 24:
raise ValueError("desktop schema v23 required") raise ValueError("desktop schema v24 required")
deliveries = [] deliveries = []
rows = connection.execute( rows = connection.execute(
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state," "SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
@ -25,7 +26,7 @@ def build_export(connection: sqlite3.Connection) -> dict:
"FROM session_preparation_deliveries d " "FROM session_preparation_deliveries d "
"JOIN session_preparations p ON p.preparation_id=d.preparation_id " "JOIN session_preparations p ON p.preparation_id=d.preparation_id "
"JOIN session_preparation_revisions r ON r.revision_id=d.revision_id " "JOIN session_preparation_revisions r ON r.revision_id=d.revision_id "
"WHERE d.state IN('pending','remote_unknown') " "WHERE p.withdrawn_at IS NULL AND d.state IN('pending','remote_unknown') "
"ORDER BY d.created_at,d.delivery_id LIMIT ?", "ORDER BY d.created_at,d.delivery_id LIMIT ?",
(MAX_DELIVERIES + 1,), (MAX_DELIVERIES + 1,),
).fetchall() ).fetchall()
@ -56,10 +57,40 @@ def build_export(connection: sqlite3.Connection) -> dict:
"notes": entry[13], "notes": entry[13],
} for entry in entries], } for entry in entries],
}) })
withdrawals = []
withdrawal_rows = connection.execute(
"SELECT withdrawal_id,preparation_id,revision_id,requested_at "
"FROM session_preparation_withdrawals WHERE acknowledged_at IS NULL "
"ORDER BY requested_at,withdrawal_id LIMIT ?",
(MAX_WITHDRAWALS + 1,),
).fetchall()
if len(withdrawal_rows) > MAX_WITHDRAWALS:
raise ValueError("preparation withdrawal capacity exceeded")
for row in withdrawal_rows:
related = connection.execute(
"SELECT delivery_id,revision_id,execution_session_id "
"FROM session_preparation_deliveries WHERE preparation_id=? "
"ORDER BY created_at,delivery_id LIMIT ?",
(row[1], MAX_DELIVERIES + 1),
).fetchall()
if len(related) > MAX_DELIVERIES:
raise ValueError("preparation withdrawal delivery capacity exceeded")
withdrawals.append({
"withdrawal_id": row[0],
"preparation_id": row[1],
"revision_id": row[2],
"requested_at": row[3],
"deliveries": [{
"delivery_id": delivery[0],
"revision_id": delivery[1],
"execution_session_id": delivery[2],
} for delivery in related],
})
return { return {
"format": "trainlog-session-preparations", "version": 1, "format": "trainlog-session-preparations", "version": 2,
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(), "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"deliveries": deliveries, "deliveries": deliveries,
"withdrawals": withdrawals,
} }

View file

@ -10,7 +10,7 @@ def main(complete_causal_envelope=False):
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args() parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database) db=connect_database(args.database)
try: try:
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21, 22, 23): raise ValueError("schema desktop v15-v23 requis") if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21, 22, 23, 24): raise ValueError("schema desktop v15-v24 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall() roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall() followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()

View file

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

View file

@ -187,7 +187,7 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8")) payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database) connection = connect_database(args.database)
try: try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23): if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
fail("schema desktop v8 à v21 requis") fail("schema desktop v8 à v21 requis")
proof_path = args.mobile_export proof_path = args.mobile_export
if proof_path is None: if proof_path is None:

View file

@ -53,7 +53,7 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False): def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction.""" """Apply validated definitions without owning the surrounding transaction."""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23): if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
fail("schema desktop v8 à v21 requis") fail("schema desktop v8 à v21 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
fail("causal equipment protection requires the staged artifact") fail("causal equipment protection requires the staged artifact")

View file

@ -56,7 +56,7 @@ def load(path):
def apply_aliases(con, aliases): def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction.""" """Apply parsed aliases inside the caller-owned transaction."""
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23): if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
fail("schema desktop v12-v21 requis") fail("schema desktop v12-v21 requis")
for source, canonical in aliases: for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()

View file

@ -151,7 +151,7 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False): def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries.""" """Apply validated zone rows without owning transaction boundaries."""
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21,22,23): raise ImportFailure("schema desktop v11-v23 requis") if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21,22,23,24): raise ImportFailure("schema desktop v11-v24 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON");grouped={} connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct # CONTRACT: resolve complete alias groups before the first write; direct

View file

@ -49,7 +49,7 @@ def validate(item):
def apply_profile_state(con, root, allow_pending=False): def apply_profile_state(con, root, allow_pending=False):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide") if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22,23): fail("schema desktop v17-v23 requis") if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22,23,24): fail("schema desktop v17-v24 requis")
applied=equal=ancestor=pending=0;seen=set() applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]: for item in root["exercises"]:
validate(item);eid=item["exercise_id"] validate(item);eid=item["exercise_id"]

View file

@ -846,9 +846,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table. # readable for legacy artifacts and are never made to fake that table.
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23): if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
raise ImportFailure( raise ImportFailure(
f"base desktop schema v5 à v23 attendue, version trouvée: {version}" f"base desktop schema v5 à v24 attendue, version trouvée: {version}"
) )
@ -1978,8 +1978,8 @@ def apply_payload(connection, payload, trace_exercises=False):
require_supported_schema(connection) require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23): if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v23") raise ImportFailure("mobile V4 exige le schéma desktop v19 à v24")
if schema_version >= 20: if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute( protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} "SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}

View file

@ -52,7 +52,7 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False): def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0 roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON") db.execute("PRAGMA foreign_keys=ON")
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21,22,23):fail("schema desktop v15-v23 requis") if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21,22,23,24):fail("schema desktop v15-v24 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
seen=set() seen=set()
for index,item in enumerate(root["exercise_feedback"]): for index,item in enumerate(root["exercise_feedback"]):

View file

@ -96,8 +96,8 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(), ).strip(),
"product_version": "0.1.2", "product_version": "0.1.2",
"desktop_schema": 23, "desktop_schema": 24,
"android_schema": 22, "android_schema": 23,
"protocols": [ "protocols": [
"mobile-export-v3", "mobile-export-v3",
"mobile-history-v4", "mobile-history-v4",
@ -106,7 +106,7 @@ def main() -> int:
"generation-manifest-v1", "generation-manifest-v1",
"generation-ack-v1", "generation-ack-v1",
"generation-archive-v1", "generation-archive-v1",
"session-preparations-v1", "session-preparations-v2",
], ],
"entry_points": [ "entry_points": [
"bin/trainlog", "bin/trainlog",

View file

@ -70,8 +70,8 @@ ARTIFACTS = (
"causal_delete_exchange.py", ("export",)), "causal_delete_exchange.py", ("export",)),
("ai-proposals", "trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", False, ("ai-proposals", "trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", False,
"export_ai_session_drafts.py", ()), "export_ai_session_drafts.py", ()),
("session-preparations", "trainlog-session-preparations", 1, ("session-preparations", "trainlog-session-preparations", 2,
"session-preparations-v1.json", False, "export_session_preparations.py", ()), "session-preparations-v2.json", False, "export_session_preparations.py", ()),
) )
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS}
@ -201,8 +201,8 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None: def require_schema(db: sqlite3.Connection) -> None:
if db.execute("PRAGMA user_version").fetchone()[0] != 23: if db.execute("PRAGMA user_version").fetchone()[0] != 24:
raise GenerationError("desktop schema v23 required") raise GenerationError("desktop schema v24 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str: def peer_identity(db: sqlite3.Connection, kind: str) -> str:
@ -407,12 +407,12 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
for value in descriptors]) for value in descriptors])
# CONTRACT: the exact generation relation is recorded only after # CONTRACT: the exact generation relation is recorded only after
# its complete immutable artifact has been captured successfully. # its complete immutable artifact has been captured successfully.
preparation_path = stage / "session-preparations-v1.json" preparation_path = stage / "session-preparations-v2.json"
if preparation_path.is_file(): if preparation_path.is_file():
captured_preparations = strict_json( captured_preparations = strict_json(
preparation_path.read_bytes(), MAX_ARTIFACT preparation_path.read_bytes(), MAX_ARTIFACT
)["deliveries"] )
for delivery in captured_preparations: for delivery in captured_preparations["deliveries"]:
cursor = db.execute( cursor = db.execute(
"UPDATE session_preparation_deliveries SET generation_id=? " "UPDATE session_preparation_deliveries SET generation_id=? "
"WHERE delivery_id=? AND revision_id=? AND " "WHERE delivery_id=? AND revision_id=? AND "
@ -421,6 +421,14 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
) )
if cursor.rowcount != 1: if cursor.rowcount != 1:
raise GenerationError("preparation delivery changed during capture") raise GenerationError("preparation delivery changed during capture")
for withdrawal in captured_preparations["withdrawals"]:
cursor = db.execute(
"UPDATE session_preparation_withdrawals SET generation_id=? "
"WHERE withdrawal_id=? AND revision_id=? AND generation_id IS NULL",
(generation, withdrawal["withdrawal_id"], withdrawal["revision_id"]),
)
if cursor.rowcount != 1:
raise GenerationError("preparation withdrawal changed during capture")
causal_path = stage / "causal-deletions-v1.json" causal_path = stage / "causal-deletions-v1.json"
if causal_path.is_file(): if causal_path.is_file():
for operation in json.loads(causal_path.read_text())["operations"]: for operation in json.loads(causal_path.read_text())["operations"]:
@ -685,6 +693,11 @@ def accept_ack(database: Path, path: Path) -> str:
"WHERE generation_id=? AND state IN('pending','remote_unknown')", "WHERE generation_id=? AND state IN('pending','remote_unknown')",
(ack["consumed_at"], ack["generation_id"]), (ack["consumed_at"], ack["generation_id"]),
) )
db.execute(
"UPDATE session_preparation_withdrawals SET acknowledged_at=? "
"WHERE generation_id=? AND acknowledged_at IS NULL",
(ack["consumed_at"], ack["generation_id"]),
)
db.execute( db.execute(
"UPDATE session_preparations SET delivery_state='acknowledged' WHERE EXISTS(" "UPDATE session_preparations SET delivery_state='acknowledged' WHERE EXISTS("
"SELECT 1 FROM session_preparation_deliveries d WHERE " "SELECT 1 FROM session_preparation_deliveries d WHERE "

View file

@ -31,6 +31,7 @@ CAPS = {
"mobile-history-v4", "mobile-history-v4",
"execution-draft-v1", "execution-draft-v1",
"generation-archive-v1", "generation-archive-v1",
"session-preparations-v2",
} }

View file

@ -16,7 +16,7 @@
#include "trainlog/model.h" #include "trainlog/model.h"
#include "trainlog/status.h" #include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 23 #define TRAINLOG_DATABASE_SCHEMA_VERSION 24
typedef struct TrainlogDatabase TrainlogDatabase; typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -40,8 +40,8 @@
* - `bo` for body observations. * - `bo` for body observations.
* - `sy` for synchronization runs; * - `sy` for synchronization runs;
* - `sxe` for persisted session-exercise occurrences; * - `sxe` for persisted session-exercise occurrences;
* - `sp`, `spr`, `spe`, and `spd` for preparation, revision, occurrence, and * - `sp`, `spr`, `spe`, `spd`, and `spw` for preparation, revision,
* delivery identities. * occurrence, delivery, and withdrawal identities.
* *
* Imported v1 documents may contain other schema-valid opaque identifiers; * Imported v1 documents may contain other schema-valid opaque identifiers;
* this API defines creation policy, not import validation. * this API defines creation policy, not import validation.

View file

@ -0,0 +1,53 @@
#ifndef TRAINLOG_WEB_PREFERENCES_H
#define TRAINLOG_WEB_PREFERENCES_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define TRAINLOG_WEB_PREFERENCES_JSON_CAPACITY 512U
#define TRAINLOG_WEB_PREFERENCES_MAX_REVISION 9007199254740991ULL
typedef enum TrainlogWebDateFormat {
TRAINLOG_WEB_DATE_FORMAT_FRENCH = 0,
TRAINLOG_WEB_DATE_FORMAT_ISO
} TrainlogWebDateFormat;
typedef struct TrainlogWebPreferences {
uint64_t revision;
TrainlogWebDateFormat date_format;
} TrainlogWebPreferences;
typedef enum TrainlogWebPreferencesSource {
TRAINLOG_WEB_PREFERENCES_DEFAULT = 0,
TRAINLOG_WEB_PREFERENCES_PERSISTED,
TRAINLOG_WEB_PREFERENCES_INVALID_PERSISTED
} TrainlogWebPreferencesSource;
typedef enum TrainlogWebPreferencesResult {
TRAINLOG_WEB_PREFERENCES_OK = 0,
TRAINLOG_WEB_PREFERENCES_INVALID,
TRAINLOG_WEB_PREFERENCES_CONFLICT,
TRAINLOG_WEB_PREFERENCES_IO_ERROR
} TrainlogWebPreferencesResult;
/*
* CONTRACT: this module owns only installation-local Web presentation
* preferences below XDG_CONFIG_HOME/trainlog/web. It never reads or writes
* business SQLite state. Revision zero is the French default when no valid
* private file exists.
*/
void trainlog_web_preferences_default(TrainlogWebPreferences *preferences);
TrainlogWebPreferencesResult trainlog_web_preferences_parse(
const char *json, size_t size, TrainlogWebPreferences *preferences);
TrainlogWebPreferencesResult trainlog_web_preferences_load(
TrainlogWebPreferences *preferences, TrainlogWebPreferencesSource *source);
TrainlogWebPreferencesResult trainlog_web_preferences_save(
const TrainlogWebPreferences *preferences, uint64_t expected_revision,
TrainlogWebPreferences *saved);
bool trainlog_web_preferences_serialize(
const TrainlogWebPreferences *preferences, TrainlogWebPreferencesSource source,
bool include_source, char *output, size_t capacity, size_t *size);
bool trainlog_web_preferences_path(char *output, size_t capacity);
#endif

View file

@ -11,7 +11,8 @@
typedef enum TrainlogWebPreparedItemKind { typedef enum TrainlogWebPreparedItemKind {
TRAINLOG_WEB_PREPARED_AI_PROPOSAL = 0, TRAINLOG_WEB_PREPARED_AI_PROPOSAL = 0,
TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT = 1 TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT = 1,
TRAINLOG_WEB_PREPARED_MANUAL_PREPARATION = 2
} TrainlogWebPreparedItemKind; } TrainlogWebPreparedItemKind;
typedef struct TrainlogWebPreparedItem { typedef struct TrainlogWebPreparedItem {
@ -20,6 +21,7 @@ typedef struct TrainlogWebPreparedItem {
char state[32]; char state[32];
char title[TRAINLOG_NAME_MAX + 1U]; char title[TRAINLOG_NAME_MAX + 1U];
char planned_for[11]; char planned_for[11];
char sort_timestamp[TRAINLOG_TIMESTAMP_MAX + 1U];
size_t occurrence_count; size_t occurrence_count;
char provenance[32]; char provenance[32];
} TrainlogWebPreparedItem; } TrainlogWebPreparedItem;

View file

@ -79,4 +79,18 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
char **output_json, char **output_json,
size_t *output_size); size_t *output_size);
/*
* CONTRACT: withdrawal is a logical, durable removal of one preparation.
* It preserves immutable revisions and every execution identity. Only the
* current revision may be withdrawn, and request_id makes response replay
* idempotent. Android delivery cancellation is completed by the versioned
* generation participant rather than by deleting delivery evidence here.
*/
TrainlogStatus trainlog_web_sessions_withdraw_json(TrainlogDatabase *database,
const char *preparation_id,
const char *expected_revision,
const char *request_id,
char **output_json,
size_t *output_size);
#endif #endif

View file

@ -47,12 +47,15 @@ if web_assets_enabled
'../web/src/api/health.ts', '../web/src/api/health.ts',
'../web/src/api/dashboard.ts', '../web/src/api/dashboard.ts',
'../web/src/api/dashboardLayout.ts', '../web/src/api/dashboardLayout.ts',
'../web/src/api/preparedItems.ts',
'../web/src/api/sync.ts', '../web/src/api/sync.ts',
'../web/src/api/sync.test.ts', '../web/src/api/sync.test.ts',
'../web/src/api/webPreferences.ts',
'../web/src/app/App.tsx', '../web/src/app/App.tsx',
'../web/src/app/routes.ts', '../web/src/app/routes.ts',
'../web/src/app/useRoute.ts', '../web/src/app/useRoute.ts',
'../web/src/components/EmptyState.tsx', '../web/src/components/EmptyState.tsx',
'../web/src/components/DisplayPreferences.tsx',
'../web/src/components/Footer.tsx', '../web/src/components/Footer.tsx',
'../web/src/components/Header.tsx', '../web/src/components/Header.tsx',
'../web/src/components/SyncControl.tsx', '../web/src/components/SyncControl.tsx',
@ -65,6 +68,8 @@ if web_assets_enabled
'../web/src/dashboard/DashboardTiles.tsx', '../web/src/dashboard/DashboardTiles.tsx',
'../web/src/dashboard/dashboardFormat.ts', '../web/src/dashboard/dashboardFormat.ts',
'../web/src/dashboard/dashboardLayout.ts', '../web/src/dashboard/dashboardLayout.ts',
'../web/src/presentation/DatePreferences.tsx',
'../web/src/presentation/dateFormat.ts',
'../web/src/routes/DashboardPage.tsx', '../web/src/routes/DashboardPage.tsx',
'../web/src/routes/PlaceholderPage.tsx', '../web/src/routes/PlaceholderPage.tsx',
'../web/src/routes/SessionsPage.tsx', '../web/src/routes/SessionsPage.tsx',
@ -266,6 +271,7 @@ trainlog_tui_sources = files(
'src/theme.c', 'src/theme.c',
'src/tui.c', 'src/tui.c',
'src/dashboard_layout.c', 'src/dashboard_layout.c',
'src/web_preferences.c',
'src/web_server.c', 'src/web_server.c',
) )
trainlog_tui_sources += web_assets_source trainlog_tui_sources += web_assets_source
@ -376,7 +382,8 @@ test('cli', test_cli)
test_web_server = executable( test_web_server = executable(
'test_web_server', 'test_web_server',
['tests/test_web_server.c', 'src/web_server.c', 'src/dashboard_layout.c', web_assets_source], ['tests/test_web_server.c', 'src/web_server.c', 'src/dashboard_layout.c',
'src/web_preferences.c', web_assets_source],
dependencies: [trainlog_core_dep, microhttpd_dep, yyjson_dep], dependencies: [trainlog_core_dep, microhttpd_dep, yyjson_dep],
include_directories: trainlog_private_include, include_directories: trainlog_private_include,
c_args: strict_c_args, c_args: strict_c_args,
@ -782,6 +789,11 @@ test(
args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'], args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'],
timeout: 120, timeout: 120,
) )
test(
'session_preparation_export',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_session_preparation_export.py'],
)
test( test(
'generation_archival', 'generation_archival',
find_program('python3'), find_program('python3'),

View file

@ -923,6 +923,22 @@ static const char *const MIGRATE_V22_TO_V23_SQL =
"UNIQUE(preparation_id,revision_id,delivery_id));" "UNIQUE(preparation_id,revision_id,delivery_id));"
"PRAGMA user_version=23;COMMIT;"; "PRAGMA user_version=23;COMMIT;";
/* WHY: omitting a delivered preparation from later snapshots cannot retract
* an already consumed Android object and permits replay resurrection.
* CONTRACT: v24 records one immutable, exportable withdrawal per preparation
* while retaining revisions, deliveries, ACKs and execution identities.
* INVARIANT: migration withdraws nothing and never touches workout facts. */
static const char *const MIGRATE_V23_TO_V24_SQL =
"BEGIN IMMEDIATE;"
"ALTER TABLE session_preparations ADD COLUMN withdrawn_at TEXT;"
"CREATE TABLE session_preparation_withdrawals("
"withdrawal_id TEXT PRIMARY KEY,"
"preparation_id TEXT NOT NULL UNIQUE REFERENCES session_preparations(preparation_id),"
"revision_id TEXT NOT NULL REFERENCES session_preparation_revisions(revision_id),"
"requested_at TEXT NOT NULL,generation_id TEXT,acknowledged_at TEXT,"
"UNIQUE(preparation_id,revision_id));"
"PRAGMA user_version=24;COMMIT;";
/* CONTRACT: v18 was already deployed before bounded draft publication was /* CONTRACT: v18 was already deployed before bounded draft publication was
* added. Keep its version number and add only the nullable lifecycle cursor; * added. Keep its version number and add only the nullable lifecycle cursor;
* NULL deliberately means retryable/not yet published. */ * NULL deliberately means retryable/not yet published. */
@ -1619,7 +1635,7 @@ static TrainlogStatus initialize_or_validate_schema(
} else if (version == 11 || version == 12 || version == 13 || version == 14 || } else if (version == 11 || version == 12 || version == 13 || version == 14 ||
version == 15 || version == 16 || version == 17 || version == 18 || version == 15 || version == 16 || version == 17 || version == 18 ||
version == 19 || version == 20 || version == 21 || version == 22 || version == 19 || version == 20 || version == 21 || version == 22 ||
version == 23) { version == 23 || version == 24) {
status = TRAINLOG_STATUS_OK; status = TRAINLOG_STATUS_OK;
} else { } else {
if (version == 1) { if (version == 1) {
@ -1798,6 +1814,9 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK && version < 23) { if (status == TRAINLOG_STATUS_OK && version < 23) {
status = execute_sql(database, MIGRATE_V22_TO_V23_SQL); status = execute_sql(database, MIGRATE_V22_TO_V23_SQL);
} }
if (status == TRAINLOG_STATUS_OK && version < 24) {
status = execute_sql(database, MIGRATE_V23_TO_V24_SQL);
}
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database); status = ensure_v18_ai_draft_publication_state(database);
} }
@ -1813,7 +1832,7 @@ static TrainlogStatus initialize_or_validate_schema(
set_open_diagnostic( set_open_diagnostic(
output_diagnostic, output_diagnostic,
output_diagnostic_capacity, output_diagnostic_capacity,
version == 0 ? "create schema v23" : "migrate database to schema v23", version == 0 ? "create schema v24" : "migrate database to schema v24",
database->connection, database->connection,
SQLITE_ERROR SQLITE_ERROR
); );

View file

@ -19,7 +19,8 @@ static bool prefix_is_supported(const char *prefix) {
*/ */
return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 || return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 ||
strcmp(prefix, "sy") == 0 || strcmp(prefix, "sxe") == 0 || strcmp(prefix, "sp") == 0 || strcmp(prefix, "sy") == 0 || strcmp(prefix, "sxe") == 0 || strcmp(prefix, "sp") == 0 ||
strcmp(prefix, "spr") == 0 || strcmp(prefix, "spe") == 0 || strcmp(prefix, "spd") == 0; strcmp(prefix, "spr") == 0 || strcmp(prefix, "spe") == 0 || strcmp(prefix, "spd") == 0 ||
strcmp(prefix, "spw") == 0;
} }
TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) { TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) {

301
tui/src/web_preferences.c Normal file
View file

@ -0,0 +1,301 @@
#include "trainlog/web_preferences.h"
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <yyjson.h>
void trainlog_web_preferences_default(TrainlogWebPreferences *preferences) {
if (preferences == NULL) {
return;
}
preferences->revision = 0U;
preferences->date_format = TRAINLOG_WEB_DATE_FORMAT_FRENCH;
}
static bool preferences_valid(const TrainlogWebPreferences *preferences) {
return preferences != NULL &&
preferences->revision <= TRAINLOG_WEB_PREFERENCES_MAX_REVISION &&
(preferences->date_format == TRAINLOG_WEB_DATE_FORMAT_FRENCH ||
preferences->date_format == TRAINLOG_WEB_DATE_FORMAT_ISO);
}
TrainlogWebPreferencesResult trainlog_web_preferences_parse(
const char *json, size_t size, TrainlogWebPreferences *preferences) {
yyjson_doc *document;
yyjson_val *root;
yyjson_val *revision;
yyjson_val *date_format;
const char *date_format_text;
if (json == NULL || size == 0U || size >= TRAINLOG_WEB_PREFERENCES_JSON_CAPACITY ||
preferences == NULL) {
return TRAINLOG_WEB_PREFERENCES_INVALID;
}
document = yyjson_read(json, size, YYJSON_READ_NOFLAG);
if (document == NULL) {
return TRAINLOG_WEB_PREFERENCES_INVALID;
}
root = yyjson_doc_get_root(document);
revision = yyjson_obj_get(root, "revision");
date_format = yyjson_obj_get(root, "date_format");
if (!yyjson_is_obj(root) || yyjson_obj_size(root) != 4U ||
!yyjson_equals_str(yyjson_obj_get(root, "format"), "trainlog-web-preferences") ||
!yyjson_is_uint(yyjson_obj_get(root, "version")) ||
yyjson_get_uint(yyjson_obj_get(root, "version")) != 1U || !yyjson_is_uint(revision) ||
!yyjson_is_str(date_format)) {
yyjson_doc_free(document);
return TRAINLOG_WEB_PREFERENCES_INVALID;
}
preferences->revision = yyjson_get_uint(revision);
date_format_text = yyjson_get_str(date_format);
if (strcmp(date_format_text, "fr") == 0) {
preferences->date_format = TRAINLOG_WEB_DATE_FORMAT_FRENCH;
} else if (strcmp(date_format_text, "iso") == 0) {
preferences->date_format = TRAINLOG_WEB_DATE_FORMAT_ISO;
} else {
yyjson_doc_free(document);
return TRAINLOG_WEB_PREFERENCES_INVALID;
}
yyjson_doc_free(document);
return preferences_valid(preferences) ? TRAINLOG_WEB_PREFERENCES_OK
: TRAINLOG_WEB_PREFERENCES_INVALID;
}
bool trainlog_web_preferences_path(char *output, size_t capacity) {
const char *base = getenv("XDG_CONFIG_HOME");
const char *home;
int length;
if (output == NULL || capacity == 0U) {
return false;
}
if (base != NULL && base[0] != '\0') {
length = snprintf(output, capacity, "%s/trainlog/web/preferences-v1.json", base);
} else {
home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return false;
}
length = snprintf(output,
capacity,
"%s/.config/trainlog/web/preferences-v1.json",
home);
}
return length > 0 && (size_t)length < capacity;
}
static bool ensure_private_directories(const char *path) {
char web[4096];
char trainlog[4096];
char *last;
struct stat status;
if (path == NULL || strlen(path) >= sizeof(web)) {
return false;
}
(void)snprintf(web, sizeof(web), "%s", path);
last = strrchr(web, '/');
if (last == NULL) {
return false;
}
*last = '\0';
(void)snprintf(trainlog, sizeof(trainlog), "%s", web);
last = strrchr(trainlog, '/');
if (last == NULL) {
return false;
}
*last = '\0';
/* CONTRACT: refuse symlinks and chmod only Trainlog-owned directories. */
if (mkdir(trainlog, 0700) != 0 && errno != EEXIST) {
return false;
}
if (lstat(trainlog, &status) != 0 || !S_ISDIR(status.st_mode) ||
chmod(trainlog, 0700) != 0) {
return false;
}
if (mkdir(web, 0700) != 0 && errno != EEXIST) {
return false;
}
if (lstat(web, &status) != 0 || !S_ISDIR(status.st_mode)) {
return false;
}
return chmod(web, 0700) == 0;
}
bool trainlog_web_preferences_serialize(
const TrainlogWebPreferences *preferences, TrainlogWebPreferencesSource source,
bool include_source, char *output, size_t capacity, size_t *size) {
const char *date_format;
const char *source_name;
int written;
if (!preferences_valid(preferences) || output == NULL || size == NULL) {
return false;
}
date_format = preferences->date_format == TRAINLOG_WEB_DATE_FORMAT_ISO ? "iso" : "fr";
source_name = source == TRAINLOG_WEB_PREFERENCES_PERSISTED
? "persisted"
: source == TRAINLOG_WEB_PREFERENCES_INVALID_PERSISTED ? "invalid_persisted"
: "default";
if (include_source) {
written = snprintf(output,
capacity,
"{\"format\":\"trainlog-web-preferences\",\"version\":1,"
"\"revision\":%llu,\"date_format\":\"%s\",\"source\":\"%s\"}\n",
(unsigned long long)preferences->revision,
date_format,
source_name);
} else {
written = snprintf(output,
capacity,
"{\"format\":\"trainlog-web-preferences\",\"version\":1,"
"\"revision\":%llu,\"date_format\":\"%s\"}\n",
(unsigned long long)preferences->revision,
date_format);
}
if (written < 0 || (size_t)written >= capacity) {
return false;
}
*size = (size_t)written;
return true;
}
TrainlogWebPreferencesResult trainlog_web_preferences_load(
TrainlogWebPreferences *preferences, TrainlogWebPreferencesSource *source) {
char path[4096];
char buffer[TRAINLOG_WEB_PREFERENCES_JSON_CAPACITY];
struct stat status;
ssize_t count;
int descriptor;
if (preferences == NULL || source == NULL ||
!trainlog_web_preferences_path(path, sizeof(path))) {
return TRAINLOG_WEB_PREFERENCES_IO_ERROR;
}
trainlog_web_preferences_default(preferences);
*source = TRAINLOG_WEB_PREFERENCES_DEFAULT;
descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
if (descriptor < 0) {
if (errno == ENOENT || errno == ELOOP) {
if (errno == ELOOP) {
*source = TRAINLOG_WEB_PREFERENCES_INVALID_PERSISTED;
}
return TRAINLOG_WEB_PREFERENCES_OK;
}
return TRAINLOG_WEB_PREFERENCES_IO_ERROR;
}
if (fstat(descriptor, &status) != 0 || !S_ISREG(status.st_mode) || status.st_size <= 0 ||
status.st_size >= (off_t)sizeof(buffer)) {
(void)close(descriptor);
*source = TRAINLOG_WEB_PREFERENCES_INVALID_PERSISTED;
return TRAINLOG_WEB_PREFERENCES_OK;
}
count = read(descriptor, buffer, (size_t)status.st_size);
if (close(descriptor) != 0 || count != status.st_size ||
trainlog_web_preferences_parse(buffer, (size_t)count, preferences) !=
TRAINLOG_WEB_PREFERENCES_OK) {
trainlog_web_preferences_default(preferences);
*source = TRAINLOG_WEB_PREFERENCES_INVALID_PERSISTED;
return TRAINLOG_WEB_PREFERENCES_OK;
}
*source = TRAINLOG_WEB_PREFERENCES_PERSISTED;
return TRAINLOG_WEB_PREFERENCES_OK;
}
static bool write_all(int descriptor, const char *data, size_t size) {
size_t used = 0U;
while (used < size) {
ssize_t count = write(descriptor, data + used, size - used);
if (count < 0 && errno == EINTR) {
continue;
}
if (count <= 0) {
return false;
}
used += (size_t)count;
}
return true;
}
static void sync_parent(const char *path) {
char parent[4096];
char *slash;
int descriptor;
if (strlen(path) >= sizeof(parent)) {
return;
}
(void)snprintf(parent, sizeof(parent), "%s", path);
slash = strrchr(parent, '/');
if (slash == NULL) {
return;
}
*slash = '\0';
descriptor = open(parent, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW);
if (descriptor >= 0) {
(void)fsync(descriptor);
(void)close(descriptor);
}
}
TrainlogWebPreferencesResult trainlog_web_preferences_save(
const TrainlogWebPreferences *preferences, uint64_t expected_revision,
TrainlogWebPreferences *saved) {
TrainlogWebPreferences current;
TrainlogWebPreferencesSource source;
char path[4096];
char temporary[4128];
char json[TRAINLOG_WEB_PREFERENCES_JSON_CAPACITY];
size_t size;
int descriptor;
bool ok;
if (!preferences_valid(preferences) || saved == NULL ||
preferences->revision != expected_revision ||
expected_revision >= TRAINLOG_WEB_PREFERENCES_MAX_REVISION) {
return TRAINLOG_WEB_PREFERENCES_INVALID;
}
if (trainlog_web_preferences_load(&current, &source) != TRAINLOG_WEB_PREFERENCES_OK) {
return TRAINLOG_WEB_PREFERENCES_IO_ERROR;
}
if (current.revision != expected_revision) {
return TRAINLOG_WEB_PREFERENCES_CONFLICT;
}
*saved = *preferences;
saved->revision = expected_revision + 1U;
if (!trainlog_web_preferences_path(path, sizeof(path)) || !ensure_private_directories(path) ||
!trainlog_web_preferences_serialize(saved,
TRAINLOG_WEB_PREFERENCES_PERSISTED,
false,
json,
sizeof(json),
&size) ||
snprintf(temporary, sizeof(temporary), "%s.tmp.XXXXXX", path) >=
(int)sizeof(temporary)) {
return TRAINLOG_WEB_PREFERENCES_IO_ERROR;
}
descriptor = mkstemp(temporary);
if (descriptor < 0) {
return TRAINLOG_WEB_PREFERENCES_IO_ERROR;
}
ok = fchmod(descriptor, 0600) == 0 && fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0 &&
write_all(descriptor, json, size) && fsync(descriptor) == 0;
if (close(descriptor) != 0) {
ok = false;
}
if (!ok || rename(temporary, path) != 0) {
(void)unlink(temporary);
return TRAINLOG_WEB_PREFERENCES_IO_ERROR;
}
/* INVARIANT: rename commits the new revision; directory fsync is best
* effort because a visible commit cannot safely be retried as the old one. */
sync_parent(path);
return TRAINLOG_WEB_PREFERENCES_OK;
}

View file

@ -56,7 +56,7 @@ static TrainlogStatus load_ai_proposals(
static const char SQL[] = static const char SQL[] =
"SELECT d.draft_id,COALESCE(d.title,''),COALESCE(d.planned_for,'')," "SELECT d.draft_id,COALESCE(d.title,''),COALESCE(d.planned_for,''),"
"COUNT(e.id),CASE WHEN d.published_at IS NULL THEN 'pending_publication' ELSE 'published' " "COUNT(e.id),CASE WHEN d.published_at IS NULL THEN 'pending_publication' ELSE 'published' "
"END FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON " "END,d.created_at FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON "
"e.draft_row_id=d.id GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31')," "e.draft_row_id=d.id GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),"
"d.created_at,d.draft_id LIMIT ?1;"; "d.created_at,d.draft_id LIMIT ?1;";
sqlite3_stmt *statement = NULL; sqlite3_stmt *statement = NULL;
@ -80,7 +80,8 @@ static TrainlogStatus load_ai_proposals(
!copy_column(statement, 1, item->title, sizeof(item->title)) || !copy_column(statement, 1, item->title, sizeof(item->title)) ||
!copy_column(statement, 2, item->planned_for, sizeof(item->planned_for)) || !copy_column(statement, 2, item->planned_for, sizeof(item->planned_for)) ||
!read_count(statement, 3, &item->occurrence_count) || !read_count(statement, 3, &item->occurrence_count) ||
!copy_column(statement, 4, item->state, sizeof(item->state))) { !copy_column(statement, 4, item->state, sizeof(item->state)) ||
!copy_column(statement, 5, item->sort_timestamp, sizeof(item->sort_timestamp))) {
goto fail; goto fail;
} }
(void)snprintf(item->provenance, sizeof(item->provenance), "%s", "ai_import"); (void)snprintf(item->provenance, sizeof(item->provenance), "%s", "ai_import");
@ -98,11 +99,61 @@ fail:
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
static TrainlogStatus load_manual_preparations(
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
static const char SQL[] =
"SELECT p.preparation_id,r.title,COALESCE(r.planned_for,''),COUNT(e.entry_id),"
"p.editing_state,p.updated_at FROM session_preparations p JOIN "
"session_preparation_revisions r ON r.revision_id=p.current_revision_id LEFT JOIN "
"session_preparation_entries e ON e.revision_id=r.revision_id WHERE "
"p.withdrawn_at IS NULL GROUP BY p.preparation_id ORDER BY "
"COALESCE(r.planned_for,'9999-12-31'),p.updated_at,p.preparation_id LIMIT ?1;";
sqlite3_stmt *statement = NULL;
int step;
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_int64(
statement, 1, (sqlite3_int64)(TRAINLOG_WEB_PREPARED_ITEM_CAPACITY + 1U)) != SQLITE_OK) {
goto fail;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
TrainlogWebPreparedItem *item;
if (output->item_count == TRAINLOG_WEB_PREPARED_ITEM_CAPACITY) {
output->partial = true;
continue;
}
item = &output->items[output->item_count];
item->kind = TRAINLOG_WEB_PREPARED_MANUAL_PREPARATION;
if (!copy_column(statement, 0, item->identity, sizeof(item->identity)) ||
!copy_column(statement, 1, item->title, sizeof(item->title)) ||
!copy_column(statement, 2, item->planned_for, sizeof(item->planned_for)) ||
!read_count(statement, 3, &item->occurrence_count) ||
!copy_column(statement, 4, item->state, sizeof(item->state)) ||
!copy_column(statement, 5, item->sort_timestamp, sizeof(item->sort_timestamp))) {
goto fail;
}
(void)snprintf(item->provenance, sizeof(item->provenance), "%s", "manual_preparation");
++output->item_count;
}
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
fail:
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus load_execution_drafts( static TrainlogStatus load_execution_drafts(
TrainlogDatabase *database, TrainlogWebPreparedItems *output) { TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
static const char SQL[] = static const char SQL[] =
"SELECT d.session_id,d.state,d.session_type,COALESCE(substr(d.started_at,1,10),'')," "SELECT d.session_id,d.state,d.session_type,COALESCE(substr(d.started_at,1,10),''),"
"COALESCE(json_array_length(d.payload_json,'$.exercises'),0) FROM execution_drafts d " "COALESCE(json_array_length(d.payload_json,'$.exercises'),0),COALESCE(d.started_at,'') "
"FROM execution_drafts d "
"WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE " "WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE "
"f.session_id=d.session_id) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE " "f.session_id=d.session_id) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE "
"c.target_kind='execution_draft' AND c.target_id=d.session_id AND c.deleted=1) " "c.target_kind='execution_draft' AND c.target_id=d.session_id AND c.deleted=1) "
@ -131,7 +182,8 @@ static TrainlogStatus load_execution_drafts(
!copy_column(statement, 1, item->state, sizeof(item->state)) || !copy_column(statement, 1, item->state, sizeof(item->state)) ||
!copy_column(statement, 2, item->title, sizeof(item->title)) || !copy_column(statement, 2, item->title, sizeof(item->title)) ||
!copy_column(statement, 3, item->planned_for, sizeof(item->planned_for)) || !copy_column(statement, 3, item->planned_for, sizeof(item->planned_for)) ||
!read_count(statement, 4, &item->occurrence_count)) { !read_count(statement, 4, &item->occurrence_count) ||
!copy_column(statement, 5, item->sort_timestamp, sizeof(item->sort_timestamp))) {
goto fail; goto fail;
} }
(void)snprintf(item->provenance, sizeof(item->provenance), "%s", "execution_store"); (void)snprintf(item->provenance, sizeof(item->provenance), "%s", "execution_store");
@ -165,7 +217,10 @@ TrainlogStatus trainlog_web_prepared_items_load(
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
return status; return status;
} }
status = load_manual_preparations(database, output);
if (status == TRAINLOG_STATUS_OK) {
status = load_ai_proposals(database, output); status = load_ai_proposals(database, output);
}
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
status = load_execution_drafts(database, output); status = load_execution_drafts(database, output);
} }

View file

@ -105,6 +105,8 @@ TrainlogStatus trainlog_web_prepared_items_serialize(
kind = "ai_proposal"; kind = "ai_proposal";
} else if (item->kind == TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT) { } else if (item->kind == TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT) {
kind = "execution_draft"; kind = "execution_draft";
} else if (item->kind == TRAINLOG_WEB_PREPARED_MANUAL_PREPARATION) {
kind = "manual_preparation";
} else { } else {
output[0] = '\0'; output[0] = '\0';
return TRAINLOG_STATUS_INVALID_ARGUMENT; return TRAINLOG_STATUS_INVALID_ARGUMENT;
@ -124,6 +126,8 @@ TrainlogStatus trainlog_web_prepared_items_serialize(
} }
append(&writer, ",\"state\":"); append(&writer, ",\"state\":");
string(&writer, item->state); string(&writer, item->state);
append(&writer, ",\"sort_timestamp\":");
string(&writer, item->sort_timestamp);
append(&writer, ",\"occurrence_count\":%zu,\"provenance\":", item->occurrence_count); append(&writer, ",\"occurrence_count\":%zu,\"provenance\":", item->occurrence_count);
string(&writer, item->provenance); string(&writer, item->provenance);
append(&writer, "}"); append(&writer, "}");

View file

@ -24,6 +24,7 @@
#include "trainlog/web_prepared_items.h" #include "trainlog/web_prepared_items.h"
#include "trainlog/web_sessions.h" #include "trainlog/web_sessions.h"
#include "trainlog/dashboard_layout.h" #include "trainlog/dashboard_layout.h"
#include "trainlog/web_preferences.h"
#include "web_assets.h" #include "web_assets.h"
#define TRAINLOG_HTTP_CONNECTION_LIMIT 32U #define TRAINLOG_HTTP_CONNECTION_LIMIT 32U
@ -45,6 +46,7 @@ typedef struct TrainlogWebContext {
pid_t worker_pid; pid_t worker_pid;
char csrf_token[65]; char csrf_token[65];
bool invalid_layout_reported; bool invalid_layout_reported;
bool invalid_preferences_reported;
} TrainlogWebContext; } TrainlogWebContext;
typedef struct TrainlogHttpRequestState { typedef struct TrainlogHttpRequestState {
@ -817,17 +819,19 @@ static enum MHD_Result handle_request(void *closure,
char expected_buffer[TRAINLOG_ID_MAX + 1U]; char expected_buffer[TRAINLOG_ID_MAX + 1U];
char identity_buffer[TRAINLOG_ID_MAX + 1U]; char identity_buffer[TRAINLOG_ID_MAX + 1U];
bool delivery = false; bool delivery = false;
bool withdrawal = false;
TrainlogStatus status; TrainlogStatus status;
if ((strcmp(url, "/api/v1/sessions/preparations") == 0 && if ((strcmp(url, "/api/v1/sessions/preparations") == 0 &&
strcmp(method, MHD_HTTP_METHOD_POST) != 0) || strcmp(method, MHD_HTTP_METHOD_POST) != 0) ||
(strcmp(url, "/api/v1/sessions/preparations") != 0 && (strcmp(url, "/api/v1/sessions/preparations") != 0 &&
strcmp(method, MHD_HTTP_METHOD_PUT) != 0 && strcmp(method, MHD_HTTP_METHOD_PUT) != 0 &&
strcmp(method, MHD_HTTP_METHOD_POST) != 0)) { strcmp(method, MHD_HTTP_METHOD_POST) != 0 &&
strcmp(method, MHD_HTTP_METHOD_DELETE) != 0)) {
return queue_json(connection, return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED, MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n", "{\"error\":\"method_not_allowed\"}\n",
"POST, PUT"); "POST, PUT, DELETE");
} }
if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) { if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) {
return queue_json( return queue_json(
@ -858,12 +862,13 @@ static enum MHD_Result handle_request(void *closure,
identity = identity_buffer; identity = identity_buffer;
delivery = true; delivery = true;
} }
withdrawal = !delivery && strcmp(method, MHD_HTTP_METHOD_DELETE) == 0;
if ((delivery && strcmp(method, MHD_HTTP_METHOD_POST) != 0) || if ((delivery && strcmp(method, MHD_HTTP_METHOD_POST) != 0) ||
(!delivery && strcmp(method, MHD_HTTP_METHOD_PUT) != 0)) { (!delivery && !withdrawal && strcmp(method, MHD_HTTP_METHOD_PUT) != 0)) {
return queue_json(connection, return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED, MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n", "{\"error\":\"method_not_allowed\"}\n",
delivery ? "POST" : "PUT"); delivery ? "POST" : "PUT, DELETE");
} }
if (if_match == NULL || if_match[0] != '"') { if (if_match == NULL || if_match[0] != '"') {
return queue_json(connection, return queue_json(connection,
@ -885,6 +890,9 @@ static enum MHD_Result handle_request(void *closure,
if (delivery) { if (delivery) {
status = trainlog_web_sessions_deliver_json( status = trainlog_web_sessions_deliver_json(
context->database, identity, expected, request_id, &json, &json_size); context->database, identity, expected, request_id, &json, &json_size);
} else if (withdrawal) {
status = trainlog_web_sessions_withdraw_json(
context->database, identity, expected, request_id, &json, &json_size);
} else { } else {
status = trainlog_web_sessions_save_json(context->database, status = trainlog_web_sessions_save_json(context->database,
identity, identity,
@ -962,6 +970,106 @@ static enum MHD_Result handle_request(void *closure,
} }
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL); return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
} }
if (strcmp(url, "/api/v1/web-preferences") == 0) {
TrainlogWebPreferences preferences;
TrainlogWebPreferences saved;
TrainlogWebPreferencesSource source;
TrainlogWebPreferencesResult preferences_result;
char json[TRAINLOG_WEB_PREFERENCES_JSON_CAPACITY];
size_t json_size;
uint64_t expected;
const char *origin;
const char *csrf;
const char *if_match;
if (is_get) {
preferences_result = trainlog_web_preferences_load(&preferences, &source);
if (preferences_result != TRAINLOG_WEB_PREFERENCES_OK ||
!trainlog_web_preferences_serialize(&preferences,
source,
true,
json,
sizeof(json),
&json_size)) {
return queue_json(connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"preferences_unavailable\"}\n",
NULL);
}
if (source == TRAINLOG_WEB_PREFERENCES_INVALID_PERSISTED &&
!context->invalid_preferences_reported) {
(void)fprintf(stderr,
"Trainlog Web : préférences persistées invalides, défaut utilisé\n");
context->invalid_preferences_reported = true;
}
return queue_layout_json(connection,
MHD_HTTP_OK,
json,
preferences.revision,
context->csrf_token);
}
if (strcmp(method, MHD_HTTP_METHOD_PUT) != 0) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"GET, PUT");
}
origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin");
csrf = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token");
if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
if_match =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
if (!parse_if_match(if_match, &expected)) {
return queue_json(connection,
MHD_HTTP_PRECONDITION_REQUIRED,
"{\"error\":\"precondition_required\"}\n",
NULL);
}
{
const char *content_type = MHD_lookup_connection_value(
connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE);
if (content_type == NULL || strcmp(content_type, "application/json") != 0) {
return queue_json(connection,
MHD_HTTP_UNSUPPORTED_MEDIA_TYPE,
"{\"error\":\"unsupported_media_type\"}\n",
NULL);
}
}
preferences_result =
trainlog_web_preferences_parse(state->body, state->body_size, &preferences);
if (preferences_result != TRAINLOG_WEB_PREFERENCES_OK ||
preferences.revision != expected) {
return queue_json(connection,
MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_preferences\"}\n",
NULL);
}
preferences_result =
trainlog_web_preferences_save(&preferences, expected, &saved);
if (preferences_result == TRAINLOG_WEB_PREFERENCES_CONFLICT) {
return queue_json(connection,
MHD_HTTP_PRECONDITION_FAILED,
"{\"error\":\"revision_conflict\"}\n",
NULL);
}
if (preferences_result != TRAINLOG_WEB_PREFERENCES_OK ||
!trainlog_web_preferences_serialize(&saved,
TRAINLOG_WEB_PREFERENCES_PERSISTED,
true,
json,
sizeof(json),
&json_size)) {
return queue_json(connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"preferences_save_failed\"}\n",
NULL);
}
return queue_layout_json(
connection, MHD_HTTP_OK, json, saved.revision, context->csrf_token);
}
if (strcmp(url, "/api/v1/dashboard-layout") == 0) { if (strcmp(url, "/api/v1/dashboard-layout") == 0) {
TrainlogDashboardLayout layout; TrainlogDashboardLayout layout;
TrainlogDashboardLayout saved; TrainlogDashboardLayout saved;
@ -1165,6 +1273,7 @@ int trainlog_web_server_run(TrainlogDatabase *database,
return -1; return -1;
} }
context.invalid_layout_reported = false; context.invalid_layout_reported = false;
context.invalid_preferences_reported = false;
if (!generate_csrf_token(context.csrf_token)) { if (!generate_csrf_token(context.csrf_token)) {
web_diagnostic(diagnostic, diagnostic_capacity, "impossible de générer la protection CSRF"); web_diagnostic(diagnostic, diagnostic_capacity, "impossible de générer la protection CSRF");
return -1; return -1;

View file

@ -23,7 +23,7 @@ static const ListDefinition LISTS[] = {
"FROM session_preparations p JOIN session_preparation_revisions r ON " "FROM session_preparations p JOIN session_preparation_revisions r ON "
"r.revision_id=p.current_revision_id " "r.revision_id=p.current_revision_id "
"LEFT JOIN session_preparation_entries e ON e.revision_id=r.revision_id " "LEFT JOIN session_preparation_entries e ON e.revision_id=r.revision_id "
"WHERE (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM " "WHERE p.withdrawn_at IS NULL AND (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM "
"session_preparation_entries pe JOIN exercises x ON x.exercise_id=pe.exercise_id WHERE " "session_preparation_entries pe JOIN exercises x ON x.exercise_id=pe.exercise_id WHERE "
"pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) " "pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
"GROUP BY p.preparation_id ORDER BY " "GROUP BY p.preparation_id ORDER BY "
@ -202,9 +202,14 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
"ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1" "ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1"
: "SELECT " : "SELECT "
"p.preparation_id,r.title,r.planned_for,r.session_type,r.notes," "p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,"
"COALESCE(p.source_payload_sha256,''),p.current_revision_id,p.delivery_" "COALESCE(p.source_payload_sha256,''),p.current_revision_id,"
"state FROM session_preparations p JOIN session_preparation_revisions " "CASE WHEN p.withdrawn_at IS NULL THEN p.delivery_state ELSE 'withdrawn' END,"
"r ON r.revision_id=p.current_revision_id WHERE p.preparation_id=?1"; "p.source_proposal_id,source.title,p.withdrawn_at,w.withdrawal_id,"
"w.acknowledged_at FROM session_preparations p JOIN "
"session_preparation_revisions r ON r.revision_id=p.current_revision_id "
"LEFT JOIN ai_session_drafts source ON source.draft_id=p.source_proposal_id "
"LEFT JOIN session_preparation_withdrawals w ON "
"w.preparation_id=p.preparation_id WHERE p.preparation_id=?1";
const char *entry_sql = const char *entry_sql =
proposal proposal
? "SELECT " ? "SELECT "
@ -247,6 +252,23 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
} }
if (!proposal) {
static const char *const provenance_keys[] = {"source_proposal_id",
"source_proposal_title",
"withdrawn_at",
"withdrawal_id",
"withdrawal_acknowledged_at"};
for (column = 0; column < 5; ++column) {
if (!add_nullable_text(document,
root,
provenance_keys[column],
statement,
column + 8)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
}
}
if (sqlite3_finalize(statement) != SQLITE_OK) { if (sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
@ -947,7 +969,8 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
const char *current = NULL; const char *current = NULL;
if (sqlite3_prepare_v2( if (sqlite3_prepare_v2(
database->connection, database->connection,
"SELECT current_revision_id FROM session_preparations WHERE preparation_id=?1", "SELECT current_revision_id FROM session_preparations WHERE preparation_id=?1 "
"AND withdrawn_at IS NULL",
-1, -1,
&statement, &statement,
NULL) != SQLITE_OK || NULL) != SQLITE_OK ||
@ -966,7 +989,10 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
statement = NULL; statement = NULL;
} else if (sqlite3_prepare_v2( } else if (sqlite3_prepare_v2(
database->connection, database->connection,
"INSERT INTO session_preparations VALUES(?1,?2,?3,?3,?4,'local',?5,?6)", "INSERT INTO session_preparations("
"preparation_id,current_revision_id,created_at,updated_at,editing_state,"
"delivery_state,source_proposal_id,source_payload_sha256,withdrawn_at) "
"VALUES(?1,?2,?3,?3,?4,'local',?5,?6,NULL)",
-1, -1,
&statement, &statement,
NULL) != SQLITE_OK || NULL) != SQLITE_OK ||
@ -1026,7 +1052,8 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
"UPDATE session_preparations SET " "UPDATE session_preparations SET "
"current_revision_id=?1,updated_at=?2,editing_state=?3,delivery_" "current_revision_id=?1,updated_at=?2,editing_state=?3,delivery_"
"state=CASE WHEN delivery_state='local' THEN 'local' ELSE 'pending' " "state=CASE WHEN delivery_state='local' THEN 'local' ELSE 'pending' "
"END WHERE preparation_id=?4 AND current_revision_id=?5", "END WHERE preparation_id=?4 AND current_revision_id=?5 AND "
"withdrawn_at IS NULL",
-1, -1,
&statement, &statement,
NULL) != SQLITE_OK || NULL) != SQLITE_OK ||
@ -1135,7 +1162,8 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
if (sqlite3_prepare_v2( if (sqlite3_prepare_v2(
database->connection, database->connection,
"SELECT 1 FROM session_preparations p WHERE p.preparation_id=?1 AND " "SELECT 1 FROM session_preparations p WHERE p.preparation_id=?1 AND "
"p.current_revision_id=?2 AND p.editing_state='ready' AND EXISTS(SELECT 1 FROM " "p.current_revision_id=?2 AND p.withdrawn_at IS NULL AND p.editing_state='ready' "
"AND EXISTS(SELECT 1 FROM "
"session_preparation_entries e WHERE e.revision_id=p.current_revision_id)", "session_preparation_entries e WHERE e.revision_id=p.current_revision_id)",
-1, -1,
&statement, &statement,
@ -1221,3 +1249,163 @@ delivery_rollback:
*output_size = 0U; *output_size = 0U;
return status; return status;
} }
TrainlogStatus trainlog_web_sessions_withdraw_json(TrainlogDatabase *database,
const char *preparation_id,
const char *expected_revision,
const char *request_id,
char **output_json,
size_t *output_size) {
sqlite3_stmt *statement = NULL;
char withdrawal_id[TRAINLOG_GENERATED_ID_CAPACITY];
char now[TRAINLOG_TIMESTAMP_MAX + 1U];
yyjson_mut_doc *response = NULL;
yyjson_mut_val *root;
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
bool has_deliveries = false;
if (database == NULL || preparation_id == NULL || expected_revision == NULL ||
request_id == NULL || request_id[0] == '\0' || output_json == NULL || output_size == NULL ||
trainlog_id_generate("spw", withdrawal_id, sizeof(withdrawal_id)) != TRAINLOG_STATUS_OK ||
!timestamp_now(now)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
/* INVARIANT: a lost HTTP response may be replayed without creating a
* second withdrawal or reconsidering a now-inactive preparation. */
if (sqlite3_prepare_v2(database->connection,
"SELECT command,preparation_id,revision_id,response_json FROM "
"session_preparation_requests WHERE request_id=?1",
-1,
&statement,
NULL) == SQLITE_OK &&
sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) {
const char *command = (const char *)sqlite3_column_text(statement, 0);
const char *saved_preparation = (const char *)sqlite3_column_text(statement, 1);
const char *saved_revision = (const char *)sqlite3_column_text(statement, 2);
const char *saved = (const char *)sqlite3_column_text(statement, 3);
if (command == NULL || saved_preparation == NULL || saved_revision == NULL ||
strcmp(command, "withdraw") != 0 || strcmp(saved_preparation, preparation_id) != 0 ||
strcmp(saved_revision, expected_revision) != 0) {
status = TRAINLOG_STATUS_CONFLICT;
goto withdraw_rollback;
}
*output_size = saved == NULL ? 0U : strlen(saved);
*output_json = saved == NULL ? NULL : strdup(saved);
(void)sqlite3_finalize(statement);
(void)sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL);
return *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
}
if (statement != NULL) {
(void)sqlite3_finalize(statement);
statement = NULL;
}
if (sqlite3_prepare_v2(database->connection,
"SELECT EXISTS(SELECT 1 FROM session_preparation_deliveries d WHERE "
"d.preparation_id=p.preparation_id) FROM session_preparations p WHERE "
"p.preparation_id=?1 AND p.current_revision_id=?2 AND "
"p.withdrawn_at IS NULL",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
goto withdraw_rollback;
}
if (sqlite3_step(statement) != SQLITE_ROW) {
status = TRAINLOG_STATUS_CONFLICT;
goto withdraw_rollback;
}
has_deliveries = sqlite3_column_int(statement, 0) != 0;
if (sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL;
goto withdraw_rollback;
}
statement = NULL;
/* CONTRACT: this transaction changes only planning availability and the
* exportable withdrawal ledger. Delivery/execution evidence is retained
* verbatim so a remotely started workout can never be erased here. */
if (sqlite3_prepare_v2(database->connection,
"INSERT INTO session_preparation_withdrawals VALUES("
"?1,?2,?3,?4,NULL,NULL)",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, withdrawal_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL;
goto withdraw_rollback;
}
statement = NULL;
if (sqlite3_prepare_v2(database->connection,
"UPDATE session_preparations SET withdrawn_at=?1,updated_at=?1 WHERE "
"preparation_id=?2 AND current_revision_id=?3 AND withdrawn_at IS NULL",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1 ||
sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL;
status = TRAINLOG_STATUS_CONFLICT;
goto withdraw_rollback;
}
statement = NULL;
response = yyjson_mut_doc_new(NULL);
root = response == NULL ? NULL : yyjson_mut_obj(response);
if (response == NULL || root == NULL) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto withdraw_rollback;
}
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, "preparation_id", preparation_id);
(void)yyjson_mut_obj_add_strcpy(response, root, "revision_id", expected_revision);
(void)yyjson_mut_obj_add_strcpy(response, root, "withdrawal_id", withdrawal_id);
(void)yyjson_mut_obj_add_strcpy(response,
root,
"android_cancellation",
has_deliveries ? "pending" : "not_required");
status = write_document(response, output_json, output_size);
response = NULL;
if (status != TRAINLOG_STATUS_OK ||
sqlite3_prepare_v2(database->connection,
"INSERT INTO session_preparation_requests VALUES("
"?1,'withdraw',?2,?3,?4,?5)",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, *output_json, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK ||
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) {
statement = NULL;
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto withdraw_rollback;
}
return TRAINLOG_STATUS_OK;
withdraw_rollback:
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
yyjson_mut_doc_free(response);
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
free(*output_json);
*output_json = NULL;
*output_size = 0U;
return status;
}

View file

@ -27,13 +27,14 @@ int main(void)
TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK); TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK);
trainlog_database_close(database);database=NULL; trainlog_database_close(database);database=NULL;
CHECK(sqlite3_open(path,&raw)==SQLITE_OK); CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE session_preparation_deliveries;DROP TABLE session_preparation_requests;DROP TABLE session_preparation_entries;DROP TABLE session_preparation_revisions;DROP TABLE session_preparations;DROP TABLE sync_generation_archives;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK); CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE session_preparation_withdrawals;DROP TABLE session_preparation_deliveries;DROP TABLE session_preparation_requests;DROP TABLE session_preparation_entries;DROP TABLE session_preparation_revisions;DROP TABLE session_preparations;DROP TABLE sync_generation_archives;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK);
CHECK(sqlite3_close(raw)==SQLITE_OK); CHECK(sqlite3_close(raw)==SQLITE_OK);
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK); CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U); CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
trainlog_database_close(database);database=NULL; trainlog_database_close(database);database=NULL;
CHECK(sqlite3_open(path,&raw)==SQLITE_OK); CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
CHECK(scalar(raw,"PRAGMA user_version")==23); CHECK(scalar(raw,"PRAGMA user_version")==TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(scalar(raw,"SELECT COUNT(*) FROM session_preparation_withdrawals")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0); CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0); CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0);
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0); CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0);

View file

@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strncmp(diagnostic, "migrate database to schema v23: SQLite rc=", CHECK(strncmp(diagnostic, "migrate database to schema v24: SQLite rc=",
strlen("migrate database to schema v23: SQLite rc=")) == 0); strlen("migrate database to schema v24: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL);
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
CHECK(fd >= 0); CHECK(fd >= 0);
CHECK(close(fd) == 0); CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=24;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_exec(raw, "PRAGMA user_version=25;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 24 is newer") != NULL); CHECK(strstr(diagnostic, "schema version 25 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0); CHECK(unlink(path) == 0);
return true; return true;

View file

@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v23") != NULL); CHECK(strstr(diagnostic, "migrate database to schema v24") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);

View file

@ -18,6 +18,7 @@
#include "trainlog/database.h" #include "trainlog/database.h"
#include "trainlog/dashboard_layout.h" #include "trainlog/dashboard_layout.h"
#include "trainlog/web_server.h" #include "trainlog/web_server.h"
#include "trainlog/web_sessions.h"
#include "web_assets.h" #include "web_assets.h"
#define CHECK(value) \ #define CHECK(value) \
@ -149,6 +150,25 @@ static bool response_header(const char *response, const char *name, char *output
return true; return true;
} }
static bool json_string(const char *json, const char *key, char *output, size_t capacity) {
char marker[96];
const char *start;
const char *end;
size_t length;
CHECK(snprintf(marker, sizeof(marker), "\"%s\":\"", key) > 0);
start = strstr(json, marker);
CHECK(start != NULL);
start += strlen(marker);
end = strchr(start, '"');
CHECK(end != NULL);
length = (size_t)(end - start);
CHECK(length > 0U && length < capacity);
(void)memcpy(output, start, length);
output[length] = '\0';
return true;
}
static bool test_sync_accepted_serialization(void) { static bool test_sync_accepted_serialization(void) {
static const char REQUEST_ID[] = "sy_11111111-1111-4111-8111-111111111111"; static const char REQUEST_ID[] = "sy_11111111-1111-4111-8111-111111111111";
static const char RUN_ID[] = "sy_22222222-2222-4222-8222-222222222222"; static const char RUN_ID[] = "sy_22222222-2222-4222-8222-222222222222";
@ -175,6 +195,13 @@ static bool test_sync_accepted_serialization(void) {
} }
static bool test_http_contract(TrainlogDatabase *database) { static bool test_http_contract(TrainlogDatabase *database) {
static const char READY_BODY[] =
"{\"title\":\"Préparation HTTP\",\"session_type\":\"training\","
"\"planned_for\":\"2026-09-20\",\"notes\":null,\"editing_state\":\"ready\","
"\"occurrences\":[{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
"\"equipment_id\":null,\"load_mode\":\"none\",\"rest_seconds\":60,"
"\"target_sets\":2,\"target_reps\":8,\"target_duration_seconds\":null,"
"\"target_weight_kg\":null,\"notes\":null}]}";
char response[32768]; char response[32768];
char large_request[12000]; char large_request[12000];
char request[8192]; char request[8192];
@ -184,6 +211,30 @@ static bool test_http_contract(TrainlogDatabase *database) {
pid_t child; pid_t child;
int status; int status;
struct sigaction action; struct sigaction action;
char *created = NULL;
size_t created_size = 0U;
char preparation_id[128];
char revision_id[128];
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"Exercice HTTP",
"exercice http",
TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS,
0U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_web_sessions_save_json(database,
NULL,
"",
"request-http-create",
READY_BODY,
strlen(READY_BODY),
&created,
&created_size) == TRAINLOG_STATUS_OK);
CHECK(created_size > 0U);
CHECK(json_string(created, "preparation_id", preparation_id, sizeof(preparation_id)));
CHECK(json_string(created, "revision_id", revision_id, sizeof(revision_id)));
free(created);
(void)unlink("/tmp/trainlog-web-test.db.sync-run.json"); (void)unlink("/tmp/trainlog-web-test.db.sync-run.json");
CHECK(reserve_port(&port, 0) == -2); CHECK(reserve_port(&port, 0) == -2);
child = fork(); child = fork();
@ -243,6 +294,42 @@ static bool test_http_contract(TrainlogDatabase *database) {
response, response,
sizeof(response)) && sizeof(response)) &&
strstr(response, "HTTP/1.1 405") != NULL); strstr(response, "HTTP/1.1 405") != NULL);
(void)snprintf(
request,
sizeof(request),
"DELETE /api/v1/sessions/preparation/%s HTTP/1.1\r\n"
"Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n"
"Content-Type: application/json\r\nIf-Match: \"%s\"\r\n"
"X-Trainlog-CSRF-Token: wrong\r\nX-Trainlog-Request-ID: request-http-delete\r\n"
"Content-Length: 2\r\n\r\n{}",
preparation_id,
(unsigned int)port,
revision_id);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 403") != NULL);
(void)snprintf(
request,
sizeof(request),
"DELETE /api/v1/sessions/preparation/%s HTTP/1.1\r\n"
"Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n"
"Content-Type: application/json\r\nIf-Match: \"%s\"\r\n"
"X-Trainlog-CSRF-Token: %s\r\nX-Trainlog-Request-ID: request-http-delete\r\n"
"Content-Length: 2\r\n\r\n{}",
preparation_id,
(unsigned int)port,
revision_id,
token);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"android_cancellation\":\"not_required\"") != NULL);
(void)snprintf(request,
sizeof(request),
"GET /api/v1/sessions/preparation/%s HTTP/1.1\r\n"
"Host: 127.0.0.1\r\n\r\n",
preparation_id);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"state\":\"withdrawn\"") != NULL);
} }
CHECK(exchange(port, CHECK(exchange(port,
"GET /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", "GET /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
@ -262,6 +349,64 @@ static bool test_http_contract(TrainlogDatabase *database) {
strstr(response, "HTTP/1.1 200") != NULL); strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "\"api_version\":1") != NULL); CHECK(strstr(response, "\"api_version\":1") != NULL);
CHECK(strstr(response, "\"items\":[]") != NULL); CHECK(strstr(response, "\"items\":[]") != NULL);
{
char token[80];
char etag[32];
CHECK(exchange(port,
"GET /api/v1/web-preferences HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"date_format\":\"fr\"") != NULL &&
strstr(response, "\"source\":\"default\"") != NULL);
CHECK(response_header(response, "ETag: ", etag, sizeof(etag)) &&
strcmp(etag, "\"0\"") == 0);
CHECK(response_header(response, "X-Trainlog-CSRF-Token: ", token, sizeof(token)));
(void)snprintf(
request,
sizeof(request),
"PUT /api/v1/web-preferences HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\nContent-Length: 82\r\n\r\n"
"{\"format\":\"trainlog-web-preferences\",\"version\":1,\"revision\":0,"
"\"date_format\":\"iso\"}",
(unsigned int)port,
token);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"revision\":1") != NULL &&
strstr(response, "\"date_format\":\"iso\"") != NULL &&
strstr(response, "\"source\":\"persisted\"") != NULL);
CHECK(exchange(port,
"GET /api/v1/web-preferences HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"revision\":1") != NULL &&
strstr(response, "\"date_format\":\"iso\"") != NULL);
{
char path[4096];
FILE *stream;
const char *config = getenv("XDG_CONFIG_HOME");
CHECK(config != NULL);
CHECK(snprintf(path,
sizeof(path),
"%s/trainlog/web/preferences-v1.json",
config) > 0);
stream = fopen(path, "w");
CHECK(stream != NULL);
CHECK(fputs("invalid preference\n", stream) >= 0);
CHECK(fclose(stream) == 0);
}
CHECK(exchange(port,
"GET /api/v1/web-preferences HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,
sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"date_format\":\"fr\"") != NULL &&
strstr(response, "\"source\":\"invalid_persisted\"") != NULL);
}
CHECK(exchange(port, CHECK(exchange(port,
"POST /api/v1/prepared-items HTTP/1.1\r\nHost: 127.0.0.1\r\n" "POST /api/v1/prepared-items HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Content-Length: 0\r\n\r\n", "Content-Length: 0\r\n\r\n",

View file

@ -29,6 +29,33 @@ static int scalar(TrainlogDatabase *database, const char *sql) {
return result; return result;
} }
static bool json_string(const char *json, const char *key, char *output, size_t capacity) {
char marker[96];
const char *start;
const char *end;
size_t length;
if (snprintf(marker, sizeof(marker), "\"%s\":\"", key) < 0) {
return false;
}
start = strstr(json, marker);
if (start == NULL) {
return false;
}
start += strlen(marker);
end = strchr(start, '"');
if (end == NULL) {
return false;
}
length = (size_t)(end - start);
if (length == 0U || length >= capacity) {
return false;
}
(void)memcpy(output, start, length);
output[length] = '\0';
return true;
}
static bool creation_replay_revision_and_pagination(void) { static bool creation_replay_revision_and_pagination(void) {
static const char CREATE_BODY[] = static const char CREATE_BODY[] =
"{\"title\":\"Séance échappée \\\"A\\\"\",\"session_type\":\"training\"," "{\"title\":\"Séance échappée \\\"A\\\"\",\"session_type\":\"training\","
@ -178,6 +205,186 @@ static bool creation_replay_revision_and_pagination(void) {
return true; return true;
} }
int main(void) { static bool withdrawal_is_durable_and_preserves_evidence(void) {
return creation_replay_revision_and_pagination() ? 0 : 1; static const char READY_BODY[] =
"{\"title\":\"Préparation à retirer\",\"session_type\":\"training\","
"\"planned_for\":\"2026-09-20\",\"notes\":null,\"editing_state\":\"ready\","
"\"occurrences\":[{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
"\"equipment_id\":null,\"load_mode\":\"none\",\"rest_seconds\":60,"
"\"target_sets\":2,\"target_reps\":8,\"target_duration_seconds\":null,"
"\"target_weight_kg\":null,\"notes\":null}],\"source_proposal_id\":"
"\"aid_11111111-1111-4111-8111-111111111111\",\"source_payload_sha256\":"
"\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}";
static const char EDIT_BODY[] =
"{\"title\":\"Modification interdite\",\"session_type\":\"training\","
"\"planned_for\":null,\"notes\":null,\"editing_state\":\"draft\","
"\"occurrences\":[]}";
static const char LOCAL_READY_BODY[] =
"{\"title\":\"Préparation locale\",\"session_type\":\"training\","
"\"planned_for\":null,\"notes\":null,\"editing_state\":\"ready\","
"\"occurrences\":[{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
"\"equipment_id\":null,\"load_mode\":\"none\",\"rest_seconds\":0,"
"\"target_sets\":1,\"target_reps\":1,\"target_duration_seconds\":null,"
"\"target_weight_kg\":null,\"notes\":null}]}";
TrainlogDatabase *database = NULL;
TrainlogWebSessionsPageQuery query = {
TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 64U, ""};
char *created = NULL;
char *delivery = NULL;
char *withdrawal = NULL;
char *replayed = NULL;
char *detail = NULL;
char *page = NULL;
size_t created_size = 0U;
size_t delivery_size = 0U;
size_t withdrawal_size = 0U;
size_t replayed_size = 0U;
size_t detail_size = 0U;
size_t page_size = 0U;
char preparation_id[128];
char revision_id[128];
char local_preparation_id[128];
char local_revision_id[128];
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"Exercice retrait",
"exercice retrait",
TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS,
0U) == TRAINLOG_STATUS_OK);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO ai_session_drafts(draft_id,created_at,session_type,title) "
"VALUES('aid_11111111-1111-4111-8111-111111111111',"
"'2026-09-18T00:00:00Z','training','Proposition conservée');"
"INSERT INTO ai_session_draft_imports VALUES(last_insert_rowid(),"
"'aid_11111111-1111-4111-8111-111111111111',"
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
"'2026-09-18T00:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(trainlog_web_sessions_save_json(database,
NULL,
"",
"request-withdraw-create",
READY_BODY,
strlen(READY_BODY),
&created,
&created_size) == TRAINLOG_STATUS_OK);
CHECK(json_string(created, "preparation_id", preparation_id, sizeof(preparation_id)));
CHECK(json_string(created, "revision_id", revision_id, sizeof(revision_id)));
CHECK(trainlog_web_sessions_withdraw_json(database,
preparation_id,
"spr_ffffffff-ffff-4fff-8fff-ffffffffffff",
"request-withdraw-stale",
&withdrawal,
&withdrawal_size) == TRAINLOG_STATUS_CONFLICT);
CHECK(withdrawal == NULL && withdrawal_size == 0U);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_withdrawals") == 0);
CHECK(trainlog_web_sessions_deliver_json(database,
preparation_id,
revision_id,
"request-withdraw-deliver",
&delivery,
&delivery_size) == TRAINLOG_STATUS_OK);
CHECK(trainlog_web_sessions_withdraw_json(database,
preparation_id,
revision_id,
"request-withdraw-exact",
&withdrawal,
&withdrawal_size) == TRAINLOG_STATUS_OK);
CHECK(strstr(withdrawal, "\"android_cancellation\":\"pending\"") != NULL);
CHECK(trainlog_web_sessions_withdraw_json(database,
preparation_id,
revision_id,
"request-withdraw-exact",
&replayed,
&replayed_size) == TRAINLOG_STATUS_OK);
CHECK(withdrawal_size == replayed_size && strcmp(withdrawal, replayed) == 0);
free(replayed);
replayed = NULL;
replayed_size = 0U;
CHECK(trainlog_web_sessions_withdraw_json(database,
"sp_ffffffff-ffff-4fff-8fff-ffffffffffff",
revision_id,
"request-withdraw-exact",
&replayed,
&replayed_size) == TRAINLOG_STATUS_CONFLICT);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_withdrawals") == 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM ai_session_drafts") == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='pending'") ==
1);
CHECK(trainlog_web_sessions_list_json(database, &query, &page, &page_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(page, preparation_id) == NULL);
CHECK(trainlog_web_sessions_detail_json(
database, "preparation", preparation_id, &detail, &detail_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(detail, "\"state\":\"withdrawn\"") != NULL);
CHECK(strstr(detail, "\"source_proposal_title\":\"Proposition conservée\"") != NULL);
CHECK(strstr(detail, "\"withdrawal_id\":\"spw_") != NULL);
CHECK(trainlog_web_sessions_save_json(database,
preparation_id,
revision_id,
"request-after-withdraw-save",
EDIT_BODY,
strlen(EDIT_BODY),
&replayed,
&replayed_size) == TRAINLOG_STATUS_NOT_FOUND);
CHECK(trainlog_web_sessions_deliver_json(database,
preparation_id,
revision_id,
"request-after-withdraw-deliver",
&replayed,
&replayed_size) == TRAINLOG_STATUS_CONFLICT);
free(replayed);
replayed = NULL;
replayed_size = 0U;
CHECK(trainlog_web_sessions_save_json(database,
NULL,
"",
"request-local-create",
LOCAL_READY_BODY,
strlen(LOCAL_READY_BODY),
&replayed,
&replayed_size) == TRAINLOG_STATUS_OK);
CHECK(json_string(replayed,
"preparation_id",
local_preparation_id,
sizeof(local_preparation_id)));
CHECK(json_string(replayed, "revision_id", local_revision_id, sizeof(local_revision_id)));
free(replayed);
replayed = NULL;
replayed_size = 0U;
CHECK(trainlog_web_sessions_withdraw_json(database,
local_preparation_id,
local_revision_id,
"request-local-withdraw",
&replayed,
&replayed_size) == TRAINLOG_STATUS_OK);
CHECK(strstr(replayed, "\"android_cancellation\":\"not_required\"") != NULL);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_withdrawals") == 2);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM ai_session_drafts") == 1);
free(created);
free(delivery);
free(withdrawal);
free(replayed);
free(detail);
free(page);
trainlog_database_close(database);
return true;
}
int main(void) {
return creation_replay_revision_and_pagination() &&
withdrawal_is_durable_and_preserves_evidence()
? 0
: 1;
} }

View file

@ -8,8 +8,8 @@ describe('projection des préparations', () => {
generated_at: '2026-09-17T12:00:00Z', generated_at: '2026-09-17T12:00:00Z',
partial: false, partial: false,
items: [ items: [
{ identity: 'aid_one', kind: 'ai_proposal', title: 'Plan', planned_for: '2026-09-18', state: 'published', occurrence_count: 6, provenance: 'ai_import' }, { identity: 'aid_one', kind: 'ai_proposal', title: 'Plan', planned_for: '2026-09-18', state: 'published', sort_timestamp: '2026-09-17T12:00:00Z', occurrence_count: 6, provenance: 'ai_import' },
{ identity: 'se_one', kind: 'execution_draft', title: 'training', planned_for: null, state: 'active', occurrence_count: 2, provenance: 'execution_store' }, { identity: 'se_one', kind: 'execution_draft', title: 'training', planned_for: null, state: 'active', sort_timestamp: '2026-09-17T13:00:00Z', occurrence_count: 2, provenance: 'execution_store' },
], ],
}) })
expect(value.items.map((item) => item.kind)).toEqual(['ai_proposal', 'execution_draft']) expect(value.items.map((item) => item.kind)).toEqual(['ai_proposal', 'execution_draft'])

View file

@ -1,4 +1,4 @@
export type PreparedItemKind = 'ai_proposal' | 'execution_draft' export type PreparedItemKind = 'ai_proposal' | 'execution_draft' | 'manual_preparation'
export interface PreparedItem { export interface PreparedItem {
identity: string identity: string
@ -6,6 +6,7 @@ export interface PreparedItem {
title: string title: string
planned_for: string | null planned_for: string | null
state: string state: string
sort_timestamp: string
occurrence_count: number occurrence_count: number
provenance: string provenance: string
} }
@ -24,11 +25,12 @@ function record(value: unknown): value is Record<string, unknown> {
function validItem(value: unknown): value is PreparedItem { function validItem(value: unknown): value is PreparedItem {
if (!record(value)) return false if (!record(value)) return false
const kind = value.kind const kind = value.kind
return (kind === 'ai_proposal' || kind === 'execution_draft') && return (kind === 'ai_proposal' || kind === 'execution_draft' || kind === 'manual_preparation') &&
typeof value.identity === 'string' && typeof value.identity === 'string' &&
typeof value.title === 'string' && typeof value.title === 'string' &&
(value.planned_for === null || typeof value.planned_for === 'string') && (value.planned_for === null || typeof value.planned_for === 'string') &&
typeof value.state === 'string' && typeof value.state === 'string' &&
typeof value.sort_timestamp === 'string' &&
Number.isSafeInteger(value.occurrence_count) && Number.isSafeInteger(value.occurrence_count) &&
Number(value.occurrence_count) >= 0 && Number(value.occurrence_count) >= 0 &&
typeof value.provenance === 'string' typeof value.provenance === 'string'

View file

@ -46,6 +46,11 @@ export interface SessionDetail {
session_type: 'training' | 'max_test' session_type: 'training' | 'max_test'
notes: string | null notes: string | null
source_fingerprint: string | null source_fingerprint: string | null
source_proposal_id?: string | null
source_proposal_title?: string | null
withdrawn_at?: string | null
withdrawal_id?: string | null
withdrawal_acknowledged_at?: string | null
state: string state: string
occurrences: PreparationOccurrence[] occurrences: PreparationOccurrence[]
} }
@ -221,3 +226,41 @@ export async function prepareForAndroid(identity: string, revision: string): Pro
throw new Error(reason) throw new Error(reason)
} }
} }
export interface PreparationWithdrawalResult {
preparation_id: string
revision_id: string
withdrawal_id: string
android_cancellation: 'pending' | 'not_required'
}
export async function withdrawPreparation(
identity: string,
revision: string,
): Promise<PreparationWithdrawalResult> {
const csrf = await mutationCsrfToken()
const response = await fetch(
`/api/v1/sessions/preparation/${encodeURIComponent(identity)}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf,
'X-Trainlog-Request-ID': requestId(),
'If-Match': `"${revision}"`,
},
body: '{}',
})
const value: unknown = await response.json()
if (!response.ok) {
const reason = object(value) && typeof value.error === 'string'
? value.error : `HTTP ${response.status}`
throw new Error(reason)
}
if (!object(value) || value.preparation_id !== identity || value.revision_id !== revision ||
typeof value.withdrawal_id !== 'string' ||
!['pending', 'not_required'].includes(String(value.android_cancellation))) {
throw new TypeError('réponse de suppression invalide')
}
return value as unknown as PreparationWithdrawalResult
}

View file

@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest'
import { fetchWebPreferences, saveWebPreferences } from './webPreferences'
const token = 'a'.repeat(64)
function response(value: unknown, revision: number, status = 200) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
headers: new Headers({ ETag: `"${revision}"`, 'X-Trainlog-CSRF-Token': token }),
json: () => Promise.resolve(value),
})
}
describe('Web presentation preferences API', () => {
it('loads the French default and persists ISO with revision and CSRF guards', async () => {
const initial = {
format: 'trainlog-web-preferences', version: 1, revision: 0,
date_format: 'fr', source: 'default',
}
const persisted = { ...initial, revision: 1, date_format: 'iso', source: 'persisted' }
const fetchMock = vi.fn()
.mockImplementationOnce(() => response(initial, 0))
.mockImplementationOnce(() => response(persisted, 1))
vi.stubGlobal('fetch', fetchMock)
const snapshot = await fetchWebPreferences()
expect(snapshot.date_format).toBe('fr')
const saved = await saveWebPreferences(snapshot, 'iso')
expect(saved).toMatchObject({ revision: 1, date_format: 'iso', source: 'persisted' })
const request = fetchMock.mock.calls[1][1] as RequestInit
expect(request.method).toBe('PUT')
expect(request.headers).toMatchObject({
'If-Match': '"0"',
'X-Trainlog-CSRF-Token': token,
})
expect(JSON.parse(String(request.body))).toMatchObject({ revision: 0, date_format: 'iso' })
})
it('reports revision conflicts explicitly', async () => {
const snapshot = {
format: 'trainlog-web-preferences' as const, version: 1 as const, revision: 4,
date_format: 'fr' as const, source: 'persisted' as const, etag: '"4"', csrfToken: token,
}
vi.stubGlobal('fetch', vi.fn(() => response({ error: 'revision_conflict' }, 4, 412)))
await expect(saveWebPreferences(snapshot, 'iso')).rejects.toThrow('revision_conflict')
})
})

View file

@ -0,0 +1,60 @@
export type WebDateFormat = 'fr' | 'iso'
export interface WebPreferencesSnapshot {
format: 'trainlog-web-preferences'
version: 1
revision: number
date_format: WebDateFormat
source: 'default' | 'persisted' | 'invalid_persisted'
etag: string
csrfToken: string
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
async function parseResponse(response: Response): Promise<WebPreferencesSnapshot> {
if (!response.ok) throw new Error(`web-preferences HTTP ${response.status}`)
const value: unknown = await response.json()
const etag = response.headers.get('ETag') ?? ''
const csrfToken = response.headers.get('X-Trainlog-CSRF-Token') ?? ''
if (!record(value) || value.format !== 'trainlog-web-preferences' || value.version !== 1 ||
!Number.isSafeInteger(value.revision) || Number(value.revision) < 0 ||
!['fr', 'iso'].includes(String(value.date_format)) ||
!['default', 'persisted', 'invalid_persisted'].includes(String(value.source)) ||
!/^"\d+"$/.test(etag) || !/^[0-9a-f]{64}$/.test(csrfToken)) {
throw new TypeError('préférences Web invalides')
}
return { ...value, etag, csrfToken } as WebPreferencesSnapshot
}
export async function fetchWebPreferences(signal?: AbortSignal): Promise<WebPreferencesSnapshot> {
return parseResponse(await fetch('/api/v1/web-preferences', {
headers: { Accept: 'application/json' },
signal,
}))
}
export async function saveWebPreferences(
snapshot: WebPreferencesSnapshot,
dateFormat: WebDateFormat,
): Promise<WebPreferencesSnapshot> {
const response = await fetch('/api/v1/web-preferences', {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'If-Match': snapshot.etag,
'X-Trainlog-CSRF-Token': snapshot.csrfToken,
},
body: JSON.stringify({
format: 'trainlog-web-preferences',
version: 1,
revision: snapshot.revision,
date_format: dateFormat,
}),
})
if (response.status === 412) throw new Error('revision_conflict')
return parseResponse(response)
}

View file

@ -118,7 +118,7 @@ describe('shell Trainlog', () => {
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, actual, health)))) vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, actual, health))))
render(<App />) render(<App />)
await waitFor(() => expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('fy59')) await waitFor(() => expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('fy59'))
expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('2026-09-15T10:00:00+02:00') expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('15/09/2026')
expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('DOS · BRAS') expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('DOS · BRAS')
}) })

View file

@ -9,6 +9,7 @@ import { PlaceholderPage } from '../routes/PlaceholderPage'
import { SessionsPage } from '../routes/SessionsPage' import { SessionsPage } from '../routes/SessionsPage'
import type { RouteId } from './routes' import type { RouteId } from './routes'
import { useRoute } from './useRoute' import { useRoute } from './useRoute'
import { DatePreferencesProvider } from '../presentation/DatePreferences'
const placeholderContent: Record<Exclude<RouteId, 'dashboard'>, [string, string, string]> = { const placeholderContent: Record<Exclude<RouteId, 'dashboard'>, [string, string, string]> = {
analysis: ['Analyse', 'COMPRENDRE', 'Les analyses détaillées apparaîtront ici lorsque leurs read models Core seront disponibles.'], analysis: ['Analyse', 'COMPRENDRE', 'Les analyses détaillées apparaîtront ici lorsque leurs read models Core seront disponibles.'],
@ -17,7 +18,7 @@ const placeholderContent: Record<Exclude<RouteId, 'dashboard'>, [string, string,
exercises: ['Exercices', 'EXPLORER', 'Le catalogue canonique des exercices sera consultable depuis cette page.'], exercises: ['Exercices', 'EXPLORER', 'Le catalogue canonique des exercices sera consultable depuis cette page.'],
} }
export function App() { function AppContent() {
const [route, navigate] = useRoute() const [route, navigate] = useRoute()
const [health, setHealth] = useState<Health | null>(null) const [health, setHealth] = useState<Health | null>(null)
const [healthPending, setHealthPending] = useState(true) const [healthPending, setHealthPending] = useState(true)
@ -90,3 +91,7 @@ export function App() {
</div> </div>
) )
} }
export function App() {
return <DatePreferencesProvider><AppContent /></DatePreferencesProvider>
}

View file

@ -6,6 +6,8 @@ import { SVGRenderer } from 'echarts/renderers'
import type { ProgressionIdentity, ProgressionPoint } from '../api/dashboard' import type { ProgressionIdentity, ProgressionPoint } from '../api/dashboard'
import { formatDateTime, formatDose, formatWeight } from '../dashboard/dashboardFormat' import { formatDateTime, formatDose, formatWeight } from '../dashboard/dashboardFormat'
import { trainlogChartTheme } from './trainlogChartTheme' import { trainlogChartTheme } from './trainlogChartTheme'
import { useDatePreferences } from '../presentation/DatePreferences'
import type { WebDateFormat } from '../api/webPreferences'
echarts.use([LineChart, GridComponent, TooltipComponent, SVGRenderer]) echarts.use([LineChart, GridComponent, TooltipComponent, SVGRenderer])
@ -50,9 +52,13 @@ export function progressionCollisionMetadata(points: ProgressionPoint[]): Array<
}) })
} }
export function progressionTooltip(identity: ProgressionIdentity, point: ProgressionPoint): string { export function progressionTooltip(
identity: ProgressionIdentity,
point: ProgressionPoint,
dateFormat: WebDateFormat = 'fr',
): string {
return [ return [
formatDateTime(point.timestamp), formatDateTime(point.timestamp, dateFormat),
formatWeight(point.weight_kg), formatWeight(point.weight_kg),
identity.exercise_name, identity.exercise_name,
identity.equipment_label, identity.equipment_label,
@ -62,6 +68,7 @@ export function progressionTooltip(identity: ProgressionIdentity, point: Progres
} }
export function ProgressionChart({ identity, points }: ProgressionChartProps) { export function ProgressionChart({ identity, points }: ProgressionChartProps) {
const { dateFormat } = useDatePreferences()
const host = useRef<HTMLDivElement>(null) const host = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
@ -91,7 +98,7 @@ export function ProgressionChart({ identity, points }: ProgressionChartProps) {
formatter: (params: unknown) => { formatter: (params: unknown) => {
const candidate = Array.isArray(params) ? params[0] : params const candidate = Array.isArray(params) ? params[0] : params
const datum = (candidate as { data?: ChartDatum } | undefined)?.data const datum = (candidate as { data?: ChartDatum } | undefined)?.data
return datum === undefined ? '' : progressionTooltip(identity, datum.point) return datum === undefined ? '' : progressionTooltip(identity, datum.point, dateFormat)
}, },
}, },
xAxis: { xAxis: {
@ -112,7 +119,7 @@ export function ProgressionChart({ identity, points }: ProgressionChartProps) {
const observer = new ResizeObserver(() => chart.resize()) const observer = new ResizeObserver(() => chart.resize())
observer.observe(element) observer.observe(element)
return () => { observer.disconnect(); chart.dispose() } return () => { observer.disconnect(); chart.dispose() }
}, [identity, points]) }, [dateFormat, identity, points])
return <div className="progression-chart" ref={host} data-testid="progression-chart" role="img" aria-label={`Courbe de ${identity.exercise_name} : ${points.length} ${points.length === 1 ? 'mesure réelle' : 'mesures réelles'} en kilogrammes. La ligne relie les observations sans représenter de mesures intermédiaires.`} /> return <div className="progression-chart" ref={host} data-testid="progression-chart" role="img" aria-label={`Courbe de ${identity.exercise_name} : ${points.length} ${points.length === 1 ? 'mesure réelle' : 'mesures réelles'} en kilogrammes. La ligne relie les observations sans représenter de mesures intermédiaires.`} />
} }

View file

@ -0,0 +1,42 @@
import { useRef, useState } from 'react'
import { useDatePreferences } from '../presentation/DatePreferences'
export function DisplayPreferences() {
const { dateFormat, pending, error, save } = useDatePreferences()
const [open, setOpen] = useState(false)
const trigger = useRef<HTMLButtonElement>(null)
const close = () => {
setOpen(false)
requestAnimationFrame(() => trigger.current?.focus())
}
const persist = (format: 'fr' | 'iso') => {
void save(format).catch(() => undefined)
}
return <div className="display-preferences">
<button ref={trigger} type="button" className="preferences-trigger"
aria-haspopup="dialog" aria-expanded={open} onClick={() => setOpen(true)}>
Paramètres d’affichage
</button>
{open && <div className="preferences-dialog" role="dialog" aria-modal="true"
aria-labelledby="preferences-title">
<div className="preferences-heading">
<h2 id="preferences-title">Paramètres d’affichage</h2>
<button type="button" className="quiet-action" onClick={close}>Fermer</button>
</div>
<fieldset disabled={pending}>
<legend>Format des dates</legend>
<label><input type="radio" name="date-format" value="fr"
checked={dateFormat === 'fr'} onChange={() => persist('fr')} />
Français — 17/09/2026</label>
<label><input type="radio" name="date-format" value="iso"
checked={dateFormat === 'iso'} onChange={() => persist('iso')} />
ISO — 2026-09-17</label>
</fieldset>
<p className="preference-example">Exemple déterministe : {dateFormat === 'fr'
? '18/09/2026 à 10:19' : '2026-09-18 10:19'}</p>
{error && <p className="form-error" role="alert">{error}</p>}
</div>}
</div>
}

View file

@ -1,5 +1,7 @@
import type { Health } from '../api/health' import type { Health } from '../api/health'
import type { DashboardSnapshot } from '../api/dashboard' import type { DashboardSnapshot } from '../api/dashboard'
import { useDatePreferences } from '../presentation/DatePreferences'
import { formatDate } from '../presentation/dateFormat'
interface FooterProps { interface FooterProps {
health: Health | null health: Health | null
@ -9,6 +11,7 @@ interface FooterProps {
} }
export function Footer({ health, healthPending, healthFailed, dashboard }: FooterProps) { export function Footer({ health, healthPending, healthFailed, dashboard }: FooterProps) {
const { dateFormat } = useDatePreferences()
const backendLabel = healthPending ? 'Connexion en cours' : const backendLabel = healthPending ? 'Connexion en cours' :
healthFailed ? 'Backend indisponible' : `Backend connecté · v${health?.version ?? '—'}` healthFailed ? 'Backend indisponible' : `Backend connecté · v${health?.version ?? '—'}`
@ -20,7 +23,10 @@ export function Footer({ health, healthPending, healthFailed, dashboard }: Foote
</section> </section>
<section className="status-block status-session" aria-label="Dernière séance"> <section className="status-block status-session" aria-label="Dernière séance">
<span className="status-label">DERNIÈRE SÉANCE</span> <span className="status-label">DERNIÈRE SÉANCE</span>
<span className="status-value">{dashboard?.data.footer.last_session_date ?? 'Indisponible'}</span> <span className="status-value">{dashboard?.data.footer.last_session_date
? <time dateTime={dashboard.data.footer.last_session_date}>
{formatDate(dashboard.data.footer.last_session_date, dateFormat)}</time>
: 'Indisponible'}</span>
</section> </section>
<section className="status-block status-zone" aria-label="Dernière zone"> <section className="status-block status-zone" aria-label="Dernière zone">
<span className="status-label">DERNIÈRE ZONE</span> <span className="status-label">DERNIÈRE ZONE</span>

View file

@ -1,6 +1,7 @@
import type { MouseEvent } from 'react' import type { MouseEvent } from 'react'
import { routes, type AppRoute } from '../app/routes' import { routes, type AppRoute } from '../app/routes'
import { SyncControl } from './SyncControl' import { SyncControl } from './SyncControl'
import { DisplayPreferences } from './DisplayPreferences'
interface HeaderProps { interface HeaderProps {
activeRoute: AppRoute activeRoute: AppRoute
@ -32,6 +33,7 @@ export function Header({ activeRoute, onNavigate, onSyncCommitted }: HeaderProps
</a> </a>
))} ))}
</nav> </nav>
<DisplayPreferences />
<SyncControl onCommitted={onSyncCommitted} /> <SyncControl onCommitted={onSyncCommitted} />
</header> </header>
) )

View file

@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { fetchSyncStatus, newRequestId, startSync, syncIsActive, type SyncStatus } from '../api/sync' import { fetchSyncStatus, newRequestId, startSync, syncIsActive, type SyncStatus } from '../api/sync'
import { useDatePreferences } from '../presentation/DatePreferences'
import { formatDateTime } from '../presentation/dateFormat'
const labels: Record<string, string> = { const labels: Record<string, string> = {
idle: 'Synchronisation inactive', idle: 'Synchronisation inactive',
@ -41,6 +43,7 @@ function statusAction(status: SyncStatus | null): string {
} }
export function SyncControl({ onCommitted }: { onCommitted: () => void }) { export function SyncControl({ onCommitted }: { onCommitted: () => void }) {
const { dateFormat } = useDatePreferences()
const [status, setStatus] = useState<SyncStatus | null>(null) const [status, setStatus] = useState<SyncStatus | null>(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const statusRef = useRef<SyncStatus | null>(null) const statusRef = useRef<SyncStatus | null>(null)
@ -148,8 +151,10 @@ export function SyncControl({ onCommitted }: { onCommitted: () => void }) {
</ul> </ul>
</section> </section>
)} )}
{status?.started_at && <p>Démarrée : {status.started_at}</p>} {status?.started_at && <p>Démarrée : <time dateTime={status.started_at}>
{status?.finished_at && <p>Dernier résultat : {status.finished_at}</p>} {formatDateTime(status.started_at, dateFormat)}</time></p>}
{status?.finished_at && <p>Dernier résultat : <time dateTime={status.finished_at}>
{formatDateTime(status.finished_at, dateFormat)}</time></p>}
</details> </details>
</div> </div>
) )

View file

@ -20,7 +20,7 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
api_version: 1 as const, api_version: 1 as const,
generated_at: '2026-09-17T12:00:00Z', generated_at: '2026-09-17T12:00:00Z',
partial: false, partial: false,
items: [{ identity: 'aid_one', kind: 'ai_proposal' as const, title: 'Préparation', planned_for: '2026-09-18', state: 'published', occurrence_count: 6, provenance: 'ai_import' }], items: [{ identity: 'aid_one', kind: 'ai_proposal' as const, title: 'Préparation', planned_for: '2026-09-18', state: 'published', sort_timestamp: '2026-09-17T12:00:00Z', occurrence_count: 6, provenance: 'ai_import' }],
} }
const { rerender } = render( const { rerender } = render(
<NextSessionTile size="large" preparedItems={proposal} pending={false} failed={false} />, <NextSessionTile size="large" preparedItems={proposal} pending={false} failed={false} />,
@ -34,7 +34,7 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
}) })
it('conserve une lecture durable lorsque son actualisation échoue', () => { it('conserve une lecture durable lorsque son actualisation échoue', () => {
render(<NextSessionTile size="medium" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [{ identity: 'aid_one', kind: 'ai_proposal', title: 'Conservée', planned_for: null, state: 'published', occurrence_count: 1, provenance: 'ai_import' }] }} pending={false} failed />) render(<NextSessionTile size="medium" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [{ identity: 'aid_one', kind: 'ai_proposal', title: 'Conservée', planned_for: null, state: 'published', sort_timestamp: '2026-09-17T12:00:00Z', occurrence_count: 1, provenance: 'ai_import' }] }} pending={false} failed />)
expect(screen.getByText('Conservée')).toBeInTheDocument() expect(screen.getByText('Conservée')).toBeInTheDocument()
expect(screen.getByText(/Dernière lecture conservée/)).toBeInTheDocument() expect(screen.getByText(/Dernière lecture conservée/)).toBeInTheDocument()
expect(screen.queryByText('Aucun élément disponible')).not.toBeInTheDocument() expect(screen.queryByText('Aucun élément disponible')).not.toBeInTheDocument()
@ -42,8 +42,8 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
it('rend chaque proposition consultable sans la transformer en brouillon', () => { it('rend chaque proposition consultable sans la transformer en brouillon', () => {
render(<NextSessionTile size="large" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [ render(<NextSessionTile size="large" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [
{ identity: 'aid_first', kind: 'ai_proposal', title: 'Première', planned_for: null, state: 'published', occurrence_count: 2, provenance: 'ai_import' }, { identity: 'aid_first', kind: 'ai_proposal', title: 'Première', planned_for: null, state: 'published', sort_timestamp: '2026-09-16T12:00:00Z', occurrence_count: 2, provenance: 'ai_import' },
{ identity: 'aid_expected', kind: 'ai_proposal', title: 'Attendue', planned_for: null, state: 'published', occurrence_count: 6, provenance: 'ai_import' }, { identity: 'aid_expected', kind: 'ai_proposal', title: 'Attendue', planned_for: null, state: 'published', sort_timestamp: '2026-09-17T12:00:00Z', occurrence_count: 6, provenance: 'ai_import' },
] }} pending={false} failed={false} />) ] }} pending={false} failed={false} />)
const expected = document.querySelector('[data-prepared-identity="aid_expected"]') const expected = document.querySelector('[data-prepared-identity="aid_expected"]')
expect(expected).toHaveTextContent('Attendue') expect(expected).toHaveTextContent('Attendue')

View file

@ -1,9 +1,12 @@
import type { DashboardSnapshot, WorkedZone } from '../api/dashboard' import type { DashboardSnapshot, WorkedZone } from '../api/dashboard'
import { lazy, Suspense, type ComponentType } from 'react' import { lazy, Suspense, type ComponentType, type ReactNode } from 'react'
import type { TileSize } from './dashboardLayout' import type { TileSize } from './dashboardLayout'
import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat' import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat'
import { BodyZoneFigure } from './BodyZoneFigure' import { BodyZoneFigure } from './BodyZoneFigure'
import type { PreparedItemsSnapshot } from '../api/preparedItems' import type { PreparedItemsSnapshot } from '../api/preparedItems'
import type { PreparedItem } from '../api/preparedItems'
import { useDatePreferences } from '../presentation/DatePreferences'
import { validDateSortValue, validTimestampValue } from '../presentation/dateFormat'
const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart }))) const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart })))
@ -16,22 +19,46 @@ export interface NextSessionTileProps {
failed?: boolean failed?: boolean
} }
export function sortPreparedItems(items: readonly PreparedItem[]): PreparedItem[] {
return [...items].sort((left, right) => {
const leftDate = validDateSortValue(left.planned_for)
const rightDate = validDateSortValue(right.planned_for)
if (leftDate !== null && rightDate !== null && leftDate !== rightDate) {
return rightDate.localeCompare(leftDate, 'en')
}
if (leftDate === null && rightDate !== null) return 1
if (leftDate !== null && rightDate === null) return -1
const leftTimestamp = validTimestampValue(left.sort_timestamp)
const rightTimestamp = validTimestampValue(right.sort_timestamp)
if (leftTimestamp !== null && rightTimestamp !== null && leftTimestamp !== rightTimestamp) {
return rightTimestamp - leftTimestamp
}
if (leftTimestamp === null && rightTimestamp !== null) return 1
if (leftTimestamp !== null && rightTimestamp === null) return -1
return `${left.kind}:${left.identity}`.localeCompare(`${right.kind}:${right.identity}`, 'en')
})
}
export function NextSessionTile({ export function NextSessionTile({
size, size,
preparedItems = null, preparedItems = null,
pending = false, pending = false,
failed = false, failed = false,
}: NextSessionTileProps) { }: NextSessionTileProps) {
const { dateFormat } = useDatePreferences()
if (preparedItems === null) { if (preparedItems === null) {
return <Unavailable text={failed ? 'Données indisponibles' : 'Chargement des préparations…'} /> return <Unavailable text={failed ? 'Données indisponibles' : 'Chargement des préparations…'} />
} }
const item = preparedItems.items[0] const orderedItems = sortPreparedItems(preparedItems.items)
const item = orderedItems[0]
if (item === undefined) { if (item === undefined) {
return <Unavailable text={failed ? 'Données indisponibles' : 'Aucun élément disponible'} /> return <Unavailable text={failed ? 'Données indisponibles' : 'Aucun élément disponible'} />
} }
const proposal = item.kind === 'ai_proposal' const proposal = item.kind === 'ai_proposal'
const manual = item.kind === 'manual_preparation'
const heading = proposal const heading = proposal
? 'Proposition préparée · à valider' ? 'Proposition préparée · à valider'
: manual ? 'Préparation manuelle'
: item.state === 'active' : item.state === 'active'
? 'Brouillon d’exécution · à reprendre' ? 'Brouillon d’exécution · à reprendre'
: 'Brouillon d’exécution · en attente' : 'Brouillon d’exécution · en attente'
@ -44,16 +71,25 @@ export function NextSessionTile({
<> <>
<dl className="fact-list horizontal"> <dl className="fact-list horizontal">
<Fact label="Éléments" value={String(item.occurrence_count)} /> <Fact label="Éléments" value={String(item.occurrence_count)} />
<Fact label="Prévue" value={item.planned_for ? formatDate(item.planned_for) : 'Non renseignée'} /> <Fact label="Prévue" value={item.planned_for
? <time dateTime={item.planned_for}>{formatDate(item.planned_for, dateFormat)}</time>
: 'Non planifiée'} />
</dl> </dl>
{preparedItems.items.length > 1 && ( {orderedItems.length > 1 && (
<details className="prepared-items-list"> <details className="prepared-items-list">
<summary>{preparedItems.items.length} éléments préparés</summary> <summary>{orderedItems.length} éléments préparés · date décroissante</summary>
<ul> <ul>
{preparedItems.items.map((prepared) => ( {orderedItems.map((prepared) => (
<li key={`${prepared.kind}:${prepared.identity}`} data-prepared-identity={prepared.identity}> <li key={`${prepared.kind}:${prepared.identity}`} data-prepared-identity={prepared.identity}>
<a href={`/seances/${prepared.kind === 'ai_proposal' ? 'proposal'
: prepared.kind === 'manual_preparation' ? 'preparation' : 'draft'}/${encodeURIComponent(prepared.identity)}`}>
{prepared.title || (prepared.kind === 'ai_proposal' ? 'Proposition sans titre' : 'Séance préparée')} {prepared.title || (prepared.kind === 'ai_proposal' ? 'Proposition sans titre' : 'Séance préparée')}
{' · '}{prepared.kind === 'ai_proposal' ? 'proposition' : 'brouillon d’exécution'} </a>{' · '}{prepared.kind === 'ai_proposal' ? 'proposition à valider'
: prepared.kind === 'manual_preparation' ? 'préparation manuelle' : 'brouillon d’exécution'}
{' · '}{prepared.planned_for
? <time dateTime={prepared.planned_for}>
{formatDate(prepared.planned_for, dateFormat)}</time>
: 'Non planifiée'}
</li> </li>
))} ))}
</ul> </ul>
@ -68,6 +104,7 @@ export function NextSessionTile({
} }
export function ActivityTile({ snapshot, size }: TileDataProps) { export function ActivityTile({ snapshot, size }: TileDataProps) {
const { dateFormat } = useDatePreferences()
const days = snapshot.data.activity.days const days = snapshot.data.activity.days
// CONTRACT: these are direct presentation totals of Core-projected counters, // CONTRACT: these are direct presentation totals of Core-projected counters,
// never an activity or physiological-intensity score. // never an activity or physiological-intensity score.
@ -78,41 +115,46 @@ export function ActivityTile({ snapshot, size }: TileDataProps) {
return <div className="activity-content"> return <div className="activity-content">
<div className="metric-row"><Metric value={String(activeDays)} label="jours actifs" />{size !== 'compact' && <Metric value={String(sessions)} label="séances" />}{size === 'large' && <Metric value={String(sets)} label="séries" />}</div> <div className="metric-row"><Metric value={String(activeDays)} label="jours actifs" />{size !== 'compact' && <Metric value={String(sessions)} label="séances" />}{size === 'large' && <Metric value={String(sets)} label="séries" />}</div>
<div className="activity-heatmap" role="img" aria-label={`Sur 90 jours : ${count(activeDays, 'jour actif', 'jours actifs')}, ${count(sessions, 'séance')}, ${count(sets, 'série')}.`}> <div className="activity-heatmap" role="img" aria-label={`Sur 90 jours : ${count(activeDays, 'jour actif', 'jours actifs')}, ${count(sessions, 'séance')}, ${count(sets, 'série')}.`}>
{shown.map((day) => <span key={day.date} className={`activity-day${day.active ? ' is-active' : ''}${day.session_count > 1 ? ' is-multiple' : ''}`} title={`${formatDate(day.date)} — ${count(day.session_count, 'séance')}, ${count(day.set_count, 'série')}`} />)} {shown.map((day) => <span key={day.date} className={`activity-day${day.active ? ' is-active' : ''}${day.session_count > 1 ? ' is-multiple' : ''}`} title={`${formatDate(day.date, dateFormat)} — ${count(day.session_count, 'séance')}, ${count(day.set_count, 'série')}`} />)}
</div> </div>
{size === 'large' && <p className="chart-legend"><span className="legend-swatch" /> Jour avec au moins une séance · la teinte indique uniquement le nombre de séances</p>} {size === 'large' && <p className="chart-legend"><span className="legend-swatch" /> Jour avec au moins une séance · la teinte indique uniquement le nombre de séances</p>}
</div> </div>
} }
export function ProgressionTile({ snapshot, size }: TileDataProps) { export function ProgressionTile({ snapshot, size }: TileDataProps) {
const { dateFormat } = useDatePreferences()
const progression = snapshot.data.progression const progression = snapshot.data.progression
if (!progression.available) return <Unavailable text="Aucune performance comparable disponible" /> if (!progression.available) return <Unavailable text="Aucune performance comparable disponible" />
const first = progression.points[0] const first = progression.points[0]
const last = progression.points.at(-1) const last = progression.points.at(-1)
return <div className="progression-content"> return <div className="progression-content">
<div><p className="primary-label">{progression.identity.exercise_name}</p>{size !== 'compact' && <p className="secondary-label">{progression.identity.equipment_label}</p>}</div> <div><p className="primary-label">{progression.identity.exercise_name}</p>{size !== 'compact' && <p className="secondary-label">{progression.identity.equipment_label}</p>}</div>
{last === undefined ? <p className="muted">Aucun point disponible</p> : <div className="progression-latest"><strong>{formatWeight(last.weight_kg)}</strong><span>{formatDate(last.timestamp)}</span>{last.improved && <em>Amélioration enregistrée</em>}</div>} {last === undefined ? <p className="muted">Aucun point disponible</p> : <div className="progression-latest"><strong>{formatWeight(last.weight_kg)}</strong><span>{formatDate(last.timestamp, dateFormat)}</span>{last.improved && <em>Amélioration enregistrée</em>}</div>}
{size !== 'compact' && <dl className="fact-list horizontal"><Fact label="Dose" value={formatDose(progression.identity.dose)} /><Fact label="Premier" value={first ? formatWeight(first.weight_kg) : '—'} /><Fact label="Dernier" value={last ? formatWeight(last.weight_kg) : '—'} /><Fact label="Points" value={String(progression.points.length)} /></dl>} {size !== 'compact' && <dl className="fact-list horizontal"><Fact label="Dose" value={formatDose(progression.identity.dose)} /><Fact label="Premier" value={first ? formatWeight(first.weight_kg) : '—'} /><Fact label="Dernier" value={last ? formatWeight(last.weight_kg) : '—'} /><Fact label="Points" value={String(progression.points.length)} /></dl>}
{size !== 'compact' && progression.points.length > 0 && <><Suspense fallback={<div className="chart-loading" role="status">Chargement de la courbe…</div>}><ProgressionChart identity={progression.identity} points={progression.points} /></Suspense><ol className="sr-only" aria-label="Mesures de progression">{progression.points.map((point, index) => <li key={`${point.session_id}-${point.timestamp}-${index}`}>{formatDateTime(point.timestamp)}, {formatWeight(point.weight_kg)}{point.improved ? ', amélioration' : ''}</li>)}</ol></>} {size !== 'compact' && progression.points.length > 0 && <><Suspense fallback={<div className="chart-loading" role="status">Chargement de la courbe…</div>}><ProgressionChart identity={progression.identity} points={progression.points} /></Suspense><ol className="sr-only" aria-label="Mesures de progression">{progression.points.map((point, index) => <li key={`${point.session_id}-${point.timestamp}-${index}`}>{formatDateTime(point.timestamp, dateFormat)}, {formatWeight(point.weight_kg)}{point.improved ? ', amélioration' : ''}</li>)}</ol></>}
{size === 'large' && <p className="chart-legend">Identité comparable : {progression.identity.tracking_mode} · {progression.identity.load_mode}</p>} {size === 'large' && <p className="chart-legend">Identité comparable : {progression.identity.tracking_mode} · {progression.identity.load_mode}</p>}
</div> </div>
} }
export function LastSessionTile({ snapshot, size }: TileDataProps) { export function LastSessionTile({ snapshot, size }: TileDataProps) {
const { dateFormat } = useDatePreferences()
const session = snapshot.data.last_session const session = snapshot.data.last_session
if (!session.available) return <Unavailable text="Aucune séance observable disponible" /> if (!session.available) return <Unavailable text="Aucune séance observable disponible" />
return <div className="session-content"> return <div className="session-content">
<p className="primary-label">{size === 'compact' ? formatDate(session.started_at) : formatDateTime(session.started_at)}</p> <p className="primary-label"><time dateTime={session.started_at}>{size === 'compact'
? formatDate(session.started_at, dateFormat)
: formatDateTime(session.started_at, dateFormat)}</time></p>
<dl className="fact-list horizontal"><Fact label="Exercices" value={String(session.exercise_count)} /><Fact label="Séries" value={String(session.set_count)} />{size !== 'compact' && <><Fact label="Durée" value={session.duration_seconds === null ? 'Indisponible' : formatDuration(session.duration_seconds)} /><Fact label="Activités continues" value={String(session.continuous_count)} /><Fact label="MAX explicites" value={String(session.max_count)} /></>}</dl> <dl className="fact-list horizontal"><Fact label="Exercices" value={String(session.exercise_count)} /><Fact label="Séries" value={String(session.set_count)} />{size !== 'compact' && <><Fact label="Durée" value={session.duration_seconds === null ? 'Indisponible' : formatDuration(session.duration_seconds)} /><Fact label="Activités continues" value={String(session.continuous_count)} /><Fact label="MAX explicites" value={String(session.max_count)} /></>}</dl>
{size === 'large' && <div className="zone-chips" aria-label="Zones primaires travaillées">{session.primary_zones.map((zone) => <span key={zone.zone_id}>{zone.label}</span>)}</div>} {size === 'large' && <div className="zone-chips" aria-label="Zones primaires travaillées">{session.primary_zones.map((zone) => <span key={zone.zone_id}>{zone.label}</span>)}</div>}
</div> </div>
} }
export function MaxRecordsTile({ snapshot, size }: TileDataProps) { export function MaxRecordsTile({ snapshot, size }: TileDataProps) {
const { dateFormat } = useDatePreferences()
const records = snapshot.data.max_records.records const records = snapshot.data.max_records.records
if (!snapshot.data.max_records.available || records.length === 0) return <Unavailable text="Aucun MAX explicite disponible" /> if (!snapshot.data.max_records.available || records.length === 0) return <Unavailable text="Aucun MAX explicite disponible" />
const limit = size === 'compact' ? 1 : size === 'medium' ? 3 : 8 const limit = size === 'compact' ? 1 : size === 'medium' ? 3 : 8
return <div className="max-content"><ol className="record-list">{records.slice(0, limit).map((record) => <li key={record.entry_id}><div><strong>{record.exercise_name}</strong><span>{formatDate(record.timestamp)}{size === 'large' && record.equipment_id ? ` · ${record.equipment_id}` : ''}</span></div><b>{formatWeight(record.weight_kg)}</b></li>)}</ol>{snapshot.meta.partial && records.length >= 8 && <p className="partial-note">8 MAX récents affichés</p>}</div> return <div className="max-content"><ol className="record-list">{records.slice(0, limit).map((record) => <li key={record.entry_id}><div><strong>{record.exercise_name}</strong><span>{formatDate(record.timestamp, dateFormat)}{size === 'large' && record.equipment_id ? ` · ${record.equipment_id}` : ''}</span></div><b>{formatWeight(record.weight_kg)}</b></li>)}</ol>{snapshot.meta.partial && records.length >= 8 && <p className="partial-note">8 MAX récents affichés</p>}</div>
} }
export function MuscleDistributionTile({ snapshot, size }: TileDataProps) { export function MuscleDistributionTile({ snapshot, size }: TileDataProps) {
@ -138,7 +180,7 @@ export function CardioTile({ size }: TileDataProps) {
} }
function Metric({ value, label }: { value: string; label: string }) { return <div className="metric"><strong>{value}</strong><span>{label}</span></div> } function Metric({ value, label }: { value: string; label: string }) { return <div className="metric"><strong>{value}</strong><span>{label}</span></div> }
function Fact({ label, value }: { label: string; value: string }) { return <div><dt>{label}</dt><dd>{value}</dd></div> } function Fact({ label, value }: { label: string; value: ReactNode }) { return <div><dt>{label}</dt><dd>{value}</dd></div> }
function Unavailable({ text }: { text: string }) { return <div className="unavailable-content"><strong aria-hidden="true">—</strong><p>{text}</p></div> } function Unavailable({ text }: { text: string }) { return <div className="unavailable-content"><strong aria-hidden="true">—</strong><p>{text}</p></div> }
export type DashboardTileComponent = ComponentType<TileDataProps> export type DashboardTileComponent = ComponentType<TileDataProps>

View file

@ -1,14 +1,15 @@
const dateFormat = new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' }) import type { WebDateFormat } from '../api/webPreferences'
const dateTimeFormat = new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) import {
formatDate as formatPresentationDate,
formatDateTime as formatPresentationDateTime,
} from '../presentation/dateFormat'
const numberFormat = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 }) const numberFormat = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 })
function parsed(value: string): Date | null { export const formatDate = (value: string, format: WebDateFormat = 'fr') =>
const result = new Date(value) formatPresentationDate(value, format)
return Number.isNaN(result.valueOf()) ? null : result export const formatDateTime = (value: string, format: WebDateFormat = 'fr') =>
} formatPresentationDateTime(value, format)
export const formatDate = (value: string) => { const date = parsed(value); return date === null ? value : dateFormat.format(date) }
export const formatDateTime = (value: string) => { const date = parsed(value); return date === null ? value : dateTimeFormat.format(date) }
export const formatWeight = (value: number) => `${numberFormat.format(value)} kg` export const formatWeight = (value: number) => `${numberFormat.format(value)} kg`
export const formatDose = (value: number) => numberFormat.format(value) export const formatDose = (value: number) => numberFormat.format(value)
export function formatDuration(seconds: number): string { export function formatDuration(seconds: number): string {

View file

@ -0,0 +1,72 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import {
fetchWebPreferences,
saveWebPreferences,
type WebDateFormat,
type WebPreferencesSnapshot,
} from '../api/webPreferences'
interface DatePreferencesValue {
dateFormat: WebDateFormat
pending: boolean
error: string
save: (format: WebDateFormat) => Promise<void>
}
const DatePreferencesContext = createContext<DatePreferencesValue>({
dateFormat: 'fr',
pending: false,
error: '',
save: async () => undefined,
})
export function DatePreferencesProvider({ children }: { children: ReactNode }) {
const [snapshot, setSnapshot] = useState<WebPreferencesSnapshot | null>(null)
const [dateFormat, setDateFormat] = useState<WebDateFormat>('fr')
const [pending, setPending] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
fetchWebPreferences(controller.signal).then((value) => {
setSnapshot(value)
setDateFormat(value.date_format)
setError(value.source === 'invalid_persisted'
? 'Préférence persistée invalide : format français utilisé.' : '')
}).catch((reason) => {
if (!controller.signal.aborted) {
setError(reason instanceof Error ? reason.message : 'Préférences indisponibles')
}
}).finally(() => {
if (!controller.signal.aborted) setPending(false)
})
return () => controller.abort()
}, [])
const save = async (format: WebDateFormat) => {
if (snapshot === null) throw new Error('Préférences indisponibles')
setPending(true)
setError('')
try {
const saved = await saveWebPreferences(snapshot, format)
setSnapshot(saved)
setDateFormat(saved.date_format)
} catch (reason) {
const message = reason instanceof Error && reason.message === 'revision_conflict'
? 'La préférence a changé dans une autre fenêtre. Rechargez la page.'
: reason instanceof Error ? reason.message : 'Enregistrement impossible'
setError(message)
throw reason
} finally {
setPending(false)
}
}
const value = useMemo(() => ({ dateFormat, pending, error, save }),
[dateFormat, pending, error, snapshot])
return <DatePreferencesContext.Provider value={value}>{children}</DatePreferencesContext.Provider>
}
export function useDatePreferences(): DatePreferencesValue {
return useContext(DatePreferencesContext)
}

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import {
INVALID_DATE_LABEL,
formatCivilDate,
formatDateTime,
parseCivilDate,
validDateSortValue,
validTimestampValue,
} from './dateFormat'
describe('date presentation contract', () => {
it('formats civil dates without converting them through a timezone', () => {
expect(formatCivilDate('2026-09-18', 'fr')).toBe('18/09/2026')
expect(formatCivilDate('2026-09-18', 'iso')).toBe('2026-09-18')
expect(parseCivilDate('2026-02-29')).toBeNull()
expect(formatCivilDate('2026-02-29', 'fr')).toBe(INVALID_DATE_LABEL)
expect(validDateSortValue('2024-02-29')).toBe('2024-02-29')
expect(validDateSortValue('2024-13-01')).toBeNull()
})
it('uses the browser timezone only for timestamps and preserves their instant', () => {
expect(validTimestampValue('2026-09-18T12:30:00Z')).toBe(1789734600000)
expect(validTimestampValue('2026-09-18')).toBeNull()
expect(validTimestampValue('2026-09-18T12:30:00')).toBeNull()
expect(validTimestampValue('2026-02-31T12:30:00Z')).toBeNull()
expect(validTimestampValue('2026-09-18T23:30:00.123456789+02:00')).not.toBeNull()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
if (timezone === 'Pacific/Honolulu') {
expect(formatDateTime('2026-09-18T12:30:00Z', 'fr')).toBe('18/09/2026 à 02:30')
}
if (timezone === 'Pacific/Kiritimati') {
expect(formatDateTime('2026-09-18T12:30:00Z', 'fr')).toBe('19/09/2026 à 02:30')
}
})
})

View file

@ -0,0 +1,88 @@
import type { WebDateFormat } from '../api/webPreferences'
export const INVALID_DATE_LABEL = 'Date invalide'
interface CivilDate {
year: number
month: number
day: number
}
export function parseCivilDate(value: string): CivilDate | null {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (match === null) return null
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
if (month < 1 || month > 12 || day < 1 || day > days[month - 1]) return null
return { year, month, day }
}
function padded(value: number): string {
return String(value).padStart(2, '0')
}
function formatParts(parts: CivilDate, format: WebDateFormat): string {
return format === 'iso'
? `${String(parts.year).padStart(4, '0')}-${padded(parts.month)}-${padded(parts.day)}`
: `${padded(parts.day)}/${padded(parts.month)}/${String(parts.year).padStart(4, '0')}`
}
export function formatCivilDate(value: string, format: WebDateFormat): string {
const parts = parseCivilDate(value)
return parts === null ? INVALID_DATE_LABEL : formatParts(parts, format)
}
function parsedTimestamp(value: string): Date | null {
const match = value.match(
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/,
)
if (match === null || parseCivilDate(`${match[1]}-${match[2]}-${match[3]}`) === null) return null
const hour = Number(match[4])
const minute = Number(match[5])
const second = Number(match[6])
const offset = match[8]
if (hour > 23 || minute > 59 || second > 59) return null
if (offset !== 'Z') {
const offsetHour = Number(offset.slice(1, 3))
const offsetMinute = Number(offset.slice(4, 6))
if (offsetHour > 23 || offsetMinute > 59) return null
}
const fraction = match[7] === undefined
? ''
: `.${match[7].padEnd(3, '0').slice(0, 3)}`
const date = new Date(
`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}${fraction}${offset}`,
)
return Number.isNaN(date.valueOf()) ? null : date
}
function localParts(date: Date): CivilDate {
return { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() }
}
export function formatDate(value: string, format: WebDateFormat): string {
const civil = parseCivilDate(value)
if (civil !== null) return formatParts(civil, format)
const timestamp = parsedTimestamp(value)
return timestamp === null ? INVALID_DATE_LABEL : formatParts(localParts(timestamp), format)
}
export function formatDateTime(value: string, format: WebDateFormat): string {
const timestamp = parsedTimestamp(value)
if (timestamp === null) return INVALID_DATE_LABEL
const date = formatParts(localParts(timestamp), format)
const time = `${padded(timestamp.getHours())}:${padded(timestamp.getMinutes())}`
return format === 'iso' ? `${date} ${time}` : `${date} à ${time}`
}
export function validDateSortValue(value: string | null): string | null {
return value !== null && parseCivilDate(value) !== null ? value : null
}
export function validTimestampValue(value: string): number | null {
const date = parsedTimestamp(value)
return date === null ? null : date.valueOf()
}

View file

@ -1,6 +1,12 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { commandOccurrence, SessionsPage } from './SessionsPage' import {
commandOccurrence,
sessionStateLabel,
SessionsPage,
sortSessionItems,
type SessionListEntry,
} from './SessionsPage'
function page(kind: string, items: unknown[]) { function page(kind: string, items: unknown[]) {
return { api_version: 1, kind, offset: 0, more: false, next_offset: items.length, items } return { api_version: 1, kind, offset: 0, more: false, next_offset: items.length, items }
@ -52,4 +58,117 @@ describe('Sessions page', () => {
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('ne sont pas disponibles')) await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('ne sont pas disponibles'))
expect(screen.queryByText('Aucun résultat pour cette vue.')).not.toBeInTheDocument() expect(screen.queryByText('Aucun résultat pour cette vue.')).not.toBeInTheDocument()
}) })
it('sorts more than one page globally with deterministic ties and invalid dates last', () => {
const values: SessionListEntry[] = Array.from({ length: 41 }, (_, index) => ({
collection: index % 2 === 0 ? 'preparations' : 'proposals',
identity: `id_${String(index).padStart(2, '0')}`,
title: `Item ${index}`,
date: index === 39 ? null : index === 40 ? 'date-invalide' :
`2026-09-${String((index % 20) + 1).padStart(2, '0')}`,
state: index % 2 === 0 ? 'ready' : 'published',
occurrence_count: 1,
sort_timestamp: `2026-09-${String((index % 20) + 1).padStart(2, '0')}T12:00:00Z`,
}))
values.push({
...values[0], identity: 'id_same_b', date: '2026-09-18',
sort_timestamp: '2026-09-18T12:00:00Z',
}, {
...values[0], identity: 'id_same_a', date: '2026-09-18',
sort_timestamp: '2026-09-18T12:00:00Z',
})
const sorted = sortSessionItems(values, 'preparation')
expect(sorted).toHaveLength(43)
expect(sorted.slice(-2).map((item) => item.identity)).toEqual(['id_39', 'id_40'])
expect(sorted.findIndex((item) => item.identity === 'id_same_a')).toBeLessThan(
sorted.findIndex((item) => item.identity === 'id_same_b'),
)
expect(sorted.every((item, index) => index === 0 ||
item.date === null || item.date === 'date-invalide' ||
sorted[index - 1].date === null || sorted[index - 1].date === 'date-invalide' ||
String(sorted[index - 1].date) >= String(item.date))).toBe(true)
})
it('uses contextual French state labels and exposes unknown values', () => {
expect(sessionStateLabel('preparations', 'acknowledged')).toBe('Reçue')
expect(sessionStateLabel('proposals', 'published')).toBe('Publiée')
expect(sessionStateLabel('drafts', 'active')).toBe('En cours')
expect(sessionStateLabel('history', 'completed')).toBe('Terminée')
expect(sessionStateLabel('preparations', 'unexpected')).toBe('État non reconnu (unexpected)')
})
it('sorts history by the real instant across offsets with a stable identity tie-break', () => {
const history: SessionListEntry[] = [
{ collection: 'history', identity: 'se_invalid', title: 'Invalide', date: null,
state: 'completed', occurrence_count: 1, sort_timestamp: 'invalid' },
{ collection: 'history', identity: 'se_b', title: 'Même instant B', date: '2026-09-18',
state: 'completed', occurrence_count: 1, sort_timestamp: '2026-09-18T10:00:00+02:00' },
{ collection: 'history', identity: 'se_a', title: 'Même instant A', date: '2026-09-18',
state: 'completed', occurrence_count: 1, sort_timestamp: '2026-09-18T08:00:00Z' },
{ collection: 'history', identity: 'se_older', title: 'Ancienne', date: '2026-09-18',
state: 'completed', occurrence_count: 1, sort_timestamp: '2026-09-18T07:59:59Z' },
]
expect(sortSessionItems(history, 'history').map((item) => item.identity)).toEqual([
'se_a', 'se_b', 'se_older', 'se_invalid',
])
})
it('cancels confirmation without mutation, then withdraws through the DELETE API', async () => {
window.history.replaceState(null, '', '/seances/preparation/sp_target')
const detail = {
api_version: 1, kind: 'preparation', identity: 'sp_target', revision_id: 'spr_current',
title: 'Préparation ciblée', planned_for: '2026-09-18', session_type: 'training',
notes: null, source_fingerprint: 'a'.repeat(64), source_proposal_id: 'aid_source',
source_proposal_title: 'Proposition source', state: 'acknowledged', occurrences: [],
}
const withdrawn = {
...detail, state: 'withdrawn', withdrawn_at: '2026-09-18T12:00:00Z',
withdrawal_id: 'spw_target', withdrawal_acknowledged_at: null,
}
let detailReads = 0
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url === '/api/v1/sync/status') {
return response({ phase: 'idle', result: 'ok' }).then((value) => ({
...value, headers: new Headers({ 'X-Trainlog-CSRF-Token': 'a'.repeat(64) }),
}))
}
if (url.includes('/api/v1/sessions/preparation/sp_target') && init?.method === 'DELETE') {
return response({ preparation_id: 'sp_target', revision_id: 'spr_current',
withdrawal_id: 'spw_target', android_cancellation: 'pending' })
}
if (url.includes('/api/v1/sessions/preparation/sp_target')) {
detailReads += 1
return response(detailReads === 1 ? detail : withdrawn)
}
throw new Error(`unexpected request ${url}`)
})
vi.stubGlobal('fetch', fetchMock)
render(<SessionsPage />)
await screen.findByRole('heading', { name: 'Préparation ciblée' })
const deleteTrigger = screen.getByRole('button', { name: 'Supprimer la préparation' })
fireEvent.click(deleteTrigger)
let dialog = screen.getByRole('alertdialog')
expect(dialog).toHaveTextContent('toute exécution commencée sera conservée')
const keepButton = within(dialog).getByRole('button', { name: 'Conserver la préparation' })
expect(keepButton).toHaveFocus()
fireEvent.click(keepButton)
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
await waitFor(() => expect(deleteTrigger).toHaveFocus())
expect(fetchMock.mock.calls.some((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Supprimer la préparation' }))
dialog = screen.getByRole('alertdialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'Supprimer la préparation' }))
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent(
'Supprimée sur le PC — annulation Android à synchroniser.',
))
expect(fetchMock.mock.calls.some((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(true)
expect(screen.queryByRole('button', { name: 'Supprimer la préparation' })).not.toBeInTheDocument()
expect(screen.getByText(/Dérivée de la proposition/)).toHaveTextContent('Proposition source')
})
}) })

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import {
fetchCatalog, fetchCatalog,
fetchSessionDetail, fetchSessionDetail,
@ -6,6 +6,7 @@ import {
prepareForAndroid, prepareForAndroid,
fetchAllSessionPages, fetchAllSessionPages,
savePreparation, savePreparation,
withdrawPreparation,
type CatalogChoice, type CatalogChoice,
type PreparationInput, type PreparationInput,
type PreparationOccurrence, type PreparationOccurrence,
@ -13,12 +14,64 @@ import {
type SessionDetail, type SessionDetail,
type SessionListItem, type SessionListItem,
} from '../api/sessions' } from '../api/sessions'
import { useDatePreferences } from '../presentation/DatePreferences'
import {
formatCivilDate,
formatDateTime,
validDateSortValue,
validTimestampValue,
} from '../presentation/dateFormat'
type View = 'preparation' | 'resume' | 'history' type View = 'preparation' | 'resume' | 'history'
const collections: Record<View, SessionCollection[]> = { const collections: Record<View, SessionCollection[]> = {
preparation: ['preparations', 'proposals'], resume: ['drafts'], history: ['history'], preparation: ['preparations', 'proposals'], resume: ['drafts'], history: ['history'],
} }
export type SessionListEntry = SessionListItem & { collection: SessionCollection }
function compareIdentity(left: SessionListEntry, right: SessionListEntry): number {
return `${left.collection}:${left.identity}`.localeCompare(
`${right.collection}:${right.identity}`, 'en')
}
function compareTimestampDescending(left: string, right: string): number {
const leftTimestamp = validTimestampValue(left)
const rightTimestamp = validTimestampValue(right)
if (leftTimestamp === null && rightTimestamp === null) return 0
if (leftTimestamp === null) return 1
if (rightTimestamp === null) return -1
return rightTimestamp - leftTimestamp
}
export function sortSessionItems(items: readonly SessionListEntry[], view: View): SessionListEntry[] {
return [...items].sort((left, right) => {
if (view === 'preparation') {
const leftDate = validDateSortValue(left.date)
const rightDate = validDateSortValue(right.date)
if (leftDate !== null && rightDate !== null && leftDate !== rightDate) {
return rightDate.localeCompare(leftDate, 'en')
}
if (leftDate === null && rightDate !== null) return 1
if (leftDate !== null && rightDate === null) return -1
}
const timestampOrder = compareTimestampDescending(left.sort_timestamp, right.sort_timestamp)
return timestampOrder !== 0 ? timestampOrder : compareIdentity(left, right)
})
}
export function sessionStateLabel(collection: SessionCollection, state: string): string {
const labels: Record<SessionCollection, Record<string, string>> = {
preparations: {
draft: 'En préparation', ready: 'Prête', local: 'Locale', pending: 'À synchroniser',
acknowledged: 'Reçue', remote_unknown: 'État Android inconnu', withdrawn: 'Supprimée',
},
proposals: { local: 'À valider', pending: 'À valider', published: 'Publiée' },
drafts: { active: 'En cours', pending: 'À reprendre', stale: 'À vérifier' },
history: { completed: 'Terminée' },
}
return labels[collection][state] ?? `État non reconnu (${state})`
}
function parseDecimal(value: string): number | null { function parseDecimal(value: string): number | null {
if (!value.trim()) return null if (!value.trim()) return null
const normalized = value.replace(',', '.') const normalized = value.replace(',', '.')
@ -185,19 +238,87 @@ function Editor({ initial, onSaved, onCancel }: {
function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'proposal'; identity: string; function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'proposal'; identity: string;
onBack: () => void; onEdit: (detail: SessionDetail) => void }) { onBack: () => void; onEdit: (detail: SessionDetail) => void }) {
const { dateFormat } = useDatePreferences()
const deleteTrigger = useRef<HTMLButtonElement>(null)
const cancelDelete = useRef<HTMLButtonElement>(null)
const [detail, setDetail] = useState<SessionDetail | null>(null) const [detail, setDetail] = useState<SessionDetail | null>(null)
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
useEffect(() => { const controller = new AbortController(); setFailed(false) const [confirming, setConfirming] = useState(false)
fetchSessionDetail(kind, identity, controller.signal).then(setDetail).catch(() => setFailed(true)) const [deleting, setDeleting] = useState(false)
const [message, setMessage] = useState('')
const [deleteError, setDeleteError] = useState('')
useEffect(() => { const controller = new AbortController(); setFailed(false); setDetail(null)
fetchSessionDetail(kind, identity, controller.signal).then((value) => {
if (!controller.signal.aborted) setDetail(value)
}).catch(() => {
if (!controller.signal.aborted) setFailed(true)
})
return () => controller.abort() }, [kind, identity]) return () => controller.abort() }, [kind, identity])
useEffect(() => {
if (confirming) cancelDelete.current?.focus()
}, [confirming])
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div> if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p> if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
const withdrawn = detail.state === 'withdrawn'
const remove = async () => {
setDeleting(true)
setDeleteError('')
try {
const result = await withdrawPreparation(detail.identity, detail.revision_id)
setConfirming(false)
setMessage(result.android_cancellation === 'pending'
? 'Supprimée sur le PC — annulation Android à synchroniser.'
: 'Préparation supprimée sur le PC. Aucune livraison Android à annuler.')
setDetail({ ...detail, state: 'withdrawn', withdrawal_id: result.withdrawal_id })
try {
setDetail(await fetchSessionDetail('preparation', detail.identity))
} catch {
setMessage((current) => `${current} Rechargez la fiche pour obtenir l’état synchronisé.`)
}
} catch (reason) {
setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible')
} finally {
setDeleting(false)
}
}
return <article className="session-detail"><div className="sessions-toolbar"> return <article className="session-detail"><div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button> <button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button>
{!withdrawn && <div className="detail-actions">
<button type="button" className="primary-action" onClick={() => onEdit(detail)}> <button type="button" className="primary-action" onClick={() => onEdit(detail)}>
{kind === 'preparation' ? 'Modifier' : 'Préparer à partir de cette proposition'}</button></div> {kind === 'preparation' ? 'Modifier' : 'Préparer à partir de cette proposition'}</button>
{kind === 'preparation' && <button ref={deleteTrigger} type="button" className="danger-action"
onClick={() => setConfirming(true)}>Supprimer la préparation</button>}
</div>}</div>
{message && <p className="success-panel" role="status">{message}</p>}
{deleteError && <p className="form-error" role="alert">{deleteError}</p>}
{confirming && <section className="delete-confirmation" role="alertdialog" aria-modal="true"
aria-labelledby="delete-preparation-title">
<h3 id="delete-preparation-title">Supprimer « {detail.title || 'Sans titre'} » ?</h3>
<p>{detail.state === 'local'
? 'La préparation sera retirée du planning. Elle n’a aucune livraison Android connue.'
: 'La préparation sera retirée du planning. Ses livraisons Android non commencées seront annulées lors de la prochaine synchronisation ; toute exécution commencée sera conservée.'}</p>
<div className="confirmation-actions">
<button ref={cancelDelete} type="button" className="quiet-action" disabled={deleting}
onClick={() => {
setConfirming(false)
requestAnimationFrame(() => deleteTrigger.current?.focus())
}}>Conserver la préparation</button>
<button type="button" className="danger-action" disabled={deleting}
onClick={() => void remove()}>{deleting ? 'Suppression…' : 'Supprimer la préparation'}</button>
</div>
</section>}
<header className="detail-summary"><p className="eyebrow">{kind === 'proposal' ? 'PROPOSITION IA' : 'PRÉPARATION MANUELLE'}</p> <header className="detail-summary"><p className="eyebrow">{kind === 'proposal' ? 'PROPOSITION IA' : 'PRÉPARATION MANUELLE'}</p>
<h2>{detail.title || 'Sans titre'}</h2><p>{detail.planned_for ?? 'Aucune date planifiée'} · {detail.state}</p></header> <h2>{detail.title || 'Sans titre'}</h2><p>
{detail.planned_for === null ? 'Non planifiée' : <time dateTime={detail.planned_for}>
{formatCivilDate(detail.planned_for, dateFormat)}</time>}
{' · '}{sessionStateLabel(kind === 'proposal' ? 'proposals' : 'preparations', detail.state)}
</p>
{detail.withdrawn_at && <p>Retirée le <time dateTime={detail.withdrawn_at}>
{formatDateTime(detail.withdrawn_at, dateFormat)}</time> · {
detail.withdrawal_acknowledged_at
? 'annulation Android consommée'
: 'annulation Android à synchroniser'
}</p>}</header>
{detail.notes && <section className="detail-tile"><h3>Note</h3><p>{detail.notes}</p></section>} {detail.notes && <section className="detail-tile"><h3>Note</h3><p>{detail.notes}</p></section>}
<section className="detail-tile"><h3>Exercices ordonnés</h3><ol className="detail-occurrences"> <section className="detail-tile"><h3>Exercices ordonnés</h3><ol className="detail-occurrences">
{detail.occurrences.map((occurrence) => <li key={occurrence.entry_id}> {detail.occurrences.map((occurrence) => <li key={occurrence.entry_id}>
@ -206,31 +327,44 @@ function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'pro
{occurrence.tracking_mode === 'duration' ? ' s' : ' répétitions'}</span> {occurrence.tracking_mode === 'duration' ? ' s' : ' répétitions'}</span>
<small>{occurrence.load_mode === 'none' ? 'Sans charge' : `${occurrence.load_mode} · ${occurrence.target_weight_kg ?? '—'} kg`}</small> <small>{occurrence.load_mode === 'none' ? 'Sans charge' : `${occurrence.load_mode} · ${occurrence.target_weight_kg ?? '—'} kg`}</small>
</li>)}</ol></section> </li>)}</ol></section>
{detail.source_fingerprint && <section className="detail-tile"><h3>Provenance</h3> {detail.source_proposal_id && <section className="detail-tile"><h3>Provenance</h3>
<code>{detail.source_fingerprint}</code></section>} <p>Dérivée de la proposition <a href={`/seances/proposal/${encodeURIComponent(detail.source_proposal_id)}`}>
« {detail.source_proposal_title || detail.source_proposal_id} »</a>.</p>
{detail.source_fingerprint && <details><summary>Détail technique</summary>
<code>{detail.source_fingerprint}</code></details>}
</section>}
</article> </article>
} }
function ReadonlyDetail({ kind, identity, onBack }: { function ReadonlyDetail({ kind, identity, onBack }: {
kind: 'draft' | 'history'; identity: string; onBack: () => void kind: 'draft' | 'history'; identity: string; onBack: () => void
}) { }) {
const { dateFormat } = useDatePreferences()
const [detail, setDetail] = useState<Record<string, unknown> | null>(null) const [detail, setDetail] = useState<Record<string, unknown> | null>(null)
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
useEffect(() => { const controller = new AbortController(); setFailed(false) useEffect(() => { const controller = new AbortController(); setFailed(false); setDetail(null)
fetchReadonlySessionDetail(kind, identity, controller.signal).then(setDetail).catch(() => setFailed(true)) fetchReadonlySessionDetail(kind, identity, controller.signal).then((value) => {
if (!controller.signal.aborted) setDetail(value)
}).catch(() => {
if (!controller.signal.aborted) setFailed(true)
})
return () => controller.abort() }, [kind, identity]) return () => controller.abort() }, [kind, identity])
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div> if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p> if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
const occurrences = Array.isArray(detail.occurrences) ? detail.occurrences as Array<Record<string, unknown>> : [] const occurrences = Array.isArray(detail.occurrences) ? detail.occurrences as Array<Record<string, unknown>> : []
const payload = typeof detail.payload === 'object' && detail.payload !== null const payload = typeof detail.payload === 'object' && detail.payload !== null
? detail.payload as Record<string, unknown> : null ? detail.payload as Record<string, unknown> : null
const startedAt = typeof detail.started_at === 'string' ? detail.started_at : null
const endedAt = typeof detail.ended_at === 'string' ? detail.ended_at : null
return <article className="session-detail"><div className="sessions-toolbar"> return <article className="session-detail"><div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button></div> <button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button></div>
<header className="detail-summary"><p className="eyebrow">{kind === 'draft' ? 'BROUILLON D’EXÉCUTION' : 'SÉANCE RÉALISÉE'}</p> <header className="detail-summary"><p className="eyebrow">{kind === 'draft' ? 'BROUILLON D’EXÉCUTION' : 'SÉANCE RÉALISÉE'}</p>
<h2>{String(detail.session_type ?? 'Séance')}</h2> <h2>{String(detail.session_type ?? 'Séance')}</h2>
<p>{String(detail.started_at ?? 'Début inconnu')} · {kind === 'history' <p>{startedAt === null ? 'Début inconnu' : <time dateTime={startedAt}>
? detail.ended_at ? `fin ${String(detail.ended_at)}` : 'heure de fin inconnue' {formatDateTime(startedAt, dateFormat)}</time>} · {kind === 'history'
: `à poursuivre sur Android · ${String(detail.state ?? '')}`}</p></header> ? endedAt ? <>fin <time dateTime={endedAt}>{formatDateTime(endedAt, dateFormat)}</time></>
: 'heure de fin inconnue'
: `à poursuivre sur Android · ${sessionStateLabel('drafts', String(detail.state ?? ''))}`}</p></header>
{typeof detail.notes === 'string' && detail.notes && <section className="detail-tile"><h3>Notes</h3><p>{detail.notes}</p></section>} {typeof detail.notes === 'string' && detail.notes && <section className="detail-tile"><h3>Notes</h3><p>{detail.notes}</p></section>}
{kind === 'history' && <section className="detail-tile"><h3>Prévu / Réalisé</h3><ol className="detail-occurrences"> {kind === 'history' && <section className="detail-tile"><h3>Prévu / Réalisé</h3><ol className="detail-occurrences">
{occurrences.map((occurrence) => <li key={String(occurrence.entry_id)}> {occurrences.map((occurrence) => <li key={String(occurrence.entry_id)}>
@ -247,8 +381,9 @@ function ReadonlyDetail({ kind, identity, onBack }: {
} }
export function SessionsPage() { export function SessionsPage() {
const { dateFormat } = useDatePreferences()
const [view, setView] = useState<View>('preparation') const [view, setView] = useState<View>('preparation')
const [items, setItems] = useState<Array<SessionListItem & { collection: SessionCollection }>>([]) const [items, setItems] = useState<SessionListEntry[]>([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [stateFilter, setStateFilter] = useState('') const [stateFilter, setStateFilter] = useState('')
const [dateFrom, setDateFrom] = useState('') const [dateFrom, setDateFrom] = useState('')
@ -257,11 +392,21 @@ export function SessionsPage() {
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
const [editor, setEditor] = useState<SessionDetail | 'new' | null>(null) const [editor, setEditor] = useState<SessionDetail | 'new' | null>(null)
const [detailPath, setDetailPath] = useState(() => window.location.pathname) const [detailPath, setDetailPath] = useState(() => window.location.pathname)
const loadRevision = useRef(0)
const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath]) const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath])
const load = () => { const controller = new AbortController(); setPending(true); setFailed(false) const load = () => { const controller = new AbortController(); const revision = ++loadRevision.current
setPending(true); setFailed(false)
Promise.all(collections[view].map((collection) => fetchAllSessionPages(collection, search, controller.signal) Promise.all(collections[view].map((collection) => fetchAllSessionPages(collection, search, controller.signal)
.then((pageItems) => pageItems.map((item) => ({ ...item, collection }))))) .then((pageItems) => pageItems.map((item) => ({ ...item, collection })))))
.then((pages) => setItems(pages.flat())).catch(() => setFailed(true)).finally(() => setPending(false)) .then((pages) => {
if (revision === loadRevision.current) setItems(sortSessionItems(pages.flat(), view))
}).catch((reason) => {
if (revision === loadRevision.current && !(reason instanceof DOMException && reason.name === 'AbortError')) {
setFailed(true)
}
}).finally(() => {
if (revision === loadRevision.current) setPending(false)
})
return controller } return controller }
useEffect(() => { const controller = load(); return () => controller.abort() }, [view, search]) useEffect(() => { const controller = load(); return () => controller.abort() }, [view, search])
useEffect(() => { const pop = () => setDetailPath(window.location.pathname) useEffect(() => { const pop = () => setDetailPath(window.location.pathname)
@ -273,13 +418,18 @@ export function SessionsPage() {
window.history.pushState(null, '', path); setDetailPath(path) window.history.pushState(null, '', path); setDetailPath(path)
} }
const back = () => { window.history.pushState(null, '', '/seances'); setDetailPath('/seances'); setEditor(null) } const back = () => { window.history.pushState(null, '', '/seances'); setDetailPath('/seances'); setEditor(null) }
const visibleItems = useMemo(() => items.filter((item) => { const visibleItems = useMemo(() => sortSessionItems(items.filter((item) => {
if (stateFilter && item.state !== stateFilter) return false if (stateFilter && item.state !== stateFilter) return false
if (dateFrom && (!item.date || item.date < dateFrom)) return false if (dateFrom && (!item.date || item.date < dateFrom)) return false
if (dateTo && (!item.date || item.date > dateTo)) return false if (dateTo && (!item.date || item.date > dateTo)) return false
return true return true
}), [items, stateFilter, dateFrom, dateTo]) }), view), [items, stateFilter, dateFrom, dateTo, view])
const states = useMemo(() => [...new Set(items.map((item) => item.state))].sort(), [items]) const states = useMemo(() => [...new Set(items.map((item) => item.state))].sort(), [items])
const filterStateLabel = (state: string) => {
const owner = items.find((item) => item.state === state)
return owner === undefined ? `État non reconnu (${state})`
: sessionStateLabel(owner.collection, state)
}
if (editor) return <section className="page"><Editor initial={editor === 'new' ? undefined : editor} if (editor) return <section className="page"><Editor initial={editor === 'new' ? undefined : editor}
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section> onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) { if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
@ -301,17 +451,22 @@ export function SessionsPage() {
onChange={(event) => setSearch(event.target.value)} /></label> onChange={(event) => setSearch(event.target.value)} /></label>
<div className="session-filters" aria-label="Filtres de séances"> <div className="session-filters" aria-label="Filtres de séances">
<label>État<select value={stateFilter} onChange={(event) => setStateFilter(event.target.value)}> <label>État<select value={stateFilter} onChange={(event) => setStateFilter(event.target.value)}>
<option value="">Tous</option>{states.map((state) => <option value={state} key={state}>{state}</option>)} <option value="">Tous</option>{states.map((state) => <option value={state} key={state}>
{filterStateLabel(state)}</option>)}
</select></label> </select></label>
<label>Du<input type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} /></label> <label>Du<input type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} /></label>
<label>Au<input type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} /></label> <label>Au<input type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} /></label>
</div> </div>
<p className="sort-indicator">Date décroissante</p>
{pending && <p aria-live="polite">Chargement…</p>}{failed && <p className="error-panel" role="alert">Les données ne sont pas disponibles.</p>} {pending && <p aria-live="polite">Chargement…</p>}{failed && <p className="error-panel" role="alert">Les données ne sont pas disponibles.</p>}
{!pending && !failed && visibleItems.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>} {!pending && !failed && visibleItems.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
<div className="session-list">{visibleItems.map((item) => <button type="button" className="session-row" <div className="session-list">{visibleItems.map((item) => <button type="button" className="session-row"
key={`${item.collection}-${item.identity}`} onClick={() => open(item.collection, item.identity)}> key={`${item.collection}-${item.identity}`} onClick={() => open(item.collection, item.identity)}>
<span><small>{item.collection === 'proposals' ? 'Proposition IA' : item.collection === 'preparations' ? 'Préparation manuelle' : item.collection === 'drafts' ? 'Brouillon d’exécution' : 'Séance réalisée'}</small> <span><small>{item.collection === 'proposals' ? 'Proposition IA' : item.collection === 'preparations' ? 'Préparation manuelle' : item.collection === 'drafts' ? 'Brouillon d’exécution' : 'Séance réalisée'}</small>
<strong>{item.title || 'Sans titre'}</strong></span><span>{item.date ?? 'Date inconnue'}</span> <strong>{item.title || 'Sans titre'}</strong></span><span>{item.date === null
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span><span>{item.state}</span></button>)}</div> ? item.collection === 'preparations' || item.collection === 'proposals' ? 'Non planifiée' : 'Date inconnue'
: <time dateTime={item.date}>{formatCivilDate(item.date, dateFormat)}</time>}</span>
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span>
<span>{sessionStateLabel(item.collection, item.state)}</span></button>)}</div>
</section> </section>
} }

View file

@ -47,6 +47,16 @@
.sync-details[open] { position: absolute; z-index: 20; right: 0; top: 2.8rem; min-width: 20rem; padding: .8rem; border: 1px solid var(--surface-2); border-radius: .7rem; background: var(--base); box-shadow: 0 .5rem 2rem #0008; } .sync-details[open] { position: absolute; z-index: 20; right: 0; top: 2.8rem; min-width: 20rem; padding: .8rem; border: 1px solid var(--surface-2); border-radius: .7rem; background: var(--base); box-shadow: 0 .5rem 2rem #0008; }
.sync-details h2 { font-size: 1rem; } .sync-details h2 { font-size: 1rem; }
.sync-details ul { padding-left: 1.2rem; } .sync-details ul { padding-left: 1.2rem; }
.display-preferences { position: relative; margin-left: auto; }
.preferences-trigger { min-height: 2.5rem; padding: .55rem .75rem; color: var(--subtext-0); background: transparent; border: 1px solid var(--surface-2); border-radius: .55rem; cursor: pointer; white-space: nowrap; }
.preferences-trigger:focus-visible { outline: 3px solid var(--lavender); outline-offset: 2px; }
.preferences-dialog { position: absolute; z-index: 30; top: 3rem; right: 0; width: min(24rem, calc(100vw - 2rem)); padding: 1rem; color: var(--text); background: var(--base); border: 1px solid var(--surface-2); border-radius: .75rem; box-shadow: 0 .75rem 2.5rem #0009; }
.preferences-heading { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
.preferences-heading h2 { margin-bottom: .75rem; font-size: 1rem; }
.preferences-dialog fieldset { display: grid; gap: .65rem; border: 0; padding: 0; }
.preferences-dialog legend { margin-bottom: .55rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
.preferences-dialog label { display: flex; align-items: center; gap: .55rem; }
.preference-example { margin: .85rem 0 0; color: var(--overlay-1); font-size: var(--font-size-xs); }
.app-main { width: 100%; min-width: 0; padding: clamp(2.25rem, 5vw, 5rem) var(--shell-gutter) clamp(3rem, 7vw, 7rem); } .app-main { width: 100%; min-width: 0; padding: clamp(2.25rem, 5vw, 5rem) var(--shell-gutter) clamp(3rem, 7vw, 7rem); }
.page { width: min(100%, 92rem); min-width: 0; margin: 0 auto; } .page { width: min(100%, 92rem); min-width: 0; margin: 0 auto; }
@ -59,15 +69,20 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.sessions-toolbar { margin-bottom: 1.25rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } .sessions-toolbar { margin-bottom: 1.25rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.detail-actions, .confirmation-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .65rem; }
.session-tabs { display: flex; gap: .4rem; padding: .3rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); } .session-tabs { display: flex; gap: .4rem; padding: .3rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); }
.session-tabs button, .quiet-action, .primary-action, .editor-actions button { min-height: 2.6rem; padding: .6rem .9rem; border: 1px solid transparent; border-radius: .55rem; color: var(--text); background: transparent; cursor: pointer; } .session-tabs button, .quiet-action, .primary-action, .editor-actions button { min-height: 2.6rem; padding: .6rem .9rem; border: 1px solid transparent; border-radius: .55rem; color: var(--text); background: transparent; cursor: pointer; }
.session-tabs button[aria-selected="true"] { color: var(--crust); background: var(--lavender); font-weight: 700; } .session-tabs button[aria-selected="true"] { color: var(--crust); background: var(--lavender); font-weight: 700; }
.primary-action, .editor-actions .primary-action { color: var(--crust); background: var(--lavender); font-weight: 700; } .primary-action, .editor-actions .primary-action { color: var(--crust); background: var(--lavender); font-weight: 700; }
.danger-action { min-height: 2.6rem; padding: .6rem .9rem; color: var(--red); background: transparent; border: 1px solid var(--red); border-radius: .55rem; cursor: pointer; font-weight: 700; }
.danger-action:hover { color: var(--crust); background: var(--red); }
.danger-action:disabled { cursor: not-allowed; opacity: .55; }
.quiet-action { border-color: var(--surface-2); } .quiet-action { border-color: var(--surface-2); }
.session-search { display: grid; gap: .45rem; max-width: 34rem; margin-bottom: 1.5rem; color: var(--subtext-0); font-size: var(--font-size-sm); } .session-search { display: grid; gap: .45rem; max-width: 34rem; margin-bottom: 1.5rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
.session-filters { display: flex; flex-wrap: wrap; gap: .8rem; margin: -.35rem 0 1.25rem; } .session-filters { display: flex; flex-wrap: wrap; gap: .8rem; margin: -.35rem 0 1.25rem; }
.session-filters label { display: grid; min-width: 10rem; gap: .35rem; color: var(--subtext-0); font-size: var(--font-size-xs); } .session-filters label { display: grid; min-width: 10rem; gap: .35rem; color: var(--subtext-0); font-size: var(--font-size-xs); }
.session-filters select, .session-filters input { min-height: 2.5rem; padding: .55rem .7rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .5rem; } .session-filters select, .session-filters input { min-height: 2.5rem; padding: .55rem .7rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .5rem; }
.sort-indicator { width: max-content; margin: -.5rem 0 1rem; padding: .35rem .55rem; color: var(--lavender); background: rgb(180 190 254 / 8%); border-radius: .4rem; font: 650 var(--font-size-xs) var(--font-mono); }
.session-search input, .form-grid input, .form-grid select, .form-grid textarea, .catalog-picker input, .occurrence-fields input, .occurrence-fields select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; border: 1px solid var(--surface-2); border-radius: .5rem; color: var(--text); background: var(--base); font-size: var(--font-size-md); } .session-search input, .form-grid input, .form-grid select, .form-grid textarea, .catalog-picker input, .occurrence-fields input, .occurrence-fields select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; border: 1px solid var(--surface-2); border-radius: .5rem; color: var(--text); background: var(--base); font-size: var(--font-size-md); }
.session-list { display: grid; gap: .6rem; } .session-list { display: grid; gap: .6rem; }
.session-row { width: 100%; padding: 1rem 1.1rem; display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(7rem, 1fr)); gap: 1rem; align-items: center; text-align: left; color: var(--text); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); cursor: pointer; } .session-row { width: 100%; padding: 1rem 1.1rem; display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(7rem, 1fr)); gap: 1rem; align-items: center; text-align: left; color: var(--text); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); cursor: pointer; }
@ -93,6 +108,10 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
.editor-actions button:disabled { cursor: not-allowed; opacity: .55; } .editor-actions button:disabled { cursor: not-allowed; opacity: .55; }
.empty-inline, .error-panel, .form-error { padding: 1rem; border-radius: .65rem; background: var(--base); color: var(--subtext-0); } .empty-inline, .error-panel, .form-error { padding: 1rem; border-radius: .65rem; background: var(--base); color: var(--subtext-0); }
.error-panel, .form-error { border: 1px solid var(--red); color: var(--red); } .error-panel, .form-error { border: 1px solid var(--red); color: var(--red); }
.success-panel { padding: 1rem; border: 1px solid var(--green); border-radius: .65rem; color: var(--green); background: rgb(166 227 161 / 7%); }
.delete-confirmation { margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--red); border-radius: .75rem; background: var(--base); }
.delete-confirmation h3 { margin-top: 0; }
.delete-confirmation p { color: var(--subtext-0); line-height: 1.55; }
.detail-summary, .detail-tile { margin-bottom: 1rem; padding: 1.1rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--base); } .detail-summary, .detail-tile { margin-bottom: 1rem; padding: 1.1rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--base); }
.detail-occurrences li { display: grid; gap: .35rem; } .detail-occurrences li { display: grid; gap: .35rem; }
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); } .detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }
@ -241,8 +260,8 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
} }
@media (max-width: 28rem) { @media (max-width: 28rem) {
.app-shell { padding-bottom: 12.5rem; } .app-shell { padding-bottom: 0; }
.status-footer { grid-template-columns: 1fr; } .status-footer { position: static; grid-template-columns: 1fr; }
.status-zone, .backend-state { grid-column: auto; } .status-zone, .backend-state { grid-column: auto; }
h1 { font-size: 2.3rem; } h1 { font-size: 2.3rem; }
} }