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
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.
Every meaningful draft/form mutation is persisted by the repository. Back,
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
durable flattened exercise-alias mapping used to resolve retired creator IDs.
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
completed-session exchange is the separate strict
`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;
```
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
flattened exercise aliases; v13-v18 add machine metadata, feedback and
revisions, occurrence tracking snapshots, causal profile state, and AI proposal
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
regression coverage.

View file

@ -2,6 +2,22 @@
## 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
history views, stable details, optimistic/idempotent manual preparation,
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:
- `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;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting;

View file

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

View file

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

View file

@ -70,7 +70,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
private val SUPPORTED =
(ANDROID_KINDS.values +
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 }
.toSet()

View file

@ -1884,9 +1884,17 @@ class TrainlogRepository(
val root = try { JSONObject(json) } catch (_: Exception) {
return AiSessionDraftImportResult.Invalid("Artifact de préparations invalide.")
}
if (!root.hasExactKeys(setOf("format", "version", "generated_at", "deliveries")) ||
root.optString("format") != "trainlog-session-preparations" || root.optInt("version") != 1)
return AiSessionDraftImportResult.Invalid("Artifact de préparations v1 non supporté.")
val version = root.optInt("version")
val rootKeys = if (version == 1) {
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")
?: return AiSessionDraftImportResult.Invalid("Livraisons de préparations manquantes.")
if (values.length() > 128) return AiSessionDraftImportResult.Invalid("Trop de préparations.")
@ -1904,8 +1912,10 @@ class TrainlogRepository(
val preparationId = delivery.getString("preparation_id")
val revisionId = delivery.getString("revision_id")
val executionId = delivery.getString("execution_session_id")
require(deliveryId.startsWith("spd_") && preparationId.startsWith("sp_") &&
revisionId.startsWith("spr_") && executionId.startsWith("se_")) { "Identité de préparation invalide." }
require(stableUuidV4Id(deliveryId, "spd_") &&
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." }
val occurrences = delivery.getJSONArray("occurrences")
require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." }
@ -1915,7 +1925,15 @@ class TrainlogRepository(
arrayOf(deliveryId),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) to cursor.getString(1) else null }
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 {
put("delivery_id", deliveryId); put("preparation_id", preparationId)
put("revision_id", revisionId); put("execution_session_id", executionId)
@ -1930,6 +1948,13 @@ class TrainlogRepository(
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)
} catch (error: IllegalArgumentException) {
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> =
buildList {
val identities = mutableSetOf<String>()
@ -6156,6 +6268,13 @@ class TrainlogRepository(
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) &&
runCatching { UUID.fromString(value.removePrefix(prefix)) }.isSuccess
private fun canonicalExerciseId(db:SQLiteDatabase,id:String):String? = db.rawQuery(
@ -8076,7 +8195,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
22,
23,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -8123,6 +8242,7 @@ private class TrainlogDatabaseHelper(
createSyncGenerationTables(db)
createSyncGenerationArchiveTable(db)
createSessionPreparationTables(db)
createSessionPreparationWithdrawalTable(db)
seedEquipment(db)
}
@ -8304,6 +8424,14 @@ private class TrainlogDatabaseHelper(
createSessionPreparationTables(db)
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) {
error(
@ -8423,6 +8551,17 @@ private class TrainlogDatabaseHelper(
"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) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS ai_session_drafts(" +

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
@ -62,6 +63,48 @@ class SessionPreparationExchangeTest {
.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
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
val name = "prepared-${System.nanoTime()}.db"
@ -109,4 +152,183 @@ class SessionPreparationExchangeTest {
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 ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(22, it.getInt(0))
assertEquals(23, it.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())

View file

@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(22, cursor.getInt(0))
assertEquals(23, cursor.getInt(0))
}
db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
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 ->
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`,
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
The Android application is Trainlog's low-friction capture client.

View file

@ -493,7 +493,9 @@ silently rendered as unclassified.
### 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
activating a new transport. Its v15 -> v16
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the

View file

@ -50,8 +50,8 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v23 |
| Android SQLite | schema v22 |
| Desktop SQLite | schema v24 |
| Android SQLite | schema v23 |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only |
| 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,
Programmes and Exercises remain explicit placeholders.
Desktop schema v23 owns immutable manual-preparation revisions, stable ordered
occurrences, persistent HTTP idempotency keys and delivery-to-execution identity.
Android schema v22 stores received preparations separately from AI proposals
and the active singleton. An exact correlated ACK is required before delivery
is shown as acknowledged.
Desktop schema v24 owns immutable manual-preparation revisions, stable ordered
occurrences, persistent HTTP idempotency keys, delivery-to-execution identity,
and durable revision-bound withdrawal records. Android schema v23 stores
received preparations and permanent withdrawal results separately from AI
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
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
`next_session.available` placeholder as a durable inventory. A separate bounded
`GET /api/v1/prepared-items` Core projection distinguishes imported AI
proposals from active or pending execution drafts and exposes stable identity,
state, title/date, occurrence count, and provenance. Reload and later sync
`GET /api/v1/prepared-items` Core projection distinguishes manual preparations,
imported AI proposals, and active or pending execution drafts and exposes stable
identity, state, title/date, occurrence count, and provenance. Reload and later sync
failure do not erase the last successfully read projection. Proposal targets
remain planning data and never alter Dashboard actual-work metrics.

View file

@ -1,5 +1,16 @@
# 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 adds `session_preparations`, immutable

View file

@ -2,8 +2,19 @@
## Manual session preparations
`trainlog-session-preparations` V1 has a strict root `format`, `version`,
`generated_at`, and bounded `deliveries` array. Each delivery contains stable
`trainlog-session-preparations` V2 has a strict root `format`, `version`,
`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,
immutable metadata, and at most 64 ordered target-only occurrences. Null target
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
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
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
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
tile rendering exist. The Dashboard's bounded progression and BODY ZONES
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
implemented without opening the Sessions route, changing schemas, or changing
the frozen Dashboard and exchange contracts.
The corrective prepared-item projection, bounded phase-owned MTP outbox,
Sessions presentation correction, local date preference and durable preparation
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

View file

@ -1,15 +1,26 @@
# 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
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
the complete artifact in the generation transaction and stores it as pending;
start remains an explicit foreground action. Desktop records the exact
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
@ -29,7 +40,7 @@ available because availability is independently guarded by causal state.
## 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
the BODY ZONES companion without wire-format changes. Machine metadata is not
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
replay, immutable successor revisions, stale-revision conflict, duplicate
exercise occurrences, list search and persisted detail serialization. The Web
Vitest suite covers the shell and API parsers; the embedded frontend is also
built into the canonical Meson binary.
exercise occurrences, list search, persisted detail serialization, revision-
guarded withdrawal, conflicting idempotency-key reuse, active-list exclusion,
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
singleton refusal, explicit start, reserved execution identity and absence of
performed facts. `SyncGenerationServiceTest` and
singleton refusal, explicit start, reserved execution identity, schema-v22 to
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
generation validation and 24 bidirectional conversations. It also asserts that
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
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
serializer with a proposal and an execution draft, then proves finalized and
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
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
rewrites the source. Derivation records the exact proposal identity and imported
payload fingerprint. The Android screen labels manual preparations separately
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
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)
inventory = json.loads((output / "candidate-inventory.json").read_text())
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-sync-once").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_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_preparations(preparation_id TEXT PRIMARY KEY,current_revision_id TEXT,delivery_state TEXT);
PRAGMA user_version=23;
CREATE TABLE session_preparations(preparation_id TEXT PRIMARY KEY,current_revision_id TEXT,delivery_state TEXT,withdrawn_at TEXT);
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):
@ -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))
with closing(sqlite3.connect(destination)) as reopened:
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
def test_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):
_,manifest,checksum=self.capture();published=self.publish()
(published/"history.json").write_text("{}")

View file

@ -1,11 +1,13 @@
#!/usr/bin/env python3
"""Exercise Web Sessions through embedded production assets in real Firefox."""
import json
import os
import signal
import socket
import sqlite3
import subprocess
import sys
import tempfile
import time
import unittest
@ -23,7 +25,7 @@ EVIDENCE = ROOT / "docs" / "reviews" / "evidence"
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():
self.skipTest("pinned geckodriver unavailable")
@ -31,11 +33,14 @@ class BrowserSessionsTest(unittest.TestCase):
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.service import Service
from selenium.webdriver.support.ui import WebDriverWait
import websocket
with tempfile.TemporaryDirectory(prefix="trainlog-web-sessions-") as directory:
root = Path(directory)
xdg = root / "data"
config = root / "config"
xdg.mkdir()
config.mkdir()
with socket.socket() as reserved:
reserved.bind(("127.0.0.1", 0))
port = reserved.getsockname()[1]
@ -43,7 +48,7 @@ class BrowserSessionsTest(unittest.TestCase):
environment.update(
{
"XDG_DATA_HOME": str(xdg),
"XDG_CONFIG_HOME": str(root / "config"),
"XDG_CONFIG_HOME": str(config),
"XDG_CACHE_HOME": str(root / "cache"),
}
)
@ -55,6 +60,7 @@ class BrowserSessionsTest(unittest.TestCase):
start_new_session=True,
)
driver = None
bidi = None
try:
for _ in range(200):
with socket.socket() as probe:
@ -64,33 +70,152 @@ class BrowserSessionsTest(unittest.TestCase):
options = webdriver.FirefoxOptions()
options.add_argument("-headless")
options.set_capability("webSocketUrl", True)
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
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")
wait.until(lambda current: "Aucun résultat" in current.page_source)
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()
wait.until(lambda current: "Nouvelle séance" in current.page_source)
title = driver.find_element(By.XPATH, "//label[normalize-space()='Titre']/input")
title.send_keys("Séance navigateur éphémère")
driver.set_window_size(390, 844)
driver.save_screenshot(str(EVIDENCE / "web-sessions-editor-mobile.png"))
save = driver.find_element(By.XPATH, "//button[normalize-space()='Enregistrer']")
planned = driver.find_element(
By.XPATH, "//label[normalize-space()='Date planifiée']/input"
)
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)
save.click()
wait.until(lambda current: "PRÉPARATION MANUELLE" in current.page_source)
driver.refresh()
wait.until(lambda current: "Séance navigateur éphémère" in current.page_source)
driver.set_window_size(1440, 1000)
driver.save_screenshot(str(EVIDENCE / "web-sessions-detail-desktop.png"))
set_viewport(1440, 1000)
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"
with sqlite3.connect(database_path) as database:
row = database.execute(
"SELECT r.title, p.editing_state "
"SELECT r.title, p.editing_state, p.withdrawn_at "
"FROM session_preparations p "
"JOIN session_preparation_revisions r "
"ON r.revision_id = p.current_revision_id"
@ -98,9 +223,37 @@ class BrowserSessionsTest(unittest.TestCase):
entry_count = database.execute(
"SELECT COUNT(*) FROM session_preparation_entries"
).fetchone()[0]
self.assertEqual(row, ("Séance navigateur éphémère", "draft"))
self.assertEqual(entry_count, 0)
withdrawal_count = database.execute(
"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:
if bidi is not None:
bidi.close()
if driver is not None:
driver.quit()
os.killpg(server.pid, signal.SIGTERM)

View file

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

View file

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

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24)
MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_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):
"""Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23):
raise ValueError("schema desktop v18-v23 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23, 24):
raise ValueError("schema desktop v18-v24 requis")
columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns:

View file

@ -28,7 +28,7 @@ def main():
args = parser.parse_args()
connection = connect_database(args.database)
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")
known_equipment = load_supplied_equipment_ids(args.catalog)
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.row_factory = sqlite3.Row
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")
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")

View file

@ -17,8 +17,8 @@ def main():
con = connect_database(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
raise ValueError("schema desktop v12-v23 requis")
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
raise ValueError("schema desktop v12-v24 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"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.row_factory = sqlite3.Row
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")
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")

View file

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

View file

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

View file

@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23):
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")
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)")
if not complete_causal_envelope and schema_version >= 20 and con.execute(
"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_ENTRIES = 64
MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict:
if connection.execute("PRAGMA user_version").fetchone()[0] != 23:
raise ValueError("desktop schema v23 required")
if connection.execute("PRAGMA user_version").fetchone()[0] != 24:
raise ValueError("desktop schema v24 required")
deliveries = []
rows = connection.execute(
"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 "
"JOIN session_preparations p ON p.preparation_id=d.preparation_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 ?",
(MAX_DELIVERIES + 1,),
).fetchall()
@ -56,10 +57,40 @@ def build_export(connection: sqlite3.Connection) -> dict:
"notes": entry[13],
} 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 {
"format": "trainlog-session-preparations", "version": 1,
"format": "trainlog-session-preparations", "version": 2,
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"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()
db=connect_database(args.database)
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")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()

View file

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

View file

@ -187,7 +187,7 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database)
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")
proof_path = args.mobile_export
if proof_path is None:

View file

@ -53,7 +53,7 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""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")
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")

View file

@ -56,7 +56,7 @@ def load(path):
def apply_aliases(con, aliases):
"""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")
for source, canonical in aliases:
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):
"""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")
connection.execute("PRAGMA foreign_keys=ON");grouped={}
# 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):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22,23): 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()
for item in root["exercises"]:
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
# 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(
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)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v23")
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 à v24")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}

View file

@ -52,7 +52,7 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON")
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")
seen=set()
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
).strip(),
"product_version": "0.1.2",
"desktop_schema": 23,
"android_schema": 22,
"desktop_schema": 24,
"android_schema": 23,
"protocols": [
"mobile-export-v3",
"mobile-history-v4",
@ -106,7 +106,7 @@ def main() -> int:
"generation-manifest-v1",
"generation-ack-v1",
"generation-archive-v1",
"session-preparations-v1",
"session-preparations-v2",
],
"entry_points": [
"bin/trainlog",

View file

@ -70,8 +70,8 @@ ARTIFACTS = (
"causal_delete_exchange.py", ("export",)),
("ai-proposals", "trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", False,
"export_ai_session_drafts.py", ()),
("session-preparations", "trainlog-session-preparations", 1,
"session-preparations-v1.json", False, "export_session_preparations.py", ()),
("session-preparations", "trainlog-session-preparations", 2,
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
)
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:
if db.execute("PRAGMA user_version").fetchone()[0] != 23:
raise GenerationError("desktop schema v23 required")
if db.execute("PRAGMA user_version").fetchone()[0] != 24:
raise GenerationError("desktop schema v24 required")
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])
# CONTRACT: the exact generation relation is recorded only after
# 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():
captured_preparations = strict_json(
preparation_path.read_bytes(), MAX_ARTIFACT
)["deliveries"]
for delivery in captured_preparations:
)
for delivery in captured_preparations["deliveries"]:
cursor = db.execute(
"UPDATE session_preparation_deliveries SET generation_id=? "
"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:
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"
if causal_path.is_file():
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')",
(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(
"UPDATE session_preparations SET delivery_state='acknowledged' WHERE EXISTS("
"SELECT 1 FROM session_preparation_deliveries d WHERE "

View file

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

View file

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

View file

@ -40,8 +40,8 @@
* - `bo` for body observations.
* - `sy` for synchronization runs;
* - `sxe` for persisted session-exercise occurrences;
* - `sp`, `spr`, `spe`, and `spd` for preparation, revision, occurrence, and
* delivery identities.
* - `sp`, `spr`, `spe`, `spd`, and `spw` for preparation, revision,
* occurrence, delivery, and withdrawal identities.
*
* Imported v1 documents may contain other schema-valid opaque identifiers;
* 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 {
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;
typedef struct TrainlogWebPreparedItem {
@ -20,6 +21,7 @@ typedef struct TrainlogWebPreparedItem {
char state[32];
char title[TRAINLOG_NAME_MAX + 1U];
char planned_for[11];
char sort_timestamp[TRAINLOG_TIMESTAMP_MAX + 1U];
size_t occurrence_count;
char provenance[32];
} TrainlogWebPreparedItem;

View file

@ -79,4 +79,18 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
char **output_json,
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

View file

@ -47,12 +47,15 @@ if web_assets_enabled
'../web/src/api/health.ts',
'../web/src/api/dashboard.ts',
'../web/src/api/dashboardLayout.ts',
'../web/src/api/preparedItems.ts',
'../web/src/api/sync.ts',
'../web/src/api/sync.test.ts',
'../web/src/api/webPreferences.ts',
'../web/src/app/App.tsx',
'../web/src/app/routes.ts',
'../web/src/app/useRoute.ts',
'../web/src/components/EmptyState.tsx',
'../web/src/components/DisplayPreferences.tsx',
'../web/src/components/Footer.tsx',
'../web/src/components/Header.tsx',
'../web/src/components/SyncControl.tsx',
@ -65,6 +68,8 @@ if web_assets_enabled
'../web/src/dashboard/DashboardTiles.tsx',
'../web/src/dashboard/dashboardFormat.ts',
'../web/src/dashboard/dashboardLayout.ts',
'../web/src/presentation/DatePreferences.tsx',
'../web/src/presentation/dateFormat.ts',
'../web/src/routes/DashboardPage.tsx',
'../web/src/routes/PlaceholderPage.tsx',
'../web/src/routes/SessionsPage.tsx',
@ -266,6 +271,7 @@ trainlog_tui_sources = files(
'src/theme.c',
'src/tui.c',
'src/dashboard_layout.c',
'src/web_preferences.c',
'src/web_server.c',
)
trainlog_tui_sources += web_assets_source
@ -376,7 +382,8 @@ test('cli', test_cli)
test_web_server = executable(
'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],
include_directories: trainlog_private_include,
c_args: strict_c_args,
@ -782,6 +789,11 @@ test(
args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'],
timeout: 120,
)
test(
'session_preparation_export',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_session_preparation_export.py'],
)
test(
'generation_archival',
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));"
"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
* added. Keep its version number and add only the nullable lifecycle cursor;
* 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 ||
version == 15 || version == 16 || version == 17 || version == 18 ||
version == 19 || version == 20 || version == 21 || version == 22 ||
version == 23) {
version == 23 || version == 24) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -1798,6 +1814,9 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK && version < 23) {
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) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -1813,7 +1832,7 @@ static TrainlogStatus initialize_or_validate_schema(
set_open_diagnostic(
output_diagnostic,
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,
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 ||
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) {

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[] =
"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' "
"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'),"
"d.created_at,d.draft_id LIMIT ?1;";
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, 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, 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", "ai_import");
@ -98,11 +99,61 @@ fail:
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(
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
static const char SQL[] =
"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 "
"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) "
@ -131,7 +182,8 @@ static TrainlogStatus load_execution_drafts(
!copy_column(statement, 1, item->state, sizeof(item->state)) ||
!copy_column(statement, 2, item->title, sizeof(item->title)) ||
!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;
}
(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) {
return status;
}
status = load_ai_proposals(database, output);
status = load_manual_preparations(database, output);
if (status == TRAINLOG_STATUS_OK) {
status = load_ai_proposals(database, output);
}
if (status == TRAINLOG_STATUS_OK) {
status = load_execution_drafts(database, output);
}

View file

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

View file

@ -24,6 +24,7 @@
#include "trainlog/web_prepared_items.h"
#include "trainlog/web_sessions.h"
#include "trainlog/dashboard_layout.h"
#include "trainlog/web_preferences.h"
#include "web_assets.h"
#define TRAINLOG_HTTP_CONNECTION_LIMIT 32U
@ -45,6 +46,7 @@ typedef struct TrainlogWebContext {
pid_t worker_pid;
char csrf_token[65];
bool invalid_layout_reported;
bool invalid_preferences_reported;
} TrainlogWebContext;
typedef struct TrainlogHttpRequestState {
@ -817,17 +819,19 @@ static enum MHD_Result handle_request(void *closure,
char expected_buffer[TRAINLOG_ID_MAX + 1U];
char identity_buffer[TRAINLOG_ID_MAX + 1U];
bool delivery = false;
bool withdrawal = false;
TrainlogStatus status;
if ((strcmp(url, "/api/v1/sessions/preparations") == 0 &&
strcmp(method, MHD_HTTP_METHOD_POST) != 0) ||
(strcmp(url, "/api/v1/sessions/preparations") != 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,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"POST, PUT");
"POST, PUT, DELETE");
}
if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) {
return queue_json(
@ -858,12 +862,13 @@ static enum MHD_Result handle_request(void *closure,
identity = identity_buffer;
delivery = true;
}
withdrawal = !delivery && strcmp(method, MHD_HTTP_METHOD_DELETE) == 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,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
delivery ? "POST" : "PUT");
delivery ? "POST" : "PUT, DELETE");
}
if (if_match == NULL || if_match[0] != '"') {
return queue_json(connection,
@ -885,6 +890,9 @@ static enum MHD_Result handle_request(void *closure,
if (delivery) {
status = trainlog_web_sessions_deliver_json(
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 {
status = trainlog_web_sessions_save_json(context->database,
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);
}
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) {
TrainlogDashboardLayout layout;
TrainlogDashboardLayout saved;
@ -1165,6 +1273,7 @@ int trainlog_web_server_run(TrainlogDatabase *database,
return -1;
}
context.invalid_layout_reported = false;
context.invalid_preferences_reported = false;
if (!generate_csrf_token(context.csrf_token)) {
web_diagnostic(diagnostic, diagnostic_capacity, "impossible de générer la protection CSRF");
return -1;

View file

@ -23,7 +23,7 @@ static const ListDefinition LISTS[] = {
"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 (?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 "
"pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
"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"
: "SELECT "
"p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,"
"COALESCE(p.source_payload_sha256,''),p.current_revision_id,p.delivery_"
"state FROM session_preparations p JOIN session_preparation_revisions "
"r ON r.revision_id=p.current_revision_id WHERE p.preparation_id=?1";
"COALESCE(p.source_payload_sha256,''),p.current_revision_id,"
"CASE WHEN p.withdrawn_at IS NULL THEN p.delivery_state ELSE 'withdrawn' END,"
"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 =
proposal
? "SELECT "
@ -247,6 +252,23 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
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) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
@ -947,7 +969,8 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
const char *current = NULL;
if (sqlite3_prepare_v2(
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,
&statement,
NULL) != SQLITE_OK ||
@ -966,7 +989,10 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
statement = NULL;
} else if (sqlite3_prepare_v2(
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,
&statement,
NULL) != SQLITE_OK ||
@ -1026,7 +1052,8 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
"UPDATE session_preparations SET "
"current_revision_id=?1,updated_at=?2,editing_state=?3,delivery_"
"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,
&statement,
NULL) != SQLITE_OK ||
@ -1135,7 +1162,8 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
if (sqlite3_prepare_v2(
database->connection,
"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)",
-1,
&statement,
@ -1221,3 +1249,163 @@ delivery_rollback:
*output_size = 0U;
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_database_close(database);database=NULL;
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(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
trainlog_database_close(database);database=NULL;
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_consumed_generations")==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,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic, "migrate database to schema v23: SQLite rc=",
strlen("migrate database to schema v23: SQLite rc=")) == 0);
CHECK(strncmp(diagnostic, "migrate database to schema v24: SQLite rc=",
strlen("migrate database to schema v24: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL);
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=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(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
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(unlink(path) == 0);
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,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
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(sqlite3_open(path, &raw) == SQLITE_OK);

View file

@ -18,6 +18,7 @@
#include "trainlog/database.h"
#include "trainlog/dashboard_layout.h"
#include "trainlog/web_server.h"
#include "trainlog/web_sessions.h"
#include "web_assets.h"
#define CHECK(value) \
@ -149,6 +150,25 @@ static bool response_header(const char *response, const char *name, char *output
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 const char REQUEST_ID[] = "sy_11111111-1111-4111-8111-111111111111";
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 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 large_request[12000];
char request[8192];
@ -184,6 +211,30 @@ static bool test_http_contract(TrainlogDatabase *database) {
pid_t child;
int status;
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");
CHECK(reserve_port(&port, 0) == -2);
child = fork();
@ -243,6 +294,42 @@ static bool test_http_contract(TrainlogDatabase *database) {
response,
sizeof(response)) &&
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,
"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);
CHECK(strstr(response, "\"api_version\":1") != 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,
"POST /api/v1/prepared-items HTTP/1.1\r\nHost: 127.0.0.1\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;
}
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 const char CREATE_BODY[] =
"{\"title\":\"Séance échappée \\\"A\\\"\",\"session_type\":\"training\","
@ -178,6 +205,186 @@ static bool creation_replay_revision_and_pagination(void) {
return true;
}
int main(void) {
return creation_replay_revision_and_pagination() ? 0 : 1;
static bool withdrawal_is_durable_and_preserves_evidence(void) {
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',
partial: false,
items: [
{ identity: 'aid_one', kind: 'ai_proposal', title: 'Plan', planned_for: '2026-09-18', state: 'published', 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: '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', 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'])

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

View file

@ -46,6 +46,11 @@ export interface SessionDetail {
session_type: 'training' | 'max_test'
notes: 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
occurrences: PreparationOccurrence[]
}
@ -221,3 +226,41 @@ export async function prepareForAndroid(identity: string, revision: string): Pro
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))))
render(<App />)
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')
})

View file

@ -9,6 +9,7 @@ import { PlaceholderPage } from '../routes/PlaceholderPage'
import { SessionsPage } from '../routes/SessionsPage'
import type { RouteId } from './routes'
import { useRoute } from './useRoute'
import { DatePreferencesProvider } from '../presentation/DatePreferences'
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.'],
@ -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.'],
}
export function App() {
function AppContent() {
const [route, navigate] = useRoute()
const [health, setHealth] = useState<Health | null>(null)
const [healthPending, setHealthPending] = useState(true)
@ -90,3 +91,7 @@ export function App() {
</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 { formatDateTime, formatDose, formatWeight } from '../dashboard/dashboardFormat'
import { trainlogChartTheme } from './trainlogChartTheme'
import { useDatePreferences } from '../presentation/DatePreferences'
import type { WebDateFormat } from '../api/webPreferences'
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 [
formatDateTime(point.timestamp),
formatDateTime(point.timestamp, dateFormat),
formatWeight(point.weight_kg),
identity.exercise_name,
identity.equipment_label,
@ -62,6 +68,7 @@ export function progressionTooltip(identity: ProgressionIdentity, point: Progres
}
export function ProgressionChart({ identity, points }: ProgressionChartProps) {
const { dateFormat } = useDatePreferences()
const host = useRef<HTMLDivElement>(null)
useEffect(() => {
@ -91,7 +98,7 @@ export function ProgressionChart({ identity, points }: ProgressionChartProps) {
formatter: (params: unknown) => {
const candidate = Array.isArray(params) ? params[0] : params
const datum = (candidate as { data?: ChartDatum } | undefined)?.data
return datum === undefined ? '' : progressionTooltip(identity, datum.point)
return datum === undefined ? '' : progressionTooltip(identity, datum.point, dateFormat)
},
},
xAxis: {
@ -112,7 +119,7 @@ export function ProgressionChart({ identity, points }: ProgressionChartProps) {
const observer = new ResizeObserver(() => chart.resize())
observer.observe(element)
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.`} />
}

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

View file

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

View file

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

View file

@ -20,7 +20,7 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
api_version: 1 as const,
generated_at: '2026-09-17T12:00:00Z',
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(
<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', () => {
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(/Dernière lecture conservée/)).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', () => {
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_expected', kind: 'ai_proposal', title: 'Attendue', planned_for: null, state: 'published', occurrence_count: 6, 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', sort_timestamp: '2026-09-17T12:00:00Z', occurrence_count: 6, provenance: 'ai_import' },
] }} pending={false} failed={false} />)
const expected = document.querySelector('[data-prepared-identity="aid_expected"]')
expect(expected).toHaveTextContent('Attendue')

View file

@ -1,9 +1,12 @@
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 { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat'
import { BodyZoneFigure } from './BodyZoneFigure'
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 })))
@ -16,22 +19,46 @@ export interface NextSessionTileProps {
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({
size,
preparedItems = null,
pending = false,
failed = false,
}: NextSessionTileProps) {
const { dateFormat } = useDatePreferences()
if (preparedItems === null) {
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) {
return <Unavailable text={failed ? 'Données indisponibles' : 'Aucun élément disponible'} />
}
const proposal = item.kind === 'ai_proposal'
const manual = item.kind === 'manual_preparation'
const heading = proposal
? 'Proposition préparée · à valider'
: manual ? 'Préparation manuelle'
: item.state === 'active'
? 'Brouillon d’exécution · à reprendre'
: 'Brouillon d’exécution · en attente'
@ -44,16 +71,25 @@ export function NextSessionTile({
<>
<dl className="fact-list horizontal">
<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>
{preparedItems.items.length > 1 && (
{orderedItems.length > 1 && (
<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>
{preparedItems.items.map((prepared) => (
{orderedItems.map((prepared) => (
<li key={`${prepared.kind}:${prepared.identity}`} data-prepared-identity={prepared.identity}>
{prepared.title || (prepared.kind === 'ai_proposal' ? 'Proposition sans titre' : 'Séance préparée')}
{' · '}{prepared.kind === 'ai_proposal' ? 'proposition' : 'brouillon d’exécution'}
<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')}
</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>
))}
</ul>
@ -68,6 +104,7 @@ export function NextSessionTile({
}
export function ActivityTile({ snapshot, size }: TileDataProps) {
const { dateFormat } = useDatePreferences()
const days = snapshot.data.activity.days
// CONTRACT: these are direct presentation totals of Core-projected counters,
// never an activity or physiological-intensity score.
@ -78,41 +115,46 @@ export function ActivityTile({ snapshot, size }: TileDataProps) {
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="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>
{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>
}
export function ProgressionTile({ snapshot, size }: TileDataProps) {
const { dateFormat } = useDatePreferences()
const progression = snapshot.data.progression
if (!progression.available) return <Unavailable text="Aucune performance comparable disponible" />
const first = progression.points[0]
const last = progression.points.at(-1)
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>
{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' && 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>}
</div>
}
export function LastSessionTile({ snapshot, size }: TileDataProps) {
const { dateFormat } = useDatePreferences()
const session = snapshot.data.last_session
if (!session.available) return <Unavailable text="Aucune séance observable disponible" />
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>
{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>
}
export function MaxRecordsTile({ snapshot, size }: TileDataProps) {
const { dateFormat } = useDatePreferences()
const records = snapshot.data.max_records.records
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
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) {
@ -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 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> }
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' })
const dateTimeFormat = new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
import type { WebDateFormat } from '../api/webPreferences'
import {
formatDate as formatPresentationDate,
formatDateTime as formatPresentationDateTime,
} from '../presentation/dateFormat'
const numberFormat = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 })
function parsed(value: string): Date | null {
const result = new Date(value)
return Number.isNaN(result.valueOf()) ? null : result
}
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 formatDate = (value: string, format: WebDateFormat = 'fr') =>
formatPresentationDate(value, format)
export const formatDateTime = (value: string, format: WebDateFormat = 'fr') =>
formatPresentationDateTime(value, format)
export const formatWeight = (value: number) => `${numberFormat.format(value)} kg`
export const formatDose = (value: number) => numberFormat.format(value)
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 { commandOccurrence, SessionsPage } from './SessionsPage'
import {
commandOccurrence,
sessionStateLabel,
SessionsPage,
sortSessionItems,
type SessionListEntry,
} from './SessionsPage'
function page(kind: string, items: unknown[]) {
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'))
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 {
fetchCatalog,
fetchSessionDetail,
@ -6,6 +6,7 @@ import {
prepareForAndroid,
fetchAllSessionPages,
savePreparation,
withdrawPreparation,
type CatalogChoice,
type PreparationInput,
type PreparationOccurrence,
@ -13,12 +14,64 @@ import {
type SessionDetail,
type SessionListItem,
} from '../api/sessions'
import { useDatePreferences } from '../presentation/DatePreferences'
import {
formatCivilDate,
formatDateTime,
validDateSortValue,
validTimestampValue,
} from '../presentation/dateFormat'
type View = 'preparation' | 'resume' | 'history'
const collections: Record<View, SessionCollection[]> = {
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 {
if (!value.trim()) return null
const normalized = value.replace(',', '.')
@ -185,19 +238,87 @@ function Editor({ initial, onSaved, onCancel }: {
function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'proposal'; identity: string;
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 [failed, setFailed] = useState(false)
useEffect(() => { const controller = new AbortController(); setFailed(false)
fetchSessionDetail(kind, identity, controller.signal).then(setDetail).catch(() => setFailed(true))
const [confirming, setConfirming] = useState(false)
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])
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 (!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">
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button>
<button type="button" className="primary-action" onClick={() => onEdit(detail)}>
{kind === 'preparation' ? 'Modifier' : 'Préparer à partir de cette proposition'}</button></div>
{!withdrawn && <div className="detail-actions">
<button type="button" className="primary-action" onClick={() => onEdit(detail)}>
{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>
<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>}
<section className="detail-tile"><h3>Exercices ordonnés</h3><ol className="detail-occurrences">
{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>
<small>{occurrence.load_mode === 'none' ? 'Sans charge' : `${occurrence.load_mode} · ${occurrence.target_weight_kg ?? '—'} kg`}</small>
</li>)}</ol></section>
{detail.source_fingerprint && <section className="detail-tile"><h3>Provenance</h3>
<code>{detail.source_fingerprint}</code></section>}
{detail.source_proposal_id && <section className="detail-tile"><h3>Provenance</h3>
<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>
}
function ReadonlyDetail({ kind, identity, onBack }: {
kind: 'draft' | 'history'; identity: string; onBack: () => void
}) {
const { dateFormat } = useDatePreferences()
const [detail, setDetail] = useState<Record<string, unknown> | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => { const controller = new AbortController(); setFailed(false)
fetchReadonlySessionDetail(kind, identity, controller.signal).then(setDetail).catch(() => setFailed(true))
useEffect(() => { const controller = new AbortController(); setFailed(false); setDetail(null)
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])
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>
const occurrences = Array.isArray(detail.occurrences) ? detail.occurrences as Array<Record<string, unknown>> : []
const payload = typeof detail.payload === 'object' && detail.payload !== 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">
<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>
<h2>{String(detail.session_type ?? 'Séance')}</h2>
<p>{String(detail.started_at ?? 'Début inconnu')} · {kind === 'history'
? detail.ended_at ? `fin ${String(detail.ended_at)}` : 'heure de fin inconnue'
: `à poursuivre sur Android · ${String(detail.state ?? '')}`}</p></header>
<p>{startedAt === null ? 'Début inconnu' : <time dateTime={startedAt}>
{formatDateTime(startedAt, dateFormat)}</time>} · {kind === 'history'
? 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>}
{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)}>
@ -247,8 +381,9 @@ function ReadonlyDetail({ kind, identity, onBack }: {
}
export function SessionsPage() {
const { dateFormat } = useDatePreferences()
const [view, setView] = useState<View>('preparation')
const [items, setItems] = useState<Array<SessionListItem & { collection: SessionCollection }>>([])
const [items, setItems] = useState<SessionListEntry[]>([])
const [search, setSearch] = useState('')
const [stateFilter, setStateFilter] = useState('')
const [dateFrom, setDateFrom] = useState('')
@ -257,11 +392,21 @@ export function SessionsPage() {
const [failed, setFailed] = useState(false)
const [editor, setEditor] = useState<SessionDetail | 'new' | null>(null)
const [detailPath, setDetailPath] = useState(() => window.location.pathname)
const loadRevision = useRef(0)
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)
.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 }
useEffect(() => { const controller = load(); return () => controller.abort() }, [view, search])
useEffect(() => { const pop = () => setDetailPath(window.location.pathname)
@ -273,13 +418,18 @@ export function SessionsPage() {
window.history.pushState(null, '', path); setDetailPath(path)
}
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 (dateFrom && (!item.date || item.date < dateFrom)) return false
if (dateTo && (!item.date || item.date > dateTo)) return false
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 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}
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
@ -301,17 +451,22 @@ export function SessionsPage() {
onChange={(event) => setSearch(event.target.value)} /></label>
<div className="session-filters" aria-label="Filtres de séances">
<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>
<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>
</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 && !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"
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>
<strong>{item.title || 'Sans titre'}</strong></span><span>{item.date ?? 'Date inconnue'}</span>
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span><span>{item.state}</span></button>)}</div>
<strong>{item.title || 'Sans titre'}</strong></span><span>{item.date === null
? 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>
}

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 h2 { font-size: 1rem; }
.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); }
.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; }
.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 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; }
.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); }
.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 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; }
.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-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; }
@ -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; }
.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); }
.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-occurrences li { display: grid; gap: .35rem; }
.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) {
.app-shell { padding-bottom: 12.5rem; }
.status-footer { grid-template-columns: 1fr; }
.app-shell { padding-bottom: 0; }
.status-footer { position: static; grid-template-columns: 1fr; }
.status-zone, .backend-state { grid-column: auto; }
h1 { font-size: 2.3rem; }
}