diff --git a/AGENTS.md b/AGENTS.md index 27f7093..3c299d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,7 +170,7 @@ It is not the canonical analytics store. The Android UI is driven by exercise metadata, never by exercise-name heuristics. -Android local SQLite schema v20 retains the exactly-one durable active-session +Android local SQLite schema v23 retains the exactly-one durable active-session draft introduced in v4. Every meaningful draft/form mutation is persisted by the repository. Back, backgrounding and process death never delete the draft. Home offers explicit @@ -184,7 +184,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the durable flattened exercise-alias mapping used to resolve retired creator IDs. Desktop schema v12 owns the same alias compatibility boundary. Later additive -desktop migrations reach v21 and Android migrations reach v20 without changing +desktop migrations reach v24 and Android migrations reach v23 without changing that identity contract. The active completed-session exchange is the separate strict `trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1` @@ -241,12 +241,13 @@ Desktop SQLite schema is versioned with: PRAGMA user_version; ``` -The current desktop schema is v21. Schema v9 added an occurrence-owned explicit +The current desktop schema is v24. Schema v9 added an occurrence-owned explicit maximum result; v10/v11 added body-zone and planning metadata; v12 added durable flattened exercise aliases; v13-v18 add machine metadata, feedback and revisions, occurrence tracking snapshots, causal profile state, and AI proposal storage/publication state; v19-v21 add lifecycle, causal deletion, and staged -generation/ACK ledgers. None changes `TRAINLOG_FORMAT_V1`. +generation/ACK ledgers; v22-v24 add generation archival, manual preparations, +and durable preparation withdrawal. None changes `TRAINLOG_FORMAT_V1`. Every incompatible schema evolution requires an explicit migration and regression coverage. diff --git a/CHANGELOG.md b/CHANGELOG.md index 647f39c..eb8e3a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased — 0.1.2 +- Corrected Web Sessions presentation with one descending cross-type order, + strict civil-date and zoned-timestamp formatting, contextual French states, + readable persisted proposal provenance, and an installation-local versioned + French/ISO date preference. The Dashboard prepared-item list, Footer, charts + and synchronization timestamps use the same presentation helpers without + changing machine values. + +- Added revision-guarded, idempotent **Delete preparation** through the Core + service and protected loopback API. Desktop schema v24 retains an immutable + withdrawal ledger and all preparation/delivery evidence. The separate + `trainlog-session-preparations` V2 generation participant propagates exact + withdrawals; Android schema v23 cancels only pending deliveries, preserves + started executions and permanently prevents older delivery replay from + resurrecting a withdrawn preparation. V1 remains readable and + `TRAINLOG_FORMAT_V1` is unchanged. + - Implemented `TRAINLOG_WEB_SESSIONS_V1` with paged preparation, resume and history views, stable details, optimistic/idempotent manual preparation, explicit proposal derivation, desktop schema v23, Android schema v22 and the diff --git a/README.md b/README.md index 91b1a2a..b4af164 100644 --- a/README.md +++ b/README.md @@ -318,7 +318,7 @@ not contain cloud credentials and does not run `rclone`. As of 2026-09-17: - `TRAINLOG_FORMAT_V1=PASS/FROZEN`; -- desktop SQLite schema v23 and Android SQLite schema v22; +- desktop SQLite schema v24 and Android SQLite schema v23; - Notcurses is the only active desktop terminal backend; - direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's all-files access setting; diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt index 5c05363..bf81478 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt @@ -219,7 +219,7 @@ internal class AndroidBackupService(private val context: Context) { manifest.getInt("version") == VERSION && manifest.getString("package") == context.packageName && manifest.getBoolean("plaintext") && - manifest.getInt("schema") in 17..22 + manifest.getInt("schema") in 17..23 ) { "Unsupported backup." } @@ -313,7 +313,7 @@ internal class AndroidBackupService(private val context: Context) { } ) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) - require(db.version in 17..22) { "Unsupported Android schema ${db.version}." } + require(db.version in 17..23) { "Unsupported Android schema ${db.version}." } return db.version } finally { db.close() diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt index a4f7de9..27104c9 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt @@ -163,6 +163,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train "mobile-history-v4", "execution-draft-v1", "generation-archive-v1", + "session-preparations-v2", ) ), ) diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt index aeb4c69..f370828 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt @@ -70,7 +70,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) private val SUPPORTED = (ANDROID_KINDS.values + Kind("trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", false) + - Kind("trainlog-session-preparations", 1, "session-preparations-v1.json", false)) + Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false)) .map { it.format to it.version } .toSet() diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index b314bf5..1fa318b 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -1884,9 +1884,17 @@ class TrainlogRepository( val root = try { JSONObject(json) } catch (_: Exception) { return AiSessionDraftImportResult.Invalid("Artifact de préparations invalide.") } - if (!root.hasExactKeys(setOf("format", "version", "generated_at", "deliveries")) || - root.optString("format") != "trainlog-session-preparations" || root.optInt("version") != 1) - return AiSessionDraftImportResult.Invalid("Artifact de préparations v1 non supporté.") + val version = root.optInt("version") + val rootKeys = if (version == 1) { + setOf("format", "version", "generated_at", "deliveries") + } else { + setOf("format", "version", "generated_at", "deliveries", "withdrawals") + } + if (!root.hasExactKeys(rootKeys) || + root.optString("format") != "trainlog-session-preparations" || + version !in setOf(1, 2)) { + return AiSessionDraftImportResult.Invalid("Artifact de préparations non supporté.") + } val values = root.optJSONArray("deliveries") ?: return AiSessionDraftImportResult.Invalid("Livraisons de préparations manquantes.") if (values.length() > 128) return AiSessionDraftImportResult.Invalid("Trop de préparations.") @@ -1904,8 +1912,10 @@ class TrainlogRepository( val preparationId = delivery.getString("preparation_id") val revisionId = delivery.getString("revision_id") val executionId = delivery.getString("execution_session_id") - require(deliveryId.startsWith("spd_") && preparationId.startsWith("sp_") && - revisionId.startsWith("spr_") && executionId.startsWith("se_")) { "Identité de préparation invalide." } + require(stableUuidV4Id(deliveryId, "spd_") && + stableUuidV4Id(preparationId, "sp_") && + stableUuidV4Id(revisionId, "spr_") && + stableUuidV4Id(executionId, "se_")) { "Identité de préparation invalide." } require(delivery.getString("state") in setOf("pending", "remote_unknown")) { "État de livraison invalide." } val occurrences = delivery.getJSONArray("occurrences") require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." } @@ -1915,7 +1925,15 @@ class TrainlogRepository( arrayOf(deliveryId), ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) to cursor.getString(1) else null } val canonical = delivery.toString() - if (existing == null) { + val withdrawn = db.rawQuery( + "SELECT 1 FROM session_preparation_withdrawals WHERE preparation_id=?", + arrayOf(preparationId), + ).use { cursor -> cursor.moveToFirst() } + if (existing == null && withdrawn) { + /* INVARIANT: an older generation cannot resurrect a + * delivery after its preparation withdrawal was consumed. */ + skipped++ + } else if (existing == null) { db.insertOrThrow("session_preparation_deliveries", null, ContentValues().apply { put("delivery_id", deliveryId); put("preparation_id", preparationId) put("revision_id", revisionId); put("execution_session_id", executionId) @@ -1930,6 +1948,13 @@ class TrainlogRepository( skipped++ } } + if (version == 2) { + val withdrawals = root.getJSONArray("withdrawals") + require(withdrawals.length() <= 128) { "Trop de retraits de préparations." } + for (withdrawalIndex in 0 until withdrawals.length()) { + applySessionPreparationWithdrawal(db, withdrawals.getJSONObject(withdrawalIndex)) + } + } AiSessionDraftImportResult.Applied(imported, skipped) } catch (error: IllegalArgumentException) { AiSessionDraftImportResult.Invalid(error.message ?: "Préparation invalide.") @@ -1938,6 +1963,93 @@ class TrainlogRepository( } } + /** + * Applies one immutable preparation withdrawal inside the generation + * transaction. Pending deliveries become non-startable; started delivery + * rows and every execution record are preserved verbatim. + */ + private fun applySessionPreparationWithdrawal(db: SQLiteDatabase, withdrawal: JSONObject) { + require(withdrawal.hasExactKeys(setOf( + "withdrawal_id", + "preparation_id", + "revision_id", + "requested_at", + "deliveries", + ))) { "Forme de retrait de préparation invalide." } + val withdrawalId = withdrawal.getString("withdrawal_id") + val preparationId = withdrawal.getString("preparation_id") + val revisionId = withdrawal.getString("revision_id") + require(stableUuidV4Id(withdrawalId, "spw_") && + stableUuidV4Id(preparationId, "sp_") && + stableUuidV4Id(revisionId, "spr_")) { "Identité de retrait de préparation invalide." } + val deliveries = withdrawal.getJSONArray("deliveries") + require(deliveries.length() <= 128) { "Trop de livraisons dans le retrait." } + val canonical = withdrawal.toString() + val existing = db.rawQuery( + "SELECT preparation_id,revision_id,payload_json FROM " + + "session_preparation_withdrawals WHERE withdrawal_id=?", + arrayOf(withdrawalId), + ).use { cursor -> + if (!cursor.moveToFirst()) { + null + } else { + Triple(cursor.getString(0), cursor.getString(1), cursor.getString(2)) + } + } + require(existing == null || + (existing.first == preparationId && existing.second == revisionId && + JSONObject(existing.third).toString() == canonical)) { + "Identité de retrait réutilisée avec un contenu différent." + } + var result = "no_matching_delivery" + for (deliveryIndex in 0 until deliveries.length()) { + val delivery = deliveries.getJSONObject(deliveryIndex) + require(delivery.hasExactKeys(setOf( + "delivery_id", + "revision_id", + "execution_session_id", + ))) { "Livraison de retrait invalide." } + val deliveryId = delivery.getString("delivery_id") + val deliveryRevision = delivery.getString("revision_id") + val executionId = delivery.getString("execution_session_id") + require(stableUuidV4Id(deliveryId, "spd_") && + stableUuidV4Id(deliveryRevision, "spr_") && + stableUuidV4Id(executionId, "se_")) { "Identité de livraison de retrait invalide." } + val state = db.rawQuery( + "SELECT state FROM session_preparation_deliveries WHERE delivery_id=? AND " + + "preparation_id=? AND revision_id=? AND execution_session_id=?", + arrayOf(deliveryId, preparationId, deliveryRevision, executionId), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + when (state) { + "pending" -> { + db.execSQL( + "UPDATE session_preparation_deliveries SET state='cancelled' " + + "WHERE delivery_id=? AND state='pending'", + arrayOf(deliveryId), + ) + if (result != "execution_preserved") { + result = "pending_cancelled" + } + } + "cancelled" -> if (result != "execution_preserved") { + result = "pending_cancelled" + } + "started" -> result = "execution_preserved" + } + } + if (existing == null) { + db.insertOrThrow("session_preparation_withdrawals", null, ContentValues().apply { + put("withdrawal_id", withdrawalId) + put("preparation_id", preparationId) + put("revision_id", revisionId) + put("requested_at", withdrawal.getString("requested_at")) + put("received_at", OffsetDateTime.now(java.time.ZoneOffset.UTC).toString()) + put("result", result) + put("payload_json", canonical) + }) + } + } + private fun parsePreparedOccurrences(db: SQLiteDatabase, values: JSONArray): List = buildList { val identities = mutableSetOf() @@ -6156,6 +6268,13 @@ class TrainlogRepository( finally{if(db.inTransaction())db.endTransaction()} } + private fun stableUuidV4Id(value: String, prefix: String): Boolean { + if (!value.startsWith(prefix)) return false + val suffix = value.removePrefix(prefix) + val parsed = runCatching { UUID.fromString(suffix) }.getOrNull() ?: return false + return parsed.version() == 4 && parsed.variant() == 2 && parsed.toString() == suffix + } + private fun stableFeedbackId(value:String,prefix:String):Boolean = value.startsWith(prefix) && runCatching { UUID.fromString(value.removePrefix(prefix)) }.isSuccess private fun canonicalExerciseId(db:SQLiteDatabase,id:String):String? = db.rawQuery( @@ -8076,7 +8195,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 22, + 23, ) { override fun onConfigure( db: SQLiteDatabase, @@ -8123,6 +8242,7 @@ private class TrainlogDatabaseHelper( createSyncGenerationTables(db) createSyncGenerationArchiveTable(db) createSessionPreparationTables(db) + createSessionPreparationWithdrawalTable(db) seedEquipment(db) } @@ -8304,6 +8424,14 @@ private class TrainlogDatabaseHelper( createSessionPreparationTables(db) version = 22 } + if (version < 23 && newVersion >= 23) { + /* WHY: snapshot omission cannot retract a preparation already + * received by Android. CONTRACT: v23 stores immutable withdrawal + * identity and the local application result. INVARIANT: no + * delivery, active draft, or completed session is removed. */ + createSessionPreparationWithdrawalTable(db) + version = 23 + } if (version != newVersion) { error( @@ -8423,6 +8551,17 @@ private class TrainlogDatabaseHelper( "session_preparation_deliveries(preparation_id,revision_id);") } + private fun createSessionPreparationWithdrawalTable(db: SQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS session_preparation_withdrawals(" + + "withdrawal_id TEXT PRIMARY KEY,preparation_id TEXT NOT NULL UNIQUE," + + "revision_id TEXT NOT NULL,requested_at TEXT NOT NULL,received_at TEXT NOT NULL," + + "result TEXT NOT NULL CHECK(result IN(" + + "'pending_cancelled','execution_preserved','no_matching_delivery'))," + + "payload_json TEXT NOT NULL);", + ) + } + private fun createAiSessionDraftTables(db: SQLiteDatabase) { db.execSQL( "CREATE TABLE IF NOT EXISTS ai_session_drafts(" + diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index 3278586..7967d49 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(22, scalar(db, "PRAGMA user_version")) + assertEquals(23, scalar(db, "PRAGMA user_version")) for (table in listOf("session_exercises", "draft_session_exercises")) { assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index 68effde..b18cb11 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(22, scalar(db, "PRAGMA user_version")) + assertEquals(23, scalar(db, "PRAGMA user_version")) /* v13 intentionally adds the frozen machine identities and * exercise-owned metadata; all non-exercise facts stay exact. */ val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt index 9ec11ea..9bc8b62 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(22, scalar(db, "PRAGMA user_version")) + assertEquals(23, scalar(db, "PRAGMA user_version")) assertEquals(before, listOf( scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM performed_sets"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index d14966d..7e48e16 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -328,7 +328,7 @@ class BodyZonesTest { SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY).use { db -> db.rawQuery("PRAGMA user_version", null).use { cursor -> - assertTrue(cursor.moveToFirst()); assertEquals(22, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(23, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt index e6d7e8b..b28219b 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt @@ -43,7 +43,7 @@ class BridgeBackupMigrationTest { null, android.database.sqlite.SQLiteDatabase.OPEN_READONLY, ) - .use { assertEquals(22, it.version) } + .use { assertEquals(23, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt index b46faaf..2b2b693 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt @@ -1,6 +1,7 @@ package com.labfytools.trainlog.data import android.content.Context +import android.database.sqlite.SQLiteDatabase import androidx.test.core.app.ApplicationProvider import com.labfytools.trainlog.model.NewExerciseProfile import com.labfytools.trainlog.model.RecordingMode @@ -62,6 +63,48 @@ class SessionPreparationExchangeTest { .toString() } + private fun withdrawalArtifact(): String { + val delivery = + JSONObject() + .put("delivery_id", "spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + .put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc") + .put("execution_session_id", "se_dddddddd-dddd-4ddd-8ddd-dddddddddddd") + val withdrawal = + JSONObject() + .put("withdrawal_id", "spw_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee") + .put("preparation_id", "sp_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") + .put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc") + .put("requested_at", "2026-09-18T12:00:00Z") + .put("deliveries", JSONArray().put(delivery)) + return JSONObject() + .put("format", "trainlog-session-preparations") + .put("version", 2) + .put("generated_at", "2026-09-18T12:01:00Z") + .put("deliveries", JSONArray()) + .put("withdrawals", JSONArray().put(withdrawal)) + .toString() + } + + private fun scalarText(name: String, sql: String): String { + val path = context.getDatabasePath(name) + return SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { db -> + db.rawQuery(sql, null).use { cursor -> + assertTrue(cursor.moveToFirst()) + cursor.getString(0) + } + } + } + + private fun scalarInt(name: String, sql: String): Int { + val path = context.getDatabasePath(name) + return SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { db -> + db.rawQuery(sql, null).use { cursor -> + assertTrue(cursor.moveToFirst()) + cursor.getInt(0) + } + } + } + @Test fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() { val name = "prepared-${System.nanoTime()}.db" @@ -109,4 +152,183 @@ class SessionPreparationExchangeTest { context.deleteDatabase(name) } } + + @Test + fun withdrawalCancelsOnlyPendingDeliveryAndPreventsOlderReplayResurrection() { + val name = "prepared-withdraw-pending-${System.nanoTime()}.db" + val repository = TrainlogRepository(context, name) + try { + assertTrue(repository.createExercise(NewExerciseProfile( + name = "Test pending withdrawal", + recordingMode = RecordingMode.SETS, + trackingMode = TrackingMode.REPS, + dataFields = 0, + )) is CreateExerciseResult.Created) + val delivery = artifact(repository) + val withdrawal = withdrawalArtifact() + assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applySessionPreparationsJson(delivery)) + val invalidWithdrawal = JSONObject(withdrawal) + invalidWithdrawal.getJSONArray("withdrawals").getJSONObject(0) + .put("withdrawal_id", "spw_invalid") + assertTrue(repository.applySessionPreparationsJson(invalidWithdrawal.toString()) is AiSessionDraftImportResult.Invalid) + assertEquals(1, repository.listPreparedSessions().size) + assertEquals(AiSessionDraftImportResult.Applied(0, 0), repository.applySessionPreparationsJson(withdrawal)) + assertTrue(repository.listPreparedSessions().isEmpty()) + assertEquals( + StartAiSessionDraftResult.NotPending, + repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + ) + assertEquals(AiSessionDraftImportResult.Applied(0, 0), repository.applySessionPreparationsJson(withdrawal)) + assertEquals(AiSessionDraftImportResult.Applied(0, 1), repository.applySessionPreparationsJson(delivery)) + assertTrue(repository.listPreparedSessions().isEmpty()) + repository.close() + assertEquals( + "cancelled", + scalarText(name, "SELECT state FROM session_preparation_deliveries"), + ) + assertEquals( + "pending_cancelled", + scalarText(name, "SELECT result FROM session_preparation_withdrawals"), + ) + } finally { + repository.close() + context.deleteDatabase(name) + } + } + + @Test + fun withdrawalPreservesStartedDeliveryAndActiveExecutionIdentity() { + val name = "prepared-withdraw-started-${System.nanoTime()}.db" + val repository = TrainlogRepository(context, name) + try { + assertTrue(repository.createExercise(NewExerciseProfile( + name = "Test started withdrawal", + recordingMode = RecordingMode.SETS, + trackingMode = TrackingMode.REPS, + dataFields = 0, + )) is CreateExerciseResult.Created) + assertEquals( + AiSessionDraftImportResult.Applied(1, 0), + repository.applySessionPreparationsJson(artifact(repository)), + ) + assertEquals( + StartAiSessionDraftResult.Started, + repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + ) + val before = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded + assertEquals(AiSessionDraftImportResult.Applied(0, 0), repository.applySessionPreparationsJson(withdrawalArtifact())) + val after = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded + assertEquals(before.draft.sessionId, after.draft.sessionId) + assertEquals("se_dddddddd-dddd-4ddd-8ddd-dddddddddddd", after.draft.sessionId) + repository.close() + assertEquals( + "started", + scalarText(name, "SELECT state FROM session_preparation_deliveries"), + ) + assertEquals( + "execution_preserved", + scalarText(name, "SELECT result FROM session_preparation_withdrawals"), + ) + } finally { + repository.close() + context.deleteDatabase(name) + } + } + + @Test + fun version22MigrationAddsWithdrawalLedgerWithoutChangingDeliveries() { + val name = "prepared-withdraw-migration-${System.nanoTime()}.db" + var repository = TrainlogRepository(context, name) + try { + assertTrue(repository.createExercise(NewExerciseProfile( + name = "Test withdrawal migration", + recordingMode = RecordingMode.SETS, + trackingMode = TrackingMode.REPS, + dataFields = 0, + )) is CreateExerciseResult.Created) + val delivery = artifact(repository) + assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applySessionPreparationsJson(delivery)) + repository.close() + SQLiteDatabase.openDatabase( + context.getDatabasePath(name).absolutePath, + null, + SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL("DROP TABLE session_preparation_withdrawals") + db.execSQL("PRAGMA user_version=22") + } + repository = TrainlogRepository(context, name) + assertEquals(1, repository.listPreparedSessions().size) + repository.close() + assertEquals("23", scalarText(name, "PRAGMA user_version")) + assertEquals( + "0", + scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"), + ) + } finally { + repository.close() + context.deleteDatabase(name) + } + } + + @Test + fun withdrawalCancelsAllPendingTargetDeliveriesButPreservesStartedAndWitnessRows() { + val name = "prepared-withdraw-multiple-${System.nanoTime()}.db" + val repository = TrainlogRepository(context, name) + try { + assertTrue(repository.createExercise(NewExerciseProfile( + name = "Test multiple withdrawal", + recordingMode = RecordingMode.SETS, + trackingMode = TrackingMode.REPS, + dataFields = 0, + )) is CreateExerciseResult.Created) + val deliveryRoot = JSONObject(artifact(repository)) + val targetTemplate = deliveryRoot.getJSONArray("deliveries").getJSONObject(0) + val secondTarget = JSONObject(targetTemplate.toString()) + .put("delivery_id", "spd_11111111-1111-4111-8111-111111111111") + .put("execution_session_id", "se_11111111-1111-4111-8111-111111111111") + val witness = JSONObject(targetTemplate.toString()) + .put("delivery_id", "spd_22222222-2222-4222-8222-222222222222") + .put("preparation_id", "sp_22222222-2222-4222-8222-222222222222") + .put("revision_id", "spr_22222222-2222-4222-8222-222222222222") + .put("execution_session_id", "se_22222222-2222-4222-8222-222222222222") + .put("title", "Préparation témoin") + deliveryRoot.getJSONArray("deliveries").put(secondTarget).put(witness) + assertEquals( + AiSessionDraftImportResult.Applied(3, 0), + repository.applySessionPreparationsJson(deliveryRoot.toString()), + ) + assertEquals( + StartAiSessionDraftResult.Started, + repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + ) + val withdrawalRoot = JSONObject(withdrawalArtifact()) + withdrawalRoot.getJSONArray("withdrawals").getJSONObject(0) + .getJSONArray("deliveries") + .put( + JSONObject() + .put("delivery_id", "spd_11111111-1111-4111-8111-111111111111") + .put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc") + .put("execution_session_id", "se_11111111-1111-4111-8111-111111111111"), + ) + assertEquals( + AiSessionDraftImportResult.Applied(0, 0), + repository.applySessionPreparationsJson(withdrawalRoot.toString()), + ) + assertEquals( + listOf("spd_22222222-2222-4222-8222-222222222222"), + repository.listPreparedSessions().map { it.deliveryId }, + ) + val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded + assertEquals("se_dddddddd-dddd-4ddd-8ddd-dddddddddddd", active.draft.sessionId) + repository.close() + assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='started'")) + assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='cancelled'")) + assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM session_preparation_deliveries WHERE state='pending'")) + assertEquals("execution_preserved", scalarText(name, "SELECT result FROM session_preparation_withdrawals")) + } finally { + repository.close() + context.deleteDatabase(name) + } + } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index 02b82c9..29490ba 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -111,7 +111,7 @@ class SyncGenerationServiceTest { .use { db -> db.rawQuery("PRAGMA user_version", null).use { assertTrue(it.moveToFirst()) - assertEquals(22, it.getInt(0)) + assertEquals(23, it.getInt(0)) } db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { assertTrue(it.moveToFirst()) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 6fa47d4..2195ba1 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(22, cursor.getInt(0)) + assertEquals(23, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(22, cursor.getInt(0)) + assertEquals(23, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/docs/android.md b/docs/android.md index bccd210..be0ade8 100644 --- a/docs/android.md +++ b/docs/android.md @@ -6,6 +6,14 @@ preparation is inert until explicit start; an occupied singleton is never overwritten. Starting preserves the desktop-reserved execution `session_id`, copies targets only and creates no performed facts. +Android schema v23 adds the durable manual-preparation withdrawal ledger. +Consumption is part of the outer generation transaction. Exact pending +deliveries are changed to `cancelled`; exact started deliveries remain started, +and their active or completed execution data is untouched. The stored result is +`pending_cancelled`, `execution_preserved`, or `no_matching_delivery`. +Withdrawal replay is idempotent, and an older preparation delivery is skipped +once the withdrawal identity for that preparation exists. + ## 1. Purpose The Android application is Trainlog's low-friction capture client. diff --git a/docs/architecture.md b/docs/architecture.md index 502cc1b..e666270 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -493,7 +493,9 @@ silently rendered as unclassified. ### Desktop -Desktop SQLite schema v23 is canonical long-term history. Its additive v18 -> +Desktop SQLite schema v24 is canonical long-term history. Its additive v23 -> +v24 migration adds durable manual-preparation withdrawal identity without +deleting revisions, deliveries or workout facts. Its additive v18 -> v19 migration stores causal notes and execution-draft lifecycle state without activating a new transport. Its v15 -> v16 transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the diff --git a/docs/current_state.md b/docs/current_state.md index 06eb68a..40334fd 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -50,8 +50,8 @@ parallel implementation of its rules. | Boundary | Current state | |---|---| | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | -| Desktop SQLite | schema v23 | -| Android SQLite | schema v22 | +| Desktop SQLite | schema v24 | +| Android SQLite | schema v23 | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Desktop terminal backend | Notcurses only | | Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` | @@ -222,11 +222,22 @@ resume and history views, stable details, optimistic manual-preparation writes, explicit proposal derivation and generation-backed Android delivery. Analyse, Programmes and Exercises remain explicit placeholders. -Desktop schema v23 owns immutable manual-preparation revisions, stable ordered -occurrences, persistent HTTP idempotency keys and delivery-to-execution identity. -Android schema v22 stores received preparations separately from AI proposals -and the active singleton. An exact correlated ACK is required before delivery -is shown as acknowledged. +Desktop schema v24 owns immutable manual-preparation revisions, stable ordered +occurrences, persistent HTTP idempotency keys, delivery-to-execution identity, +and durable revision-bound withdrawal records. Android schema v23 stores +received preparations and permanent withdrawal results separately from AI +proposals and the active singleton. Pending deliveries are cancelled by an +exact V2 withdrawal; started executions are preserved. An exact correlated ACK +is required before delivery or withdrawal is shown as acknowledged. + +Sessions and the prepared-items Dashboard projection now share descending +planned-date presentation with timestamp and stable-identity tie-breaks. Web +dates default to French presentation, with an installation-local versioned ISO +preference outside business SQLite and synchronization. Contextual French state +labels and persisted proposal provenance replace raw enums and digest-only +presentation. The preparation detail exposes a protected, idempotent **Delete +preparation** action; withdrawn rows remain technically inspectable but cannot +be edited or delivered. The controlled deployment preserved the installed Android signing identity and advanced its private `versionCode` without uninstalling or clearing data. The @@ -237,9 +248,9 @@ performed fact, or active-draft replacement. The Next Session tile no longer treats the frozen Dashboard `next_session.available` placeholder as a durable inventory. A separate bounded -`GET /api/v1/prepared-items` Core projection distinguishes imported AI -proposals from active or pending execution drafts and exposes stable identity, -state, title/date, occurrence count, and provenance. Reload and later sync +`GET /api/v1/prepared-items` Core projection distinguishes manual preparations, +imported AI proposals, and active or pending execution drafts and exposes stable +identity, state, title/date, occurrence count, and provenance. Reload and later sync failure do not erase the last successfully read projection. Proposal targets remain planning data and never alter Dashboard actual-work metrics. diff --git a/docs/database.md b/docs/database.md index 6926338..60169b7 100644 --- a/docs/database.md +++ b/docs/database.md @@ -1,5 +1,16 @@ # Desktop database +## Schema v24: durable preparation withdrawal + +Schema v24 adds nullable `session_preparations.withdrawn_at` and the immutable +`session_preparation_withdrawals` ledger. Each preparation has at most one +withdrawal identity, bound to the exact current revision, request timestamp, +generation and correlated ACK. The service transaction inserts this ledger row +and withdraws the preparation together. It does not delete or rewrite revision, +entry, delivery, proposal, execution, session, feedback or acknowledgement +evidence. A withdrawn preparation is excluded from active projections and +cannot be edited or delivered again. + ## Schema v23: Web session preparations Schema v23 adds `session_preparations`, immutable diff --git a/docs/exchange_format.md b/docs/exchange_format.md index 697738b..5d8c4fc 100644 --- a/docs/exchange_format.md +++ b/docs/exchange_format.md @@ -2,8 +2,19 @@ ## Manual session preparations -`trainlog-session-preparations` V1 has a strict root `format`, `version`, -`generated_at`, and bounded `deliveries` array. Each delivery contains stable +`trainlog-session-preparations` V2 has a strict root `format`, `version`, +`generated_at`, bounded `deliveries`, and bounded `withdrawals`. Delivery +entries retain the V1 shape. Each withdrawal contains its stable +`spw_` identity, the exact preparation and revision, request timestamp, +and the bounded exact delivery/revision/reserved-execution identities that the +Android consumer may affect. Only a matching local `pending` delivery becomes +`cancelled`; a matching `started` delivery and its execution are preserved. +The durable Android withdrawal row prevents an older V1 delivery or generation +from recreating a startable preparation. + +The readable legacy `trainlog-session-preparations` V1 root has a strict +`format`, `version`, `generated_at`, and bounded `deliveries` array. Each +delivery contains stable delivery, preparation, revision, reserved execution and occurrence identities, immutable metadata, and at most 64 ordered target-only occurrences. Null target values remain distinct from zero. Performed sets, continuous results, MAX and diff --git a/docs/reviews/evidence/web-sessions-delete-confirmation-mobile.png b/docs/reviews/evidence/web-sessions-delete-confirmation-mobile.png new file mode 100644 index 0000000..cf5d0ad Binary files /dev/null and b/docs/reviews/evidence/web-sessions-delete-confirmation-mobile.png differ diff --git a/docs/reviews/evidence/web-sessions-detail-desktop.png b/docs/reviews/evidence/web-sessions-detail-desktop.png index 5ed791e..c370990 100644 Binary files a/docs/reviews/evidence/web-sessions-detail-desktop.png and b/docs/reviews/evidence/web-sessions-detail-desktop.png differ diff --git a/docs/reviews/evidence/web-sessions-editor-mobile.png b/docs/reviews/evidence/web-sessions-editor-mobile.png index 383ae1b..b1a82e1 100644 Binary files a/docs/reviews/evidence/web-sessions-editor-mobile.png and b/docs/reviews/evidence/web-sessions-editor-mobile.png differ diff --git a/docs/reviews/evidence/web-sessions-list-desktop.png b/docs/reviews/evidence/web-sessions-list-desktop.png index 6fa1671..47fa83c 100644 Binary files a/docs/reviews/evidence/web-sessions-list-desktop.png and b/docs/reviews/evidence/web-sessions-list-desktop.png differ diff --git a/docs/reviews/evidence/web-sessions-withdrawn-detail-desktop.png b/docs/reviews/evidence/web-sessions-withdrawn-detail-desktop.png new file mode 100644 index 0000000..ba390b7 Binary files /dev/null and b/docs/reviews/evidence/web-sessions-withdrawn-detail-desktop.png differ diff --git a/docs/roadmap.md b/docs/roadmap.md index 704aec8..f258a11 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -24,7 +24,7 @@ distinction between measured values and estimates. ## Current baseline -Desktop schema v23, Android schema v22, Notcurses, direct +Desktop schema v24, Android schema v23, Notcurses, direct `Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session Generator V1 is hidden pending V2. AI session-draft @@ -34,9 +34,11 @@ CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data contract, frozen interactive grid, versioned layout persistence and factual tile rendering exist. The Dashboard's bounded progression and BODY ZONES visualizations and Sessions V1 are implemented; the other three Web routes remain placeholders. -The corrective prepared-item projection and bounded phase-owned MTP outbox are -implemented without opening the Sessions route, changing schemas, or changing -the frozen Dashboard and exchange contracts. +The corrective prepared-item projection, bounded phase-owned MTP outbox, +Sessions presentation correction, local date preference and durable preparation +withdrawal are implemented. The withdrawal uses the separate versioned +session-preparations V2 participant and does not change the frozen Dashboard, +mobile snapshot or `TRAINLOG_FORMAT_V1` contracts. ## Current cursor diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 3bcfedf..87f2f3e 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -1,15 +1,26 @@ # Synchronization exchange -## Manual session preparations V1 +## Manual session preparations V2 -`trainlog-session-preparations` V1 is a separate optional desktop-to-Android +`trainlog-session-preparations` V2 is a separate optional desktop-to-Android generation artifact. It carries immutable ready revision snapshots and stable -delivery, preparation, occurrence and reserved execution identities. It never +delivery, preparation, occurrence and reserved execution identities, plus +immutable preparation-withdrawal operations. It never overloads mobile export, AI proposals or execution-drafts V1. Android validates the complete artifact in the generation transaction and stores it as pending; start remains an explicit foreground action. Desktop records the exact generation relation at capture and changes delivery state to acknowledged only -after accepting the correlated durable consumption ACK. +after accepting the correlated durable consumption ACK. It similarly records +the exact withdrawal-to-generation relation and acknowledges a withdrawal only +after Android's whole-generation commit. + +V1 remains readable for delivery replay compatibility but cannot transport a +withdrawal. Both peers advertise `session-preparations-v2`; generation mode +refuses an older capability set instead of omitting the causal operation. A V2 +withdrawal lists every desktop-known delivery for its preparation. Android +cancels only exact pending matches, preserves exact started matches, stores the +result as a tombstone, and refuses later resurrection from an old V1/V2 +delivery. Neither result deletes an active draft or completed workout. ## Causal deletion safety gate @@ -29,7 +40,7 @@ available because availability is independently guarded by causal state. ## Machine-exercise Phase 1 compatibility -Desktop schema v23 and Android schema v22 retain mobile export V3, readable V1/V2 imports, +Desktop schema v24 and Android schema v23 retain mobile export V3, readable V1/V2 imports, equipment definitions V1, equipment associations V2, exercise aliases V1, and the BODY ZONES companion without wire-format changes. Machine metadata is not silently added to a frozen artifact: stable exercise IDs and canonical names diff --git a/docs/tests.md b/docs/tests.md index d007414..1b4a474 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -2,13 +2,25 @@ `web_sessions` exercises production preparation creation, durable request replay, immutable successor revisions, stale-revision conflict, duplicate -exercise occurrences, list search and persisted detail serialization. The Web -Vitest suite covers the shell and API parsers; the embedded frontend is also -built into the canonical Meson binary. +exercise occurrences, list search, persisted detail serialization, revision- +guarded withdrawal, conflicting idempotency-key reuse, active-list exclusion, +and preservation of proposal/revision/delivery evidence. The HTTP test covers +the real protected DELETE route and the versioned local preference GET/PUT, +including CSRF, ETag, persistence and invalid-file fallback. The Web Vitest +suite additionally covers cross-type global ordering beyond 32 rows, timestamp +offsets, stable ties, invalid/absent dates, contextual states, confirmation +cancel, the DELETE request and preference conflict behavior. Date tests run +under `Pacific/Honolulu` and `Pacific/Kiritimati`. `SessionPreparationExchangeTest` covers Android import replay, occupied -singleton refusal, explicit start, reserved execution identity and absence of -performed facts. `SyncGenerationServiceTest` and +singleton refusal, explicit start, reserved execution identity, schema-v22 to +v23 migration, permanent withdrawal replay, pending cancellation, started +execution preservation, multi-delivery targeting, witness preservation and +absence of performed facts. `test_session_preparation_export.py` proves that a +withdrawn preparation is not redelivered, its exact evidence is exported in V2, +and an acknowledged withdrawal is not republished. Generation tests bind the +withdrawal to one immutable generation and acknowledge it only after the exact +durable ACK. `SyncGenerationServiceTest` and `SyncDeploymentConversationTest` exercise the real optional artifact through generation validation and 24 bidirectional conversations. It also asserts that every foreground conversation emits exactly one fresh daemon request signal; @@ -29,6 +41,14 @@ The C MTP regression keeps a malformed retained generation beside the current reference and proves that pull downloads only the referenced generation while still rejecting a truncated object inside that current generation. +`test_web_sessions_browser.py` runs headless Firefox against the embedded +production assets and C HTTP server with private temporary XDG roots. It creates +only a synthetic ready preparation and delivery, verifies French then persisted +ISO presentation, captures desktop and an exact BiDi-emulated 390x844 viewport, +performs the real confirmation click, checks the schema-v24 withdrawal ledger +after reload, and validates the resulting V2 export evidence. Android repository +tests exercise the actual V2 consumer rather than a permissive protocol mock. + `web_prepared_items` exercises the production SQLite projection and JSON serializer with a proposal and an execution draft, then proves finalized and causally deleted execution drafts are absent. The HTTP test covers the diff --git a/docs/web_sessions.md b/docs/web_sessions.md index f7b2cba..a5cf356 100644 --- a/docs/web_sessions.md +++ b/docs/web_sessions.md @@ -12,10 +12,51 @@ equipment/load semantics and profile-compatible targets. Draft saves may remain incomplete. Marking ready validates executable content; preparing for Android then creates a distinct immutable delivery and reserved execution identity. +Every view has one stable descending presentation order. Preparation merges +manual preparations and proposals by valid planned civil date, then factual +`sort_timestamp`, then kind and stable identity. Undated or invalidly dated +items remain visible after valid dates. Resume uses the execution-draft recency +timestamp supplied by Core, and History uses the real start instant; both use +stable identity to break exact ties. Search and filters operate on the complete +paged result and reapply the same order. Late results from an older view or +query cannot replace the current list. + +Web presentation defaults to French civil dates (`DD/MM/YYYY`) and 24-hour +times. A local **Display settings** dialog can select ISO dates. The versioned +preference is stored atomically with private permissions in +`$XDG_CONFIG_HOME/trainlog/web/preferences-v1.json`, falling back to +`$HOME/.config`; it is neither business data nor synchronized state. Missing +or invalid storage uses the French default, and invalid storage is reported. +Civil `YYYY-MM-DD` values are validated and never timezone-shifted. Zoned +timestamps preserve their instant and are presented in the browser timezone; +the original machine value remains in API data and `