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
|
The Android UI is driven by exercise metadata, never by exercise-name
|
||||||
heuristics.
|
heuristics.
|
||||||
|
|
||||||
Android local SQLite schema v20 retains the exactly-one durable active-session
|
Android local SQLite schema v23 retains the exactly-one durable active-session
|
||||||
draft introduced in v4.
|
draft introduced in v4.
|
||||||
Every meaningful draft/form mutation is persisted by the repository. Back,
|
Every meaningful draft/form mutation is persisted by the repository. Back,
|
||||||
backgrounding and process death never delete the draft. Home offers explicit
|
backgrounding and process death never delete the draft. Home offers explicit
|
||||||
|
|
@ -184,7 +184,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing
|
||||||
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
|
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
|
||||||
durable flattened exercise-alias mapping used to resolve retired creator IDs.
|
durable flattened exercise-alias mapping used to resolve retired creator IDs.
|
||||||
Desktop schema v12 owns the same alias compatibility boundary. Later additive
|
Desktop schema v12 owns the same alias compatibility boundary. Later additive
|
||||||
desktop migrations reach v21 and Android migrations reach v20 without changing
|
desktop migrations reach v24 and Android migrations reach v23 without changing
|
||||||
that identity contract. The active
|
that identity contract. The active
|
||||||
completed-session exchange is the separate strict
|
completed-session exchange is the separate strict
|
||||||
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
|
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
|
||||||
|
|
@ -241,12 +241,13 @@ Desktop SQLite schema is versioned with:
|
||||||
PRAGMA user_version;
|
PRAGMA user_version;
|
||||||
```
|
```
|
||||||
|
|
||||||
The current desktop schema is v21. Schema v9 added an occurrence-owned explicit
|
The current desktop schema is v24. Schema v9 added an occurrence-owned explicit
|
||||||
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
|
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
|
||||||
flattened exercise aliases; v13-v18 add machine metadata, feedback and
|
flattened exercise aliases; v13-v18 add machine metadata, feedback and
|
||||||
revisions, occurrence tracking snapshots, causal profile state, and AI proposal
|
revisions, occurrence tracking snapshots, causal profile state, and AI proposal
|
||||||
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
|
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
|
||||||
generation/ACK ledgers. None changes `TRAINLOG_FORMAT_V1`.
|
generation/ACK ledgers; v22-v24 add generation archival, manual preparations,
|
||||||
|
and durable preparation withdrawal. None changes `TRAINLOG_FORMAT_V1`.
|
||||||
|
|
||||||
Every incompatible schema evolution requires an explicit migration and
|
Every incompatible schema evolution requires an explicit migration and
|
||||||
regression coverage.
|
regression coverage.
|
||||||
|
|
|
||||||
16
CHANGELOG.md
|
|
@ -2,6 +2,22 @@
|
||||||
|
|
||||||
## Unreleased — 0.1.2
|
## Unreleased — 0.1.2
|
||||||
|
|
||||||
|
- Corrected Web Sessions presentation with one descending cross-type order,
|
||||||
|
strict civil-date and zoned-timestamp formatting, contextual French states,
|
||||||
|
readable persisted proposal provenance, and an installation-local versioned
|
||||||
|
French/ISO date preference. The Dashboard prepared-item list, Footer, charts
|
||||||
|
and synchronization timestamps use the same presentation helpers without
|
||||||
|
changing machine values.
|
||||||
|
|
||||||
|
- Added revision-guarded, idempotent **Delete preparation** through the Core
|
||||||
|
service and protected loopback API. Desktop schema v24 retains an immutable
|
||||||
|
withdrawal ledger and all preparation/delivery evidence. The separate
|
||||||
|
`trainlog-session-preparations` V2 generation participant propagates exact
|
||||||
|
withdrawals; Android schema v23 cancels only pending deliveries, preserves
|
||||||
|
started executions and permanently prevents older delivery replay from
|
||||||
|
resurrecting a withdrawn preparation. V1 remains readable and
|
||||||
|
`TRAINLOG_FORMAT_V1` is unchanged.
|
||||||
|
|
||||||
- Implemented `TRAINLOG_WEB_SESSIONS_V1` with paged preparation, resume and
|
- Implemented `TRAINLOG_WEB_SESSIONS_V1` with paged preparation, resume and
|
||||||
history views, stable details, optimistic/idempotent manual preparation,
|
history views, stable details, optimistic/idempotent manual preparation,
|
||||||
explicit proposal derivation, desktop schema v23, Android schema v22 and the
|
explicit proposal derivation, desktop schema v23, Android schema v22 and the
|
||||||
|
|
|
||||||
|
|
@ -318,7 +318,7 @@ not contain cloud credentials and does not run `rclone`.
|
||||||
As of 2026-09-17:
|
As of 2026-09-17:
|
||||||
|
|
||||||
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
|
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
|
||||||
- desktop SQLite schema v23 and Android SQLite schema v22;
|
- desktop SQLite schema v24 and Android SQLite schema v23;
|
||||||
- Notcurses is the only active desktop terminal backend;
|
- Notcurses is the only active desktop terminal backend;
|
||||||
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
||||||
all-files access setting;
|
all-files access setting;
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ internal class AndroidBackupService(private val context: Context) {
|
||||||
manifest.getInt("version") == VERSION &&
|
manifest.getInt("version") == VERSION &&
|
||||||
manifest.getString("package") == context.packageName &&
|
manifest.getString("package") == context.packageName &&
|
||||||
manifest.getBoolean("plaintext") &&
|
manifest.getBoolean("plaintext") &&
|
||||||
manifest.getInt("schema") in 17..22
|
manifest.getInt("schema") in 17..23
|
||||||
) {
|
) {
|
||||||
"Unsupported backup."
|
"Unsupported backup."
|
||||||
}
|
}
|
||||||
|
|
@ -313,7 +313,7 @@ internal class AndroidBackupService(private val context: Context) {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||||
require(db.version in 17..22) { "Unsupported Android schema ${db.version}." }
|
require(db.version in 17..23) { "Unsupported Android schema ${db.version}." }
|
||||||
return db.version
|
return db.version
|
||||||
} finally {
|
} finally {
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
||||||
"mobile-history-v4",
|
"mobile-history-v4",
|
||||||
"execution-draft-v1",
|
"execution-draft-v1",
|
||||||
"generation-archive-v1",
|
"generation-archive-v1",
|
||||||
|
"session-preparations-v2",
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
private val SUPPORTED =
|
private val SUPPORTED =
|
||||||
(ANDROID_KINDS.values +
|
(ANDROID_KINDS.values +
|
||||||
Kind("trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", false) +
|
Kind("trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", false) +
|
||||||
Kind("trainlog-session-preparations", 1, "session-preparations-v1.json", false))
|
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false))
|
||||||
.map { it.format to it.version }
|
.map { it.format to it.version }
|
||||||
.toSet()
|
.toSet()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1884,9 +1884,17 @@ class TrainlogRepository(
|
||||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||||
return AiSessionDraftImportResult.Invalid("Artifact de préparations invalide.")
|
return AiSessionDraftImportResult.Invalid("Artifact de préparations invalide.")
|
||||||
}
|
}
|
||||||
if (!root.hasExactKeys(setOf("format", "version", "generated_at", "deliveries")) ||
|
val version = root.optInt("version")
|
||||||
root.optString("format") != "trainlog-session-preparations" || root.optInt("version") != 1)
|
val rootKeys = if (version == 1) {
|
||||||
return AiSessionDraftImportResult.Invalid("Artifact de préparations v1 non supporté.")
|
setOf("format", "version", "generated_at", "deliveries")
|
||||||
|
} else {
|
||||||
|
setOf("format", "version", "generated_at", "deliveries", "withdrawals")
|
||||||
|
}
|
||||||
|
if (!root.hasExactKeys(rootKeys) ||
|
||||||
|
root.optString("format") != "trainlog-session-preparations" ||
|
||||||
|
version !in setOf(1, 2)) {
|
||||||
|
return AiSessionDraftImportResult.Invalid("Artifact de préparations non supporté.")
|
||||||
|
}
|
||||||
val values = root.optJSONArray("deliveries")
|
val values = root.optJSONArray("deliveries")
|
||||||
?: return AiSessionDraftImportResult.Invalid("Livraisons de préparations manquantes.")
|
?: return AiSessionDraftImportResult.Invalid("Livraisons de préparations manquantes.")
|
||||||
if (values.length() > 128) return AiSessionDraftImportResult.Invalid("Trop de préparations.")
|
if (values.length() > 128) return AiSessionDraftImportResult.Invalid("Trop de préparations.")
|
||||||
|
|
@ -1904,8 +1912,10 @@ class TrainlogRepository(
|
||||||
val preparationId = delivery.getString("preparation_id")
|
val preparationId = delivery.getString("preparation_id")
|
||||||
val revisionId = delivery.getString("revision_id")
|
val revisionId = delivery.getString("revision_id")
|
||||||
val executionId = delivery.getString("execution_session_id")
|
val executionId = delivery.getString("execution_session_id")
|
||||||
require(deliveryId.startsWith("spd_") && preparationId.startsWith("sp_") &&
|
require(stableUuidV4Id(deliveryId, "spd_") &&
|
||||||
revisionId.startsWith("spr_") && executionId.startsWith("se_")) { "Identité de préparation invalide." }
|
stableUuidV4Id(preparationId, "sp_") &&
|
||||||
|
stableUuidV4Id(revisionId, "spr_") &&
|
||||||
|
stableUuidV4Id(executionId, "se_")) { "Identité de préparation invalide." }
|
||||||
require(delivery.getString("state") in setOf("pending", "remote_unknown")) { "État de livraison invalide." }
|
require(delivery.getString("state") in setOf("pending", "remote_unknown")) { "État de livraison invalide." }
|
||||||
val occurrences = delivery.getJSONArray("occurrences")
|
val occurrences = delivery.getJSONArray("occurrences")
|
||||||
require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." }
|
require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." }
|
||||||
|
|
@ -1915,7 +1925,15 @@ class TrainlogRepository(
|
||||||
arrayOf(deliveryId),
|
arrayOf(deliveryId),
|
||||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) to cursor.getString(1) else null }
|
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) to cursor.getString(1) else null }
|
||||||
val canonical = delivery.toString()
|
val canonical = delivery.toString()
|
||||||
if (existing == null) {
|
val withdrawn = db.rawQuery(
|
||||||
|
"SELECT 1 FROM session_preparation_withdrawals WHERE preparation_id=?",
|
||||||
|
arrayOf(preparationId),
|
||||||
|
).use { cursor -> cursor.moveToFirst() }
|
||||||
|
if (existing == null && withdrawn) {
|
||||||
|
/* INVARIANT: an older generation cannot resurrect a
|
||||||
|
* delivery after its preparation withdrawal was consumed. */
|
||||||
|
skipped++
|
||||||
|
} else if (existing == null) {
|
||||||
db.insertOrThrow("session_preparation_deliveries", null, ContentValues().apply {
|
db.insertOrThrow("session_preparation_deliveries", null, ContentValues().apply {
|
||||||
put("delivery_id", deliveryId); put("preparation_id", preparationId)
|
put("delivery_id", deliveryId); put("preparation_id", preparationId)
|
||||||
put("revision_id", revisionId); put("execution_session_id", executionId)
|
put("revision_id", revisionId); put("execution_session_id", executionId)
|
||||||
|
|
@ -1930,6 +1948,13 @@ class TrainlogRepository(
|
||||||
skipped++
|
skipped++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (version == 2) {
|
||||||
|
val withdrawals = root.getJSONArray("withdrawals")
|
||||||
|
require(withdrawals.length() <= 128) { "Trop de retraits de préparations." }
|
||||||
|
for (withdrawalIndex in 0 until withdrawals.length()) {
|
||||||
|
applySessionPreparationWithdrawal(db, withdrawals.getJSONObject(withdrawalIndex))
|
||||||
|
}
|
||||||
|
}
|
||||||
AiSessionDraftImportResult.Applied(imported, skipped)
|
AiSessionDraftImportResult.Applied(imported, skipped)
|
||||||
} catch (error: IllegalArgumentException) {
|
} catch (error: IllegalArgumentException) {
|
||||||
AiSessionDraftImportResult.Invalid(error.message ?: "Préparation invalide.")
|
AiSessionDraftImportResult.Invalid(error.message ?: "Préparation invalide.")
|
||||||
|
|
@ -1938,6 +1963,93 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies one immutable preparation withdrawal inside the generation
|
||||||
|
* transaction. Pending deliveries become non-startable; started delivery
|
||||||
|
* rows and every execution record are preserved verbatim.
|
||||||
|
*/
|
||||||
|
private fun applySessionPreparationWithdrawal(db: SQLiteDatabase, withdrawal: JSONObject) {
|
||||||
|
require(withdrawal.hasExactKeys(setOf(
|
||||||
|
"withdrawal_id",
|
||||||
|
"preparation_id",
|
||||||
|
"revision_id",
|
||||||
|
"requested_at",
|
||||||
|
"deliveries",
|
||||||
|
))) { "Forme de retrait de préparation invalide." }
|
||||||
|
val withdrawalId = withdrawal.getString("withdrawal_id")
|
||||||
|
val preparationId = withdrawal.getString("preparation_id")
|
||||||
|
val revisionId = withdrawal.getString("revision_id")
|
||||||
|
require(stableUuidV4Id(withdrawalId, "spw_") &&
|
||||||
|
stableUuidV4Id(preparationId, "sp_") &&
|
||||||
|
stableUuidV4Id(revisionId, "spr_")) { "Identité de retrait de préparation invalide." }
|
||||||
|
val deliveries = withdrawal.getJSONArray("deliveries")
|
||||||
|
require(deliveries.length() <= 128) { "Trop de livraisons dans le retrait." }
|
||||||
|
val canonical = withdrawal.toString()
|
||||||
|
val existing = db.rawQuery(
|
||||||
|
"SELECT preparation_id,revision_id,payload_json FROM " +
|
||||||
|
"session_preparation_withdrawals WHERE withdrawal_id=?",
|
||||||
|
arrayOf(withdrawalId),
|
||||||
|
).use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
Triple(cursor.getString(0), cursor.getString(1), cursor.getString(2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require(existing == null ||
|
||||||
|
(existing.first == preparationId && existing.second == revisionId &&
|
||||||
|
JSONObject(existing.third).toString() == canonical)) {
|
||||||
|
"Identité de retrait réutilisée avec un contenu différent."
|
||||||
|
}
|
||||||
|
var result = "no_matching_delivery"
|
||||||
|
for (deliveryIndex in 0 until deliveries.length()) {
|
||||||
|
val delivery = deliveries.getJSONObject(deliveryIndex)
|
||||||
|
require(delivery.hasExactKeys(setOf(
|
||||||
|
"delivery_id",
|
||||||
|
"revision_id",
|
||||||
|
"execution_session_id",
|
||||||
|
))) { "Livraison de retrait invalide." }
|
||||||
|
val deliveryId = delivery.getString("delivery_id")
|
||||||
|
val deliveryRevision = delivery.getString("revision_id")
|
||||||
|
val executionId = delivery.getString("execution_session_id")
|
||||||
|
require(stableUuidV4Id(deliveryId, "spd_") &&
|
||||||
|
stableUuidV4Id(deliveryRevision, "spr_") &&
|
||||||
|
stableUuidV4Id(executionId, "se_")) { "Identité de livraison de retrait invalide." }
|
||||||
|
val state = db.rawQuery(
|
||||||
|
"SELECT state FROM session_preparation_deliveries WHERE delivery_id=? AND " +
|
||||||
|
"preparation_id=? AND revision_id=? AND execution_session_id=?",
|
||||||
|
arrayOf(deliveryId, preparationId, deliveryRevision, executionId),
|
||||||
|
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||||
|
when (state) {
|
||||||
|
"pending" -> {
|
||||||
|
db.execSQL(
|
||||||
|
"UPDATE session_preparation_deliveries SET state='cancelled' " +
|
||||||
|
"WHERE delivery_id=? AND state='pending'",
|
||||||
|
arrayOf(deliveryId),
|
||||||
|
)
|
||||||
|
if (result != "execution_preserved") {
|
||||||
|
result = "pending_cancelled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"cancelled" -> if (result != "execution_preserved") {
|
||||||
|
result = "pending_cancelled"
|
||||||
|
}
|
||||||
|
"started" -> result = "execution_preserved"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (existing == null) {
|
||||||
|
db.insertOrThrow("session_preparation_withdrawals", null, ContentValues().apply {
|
||||||
|
put("withdrawal_id", withdrawalId)
|
||||||
|
put("preparation_id", preparationId)
|
||||||
|
put("revision_id", revisionId)
|
||||||
|
put("requested_at", withdrawal.getString("requested_at"))
|
||||||
|
put("received_at", OffsetDateTime.now(java.time.ZoneOffset.UTC).toString())
|
||||||
|
put("result", result)
|
||||||
|
put("payload_json", canonical)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parsePreparedOccurrences(db: SQLiteDatabase, values: JSONArray): List<SessionExerciseDraft> =
|
private fun parsePreparedOccurrences(db: SQLiteDatabase, values: JSONArray): List<SessionExerciseDraft> =
|
||||||
buildList {
|
buildList {
|
||||||
val identities = mutableSetOf<String>()
|
val identities = mutableSetOf<String>()
|
||||||
|
|
@ -6156,6 +6268,13 @@ class TrainlogRepository(
|
||||||
finally{if(db.inTransaction())db.endTransaction()}
|
finally{if(db.inTransaction())db.endTransaction()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun stableUuidV4Id(value: String, prefix: String): Boolean {
|
||||||
|
if (!value.startsWith(prefix)) return false
|
||||||
|
val suffix = value.removePrefix(prefix)
|
||||||
|
val parsed = runCatching { UUID.fromString(suffix) }.getOrNull() ?: return false
|
||||||
|
return parsed.version() == 4 && parsed.variant() == 2 && parsed.toString() == suffix
|
||||||
|
}
|
||||||
|
|
||||||
private fun stableFeedbackId(value:String,prefix:String):Boolean = value.startsWith(prefix) &&
|
private fun stableFeedbackId(value:String,prefix:String):Boolean = value.startsWith(prefix) &&
|
||||||
runCatching { UUID.fromString(value.removePrefix(prefix)) }.isSuccess
|
runCatching { UUID.fromString(value.removePrefix(prefix)) }.isSuccess
|
||||||
private fun canonicalExerciseId(db:SQLiteDatabase,id:String):String? = db.rawQuery(
|
private fun canonicalExerciseId(db:SQLiteDatabase,id:String):String? = db.rawQuery(
|
||||||
|
|
@ -8076,7 +8195,7 @@ private class TrainlogDatabaseHelper(
|
||||||
appContext,
|
appContext,
|
||||||
databaseName,
|
databaseName,
|
||||||
null,
|
null,
|
||||||
22,
|
23,
|
||||||
) {
|
) {
|
||||||
override fun onConfigure(
|
override fun onConfigure(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -8123,6 +8242,7 @@ private class TrainlogDatabaseHelper(
|
||||||
createSyncGenerationTables(db)
|
createSyncGenerationTables(db)
|
||||||
createSyncGenerationArchiveTable(db)
|
createSyncGenerationArchiveTable(db)
|
||||||
createSessionPreparationTables(db)
|
createSessionPreparationTables(db)
|
||||||
|
createSessionPreparationWithdrawalTable(db)
|
||||||
seedEquipment(db)
|
seedEquipment(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -8304,6 +8424,14 @@ private class TrainlogDatabaseHelper(
|
||||||
createSessionPreparationTables(db)
|
createSessionPreparationTables(db)
|
||||||
version = 22
|
version = 22
|
||||||
}
|
}
|
||||||
|
if (version < 23 && newVersion >= 23) {
|
||||||
|
/* WHY: snapshot omission cannot retract a preparation already
|
||||||
|
* received by Android. CONTRACT: v23 stores immutable withdrawal
|
||||||
|
* identity and the local application result. INVARIANT: no
|
||||||
|
* delivery, active draft, or completed session is removed. */
|
||||||
|
createSessionPreparationWithdrawalTable(db)
|
||||||
|
version = 23
|
||||||
|
}
|
||||||
|
|
||||||
if (version != newVersion) {
|
if (version != newVersion) {
|
||||||
error(
|
error(
|
||||||
|
|
@ -8423,6 +8551,17 @@ private class TrainlogDatabaseHelper(
|
||||||
"session_preparation_deliveries(preparation_id,revision_id);")
|
"session_preparation_deliveries(preparation_id,revision_id);")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createSessionPreparationWithdrawalTable(db: SQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS session_preparation_withdrawals(" +
|
||||||
|
"withdrawal_id TEXT PRIMARY KEY,preparation_id TEXT NOT NULL UNIQUE," +
|
||||||
|
"revision_id TEXT NOT NULL,requested_at TEXT NOT NULL,received_at TEXT NOT NULL," +
|
||||||
|
"result TEXT NOT NULL CHECK(result IN(" +
|
||||||
|
"'pending_cancelled','execution_preserved','no_matching_delivery'))," +
|
||||||
|
"payload_json TEXT NOT NULL);",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createAiSessionDraftTables(db: SQLiteDatabase) {
|
private fun createAiSessionDraftTables(db: SQLiteDatabase) {
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"CREATE TABLE IF NOT EXISTS ai_session_drafts(" +
|
"CREATE TABLE IF NOT EXISTS ai_session_drafts(" +
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
||||||
|
|
||||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(22, scalar(db, "PRAGMA user_version"))
|
assertEquals(23, scalar(db, "PRAGMA user_version"))
|
||||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(22, scalar(db, "PRAGMA user_version"))
|
assertEquals(23, scalar(db, "PRAGMA user_version"))
|
||||||
/* v13 intentionally adds the frozen machine identities and
|
/* v13 intentionally adds the frozen machine identities and
|
||||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(22, scalar(db, "PRAGMA user_version"))
|
assertEquals(23, scalar(db, "PRAGMA user_version"))
|
||||||
assertEquals(before, listOf(
|
assertEquals(before, listOf(
|
||||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
||||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(22, cursor.getInt(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(23, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
|
||||||
null,
|
null,
|
||||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||||
)
|
)
|
||||||
.use { assertEquals(22, it.version) }
|
.use { assertEquals(23, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.labfytools.trainlog.data
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
|
|
@ -62,6 +63,48 @@ class SessionPreparationExchangeTest {
|
||||||
.toString()
|
.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun withdrawalArtifact(): String {
|
||||||
|
val delivery =
|
||||||
|
JSONObject()
|
||||||
|
.put("delivery_id", "spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||||
|
.put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc")
|
||||||
|
.put("execution_session_id", "se_dddddddd-dddd-4ddd-8ddd-dddddddddddd")
|
||||||
|
val withdrawal =
|
||||||
|
JSONObject()
|
||||||
|
.put("withdrawal_id", "spw_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
|
||||||
|
.put("preparation_id", "sp_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
|
||||||
|
.put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc")
|
||||||
|
.put("requested_at", "2026-09-18T12:00:00Z")
|
||||||
|
.put("deliveries", JSONArray().put(delivery))
|
||||||
|
return JSONObject()
|
||||||
|
.put("format", "trainlog-session-preparations")
|
||||||
|
.put("version", 2)
|
||||||
|
.put("generated_at", "2026-09-18T12:01:00Z")
|
||||||
|
.put("deliveries", JSONArray())
|
||||||
|
.put("withdrawals", JSONArray().put(withdrawal))
|
||||||
|
.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scalarText(name: String, sql: String): String {
|
||||||
|
val path = context.getDatabasePath(name)
|
||||||
|
return SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
|
db.rawQuery(sql, null).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
cursor.getString(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scalarInt(name: String, sql: String): Int {
|
||||||
|
val path = context.getDatabasePath(name)
|
||||||
|
return SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
|
db.rawQuery(sql, null).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
cursor.getInt(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
|
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
|
||||||
val name = "prepared-${System.nanoTime()}.db"
|
val name = "prepared-${System.nanoTime()}.db"
|
||||||
|
|
@ -109,4 +152,183 @@ class SessionPreparationExchangeTest {
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun withdrawalCancelsOnlyPendingDeliveryAndPreventsOlderReplayResurrection() {
|
||||||
|
val name = "prepared-withdraw-pending-${System.nanoTime()}.db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
assertTrue(repository.createExercise(NewExerciseProfile(
|
||||||
|
name = "Test pending withdrawal",
|
||||||
|
recordingMode = RecordingMode.SETS,
|
||||||
|
trackingMode = TrackingMode.REPS,
|
||||||
|
dataFields = 0,
|
||||||
|
)) is CreateExerciseResult.Created)
|
||||||
|
val delivery = artifact(repository)
|
||||||
|
val withdrawal = withdrawalArtifact()
|
||||||
|
assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applySessionPreparationsJson(delivery))
|
||||||
|
val invalidWithdrawal = JSONObject(withdrawal)
|
||||||
|
invalidWithdrawal.getJSONArray("withdrawals").getJSONObject(0)
|
||||||
|
.put("withdrawal_id", "spw_invalid")
|
||||||
|
assertTrue(repository.applySessionPreparationsJson(invalidWithdrawal.toString()) is AiSessionDraftImportResult.Invalid)
|
||||||
|
assertEquals(1, repository.listPreparedSessions().size)
|
||||||
|
assertEquals(AiSessionDraftImportResult.Applied(0, 0), repository.applySessionPreparationsJson(withdrawal))
|
||||||
|
assertTrue(repository.listPreparedSessions().isEmpty())
|
||||||
|
assertEquals(
|
||||||
|
StartAiSessionDraftResult.NotPending,
|
||||||
|
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
||||||
|
)
|
||||||
|
assertEquals(AiSessionDraftImportResult.Applied(0, 0), repository.applySessionPreparationsJson(withdrawal))
|
||||||
|
assertEquals(AiSessionDraftImportResult.Applied(0, 1), repository.applySessionPreparationsJson(delivery))
|
||||||
|
assertTrue(repository.listPreparedSessions().isEmpty())
|
||||||
|
repository.close()
|
||||||
|
assertEquals(
|
||||||
|
"cancelled",
|
||||||
|
scalarText(name, "SELECT state FROM session_preparation_deliveries"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"pending_cancelled",
|
||||||
|
scalarText(name, "SELECT result FROM session_preparation_withdrawals"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun withdrawalPreservesStartedDeliveryAndActiveExecutionIdentity() {
|
||||||
|
val name = "prepared-withdraw-started-${System.nanoTime()}.db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
assertTrue(repository.createExercise(NewExerciseProfile(
|
||||||
|
name = "Test started withdrawal",
|
||||||
|
recordingMode = RecordingMode.SETS,
|
||||||
|
trackingMode = TrackingMode.REPS,
|
||||||
|
dataFields = 0,
|
||||||
|
)) is CreateExerciseResult.Created)
|
||||||
|
assertEquals(
|
||||||
|
AiSessionDraftImportResult.Applied(1, 0),
|
||||||
|
repository.applySessionPreparationsJson(artifact(repository)),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
StartAiSessionDraftResult.Started,
|
||||||
|
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
||||||
|
)
|
||||||
|
val before = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
assertEquals(AiSessionDraftImportResult.Applied(0, 0), repository.applySessionPreparationsJson(withdrawalArtifact()))
|
||||||
|
val after = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
assertEquals(before.draft.sessionId, after.draft.sessionId)
|
||||||
|
assertEquals("se_dddddddd-dddd-4ddd-8ddd-dddddddddddd", after.draft.sessionId)
|
||||||
|
repository.close()
|
||||||
|
assertEquals(
|
||||||
|
"started",
|
||||||
|
scalarText(name, "SELECT state FROM session_preparation_deliveries"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"execution_preserved",
|
||||||
|
scalarText(name, "SELECT result FROM session_preparation_withdrawals"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun version22MigrationAddsWithdrawalLedgerWithoutChangingDeliveries() {
|
||||||
|
val name = "prepared-withdraw-migration-${System.nanoTime()}.db"
|
||||||
|
var repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
assertTrue(repository.createExercise(NewExerciseProfile(
|
||||||
|
name = "Test withdrawal migration",
|
||||||
|
recordingMode = RecordingMode.SETS,
|
||||||
|
trackingMode = TrackingMode.REPS,
|
||||||
|
dataFields = 0,
|
||||||
|
)) is CreateExerciseResult.Created)
|
||||||
|
val delivery = artifact(repository)
|
||||||
|
assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applySessionPreparationsJson(delivery))
|
||||||
|
repository.close()
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).absolutePath,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READWRITE,
|
||||||
|
).use { db ->
|
||||||
|
db.execSQL("DROP TABLE session_preparation_withdrawals")
|
||||||
|
db.execSQL("PRAGMA user_version=22")
|
||||||
|
}
|
||||||
|
repository = TrainlogRepository(context, name)
|
||||||
|
assertEquals(1, repository.listPreparedSessions().size)
|
||||||
|
repository.close()
|
||||||
|
assertEquals("23", scalarText(name, "PRAGMA user_version"))
|
||||||
|
assertEquals(
|
||||||
|
"0",
|
||||||
|
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun withdrawalCancelsAllPendingTargetDeliveriesButPreservesStartedAndWitnessRows() {
|
||||||
|
val name = "prepared-withdraw-multiple-${System.nanoTime()}.db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
assertTrue(repository.createExercise(NewExerciseProfile(
|
||||||
|
name = "Test multiple withdrawal",
|
||||||
|
recordingMode = RecordingMode.SETS,
|
||||||
|
trackingMode = TrackingMode.REPS,
|
||||||
|
dataFields = 0,
|
||||||
|
)) is CreateExerciseResult.Created)
|
||||||
|
val deliveryRoot = JSONObject(artifact(repository))
|
||||||
|
val targetTemplate = deliveryRoot.getJSONArray("deliveries").getJSONObject(0)
|
||||||
|
val secondTarget = JSONObject(targetTemplate.toString())
|
||||||
|
.put("delivery_id", "spd_11111111-1111-4111-8111-111111111111")
|
||||||
|
.put("execution_session_id", "se_11111111-1111-4111-8111-111111111111")
|
||||||
|
val witness = JSONObject(targetTemplate.toString())
|
||||||
|
.put("delivery_id", "spd_22222222-2222-4222-8222-222222222222")
|
||||||
|
.put("preparation_id", "sp_22222222-2222-4222-8222-222222222222")
|
||||||
|
.put("revision_id", "spr_22222222-2222-4222-8222-222222222222")
|
||||||
|
.put("execution_session_id", "se_22222222-2222-4222-8222-222222222222")
|
||||||
|
.put("title", "Préparation témoin")
|
||||||
|
deliveryRoot.getJSONArray("deliveries").put(secondTarget).put(witness)
|
||||||
|
assertEquals(
|
||||||
|
AiSessionDraftImportResult.Applied(3, 0),
|
||||||
|
repository.applySessionPreparationsJson(deliveryRoot.toString()),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
StartAiSessionDraftResult.Started,
|
||||||
|
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
||||||
|
)
|
||||||
|
val withdrawalRoot = JSONObject(withdrawalArtifact())
|
||||||
|
withdrawalRoot.getJSONArray("withdrawals").getJSONObject(0)
|
||||||
|
.getJSONArray("deliveries")
|
||||||
|
.put(
|
||||||
|
JSONObject()
|
||||||
|
.put("delivery_id", "spd_11111111-1111-4111-8111-111111111111")
|
||||||
|
.put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc")
|
||||||
|
.put("execution_session_id", "se_11111111-1111-4111-8111-111111111111"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AiSessionDraftImportResult.Applied(0, 0),
|
||||||
|
repository.applySessionPreparationsJson(withdrawalRoot.toString()),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("spd_22222222-2222-4222-8222-222222222222"),
|
||||||
|
repository.listPreparedSessions().map { it.deliveryId },
|
||||||
|
)
|
||||||
|
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
assertEquals("se_dddddddd-dddd-4ddd-8ddd-dddddddddddd", active.draft.sessionId)
|
||||||
|
repository.close()
|
||||||
|
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='started'"))
|
||||||
|
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='cancelled'"))
|
||||||
|
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='pending'"))
|
||||||
|
assertEquals("execution_preserved", scalarText(name, "SELECT result FROM session_preparation_withdrawals"))
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ class SyncGenerationServiceTest {
|
||||||
.use { db ->
|
.use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use {
|
db.rawQuery("PRAGMA user_version", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
assertEquals(22, it.getInt(0))
|
assertEquals(23, it.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(22, cursor.getInt(0))
|
assertEquals(23, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(22, cursor.getInt(0))
|
assertEquals(23, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,14 @@ preparation is inert until explicit start; an occupied singleton is never
|
||||||
overwritten. Starting preserves the desktop-reserved execution `session_id`,
|
overwritten. Starting preserves the desktop-reserved execution `session_id`,
|
||||||
copies targets only and creates no performed facts.
|
copies targets only and creates no performed facts.
|
||||||
|
|
||||||
|
Android schema v23 adds the durable manual-preparation withdrawal ledger.
|
||||||
|
Consumption is part of the outer generation transaction. Exact pending
|
||||||
|
deliveries are changed to `cancelled`; exact started deliveries remain started,
|
||||||
|
and their active or completed execution data is untouched. The stored result is
|
||||||
|
`pending_cancelled`, `execution_preserved`, or `no_matching_delivery`.
|
||||||
|
Withdrawal replay is idempotent, and an older preparation delivery is skipped
|
||||||
|
once the withdrawal identity for that preparation exists.
|
||||||
|
|
||||||
## 1. Purpose
|
## 1. Purpose
|
||||||
|
|
||||||
The Android application is Trainlog's low-friction capture client.
|
The Android application is Trainlog's low-friction capture client.
|
||||||
|
|
|
||||||
|
|
@ -493,7 +493,9 @@ silently rendered as unclassified.
|
||||||
|
|
||||||
### Desktop
|
### Desktop
|
||||||
|
|
||||||
Desktop SQLite schema v23 is canonical long-term history. Its additive v18 ->
|
Desktop SQLite schema v24 is canonical long-term history. Its additive v23 ->
|
||||||
|
v24 migration adds durable manual-preparation withdrawal identity without
|
||||||
|
deleting revisions, deliveries or workout facts. Its additive v18 ->
|
||||||
v19 migration stores causal notes and execution-draft lifecycle state without
|
v19 migration stores causal notes and execution-draft lifecycle state without
|
||||||
activating a new transport. Its v15 -> v16
|
activating a new transport. Its v15 -> v16
|
||||||
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the
|
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,8 @@ parallel implementation of its rules.
|
||||||
| Boundary | Current state |
|
| Boundary | Current state |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||||
| Desktop SQLite | schema v23 |
|
| Desktop SQLite | schema v24 |
|
||||||
| Android SQLite | schema v22 |
|
| Android SQLite | schema v23 |
|
||||||
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
||||||
| Desktop terminal backend | Notcurses only |
|
| Desktop terminal backend | Notcurses only |
|
||||||
| Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` |
|
| Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` |
|
||||||
|
|
@ -222,11 +222,22 @@ resume and history views, stable details, optimistic manual-preparation writes,
|
||||||
explicit proposal derivation and generation-backed Android delivery. Analyse,
|
explicit proposal derivation and generation-backed Android delivery. Analyse,
|
||||||
Programmes and Exercises remain explicit placeholders.
|
Programmes and Exercises remain explicit placeholders.
|
||||||
|
|
||||||
Desktop schema v23 owns immutable manual-preparation revisions, stable ordered
|
Desktop schema v24 owns immutable manual-preparation revisions, stable ordered
|
||||||
occurrences, persistent HTTP idempotency keys and delivery-to-execution identity.
|
occurrences, persistent HTTP idempotency keys, delivery-to-execution identity,
|
||||||
Android schema v22 stores received preparations separately from AI proposals
|
and durable revision-bound withdrawal records. Android schema v23 stores
|
||||||
and the active singleton. An exact correlated ACK is required before delivery
|
received preparations and permanent withdrawal results separately from AI
|
||||||
is shown as acknowledged.
|
proposals and the active singleton. Pending deliveries are cancelled by an
|
||||||
|
exact V2 withdrawal; started executions are preserved. An exact correlated ACK
|
||||||
|
is required before delivery or withdrawal is shown as acknowledged.
|
||||||
|
|
||||||
|
Sessions and the prepared-items Dashboard projection now share descending
|
||||||
|
planned-date presentation with timestamp and stable-identity tie-breaks. Web
|
||||||
|
dates default to French presentation, with an installation-local versioned ISO
|
||||||
|
preference outside business SQLite and synchronization. Contextual French state
|
||||||
|
labels and persisted proposal provenance replace raw enums and digest-only
|
||||||
|
presentation. The preparation detail exposes a protected, idempotent **Delete
|
||||||
|
preparation** action; withdrawn rows remain technically inspectable but cannot
|
||||||
|
be edited or delivered.
|
||||||
|
|
||||||
The controlled deployment preserved the installed Android signing identity and
|
The controlled deployment preserved the installed Android signing identity and
|
||||||
advanced its private `versionCode` without uninstalling or clearing data. The
|
advanced its private `versionCode` without uninstalling or clearing data. The
|
||||||
|
|
@ -237,9 +248,9 @@ performed fact, or active-draft replacement.
|
||||||
|
|
||||||
The Next Session tile no longer treats the frozen Dashboard
|
The Next Session tile no longer treats the frozen Dashboard
|
||||||
`next_session.available` placeholder as a durable inventory. A separate bounded
|
`next_session.available` placeholder as a durable inventory. A separate bounded
|
||||||
`GET /api/v1/prepared-items` Core projection distinguishes imported AI
|
`GET /api/v1/prepared-items` Core projection distinguishes manual preparations,
|
||||||
proposals from active or pending execution drafts and exposes stable identity,
|
imported AI proposals, and active or pending execution drafts and exposes stable
|
||||||
state, title/date, occurrence count, and provenance. Reload and later sync
|
identity, state, title/date, occurrence count, and provenance. Reload and later sync
|
||||||
failure do not erase the last successfully read projection. Proposal targets
|
failure do not erase the last successfully read projection. Proposal targets
|
||||||
remain planning data and never alter Dashboard actual-work metrics.
|
remain planning data and never alter Dashboard actual-work metrics.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
# Desktop database
|
# Desktop database
|
||||||
|
|
||||||
|
## Schema v24: durable preparation withdrawal
|
||||||
|
|
||||||
|
Schema v24 adds nullable `session_preparations.withdrawn_at` and the immutable
|
||||||
|
`session_preparation_withdrawals` ledger. Each preparation has at most one
|
||||||
|
withdrawal identity, bound to the exact current revision, request timestamp,
|
||||||
|
generation and correlated ACK. The service transaction inserts this ledger row
|
||||||
|
and withdraws the preparation together. It does not delete or rewrite revision,
|
||||||
|
entry, delivery, proposal, execution, session, feedback or acknowledgement
|
||||||
|
evidence. A withdrawn preparation is excluded from active projections and
|
||||||
|
cannot be edited or delivered again.
|
||||||
|
|
||||||
## Schema v23: Web session preparations
|
## Schema v23: Web session preparations
|
||||||
|
|
||||||
Schema v23 adds `session_preparations`, immutable
|
Schema v23 adds `session_preparations`, immutable
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,19 @@
|
||||||
|
|
||||||
## Manual session preparations
|
## Manual session preparations
|
||||||
|
|
||||||
`trainlog-session-preparations` V1 has a strict root `format`, `version`,
|
`trainlog-session-preparations` V2 has a strict root `format`, `version`,
|
||||||
`generated_at`, and bounded `deliveries` array. Each delivery contains stable
|
`generated_at`, bounded `deliveries`, and bounded `withdrawals`. Delivery
|
||||||
|
entries retain the V1 shape. Each withdrawal contains its stable
|
||||||
|
`spw_<uuid-v4>` identity, the exact preparation and revision, request timestamp,
|
||||||
|
and the bounded exact delivery/revision/reserved-execution identities that the
|
||||||
|
Android consumer may affect. Only a matching local `pending` delivery becomes
|
||||||
|
`cancelled`; a matching `started` delivery and its execution are preserved.
|
||||||
|
The durable Android withdrawal row prevents an older V1 delivery or generation
|
||||||
|
from recreating a startable preparation.
|
||||||
|
|
||||||
|
The readable legacy `trainlog-session-preparations` V1 root has a strict
|
||||||
|
`format`, `version`, `generated_at`, and bounded `deliveries` array. Each
|
||||||
|
delivery contains stable
|
||||||
delivery, preparation, revision, reserved execution and occurrence identities,
|
delivery, preparation, revision, reserved execution and occurrence identities,
|
||||||
immutable metadata, and at most 64 ordered target-only occurrences. Null target
|
immutable metadata, and at most 64 ordered target-only occurrences. Null target
|
||||||
values remain distinct from zero. Performed sets, continuous results, MAX and
|
values remain distinct from zero. Performed sets, continuous results, MAX and
|
||||||
|
|
|
||||||
|
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
|
## Current baseline
|
||||||
|
|
||||||
Desktop schema v23, Android schema v22, Notcurses, direct
|
Desktop schema v24, Android schema v23, Notcurses, direct
|
||||||
`Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body
|
`Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body
|
||||||
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
|
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
|
||||||
implemented. Session Generator V1 is hidden pending V2. AI session-draft
|
implemented. Session Generator V1 is hidden pending V2. AI session-draft
|
||||||
|
|
@ -34,9 +34,11 @@ CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
|
||||||
contract, frozen interactive grid, versioned layout persistence and factual
|
contract, frozen interactive grid, versioned layout persistence and factual
|
||||||
tile rendering exist. The Dashboard's bounded progression and BODY ZONES
|
tile rendering exist. The Dashboard's bounded progression and BODY ZONES
|
||||||
visualizations and Sessions V1 are implemented; the other three Web routes remain placeholders.
|
visualizations and Sessions V1 are implemented; the other three Web routes remain placeholders.
|
||||||
The corrective prepared-item projection and bounded phase-owned MTP outbox are
|
The corrective prepared-item projection, bounded phase-owned MTP outbox,
|
||||||
implemented without opening the Sessions route, changing schemas, or changing
|
Sessions presentation correction, local date preference and durable preparation
|
||||||
the frozen Dashboard and exchange contracts.
|
withdrawal are implemented. The withdrawal uses the separate versioned
|
||||||
|
session-preparations V2 participant and does not change the frozen Dashboard,
|
||||||
|
mobile snapshot or `TRAINLOG_FORMAT_V1` contracts.
|
||||||
|
|
||||||
## Current cursor
|
## Current cursor
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,26 @@
|
||||||
# Synchronization exchange
|
# Synchronization exchange
|
||||||
|
|
||||||
## Manual session preparations V1
|
## Manual session preparations V2
|
||||||
|
|
||||||
`trainlog-session-preparations` V1 is a separate optional desktop-to-Android
|
`trainlog-session-preparations` V2 is a separate optional desktop-to-Android
|
||||||
generation artifact. It carries immutable ready revision snapshots and stable
|
generation artifact. It carries immutable ready revision snapshots and stable
|
||||||
delivery, preparation, occurrence and reserved execution identities. It never
|
delivery, preparation, occurrence and reserved execution identities, plus
|
||||||
|
immutable preparation-withdrawal operations. It never
|
||||||
overloads mobile export, AI proposals or execution-drafts V1. Android validates
|
overloads mobile export, AI proposals or execution-drafts V1. Android validates
|
||||||
the complete artifact in the generation transaction and stores it as pending;
|
the complete artifact in the generation transaction and stores it as pending;
|
||||||
start remains an explicit foreground action. Desktop records the exact
|
start remains an explicit foreground action. Desktop records the exact
|
||||||
generation relation at capture and changes delivery state to acknowledged only
|
generation relation at capture and changes delivery state to acknowledged only
|
||||||
after accepting the correlated durable consumption ACK.
|
after accepting the correlated durable consumption ACK. It similarly records
|
||||||
|
the exact withdrawal-to-generation relation and acknowledges a withdrawal only
|
||||||
|
after Android's whole-generation commit.
|
||||||
|
|
||||||
|
V1 remains readable for delivery replay compatibility but cannot transport a
|
||||||
|
withdrawal. Both peers advertise `session-preparations-v2`; generation mode
|
||||||
|
refuses an older capability set instead of omitting the causal operation. A V2
|
||||||
|
withdrawal lists every desktop-known delivery for its preparation. Android
|
||||||
|
cancels only exact pending matches, preserves exact started matches, stores the
|
||||||
|
result as a tombstone, and refuses later resurrection from an old V1/V2
|
||||||
|
delivery. Neither result deletes an active draft or completed workout.
|
||||||
|
|
||||||
## Causal deletion safety gate
|
## Causal deletion safety gate
|
||||||
|
|
||||||
|
|
@ -29,7 +40,7 @@ available because availability is independently guarded by causal state.
|
||||||
|
|
||||||
## Machine-exercise Phase 1 compatibility
|
## Machine-exercise Phase 1 compatibility
|
||||||
|
|
||||||
Desktop schema v23 and Android schema v22 retain mobile export V3, readable V1/V2 imports,
|
Desktop schema v24 and Android schema v23 retain mobile export V3, readable V1/V2 imports,
|
||||||
equipment definitions V1, equipment associations V2, exercise aliases V1, and
|
equipment definitions V1, equipment associations V2, exercise aliases V1, and
|
||||||
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
||||||
silently added to a frozen artifact: stable exercise IDs and canonical names
|
silently added to a frozen artifact: stable exercise IDs and canonical names
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,25 @@
|
||||||
|
|
||||||
`web_sessions` exercises production preparation creation, durable request
|
`web_sessions` exercises production preparation creation, durable request
|
||||||
replay, immutable successor revisions, stale-revision conflict, duplicate
|
replay, immutable successor revisions, stale-revision conflict, duplicate
|
||||||
exercise occurrences, list search and persisted detail serialization. The Web
|
exercise occurrences, list search, persisted detail serialization, revision-
|
||||||
Vitest suite covers the shell and API parsers; the embedded frontend is also
|
guarded withdrawal, conflicting idempotency-key reuse, active-list exclusion,
|
||||||
built into the canonical Meson binary.
|
and preservation of proposal/revision/delivery evidence. The HTTP test covers
|
||||||
|
the real protected DELETE route and the versioned local preference GET/PUT,
|
||||||
|
including CSRF, ETag, persistence and invalid-file fallback. The Web Vitest
|
||||||
|
suite additionally covers cross-type global ordering beyond 32 rows, timestamp
|
||||||
|
offsets, stable ties, invalid/absent dates, contextual states, confirmation
|
||||||
|
cancel, the DELETE request and preference conflict behavior. Date tests run
|
||||||
|
under `Pacific/Honolulu` and `Pacific/Kiritimati`.
|
||||||
|
|
||||||
`SessionPreparationExchangeTest` covers Android import replay, occupied
|
`SessionPreparationExchangeTest` covers Android import replay, occupied
|
||||||
singleton refusal, explicit start, reserved execution identity and absence of
|
singleton refusal, explicit start, reserved execution identity, schema-v22 to
|
||||||
performed facts. `SyncGenerationServiceTest` and
|
v23 migration, permanent withdrawal replay, pending cancellation, started
|
||||||
|
execution preservation, multi-delivery targeting, witness preservation and
|
||||||
|
absence of performed facts. `test_session_preparation_export.py` proves that a
|
||||||
|
withdrawn preparation is not redelivered, its exact evidence is exported in V2,
|
||||||
|
and an acknowledged withdrawal is not republished. Generation tests bind the
|
||||||
|
withdrawal to one immutable generation and acknowledge it only after the exact
|
||||||
|
durable ACK. `SyncGenerationServiceTest` and
|
||||||
`SyncDeploymentConversationTest` exercise the real optional artifact through
|
`SyncDeploymentConversationTest` exercise the real optional artifact through
|
||||||
generation validation and 24 bidirectional conversations. It also asserts that
|
generation validation and 24 bidirectional conversations. It also asserts that
|
||||||
every foreground conversation emits exactly one fresh daemon request signal;
|
every foreground conversation emits exactly one fresh daemon request signal;
|
||||||
|
|
@ -29,6 +41,14 @@ The C MTP regression keeps a malformed retained generation beside the current
|
||||||
reference and proves that pull downloads only the referenced generation while
|
reference and proves that pull downloads only the referenced generation while
|
||||||
still rejecting a truncated object inside that current generation.
|
still rejecting a truncated object inside that current generation.
|
||||||
|
|
||||||
|
`test_web_sessions_browser.py` runs headless Firefox against the embedded
|
||||||
|
production assets and C HTTP server with private temporary XDG roots. It creates
|
||||||
|
only a synthetic ready preparation and delivery, verifies French then persisted
|
||||||
|
ISO presentation, captures desktop and an exact BiDi-emulated 390x844 viewport,
|
||||||
|
performs the real confirmation click, checks the schema-v24 withdrawal ledger
|
||||||
|
after reload, and validates the resulting V2 export evidence. Android repository
|
||||||
|
tests exercise the actual V2 consumer rather than a permissive protocol mock.
|
||||||
|
|
||||||
`web_prepared_items` exercises the production SQLite projection and JSON
|
`web_prepared_items` exercises the production SQLite projection and JSON
|
||||||
serializer with a proposal and an execution draft, then proves finalized and
|
serializer with a proposal and an execution draft, then proves finalized and
|
||||||
causally deleted execution drafts are absent. The HTTP test covers the
|
causally deleted execution drafts are absent. The HTTP test covers the
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,51 @@ equipment/load semantics and profile-compatible targets. Draft saves may remain
|
||||||
incomplete. Marking ready validates executable content; preparing for Android
|
incomplete. Marking ready validates executable content; preparing for Android
|
||||||
then creates a distinct immutable delivery and reserved execution identity.
|
then creates a distinct immutable delivery and reserved execution identity.
|
||||||
|
|
||||||
|
Every view has one stable descending presentation order. Preparation merges
|
||||||
|
manual preparations and proposals by valid planned civil date, then factual
|
||||||
|
`sort_timestamp`, then kind and stable identity. Undated or invalidly dated
|
||||||
|
items remain visible after valid dates. Resume uses the execution-draft recency
|
||||||
|
timestamp supplied by Core, and History uses the real start instant; both use
|
||||||
|
stable identity to break exact ties. Search and filters operate on the complete
|
||||||
|
paged result and reapply the same order. Late results from an older view or
|
||||||
|
query cannot replace the current list.
|
||||||
|
|
||||||
|
Web presentation defaults to French civil dates (`DD/MM/YYYY`) and 24-hour
|
||||||
|
times. A local **Display settings** dialog can select ISO dates. The versioned
|
||||||
|
preference is stored atomically with private permissions in
|
||||||
|
`$XDG_CONFIG_HOME/trainlog/web/preferences-v1.json`, falling back to
|
||||||
|
`$HOME/.config`; it is neither business data nor synchronized state. Missing
|
||||||
|
or invalid storage uses the French default, and invalid storage is reported.
|
||||||
|
Civil `YYYY-MM-DD` values are validated and never timezone-shifted. Zoned
|
||||||
|
timestamps preserve their instant and are presented in the browser timezone;
|
||||||
|
the original machine value remains in API data and `<time datetime>`.
|
||||||
|
|
||||||
|
Stored states remain wire-level enums, while Sessions presents contextual
|
||||||
|
French labels. A preparation derived from a proposal exposes the persisted
|
||||||
|
source identity and title as a stable deep link; the source digest remains a
|
||||||
|
technical detail and no title-based inference is performed.
|
||||||
|
|
||||||
Opening or deriving from an AI proposal never accepts, starts, deletes or
|
Opening or deriving from an AI proposal never accepts, starts, deletes or
|
||||||
rewrites the source. Derivation records the exact proposal identity and imported
|
rewrites the source. Derivation records the exact proposal identity and imported
|
||||||
payload fingerprint. The Android screen labels manual preparations separately
|
payload fingerprint. The Android screen labels manual preparations separately
|
||||||
and refuses to overwrite an active draft.
|
and refuses to overwrite an active draft.
|
||||||
|
|
||||||
|
**Delete preparation** is a durable logical withdrawal, not physical evidence
|
||||||
|
purging. The protected DELETE command requires Host/Origin/CSRF, a bounded
|
||||||
|
idempotency key and the exact current revision. One desktop transaction records
|
||||||
|
the immutable withdrawal and makes the preparation unavailable for editing,
|
||||||
|
new delivery, active Sessions lists and the Dashboard projection. Revisions,
|
||||||
|
delivery rows, ACK evidence, the source proposal and all execution identities
|
||||||
|
remain intact.
|
||||||
|
|
||||||
|
The separately versioned `trainlog-session-preparations` V2 artifact carries
|
||||||
|
unacknowledged withdrawals and the exact linked delivery identities. Android
|
||||||
|
schema v23 stores one permanent withdrawal result per preparation. Pending
|
||||||
|
deliveries become `cancelled`; started deliveries, the active draft and all
|
||||||
|
completed facts remain unchanged and are recorded as `execution_preserved`.
|
||||||
|
The tombstone rejects an older delivery replay, including after process restart.
|
||||||
|
Desktop marks a withdrawal acknowledged only after the correlated Android
|
||||||
|
generation ACK. Until then Web reports that Android synchronization is pending.
|
||||||
|
|
||||||
This V1 does not provide live Web capture, historical correction, automatic
|
This V1 does not provide live Web capture, historical correction, automatic
|
||||||
training generation, Programs, Analysis or the standalone Exercises route.
|
training generation, Programs, Analysis or the standalone Exercises route.
|
||||||
|
|
|
||||||
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)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
||||||
self.assertEqual(inventory["product_version"], "0.1.2")
|
self.assertEqual(inventory["product_version"], "0.1.2")
|
||||||
self.assertIn("session-preparations-v1", inventory["protocols"])
|
self.assertIn("session-preparations-v2", inventory["protocols"])
|
||||||
self.assertTrue((output / "bin/trainlog").is_file())
|
self.assertTrue((output / "bin/trainlog").is_file())
|
||||||
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
|
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
|
||||||
self.assertTrue((output / "bin/trainlog-syncd").is_file())
|
self.assertTrue((output / "bin/trainlog-syncd").is_file())
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,9 @@ CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,ru
|
||||||
CREATE TABLE sync_generation_archives(generation_id TEXT PRIMARY KEY,archive_path TEXT UNIQUE,manifest_sha256 TEXT,archive_sha256 TEXT,archived_at TEXT,audit_json TEXT);
|
CREATE TABLE sync_generation_archives(generation_id TEXT PRIMARY KEY,archive_path TEXT UNIQUE,manifest_sha256 TEXT,archive_sha256 TEXT,archived_at TEXT,audit_json TEXT);
|
||||||
CREATE TABLE sync_causal_publications(operation_id TEXT,generation_id TEXT,first_emission INTEGER,PRIMARY KEY(operation_id,generation_id));
|
CREATE TABLE sync_causal_publications(operation_id TEXT,generation_id TEXT,first_emission INTEGER,PRIMARY KEY(operation_id,generation_id));
|
||||||
CREATE TABLE session_preparation_deliveries(delivery_id TEXT PRIMARY KEY,preparation_id TEXT,revision_id TEXT,execution_session_id TEXT,state TEXT,created_at TEXT,generation_id TEXT,acknowledged_at TEXT);
|
CREATE TABLE session_preparation_deliveries(delivery_id TEXT PRIMARY KEY,preparation_id TEXT,revision_id TEXT,execution_session_id TEXT,state TEXT,created_at TEXT,generation_id TEXT,acknowledged_at TEXT);
|
||||||
CREATE TABLE session_preparations(preparation_id TEXT PRIMARY KEY,current_revision_id TEXT,delivery_state TEXT);
|
CREATE TABLE session_preparations(preparation_id TEXT PRIMARY KEY,current_revision_id TEXT,delivery_state TEXT,withdrawn_at TEXT);
|
||||||
PRAGMA user_version=23;
|
CREATE TABLE session_preparation_withdrawals(withdrawal_id TEXT PRIMARY KEY,preparation_id TEXT UNIQUE,revision_id TEXT,requested_at TEXT,generation_id TEXT,acknowledged_at TEXT);
|
||||||
|
PRAGMA user_version=24;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class GenerationTest(unittest.TestCase):
|
class GenerationTest(unittest.TestCase):
|
||||||
|
|
@ -69,6 +70,72 @@ class GenerationTest(unittest.TestCase):
|
||||||
self.assertEqual("acknowledged",generation.accept_ack(self.database,ack_path));self.assertEqual("unchanged",generation.accept_ack(self.database,ack_path))
|
self.assertEqual("acknowledged",generation.accept_ack(self.database,ack_path));self.assertEqual("unchanged",generation.accept_ack(self.database,ack_path))
|
||||||
with closing(sqlite3.connect(destination)) as reopened:
|
with closing(sqlite3.connect(destination)) as reopened:
|
||||||
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
|
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
|
||||||
|
|
||||||
|
def test_withdrawal_generation_relation_is_durable_until_ack(self):
|
||||||
|
withdrawal = "spw_77777777-7777-4777-8777-777777777777"
|
||||||
|
preparation = "sp_88888888-8888-4888-8888-888888888888"
|
||||||
|
revision = "spr_99999999-9999-4999-8999-999999999999"
|
||||||
|
with closing(sqlite3.connect(self.database)) as db:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO session_preparations VALUES(?,?,?,?)",
|
||||||
|
(preparation, revision, "acknowledged", "2026-09-18T12:00:00Z"),
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO session_preparation_withdrawals VALUES(?,?,?,?,NULL,NULL)",
|
||||||
|
(withdrawal, preparation, revision, "2026-09-18T12:00:00Z"),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
artifacts = (("session-preparations", "trainlog-session-preparations", 2,
|
||||||
|
"session-preparations-v2.json", False, "controlled.py", ()),)
|
||||||
|
payload = {
|
||||||
|
"format": "trainlog-session-preparations",
|
||||||
|
"version": 2,
|
||||||
|
"generated_at": "2026-09-18T12:01:00Z",
|
||||||
|
"deliveries": [],
|
||||||
|
"withdrawals": [{
|
||||||
|
"withdrawal_id": withdrawal,
|
||||||
|
"preparation_id": preparation,
|
||||||
|
"revision_id": revision,
|
||||||
|
"requested_at": "2026-09-18T12:00:00Z",
|
||||||
|
"deliveries": [],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
def exporter(_tool, _extra, output, _snapshot):
|
||||||
|
output.write_text(json.dumps(payload))
|
||||||
|
with mock.patch.object(generation, "ARTIFACTS", artifacts), \
|
||||||
|
mock.patch.object(generation, "SUPPORTED", {("trainlog-session-preparations", 2)}), \
|
||||||
|
mock.patch.object(generation, "run_export", exporter):
|
||||||
|
stage, manifest, checksum = generation.capture_desktop(
|
||||||
|
self.database, self.root / "withdrawal", PEER_B, RUN, GEN,
|
||||||
|
)
|
||||||
|
self.assertTrue((stage / "session-preparations-v2.json").is_file())
|
||||||
|
with closing(sqlite3.connect(self.database)) as db:
|
||||||
|
self.assertEqual(
|
||||||
|
(GEN, None),
|
||||||
|
db.execute(
|
||||||
|
"SELECT generation_id,acknowledged_at FROM session_preparation_withdrawals",
|
||||||
|
).fetchone(),
|
||||||
|
)
|
||||||
|
db.execute("UPDATE sync_generations SET status='waiting_acknowledgement' WHERE generation_id=?", (GEN,))
|
||||||
|
db.commit()
|
||||||
|
ack = generation.ack_document(
|
||||||
|
RUN,
|
||||||
|
GEN,
|
||||||
|
manifest["producer"]["peer_id"],
|
||||||
|
PEER_B,
|
||||||
|
checksum,
|
||||||
|
"consumed",
|
||||||
|
generation.now(),
|
||||||
|
)
|
||||||
|
ack_path = self.root / "withdrawal-ack.json"
|
||||||
|
ack_path.write_bytes(generation.canonical(ack))
|
||||||
|
self.assertEqual("acknowledged", generation.accept_ack(self.database, ack_path))
|
||||||
|
with closing(sqlite3.connect(self.database)) as db:
|
||||||
|
self.assertIsNotNone(
|
||||||
|
db.execute(
|
||||||
|
"SELECT acknowledged_at FROM session_preparation_withdrawals",
|
||||||
|
).fetchone()[0],
|
||||||
|
)
|
||||||
def test_substitution_and_transaction_failure_leave_no_consumption(self):
|
def test_substitution_and_transaction_failure_leave_no_consumption(self):
|
||||||
_,manifest,checksum=self.capture();published=self.publish()
|
_,manifest,checksum=self.capture();published=self.publish()
|
||||||
(published/"history.json").write_text("{}")
|
(published/"history.json").write_text("{}")
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Exercise Web Sessions through embedded production assets in real Firefox."""
|
"""Exercise Web Sessions through embedded production assets in real Firefox."""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
|
@ -23,7 +25,7 @@ EVIDENCE = ROOT / "docs" / "reviews" / "evidence"
|
||||||
|
|
||||||
|
|
||||||
class BrowserSessionsTest(unittest.TestCase):
|
class BrowserSessionsTest(unittest.TestCase):
|
||||||
def test_create_reload_detail_and_mobile_editor(self):
|
def test_create_preferences_reload_and_withdraw_by_real_click(self):
|
||||||
if not GECKO.is_file():
|
if not GECKO.is_file():
|
||||||
self.skipTest("pinned geckodriver unavailable")
|
self.skipTest("pinned geckodriver unavailable")
|
||||||
|
|
||||||
|
|
@ -31,11 +33,14 @@ class BrowserSessionsTest(unittest.TestCase):
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.firefox.service import Service
|
from selenium.webdriver.firefox.service import Service
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
import websocket
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(prefix="trainlog-web-sessions-") as directory:
|
with tempfile.TemporaryDirectory(prefix="trainlog-web-sessions-") as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
xdg = root / "data"
|
xdg = root / "data"
|
||||||
|
config = root / "config"
|
||||||
xdg.mkdir()
|
xdg.mkdir()
|
||||||
|
config.mkdir()
|
||||||
with socket.socket() as reserved:
|
with socket.socket() as reserved:
|
||||||
reserved.bind(("127.0.0.1", 0))
|
reserved.bind(("127.0.0.1", 0))
|
||||||
port = reserved.getsockname()[1]
|
port = reserved.getsockname()[1]
|
||||||
|
|
@ -43,7 +48,7 @@ class BrowserSessionsTest(unittest.TestCase):
|
||||||
environment.update(
|
environment.update(
|
||||||
{
|
{
|
||||||
"XDG_DATA_HOME": str(xdg),
|
"XDG_DATA_HOME": str(xdg),
|
||||||
"XDG_CONFIG_HOME": str(root / "config"),
|
"XDG_CONFIG_HOME": str(config),
|
||||||
"XDG_CACHE_HOME": str(root / "cache"),
|
"XDG_CACHE_HOME": str(root / "cache"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -55,6 +60,7 @@ class BrowserSessionsTest(unittest.TestCase):
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
driver = None
|
driver = None
|
||||||
|
bidi = None
|
||||||
try:
|
try:
|
||||||
for _ in range(200):
|
for _ in range(200):
|
||||||
with socket.socket() as probe:
|
with socket.socket() as probe:
|
||||||
|
|
@ -64,33 +70,152 @@ class BrowserSessionsTest(unittest.TestCase):
|
||||||
|
|
||||||
options = webdriver.FirefoxOptions()
|
options = webdriver.FirefoxOptions()
|
||||||
options.add_argument("-headless")
|
options.add_argument("-headless")
|
||||||
|
options.set_capability("webSocketUrl", True)
|
||||||
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
|
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
|
||||||
wait = WebDriverWait(driver, 15)
|
wait = WebDriverWait(driver, 15)
|
||||||
driver.set_window_size(1440, 1000)
|
bidi = websocket.create_connection(
|
||||||
|
driver.capabilities["webSocketUrl"], suppress_origin=True
|
||||||
|
)
|
||||||
|
command_id = 0
|
||||||
|
|
||||||
|
def bidi_command(method: str, parameters: dict):
|
||||||
|
nonlocal command_id
|
||||||
|
command_id += 1
|
||||||
|
bidi.send(json.dumps({
|
||||||
|
"id": command_id,
|
||||||
|
"method": method,
|
||||||
|
"params": parameters,
|
||||||
|
}))
|
||||||
|
while True:
|
||||||
|
response = json.loads(bidi.recv())
|
||||||
|
if response.get("id") != command_id:
|
||||||
|
continue
|
||||||
|
if response.get("type") != "success":
|
||||||
|
raise AssertionError(response)
|
||||||
|
return response["result"]
|
||||||
|
|
||||||
|
contexts = bidi_command("browsingContext.getTree", {})["contexts"]
|
||||||
|
self.assertEqual(len(contexts), 1)
|
||||||
|
context = contexts[0]["context"]
|
||||||
|
|
||||||
|
def set_viewport(width: int, height: int):
|
||||||
|
bidi_command(
|
||||||
|
"browsingContext.setViewport",
|
||||||
|
{
|
||||||
|
"context": context,
|
||||||
|
"viewport": {"width": width, "height": height},
|
||||||
|
"devicePixelRatio": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
wait.until(
|
||||||
|
lambda current: current.execute_script(
|
||||||
|
"return innerWidth===arguments[0]&&innerHeight===arguments[1]",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
set_viewport(1440, 1000)
|
||||||
driver.get(f"http://127.0.0.1:{port}/seances")
|
driver.get(f"http://127.0.0.1:{port}/seances")
|
||||||
wait.until(lambda current: "Aucun résultat" in current.page_source)
|
wait.until(lambda current: "Aucun résultat" in current.page_source)
|
||||||
EVIDENCE.mkdir(parents=True, exist_ok=True)
|
EVIDENCE.mkdir(parents=True, exist_ok=True)
|
||||||
driver.save_screenshot(str(EVIDENCE / "web-sessions-list-desktop.png"))
|
self.assertTrue(driver.save_screenshot(
|
||||||
|
str(EVIDENCE / "web-sessions-list-desktop.png")
|
||||||
|
))
|
||||||
|
|
||||||
driver.find_element(By.XPATH, "//button[normalize-space()='Nouvelle séance']").click()
|
driver.find_element(By.XPATH, "//button[normalize-space()='Nouvelle séance']").click()
|
||||||
wait.until(lambda current: "Nouvelle séance" in current.page_source)
|
wait.until(lambda current: "Nouvelle séance" in current.page_source)
|
||||||
title = driver.find_element(By.XPATH, "//label[normalize-space()='Titre']/input")
|
title = driver.find_element(By.XPATH, "//label[normalize-space()='Titre']/input")
|
||||||
title.send_keys("Séance navigateur éphémère")
|
title.send_keys("Séance navigateur éphémère")
|
||||||
driver.set_window_size(390, 844)
|
planned = driver.find_element(
|
||||||
driver.save_screenshot(str(EVIDENCE / "web-sessions-editor-mobile.png"))
|
By.XPATH, "//label[normalize-space()='Date planifiée']/input"
|
||||||
save = driver.find_element(By.XPATH, "//button[normalize-space()='Enregistrer']")
|
)
|
||||||
|
driver.execute_script(
|
||||||
|
"const setter=Object.getOwnPropertyDescriptor("
|
||||||
|
"HTMLInputElement.prototype,'value').set;"
|
||||||
|
"setter.call(arguments[0],'2026-09-18');"
|
||||||
|
"arguments[0].dispatchEvent(new Event('input',{bubbles:true}));"
|
||||||
|
"arguments[0].dispatchEvent(new Event('change',{bubbles:true}));",
|
||||||
|
planned,
|
||||||
|
)
|
||||||
|
catalog_choice = wait.until(
|
||||||
|
lambda current: current.find_element(
|
||||||
|
By.CSS_SELECTOR, ".catalog-results button"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
catalog_choice.click()
|
||||||
|
set_viewport(390, 844)
|
||||||
|
self.assertTrue(driver.save_screenshot(
|
||||||
|
str(EVIDENCE / "web-sessions-editor-mobile.png")
|
||||||
|
))
|
||||||
|
save = driver.find_element(
|
||||||
|
By.XPATH,
|
||||||
|
"//button[normalize-space()='Marquer prête / Préparer pour Android']",
|
||||||
|
)
|
||||||
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", save)
|
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", save)
|
||||||
save.click()
|
save.click()
|
||||||
wait.until(lambda current: "PRÉPARATION MANUELLE" in current.page_source)
|
wait.until(lambda current: "PRÉPARATION MANUELLE" in current.page_source)
|
||||||
driver.refresh()
|
driver.refresh()
|
||||||
wait.until(lambda current: "Séance navigateur éphémère" in current.page_source)
|
wait.until(lambda current: "Séance navigateur éphémère" in current.page_source)
|
||||||
driver.set_window_size(1440, 1000)
|
set_viewport(1440, 1000)
|
||||||
driver.save_screenshot(str(EVIDENCE / "web-sessions-detail-desktop.png"))
|
self.assertTrue(driver.save_screenshot(
|
||||||
|
str(EVIDENCE / "web-sessions-detail-desktop.png")
|
||||||
|
))
|
||||||
|
|
||||||
|
self.assertIn("18/09/2026", driver.page_source)
|
||||||
|
driver.find_element(
|
||||||
|
By.XPATH, "//button[normalize-space()='Paramètres d’affichage']"
|
||||||
|
).click()
|
||||||
|
wait.until(lambda current: "Format des dates" in current.page_source)
|
||||||
|
driver.find_element(By.CSS_SELECTOR, "input[value='iso']").click()
|
||||||
|
wait.until(
|
||||||
|
lambda current: current.find_element(
|
||||||
|
By.CSS_SELECTOR, "main time"
|
||||||
|
).text == "2026-09-18"
|
||||||
|
)
|
||||||
|
driver.refresh()
|
||||||
|
wait.until(
|
||||||
|
lambda current: current.find_element(
|
||||||
|
By.CSS_SELECTOR, "main time"
|
||||||
|
).text == "2026-09-18"
|
||||||
|
)
|
||||||
|
preferences = (
|
||||||
|
config / "trainlog" / "web" / "preferences-v1.json"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
self.assertIn('"date_format":"iso"', preferences)
|
||||||
|
|
||||||
|
driver.find_element(
|
||||||
|
By.XPATH, "//button[normalize-space()='Supprimer la préparation']"
|
||||||
|
).click()
|
||||||
|
confirmation = wait.until(
|
||||||
|
lambda current: current.find_element(By.CSS_SELECTOR, "[role='alertdialog']")
|
||||||
|
)
|
||||||
|
self.assertIn("Séance navigateur éphémère", confirmation.text)
|
||||||
|
set_viewport(390, 844)
|
||||||
|
self.assertTrue(driver.save_screenshot(
|
||||||
|
str(EVIDENCE / "web-sessions-delete-confirmation-mobile.png")
|
||||||
|
))
|
||||||
|
confirmation.find_element(
|
||||||
|
By.XPATH, ".//button[normalize-space()='Supprimer la préparation']"
|
||||||
|
).click()
|
||||||
|
wait.until(
|
||||||
|
lambda current: "annulation Android à synchroniser" in current.page_source
|
||||||
|
)
|
||||||
|
set_viewport(1440, 1000)
|
||||||
|
self.assertTrue(driver.save_screenshot(
|
||||||
|
str(EVIDENCE / "web-sessions-withdrawn-detail-desktop.png")
|
||||||
|
))
|
||||||
|
driver.refresh()
|
||||||
|
wait.until(lambda current: "Supprimée" in current.page_source)
|
||||||
|
self.assertNotIn(
|
||||||
|
"Supprimer la préparation",
|
||||||
|
driver.find_element(By.CSS_SELECTOR, "main").text,
|
||||||
|
)
|
||||||
|
|
||||||
database_path = xdg / "trainlog" / "trainlog.db"
|
database_path = xdg / "trainlog" / "trainlog.db"
|
||||||
with sqlite3.connect(database_path) as database:
|
with sqlite3.connect(database_path) as database:
|
||||||
row = database.execute(
|
row = database.execute(
|
||||||
"SELECT r.title, p.editing_state "
|
"SELECT r.title, p.editing_state, p.withdrawn_at "
|
||||||
"FROM session_preparations p "
|
"FROM session_preparations p "
|
||||||
"JOIN session_preparation_revisions r "
|
"JOIN session_preparation_revisions r "
|
||||||
"ON r.revision_id = p.current_revision_id"
|
"ON r.revision_id = p.current_revision_id"
|
||||||
|
|
@ -98,9 +223,37 @@ class BrowserSessionsTest(unittest.TestCase):
|
||||||
entry_count = database.execute(
|
entry_count = database.execute(
|
||||||
"SELECT COUNT(*) FROM session_preparation_entries"
|
"SELECT COUNT(*) FROM session_preparation_entries"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
self.assertEqual(row, ("Séance navigateur éphémère", "draft"))
|
withdrawal_count = database.execute(
|
||||||
self.assertEqual(entry_count, 0)
|
"SELECT COUNT(*) FROM session_preparation_withdrawals"
|
||||||
|
).fetchone()[0]
|
||||||
|
delivery_count = database.execute(
|
||||||
|
"SELECT COUNT(*) FROM session_preparation_deliveries"
|
||||||
|
).fetchone()[0]
|
||||||
|
self.assertEqual(row[:2], ("Séance navigateur éphémère", "ready"))
|
||||||
|
self.assertIsNotNone(row[2])
|
||||||
|
self.assertEqual(entry_count, 1)
|
||||||
|
self.assertEqual(delivery_count, 1)
|
||||||
|
self.assertEqual(withdrawal_count, 1)
|
||||||
|
export_path = root / "session-preparations-v2.json"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(ROOT / "tools" / "export_session_preparations.py"),
|
||||||
|
str(export_path),
|
||||||
|
"--database",
|
||||||
|
str(database_path),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
exported = json.loads(export_path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(exported["deliveries"], [])
|
||||||
|
self.assertEqual(len(exported["withdrawals"]), 1)
|
||||||
|
self.assertEqual(len(exported["withdrawals"][0]["deliveries"]), 1)
|
||||||
finally:
|
finally:
|
||||||
|
if bidi is not None:
|
||||||
|
bidi.close()
|
||||||
if driver is not None:
|
if driver is not None:
|
||||||
driver.quit()
|
driver.quit()
|
||||||
os.killpg(server.pid, signal.SIGTERM)
|
os.killpg(server.pid, signal.SIGTERM)
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ def digest(value) -> str:
|
||||||
|
|
||||||
def require_schema(db: sqlite3.Connection) -> None:
|
def require_schema(db: sqlite3.Connection) -> None:
|
||||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (20, 21, 22, 23):
|
if version not in (20, 21, 22, 23, 24):
|
||||||
raise CausalError(f"desktop schema v20-v23 required, found v{version}")
|
raise CausalError(f"desktop schema v20-v24 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
||||||
|
|
||||||
def require_schema(connection):
|
def require_schema(connection):
|
||||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (19, 20, 21, 22, 23):
|
if version not in (19, 20, 21, 22, 23, 24):
|
||||||
raise LifecycleError(f"desktop schema v19-v23 required, found v{version}")
|
raise LifecycleError(f"desktop schema v19-v24 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def import_document(connection, document, own_transaction=True):
|
def import_document(connection, document, own_transaction=True):
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
||||||
from trainlog_sqlite import configure_connection
|
from trainlog_sqlite import configure_connection
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23)
|
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24)
|
||||||
MEASUREMENT_FIELDS = (
|
MEASUREMENT_FIELDS = (
|
||||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ ID_PREFIX = "aid_"
|
||||||
def ensure_publication_state(connection):
|
def ensure_publication_state(connection):
|
||||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||||
owned_transaction = not connection.in_transaction
|
owned_transaction = not connection.in_transaction
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23, 24):
|
||||||
raise ValueError("schema desktop v18-v23 requis")
|
raise ValueError("schema desktop v18-v24 requis")
|
||||||
columns = {row[1] for row in connection.execute(
|
columns = {row[1] for row in connection.execute(
|
||||||
"PRAGMA table_info(ai_session_drafts)")}
|
"PRAGMA table_info(ai_session_drafts)")}
|
||||||
if "published_at" not in columns:
|
if "published_at" not in columns:
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
|
||||||
raise ValueError("schema desktop v8 à v16 requis")
|
raise ValueError("schema desktop v8 à v16 requis")
|
||||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||||
known_equipment.update(row[0] for row in connection.execute(
|
known_equipment.update(row[0] for row in connection.execute(
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
|
||||||
raise ValueError("schema desktop v8 à v16 requis")
|
raise ValueError("schema desktop v8 à v16 requis")
|
||||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||||
raise ValueError("causal equipment protection requires the staged artifact")
|
raise ValueError("causal equipment protection requires the staged artifact")
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ def main():
|
||||||
con = connect_database(args.database)
|
con = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
|
||||||
raise ValueError("schema desktop v12-v23 requis")
|
raise ValueError("schema desktop v12-v24 requis")
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
|
||||||
raise ValueError("schema desktop v11-v16 requis")
|
raise ValueError("schema desktop v11-v16 requis")
|
||||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
||||||
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ def main():
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (17, 18, 19, 20, 21, 22, 23):
|
if version not in (17, 18, 19, 20, 21, 22, 23, 24):
|
||||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||||
rows = con.execute("""
|
rows = con.execute("""
|
||||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ def main():
|
||||||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||||
# catalogue artifact is unchanged, but it must read the current
|
# catalogue artifact is unchanged, but it must read the current
|
||||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23):
|
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24):
|
||||||
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
||||||
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23) and not (args.version == 2 and schema_version == 10):
|
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24) and not (args.version == 2 and schema_version == 10):
|
||||||
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
|
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
|
||||||
if not complete_causal_envelope and schema_version >= 20 and con.execute(
|
if not complete_causal_envelope and schema_version >= 20 and con.execute(
|
||||||
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,12 @@ from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
MAX_DELIVERIES = 128
|
MAX_DELIVERIES = 128
|
||||||
MAX_ENTRIES = 64
|
MAX_ENTRIES = 64
|
||||||
|
MAX_WITHDRAWALS = 128
|
||||||
|
|
||||||
|
|
||||||
def build_export(connection: sqlite3.Connection) -> dict:
|
def build_export(connection: sqlite3.Connection) -> dict:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] != 23:
|
if connection.execute("PRAGMA user_version").fetchone()[0] != 24:
|
||||||
raise ValueError("desktop schema v23 required")
|
raise ValueError("desktop schema v24 required")
|
||||||
deliveries = []
|
deliveries = []
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
|
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
|
||||||
|
|
@ -25,7 +26,7 @@ def build_export(connection: sqlite3.Connection) -> dict:
|
||||||
"FROM session_preparation_deliveries d "
|
"FROM session_preparation_deliveries d "
|
||||||
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
|
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
|
||||||
"JOIN session_preparation_revisions r ON r.revision_id=d.revision_id "
|
"JOIN session_preparation_revisions r ON r.revision_id=d.revision_id "
|
||||||
"WHERE d.state IN('pending','remote_unknown') "
|
"WHERE p.withdrawn_at IS NULL AND d.state IN('pending','remote_unknown') "
|
||||||
"ORDER BY d.created_at,d.delivery_id LIMIT ?",
|
"ORDER BY d.created_at,d.delivery_id LIMIT ?",
|
||||||
(MAX_DELIVERIES + 1,),
|
(MAX_DELIVERIES + 1,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
@ -56,10 +57,40 @@ def build_export(connection: sqlite3.Connection) -> dict:
|
||||||
"notes": entry[13],
|
"notes": entry[13],
|
||||||
} for entry in entries],
|
} for entry in entries],
|
||||||
})
|
})
|
||||||
|
withdrawals = []
|
||||||
|
withdrawal_rows = connection.execute(
|
||||||
|
"SELECT withdrawal_id,preparation_id,revision_id,requested_at "
|
||||||
|
"FROM session_preparation_withdrawals WHERE acknowledged_at IS NULL "
|
||||||
|
"ORDER BY requested_at,withdrawal_id LIMIT ?",
|
||||||
|
(MAX_WITHDRAWALS + 1,),
|
||||||
|
).fetchall()
|
||||||
|
if len(withdrawal_rows) > MAX_WITHDRAWALS:
|
||||||
|
raise ValueError("preparation withdrawal capacity exceeded")
|
||||||
|
for row in withdrawal_rows:
|
||||||
|
related = connection.execute(
|
||||||
|
"SELECT delivery_id,revision_id,execution_session_id "
|
||||||
|
"FROM session_preparation_deliveries WHERE preparation_id=? "
|
||||||
|
"ORDER BY created_at,delivery_id LIMIT ?",
|
||||||
|
(row[1], MAX_DELIVERIES + 1),
|
||||||
|
).fetchall()
|
||||||
|
if len(related) > MAX_DELIVERIES:
|
||||||
|
raise ValueError("preparation withdrawal delivery capacity exceeded")
|
||||||
|
withdrawals.append({
|
||||||
|
"withdrawal_id": row[0],
|
||||||
|
"preparation_id": row[1],
|
||||||
|
"revision_id": row[2],
|
||||||
|
"requested_at": row[3],
|
||||||
|
"deliveries": [{
|
||||||
|
"delivery_id": delivery[0],
|
||||||
|
"revision_id": delivery[1],
|
||||||
|
"execution_session_id": delivery[2],
|
||||||
|
} for delivery in related],
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
"format": "trainlog-session-preparations", "version": 1,
|
"format": "trainlog-session-preparations", "version": 2,
|
||||||
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||||
"deliveries": deliveries,
|
"deliveries": deliveries,
|
||||||
|
"withdrawals": withdrawals,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ def main(complete_causal_envelope=False):
|
||||||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||||
db=connect_database(args.database)
|
db=connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21, 22, 23): raise ValueError("schema desktop v15-v23 requis")
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17, 18, 19, 20, 21, 22, 23, 24): raise ValueError("schema desktop v15-v24 requis")
|
||||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
|
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
|
||||||
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
||||||
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
||||||
|
|
|
||||||
|
|
@ -193,8 +193,8 @@ def import_payload(connection, payload, imported_at=None):
|
||||||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||||
any failure leaves no draft, child, or import-identity row behind.
|
any failure leaves no draft, child, or import-identity row behind.
|
||||||
"""
|
"""
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23, 24):
|
||||||
raise ImportFailure("schema desktop v18-v23 requis")
|
raise ImportFailure("schema desktop v18-v24 requis")
|
||||||
draft, entries = validate(payload, connection)
|
draft, entries = validate(payload, connection)
|
||||||
digest = canonical_digest(payload)
|
digest = canonical_digest(payload)
|
||||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ def main():
|
||||||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
|
||||||
fail("schema desktop v8 à v21 requis")
|
fail("schema desktop v8 à v21 requis")
|
||||||
proof_path = args.mobile_export
|
proof_path = args.mobile_export
|
||||||
if proof_path is None:
|
if proof_path is None:
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ def validate(payload, reserved):
|
||||||
|
|
||||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||||
"""Apply validated definitions without owning the surrounding transaction."""
|
"""Apply validated definitions without owning the surrounding transaction."""
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
|
||||||
fail("schema desktop v8 à v21 requis")
|
fail("schema desktop v8 à v21 requis")
|
||||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||||
fail("causal equipment protection requires the staged artifact")
|
fail("causal equipment protection requires the staged artifact")
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ def load(path):
|
||||||
|
|
||||||
def apply_aliases(con, aliases):
|
def apply_aliases(con, aliases):
|
||||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
|
||||||
fail("schema desktop v12-v21 requis")
|
fail("schema desktop v12-v21 requis")
|
||||||
for source, canonical in aliases:
|
for source, canonical in aliases:
|
||||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ def replace(connection, row_id, primary, secondary):
|
||||||
|
|
||||||
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||||
"""Apply validated zone rows without owning transaction boundaries."""
|
"""Apply validated zone rows without owning transaction boundaries."""
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21,22,23): raise ImportFailure("schema desktop v11-v23 requis")
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21,22,23,24): raise ImportFailure("schema desktop v11-v24 requis")
|
||||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
|
||||||
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
||||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
# CONTRACT: resolve complete alias groups before the first write; direct
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ def validate(item):
|
||||||
def apply_profile_state(con, root, allow_pending=False):
|
def apply_profile_state(con, root, allow_pending=False):
|
||||||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||||
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22,23): fail("schema desktop v17-v23 requis")
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22,23,24): fail("schema desktop v17-v24 requis")
|
||||||
applied=equal=ancestor=pending=0;seen=set()
|
applied=equal=ancestor=pending=0;seen=set()
|
||||||
for item in root["exercises"]:
|
for item in root["exercises"]:
|
||||||
validate(item);eid=item["exercise_id"]
|
validate(item);eid=item["exercise_id"]
|
||||||
|
|
|
||||||
|
|
@ -846,9 +846,9 @@ def require_supported_schema(connection):
|
||||||
|
|
||||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||||
# readable for legacy artifacts and are never made to fake that table.
|
# readable for legacy artifacts and are never made to fake that table.
|
||||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24):
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
f"base desktop schema v5 à v23 attendue, version trouvée: {version}"
|
f"base desktop schema v5 à v24 attendue, version trouvée: {version}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1978,8 +1978,8 @@ def apply_payload(connection, payload, trace_exercises=False):
|
||||||
require_supported_schema(connection)
|
require_supported_schema(connection)
|
||||||
|
|
||||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23):
|
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24):
|
||||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v23")
|
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v24")
|
||||||
if schema_version >= 20:
|
if schema_version >= 20:
|
||||||
protected = {(row[0], row[1]) for row in connection.execute(
|
protected = {(row[0], row[1]) for row in connection.execute(
|
||||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
||||||
def apply_feedback(db,root,complete_causal_envelope=False):
|
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||||
db.execute("PRAGMA foreign_keys=ON")
|
db.execute("PRAGMA foreign_keys=ON")
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21,22,23):fail("schema desktop v15-v23 requis")
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21,22,23,24):fail("schema desktop v15-v24 requis")
|
||||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
|
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
|
||||||
seen=set()
|
seen=set()
|
||||||
for index,item in enumerate(root["exercise_feedback"]):
|
for index,item in enumerate(root["exercise_feedback"]):
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,8 @@ def main() -> int:
|
||||||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||||
).strip(),
|
).strip(),
|
||||||
"product_version": "0.1.2",
|
"product_version": "0.1.2",
|
||||||
"desktop_schema": 23,
|
"desktop_schema": 24,
|
||||||
"android_schema": 22,
|
"android_schema": 23,
|
||||||
"protocols": [
|
"protocols": [
|
||||||
"mobile-export-v3",
|
"mobile-export-v3",
|
||||||
"mobile-history-v4",
|
"mobile-history-v4",
|
||||||
|
|
@ -106,7 +106,7 @@ def main() -> int:
|
||||||
"generation-manifest-v1",
|
"generation-manifest-v1",
|
||||||
"generation-ack-v1",
|
"generation-ack-v1",
|
||||||
"generation-archive-v1",
|
"generation-archive-v1",
|
||||||
"session-preparations-v1",
|
"session-preparations-v2",
|
||||||
],
|
],
|
||||||
"entry_points": [
|
"entry_points": [
|
||||||
"bin/trainlog",
|
"bin/trainlog",
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,8 @@ ARTIFACTS = (
|
||||||
"causal_delete_exchange.py", ("export",)),
|
"causal_delete_exchange.py", ("export",)),
|
||||||
("ai-proposals", "trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", False,
|
("ai-proposals", "trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", False,
|
||||||
"export_ai_session_drafts.py", ()),
|
"export_ai_session_drafts.py", ()),
|
||||||
("session-preparations", "trainlog-session-preparations", 1,
|
("session-preparations", "trainlog-session-preparations", 2,
|
||||||
"session-preparations-v1.json", False, "export_session_preparations.py", ()),
|
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
|
||||||
)
|
)
|
||||||
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS}
|
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS}
|
||||||
|
|
||||||
|
|
@ -201,8 +201,8 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
|
||||||
|
|
||||||
|
|
||||||
def require_schema(db: sqlite3.Connection) -> None:
|
def require_schema(db: sqlite3.Connection) -> None:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] != 23:
|
if db.execute("PRAGMA user_version").fetchone()[0] != 24:
|
||||||
raise GenerationError("desktop schema v23 required")
|
raise GenerationError("desktop schema v24 required")
|
||||||
|
|
||||||
|
|
||||||
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||||
|
|
@ -407,12 +407,12 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
||||||
for value in descriptors])
|
for value in descriptors])
|
||||||
# CONTRACT: the exact generation relation is recorded only after
|
# CONTRACT: the exact generation relation is recorded only after
|
||||||
# its complete immutable artifact has been captured successfully.
|
# its complete immutable artifact has been captured successfully.
|
||||||
preparation_path = stage / "session-preparations-v1.json"
|
preparation_path = stage / "session-preparations-v2.json"
|
||||||
if preparation_path.is_file():
|
if preparation_path.is_file():
|
||||||
captured_preparations = strict_json(
|
captured_preparations = strict_json(
|
||||||
preparation_path.read_bytes(), MAX_ARTIFACT
|
preparation_path.read_bytes(), MAX_ARTIFACT
|
||||||
)["deliveries"]
|
)
|
||||||
for delivery in captured_preparations:
|
for delivery in captured_preparations["deliveries"]:
|
||||||
cursor = db.execute(
|
cursor = db.execute(
|
||||||
"UPDATE session_preparation_deliveries SET generation_id=? "
|
"UPDATE session_preparation_deliveries SET generation_id=? "
|
||||||
"WHERE delivery_id=? AND revision_id=? AND "
|
"WHERE delivery_id=? AND revision_id=? AND "
|
||||||
|
|
@ -421,6 +421,14 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
||||||
)
|
)
|
||||||
if cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise GenerationError("preparation delivery changed during capture")
|
raise GenerationError("preparation delivery changed during capture")
|
||||||
|
for withdrawal in captured_preparations["withdrawals"]:
|
||||||
|
cursor = db.execute(
|
||||||
|
"UPDATE session_preparation_withdrawals SET generation_id=? "
|
||||||
|
"WHERE withdrawal_id=? AND revision_id=? AND generation_id IS NULL",
|
||||||
|
(generation, withdrawal["withdrawal_id"], withdrawal["revision_id"]),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise GenerationError("preparation withdrawal changed during capture")
|
||||||
causal_path = stage / "causal-deletions-v1.json"
|
causal_path = stage / "causal-deletions-v1.json"
|
||||||
if causal_path.is_file():
|
if causal_path.is_file():
|
||||||
for operation in json.loads(causal_path.read_text())["operations"]:
|
for operation in json.loads(causal_path.read_text())["operations"]:
|
||||||
|
|
@ -685,6 +693,11 @@ def accept_ack(database: Path, path: Path) -> str:
|
||||||
"WHERE generation_id=? AND state IN('pending','remote_unknown')",
|
"WHERE generation_id=? AND state IN('pending','remote_unknown')",
|
||||||
(ack["consumed_at"], ack["generation_id"]),
|
(ack["consumed_at"], ack["generation_id"]),
|
||||||
)
|
)
|
||||||
|
db.execute(
|
||||||
|
"UPDATE session_preparation_withdrawals SET acknowledged_at=? "
|
||||||
|
"WHERE generation_id=? AND acknowledged_at IS NULL",
|
||||||
|
(ack["consumed_at"], ack["generation_id"]),
|
||||||
|
)
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE session_preparations SET delivery_state='acknowledged' WHERE EXISTS("
|
"UPDATE session_preparations SET delivery_state='acknowledged' WHERE EXISTS("
|
||||||
"SELECT 1 FROM session_preparation_deliveries d WHERE "
|
"SELECT 1 FROM session_preparation_deliveries d WHERE "
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ CAPS = {
|
||||||
"mobile-history-v4",
|
"mobile-history-v4",
|
||||||
"execution-draft-v1",
|
"execution-draft-v1",
|
||||||
"generation-archive-v1",
|
"generation-archive-v1",
|
||||||
|
"session-preparations-v2",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
#include "trainlog/model.h"
|
#include "trainlog/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 23
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 24
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,8 @@
|
||||||
* - `bo` for body observations.
|
* - `bo` for body observations.
|
||||||
* - `sy` for synchronization runs;
|
* - `sy` for synchronization runs;
|
||||||
* - `sxe` for persisted session-exercise occurrences;
|
* - `sxe` for persisted session-exercise occurrences;
|
||||||
* - `sp`, `spr`, `spe`, and `spd` for preparation, revision, occurrence, and
|
* - `sp`, `spr`, `spe`, `spd`, and `spw` for preparation, revision,
|
||||||
* delivery identities.
|
* occurrence, delivery, and withdrawal identities.
|
||||||
*
|
*
|
||||||
* Imported v1 documents may contain other schema-valid opaque identifiers;
|
* Imported v1 documents may contain other schema-valid opaque identifiers;
|
||||||
* this API defines creation policy, not import validation.
|
* this API defines creation policy, not import validation.
|
||||||
|
|
|
||||||
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 {
|
typedef enum TrainlogWebPreparedItemKind {
|
||||||
TRAINLOG_WEB_PREPARED_AI_PROPOSAL = 0,
|
TRAINLOG_WEB_PREPARED_AI_PROPOSAL = 0,
|
||||||
TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT = 1
|
TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT = 1,
|
||||||
|
TRAINLOG_WEB_PREPARED_MANUAL_PREPARATION = 2
|
||||||
} TrainlogWebPreparedItemKind;
|
} TrainlogWebPreparedItemKind;
|
||||||
|
|
||||||
typedef struct TrainlogWebPreparedItem {
|
typedef struct TrainlogWebPreparedItem {
|
||||||
|
|
@ -20,6 +21,7 @@ typedef struct TrainlogWebPreparedItem {
|
||||||
char state[32];
|
char state[32];
|
||||||
char title[TRAINLOG_NAME_MAX + 1U];
|
char title[TRAINLOG_NAME_MAX + 1U];
|
||||||
char planned_for[11];
|
char planned_for[11];
|
||||||
|
char sort_timestamp[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||||
size_t occurrence_count;
|
size_t occurrence_count;
|
||||||
char provenance[32];
|
char provenance[32];
|
||||||
} TrainlogWebPreparedItem;
|
} TrainlogWebPreparedItem;
|
||||||
|
|
|
||||||
|
|
@ -79,4 +79,18 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
|
||||||
char **output_json,
|
char **output_json,
|
||||||
size_t *output_size);
|
size_t *output_size);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* CONTRACT: withdrawal is a logical, durable removal of one preparation.
|
||||||
|
* It preserves immutable revisions and every execution identity. Only the
|
||||||
|
* current revision may be withdrawn, and request_id makes response replay
|
||||||
|
* idempotent. Android delivery cancellation is completed by the versioned
|
||||||
|
* generation participant rather than by deleting delivery evidence here.
|
||||||
|
*/
|
||||||
|
TrainlogStatus trainlog_web_sessions_withdraw_json(TrainlogDatabase *database,
|
||||||
|
const char *preparation_id,
|
||||||
|
const char *expected_revision,
|
||||||
|
const char *request_id,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -47,12 +47,15 @@ if web_assets_enabled
|
||||||
'../web/src/api/health.ts',
|
'../web/src/api/health.ts',
|
||||||
'../web/src/api/dashboard.ts',
|
'../web/src/api/dashboard.ts',
|
||||||
'../web/src/api/dashboardLayout.ts',
|
'../web/src/api/dashboardLayout.ts',
|
||||||
|
'../web/src/api/preparedItems.ts',
|
||||||
'../web/src/api/sync.ts',
|
'../web/src/api/sync.ts',
|
||||||
'../web/src/api/sync.test.ts',
|
'../web/src/api/sync.test.ts',
|
||||||
|
'../web/src/api/webPreferences.ts',
|
||||||
'../web/src/app/App.tsx',
|
'../web/src/app/App.tsx',
|
||||||
'../web/src/app/routes.ts',
|
'../web/src/app/routes.ts',
|
||||||
'../web/src/app/useRoute.ts',
|
'../web/src/app/useRoute.ts',
|
||||||
'../web/src/components/EmptyState.tsx',
|
'../web/src/components/EmptyState.tsx',
|
||||||
|
'../web/src/components/DisplayPreferences.tsx',
|
||||||
'../web/src/components/Footer.tsx',
|
'../web/src/components/Footer.tsx',
|
||||||
'../web/src/components/Header.tsx',
|
'../web/src/components/Header.tsx',
|
||||||
'../web/src/components/SyncControl.tsx',
|
'../web/src/components/SyncControl.tsx',
|
||||||
|
|
@ -65,6 +68,8 @@ if web_assets_enabled
|
||||||
'../web/src/dashboard/DashboardTiles.tsx',
|
'../web/src/dashboard/DashboardTiles.tsx',
|
||||||
'../web/src/dashboard/dashboardFormat.ts',
|
'../web/src/dashboard/dashboardFormat.ts',
|
||||||
'../web/src/dashboard/dashboardLayout.ts',
|
'../web/src/dashboard/dashboardLayout.ts',
|
||||||
|
'../web/src/presentation/DatePreferences.tsx',
|
||||||
|
'../web/src/presentation/dateFormat.ts',
|
||||||
'../web/src/routes/DashboardPage.tsx',
|
'../web/src/routes/DashboardPage.tsx',
|
||||||
'../web/src/routes/PlaceholderPage.tsx',
|
'../web/src/routes/PlaceholderPage.tsx',
|
||||||
'../web/src/routes/SessionsPage.tsx',
|
'../web/src/routes/SessionsPage.tsx',
|
||||||
|
|
@ -266,6 +271,7 @@ trainlog_tui_sources = files(
|
||||||
'src/theme.c',
|
'src/theme.c',
|
||||||
'src/tui.c',
|
'src/tui.c',
|
||||||
'src/dashboard_layout.c',
|
'src/dashboard_layout.c',
|
||||||
|
'src/web_preferences.c',
|
||||||
'src/web_server.c',
|
'src/web_server.c',
|
||||||
)
|
)
|
||||||
trainlog_tui_sources += web_assets_source
|
trainlog_tui_sources += web_assets_source
|
||||||
|
|
@ -376,7 +382,8 @@ test('cli', test_cli)
|
||||||
|
|
||||||
test_web_server = executable(
|
test_web_server = executable(
|
||||||
'test_web_server',
|
'test_web_server',
|
||||||
['tests/test_web_server.c', 'src/web_server.c', 'src/dashboard_layout.c', web_assets_source],
|
['tests/test_web_server.c', 'src/web_server.c', 'src/dashboard_layout.c',
|
||||||
|
'src/web_preferences.c', web_assets_source],
|
||||||
dependencies: [trainlog_core_dep, microhttpd_dep, yyjson_dep],
|
dependencies: [trainlog_core_dep, microhttpd_dep, yyjson_dep],
|
||||||
include_directories: trainlog_private_include,
|
include_directories: trainlog_private_include,
|
||||||
c_args: strict_c_args,
|
c_args: strict_c_args,
|
||||||
|
|
@ -782,6 +789,11 @@ test(
|
||||||
args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'],
|
args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'],
|
||||||
timeout: 120,
|
timeout: 120,
|
||||||
)
|
)
|
||||||
|
test(
|
||||||
|
'session_preparation_export',
|
||||||
|
find_program('python3'),
|
||||||
|
args: [meson.project_source_root() / 'tests/test_session_preparation_export.py'],
|
||||||
|
)
|
||||||
test(
|
test(
|
||||||
'generation_archival',
|
'generation_archival',
|
||||||
find_program('python3'),
|
find_program('python3'),
|
||||||
|
|
|
||||||
|
|
@ -923,6 +923,22 @@ static const char *const MIGRATE_V22_TO_V23_SQL =
|
||||||
"UNIQUE(preparation_id,revision_id,delivery_id));"
|
"UNIQUE(preparation_id,revision_id,delivery_id));"
|
||||||
"PRAGMA user_version=23;COMMIT;";
|
"PRAGMA user_version=23;COMMIT;";
|
||||||
|
|
||||||
|
/* WHY: omitting a delivered preparation from later snapshots cannot retract
|
||||||
|
* an already consumed Android object and permits replay resurrection.
|
||||||
|
* CONTRACT: v24 records one immutable, exportable withdrawal per preparation
|
||||||
|
* while retaining revisions, deliveries, ACKs and execution identities.
|
||||||
|
* INVARIANT: migration withdraws nothing and never touches workout facts. */
|
||||||
|
static const char *const MIGRATE_V23_TO_V24_SQL =
|
||||||
|
"BEGIN IMMEDIATE;"
|
||||||
|
"ALTER TABLE session_preparations ADD COLUMN withdrawn_at TEXT;"
|
||||||
|
"CREATE TABLE session_preparation_withdrawals("
|
||||||
|
"withdrawal_id TEXT PRIMARY KEY,"
|
||||||
|
"preparation_id TEXT NOT NULL UNIQUE REFERENCES session_preparations(preparation_id),"
|
||||||
|
"revision_id TEXT NOT NULL REFERENCES session_preparation_revisions(revision_id),"
|
||||||
|
"requested_at TEXT NOT NULL,generation_id TEXT,acknowledged_at TEXT,"
|
||||||
|
"UNIQUE(preparation_id,revision_id));"
|
||||||
|
"PRAGMA user_version=24;COMMIT;";
|
||||||
|
|
||||||
/* CONTRACT: v18 was already deployed before bounded draft publication was
|
/* CONTRACT: v18 was already deployed before bounded draft publication was
|
||||||
* added. Keep its version number and add only the nullable lifecycle cursor;
|
* added. Keep its version number and add only the nullable lifecycle cursor;
|
||||||
* NULL deliberately means retryable/not yet published. */
|
* NULL deliberately means retryable/not yet published. */
|
||||||
|
|
@ -1619,7 +1635,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
|
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
|
||||||
version == 15 || version == 16 || version == 17 || version == 18 ||
|
version == 15 || version == 16 || version == 17 || version == 18 ||
|
||||||
version == 19 || version == 20 || version == 21 || version == 22 ||
|
version == 19 || version == 20 || version == 21 || version == 22 ||
|
||||||
version == 23) {
|
version == 23 || version == 24) {
|
||||||
status = TRAINLOG_STATUS_OK;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} else {
|
||||||
if (version == 1) {
|
if (version == 1) {
|
||||||
|
|
@ -1798,6 +1814,9 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
if (status == TRAINLOG_STATUS_OK && version < 23) {
|
if (status == TRAINLOG_STATUS_OK && version < 23) {
|
||||||
status = execute_sql(database, MIGRATE_V22_TO_V23_SQL);
|
status = execute_sql(database, MIGRATE_V22_TO_V23_SQL);
|
||||||
}
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && version < 24) {
|
||||||
|
status = execute_sql(database, MIGRATE_V23_TO_V24_SQL);
|
||||||
|
}
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = ensure_v18_ai_draft_publication_state(database);
|
status = ensure_v18_ai_draft_publication_state(database);
|
||||||
}
|
}
|
||||||
|
|
@ -1813,7 +1832,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
set_open_diagnostic(
|
set_open_diagnostic(
|
||||||
output_diagnostic,
|
output_diagnostic,
|
||||||
output_diagnostic_capacity,
|
output_diagnostic_capacity,
|
||||||
version == 0 ? "create schema v23" : "migrate database to schema v23",
|
version == 0 ? "create schema v24" : "migrate database to schema v24",
|
||||||
database->connection,
|
database->connection,
|
||||||
SQLITE_ERROR
|
SQLITE_ERROR
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ static bool prefix_is_supported(const char *prefix) {
|
||||||
*/
|
*/
|
||||||
return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 ||
|
return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 ||
|
||||||
strcmp(prefix, "sy") == 0 || strcmp(prefix, "sxe") == 0 || strcmp(prefix, "sp") == 0 ||
|
strcmp(prefix, "sy") == 0 || strcmp(prefix, "sxe") == 0 || strcmp(prefix, "sp") == 0 ||
|
||||||
strcmp(prefix, "spr") == 0 || strcmp(prefix, "spe") == 0 || strcmp(prefix, "spd") == 0;
|
strcmp(prefix, "spr") == 0 || strcmp(prefix, "spe") == 0 || strcmp(prefix, "spd") == 0 ||
|
||||||
|
strcmp(prefix, "spw") == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) {
|
TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) {
|
||||||
|
|
|
||||||
301
tui/src/web_preferences.c
Normal file
|
|
@ -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[] =
|
static const char SQL[] =
|
||||||
"SELECT d.draft_id,COALESCE(d.title,''),COALESCE(d.planned_for,''),"
|
"SELECT d.draft_id,COALESCE(d.title,''),COALESCE(d.planned_for,''),"
|
||||||
"COUNT(e.id),CASE WHEN d.published_at IS NULL THEN 'pending_publication' ELSE 'published' "
|
"COUNT(e.id),CASE WHEN d.published_at IS NULL THEN 'pending_publication' ELSE 'published' "
|
||||||
"END FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON "
|
"END,d.created_at FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON "
|
||||||
"e.draft_row_id=d.id GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),"
|
"e.draft_row_id=d.id GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),"
|
||||||
"d.created_at,d.draft_id LIMIT ?1;";
|
"d.created_at,d.draft_id LIMIT ?1;";
|
||||||
sqlite3_stmt *statement = NULL;
|
sqlite3_stmt *statement = NULL;
|
||||||
|
|
@ -80,7 +80,8 @@ static TrainlogStatus load_ai_proposals(
|
||||||
!copy_column(statement, 1, item->title, sizeof(item->title)) ||
|
!copy_column(statement, 1, item->title, sizeof(item->title)) ||
|
||||||
!copy_column(statement, 2, item->planned_for, sizeof(item->planned_for)) ||
|
!copy_column(statement, 2, item->planned_for, sizeof(item->planned_for)) ||
|
||||||
!read_count(statement, 3, &item->occurrence_count) ||
|
!read_count(statement, 3, &item->occurrence_count) ||
|
||||||
!copy_column(statement, 4, item->state, sizeof(item->state))) {
|
!copy_column(statement, 4, item->state, sizeof(item->state)) ||
|
||||||
|
!copy_column(statement, 5, item->sort_timestamp, sizeof(item->sort_timestamp))) {
|
||||||
goto fail;
|
goto fail;
|
||||||
}
|
}
|
||||||
(void)snprintf(item->provenance, sizeof(item->provenance), "%s", "ai_import");
|
(void)snprintf(item->provenance, sizeof(item->provenance), "%s", "ai_import");
|
||||||
|
|
@ -98,11 +99,61 @@ fail:
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus load_manual_preparations(
|
||||||
|
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
|
||||||
|
static const char SQL[] =
|
||||||
|
"SELECT p.preparation_id,r.title,COALESCE(r.planned_for,''),COUNT(e.entry_id),"
|
||||||
|
"p.editing_state,p.updated_at FROM session_preparations p JOIN "
|
||||||
|
"session_preparation_revisions r ON r.revision_id=p.current_revision_id LEFT JOIN "
|
||||||
|
"session_preparation_entries e ON e.revision_id=r.revision_id WHERE "
|
||||||
|
"p.withdrawn_at IS NULL GROUP BY p.preparation_id ORDER BY "
|
||||||
|
"COALESCE(r.planned_for,'9999-12-31'),p.updated_at,p.preparation_id LIMIT ?1;";
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
int step;
|
||||||
|
|
||||||
|
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_int64(
|
||||||
|
statement, 1, (sqlite3_int64)(TRAINLOG_WEB_PREPARED_ITEM_CAPACITY + 1U)) != SQLITE_OK) {
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||||
|
TrainlogWebPreparedItem *item;
|
||||||
|
|
||||||
|
if (output->item_count == TRAINLOG_WEB_PREPARED_ITEM_CAPACITY) {
|
||||||
|
output->partial = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
item = &output->items[output->item_count];
|
||||||
|
item->kind = TRAINLOG_WEB_PREPARED_MANUAL_PREPARATION;
|
||||||
|
if (!copy_column(statement, 0, item->identity, sizeof(item->identity)) ||
|
||||||
|
!copy_column(statement, 1, item->title, sizeof(item->title)) ||
|
||||||
|
!copy_column(statement, 2, item->planned_for, sizeof(item->planned_for)) ||
|
||||||
|
!read_count(statement, 3, &item->occurrence_count) ||
|
||||||
|
!copy_column(statement, 4, item->state, sizeof(item->state)) ||
|
||||||
|
!copy_column(statement, 5, item->sort_timestamp, sizeof(item->sort_timestamp))) {
|
||||||
|
goto fail;
|
||||||
|
}
|
||||||
|
(void)snprintf(item->provenance, sizeof(item->provenance), "%s", "manual_preparation");
|
||||||
|
++output->item_count;
|
||||||
|
}
|
||||||
|
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
|
||||||
|
fail:
|
||||||
|
if (statement != NULL) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
}
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
static TrainlogStatus load_execution_drafts(
|
static TrainlogStatus load_execution_drafts(
|
||||||
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
|
TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
|
||||||
static const char SQL[] =
|
static const char SQL[] =
|
||||||
"SELECT d.session_id,d.state,d.session_type,COALESCE(substr(d.started_at,1,10),''),"
|
"SELECT d.session_id,d.state,d.session_type,COALESCE(substr(d.started_at,1,10),''),"
|
||||||
"COALESCE(json_array_length(d.payload_json,'$.exercises'),0) FROM execution_drafts d "
|
"COALESCE(json_array_length(d.payload_json,'$.exercises'),0),COALESCE(d.started_at,'') "
|
||||||
|
"FROM execution_drafts d "
|
||||||
"WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE "
|
"WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE "
|
||||||
"f.session_id=d.session_id) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE "
|
"f.session_id=d.session_id) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE "
|
||||||
"c.target_kind='execution_draft' AND c.target_id=d.session_id AND c.deleted=1) "
|
"c.target_kind='execution_draft' AND c.target_id=d.session_id AND c.deleted=1) "
|
||||||
|
|
@ -131,7 +182,8 @@ static TrainlogStatus load_execution_drafts(
|
||||||
!copy_column(statement, 1, item->state, sizeof(item->state)) ||
|
!copy_column(statement, 1, item->state, sizeof(item->state)) ||
|
||||||
!copy_column(statement, 2, item->title, sizeof(item->title)) ||
|
!copy_column(statement, 2, item->title, sizeof(item->title)) ||
|
||||||
!copy_column(statement, 3, item->planned_for, sizeof(item->planned_for)) ||
|
!copy_column(statement, 3, item->planned_for, sizeof(item->planned_for)) ||
|
||||||
!read_count(statement, 4, &item->occurrence_count)) {
|
!read_count(statement, 4, &item->occurrence_count) ||
|
||||||
|
!copy_column(statement, 5, item->sort_timestamp, sizeof(item->sort_timestamp))) {
|
||||||
goto fail;
|
goto fail;
|
||||||
}
|
}
|
||||||
(void)snprintf(item->provenance, sizeof(item->provenance), "%s", "execution_store");
|
(void)snprintf(item->provenance, sizeof(item->provenance), "%s", "execution_store");
|
||||||
|
|
@ -165,7 +217,10 @@ TrainlogStatus trainlog_web_prepared_items_load(
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
status = load_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) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = load_execution_drafts(database, output);
|
status = load_execution_drafts(database, output);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ TrainlogStatus trainlog_web_prepared_items_serialize(
|
||||||
kind = "ai_proposal";
|
kind = "ai_proposal";
|
||||||
} else if (item->kind == TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT) {
|
} else if (item->kind == TRAINLOG_WEB_PREPARED_EXECUTION_DRAFT) {
|
||||||
kind = "execution_draft";
|
kind = "execution_draft";
|
||||||
|
} else if (item->kind == TRAINLOG_WEB_PREPARED_MANUAL_PREPARATION) {
|
||||||
|
kind = "manual_preparation";
|
||||||
} else {
|
} else {
|
||||||
output[0] = '\0';
|
output[0] = '\0';
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
|
@ -124,6 +126,8 @@ TrainlogStatus trainlog_web_prepared_items_serialize(
|
||||||
}
|
}
|
||||||
append(&writer, ",\"state\":");
|
append(&writer, ",\"state\":");
|
||||||
string(&writer, item->state);
|
string(&writer, item->state);
|
||||||
|
append(&writer, ",\"sort_timestamp\":");
|
||||||
|
string(&writer, item->sort_timestamp);
|
||||||
append(&writer, ",\"occurrence_count\":%zu,\"provenance\":", item->occurrence_count);
|
append(&writer, ",\"occurrence_count\":%zu,\"provenance\":", item->occurrence_count);
|
||||||
string(&writer, item->provenance);
|
string(&writer, item->provenance);
|
||||||
append(&writer, "}");
|
append(&writer, "}");
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
#include "trainlog/web_prepared_items.h"
|
#include "trainlog/web_prepared_items.h"
|
||||||
#include "trainlog/web_sessions.h"
|
#include "trainlog/web_sessions.h"
|
||||||
#include "trainlog/dashboard_layout.h"
|
#include "trainlog/dashboard_layout.h"
|
||||||
|
#include "trainlog/web_preferences.h"
|
||||||
#include "web_assets.h"
|
#include "web_assets.h"
|
||||||
|
|
||||||
#define TRAINLOG_HTTP_CONNECTION_LIMIT 32U
|
#define TRAINLOG_HTTP_CONNECTION_LIMIT 32U
|
||||||
|
|
@ -45,6 +46,7 @@ typedef struct TrainlogWebContext {
|
||||||
pid_t worker_pid;
|
pid_t worker_pid;
|
||||||
char csrf_token[65];
|
char csrf_token[65];
|
||||||
bool invalid_layout_reported;
|
bool invalid_layout_reported;
|
||||||
|
bool invalid_preferences_reported;
|
||||||
} TrainlogWebContext;
|
} TrainlogWebContext;
|
||||||
|
|
||||||
typedef struct TrainlogHttpRequestState {
|
typedef struct TrainlogHttpRequestState {
|
||||||
|
|
@ -817,17 +819,19 @@ static enum MHD_Result handle_request(void *closure,
|
||||||
char expected_buffer[TRAINLOG_ID_MAX + 1U];
|
char expected_buffer[TRAINLOG_ID_MAX + 1U];
|
||||||
char identity_buffer[TRAINLOG_ID_MAX + 1U];
|
char identity_buffer[TRAINLOG_ID_MAX + 1U];
|
||||||
bool delivery = false;
|
bool delivery = false;
|
||||||
|
bool withdrawal = false;
|
||||||
TrainlogStatus status;
|
TrainlogStatus status;
|
||||||
|
|
||||||
if ((strcmp(url, "/api/v1/sessions/preparations") == 0 &&
|
if ((strcmp(url, "/api/v1/sessions/preparations") == 0 &&
|
||||||
strcmp(method, MHD_HTTP_METHOD_POST) != 0) ||
|
strcmp(method, MHD_HTTP_METHOD_POST) != 0) ||
|
||||||
(strcmp(url, "/api/v1/sessions/preparations") != 0 &&
|
(strcmp(url, "/api/v1/sessions/preparations") != 0 &&
|
||||||
strcmp(method, MHD_HTTP_METHOD_PUT) != 0 &&
|
strcmp(method, MHD_HTTP_METHOD_PUT) != 0 &&
|
||||||
strcmp(method, MHD_HTTP_METHOD_POST) != 0)) {
|
strcmp(method, MHD_HTTP_METHOD_POST) != 0 &&
|
||||||
|
strcmp(method, MHD_HTTP_METHOD_DELETE) != 0)) {
|
||||||
return queue_json(connection,
|
return queue_json(connection,
|
||||||
MHD_HTTP_METHOD_NOT_ALLOWED,
|
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||||
"{\"error\":\"method_not_allowed\"}\n",
|
"{\"error\":\"method_not_allowed\"}\n",
|
||||||
"POST, PUT");
|
"POST, PUT, DELETE");
|
||||||
}
|
}
|
||||||
if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) {
|
if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) {
|
||||||
return queue_json(
|
return queue_json(
|
||||||
|
|
@ -858,12 +862,13 @@ static enum MHD_Result handle_request(void *closure,
|
||||||
identity = identity_buffer;
|
identity = identity_buffer;
|
||||||
delivery = true;
|
delivery = true;
|
||||||
}
|
}
|
||||||
|
withdrawal = !delivery && strcmp(method, MHD_HTTP_METHOD_DELETE) == 0;
|
||||||
if ((delivery && strcmp(method, MHD_HTTP_METHOD_POST) != 0) ||
|
if ((delivery && strcmp(method, MHD_HTTP_METHOD_POST) != 0) ||
|
||||||
(!delivery && strcmp(method, MHD_HTTP_METHOD_PUT) != 0)) {
|
(!delivery && !withdrawal && strcmp(method, MHD_HTTP_METHOD_PUT) != 0)) {
|
||||||
return queue_json(connection,
|
return queue_json(connection,
|
||||||
MHD_HTTP_METHOD_NOT_ALLOWED,
|
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||||
"{\"error\":\"method_not_allowed\"}\n",
|
"{\"error\":\"method_not_allowed\"}\n",
|
||||||
delivery ? "POST" : "PUT");
|
delivery ? "POST" : "PUT, DELETE");
|
||||||
}
|
}
|
||||||
if (if_match == NULL || if_match[0] != '"') {
|
if (if_match == NULL || if_match[0] != '"') {
|
||||||
return queue_json(connection,
|
return queue_json(connection,
|
||||||
|
|
@ -885,6 +890,9 @@ static enum MHD_Result handle_request(void *closure,
|
||||||
if (delivery) {
|
if (delivery) {
|
||||||
status = trainlog_web_sessions_deliver_json(
|
status = trainlog_web_sessions_deliver_json(
|
||||||
context->database, identity, expected, request_id, &json, &json_size);
|
context->database, identity, expected, request_id, &json, &json_size);
|
||||||
|
} else if (withdrawal) {
|
||||||
|
status = trainlog_web_sessions_withdraw_json(
|
||||||
|
context->database, identity, expected, request_id, &json, &json_size);
|
||||||
} else {
|
} else {
|
||||||
status = trainlog_web_sessions_save_json(context->database,
|
status = trainlog_web_sessions_save_json(context->database,
|
||||||
identity,
|
identity,
|
||||||
|
|
@ -962,6 +970,106 @@ static enum MHD_Result handle_request(void *closure,
|
||||||
}
|
}
|
||||||
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
|
||||||
}
|
}
|
||||||
|
if (strcmp(url, "/api/v1/web-preferences") == 0) {
|
||||||
|
TrainlogWebPreferences preferences;
|
||||||
|
TrainlogWebPreferences saved;
|
||||||
|
TrainlogWebPreferencesSource source;
|
||||||
|
TrainlogWebPreferencesResult preferences_result;
|
||||||
|
char json[TRAINLOG_WEB_PREFERENCES_JSON_CAPACITY];
|
||||||
|
size_t json_size;
|
||||||
|
uint64_t expected;
|
||||||
|
const char *origin;
|
||||||
|
const char *csrf;
|
||||||
|
const char *if_match;
|
||||||
|
|
||||||
|
if (is_get) {
|
||||||
|
preferences_result = trainlog_web_preferences_load(&preferences, &source);
|
||||||
|
if (preferences_result != TRAINLOG_WEB_PREFERENCES_OK ||
|
||||||
|
!trainlog_web_preferences_serialize(&preferences,
|
||||||
|
source,
|
||||||
|
true,
|
||||||
|
json,
|
||||||
|
sizeof(json),
|
||||||
|
&json_size)) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||||
|
"{\"error\":\"preferences_unavailable\"}\n",
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
if (source == TRAINLOG_WEB_PREFERENCES_INVALID_PERSISTED &&
|
||||||
|
!context->invalid_preferences_reported) {
|
||||||
|
(void)fprintf(stderr,
|
||||||
|
"Trainlog Web : préférences persistées invalides, défaut utilisé\n");
|
||||||
|
context->invalid_preferences_reported = true;
|
||||||
|
}
|
||||||
|
return queue_layout_json(connection,
|
||||||
|
MHD_HTTP_OK,
|
||||||
|
json,
|
||||||
|
preferences.revision,
|
||||||
|
context->csrf_token);
|
||||||
|
}
|
||||||
|
if (strcmp(method, MHD_HTTP_METHOD_PUT) != 0) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||||
|
"{\"error\":\"method_not_allowed\"}\n",
|
||||||
|
"GET, PUT");
|
||||||
|
}
|
||||||
|
origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin");
|
||||||
|
csrf = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token");
|
||||||
|
if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) {
|
||||||
|
return queue_json(
|
||||||
|
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
|
||||||
|
}
|
||||||
|
if_match =
|
||||||
|
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
|
||||||
|
if (!parse_if_match(if_match, &expected)) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_PRECONDITION_REQUIRED,
|
||||||
|
"{\"error\":\"precondition_required\"}\n",
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const char *content_type = MHD_lookup_connection_value(
|
||||||
|
connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE);
|
||||||
|
if (content_type == NULL || strcmp(content_type, "application/json") != 0) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
"{\"error\":\"unsupported_media_type\"}\n",
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preferences_result =
|
||||||
|
trainlog_web_preferences_parse(state->body, state->body_size, &preferences);
|
||||||
|
if (preferences_result != TRAINLOG_WEB_PREFERENCES_OK ||
|
||||||
|
preferences.revision != expected) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_BAD_REQUEST,
|
||||||
|
"{\"error\":\"invalid_preferences\"}\n",
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
preferences_result =
|
||||||
|
trainlog_web_preferences_save(&preferences, expected, &saved);
|
||||||
|
if (preferences_result == TRAINLOG_WEB_PREFERENCES_CONFLICT) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_PRECONDITION_FAILED,
|
||||||
|
"{\"error\":\"revision_conflict\"}\n",
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
if (preferences_result != TRAINLOG_WEB_PREFERENCES_OK ||
|
||||||
|
!trainlog_web_preferences_serialize(&saved,
|
||||||
|
TRAINLOG_WEB_PREFERENCES_PERSISTED,
|
||||||
|
true,
|
||||||
|
json,
|
||||||
|
sizeof(json),
|
||||||
|
&json_size)) {
|
||||||
|
return queue_json(connection,
|
||||||
|
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||||
|
"{\"error\":\"preferences_save_failed\"}\n",
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
return queue_layout_json(
|
||||||
|
connection, MHD_HTTP_OK, json, saved.revision, context->csrf_token);
|
||||||
|
}
|
||||||
if (strcmp(url, "/api/v1/dashboard-layout") == 0) {
|
if (strcmp(url, "/api/v1/dashboard-layout") == 0) {
|
||||||
TrainlogDashboardLayout layout;
|
TrainlogDashboardLayout layout;
|
||||||
TrainlogDashboardLayout saved;
|
TrainlogDashboardLayout saved;
|
||||||
|
|
@ -1165,6 +1273,7 @@ int trainlog_web_server_run(TrainlogDatabase *database,
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
context.invalid_layout_reported = false;
|
context.invalid_layout_reported = false;
|
||||||
|
context.invalid_preferences_reported = false;
|
||||||
if (!generate_csrf_token(context.csrf_token)) {
|
if (!generate_csrf_token(context.csrf_token)) {
|
||||||
web_diagnostic(diagnostic, diagnostic_capacity, "impossible de générer la protection CSRF");
|
web_diagnostic(diagnostic, diagnostic_capacity, "impossible de générer la protection CSRF");
|
||||||
return -1;
|
return -1;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ static const ListDefinition LISTS[] = {
|
||||||
"FROM session_preparations p JOIN session_preparation_revisions r ON "
|
"FROM session_preparations p JOIN session_preparation_revisions r ON "
|
||||||
"r.revision_id=p.current_revision_id "
|
"r.revision_id=p.current_revision_id "
|
||||||
"LEFT JOIN session_preparation_entries e ON e.revision_id=r.revision_id "
|
"LEFT JOIN session_preparation_entries e ON e.revision_id=r.revision_id "
|
||||||
"WHERE (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM "
|
"WHERE p.withdrawn_at IS NULL AND (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM "
|
||||||
"session_preparation_entries pe JOIN exercises x ON x.exercise_id=pe.exercise_id WHERE "
|
"session_preparation_entries pe JOIN exercises x ON x.exercise_id=pe.exercise_id WHERE "
|
||||||
"pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
|
"pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
|
||||||
"GROUP BY p.preparation_id ORDER BY "
|
"GROUP BY p.preparation_id ORDER BY "
|
||||||
|
|
@ -202,9 +202,14 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
|
||||||
"ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1"
|
"ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1"
|
||||||
: "SELECT "
|
: "SELECT "
|
||||||
"p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,"
|
"p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,"
|
||||||
"COALESCE(p.source_payload_sha256,''),p.current_revision_id,p.delivery_"
|
"COALESCE(p.source_payload_sha256,''),p.current_revision_id,"
|
||||||
"state FROM session_preparations p JOIN session_preparation_revisions "
|
"CASE WHEN p.withdrawn_at IS NULL THEN p.delivery_state ELSE 'withdrawn' END,"
|
||||||
"r ON r.revision_id=p.current_revision_id WHERE p.preparation_id=?1";
|
"p.source_proposal_id,source.title,p.withdrawn_at,w.withdrawal_id,"
|
||||||
|
"w.acknowledged_at FROM session_preparations p JOIN "
|
||||||
|
"session_preparation_revisions r ON r.revision_id=p.current_revision_id "
|
||||||
|
"LEFT JOIN ai_session_drafts source ON source.draft_id=p.source_proposal_id "
|
||||||
|
"LEFT JOIN session_preparation_withdrawals w ON "
|
||||||
|
"w.preparation_id=p.preparation_id WHERE p.preparation_id=?1";
|
||||||
const char *entry_sql =
|
const char *entry_sql =
|
||||||
proposal
|
proposal
|
||||||
? "SELECT "
|
? "SELECT "
|
||||||
|
|
@ -247,6 +252,23 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!proposal) {
|
||||||
|
static const char *const provenance_keys[] = {"source_proposal_id",
|
||||||
|
"source_proposal_title",
|
||||||
|
"withdrawn_at",
|
||||||
|
"withdrawal_id",
|
||||||
|
"withdrawal_acknowledged_at"};
|
||||||
|
for (column = 0; column < 5; ++column) {
|
||||||
|
if (!add_nullable_text(document,
|
||||||
|
root,
|
||||||
|
provenance_keys[column],
|
||||||
|
statement,
|
||||||
|
column + 8)) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
@ -947,7 +969,8 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
|
||||||
const char *current = NULL;
|
const char *current = NULL;
|
||||||
if (sqlite3_prepare_v2(
|
if (sqlite3_prepare_v2(
|
||||||
database->connection,
|
database->connection,
|
||||||
"SELECT current_revision_id FROM session_preparations WHERE preparation_id=?1",
|
"SELECT current_revision_id FROM session_preparations WHERE preparation_id=?1 "
|
||||||
|
"AND withdrawn_at IS NULL",
|
||||||
-1,
|
-1,
|
||||||
&statement,
|
&statement,
|
||||||
NULL) != SQLITE_OK ||
|
NULL) != SQLITE_OK ||
|
||||||
|
|
@ -966,7 +989,10 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
|
||||||
statement = NULL;
|
statement = NULL;
|
||||||
} else if (sqlite3_prepare_v2(
|
} else if (sqlite3_prepare_v2(
|
||||||
database->connection,
|
database->connection,
|
||||||
"INSERT INTO session_preparations VALUES(?1,?2,?3,?3,?4,'local',?5,?6)",
|
"INSERT INTO session_preparations("
|
||||||
|
"preparation_id,current_revision_id,created_at,updated_at,editing_state,"
|
||||||
|
"delivery_state,source_proposal_id,source_payload_sha256,withdrawn_at) "
|
||||||
|
"VALUES(?1,?2,?3,?3,?4,'local',?5,?6,NULL)",
|
||||||
-1,
|
-1,
|
||||||
&statement,
|
&statement,
|
||||||
NULL) != SQLITE_OK ||
|
NULL) != SQLITE_OK ||
|
||||||
|
|
@ -1026,7 +1052,8 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
|
||||||
"UPDATE session_preparations SET "
|
"UPDATE session_preparations SET "
|
||||||
"current_revision_id=?1,updated_at=?2,editing_state=?3,delivery_"
|
"current_revision_id=?1,updated_at=?2,editing_state=?3,delivery_"
|
||||||
"state=CASE WHEN delivery_state='local' THEN 'local' ELSE 'pending' "
|
"state=CASE WHEN delivery_state='local' THEN 'local' ELSE 'pending' "
|
||||||
"END WHERE preparation_id=?4 AND current_revision_id=?5",
|
"END WHERE preparation_id=?4 AND current_revision_id=?5 AND "
|
||||||
|
"withdrawn_at IS NULL",
|
||||||
-1,
|
-1,
|
||||||
&statement,
|
&statement,
|
||||||
NULL) != SQLITE_OK ||
|
NULL) != SQLITE_OK ||
|
||||||
|
|
@ -1135,7 +1162,8 @@ TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
|
||||||
if (sqlite3_prepare_v2(
|
if (sqlite3_prepare_v2(
|
||||||
database->connection,
|
database->connection,
|
||||||
"SELECT 1 FROM session_preparations p WHERE p.preparation_id=?1 AND "
|
"SELECT 1 FROM session_preparations p WHERE p.preparation_id=?1 AND "
|
||||||
"p.current_revision_id=?2 AND p.editing_state='ready' AND EXISTS(SELECT 1 FROM "
|
"p.current_revision_id=?2 AND p.withdrawn_at IS NULL AND p.editing_state='ready' "
|
||||||
|
"AND EXISTS(SELECT 1 FROM "
|
||||||
"session_preparation_entries e WHERE e.revision_id=p.current_revision_id)",
|
"session_preparation_entries e WHERE e.revision_id=p.current_revision_id)",
|
||||||
-1,
|
-1,
|
||||||
&statement,
|
&statement,
|
||||||
|
|
@ -1221,3 +1249,163 @@ delivery_rollback:
|
||||||
*output_size = 0U;
|
*output_size = 0U;
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_web_sessions_withdraw_json(TrainlogDatabase *database,
|
||||||
|
const char *preparation_id,
|
||||||
|
const char *expected_revision,
|
||||||
|
const char *request_id,
|
||||||
|
char **output_json,
|
||||||
|
size_t *output_size) {
|
||||||
|
sqlite3_stmt *statement = NULL;
|
||||||
|
char withdrawal_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||||
|
char now[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||||
|
yyjson_mut_doc *response = NULL;
|
||||||
|
yyjson_mut_val *root;
|
||||||
|
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
bool has_deliveries = false;
|
||||||
|
|
||||||
|
if (database == NULL || preparation_id == NULL || expected_revision == NULL ||
|
||||||
|
request_id == NULL || request_id[0] == '\0' || output_json == NULL || output_size == NULL ||
|
||||||
|
trainlog_id_generate("spw", withdrawal_id, sizeof(withdrawal_id)) != TRAINLOG_STATUS_OK ||
|
||||||
|
!timestamp_now(now)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
*output_json = NULL;
|
||||||
|
*output_size = 0U;
|
||||||
|
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
/* INVARIANT: a lost HTTP response may be replayed without creating a
|
||||||
|
* second withdrawal or reconsidering a now-inactive preparation. */
|
||||||
|
if (sqlite3_prepare_v2(database->connection,
|
||||||
|
"SELECT command,preparation_id,revision_id,response_json FROM "
|
||||||
|
"session_preparation_requests WHERE request_id=?1",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL) == SQLITE_OK &&
|
||||||
|
sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) == SQLITE_OK &&
|
||||||
|
sqlite3_step(statement) == SQLITE_ROW) {
|
||||||
|
const char *command = (const char *)sqlite3_column_text(statement, 0);
|
||||||
|
const char *saved_preparation = (const char *)sqlite3_column_text(statement, 1);
|
||||||
|
const char *saved_revision = (const char *)sqlite3_column_text(statement, 2);
|
||||||
|
const char *saved = (const char *)sqlite3_column_text(statement, 3);
|
||||||
|
if (command == NULL || saved_preparation == NULL || saved_revision == NULL ||
|
||||||
|
strcmp(command, "withdraw") != 0 || strcmp(saved_preparation, preparation_id) != 0 ||
|
||||||
|
strcmp(saved_revision, expected_revision) != 0) {
|
||||||
|
status = TRAINLOG_STATUS_CONFLICT;
|
||||||
|
goto withdraw_rollback;
|
||||||
|
}
|
||||||
|
*output_size = saved == NULL ? 0U : strlen(saved);
|
||||||
|
*output_json = saved == NULL ? NULL : strdup(saved);
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
(void)sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL);
|
||||||
|
return *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
if (statement != NULL) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
statement = NULL;
|
||||||
|
}
|
||||||
|
if (sqlite3_prepare_v2(database->connection,
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM session_preparation_deliveries d WHERE "
|
||||||
|
"d.preparation_id=p.preparation_id) FROM session_preparations p WHERE "
|
||||||
|
"p.preparation_id=?1 AND p.current_revision_id=?2 AND "
|
||||||
|
"p.withdrawn_at IS NULL",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_text(statement, 1, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_text(statement, 2, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
||||||
|
goto withdraw_rollback;
|
||||||
|
}
|
||||||
|
if (sqlite3_step(statement) != SQLITE_ROW) {
|
||||||
|
status = TRAINLOG_STATUS_CONFLICT;
|
||||||
|
goto withdraw_rollback;
|
||||||
|
}
|
||||||
|
has_deliveries = sqlite3_column_int(statement, 0) != 0;
|
||||||
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
statement = NULL;
|
||||||
|
goto withdraw_rollback;
|
||||||
|
}
|
||||||
|
statement = NULL;
|
||||||
|
/* CONTRACT: this transaction changes only planning availability and the
|
||||||
|
* exportable withdrawal ledger. Delivery/execution evidence is retained
|
||||||
|
* verbatim so a remotely started workout can never be erased here. */
|
||||||
|
if (sqlite3_prepare_v2(database->connection,
|
||||||
|
"INSERT INTO session_preparation_withdrawals VALUES("
|
||||||
|
"?1,?2,?3,?4,NULL,NULL)",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_text(statement, 1, withdrawal_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_text(statement, 4, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||||
|
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
statement = NULL;
|
||||||
|
goto withdraw_rollback;
|
||||||
|
}
|
||||||
|
statement = NULL;
|
||||||
|
if (sqlite3_prepare_v2(database->connection,
|
||||||
|
"UPDATE session_preparations SET withdrawn_at=?1,updated_at=?1 WHERE "
|
||||||
|
"preparation_id=?2 AND current_revision_id=?3 AND withdrawn_at IS NULL",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_text(statement, 1, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||||
|
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1 ||
|
||||||
|
sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
statement = NULL;
|
||||||
|
status = TRAINLOG_STATUS_CONFLICT;
|
||||||
|
goto withdraw_rollback;
|
||||||
|
}
|
||||||
|
statement = NULL;
|
||||||
|
response = yyjson_mut_doc_new(NULL);
|
||||||
|
root = response == NULL ? NULL : yyjson_mut_obj(response);
|
||||||
|
if (response == NULL || root == NULL) {
|
||||||
|
status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
goto withdraw_rollback;
|
||||||
|
}
|
||||||
|
yyjson_mut_doc_set_root(response, root);
|
||||||
|
(void)yyjson_mut_obj_add_uint(response, root, "api_version", 1U);
|
||||||
|
(void)yyjson_mut_obj_add_strcpy(response, root, "preparation_id", preparation_id);
|
||||||
|
(void)yyjson_mut_obj_add_strcpy(response, root, "revision_id", expected_revision);
|
||||||
|
(void)yyjson_mut_obj_add_strcpy(response, root, "withdrawal_id", withdrawal_id);
|
||||||
|
(void)yyjson_mut_obj_add_strcpy(response,
|
||||||
|
root,
|
||||||
|
"android_cancellation",
|
||||||
|
has_deliveries ? "pending" : "not_required");
|
||||||
|
status = write_document(response, output_json, output_size);
|
||||||
|
response = NULL;
|
||||||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
|
sqlite3_prepare_v2(database->connection,
|
||||||
|
"INSERT INTO session_preparation_requests VALUES("
|
||||||
|
"?1,'withdraw',?2,?3,?4,?5)",
|
||||||
|
-1,
|
||||||
|
&statement,
|
||||||
|
NULL) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_text(statement, 4, *output_json, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||||
|
sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||||
|
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK ||
|
||||||
|
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) {
|
||||||
|
statement = NULL;
|
||||||
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
goto withdraw_rollback;
|
||||||
|
}
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
|
||||||
|
withdraw_rollback:
|
||||||
|
if (statement != NULL) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
}
|
||||||
|
yyjson_mut_doc_free(response);
|
||||||
|
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
|
||||||
|
free(*output_json);
|
||||||
|
*output_json = NULL;
|
||||||
|
*output_size = 0U;
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,14 @@ int main(void)
|
||||||
TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK);
|
TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,0U)==TRAINLOG_STATUS_OK);
|
||||||
trainlog_database_close(database);database=NULL;
|
trainlog_database_close(database);database=NULL;
|
||||||
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
|
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
|
||||||
CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE session_preparation_deliveries;DROP TABLE session_preparation_requests;DROP TABLE session_preparation_entries;DROP TABLE session_preparation_revisions;DROP TABLE session_preparations;DROP TABLE sync_generation_archives;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK);
|
CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE session_preparation_withdrawals;DROP TABLE session_preparation_deliveries;DROP TABLE session_preparation_requests;DROP TABLE session_preparation_entries;DROP TABLE session_preparation_revisions;DROP TABLE session_preparations;DROP TABLE sync_generation_archives;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK);
|
||||||
CHECK(sqlite3_close(raw)==SQLITE_OK);
|
CHECK(sqlite3_close(raw)==SQLITE_OK);
|
||||||
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
|
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
|
||||||
trainlog_database_close(database);database=NULL;
|
trainlog_database_close(database);database=NULL;
|
||||||
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
|
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
|
||||||
CHECK(scalar(raw,"PRAGMA user_version")==23);
|
CHECK(scalar(raw,"PRAGMA user_version")==TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||||
|
CHECK(scalar(raw,"SELECT COUNT(*) FROM session_preparation_withdrawals")==0);
|
||||||
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0);
|
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0);
|
||||||
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0);
|
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0);
|
||||||
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0);
|
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0);
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strncmp(diagnostic, "migrate database to schema v23: SQLite rc=",
|
CHECK(strncmp(diagnostic, "migrate database to schema v24: SQLite rc=",
|
||||||
strlen("migrate database to schema v23: SQLite rc=")) == 0);
|
strlen("migrate database to schema v24: SQLite rc=")) == 0);
|
||||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||||
|
|
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
|
||||||
CHECK(fd >= 0);
|
CHECK(fd >= 0);
|
||||||
CHECK(close(fd) == 0);
|
CHECK(close(fd) == 0);
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=24;", NULL, NULL, NULL) == SQLITE_OK);
|
CHECK(sqlite3_exec(raw, "PRAGMA user_version=25;", NULL, NULL, NULL) == SQLITE_OK);
|
||||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "schema version 24 is newer") != NULL);
|
CHECK(strstr(diagnostic, "schema version 25 is newer") != NULL);
|
||||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||||
CHECK(unlink(path) == 0);
|
CHECK(unlink(path) == 0);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "migrate database to schema v23") != NULL);
|
CHECK(strstr(diagnostic, "migrate database to schema v24") != NULL);
|
||||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||||
|
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
#include "trainlog/database.h"
|
#include "trainlog/database.h"
|
||||||
#include "trainlog/dashboard_layout.h"
|
#include "trainlog/dashboard_layout.h"
|
||||||
#include "trainlog/web_server.h"
|
#include "trainlog/web_server.h"
|
||||||
|
#include "trainlog/web_sessions.h"
|
||||||
#include "web_assets.h"
|
#include "web_assets.h"
|
||||||
|
|
||||||
#define CHECK(value) \
|
#define CHECK(value) \
|
||||||
|
|
@ -149,6 +150,25 @@ static bool response_header(const char *response, const char *name, char *output
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool json_string(const char *json, const char *key, char *output, size_t capacity) {
|
||||||
|
char marker[96];
|
||||||
|
const char *start;
|
||||||
|
const char *end;
|
||||||
|
size_t length;
|
||||||
|
|
||||||
|
CHECK(snprintf(marker, sizeof(marker), "\"%s\":\"", key) > 0);
|
||||||
|
start = strstr(json, marker);
|
||||||
|
CHECK(start != NULL);
|
||||||
|
start += strlen(marker);
|
||||||
|
end = strchr(start, '"');
|
||||||
|
CHECK(end != NULL);
|
||||||
|
length = (size_t)(end - start);
|
||||||
|
CHECK(length > 0U && length < capacity);
|
||||||
|
(void)memcpy(output, start, length);
|
||||||
|
output[length] = '\0';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static bool test_sync_accepted_serialization(void) {
|
static bool test_sync_accepted_serialization(void) {
|
||||||
static const char REQUEST_ID[] = "sy_11111111-1111-4111-8111-111111111111";
|
static const char REQUEST_ID[] = "sy_11111111-1111-4111-8111-111111111111";
|
||||||
static const char RUN_ID[] = "sy_22222222-2222-4222-8222-222222222222";
|
static const char RUN_ID[] = "sy_22222222-2222-4222-8222-222222222222";
|
||||||
|
|
@ -175,6 +195,13 @@ static bool test_sync_accepted_serialization(void) {
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool test_http_contract(TrainlogDatabase *database) {
|
static bool test_http_contract(TrainlogDatabase *database) {
|
||||||
|
static const char READY_BODY[] =
|
||||||
|
"{\"title\":\"Préparation HTTP\",\"session_type\":\"training\","
|
||||||
|
"\"planned_for\":\"2026-09-20\",\"notes\":null,\"editing_state\":\"ready\","
|
||||||
|
"\"occurrences\":[{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
|
||||||
|
"\"equipment_id\":null,\"load_mode\":\"none\",\"rest_seconds\":60,"
|
||||||
|
"\"target_sets\":2,\"target_reps\":8,\"target_duration_seconds\":null,"
|
||||||
|
"\"target_weight_kg\":null,\"notes\":null}]}";
|
||||||
char response[32768];
|
char response[32768];
|
||||||
char large_request[12000];
|
char large_request[12000];
|
||||||
char request[8192];
|
char request[8192];
|
||||||
|
|
@ -184,6 +211,30 @@ static bool test_http_contract(TrainlogDatabase *database) {
|
||||||
pid_t child;
|
pid_t child;
|
||||||
int status;
|
int status;
|
||||||
struct sigaction action;
|
struct sigaction action;
|
||||||
|
char *created = NULL;
|
||||||
|
size_t created_size = 0U;
|
||||||
|
char preparation_id[128];
|
||||||
|
char revision_id[128];
|
||||||
|
|
||||||
|
CHECK(trainlog_database_insert_exercise_profiled(database,
|
||||||
|
"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
"Exercice HTTP",
|
||||||
|
"exercice http",
|
||||||
|
TRAINLOG_TRACKING_REPS,
|
||||||
|
TRAINLOG_RECORDING_SETS,
|
||||||
|
0U) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(trainlog_web_sessions_save_json(database,
|
||||||
|
NULL,
|
||||||
|
"",
|
||||||
|
"request-http-create",
|
||||||
|
READY_BODY,
|
||||||
|
strlen(READY_BODY),
|
||||||
|
&created,
|
||||||
|
&created_size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(created_size > 0U);
|
||||||
|
CHECK(json_string(created, "preparation_id", preparation_id, sizeof(preparation_id)));
|
||||||
|
CHECK(json_string(created, "revision_id", revision_id, sizeof(revision_id)));
|
||||||
|
free(created);
|
||||||
(void)unlink("/tmp/trainlog-web-test.db.sync-run.json");
|
(void)unlink("/tmp/trainlog-web-test.db.sync-run.json");
|
||||||
CHECK(reserve_port(&port, 0) == -2);
|
CHECK(reserve_port(&port, 0) == -2);
|
||||||
child = fork();
|
child = fork();
|
||||||
|
|
@ -243,6 +294,42 @@ static bool test_http_contract(TrainlogDatabase *database) {
|
||||||
response,
|
response,
|
||||||
sizeof(response)) &&
|
sizeof(response)) &&
|
||||||
strstr(response, "HTTP/1.1 405") != NULL);
|
strstr(response, "HTTP/1.1 405") != NULL);
|
||||||
|
(void)snprintf(
|
||||||
|
request,
|
||||||
|
sizeof(request),
|
||||||
|
"DELETE /api/v1/sessions/preparation/%s HTTP/1.1\r\n"
|
||||||
|
"Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n"
|
||||||
|
"Content-Type: application/json\r\nIf-Match: \"%s\"\r\n"
|
||||||
|
"X-Trainlog-CSRF-Token: wrong\r\nX-Trainlog-Request-ID: request-http-delete\r\n"
|
||||||
|
"Content-Length: 2\r\n\r\n{}",
|
||||||
|
preparation_id,
|
||||||
|
(unsigned int)port,
|
||||||
|
revision_id);
|
||||||
|
CHECK(exchange(port, request, response, sizeof(response)) &&
|
||||||
|
strstr(response, "HTTP/1.1 403") != NULL);
|
||||||
|
(void)snprintf(
|
||||||
|
request,
|
||||||
|
sizeof(request),
|
||||||
|
"DELETE /api/v1/sessions/preparation/%s HTTP/1.1\r\n"
|
||||||
|
"Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n"
|
||||||
|
"Content-Type: application/json\r\nIf-Match: \"%s\"\r\n"
|
||||||
|
"X-Trainlog-CSRF-Token: %s\r\nX-Trainlog-Request-ID: request-http-delete\r\n"
|
||||||
|
"Content-Length: 2\r\n\r\n{}",
|
||||||
|
preparation_id,
|
||||||
|
(unsigned int)port,
|
||||||
|
revision_id,
|
||||||
|
token);
|
||||||
|
CHECK(exchange(port, request, response, sizeof(response)) &&
|
||||||
|
strstr(response, "HTTP/1.1 200") != NULL &&
|
||||||
|
strstr(response, "\"android_cancellation\":\"not_required\"") != NULL);
|
||||||
|
(void)snprintf(request,
|
||||||
|
sizeof(request),
|
||||||
|
"GET /api/v1/sessions/preparation/%s HTTP/1.1\r\n"
|
||||||
|
"Host: 127.0.0.1\r\n\r\n",
|
||||||
|
preparation_id);
|
||||||
|
CHECK(exchange(port, request, response, sizeof(response)) &&
|
||||||
|
strstr(response, "HTTP/1.1 200") != NULL &&
|
||||||
|
strstr(response, "\"state\":\"withdrawn\"") != NULL);
|
||||||
}
|
}
|
||||||
CHECK(exchange(port,
|
CHECK(exchange(port,
|
||||||
"GET /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
|
"GET /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
|
||||||
|
|
@ -262,6 +349,64 @@ static bool test_http_contract(TrainlogDatabase *database) {
|
||||||
strstr(response, "HTTP/1.1 200") != NULL);
|
strstr(response, "HTTP/1.1 200") != NULL);
|
||||||
CHECK(strstr(response, "\"api_version\":1") != NULL);
|
CHECK(strstr(response, "\"api_version\":1") != NULL);
|
||||||
CHECK(strstr(response, "\"items\":[]") != NULL);
|
CHECK(strstr(response, "\"items\":[]") != NULL);
|
||||||
|
{
|
||||||
|
char token[80];
|
||||||
|
char etag[32];
|
||||||
|
CHECK(exchange(port,
|
||||||
|
"GET /api/v1/web-preferences HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
|
||||||
|
response,
|
||||||
|
sizeof(response)) &&
|
||||||
|
strstr(response, "HTTP/1.1 200") != NULL &&
|
||||||
|
strstr(response, "\"date_format\":\"fr\"") != NULL &&
|
||||||
|
strstr(response, "\"source\":\"default\"") != NULL);
|
||||||
|
CHECK(response_header(response, "ETag: ", etag, sizeof(etag)) &&
|
||||||
|
strcmp(etag, "\"0\"") == 0);
|
||||||
|
CHECK(response_header(response, "X-Trainlog-CSRF-Token: ", token, sizeof(token)));
|
||||||
|
(void)snprintf(
|
||||||
|
request,
|
||||||
|
sizeof(request),
|
||||||
|
"PUT /api/v1/web-preferences HTTP/1.1\r\nHost: 127.0.0.1\r\n"
|
||||||
|
"Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
|
||||||
|
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\nContent-Length: 82\r\n\r\n"
|
||||||
|
"{\"format\":\"trainlog-web-preferences\",\"version\":1,\"revision\":0,"
|
||||||
|
"\"date_format\":\"iso\"}",
|
||||||
|
(unsigned int)port,
|
||||||
|
token);
|
||||||
|
CHECK(exchange(port, request, response, sizeof(response)) &&
|
||||||
|
strstr(response, "HTTP/1.1 200") != NULL &&
|
||||||
|
strstr(response, "\"revision\":1") != NULL &&
|
||||||
|
strstr(response, "\"date_format\":\"iso\"") != NULL &&
|
||||||
|
strstr(response, "\"source\":\"persisted\"") != NULL);
|
||||||
|
CHECK(exchange(port,
|
||||||
|
"GET /api/v1/web-preferences HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
|
||||||
|
response,
|
||||||
|
sizeof(response)) &&
|
||||||
|
strstr(response, "HTTP/1.1 200") != NULL &&
|
||||||
|
strstr(response, "\"revision\":1") != NULL &&
|
||||||
|
strstr(response, "\"date_format\":\"iso\"") != NULL);
|
||||||
|
{
|
||||||
|
char path[4096];
|
||||||
|
FILE *stream;
|
||||||
|
const char *config = getenv("XDG_CONFIG_HOME");
|
||||||
|
|
||||||
|
CHECK(config != NULL);
|
||||||
|
CHECK(snprintf(path,
|
||||||
|
sizeof(path),
|
||||||
|
"%s/trainlog/web/preferences-v1.json",
|
||||||
|
config) > 0);
|
||||||
|
stream = fopen(path, "w");
|
||||||
|
CHECK(stream != NULL);
|
||||||
|
CHECK(fputs("invalid preference\n", stream) >= 0);
|
||||||
|
CHECK(fclose(stream) == 0);
|
||||||
|
}
|
||||||
|
CHECK(exchange(port,
|
||||||
|
"GET /api/v1/web-preferences HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
|
||||||
|
response,
|
||||||
|
sizeof(response)) &&
|
||||||
|
strstr(response, "HTTP/1.1 200") != NULL &&
|
||||||
|
strstr(response, "\"date_format\":\"fr\"") != NULL &&
|
||||||
|
strstr(response, "\"source\":\"invalid_persisted\"") != NULL);
|
||||||
|
}
|
||||||
CHECK(exchange(port,
|
CHECK(exchange(port,
|
||||||
"POST /api/v1/prepared-items HTTP/1.1\r\nHost: 127.0.0.1\r\n"
|
"POST /api/v1/prepared-items HTTP/1.1\r\nHost: 127.0.0.1\r\n"
|
||||||
"Content-Length: 0\r\n\r\n",
|
"Content-Length: 0\r\n\r\n",
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,33 @@ static int scalar(TrainlogDatabase *database, const char *sql) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool json_string(const char *json, const char *key, char *output, size_t capacity) {
|
||||||
|
char marker[96];
|
||||||
|
const char *start;
|
||||||
|
const char *end;
|
||||||
|
size_t length;
|
||||||
|
|
||||||
|
if (snprintf(marker, sizeof(marker), "\"%s\":\"", key) < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
start = strstr(json, marker);
|
||||||
|
if (start == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
start += strlen(marker);
|
||||||
|
end = strchr(start, '"');
|
||||||
|
if (end == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
length = (size_t)(end - start);
|
||||||
|
if (length == 0U || length >= capacity) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
(void)memcpy(output, start, length);
|
||||||
|
output[length] = '\0';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static bool creation_replay_revision_and_pagination(void) {
|
static bool creation_replay_revision_and_pagination(void) {
|
||||||
static const char CREATE_BODY[] =
|
static const char CREATE_BODY[] =
|
||||||
"{\"title\":\"Séance échappée \\\"A\\\"\",\"session_type\":\"training\","
|
"{\"title\":\"Séance échappée \\\"A\\\"\",\"session_type\":\"training\","
|
||||||
|
|
@ -178,6 +205,186 @@ static bool creation_replay_revision_and_pagination(void) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
int main(void) {
|
static bool withdrawal_is_durable_and_preserves_evidence(void) {
|
||||||
return creation_replay_revision_and_pagination() ? 0 : 1;
|
static const char READY_BODY[] =
|
||||||
|
"{\"title\":\"Préparation à retirer\",\"session_type\":\"training\","
|
||||||
|
"\"planned_for\":\"2026-09-20\",\"notes\":null,\"editing_state\":\"ready\","
|
||||||
|
"\"occurrences\":[{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
|
||||||
|
"\"equipment_id\":null,\"load_mode\":\"none\",\"rest_seconds\":60,"
|
||||||
|
"\"target_sets\":2,\"target_reps\":8,\"target_duration_seconds\":null,"
|
||||||
|
"\"target_weight_kg\":null,\"notes\":null}],\"source_proposal_id\":"
|
||||||
|
"\"aid_11111111-1111-4111-8111-111111111111\",\"source_payload_sha256\":"
|
||||||
|
"\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}";
|
||||||
|
static const char EDIT_BODY[] =
|
||||||
|
"{\"title\":\"Modification interdite\",\"session_type\":\"training\","
|
||||||
|
"\"planned_for\":null,\"notes\":null,\"editing_state\":\"draft\","
|
||||||
|
"\"occurrences\":[]}";
|
||||||
|
static const char LOCAL_READY_BODY[] =
|
||||||
|
"{\"title\":\"Préparation locale\",\"session_type\":\"training\","
|
||||||
|
"\"planned_for\":null,\"notes\":null,\"editing_state\":\"ready\","
|
||||||
|
"\"occurrences\":[{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
|
||||||
|
"\"equipment_id\":null,\"load_mode\":\"none\",\"rest_seconds\":0,"
|
||||||
|
"\"target_sets\":1,\"target_reps\":1,\"target_duration_seconds\":null,"
|
||||||
|
"\"target_weight_kg\":null,\"notes\":null}]}";
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
TrainlogWebSessionsPageQuery query = {
|
||||||
|
TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 64U, ""};
|
||||||
|
char *created = NULL;
|
||||||
|
char *delivery = NULL;
|
||||||
|
char *withdrawal = NULL;
|
||||||
|
char *replayed = NULL;
|
||||||
|
char *detail = NULL;
|
||||||
|
char *page = NULL;
|
||||||
|
size_t created_size = 0U;
|
||||||
|
size_t delivery_size = 0U;
|
||||||
|
size_t withdrawal_size = 0U;
|
||||||
|
size_t replayed_size = 0U;
|
||||||
|
size_t detail_size = 0U;
|
||||||
|
size_t page_size = 0U;
|
||||||
|
char preparation_id[128];
|
||||||
|
char revision_id[128];
|
||||||
|
char local_preparation_id[128];
|
||||||
|
char local_revision_id[128];
|
||||||
|
|
||||||
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(trainlog_database_insert_exercise_profiled(database,
|
||||||
|
"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
"Exercice retrait",
|
||||||
|
"exercice retrait",
|
||||||
|
TRAINLOG_TRACKING_REPS,
|
||||||
|
TRAINLOG_RECORDING_SETS,
|
||||||
|
0U) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(sqlite3_exec(database->connection,
|
||||||
|
"INSERT INTO ai_session_drafts(draft_id,created_at,session_type,title) "
|
||||||
|
"VALUES('aid_11111111-1111-4111-8111-111111111111',"
|
||||||
|
"'2026-09-18T00:00:00Z','training','Proposition conservée');"
|
||||||
|
"INSERT INTO ai_session_draft_imports VALUES(last_insert_rowid(),"
|
||||||
|
"'aid_11111111-1111-4111-8111-111111111111',"
|
||||||
|
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
|
||||||
|
"'2026-09-18T00:00:00Z');",
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL) == SQLITE_OK);
|
||||||
|
CHECK(trainlog_web_sessions_save_json(database,
|
||||||
|
NULL,
|
||||||
|
"",
|
||||||
|
"request-withdraw-create",
|
||||||
|
READY_BODY,
|
||||||
|
strlen(READY_BODY),
|
||||||
|
&created,
|
||||||
|
&created_size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(json_string(created, "preparation_id", preparation_id, sizeof(preparation_id)));
|
||||||
|
CHECK(json_string(created, "revision_id", revision_id, sizeof(revision_id)));
|
||||||
|
CHECK(trainlog_web_sessions_withdraw_json(database,
|
||||||
|
preparation_id,
|
||||||
|
"spr_ffffffff-ffff-4fff-8fff-ffffffffffff",
|
||||||
|
"request-withdraw-stale",
|
||||||
|
&withdrawal,
|
||||||
|
&withdrawal_size) == TRAINLOG_STATUS_CONFLICT);
|
||||||
|
CHECK(withdrawal == NULL && withdrawal_size == 0U);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_withdrawals") == 0);
|
||||||
|
CHECK(trainlog_web_sessions_deliver_json(database,
|
||||||
|
preparation_id,
|
||||||
|
revision_id,
|
||||||
|
"request-withdraw-deliver",
|
||||||
|
&delivery,
|
||||||
|
&delivery_size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(trainlog_web_sessions_withdraw_json(database,
|
||||||
|
preparation_id,
|
||||||
|
revision_id,
|
||||||
|
"request-withdraw-exact",
|
||||||
|
&withdrawal,
|
||||||
|
&withdrawal_size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(strstr(withdrawal, "\"android_cancellation\":\"pending\"") != NULL);
|
||||||
|
CHECK(trainlog_web_sessions_withdraw_json(database,
|
||||||
|
preparation_id,
|
||||||
|
revision_id,
|
||||||
|
"request-withdraw-exact",
|
||||||
|
&replayed,
|
||||||
|
&replayed_size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(withdrawal_size == replayed_size && strcmp(withdrawal, replayed) == 0);
|
||||||
|
free(replayed);
|
||||||
|
replayed = NULL;
|
||||||
|
replayed_size = 0U;
|
||||||
|
CHECK(trainlog_web_sessions_withdraw_json(database,
|
||||||
|
"sp_ffffffff-ffff-4fff-8fff-ffffffffffff",
|
||||||
|
revision_id,
|
||||||
|
"request-withdraw-exact",
|
||||||
|
&replayed,
|
||||||
|
&replayed_size) == TRAINLOG_STATUS_CONFLICT);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_withdrawals") == 1);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 1);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 1);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM ai_session_drafts") == 1);
|
||||||
|
CHECK(scalar(database,
|
||||||
|
"SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='pending'") ==
|
||||||
|
1);
|
||||||
|
CHECK(trainlog_web_sessions_list_json(database, &query, &page, &page_size) ==
|
||||||
|
TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(strstr(page, preparation_id) == NULL);
|
||||||
|
CHECK(trainlog_web_sessions_detail_json(
|
||||||
|
database, "preparation", preparation_id, &detail, &detail_size) ==
|
||||||
|
TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(strstr(detail, "\"state\":\"withdrawn\"") != NULL);
|
||||||
|
CHECK(strstr(detail, "\"source_proposal_title\":\"Proposition conservée\"") != NULL);
|
||||||
|
CHECK(strstr(detail, "\"withdrawal_id\":\"spw_") != NULL);
|
||||||
|
CHECK(trainlog_web_sessions_save_json(database,
|
||||||
|
preparation_id,
|
||||||
|
revision_id,
|
||||||
|
"request-after-withdraw-save",
|
||||||
|
EDIT_BODY,
|
||||||
|
strlen(EDIT_BODY),
|
||||||
|
&replayed,
|
||||||
|
&replayed_size) == TRAINLOG_STATUS_NOT_FOUND);
|
||||||
|
CHECK(trainlog_web_sessions_deliver_json(database,
|
||||||
|
preparation_id,
|
||||||
|
revision_id,
|
||||||
|
"request-after-withdraw-deliver",
|
||||||
|
&replayed,
|
||||||
|
&replayed_size) == TRAINLOG_STATUS_CONFLICT);
|
||||||
|
free(replayed);
|
||||||
|
replayed = NULL;
|
||||||
|
replayed_size = 0U;
|
||||||
|
CHECK(trainlog_web_sessions_save_json(database,
|
||||||
|
NULL,
|
||||||
|
"",
|
||||||
|
"request-local-create",
|
||||||
|
LOCAL_READY_BODY,
|
||||||
|
strlen(LOCAL_READY_BODY),
|
||||||
|
&replayed,
|
||||||
|
&replayed_size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(json_string(replayed,
|
||||||
|
"preparation_id",
|
||||||
|
local_preparation_id,
|
||||||
|
sizeof(local_preparation_id)));
|
||||||
|
CHECK(json_string(replayed, "revision_id", local_revision_id, sizeof(local_revision_id)));
|
||||||
|
free(replayed);
|
||||||
|
replayed = NULL;
|
||||||
|
replayed_size = 0U;
|
||||||
|
CHECK(trainlog_web_sessions_withdraw_json(database,
|
||||||
|
local_preparation_id,
|
||||||
|
local_revision_id,
|
||||||
|
"request-local-withdraw",
|
||||||
|
&replayed,
|
||||||
|
&replayed_size) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(strstr(replayed, "\"android_cancellation\":\"not_required\"") != NULL);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_withdrawals") == 2);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 1);
|
||||||
|
CHECK(scalar(database, "SELECT COUNT(*) FROM ai_session_drafts") == 1);
|
||||||
|
|
||||||
|
free(created);
|
||||||
|
free(delivery);
|
||||||
|
free(withdrawal);
|
||||||
|
free(replayed);
|
||||||
|
free(detail);
|
||||||
|
free(page);
|
||||||
|
trainlog_database_close(database);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
return creation_replay_revision_and_pagination() &&
|
||||||
|
withdrawal_is_durable_and_preserves_evidence()
|
||||||
|
? 0
|
||||||
|
: 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ describe('projection des préparations', () => {
|
||||||
generated_at: '2026-09-17T12:00:00Z',
|
generated_at: '2026-09-17T12:00:00Z',
|
||||||
partial: false,
|
partial: false,
|
||||||
items: [
|
items: [
|
||||||
{ identity: 'aid_one', kind: 'ai_proposal', title: 'Plan', planned_for: '2026-09-18', state: 'published', occurrence_count: 6, provenance: 'ai_import' },
|
{ identity: 'aid_one', kind: 'ai_proposal', title: 'Plan', planned_for: '2026-09-18', state: 'published', sort_timestamp: '2026-09-17T12:00:00Z', occurrence_count: 6, provenance: 'ai_import' },
|
||||||
{ identity: 'se_one', kind: 'execution_draft', title: 'training', planned_for: null, state: 'active', occurrence_count: 2, provenance: 'execution_store' },
|
{ identity: 'se_one', kind: 'execution_draft', title: 'training', planned_for: null, state: 'active', sort_timestamp: '2026-09-17T13:00:00Z', occurrence_count: 2, provenance: 'execution_store' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
expect(value.items.map((item) => item.kind)).toEqual(['ai_proposal', 'execution_draft'])
|
expect(value.items.map((item) => item.kind)).toEqual(['ai_proposal', 'execution_draft'])
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export type PreparedItemKind = 'ai_proposal' | 'execution_draft'
|
export type PreparedItemKind = 'ai_proposal' | 'execution_draft' | 'manual_preparation'
|
||||||
|
|
||||||
export interface PreparedItem {
|
export interface PreparedItem {
|
||||||
identity: string
|
identity: string
|
||||||
|
|
@ -6,6 +6,7 @@ export interface PreparedItem {
|
||||||
title: string
|
title: string
|
||||||
planned_for: string | null
|
planned_for: string | null
|
||||||
state: string
|
state: string
|
||||||
|
sort_timestamp: string
|
||||||
occurrence_count: number
|
occurrence_count: number
|
||||||
provenance: string
|
provenance: string
|
||||||
}
|
}
|
||||||
|
|
@ -24,11 +25,12 @@ function record(value: unknown): value is Record<string, unknown> {
|
||||||
function validItem(value: unknown): value is PreparedItem {
|
function validItem(value: unknown): value is PreparedItem {
|
||||||
if (!record(value)) return false
|
if (!record(value)) return false
|
||||||
const kind = value.kind
|
const kind = value.kind
|
||||||
return (kind === 'ai_proposal' || kind === 'execution_draft') &&
|
return (kind === 'ai_proposal' || kind === 'execution_draft' || kind === 'manual_preparation') &&
|
||||||
typeof value.identity === 'string' &&
|
typeof value.identity === 'string' &&
|
||||||
typeof value.title === 'string' &&
|
typeof value.title === 'string' &&
|
||||||
(value.planned_for === null || typeof value.planned_for === 'string') &&
|
(value.planned_for === null || typeof value.planned_for === 'string') &&
|
||||||
typeof value.state === 'string' &&
|
typeof value.state === 'string' &&
|
||||||
|
typeof value.sort_timestamp === 'string' &&
|
||||||
Number.isSafeInteger(value.occurrence_count) &&
|
Number.isSafeInteger(value.occurrence_count) &&
|
||||||
Number(value.occurrence_count) >= 0 &&
|
Number(value.occurrence_count) >= 0 &&
|
||||||
typeof value.provenance === 'string'
|
typeof value.provenance === 'string'
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,11 @@ export interface SessionDetail {
|
||||||
session_type: 'training' | 'max_test'
|
session_type: 'training' | 'max_test'
|
||||||
notes: string | null
|
notes: string | null
|
||||||
source_fingerprint: string | null
|
source_fingerprint: string | null
|
||||||
|
source_proposal_id?: string | null
|
||||||
|
source_proposal_title?: string | null
|
||||||
|
withdrawn_at?: string | null
|
||||||
|
withdrawal_id?: string | null
|
||||||
|
withdrawal_acknowledged_at?: string | null
|
||||||
state: string
|
state: string
|
||||||
occurrences: PreparationOccurrence[]
|
occurrences: PreparationOccurrence[]
|
||||||
}
|
}
|
||||||
|
|
@ -221,3 +226,41 @@ export async function prepareForAndroid(identity: string, revision: string): Pro
|
||||||
throw new Error(reason)
|
throw new Error(reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PreparationWithdrawalResult {
|
||||||
|
preparation_id: string
|
||||||
|
revision_id: string
|
||||||
|
withdrawal_id: string
|
||||||
|
android_cancellation: 'pending' | 'not_required'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withdrawPreparation(
|
||||||
|
identity: string,
|
||||||
|
revision: string,
|
||||||
|
): Promise<PreparationWithdrawalResult> {
|
||||||
|
const csrf = await mutationCsrfToken()
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/sessions/preparation/${encodeURIComponent(identity)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Trainlog-CSRF-Token': csrf,
|
||||||
|
'X-Trainlog-Request-ID': requestId(),
|
||||||
|
'If-Match': `"${revision}"`,
|
||||||
|
},
|
||||||
|
body: '{}',
|
||||||
|
})
|
||||||
|
const value: unknown = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
const reason = object(value) && typeof value.error === 'string'
|
||||||
|
? value.error : `HTTP ${response.status}`
|
||||||
|
throw new Error(reason)
|
||||||
|
}
|
||||||
|
if (!object(value) || value.preparation_id !== identity || value.revision_id !== revision ||
|
||||||
|
typeof value.withdrawal_id !== 'string' ||
|
||||||
|
!['pending', 'not_required'].includes(String(value.android_cancellation))) {
|
||||||
|
throw new TypeError('réponse de suppression invalide')
|
||||||
|
}
|
||||||
|
return value as unknown as PreparationWithdrawalResult
|
||||||
|
}
|
||||||
|
|
|
||||||
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))))
|
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, actual, health))))
|
||||||
render(<App />)
|
render(<App />)
|
||||||
await waitFor(() => expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('fy59'))
|
await waitFor(() => expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('fy59'))
|
||||||
expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('2026-09-15T10:00:00+02:00')
|
expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('15/09/2026')
|
||||||
expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('DOS · BRAS')
|
expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('DOS · BRAS')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { PlaceholderPage } from '../routes/PlaceholderPage'
|
||||||
import { SessionsPage } from '../routes/SessionsPage'
|
import { SessionsPage } from '../routes/SessionsPage'
|
||||||
import type { RouteId } from './routes'
|
import type { RouteId } from './routes'
|
||||||
import { useRoute } from './useRoute'
|
import { useRoute } from './useRoute'
|
||||||
|
import { DatePreferencesProvider } from '../presentation/DatePreferences'
|
||||||
|
|
||||||
const placeholderContent: Record<Exclude<RouteId, 'dashboard'>, [string, string, string]> = {
|
const placeholderContent: Record<Exclude<RouteId, 'dashboard'>, [string, string, string]> = {
|
||||||
analysis: ['Analyse', 'COMPRENDRE', 'Les analyses détaillées apparaîtront ici lorsque leurs read models Core seront disponibles.'],
|
analysis: ['Analyse', 'COMPRENDRE', 'Les analyses détaillées apparaîtront ici lorsque leurs read models Core seront disponibles.'],
|
||||||
|
|
@ -17,7 +18,7 @@ const placeholderContent: Record<Exclude<RouteId, 'dashboard'>, [string, string,
|
||||||
exercises: ['Exercices', 'EXPLORER', 'Le catalogue canonique des exercices sera consultable depuis cette page.'],
|
exercises: ['Exercices', 'EXPLORER', 'Le catalogue canonique des exercices sera consultable depuis cette page.'],
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
function AppContent() {
|
||||||
const [route, navigate] = useRoute()
|
const [route, navigate] = useRoute()
|
||||||
const [health, setHealth] = useState<Health | null>(null)
|
const [health, setHealth] = useState<Health | null>(null)
|
||||||
const [healthPending, setHealthPending] = useState(true)
|
const [healthPending, setHealthPending] = useState(true)
|
||||||
|
|
@ -90,3 +91,7 @@ export function App() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return <DatePreferencesProvider><AppContent /></DatePreferencesProvider>
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { SVGRenderer } from 'echarts/renderers'
|
||||||
import type { ProgressionIdentity, ProgressionPoint } from '../api/dashboard'
|
import type { ProgressionIdentity, ProgressionPoint } from '../api/dashboard'
|
||||||
import { formatDateTime, formatDose, formatWeight } from '../dashboard/dashboardFormat'
|
import { formatDateTime, formatDose, formatWeight } from '../dashboard/dashboardFormat'
|
||||||
import { trainlogChartTheme } from './trainlogChartTheme'
|
import { trainlogChartTheme } from './trainlogChartTheme'
|
||||||
|
import { useDatePreferences } from '../presentation/DatePreferences'
|
||||||
|
import type { WebDateFormat } from '../api/webPreferences'
|
||||||
|
|
||||||
echarts.use([LineChart, GridComponent, TooltipComponent, SVGRenderer])
|
echarts.use([LineChart, GridComponent, TooltipComponent, SVGRenderer])
|
||||||
|
|
||||||
|
|
@ -50,9 +52,13 @@ export function progressionCollisionMetadata(points: ProgressionPoint[]): Array<
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function progressionTooltip(identity: ProgressionIdentity, point: ProgressionPoint): string {
|
export function progressionTooltip(
|
||||||
|
identity: ProgressionIdentity,
|
||||||
|
point: ProgressionPoint,
|
||||||
|
dateFormat: WebDateFormat = 'fr',
|
||||||
|
): string {
|
||||||
return [
|
return [
|
||||||
formatDateTime(point.timestamp),
|
formatDateTime(point.timestamp, dateFormat),
|
||||||
formatWeight(point.weight_kg),
|
formatWeight(point.weight_kg),
|
||||||
identity.exercise_name,
|
identity.exercise_name,
|
||||||
identity.equipment_label,
|
identity.equipment_label,
|
||||||
|
|
@ -62,6 +68,7 @@ export function progressionTooltip(identity: ProgressionIdentity, point: Progres
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProgressionChart({ identity, points }: ProgressionChartProps) {
|
export function ProgressionChart({ identity, points }: ProgressionChartProps) {
|
||||||
|
const { dateFormat } = useDatePreferences()
|
||||||
const host = useRef<HTMLDivElement>(null)
|
const host = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -91,7 +98,7 @@ export function ProgressionChart({ identity, points }: ProgressionChartProps) {
|
||||||
formatter: (params: unknown) => {
|
formatter: (params: unknown) => {
|
||||||
const candidate = Array.isArray(params) ? params[0] : params
|
const candidate = Array.isArray(params) ? params[0] : params
|
||||||
const datum = (candidate as { data?: ChartDatum } | undefined)?.data
|
const datum = (candidate as { data?: ChartDatum } | undefined)?.data
|
||||||
return datum === undefined ? '' : progressionTooltip(identity, datum.point)
|
return datum === undefined ? '' : progressionTooltip(identity, datum.point, dateFormat)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
|
|
@ -112,7 +119,7 @@ export function ProgressionChart({ identity, points }: ProgressionChartProps) {
|
||||||
const observer = new ResizeObserver(() => chart.resize())
|
const observer = new ResizeObserver(() => chart.resize())
|
||||||
observer.observe(element)
|
observer.observe(element)
|
||||||
return () => { observer.disconnect(); chart.dispose() }
|
return () => { observer.disconnect(); chart.dispose() }
|
||||||
}, [identity, points])
|
}, [dateFormat, identity, points])
|
||||||
|
|
||||||
return <div className="progression-chart" ref={host} data-testid="progression-chart" role="img" aria-label={`Courbe de ${identity.exercise_name} : ${points.length} ${points.length === 1 ? 'mesure réelle' : 'mesures réelles'} en kilogrammes. La ligne relie les observations sans représenter de mesures intermédiaires.`} />
|
return <div className="progression-chart" ref={host} data-testid="progression-chart" role="img" aria-label={`Courbe de ${identity.exercise_name} : ${points.length} ${points.length === 1 ? 'mesure réelle' : 'mesures réelles'} en kilogrammes. La ligne relie les observations sans représenter de mesures intermédiaires.`} />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { Health } from '../api/health'
|
||||||
import type { DashboardSnapshot } from '../api/dashboard'
|
import type { DashboardSnapshot } from '../api/dashboard'
|
||||||
|
import { useDatePreferences } from '../presentation/DatePreferences'
|
||||||
|
import { formatDate } from '../presentation/dateFormat'
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
health: Health | null
|
health: Health | null
|
||||||
|
|
@ -9,6 +11,7 @@ interface FooterProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Footer({ health, healthPending, healthFailed, dashboard }: FooterProps) {
|
export function Footer({ health, healthPending, healthFailed, dashboard }: FooterProps) {
|
||||||
|
const { dateFormat } = useDatePreferences()
|
||||||
const backendLabel = healthPending ? 'Connexion en cours' :
|
const backendLabel = healthPending ? 'Connexion en cours' :
|
||||||
healthFailed ? 'Backend indisponible' : `Backend connecté · v${health?.version ?? '—'}`
|
healthFailed ? 'Backend indisponible' : `Backend connecté · v${health?.version ?? '—'}`
|
||||||
|
|
||||||
|
|
@ -20,7 +23,10 @@ export function Footer({ health, healthPending, healthFailed, dashboard }: Foote
|
||||||
</section>
|
</section>
|
||||||
<section className="status-block status-session" aria-label="Dernière séance">
|
<section className="status-block status-session" aria-label="Dernière séance">
|
||||||
<span className="status-label">DERNIÈRE SÉANCE</span>
|
<span className="status-label">DERNIÈRE SÉANCE</span>
|
||||||
<span className="status-value">{dashboard?.data.footer.last_session_date ?? 'Indisponible'}</span>
|
<span className="status-value">{dashboard?.data.footer.last_session_date
|
||||||
|
? <time dateTime={dashboard.data.footer.last_session_date}>
|
||||||
|
{formatDate(dashboard.data.footer.last_session_date, dateFormat)}</time>
|
||||||
|
: 'Indisponible'}</span>
|
||||||
</section>
|
</section>
|
||||||
<section className="status-block status-zone" aria-label="Dernière zone">
|
<section className="status-block status-zone" aria-label="Dernière zone">
|
||||||
<span className="status-label">DERNIÈRE ZONE</span>
|
<span className="status-label">DERNIÈRE ZONE</span>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { MouseEvent } from 'react'
|
import type { MouseEvent } from 'react'
|
||||||
import { routes, type AppRoute } from '../app/routes'
|
import { routes, type AppRoute } from '../app/routes'
|
||||||
import { SyncControl } from './SyncControl'
|
import { SyncControl } from './SyncControl'
|
||||||
|
import { DisplayPreferences } from './DisplayPreferences'
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
activeRoute: AppRoute
|
activeRoute: AppRoute
|
||||||
|
|
@ -32,6 +33,7 @@ export function Header({ activeRoute, onNavigate, onSyncCommitted }: HeaderProps
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
<DisplayPreferences />
|
||||||
<SyncControl onCommitted={onSyncCommitted} />
|
<SyncControl onCommitted={onSyncCommitted} />
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { fetchSyncStatus, newRequestId, startSync, syncIsActive, type SyncStatus } from '../api/sync'
|
import { fetchSyncStatus, newRequestId, startSync, syncIsActive, type SyncStatus } from '../api/sync'
|
||||||
|
import { useDatePreferences } from '../presentation/DatePreferences'
|
||||||
|
import { formatDateTime } from '../presentation/dateFormat'
|
||||||
|
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
idle: 'Synchronisation inactive',
|
idle: 'Synchronisation inactive',
|
||||||
|
|
@ -41,6 +43,7 @@ function statusAction(status: SyncStatus | null): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SyncControl({ onCommitted }: { onCommitted: () => void }) {
|
export function SyncControl({ onCommitted }: { onCommitted: () => void }) {
|
||||||
|
const { dateFormat } = useDatePreferences()
|
||||||
const [status, setStatus] = useState<SyncStatus | null>(null)
|
const [status, setStatus] = useState<SyncStatus | null>(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const statusRef = useRef<SyncStatus | null>(null)
|
const statusRef = useRef<SyncStatus | null>(null)
|
||||||
|
|
@ -148,8 +151,10 @@ export function SyncControl({ onCommitted }: { onCommitted: () => void }) {
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{status?.started_at && <p>Démarrée : {status.started_at}</p>}
|
{status?.started_at && <p>Démarrée : <time dateTime={status.started_at}>
|
||||||
{status?.finished_at && <p>Dernier résultat : {status.finished_at}</p>}
|
{formatDateTime(status.started_at, dateFormat)}</time></p>}
|
||||||
|
{status?.finished_at && <p>Dernier résultat : <time dateTime={status.finished_at}>
|
||||||
|
{formatDateTime(status.finished_at, dateFormat)}</time></p>}
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
|
||||||
api_version: 1 as const,
|
api_version: 1 as const,
|
||||||
generated_at: '2026-09-17T12:00:00Z',
|
generated_at: '2026-09-17T12:00:00Z',
|
||||||
partial: false,
|
partial: false,
|
||||||
items: [{ identity: 'aid_one', kind: 'ai_proposal' as const, title: 'Préparation', planned_for: '2026-09-18', state: 'published', occurrence_count: 6, provenance: 'ai_import' }],
|
items: [{ identity: 'aid_one', kind: 'ai_proposal' as const, title: 'Préparation', planned_for: '2026-09-18', state: 'published', sort_timestamp: '2026-09-17T12:00:00Z', occurrence_count: 6, provenance: 'ai_import' }],
|
||||||
}
|
}
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<NextSessionTile size="large" preparedItems={proposal} pending={false} failed={false} />,
|
<NextSessionTile size="large" preparedItems={proposal} pending={false} failed={false} />,
|
||||||
|
|
@ -34,7 +34,7 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('conserve une lecture durable lorsque son actualisation échoue', () => {
|
it('conserve une lecture durable lorsque son actualisation échoue', () => {
|
||||||
render(<NextSessionTile size="medium" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [{ identity: 'aid_one', kind: 'ai_proposal', title: 'Conservée', planned_for: null, state: 'published', occurrence_count: 1, provenance: 'ai_import' }] }} pending={false} failed />)
|
render(<NextSessionTile size="medium" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [{ identity: 'aid_one', kind: 'ai_proposal', title: 'Conservée', planned_for: null, state: 'published', sort_timestamp: '2026-09-17T12:00:00Z', occurrence_count: 1, provenance: 'ai_import' }] }} pending={false} failed />)
|
||||||
expect(screen.getByText('Conservée')).toBeInTheDocument()
|
expect(screen.getByText('Conservée')).toBeInTheDocument()
|
||||||
expect(screen.getByText(/Dernière lecture conservée/)).toBeInTheDocument()
|
expect(screen.getByText(/Dernière lecture conservée/)).toBeInTheDocument()
|
||||||
expect(screen.queryByText('Aucun élément disponible')).not.toBeInTheDocument()
|
expect(screen.queryByText('Aucun élément disponible')).not.toBeInTheDocument()
|
||||||
|
|
@ -42,8 +42,8 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
|
||||||
|
|
||||||
it('rend chaque proposition consultable sans la transformer en brouillon', () => {
|
it('rend chaque proposition consultable sans la transformer en brouillon', () => {
|
||||||
render(<NextSessionTile size="large" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [
|
render(<NextSessionTile size="large" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [
|
||||||
{ identity: 'aid_first', kind: 'ai_proposal', title: 'Première', planned_for: null, state: 'published', occurrence_count: 2, provenance: 'ai_import' },
|
{ identity: 'aid_first', kind: 'ai_proposal', title: 'Première', planned_for: null, state: 'published', sort_timestamp: '2026-09-16T12:00:00Z', occurrence_count: 2, provenance: 'ai_import' },
|
||||||
{ identity: 'aid_expected', kind: 'ai_proposal', title: 'Attendue', planned_for: null, state: 'published', occurrence_count: 6, provenance: 'ai_import' },
|
{ identity: 'aid_expected', kind: 'ai_proposal', title: 'Attendue', planned_for: null, state: 'published', sort_timestamp: '2026-09-17T12:00:00Z', occurrence_count: 6, provenance: 'ai_import' },
|
||||||
] }} pending={false} failed={false} />)
|
] }} pending={false} failed={false} />)
|
||||||
const expected = document.querySelector('[data-prepared-identity="aid_expected"]')
|
const expected = document.querySelector('[data-prepared-identity="aid_expected"]')
|
||||||
expect(expected).toHaveTextContent('Attendue')
|
expect(expected).toHaveTextContent('Attendue')
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import type { DashboardSnapshot, WorkedZone } from '../api/dashboard'
|
import type { DashboardSnapshot, WorkedZone } from '../api/dashboard'
|
||||||
import { lazy, Suspense, type ComponentType } from 'react'
|
import { lazy, Suspense, type ComponentType, type ReactNode } from 'react'
|
||||||
import type { TileSize } from './dashboardLayout'
|
import type { TileSize } from './dashboardLayout'
|
||||||
import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat'
|
import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat'
|
||||||
import { BodyZoneFigure } from './BodyZoneFigure'
|
import { BodyZoneFigure } from './BodyZoneFigure'
|
||||||
import type { PreparedItemsSnapshot } from '../api/preparedItems'
|
import type { PreparedItemsSnapshot } from '../api/preparedItems'
|
||||||
|
import type { PreparedItem } from '../api/preparedItems'
|
||||||
|
import { useDatePreferences } from '../presentation/DatePreferences'
|
||||||
|
import { validDateSortValue, validTimestampValue } from '../presentation/dateFormat'
|
||||||
|
|
||||||
const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart })))
|
const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart })))
|
||||||
|
|
||||||
|
|
@ -16,22 +19,46 @@ export interface NextSessionTileProps {
|
||||||
failed?: boolean
|
failed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sortPreparedItems(items: readonly PreparedItem[]): PreparedItem[] {
|
||||||
|
return [...items].sort((left, right) => {
|
||||||
|
const leftDate = validDateSortValue(left.planned_for)
|
||||||
|
const rightDate = validDateSortValue(right.planned_for)
|
||||||
|
if (leftDate !== null && rightDate !== null && leftDate !== rightDate) {
|
||||||
|
return rightDate.localeCompare(leftDate, 'en')
|
||||||
|
}
|
||||||
|
if (leftDate === null && rightDate !== null) return 1
|
||||||
|
if (leftDate !== null && rightDate === null) return -1
|
||||||
|
const leftTimestamp = validTimestampValue(left.sort_timestamp)
|
||||||
|
const rightTimestamp = validTimestampValue(right.sort_timestamp)
|
||||||
|
if (leftTimestamp !== null && rightTimestamp !== null && leftTimestamp !== rightTimestamp) {
|
||||||
|
return rightTimestamp - leftTimestamp
|
||||||
|
}
|
||||||
|
if (leftTimestamp === null && rightTimestamp !== null) return 1
|
||||||
|
if (leftTimestamp !== null && rightTimestamp === null) return -1
|
||||||
|
return `${left.kind}:${left.identity}`.localeCompare(`${right.kind}:${right.identity}`, 'en')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function NextSessionTile({
|
export function NextSessionTile({
|
||||||
size,
|
size,
|
||||||
preparedItems = null,
|
preparedItems = null,
|
||||||
pending = false,
|
pending = false,
|
||||||
failed = false,
|
failed = false,
|
||||||
}: NextSessionTileProps) {
|
}: NextSessionTileProps) {
|
||||||
|
const { dateFormat } = useDatePreferences()
|
||||||
if (preparedItems === null) {
|
if (preparedItems === null) {
|
||||||
return <Unavailable text={failed ? 'Données indisponibles' : 'Chargement des préparations…'} />
|
return <Unavailable text={failed ? 'Données indisponibles' : 'Chargement des préparations…'} />
|
||||||
}
|
}
|
||||||
const item = preparedItems.items[0]
|
const orderedItems = sortPreparedItems(preparedItems.items)
|
||||||
|
const item = orderedItems[0]
|
||||||
if (item === undefined) {
|
if (item === undefined) {
|
||||||
return <Unavailable text={failed ? 'Données indisponibles' : 'Aucun élément disponible'} />
|
return <Unavailable text={failed ? 'Données indisponibles' : 'Aucun élément disponible'} />
|
||||||
}
|
}
|
||||||
const proposal = item.kind === 'ai_proposal'
|
const proposal = item.kind === 'ai_proposal'
|
||||||
|
const manual = item.kind === 'manual_preparation'
|
||||||
const heading = proposal
|
const heading = proposal
|
||||||
? 'Proposition préparée · à valider'
|
? 'Proposition préparée · à valider'
|
||||||
|
: manual ? 'Préparation manuelle'
|
||||||
: item.state === 'active'
|
: item.state === 'active'
|
||||||
? 'Brouillon d’exécution · à reprendre'
|
? 'Brouillon d’exécution · à reprendre'
|
||||||
: 'Brouillon d’exécution · en attente'
|
: 'Brouillon d’exécution · en attente'
|
||||||
|
|
@ -44,16 +71,25 @@ export function NextSessionTile({
|
||||||
<>
|
<>
|
||||||
<dl className="fact-list horizontal">
|
<dl className="fact-list horizontal">
|
||||||
<Fact label="Éléments" value={String(item.occurrence_count)} />
|
<Fact label="Éléments" value={String(item.occurrence_count)} />
|
||||||
<Fact label="Prévue" value={item.planned_for ? formatDate(item.planned_for) : 'Non renseignée'} />
|
<Fact label="Prévue" value={item.planned_for
|
||||||
|
? <time dateTime={item.planned_for}>{formatDate(item.planned_for, dateFormat)}</time>
|
||||||
|
: 'Non planifiée'} />
|
||||||
</dl>
|
</dl>
|
||||||
{preparedItems.items.length > 1 && (
|
{orderedItems.length > 1 && (
|
||||||
<details className="prepared-items-list">
|
<details className="prepared-items-list">
|
||||||
<summary>{preparedItems.items.length} éléments préparés</summary>
|
<summary>{orderedItems.length} éléments préparés · date décroissante</summary>
|
||||||
<ul>
|
<ul>
|
||||||
{preparedItems.items.map((prepared) => (
|
{orderedItems.map((prepared) => (
|
||||||
<li key={`${prepared.kind}:${prepared.identity}`} data-prepared-identity={prepared.identity}>
|
<li key={`${prepared.kind}:${prepared.identity}`} data-prepared-identity={prepared.identity}>
|
||||||
{prepared.title || (prepared.kind === 'ai_proposal' ? 'Proposition sans titre' : 'Séance préparée')}
|
<a href={`/seances/${prepared.kind === 'ai_proposal' ? 'proposal'
|
||||||
{' · '}{prepared.kind === 'ai_proposal' ? 'proposition' : 'brouillon d’exécution'}
|
: 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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -68,6 +104,7 @@ export function NextSessionTile({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityTile({ snapshot, size }: TileDataProps) {
|
export function ActivityTile({ snapshot, size }: TileDataProps) {
|
||||||
|
const { dateFormat } = useDatePreferences()
|
||||||
const days = snapshot.data.activity.days
|
const days = snapshot.data.activity.days
|
||||||
// CONTRACT: these are direct presentation totals of Core-projected counters,
|
// CONTRACT: these are direct presentation totals of Core-projected counters,
|
||||||
// never an activity or physiological-intensity score.
|
// never an activity or physiological-intensity score.
|
||||||
|
|
@ -78,41 +115,46 @@ export function ActivityTile({ snapshot, size }: TileDataProps) {
|
||||||
return <div className="activity-content">
|
return <div className="activity-content">
|
||||||
<div className="metric-row"><Metric value={String(activeDays)} label="jours actifs" />{size !== 'compact' && <Metric value={String(sessions)} label="séances" />}{size === 'large' && <Metric value={String(sets)} label="séries" />}</div>
|
<div className="metric-row"><Metric value={String(activeDays)} label="jours actifs" />{size !== 'compact' && <Metric value={String(sessions)} label="séances" />}{size === 'large' && <Metric value={String(sets)} label="séries" />}</div>
|
||||||
<div className="activity-heatmap" role="img" aria-label={`Sur 90 jours : ${count(activeDays, 'jour actif', 'jours actifs')}, ${count(sessions, 'séance')}, ${count(sets, 'série')}.`}>
|
<div className="activity-heatmap" role="img" aria-label={`Sur 90 jours : ${count(activeDays, 'jour actif', 'jours actifs')}, ${count(sessions, 'séance')}, ${count(sets, 'série')}.`}>
|
||||||
{shown.map((day) => <span key={day.date} className={`activity-day${day.active ? ' is-active' : ''}${day.session_count > 1 ? ' is-multiple' : ''}`} title={`${formatDate(day.date)} — ${count(day.session_count, 'séance')}, ${count(day.set_count, 'série')}`} />)}
|
{shown.map((day) => <span key={day.date} className={`activity-day${day.active ? ' is-active' : ''}${day.session_count > 1 ? ' is-multiple' : ''}`} title={`${formatDate(day.date, dateFormat)} — ${count(day.session_count, 'séance')}, ${count(day.set_count, 'série')}`} />)}
|
||||||
</div>
|
</div>
|
||||||
{size === 'large' && <p className="chart-legend"><span className="legend-swatch" /> Jour avec au moins une séance · la teinte indique uniquement le nombre de séances</p>}
|
{size === 'large' && <p className="chart-legend"><span className="legend-swatch" /> Jour avec au moins une séance · la teinte indique uniquement le nombre de séances</p>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProgressionTile({ snapshot, size }: TileDataProps) {
|
export function ProgressionTile({ snapshot, size }: TileDataProps) {
|
||||||
|
const { dateFormat } = useDatePreferences()
|
||||||
const progression = snapshot.data.progression
|
const progression = snapshot.data.progression
|
||||||
if (!progression.available) return <Unavailable text="Aucune performance comparable disponible" />
|
if (!progression.available) return <Unavailable text="Aucune performance comparable disponible" />
|
||||||
const first = progression.points[0]
|
const first = progression.points[0]
|
||||||
const last = progression.points.at(-1)
|
const last = progression.points.at(-1)
|
||||||
return <div className="progression-content">
|
return <div className="progression-content">
|
||||||
<div><p className="primary-label">{progression.identity.exercise_name}</p>{size !== 'compact' && <p className="secondary-label">{progression.identity.equipment_label}</p>}</div>
|
<div><p className="primary-label">{progression.identity.exercise_name}</p>{size !== 'compact' && <p className="secondary-label">{progression.identity.equipment_label}</p>}</div>
|
||||||
{last === undefined ? <p className="muted">Aucun point disponible</p> : <div className="progression-latest"><strong>{formatWeight(last.weight_kg)}</strong><span>{formatDate(last.timestamp)}</span>{last.improved && <em>Amélioration enregistrée</em>}</div>}
|
{last === undefined ? <p className="muted">Aucun point disponible</p> : <div className="progression-latest"><strong>{formatWeight(last.weight_kg)}</strong><span>{formatDate(last.timestamp, dateFormat)}</span>{last.improved && <em>Amélioration enregistrée</em>}</div>}
|
||||||
{size !== 'compact' && <dl className="fact-list horizontal"><Fact label="Dose" value={formatDose(progression.identity.dose)} /><Fact label="Premier" value={first ? formatWeight(first.weight_kg) : '—'} /><Fact label="Dernier" value={last ? formatWeight(last.weight_kg) : '—'} /><Fact label="Points" value={String(progression.points.length)} /></dl>}
|
{size !== 'compact' && <dl className="fact-list horizontal"><Fact label="Dose" value={formatDose(progression.identity.dose)} /><Fact label="Premier" value={first ? formatWeight(first.weight_kg) : '—'} /><Fact label="Dernier" value={last ? formatWeight(last.weight_kg) : '—'} /><Fact label="Points" value={String(progression.points.length)} /></dl>}
|
||||||
{size !== 'compact' && progression.points.length > 0 && <><Suspense fallback={<div className="chart-loading" role="status">Chargement de la courbe…</div>}><ProgressionChart identity={progression.identity} points={progression.points} /></Suspense><ol className="sr-only" aria-label="Mesures de progression">{progression.points.map((point, index) => <li key={`${point.session_id}-${point.timestamp}-${index}`}>{formatDateTime(point.timestamp)}, {formatWeight(point.weight_kg)}{point.improved ? ', amélioration' : ''}</li>)}</ol></>}
|
{size !== 'compact' && progression.points.length > 0 && <><Suspense fallback={<div className="chart-loading" role="status">Chargement de la courbe…</div>}><ProgressionChart identity={progression.identity} points={progression.points} /></Suspense><ol className="sr-only" aria-label="Mesures de progression">{progression.points.map((point, index) => <li key={`${point.session_id}-${point.timestamp}-${index}`}>{formatDateTime(point.timestamp, dateFormat)}, {formatWeight(point.weight_kg)}{point.improved ? ', amélioration' : ''}</li>)}</ol></>}
|
||||||
{size === 'large' && <p className="chart-legend">Identité comparable : {progression.identity.tracking_mode} · {progression.identity.load_mode}</p>}
|
{size === 'large' && <p className="chart-legend">Identité comparable : {progression.identity.tracking_mode} · {progression.identity.load_mode}</p>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LastSessionTile({ snapshot, size }: TileDataProps) {
|
export function LastSessionTile({ snapshot, size }: TileDataProps) {
|
||||||
|
const { dateFormat } = useDatePreferences()
|
||||||
const session = snapshot.data.last_session
|
const session = snapshot.data.last_session
|
||||||
if (!session.available) return <Unavailable text="Aucune séance observable disponible" />
|
if (!session.available) return <Unavailable text="Aucune séance observable disponible" />
|
||||||
return <div className="session-content">
|
return <div className="session-content">
|
||||||
<p className="primary-label">{size === 'compact' ? formatDate(session.started_at) : formatDateTime(session.started_at)}</p>
|
<p className="primary-label"><time dateTime={session.started_at}>{size === 'compact'
|
||||||
|
? formatDate(session.started_at, dateFormat)
|
||||||
|
: formatDateTime(session.started_at, dateFormat)}</time></p>
|
||||||
<dl className="fact-list horizontal"><Fact label="Exercices" value={String(session.exercise_count)} /><Fact label="Séries" value={String(session.set_count)} />{size !== 'compact' && <><Fact label="Durée" value={session.duration_seconds === null ? 'Indisponible' : formatDuration(session.duration_seconds)} /><Fact label="Activités continues" value={String(session.continuous_count)} /><Fact label="MAX explicites" value={String(session.max_count)} /></>}</dl>
|
<dl className="fact-list horizontal"><Fact label="Exercices" value={String(session.exercise_count)} /><Fact label="Séries" value={String(session.set_count)} />{size !== 'compact' && <><Fact label="Durée" value={session.duration_seconds === null ? 'Indisponible' : formatDuration(session.duration_seconds)} /><Fact label="Activités continues" value={String(session.continuous_count)} /><Fact label="MAX explicites" value={String(session.max_count)} /></>}</dl>
|
||||||
{size === 'large' && <div className="zone-chips" aria-label="Zones primaires travaillées">{session.primary_zones.map((zone) => <span key={zone.zone_id}>{zone.label}</span>)}</div>}
|
{size === 'large' && <div className="zone-chips" aria-label="Zones primaires travaillées">{session.primary_zones.map((zone) => <span key={zone.zone_id}>{zone.label}</span>)}</div>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MaxRecordsTile({ snapshot, size }: TileDataProps) {
|
export function MaxRecordsTile({ snapshot, size }: TileDataProps) {
|
||||||
|
const { dateFormat } = useDatePreferences()
|
||||||
const records = snapshot.data.max_records.records
|
const records = snapshot.data.max_records.records
|
||||||
if (!snapshot.data.max_records.available || records.length === 0) return <Unavailable text="Aucun MAX explicite disponible" />
|
if (!snapshot.data.max_records.available || records.length === 0) return <Unavailable text="Aucun MAX explicite disponible" />
|
||||||
const limit = size === 'compact' ? 1 : size === 'medium' ? 3 : 8
|
const limit = size === 'compact' ? 1 : size === 'medium' ? 3 : 8
|
||||||
return <div className="max-content"><ol className="record-list">{records.slice(0, limit).map((record) => <li key={record.entry_id}><div><strong>{record.exercise_name}</strong><span>{formatDate(record.timestamp)}{size === 'large' && record.equipment_id ? ` · ${record.equipment_id}` : ''}</span></div><b>{formatWeight(record.weight_kg)}</b></li>)}</ol>{snapshot.meta.partial && records.length >= 8 && <p className="partial-note">8 MAX récents affichés</p>}</div>
|
return <div className="max-content"><ol className="record-list">{records.slice(0, limit).map((record) => <li key={record.entry_id}><div><strong>{record.exercise_name}</strong><span>{formatDate(record.timestamp, dateFormat)}{size === 'large' && record.equipment_id ? ` · ${record.equipment_id}` : ''}</span></div><b>{formatWeight(record.weight_kg)}</b></li>)}</ol>{snapshot.meta.partial && records.length >= 8 && <p className="partial-note">8 MAX récents affichés</p>}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MuscleDistributionTile({ snapshot, size }: TileDataProps) {
|
export function MuscleDistributionTile({ snapshot, size }: TileDataProps) {
|
||||||
|
|
@ -138,7 +180,7 @@ export function CardioTile({ size }: TileDataProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Metric({ value, label }: { value: string; label: string }) { return <div className="metric"><strong>{value}</strong><span>{label}</span></div> }
|
function Metric({ value, label }: { value: string; label: string }) { return <div className="metric"><strong>{value}</strong><span>{label}</span></div> }
|
||||||
function Fact({ label, value }: { label: string; value: string }) { return <div><dt>{label}</dt><dd>{value}</dd></div> }
|
function Fact({ label, value }: { label: string; value: ReactNode }) { return <div><dt>{label}</dt><dd>{value}</dd></div> }
|
||||||
function Unavailable({ text }: { text: string }) { return <div className="unavailable-content"><strong aria-hidden="true">—</strong><p>{text}</p></div> }
|
function Unavailable({ text }: { text: string }) { return <div className="unavailable-content"><strong aria-hidden="true">—</strong><p>{text}</p></div> }
|
||||||
|
|
||||||
export type DashboardTileComponent = ComponentType<TileDataProps>
|
export type DashboardTileComponent = ComponentType<TileDataProps>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })
|
import type { WebDateFormat } from '../api/webPreferences'
|
||||||
const dateTimeFormat = new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
|
import {
|
||||||
|
formatDate as formatPresentationDate,
|
||||||
|
formatDateTime as formatPresentationDateTime,
|
||||||
|
} from '../presentation/dateFormat'
|
||||||
|
|
||||||
const numberFormat = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 })
|
const numberFormat = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 })
|
||||||
|
|
||||||
function parsed(value: string): Date | null {
|
export const formatDate = (value: string, format: WebDateFormat = 'fr') =>
|
||||||
const result = new Date(value)
|
formatPresentationDate(value, format)
|
||||||
return Number.isNaN(result.valueOf()) ? null : result
|
export const formatDateTime = (value: string, format: WebDateFormat = 'fr') =>
|
||||||
}
|
formatPresentationDateTime(value, format)
|
||||||
|
|
||||||
export const formatDate = (value: string) => { const date = parsed(value); return date === null ? value : dateFormat.format(date) }
|
|
||||||
export const formatDateTime = (value: string) => { const date = parsed(value); return date === null ? value : dateTimeFormat.format(date) }
|
|
||||||
export const formatWeight = (value: number) => `${numberFormat.format(value)} kg`
|
export const formatWeight = (value: number) => `${numberFormat.format(value)} kg`
|
||||||
export const formatDose = (value: number) => numberFormat.format(value)
|
export const formatDose = (value: number) => numberFormat.format(value)
|
||||||
export function formatDuration(seconds: number): string {
|
export function formatDuration(seconds: number): string {
|
||||||
|
|
|
||||||
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 { describe, expect, it, vi } from 'vitest'
|
||||||
import { commandOccurrence, SessionsPage } from './SessionsPage'
|
import {
|
||||||
|
commandOccurrence,
|
||||||
|
sessionStateLabel,
|
||||||
|
SessionsPage,
|
||||||
|
sortSessionItems,
|
||||||
|
type SessionListEntry,
|
||||||
|
} from './SessionsPage'
|
||||||
|
|
||||||
function page(kind: string, items: unknown[]) {
|
function page(kind: string, items: unknown[]) {
|
||||||
return { api_version: 1, kind, offset: 0, more: false, next_offset: items.length, items }
|
return { api_version: 1, kind, offset: 0, more: false, next_offset: items.length, items }
|
||||||
|
|
@ -52,4 +58,117 @@ describe('Sessions page', () => {
|
||||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('ne sont pas disponibles'))
|
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('ne sont pas disponibles'))
|
||||||
expect(screen.queryByText('Aucun résultat pour cette vue.')).not.toBeInTheDocument()
|
expect(screen.queryByText('Aucun résultat pour cette vue.')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sorts more than one page globally with deterministic ties and invalid dates last', () => {
|
||||||
|
const values: SessionListEntry[] = Array.from({ length: 41 }, (_, index) => ({
|
||||||
|
collection: index % 2 === 0 ? 'preparations' : 'proposals',
|
||||||
|
identity: `id_${String(index).padStart(2, '0')}`,
|
||||||
|
title: `Item ${index}`,
|
||||||
|
date: index === 39 ? null : index === 40 ? 'date-invalide' :
|
||||||
|
`2026-09-${String((index % 20) + 1).padStart(2, '0')}`,
|
||||||
|
state: index % 2 === 0 ? 'ready' : 'published',
|
||||||
|
occurrence_count: 1,
|
||||||
|
sort_timestamp: `2026-09-${String((index % 20) + 1).padStart(2, '0')}T12:00:00Z`,
|
||||||
|
}))
|
||||||
|
values.push({
|
||||||
|
...values[0], identity: 'id_same_b', date: '2026-09-18',
|
||||||
|
sort_timestamp: '2026-09-18T12:00:00Z',
|
||||||
|
}, {
|
||||||
|
...values[0], identity: 'id_same_a', date: '2026-09-18',
|
||||||
|
sort_timestamp: '2026-09-18T12:00:00Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
const sorted = sortSessionItems(values, 'preparation')
|
||||||
|
|
||||||
|
expect(sorted).toHaveLength(43)
|
||||||
|
expect(sorted.slice(-2).map((item) => item.identity)).toEqual(['id_39', 'id_40'])
|
||||||
|
expect(sorted.findIndex((item) => item.identity === 'id_same_a')).toBeLessThan(
|
||||||
|
sorted.findIndex((item) => item.identity === 'id_same_b'),
|
||||||
|
)
|
||||||
|
expect(sorted.every((item, index) => index === 0 ||
|
||||||
|
item.date === null || item.date === 'date-invalide' ||
|
||||||
|
sorted[index - 1].date === null || sorted[index - 1].date === 'date-invalide' ||
|
||||||
|
String(sorted[index - 1].date) >= String(item.date))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses contextual French state labels and exposes unknown values', () => {
|
||||||
|
expect(sessionStateLabel('preparations', 'acknowledged')).toBe('Reçue')
|
||||||
|
expect(sessionStateLabel('proposals', 'published')).toBe('Publiée')
|
||||||
|
expect(sessionStateLabel('drafts', 'active')).toBe('En cours')
|
||||||
|
expect(sessionStateLabel('history', 'completed')).toBe('Terminée')
|
||||||
|
expect(sessionStateLabel('preparations', 'unexpected')).toBe('État non reconnu (unexpected)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts history by the real instant across offsets with a stable identity tie-break', () => {
|
||||||
|
const history: SessionListEntry[] = [
|
||||||
|
{ collection: 'history', identity: 'se_invalid', title: 'Invalide', date: null,
|
||||||
|
state: 'completed', occurrence_count: 1, sort_timestamp: 'invalid' },
|
||||||
|
{ collection: 'history', identity: 'se_b', title: 'Même instant B', date: '2026-09-18',
|
||||||
|
state: 'completed', occurrence_count: 1, sort_timestamp: '2026-09-18T10:00:00+02:00' },
|
||||||
|
{ collection: 'history', identity: 'se_a', title: 'Même instant A', date: '2026-09-18',
|
||||||
|
state: 'completed', occurrence_count: 1, sort_timestamp: '2026-09-18T08:00:00Z' },
|
||||||
|
{ collection: 'history', identity: 'se_older', title: 'Ancienne', date: '2026-09-18',
|
||||||
|
state: 'completed', occurrence_count: 1, sort_timestamp: '2026-09-18T07:59:59Z' },
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(sortSessionItems(history, 'history').map((item) => item.identity)).toEqual([
|
||||||
|
'se_a', 'se_b', 'se_older', 'se_invalid',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels confirmation without mutation, then withdraws through the DELETE API', async () => {
|
||||||
|
window.history.replaceState(null, '', '/seances/preparation/sp_target')
|
||||||
|
const detail = {
|
||||||
|
api_version: 1, kind: 'preparation', identity: 'sp_target', revision_id: 'spr_current',
|
||||||
|
title: 'Préparation ciblée', planned_for: '2026-09-18', session_type: 'training',
|
||||||
|
notes: null, source_fingerprint: 'a'.repeat(64), source_proposal_id: 'aid_source',
|
||||||
|
source_proposal_title: 'Proposition source', state: 'acknowledged', occurrences: [],
|
||||||
|
}
|
||||||
|
const withdrawn = {
|
||||||
|
...detail, state: 'withdrawn', withdrawn_at: '2026-09-18T12:00:00Z',
|
||||||
|
withdrawal_id: 'spw_target', withdrawal_acknowledged_at: null,
|
||||||
|
}
|
||||||
|
let detailReads = 0
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input)
|
||||||
|
if (url === '/api/v1/sync/status') {
|
||||||
|
return response({ phase: 'idle', result: 'ok' }).then((value) => ({
|
||||||
|
...value, headers: new Headers({ 'X-Trainlog-CSRF-Token': 'a'.repeat(64) }),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (url.includes('/api/v1/sessions/preparation/sp_target') && init?.method === 'DELETE') {
|
||||||
|
return response({ preparation_id: 'sp_target', revision_id: 'spr_current',
|
||||||
|
withdrawal_id: 'spw_target', android_cancellation: 'pending' })
|
||||||
|
}
|
||||||
|
if (url.includes('/api/v1/sessions/preparation/sp_target')) {
|
||||||
|
detailReads += 1
|
||||||
|
return response(detailReads === 1 ? detail : withdrawn)
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected request ${url}`)
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
render(<SessionsPage />)
|
||||||
|
await screen.findByRole('heading', { name: 'Préparation ciblée' })
|
||||||
|
|
||||||
|
const deleteTrigger = screen.getByRole('button', { name: 'Supprimer la préparation' })
|
||||||
|
fireEvent.click(deleteTrigger)
|
||||||
|
let dialog = screen.getByRole('alertdialog')
|
||||||
|
expect(dialog).toHaveTextContent('toute exécution commencée sera conservée')
|
||||||
|
const keepButton = within(dialog).getByRole('button', { name: 'Conserver la préparation' })
|
||||||
|
expect(keepButton).toHaveFocus()
|
||||||
|
fireEvent.click(keepButton)
|
||||||
|
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(deleteTrigger).toHaveFocus())
|
||||||
|
expect(fetchMock.mock.calls.some((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(false)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Supprimer la préparation' }))
|
||||||
|
dialog = screen.getByRole('alertdialog')
|
||||||
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Supprimer la préparation' }))
|
||||||
|
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent(
|
||||||
|
'Supprimée sur le PC — annulation Android à synchroniser.',
|
||||||
|
))
|
||||||
|
expect(fetchMock.mock.calls.some((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(true)
|
||||||
|
expect(screen.queryByRole('button', { name: 'Supprimer la préparation' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Dérivée de la proposition/)).toHaveTextContent('Proposition source')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
fetchCatalog,
|
fetchCatalog,
|
||||||
fetchSessionDetail,
|
fetchSessionDetail,
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
prepareForAndroid,
|
prepareForAndroid,
|
||||||
fetchAllSessionPages,
|
fetchAllSessionPages,
|
||||||
savePreparation,
|
savePreparation,
|
||||||
|
withdrawPreparation,
|
||||||
type CatalogChoice,
|
type CatalogChoice,
|
||||||
type PreparationInput,
|
type PreparationInput,
|
||||||
type PreparationOccurrence,
|
type PreparationOccurrence,
|
||||||
|
|
@ -13,12 +14,64 @@ import {
|
||||||
type SessionDetail,
|
type SessionDetail,
|
||||||
type SessionListItem,
|
type SessionListItem,
|
||||||
} from '../api/sessions'
|
} from '../api/sessions'
|
||||||
|
import { useDatePreferences } from '../presentation/DatePreferences'
|
||||||
|
import {
|
||||||
|
formatCivilDate,
|
||||||
|
formatDateTime,
|
||||||
|
validDateSortValue,
|
||||||
|
validTimestampValue,
|
||||||
|
} from '../presentation/dateFormat'
|
||||||
|
|
||||||
type View = 'preparation' | 'resume' | 'history'
|
type View = 'preparation' | 'resume' | 'history'
|
||||||
const collections: Record<View, SessionCollection[]> = {
|
const collections: Record<View, SessionCollection[]> = {
|
||||||
preparation: ['preparations', 'proposals'], resume: ['drafts'], history: ['history'],
|
preparation: ['preparations', 'proposals'], resume: ['drafts'], history: ['history'],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionListEntry = SessionListItem & { collection: SessionCollection }
|
||||||
|
|
||||||
|
function compareIdentity(left: SessionListEntry, right: SessionListEntry): number {
|
||||||
|
return `${left.collection}:${left.identity}`.localeCompare(
|
||||||
|
`${right.collection}:${right.identity}`, 'en')
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareTimestampDescending(left: string, right: string): number {
|
||||||
|
const leftTimestamp = validTimestampValue(left)
|
||||||
|
const rightTimestamp = validTimestampValue(right)
|
||||||
|
if (leftTimestamp === null && rightTimestamp === null) return 0
|
||||||
|
if (leftTimestamp === null) return 1
|
||||||
|
if (rightTimestamp === null) return -1
|
||||||
|
return rightTimestamp - leftTimestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortSessionItems(items: readonly SessionListEntry[], view: View): SessionListEntry[] {
|
||||||
|
return [...items].sort((left, right) => {
|
||||||
|
if (view === 'preparation') {
|
||||||
|
const leftDate = validDateSortValue(left.date)
|
||||||
|
const rightDate = validDateSortValue(right.date)
|
||||||
|
if (leftDate !== null && rightDate !== null && leftDate !== rightDate) {
|
||||||
|
return rightDate.localeCompare(leftDate, 'en')
|
||||||
|
}
|
||||||
|
if (leftDate === null && rightDate !== null) return 1
|
||||||
|
if (leftDate !== null && rightDate === null) return -1
|
||||||
|
}
|
||||||
|
const timestampOrder = compareTimestampDescending(left.sort_timestamp, right.sort_timestamp)
|
||||||
|
return timestampOrder !== 0 ? timestampOrder : compareIdentity(left, right)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionStateLabel(collection: SessionCollection, state: string): string {
|
||||||
|
const labels: Record<SessionCollection, Record<string, string>> = {
|
||||||
|
preparations: {
|
||||||
|
draft: 'En préparation', ready: 'Prête', local: 'Locale', pending: 'À synchroniser',
|
||||||
|
acknowledged: 'Reçue', remote_unknown: 'État Android inconnu', withdrawn: 'Supprimée',
|
||||||
|
},
|
||||||
|
proposals: { local: 'À valider', pending: 'À valider', published: 'Publiée' },
|
||||||
|
drafts: { active: 'En cours', pending: 'À reprendre', stale: 'À vérifier' },
|
||||||
|
history: { completed: 'Terminée' },
|
||||||
|
}
|
||||||
|
return labels[collection][state] ?? `État non reconnu (${state})`
|
||||||
|
}
|
||||||
|
|
||||||
function parseDecimal(value: string): number | null {
|
function parseDecimal(value: string): number | null {
|
||||||
if (!value.trim()) return null
|
if (!value.trim()) return null
|
||||||
const normalized = value.replace(',', '.')
|
const normalized = value.replace(',', '.')
|
||||||
|
|
@ -185,19 +238,87 @@ function Editor({ initial, onSaved, onCancel }: {
|
||||||
|
|
||||||
function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'proposal'; identity: string;
|
function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'proposal'; identity: string;
|
||||||
onBack: () => void; onEdit: (detail: SessionDetail) => void }) {
|
onBack: () => void; onEdit: (detail: SessionDetail) => void }) {
|
||||||
|
const { dateFormat } = useDatePreferences()
|
||||||
|
const deleteTrigger = useRef<HTMLButtonElement>(null)
|
||||||
|
const cancelDelete = useRef<HTMLButtonElement>(null)
|
||||||
const [detail, setDetail] = useState<SessionDetail | null>(null)
|
const [detail, setDetail] = useState<SessionDetail | null>(null)
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
useEffect(() => { const controller = new AbortController(); setFailed(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
fetchSessionDetail(kind, identity, controller.signal).then(setDetail).catch(() => setFailed(true))
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [deleteError, setDeleteError] = useState('')
|
||||||
|
useEffect(() => { const controller = new AbortController(); setFailed(false); setDetail(null)
|
||||||
|
fetchSessionDetail(kind, identity, controller.signal).then((value) => {
|
||||||
|
if (!controller.signal.aborted) setDetail(value)
|
||||||
|
}).catch(() => {
|
||||||
|
if (!controller.signal.aborted) setFailed(true)
|
||||||
|
})
|
||||||
return () => controller.abort() }, [kind, identity])
|
return () => controller.abort() }, [kind, identity])
|
||||||
|
useEffect(() => {
|
||||||
|
if (confirming) cancelDelete.current?.focus()
|
||||||
|
}, [confirming])
|
||||||
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
|
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
|
||||||
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
|
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
|
||||||
|
const withdrawn = detail.state === 'withdrawn'
|
||||||
|
const remove = async () => {
|
||||||
|
setDeleting(true)
|
||||||
|
setDeleteError('')
|
||||||
|
try {
|
||||||
|
const result = await withdrawPreparation(detail.identity, detail.revision_id)
|
||||||
|
setConfirming(false)
|
||||||
|
setMessage(result.android_cancellation === 'pending'
|
||||||
|
? 'Supprimée sur le PC — annulation Android à synchroniser.'
|
||||||
|
: 'Préparation supprimée sur le PC. Aucune livraison Android à annuler.')
|
||||||
|
setDetail({ ...detail, state: 'withdrawn', withdrawal_id: result.withdrawal_id })
|
||||||
|
try {
|
||||||
|
setDetail(await fetchSessionDetail('preparation', detail.identity))
|
||||||
|
} catch {
|
||||||
|
setMessage((current) => `${current} Rechargez la fiche pour obtenir l’état synchronisé.`)
|
||||||
|
}
|
||||||
|
} catch (reason) {
|
||||||
|
setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
return <article className="session-detail"><div className="sessions-toolbar">
|
return <article className="session-detail"><div className="sessions-toolbar">
|
||||||
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button>
|
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button>
|
||||||
<button type="button" className="primary-action" onClick={() => onEdit(detail)}>
|
{!withdrawn && <div className="detail-actions">
|
||||||
{kind === 'preparation' ? 'Modifier' : 'Préparer à partir de cette proposition'}</button></div>
|
<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>
|
<header className="detail-summary"><p className="eyebrow">{kind === 'proposal' ? 'PROPOSITION IA' : 'PRÉPARATION MANUELLE'}</p>
|
||||||
<h2>{detail.title || 'Sans titre'}</h2><p>{detail.planned_for ?? 'Aucune date planifiée'} · {detail.state}</p></header>
|
<h2>{detail.title || 'Sans titre'}</h2><p>
|
||||||
|
{detail.planned_for === null ? 'Non planifiée' : <time dateTime={detail.planned_for}>
|
||||||
|
{formatCivilDate(detail.planned_for, dateFormat)}</time>}
|
||||||
|
{' · '}{sessionStateLabel(kind === 'proposal' ? 'proposals' : 'preparations', detail.state)}
|
||||||
|
</p>
|
||||||
|
{detail.withdrawn_at && <p>Retirée le <time dateTime={detail.withdrawn_at}>
|
||||||
|
{formatDateTime(detail.withdrawn_at, dateFormat)}</time> · {
|
||||||
|
detail.withdrawal_acknowledged_at
|
||||||
|
? 'annulation Android consommée'
|
||||||
|
: 'annulation Android à synchroniser'
|
||||||
|
}</p>}</header>
|
||||||
{detail.notes && <section className="detail-tile"><h3>Note</h3><p>{detail.notes}</p></section>}
|
{detail.notes && <section className="detail-tile"><h3>Note</h3><p>{detail.notes}</p></section>}
|
||||||
<section className="detail-tile"><h3>Exercices ordonnés</h3><ol className="detail-occurrences">
|
<section className="detail-tile"><h3>Exercices ordonnés</h3><ol className="detail-occurrences">
|
||||||
{detail.occurrences.map((occurrence) => <li key={occurrence.entry_id}>
|
{detail.occurrences.map((occurrence) => <li key={occurrence.entry_id}>
|
||||||
|
|
@ -206,31 +327,44 @@ function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'pro
|
||||||
{occurrence.tracking_mode === 'duration' ? ' s' : ' répétitions'}</span>
|
{occurrence.tracking_mode === 'duration' ? ' s' : ' répétitions'}</span>
|
||||||
<small>{occurrence.load_mode === 'none' ? 'Sans charge' : `${occurrence.load_mode} · ${occurrence.target_weight_kg ?? '—'} kg`}</small>
|
<small>{occurrence.load_mode === 'none' ? 'Sans charge' : `${occurrence.load_mode} · ${occurrence.target_weight_kg ?? '—'} kg`}</small>
|
||||||
</li>)}</ol></section>
|
</li>)}</ol></section>
|
||||||
{detail.source_fingerprint && <section className="detail-tile"><h3>Provenance</h3>
|
{detail.source_proposal_id && <section className="detail-tile"><h3>Provenance</h3>
|
||||||
<code>{detail.source_fingerprint}</code></section>}
|
<p>Dérivée de la proposition <a href={`/seances/proposal/${encodeURIComponent(detail.source_proposal_id)}`}>
|
||||||
|
« {detail.source_proposal_title || detail.source_proposal_id} »</a>.</p>
|
||||||
|
{detail.source_fingerprint && <details><summary>Détail technique</summary>
|
||||||
|
<code>{detail.source_fingerprint}</code></details>}
|
||||||
|
</section>}
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReadonlyDetail({ kind, identity, onBack }: {
|
function ReadonlyDetail({ kind, identity, onBack }: {
|
||||||
kind: 'draft' | 'history'; identity: string; onBack: () => void
|
kind: 'draft' | 'history'; identity: string; onBack: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const { dateFormat } = useDatePreferences()
|
||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null)
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null)
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
useEffect(() => { const controller = new AbortController(); setFailed(false)
|
useEffect(() => { const controller = new AbortController(); setFailed(false); setDetail(null)
|
||||||
fetchReadonlySessionDetail(kind, identity, controller.signal).then(setDetail).catch(() => setFailed(true))
|
fetchReadonlySessionDetail(kind, identity, controller.signal).then((value) => {
|
||||||
|
if (!controller.signal.aborted) setDetail(value)
|
||||||
|
}).catch(() => {
|
||||||
|
if (!controller.signal.aborted) setFailed(true)
|
||||||
|
})
|
||||||
return () => controller.abort() }, [kind, identity])
|
return () => controller.abort() }, [kind, identity])
|
||||||
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
|
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
|
||||||
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
|
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
|
||||||
const occurrences = Array.isArray(detail.occurrences) ? detail.occurrences as Array<Record<string, unknown>> : []
|
const occurrences = Array.isArray(detail.occurrences) ? detail.occurrences as Array<Record<string, unknown>> : []
|
||||||
const payload = typeof detail.payload === 'object' && detail.payload !== null
|
const payload = typeof detail.payload === 'object' && detail.payload !== null
|
||||||
? detail.payload as Record<string, unknown> : null
|
? detail.payload as Record<string, unknown> : null
|
||||||
|
const startedAt = typeof detail.started_at === 'string' ? detail.started_at : null
|
||||||
|
const endedAt = typeof detail.ended_at === 'string' ? detail.ended_at : null
|
||||||
return <article className="session-detail"><div className="sessions-toolbar">
|
return <article className="session-detail"><div className="sessions-toolbar">
|
||||||
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button></div>
|
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button></div>
|
||||||
<header className="detail-summary"><p className="eyebrow">{kind === 'draft' ? 'BROUILLON D’EXÉCUTION' : 'SÉANCE RÉALISÉE'}</p>
|
<header className="detail-summary"><p className="eyebrow">{kind === 'draft' ? 'BROUILLON D’EXÉCUTION' : 'SÉANCE RÉALISÉE'}</p>
|
||||||
<h2>{String(detail.session_type ?? 'Séance')}</h2>
|
<h2>{String(detail.session_type ?? 'Séance')}</h2>
|
||||||
<p>{String(detail.started_at ?? 'Début inconnu')} · {kind === 'history'
|
<p>{startedAt === null ? 'Début inconnu' : <time dateTime={startedAt}>
|
||||||
? detail.ended_at ? `fin ${String(detail.ended_at)}` : 'heure de fin inconnue'
|
{formatDateTime(startedAt, dateFormat)}</time>} · {kind === 'history'
|
||||||
: `à poursuivre sur Android · ${String(detail.state ?? '')}`}</p></header>
|
? endedAt ? <>fin <time dateTime={endedAt}>{formatDateTime(endedAt, dateFormat)}</time></>
|
||||||
|
: 'heure de fin inconnue'
|
||||||
|
: `à poursuivre sur Android · ${sessionStateLabel('drafts', String(detail.state ?? ''))}`}</p></header>
|
||||||
{typeof detail.notes === 'string' && detail.notes && <section className="detail-tile"><h3>Notes</h3><p>{detail.notes}</p></section>}
|
{typeof detail.notes === 'string' && detail.notes && <section className="detail-tile"><h3>Notes</h3><p>{detail.notes}</p></section>}
|
||||||
{kind === 'history' && <section className="detail-tile"><h3>Prévu / Réalisé</h3><ol className="detail-occurrences">
|
{kind === 'history' && <section className="detail-tile"><h3>Prévu / Réalisé</h3><ol className="detail-occurrences">
|
||||||
{occurrences.map((occurrence) => <li key={String(occurrence.entry_id)}>
|
{occurrences.map((occurrence) => <li key={String(occurrence.entry_id)}>
|
||||||
|
|
@ -247,8 +381,9 @@ function ReadonlyDetail({ kind, identity, onBack }: {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionsPage() {
|
export function SessionsPage() {
|
||||||
|
const { dateFormat } = useDatePreferences()
|
||||||
const [view, setView] = useState<View>('preparation')
|
const [view, setView] = useState<View>('preparation')
|
||||||
const [items, setItems] = useState<Array<SessionListItem & { collection: SessionCollection }>>([])
|
const [items, setItems] = useState<SessionListEntry[]>([])
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [stateFilter, setStateFilter] = useState('')
|
const [stateFilter, setStateFilter] = useState('')
|
||||||
const [dateFrom, setDateFrom] = useState('')
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
|
|
@ -257,11 +392,21 @@ export function SessionsPage() {
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
const [editor, setEditor] = useState<SessionDetail | 'new' | null>(null)
|
const [editor, setEditor] = useState<SessionDetail | 'new' | null>(null)
|
||||||
const [detailPath, setDetailPath] = useState(() => window.location.pathname)
|
const [detailPath, setDetailPath] = useState(() => window.location.pathname)
|
||||||
|
const loadRevision = useRef(0)
|
||||||
const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath])
|
const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath])
|
||||||
const load = () => { const controller = new AbortController(); setPending(true); setFailed(false)
|
const load = () => { const controller = new AbortController(); const revision = ++loadRevision.current
|
||||||
|
setPending(true); setFailed(false)
|
||||||
Promise.all(collections[view].map((collection) => fetchAllSessionPages(collection, search, controller.signal)
|
Promise.all(collections[view].map((collection) => fetchAllSessionPages(collection, search, controller.signal)
|
||||||
.then((pageItems) => pageItems.map((item) => ({ ...item, collection })))))
|
.then((pageItems) => pageItems.map((item) => ({ ...item, collection })))))
|
||||||
.then((pages) => setItems(pages.flat())).catch(() => setFailed(true)).finally(() => setPending(false))
|
.then((pages) => {
|
||||||
|
if (revision === loadRevision.current) setItems(sortSessionItems(pages.flat(), view))
|
||||||
|
}).catch((reason) => {
|
||||||
|
if (revision === loadRevision.current && !(reason instanceof DOMException && reason.name === 'AbortError')) {
|
||||||
|
setFailed(true)
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
if (revision === loadRevision.current) setPending(false)
|
||||||
|
})
|
||||||
return controller }
|
return controller }
|
||||||
useEffect(() => { const controller = load(); return () => controller.abort() }, [view, search])
|
useEffect(() => { const controller = load(); return () => controller.abort() }, [view, search])
|
||||||
useEffect(() => { const pop = () => setDetailPath(window.location.pathname)
|
useEffect(() => { const pop = () => setDetailPath(window.location.pathname)
|
||||||
|
|
@ -273,13 +418,18 @@ export function SessionsPage() {
|
||||||
window.history.pushState(null, '', path); setDetailPath(path)
|
window.history.pushState(null, '', path); setDetailPath(path)
|
||||||
}
|
}
|
||||||
const back = () => { window.history.pushState(null, '', '/seances'); setDetailPath('/seances'); setEditor(null) }
|
const back = () => { window.history.pushState(null, '', '/seances'); setDetailPath('/seances'); setEditor(null) }
|
||||||
const visibleItems = useMemo(() => items.filter((item) => {
|
const visibleItems = useMemo(() => sortSessionItems(items.filter((item) => {
|
||||||
if (stateFilter && item.state !== stateFilter) return false
|
if (stateFilter && item.state !== stateFilter) return false
|
||||||
if (dateFrom && (!item.date || item.date < dateFrom)) return false
|
if (dateFrom && (!item.date || item.date < dateFrom)) return false
|
||||||
if (dateTo && (!item.date || item.date > dateTo)) return false
|
if (dateTo && (!item.date || item.date > dateTo)) return false
|
||||||
return true
|
return true
|
||||||
}), [items, stateFilter, dateFrom, dateTo])
|
}), view), [items, stateFilter, dateFrom, dateTo, view])
|
||||||
const states = useMemo(() => [...new Set(items.map((item) => item.state))].sort(), [items])
|
const states = useMemo(() => [...new Set(items.map((item) => item.state))].sort(), [items])
|
||||||
|
const filterStateLabel = (state: string) => {
|
||||||
|
const owner = items.find((item) => item.state === state)
|
||||||
|
return owner === undefined ? `État non reconnu (${state})`
|
||||||
|
: sessionStateLabel(owner.collection, state)
|
||||||
|
}
|
||||||
if (editor) return <section className="page"><Editor initial={editor === 'new' ? undefined : editor}
|
if (editor) return <section className="page"><Editor initial={editor === 'new' ? undefined : editor}
|
||||||
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
|
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
|
||||||
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
|
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
|
||||||
|
|
@ -301,17 +451,22 @@ export function SessionsPage() {
|
||||||
onChange={(event) => setSearch(event.target.value)} /></label>
|
onChange={(event) => setSearch(event.target.value)} /></label>
|
||||||
<div className="session-filters" aria-label="Filtres de séances">
|
<div className="session-filters" aria-label="Filtres de séances">
|
||||||
<label>État<select value={stateFilter} onChange={(event) => setStateFilter(event.target.value)}>
|
<label>État<select value={stateFilter} onChange={(event) => setStateFilter(event.target.value)}>
|
||||||
<option value="">Tous</option>{states.map((state) => <option value={state} key={state}>{state}</option>)}
|
<option value="">Tous</option>{states.map((state) => <option value={state} key={state}>
|
||||||
|
{filterStateLabel(state)}</option>)}
|
||||||
</select></label>
|
</select></label>
|
||||||
<label>Du<input type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} /></label>
|
<label>Du<input type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} /></label>
|
||||||
<label>Au<input type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} /></label>
|
<label>Au<input type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} /></label>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="sort-indicator">Date décroissante</p>
|
||||||
{pending && <p aria-live="polite">Chargement…</p>}{failed && <p className="error-panel" role="alert">Les données ne sont pas disponibles.</p>}
|
{pending && <p aria-live="polite">Chargement…</p>}{failed && <p className="error-panel" role="alert">Les données ne sont pas disponibles.</p>}
|
||||||
{!pending && !failed && visibleItems.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
|
{!pending && !failed && visibleItems.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
|
||||||
<div className="session-list">{visibleItems.map((item) => <button type="button" className="session-row"
|
<div className="session-list">{visibleItems.map((item) => <button type="button" className="session-row"
|
||||||
key={`${item.collection}-${item.identity}`} onClick={() => open(item.collection, item.identity)}>
|
key={`${item.collection}-${item.identity}`} onClick={() => open(item.collection, item.identity)}>
|
||||||
<span><small>{item.collection === 'proposals' ? 'Proposition IA' : item.collection === 'preparations' ? 'Préparation manuelle' : item.collection === 'drafts' ? 'Brouillon d’exécution' : 'Séance réalisée'}</small>
|
<span><small>{item.collection === 'proposals' ? 'Proposition IA' : item.collection === 'preparations' ? 'Préparation manuelle' : item.collection === 'drafts' ? 'Brouillon d’exécution' : 'Séance réalisée'}</small>
|
||||||
<strong>{item.title || 'Sans titre'}</strong></span><span>{item.date ?? 'Date inconnue'}</span>
|
<strong>{item.title || 'Sans titre'}</strong></span><span>{item.date === null
|
||||||
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span><span>{item.state}</span></button>)}</div>
|
? item.collection === 'preparations' || item.collection === 'proposals' ? 'Non planifiée' : 'Date inconnue'
|
||||||
|
: <time dateTime={item.date}>{formatCivilDate(item.date, dateFormat)}</time>}</span>
|
||||||
|
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span>
|
||||||
|
<span>{sessionStateLabel(item.collection, item.state)}</span></button>)}</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,16 @@
|
||||||
.sync-details[open] { position: absolute; z-index: 20; right: 0; top: 2.8rem; min-width: 20rem; padding: .8rem; border: 1px solid var(--surface-2); border-radius: .7rem; background: var(--base); box-shadow: 0 .5rem 2rem #0008; }
|
.sync-details[open] { position: absolute; z-index: 20; right: 0; top: 2.8rem; min-width: 20rem; padding: .8rem; border: 1px solid var(--surface-2); border-radius: .7rem; background: var(--base); box-shadow: 0 .5rem 2rem #0008; }
|
||||||
.sync-details h2 { font-size: 1rem; }
|
.sync-details h2 { font-size: 1rem; }
|
||||||
.sync-details ul { padding-left: 1.2rem; }
|
.sync-details ul { padding-left: 1.2rem; }
|
||||||
|
.display-preferences { position: relative; margin-left: auto; }
|
||||||
|
.preferences-trigger { min-height: 2.5rem; padding: .55rem .75rem; color: var(--subtext-0); background: transparent; border: 1px solid var(--surface-2); border-radius: .55rem; cursor: pointer; white-space: nowrap; }
|
||||||
|
.preferences-trigger:focus-visible { outline: 3px solid var(--lavender); outline-offset: 2px; }
|
||||||
|
.preferences-dialog { position: absolute; z-index: 30; top: 3rem; right: 0; width: min(24rem, calc(100vw - 2rem)); padding: 1rem; color: var(--text); background: var(--base); border: 1px solid var(--surface-2); border-radius: .75rem; box-shadow: 0 .75rem 2.5rem #0009; }
|
||||||
|
.preferences-heading { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
|
||||||
|
.preferences-heading h2 { margin-bottom: .75rem; font-size: 1rem; }
|
||||||
|
.preferences-dialog fieldset { display: grid; gap: .65rem; border: 0; padding: 0; }
|
||||||
|
.preferences-dialog legend { margin-bottom: .55rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
|
||||||
|
.preferences-dialog label { display: flex; align-items: center; gap: .55rem; }
|
||||||
|
.preference-example { margin: .85rem 0 0; color: var(--overlay-1); font-size: var(--font-size-xs); }
|
||||||
|
|
||||||
.app-main { width: 100%; min-width: 0; padding: clamp(2.25rem, 5vw, 5rem) var(--shell-gutter) clamp(3rem, 7vw, 7rem); }
|
.app-main { width: 100%; min-width: 0; padding: clamp(2.25rem, 5vw, 5rem) var(--shell-gutter) clamp(3rem, 7vw, 7rem); }
|
||||||
.page { width: min(100%, 92rem); min-width: 0; margin: 0 auto; }
|
.page { width: min(100%, 92rem); min-width: 0; margin: 0 auto; }
|
||||||
|
|
@ -59,15 +69,20 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
||||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||||
|
|
||||||
.sessions-toolbar { margin-bottom: 1.25rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
.sessions-toolbar { margin-bottom: 1.25rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.detail-actions, .confirmation-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .65rem; }
|
||||||
.session-tabs { display: flex; gap: .4rem; padding: .3rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); }
|
.session-tabs { display: flex; gap: .4rem; padding: .3rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); }
|
||||||
.session-tabs button, .quiet-action, .primary-action, .editor-actions button { min-height: 2.6rem; padding: .6rem .9rem; border: 1px solid transparent; border-radius: .55rem; color: var(--text); background: transparent; cursor: pointer; }
|
.session-tabs button, .quiet-action, .primary-action, .editor-actions button { min-height: 2.6rem; padding: .6rem .9rem; border: 1px solid transparent; border-radius: .55rem; color: var(--text); background: transparent; cursor: pointer; }
|
||||||
.session-tabs button[aria-selected="true"] { color: var(--crust); background: var(--lavender); font-weight: 700; }
|
.session-tabs button[aria-selected="true"] { color: var(--crust); background: var(--lavender); font-weight: 700; }
|
||||||
.primary-action, .editor-actions .primary-action { color: var(--crust); background: var(--lavender); font-weight: 700; }
|
.primary-action, .editor-actions .primary-action { color: var(--crust); background: var(--lavender); font-weight: 700; }
|
||||||
|
.danger-action { min-height: 2.6rem; padding: .6rem .9rem; color: var(--red); background: transparent; border: 1px solid var(--red); border-radius: .55rem; cursor: pointer; font-weight: 700; }
|
||||||
|
.danger-action:hover { color: var(--crust); background: var(--red); }
|
||||||
|
.danger-action:disabled { cursor: not-allowed; opacity: .55; }
|
||||||
.quiet-action { border-color: var(--surface-2); }
|
.quiet-action { border-color: var(--surface-2); }
|
||||||
.session-search { display: grid; gap: .45rem; max-width: 34rem; margin-bottom: 1.5rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
|
.session-search { display: grid; gap: .45rem; max-width: 34rem; margin-bottom: 1.5rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
|
||||||
.session-filters { display: flex; flex-wrap: wrap; gap: .8rem; margin: -.35rem 0 1.25rem; }
|
.session-filters { display: flex; flex-wrap: wrap; gap: .8rem; margin: -.35rem 0 1.25rem; }
|
||||||
.session-filters label { display: grid; min-width: 10rem; gap: .35rem; color: var(--subtext-0); font-size: var(--font-size-xs); }
|
.session-filters label { display: grid; min-width: 10rem; gap: .35rem; color: var(--subtext-0); font-size: var(--font-size-xs); }
|
||||||
.session-filters select, .session-filters input { min-height: 2.5rem; padding: .55rem .7rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .5rem; }
|
.session-filters select, .session-filters input { min-height: 2.5rem; padding: .55rem .7rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .5rem; }
|
||||||
|
.sort-indicator { width: max-content; margin: -.5rem 0 1rem; padding: .35rem .55rem; color: var(--lavender); background: rgb(180 190 254 / 8%); border-radius: .4rem; font: 650 var(--font-size-xs) var(--font-mono); }
|
||||||
.session-search input, .form-grid input, .form-grid select, .form-grid textarea, .catalog-picker input, .occurrence-fields input, .occurrence-fields select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; border: 1px solid var(--surface-2); border-radius: .5rem; color: var(--text); background: var(--base); font-size: var(--font-size-md); }
|
.session-search input, .form-grid input, .form-grid select, .form-grid textarea, .catalog-picker input, .occurrence-fields input, .occurrence-fields select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; border: 1px solid var(--surface-2); border-radius: .5rem; color: var(--text); background: var(--base); font-size: var(--font-size-md); }
|
||||||
.session-list { display: grid; gap: .6rem; }
|
.session-list { display: grid; gap: .6rem; }
|
||||||
.session-row { width: 100%; padding: 1rem 1.1rem; display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(7rem, 1fr)); gap: 1rem; align-items: center; text-align: left; color: var(--text); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); cursor: pointer; }
|
.session-row { width: 100%; padding: 1rem 1.1rem; display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(7rem, 1fr)); gap: 1rem; align-items: center; text-align: left; color: var(--text); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); cursor: pointer; }
|
||||||
|
|
@ -93,6 +108,10 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
||||||
.editor-actions button:disabled { cursor: not-allowed; opacity: .55; }
|
.editor-actions button:disabled { cursor: not-allowed; opacity: .55; }
|
||||||
.empty-inline, .error-panel, .form-error { padding: 1rem; border-radius: .65rem; background: var(--base); color: var(--subtext-0); }
|
.empty-inline, .error-panel, .form-error { padding: 1rem; border-radius: .65rem; background: var(--base); color: var(--subtext-0); }
|
||||||
.error-panel, .form-error { border: 1px solid var(--red); color: var(--red); }
|
.error-panel, .form-error { border: 1px solid var(--red); color: var(--red); }
|
||||||
|
.success-panel { padding: 1rem; border: 1px solid var(--green); border-radius: .65rem; color: var(--green); background: rgb(166 227 161 / 7%); }
|
||||||
|
.delete-confirmation { margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--red); border-radius: .75rem; background: var(--base); }
|
||||||
|
.delete-confirmation h3 { margin-top: 0; }
|
||||||
|
.delete-confirmation p { color: var(--subtext-0); line-height: 1.55; }
|
||||||
.detail-summary, .detail-tile { margin-bottom: 1rem; padding: 1.1rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--base); }
|
.detail-summary, .detail-tile { margin-bottom: 1rem; padding: 1.1rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--base); }
|
||||||
.detail-occurrences li { display: grid; gap: .35rem; }
|
.detail-occurrences li { display: grid; gap: .35rem; }
|
||||||
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }
|
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }
|
||||||
|
|
@ -241,8 +260,8 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 28rem) {
|
@media (max-width: 28rem) {
|
||||||
.app-shell { padding-bottom: 12.5rem; }
|
.app-shell { padding-bottom: 0; }
|
||||||
.status-footer { grid-template-columns: 1fr; }
|
.status-footer { position: static; grid-template-columns: 1fr; }
|
||||||
.status-zone, .backend-state { grid-column: auto; }
|
.status-zone, .backend-state { grid-column: auto; }
|
||||||
h1 { font-size: 2.3rem; }
|
h1 { font-size: 2.3rem; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||