Add durable Web preparation withdrawal
|
|
@ -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.
|
||||
|
|
|
|||
16
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
"mobile-history-v4",
|
||||
"execution-draft-v1",
|
||||
"generation-archive-v1",
|
||||
"session-preparations-v2",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(" +
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 103 KiB |
BIN
docs/reviews/evidence/web-sessions-withdrawn-detail-desktop.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
152
tests/test_session_preparation_export.py
Normal 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()
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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("{}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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"]):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ CAPS = {
|
|||
"mobile-history-v4",
|
||||
"execution-draft-v1",
|
||||
"generation-archive-v1",
|
||||
"session-preparations-v2",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
53
tui/include/trainlog/web_preferences.h
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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(¤t, &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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "}");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
48
web/src/api/webPreferences.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
60
web/src/api/webPreferences.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`} />
|
||||
}
|
||||
|
|
|
|||
42
web/src/components/DisplayPreferences.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
72
web/src/presentation/DatePreferences.tsx
Normal 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)
|
||||
}
|
||||
35
web/src/presentation/dateFormat.test.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
88
web/src/presentation/dateFormat.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||