Compare commits

..

No commits in common. "4b76848c54e0269d7adcc61fe35084c7ba1f51a9" and "a5cdbdf2b9a60ba0881368d8542e16cc19b4ff45" have entirely different histories.

86 changed files with 121 additions and 3488 deletions

View file

@ -2,17 +2,6 @@
## Unreleased — 0.1.2
- 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
separate `trainlog-session-preparations` V1 generation participant.
- Completed the controlled Sessions V1 deployment and delivered a six-entry
validation preparation to Android without starting it or replacing an active
draft. Foreground generation retries now emit a fresh daemon request and
resume the exact interrupted generation until both peer halves are durable,
preventing false transport timeouts and duplicate recapture.
- Added compatible non-destructive generation archival on desktop schema v22
and Android schema v21. Only exactly acknowledged generations older than the
two active recovery tips leave admission after a verified durable payload

View file

@ -17,7 +17,7 @@ over direct MTP; it never copies SQLite database files between devices.
|---|---|
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
| Local Web (0.1.2 development) | Display the Dashboard and the implemented Sessions inventory, detail, manual preparation, and Android-delivery workflow through typed Trainlog Core/API boundaries. Analyse, Programmes, and Exercises remain placeholders. |
| Local Web (0.1.2 development) | Display the implemented local Dashboard and persist its private layout through typed Trainlog Core/API boundaries. Analyse, Programmes, Sessions, and Exercises remain placeholders. |
No interface reconstructs business truth from SQLite tables. The local Web is
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
@ -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 v22 and Android SQLite schema v21;
- Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting;
@ -330,8 +330,7 @@ As of 2026-09-17:
- `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` pending a real Drive plus
Android-triggered bidirectional smoke test.
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
Analyse, Programmes, and Exercises remain placeholders;
`WEB_DASHBOARD_V1=PASS/FROZEN`; the other four Web routes remain placeholders;
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
services; automatic synchronization still selects V3. The opt-in grouped Web

View file

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

View file

@ -17,34 +17,6 @@ internal sealed interface ForegroundGenerationResult {
internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) {
private val service = SyncGenerationService(repository)
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
"OR (status='acknowledged' AND NOT EXISTS(" +
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
"AND c.result IN('consumed','rejected')))) " +
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
"LIMIT 2",
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
).use { cursor ->
if (!cursor.moveToFirst()) null
else {
val captured =
CapturedSyncGeneration(
cursor.getString(0),
runId,
cursor.getString(1),
File(cursor.getString(2)),
)
if (cursor.moveToNext())
throw SyncGenerationException("ambiguous resumable generation")
captured
}
}
}
private fun requireExactKeys(value: JSONObject, expected: Set<String>, label: String) {
val actual = value.keys().asSequence().toSet()
if (actual != expected) throw SyncGenerationException("invalid $label fields")
@ -140,7 +112,6 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
fun run(
directory: File,
timeout: Duration = Duration.ofMinutes(5),
afterPeerPublication: (() -> Unit)? = null,
afterPublication: (() -> Unit)? = null,
): ForegroundGenerationResult =
try {
@ -168,25 +139,17 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
)
.toString(),
)
/* WHY: trainlog-syncd is intentionally driven by the established
* Android request artifact, not by polling generation internals.
* CONTRACT: each explicit foreground attempt publishes the peer
* identity before its caller emits one fresh request signal.
* INVARIANT: a failed attempt's request, run, generations, and
* ACK evidence remain immutable; retry creates new correlation. */
afterPeerPublication?.invoke()
val deadline = System.nanoTime() + timeout.toNanos()
val requestRaw =
awaitCorrelated(File(directory, "request-v1.json"), deadline) { candidate ->
val runId = candidate.optString("run_id")
runId.startsWith("sy_") &&
(resumableGeneration(runId) != null ||
!repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
arrayOf(runId),
).use { it.moveToFirst() }
})
!repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
arrayOf(runId),
).use { it.moveToFirst() }
}
}
val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
if (request.getString("android_peer_id") != peer)
@ -205,18 +168,8 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
peer,
request.getString("desktop_peer_id"),
)
/* WHY: transport can fail after immutable generation publication.
* CONTRACT: an explicit retry for the same run republishes that
* exact generation and resumes ACK handling; it never recaptures
* mutable application state under an existing identity.
* INVARIANT: a completed inbound ledger makes the run ineligible;
* an acknowledged outbound generation remains resumable only to
* finish its missing inbound half. */
val captured =
resumableGeneration(
request.getString("run_id"),
request.getString("desktop_peer_id"),
) ?: service.capture(
service.capture(
directory,
request.getString("desktop_peer_id"),
request.getString("run_id"),

View file

@ -69,8 +69,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-ai-session-drafts", 1, "ai-proposals-v1.json", false))
.map { it.format to it.version }
.toSet()
@ -836,12 +835,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
apply("ai-proposals") {
repository.applyAiSessionDraftsJson(checkNotNull(a["ai-proposals"]))
}
if ("session-preparations" in a)
apply("session-preparations") {
repository.applySessionPreparationsJson(
checkNotNull(a["session-preparations"])
)
}
val consumedAt = OffsetDateTime.now().toString()
val ack =
JSONObject()

View file

@ -24,7 +24,6 @@ import com.labfytools.trainlog.model.ExerciseFeedback
import com.labfytools.trainlog.model.DraftExerciseFeedback
import com.labfytools.trainlog.model.LatestExerciseMax
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.PreparedSession
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionDraftForm
@ -1875,147 +1874,6 @@ class TrainlogRepository(
return output
}
/**
* Consumes immutable manual-preparation deliveries without touching AI
* proposals or the active draft. GenerationService owns the outer
* transaction, so any later participant failure rolls these rows back.
*/
internal fun applySessionPreparationsJson(json: String): AiSessionDraftImportResult {
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 values = root.optJSONArray("deliveries")
?: return AiSessionDraftImportResult.Invalid("Livraisons de préparations manquantes.")
if (values.length() > 128) return AiSessionDraftImportResult.Invalid("Trop de préparations.")
val db = database.writableDatabase
var imported = 0
var skipped = 0
return try {
for (deliveryIndex in 0 until values.length()) {
val delivery = values.getJSONObject(deliveryIndex)
val required = setOf("delivery_id", "preparation_id", "revision_id", "execution_session_id",
"state", "title", "session_type", "planned_for", "notes", "source_proposal_id",
"source_payload_sha256", "occurrences")
require(delivery.hasExactKeys(required)) { "Forme de préparation invalide." }
val deliveryId = delivery.getString("delivery_id")
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(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." }
parsePreparedOccurrences(db, occurrences)
val existing = db.rawQuery(
"SELECT revision_id,payload_json FROM session_preparation_deliveries WHERE delivery_id=?",
arrayOf(deliveryId),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) to cursor.getString(1) else null }
val canonical = delivery.toString()
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)
put("state", "pending"); put("payload_json", canonical)
put("received_at", OffsetDateTime.now(java.time.ZoneOffset.UTC).toString())
})
imported++
} else {
require(existing.first == revisionId && JSONObject(existing.second).toString() == canonical) {
"Identité de livraison réutilisée avec un contenu différent."
}
skipped++
}
}
AiSessionDraftImportResult.Applied(imported, skipped)
} catch (error: IllegalArgumentException) {
AiSessionDraftImportResult.Invalid(error.message ?: "Préparation invalide.")
} catch (_: Exception) {
AiSessionDraftImportResult.DatabaseError
}
}
private fun parsePreparedOccurrences(db: SQLiteDatabase, values: JSONArray): List<SessionExerciseDraft> =
buildList {
val identities = mutableSetOf<String>()
for (itemIndex in 0 until values.length()) {
val item = values.getJSONObject(itemIndex)
val entryId = item.getString("entry_id")
require(entryId.startsWith("spe_") && identities.add(entryId)) { "Occurrence dupliquée." }
val exerciseId = item.getString("exercise_id")
val exercise = readExerciseProfileExact(db, exerciseId)
?: throw IllegalArgumentException("Exercice de préparation inconnu : $exerciseId")
require(item.getString("recording_mode") == exercise.recordingMode.wireValue &&
item.getString("tracking_mode") == exercise.trackingMode.wireValue &&
item.getInt("data_fields") == exercise.dataFields) { "Profil de préparation divergent." }
val equipmentId = if (item.isNull("equipment_id")) null else item.getString("equipment_id")
require(equipmentId == null || lookupEquipmentRowIdOrNull(db, equipmentId) != null) {
"Équipement de préparation inconnu."
}
val loadMode = SessionLoadMode.fromWire(item.getString("load_mode"))
val sets = if (item.isNull("target_sets")) null else item.getInt("target_sets")
val reps = if (item.isNull("target_reps")) null else item.getInt("target_reps")
val duration = if (item.isNull("target_duration_seconds")) null else item.getInt("target_duration_seconds")
val weight = if (item.isNull("target_weight_kg")) null else item.getDouble("target_weight_kg")
require(item.getInt("position") == size && item.getInt("rest_seconds") in 0..MAX_PLAN_REST_SECONDS)
require(if (exercise.recordingMode == RecordingMode.CONTINUOUS) {
sets == null && reps == null && duration != null && loadMode == SessionLoadMode.NONE && weight == null
} else {
sets != null && sets in 1..99 && ((reps != null) xor (duration != null)) &&
((loadMode == SessionLoadMode.NONE) == (weight == null))
}) { "Objectifs de préparation invalides." }
add(SessionExerciseDraft(entryId = entryId, exercise = exercise, equipmentId = equipmentId,
plan = SessionExercisePlan(sets ?: 0, reps = reps, durationSeconds = duration,
weightKg = weight, loadMode = loadMode, restSeconds = item.getInt("rest_seconds"))))
}
}
fun listPreparedSessions(): List<PreparedSession> {
val db = database.readableDatabase
return buildList {
db.rawQuery("SELECT delivery_id,preparation_id,revision_id,execution_session_id,payload_json " +
"FROM session_preparation_deliveries WHERE state='pending' ORDER BY received_at,delivery_id", null)
.use { cursor -> while (cursor.moveToNext()) {
val payload = JSONObject(cursor.getString(4))
add(PreparedSession(cursor.getString(0), cursor.getString(1), cursor.getString(2),
cursor.getString(3), if (payload.isNull("planned_for")) null else payload.getString("planned_for"),
payload.getString("title"), if (payload.isNull("notes")) null else payload.getString("notes"),
parsePreparedOccurrences(db, payload.getJSONArray("occurrences"))))
}}
}
}
fun startPreparedSession(deliveryId: String): StartAiSessionDraftResult {
val db = database.writableDatabase
return try {
db.beginTransaction()
try {
if (db.rawQuery("SELECT 1 FROM active_session_draft WHERE id=1", null).use { it.moveToFirst() })
return StartAiSessionDraftResult.ExistingActiveDraft
val row = db.rawQuery("SELECT execution_session_id,payload_json,state FROM session_preparation_deliveries WHERE delivery_id=?",
arrayOf(deliveryId)).use { cursor ->
if (!cursor.moveToFirst()) null else Triple(cursor.getString(0), cursor.getString(1), cursor.getString(2))
} ?: return StartAiSessionDraftResult.NotPending
if (row.third != "pending") return StartAiSessionDraftResult.NotPending
val payload = JSONObject(row.second)
val entries = parsePreparedOccurrences(db, payload.getJSONArray("occurrences"))
val type = if (payload.getString("session_type") == "max_test") SessionType.MAX_TEST else SessionType.TRAINING
/* INVARIANT: the desktop-reserved execution identity survives
* start and replay; no performed value is manufactured. */
persistActiveSessionDraft(db, ActiveSessionDraft(sessionId = row.first, exercises = entries, sessionType = type))
db.execSQL("UPDATE session_preparation_deliveries SET state='started',started_at=? WHERE delivery_id=? AND state='pending'",
arrayOf(OffsetDateTime.now(java.time.ZoneOffset.UTC).toString(), deliveryId))
db.setTransactionSuccessful()
StartAiSessionDraftResult.Started
} finally { db.endTransaction() }
} catch (error: Exception) {
StartAiSessionDraftResult.Error(error.message ?: "Démarrage de la préparation impossible.")
}
}
fun deleteAiSessionDraft(draftId: String): ActiveDraftMutationResult = try {
val db = database.writableDatabase
db.beginTransaction()
@ -8076,7 +7934,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
22,
21,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -8122,7 +7980,6 @@ private class TrainlogDatabaseHelper(
createCausalDeleteTables(db)
createSyncGenerationTables(db)
createSyncGenerationArchiveTable(db)
createSessionPreparationTables(db)
seedEquipment(db)
}
@ -8298,12 +8155,6 @@ private class TrainlogDatabaseHelper(
createSyncGenerationArchiveTable(db)
version = 21
}
if (version < 22 && newVersion >= 22) {
/* CONTRACT: prepared deliveries are separate from proposals and
* the active singleton. Migration creates no delivery or start. */
createSessionPreparationTables(db)
version = 22
}
if (version != newVersion) {
error(
@ -8411,18 +8262,6 @@ private class TrainlogDatabaseHelper(
)
}
private fun createSessionPreparationTables(db: SQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS session_preparation_deliveries(" +
"delivery_id TEXT PRIMARY KEY,preparation_id TEXT NOT NULL,revision_id TEXT NOT NULL," +
"execution_session_id TEXT NOT NULL UNIQUE,state TEXT NOT NULL " +
"CHECK(state IN('pending','started','cancelled')),payload_json TEXT NOT NULL," +
"received_at TEXT NOT NULL,started_at TEXT);",
)
db.execSQL("CREATE INDEX IF NOT EXISTS session_preparation_identity ON " +
"session_preparation_deliveries(preparation_id,revision_id);")
}
private fun createAiSessionDraftTables(db: SQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS ai_session_drafts(" +

View file

@ -130,18 +130,6 @@ data class AiSessionDraft(
val entries: List<SessionExerciseDraft>,
)
/** Immutable desktop preparation delivery waiting for an explicit start. */
data class PreparedSession(
val deliveryId: String,
val preparationId: String,
val revisionId: String,
val executionSessionId: String,
val plannedFor: String?,
val title: String,
val notes: String?,
val entries: List<SessionExerciseDraft>,
)
data class SessionSummary(
val sessionId: String,
val startedAt: String,

View file

@ -83,36 +83,9 @@ fun AiSessionDraftsScreen(
var message by remember { mutableStateOf<String?>(null) }
var pendingDeletion by remember { mutableStateOf<Pair<String, String>?>(null) }
val drafts = remember(externalRevision, revision) { repository.listAiSessionDrafts() }
val preparations = remember(externalRevision, revision) { repository.listPreparedSessions() }
TrainlogScreen(strings.getString(R.string.route_ai_drafts)) {
message?.let { TrainlogInfo(it) }
if (drafts.isEmpty() && preparations.isEmpty()) TrainlogInfo(strings.getString(R.string.draft_none))
preparations.forEach { preparation ->
TrainlogFrame(preparation.title, active = true) {
TrainlogInfo(listOfNotNull(
strings.getString(R.string.manual_preparation),
preparation.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, preparation.entries.size, preparation.entries.size),
).joinToString(" · "))
preparation.notes?.let { TrainlogInfo(it) }
preparation.entries.forEach { entry ->
val plan = checkNotNull(entry.plan)
val metric = plan.reps?.let { strings.resources.getQuantityString(R.plurals.repetition_count, it, it) }
?: plan.durationSeconds?.let { strings.resources.getQuantityString(R.plurals.seconds_count, it, it) }.orEmpty()
val weight = plan.weightKg?.let { " · $it kg" }.orEmpty()
TrainlogInfo(strings.getString(R.string.plan_draft_summary, entry.exercise.name, plan.sets, metric, weight, plan.restSeconds))
}
TrainlogPrimaryAction(strings.getString(R.string.start), strings.getString(R.string.start_preparation_description)) {
when (val result = repository.startPreparedSession(preparation.deliveryId)) {
StartAiSessionDraftResult.Started -> { onPendingChanged(); onStarted() }
StartAiSessionDraftResult.ExistingActiveDraft ->
message = strings.getString(R.string.draft_already_active)
StartAiSessionDraftResult.NotPending -> { message = strings.getString(R.string.draft_missing); revision++; onPendingChanged() }
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
}
}
}
}
if (drafts.isEmpty()) TrainlogInfo(strings.getString(R.string.draft_none))
drafts.forEach { draft ->
TrainlogFrame(draft.title ?: strings.getString(R.string.session_proposal), active = true) {
TrainlogInfo(listOfNotNull(

View file

@ -54,26 +54,7 @@ internal suspend fun publishBundleAndRequest(
value.opt("version") is Int && value.getInt("version") == 1 && value.opt("enabled") == true
} catch (_: Exception) { false }
if (!enabled) return@withContext SyncRequestResult.Error("Invalid generation synchronization opt-in.")
val opened = exporter.openStorageSnapshot()
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
return@withContext SyncRequestResult.Error("prepare:${opened.message}")
}
val snapshot = (opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready).snapshot
return@withContext when (
val result = SyncGenerationForegroundCoordinator(repository).run(
canonicalExchangeDirectory(),
afterPeerPublication = {
when (val request = requestOutbox.requestSync(snapshot)) {
is SyncRequestResult.Requested -> Unit
SyncRequestResult.Unsupported ->
throw IllegalStateException("Generation synchronization is unsupported.")
is SyncRequestResult.Error -> throw IllegalStateException(request.message)
is SyncRequestResult.Completed ->
throw IllegalStateException("Unexpected completed request signal.")
}
},
)
) {
return@withContext when (val result = SyncGenerationForegroundCoordinator(repository).run(canonicalExchangeDirectory())) {
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId)
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message)
}

View file

@ -62,7 +62,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() }
val activeDraft = (draftLoad as? ActiveDraftLoadResult.Loaded)?.draft
val pendingAiDraftCount = remember(draftRevision, catalogRevision) {
repository.listAiSessionDrafts().size + repository.listPreparedSessions().size
repository.listAiSessionDrafts().size
}
fun open(route: AppRoute) { navigationController.open(route) }

View file

@ -295,8 +295,6 @@
<string name="session_proposal">Session proposal</string><string name="planned_for">Planned for %1$s</string>
<plurals name="repetition_count"><item quantity="one">%d repetition</item><item quantity="other">%d repetitions</item></plurals>
<string name="start">Start</string><string name="start_proposal_description">Copy this proposal into the current session.</string>
<string name="manual_preparation">Manual preparation</string>
<string name="start_preparation_description">Start this preparation without replacing an active session.</string>
<string name="draft_already_active">A session is already in progress. The imported draft was preserved.</string>
<string name="delete_draft_detail">Draft “%1$s” will be deleted.\nThis action cannot be undone.</string>
<string name="training">Training</string><string name="resume_current_session">Resume current session</string>

View file

@ -295,8 +295,6 @@
<string name="session_proposal">Proposition de séance</string><string name="planned_for">Prévue le %1$s</string>
<plurals name="repetition_count"><item quantity="one">%d répétition</item><item quantity="other">%d répétitions</item></plurals>
<string name="start">Démarrer</string><string name="start_proposal_description">Copier cette proposition dans la séance en cours.</string>
<string name="manual_preparation">Préparation manuelle</string>
<string name="start_preparation_description">Démarrer cette préparation sans écraser une séance en cours.</string>
<string name="draft_already_active">Une séance est déjà en cours. Le brouillon importé a été conservé.</string>
<string name="delete_draft_detail">Le brouillon « %1$s » sera supprimé.\nCette action est irréversible.</string>
<string name="training">Entraînement</string><string name="resume_current_session">Reprendre la séance en cours</string>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,112 +0,0 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.TrackingMode
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SessionPreparationExchangeTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private fun artifact(repository: TrainlogRepository): String {
val exercise = repository.listExercises().first { it.recordingMode.wireValue == "sets" }
val occurrence =
JSONObject()
.put("entry_id", "spe_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("position", 0)
.put("exercise_id", exercise.exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", exercise.recordingMode.wireValue)
.put("tracking_mode", exercise.trackingMode.wireValue)
.put("data_fields", exercise.dataFields)
.put("load_mode", "none")
.put("rest_seconds", 60)
.put("target_sets", 2)
.put("target_reps", if (exercise.trackingMode.wireValue == "reps") 8 else JSONObject.NULL)
.put(
"target_duration_seconds",
if (exercise.trackingMode.wireValue == "duration") 45 else JSONObject.NULL,
)
.put("target_weight_kg", JSONObject.NULL)
.put("notes", JSONObject.NULL)
val delivery =
JSONObject()
.put("delivery_id", "spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("preparation_id", "sp_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
.put("revision_id", "spr_cccccccc-cccc-4ccc-8ccc-cccccccccccc")
.put("execution_session_id", "se_dddddddd-dddd-4ddd-8ddd-dddddddddddd")
.put("state", "pending")
.put("title", "Préparation contrôlée")
.put("session_type", "training")
.put("planned_for", "2026-09-20")
.put("notes", "Sans faits réalisés")
.put("source_proposal_id", JSONObject.NULL)
.put("source_payload_sha256", JSONObject.NULL)
.put("occurrences", JSONArray().put(occurrence))
return JSONObject()
.put("format", "trainlog-session-preparations")
.put("version", 1)
.put("generated_at", "2026-09-17T20:00:00Z")
.put("deliveries", JSONArray().put(delivery))
.toString()
}
@Test
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
val name = "prepared-${System.nanoTime()}.db"
var repository = TrainlogRepository(context, name)
try {
assertTrue(repository.createExercise(NewExerciseProfile(
name = "Test preparation exercise",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
)) is CreateExerciseResult.Created)
val payload = artifact(repository)
assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applySessionPreparationsJson(payload))
assertEquals(AiSessionDraftImportResult.Applied(0, 1), repository.applySessionPreparationsJson(payload))
assertEquals(1, repository.listPreparedSessions().size)
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
assertEquals(
StartAiSessionDraftResult.ExistingActiveDraft,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
repository.close()
context.deleteDatabase(name)
repository = TrainlogRepository(context, name)
assertTrue(repository.createExercise(NewExerciseProfile(
name = "Test preparation exercise",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
)) is CreateExerciseResult.Created)
val reloadedPayload = artifact(repository)
assertEquals(AiSessionDraftImportResult.Applied(1, 0), repository.applySessionPreparationsJson(reloadedPayload))
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals("se_dddddddd-dddd-4ddd-8ddd-dddddddddddd", active.draft.sessionId)
assertTrue(active.draft.exercises.all { it.sets.isEmpty() })
assertEquals(
StartAiSessionDraftResult.ExistingActiveDraft,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -103,15 +103,11 @@ class SyncDeploymentConversationTest {
repeat(conversations) {
val coordinator = SyncGenerationForegroundCoordinator(repository)
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
var requestSignals = 0
val result =
executor.submit<ForegroundGenerationResult> {
coordinator.run(
transport,
Duration.ofSeconds(30),
afterPeerPublication = { requestSignals += 1 },
afterPublication = { release?.let { awaitFile(File(it)) } },
)
coordinator.run(transport, Duration.ofSeconds(30)) {
release?.let { awaitFile(File(it)) }
}
}
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
if (suppliedTransport == null) {
@ -142,7 +138,6 @@ class SyncDeploymentConversationTest {
"conversation ${it + 1}: $completed",
completed is ForegroundGenerationResult.Completed,
)
assertEquals("one fresh request signal per conversation", 1, requestSignals)
var workerOutput = ""
localWorker?.let { worker ->
workerOutput = worker.inputStream.bufferedReader().readText()

View file

@ -26,54 +26,6 @@ import org.robolectric.annotation.Config
class SyncGenerationServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun acknowledgedOutboundRemainsResumableUntilInboundIsTerminal() {
val sourceName = "generation-resume-source-${UUID.randomUUID()}.db"
val destinationName = "generation-resume-destination-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-resume-").toFile()
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
try {
val sourceService = SyncGenerationService(source)
val destinationService = SyncGenerationService(destination)
val runId = "sy_${UUID.randomUUID()}"
val outbound =
sourceService.capture(
java.io.File(root, "source-owned"),
destinationService.peerId(),
runId,
)
val outboundDirectory =
sourceService.publish(outbound, java.io.File(root, "source-objects"))
sourceService.acceptAcknowledgement(
destinationService.consume(outboundDirectory).toByteArray()
)
val coordinator = SyncGenerationForegroundCoordinator(source)
assertEquals(
outbound.generationId,
coordinator.resumableGeneration(runId, destinationService.peerId())?.generationId,
)
val inbound =
destinationService.capture(
java.io.File(root, "destination-owned"),
sourceService.peerId(),
runId,
)
sourceService.consume(
destinationService.publish(inbound, java.io.File(root, "destination-objects"))
)
assertEquals(null, coordinator.resumableGeneration(runId, destinationService.peerId()))
} finally {
source.close()
destination.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
root.deleteRecursively()
}
}
@Test
fun v19MigrationPreservesDataAndInventsNoGenerationState() {
val name = "generation-migration-${UUID.randomUUID()}.db"
@ -111,7 +63,7 @@ class SyncGenerationServiceTest {
.use { db ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(22, it.getInt(0))
assertEquals(21, it.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())

View file

@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(22, cursor.getInt(0))
assertEquals(21, 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(21, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())

View file

@ -14,9 +14,6 @@ evidence and chronology.
## Interfaces
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
history and Android-delivery workflow.
- [Android](android.md) owns field-companion capture, local persistence,
glanceable summaries, permissions, and Android interaction rules.
- [Desktop TUI](tui.md) owns Notcurses navigation, correction, detailed

View file

@ -1,11 +1,5 @@
# Android application
Android schema v22 adds a pending manual-preparation store which is separate
from AI proposals and the exactly-one active-session singleton. A received
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.
## 1. Purpose
The Android application is Trainlog's low-friction capture client.
@ -972,11 +966,3 @@ ledger without changing any domain identity. Its full-generation foreground
coordinator waits for correlated objects, tolerates stale objects left by
interrupted runs, strictly revalidates desktop-retained historical ACKs, and
shows completion only after both durable peer acknowledgements.
Each explicit foreground generation attempt publishes the Android peer before
emitting one fresh legacy request signal for `trainlog-syncd`. If transport is
interrupted after generation publication, a later attempt resumes the exact
stored generation instead of recapturing mutable data. An acknowledged Android
generation remains resumable only until the desktop generation for that run is
durably consumed; a fully completed run is then excluded from foreground
selection. This preserves late-ACK recovery without replaying completed runs.

View file

@ -493,7 +493,7 @@ silently rendered as unclassified.
### Desktop
Desktop SQLite schema v23 is canonical long-term history. Its additive v18 ->
Desktop SQLite schema v21 is canonical long-term history. Its additive v18 ->
v19 migration stores causal notes and execution-draft lifecycle state without
activating a new transport. Its v15 -> v16
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the

View file

@ -50,8 +50,8 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v23 |
| Android SQLite | schema v22 |
| Desktop SQLite | schema v22 |
| Android SQLite | schema v21 |
| 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` |
@ -72,7 +72,6 @@ parallel implementation of its rules.
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
| Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` |
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` |
| Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` |
@ -217,23 +216,8 @@ and linked into the binary. The read-only Dashboard uses one typed Core
snapshot, a seven-tile responsive grid, factual size-adaptive tiles, an ECharts
progression series, an original BODY ZONES SVG, and private versioned layout
persistence with optimistic conflict and CSRF/Origin protection. It provides no
automatic browser launch. Sessions provides complete paged preparation,
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.
The controlled deployment preserved the installed Android signing identity and
advanced its private `versionCode` without uninstalling or clearing data. The
validation preparation derived from the six-occurrence proposal remained a
separate pending Android preparation across application and service restart.
Two correlated generation conversations completed without a duplicate,
performed fact, or active-draft replacement.
business mutation or automatic browser launch. Analyse, Programmes, Sessions,
and Exercises remain explicit placeholders.
The Next Session tile no longer treats the frozen Dashboard
`next_session.available` placeholder as a durable inventory. A separate bounded
@ -394,8 +378,9 @@ tests, Android debug assembly, and 211 Android tests (207 passed, four optional
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
- `WEB_NEXT_MODULE_SELECTION_V1`: Analyse, Programmes and Exercices remain
shell placeholders. Exercises is the next proposed Web module.
- `WEB_NEXT_MODULE_SELECTION_V1`: Analyse, Programmes, Séances and Exercices
remain shell placeholders until their Core/API ownership and implementation
order are explicitly contracted.
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
- Session Generator V1 is hidden while V2 planning semantics are developed.
- Hardware MTP validation requires a connected unlocked Android device.

View file

@ -1,15 +1,5 @@
# Desktop database
## Schema v23: Web session preparations
Schema v23 adds `session_preparations`, immutable
`session_preparation_revisions` and ordered `session_preparation_entries`.
`session_preparation_requests` makes Web command replay durable, while
`session_preparation_deliveries` binds one immutable revision to one reserved
execution `session_id` and its correlated generation/ACK evidence. These tables
store no performed sets, MAX results or feedback and do not alter imported AI
proposal identity.
## Schema v13: machine-specific exercises
Migration v12→v13 is additive and transactional. It adds exercise-owned load
@ -26,8 +16,8 @@ is a later, separately versioned migration.
## 1. Status
```text
TRAINLOG_DATABASE_SCHEMA_VERSION=23
DATABASE_SCHEMA_V23=IMPLEMENTED
TRAINLOG_DATABASE_SCHEMA_VERSION=21
DATABASE_SCHEMA_V21=IMPLEMENTED
TRAINLOG_FORMAT_V1=FROZEN
```
@ -46,7 +36,7 @@ PRAGMA user_version;
Current value:
```text
23
21
```
The independent actual-set loads documented in the current desktop, Android

View file

@ -1,83 +0,0 @@
# Trainlog Web Sessions V1 contract
Status: implementation contract for `TRAINLOG_WEB_SESSIONS_V1`.
## Scope and domain boundaries
The Sessions route has three independent views:
- Preparation lists manual preparations and immutable imported AI proposals.
- Resume lists lifecycle-backed execution drafts that are not finalized or causally deleted.
- History lists completed sessions. A missing `ended_at` is rendered as an unknown end time; it
does not make a historical row an active draft.
An AI proposal is never mutated by viewing it or by deriving a manual preparation. Derivation is
an explicit command which creates a new `sp_<uuid-v4>` preparation and records the proposal ID and
payload fingerprint used. A manual preparation is not an execution draft or completed session.
## Manual preparation persistence
Desktop schema v23 adds:
- `session_preparations`, the mutable head and delivery state;
- `session_preparation_revisions`, immutable revision metadata;
- `session_preparation_entries`, immutable ordered occurrence snapshots;
- `session_preparation_requests`, durable HTTP idempotency results;
- `session_preparation_deliveries`, the preparation-revision to execution identity relation.
Preparation IDs use `sp_<uuid-v4>`, occurrence IDs use `spe_<uuid-v4>`, revision IDs use
`spr_<uuid-v4>`, and execution IDs use the existing `se_<uuid-v4>` namespace. Revision content is
immutable. Updating a preparation inserts a complete successor revision and atomically advances
the head. Occurrence IDs survive reordering and compatible edits. Two occurrences of one exercise
remain distinct.
`editing_state` (`draft` or `ready`) and `delivery_state` (`local`, `pending`, `acknowledged`,
`remote_unknown`, or `cancelled`) are independent. Marking a complete revision ready changes no
Android state. Preparing it for Android creates one durable delivery and deterministic execution
identity per explicit command. Retrying the command with its idempotency key returns that same
identity. Later preparation edits never alter a published revision or execution.
## Validation
Titles and notes are UTF-8 and bounded by the existing name/note limits. A preparation may be
saved with no occurrences while it is a draft. Ready revisions require a non-empty title and at
least one occurrence. Every occurrence resolves a non-retired exercise and a compatible equipment
choice at commit time. `SETS/REPS`, `SETS/DURATION`, and `CONTINUOUS/DURATION` are the only legal
profiles. Continuous work has a duration target and never a target set. Assistance, external load,
and no load remain distinct. JSON `null`, zero, and absence retain distinct meanings; non-finite
numbers are rejected by the JSON parser and Core validation.
Every update supplies the expected revision and is checked in the write transaction. A mismatch
returns a conflict without partial mutation. Request idempotency is persistent, so a lost HTTP
response can be replayed without creating another preparation.
## Read API
Lists use bounded offset pages with deterministic stable-ID tie breaking. The response returns a
snapshot revision. If a write changes that revision between page requests, the client discards its
accumulated pages and explicitly reloads. Search matches the title or an exercise display name.
Dates mean planned calendar dates for preparations and occurrence dates for history. Zones are
offered only where the persisted exercise relations provide them.
Stable details are addressed by kind and identity under `/api/v1/sessions/...`. Unknown API routes
remain JSON 404 responses; recognized non-API paths use the embedded SPA fallback.
## Synchronization and Android
Ready deliveries use a separate `trainlog-session-preparations` V1 generation participant. It does
not change `TRAINLOG_FORMAT_V1`, mobile V3/V4, or execution-drafts V1. The artifact contains complete
immutable revision snapshots and explicit cancellation state. Android consumes a delivery into a
pending prepared-session store. Starting it is an explicit Android action: an occupied active-draft
singleton is never overwritten. Consumption and start are idempotent by delivery, preparation,
revision, execution, and occurrence IDs.
Delivery is shown as acknowledged only when the correlated generation ACK proves that the exact
artifact containing that revision was consumed. Publication time or a global successful sync is
not sufficient evidence. Old peers which do not advertise this participant are rejected before a
generation containing it is published.
## Explicit exclusions
This V1 does not implement Programs, Analysis, or the standalone Exercises route. It does not add
live Web workout capture, historical correction, automatic training prescriptions, proposal
acceptance, or automatic proposal-to-execution conversion.

View file

@ -1,15 +1,5 @@
# Trainlog Exchange Format v1
## Manual session preparations
`trainlog-session-preparations` V1 has a strict root `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
feedback are excluded. This format does not change `TRAINLOG_FORMAT_V1` or any
mobile snapshot version.
## TRAINLOG_AI_SESSION_DRAFT_V1
`TRAINLOG_AI_SESSION_DRAFT_V1` is a separately versioned AI-proposal contract,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

View file

@ -1,21 +0,0 @@
# Web Sessions V1 evidence matrix
| Requirement | Production owner | Decisive test or check | Result |
|---|---|---|---|
| Manual preparation, stable occurrences and causal revisions | `web_sessions.c`, desktop schema v23 | `web_sessions` | PASS |
| Lost response replay and stale multi-tab conflict | durable request ledger and transactional head compare | `web_sessions` | PASS |
| Complete lists beyond Dashboard summary | paged Sessions Core query and exhaustive Web consumer | `web_sessions`, `sessions.test.ts` (41 objects over two pages) | PASS |
| Proposal remains immutable during explicit derivation | provenance-validated creation command | Core transaction and isolated Web flow | PASS |
| Separate Android pending preparation | Android schema v22 and repository | `SessionPreparationExchangeTest` | PASS |
| Occupied singleton is never overwritten | `startPreparedSession()` transaction | `SessionPreparationExchangeTest` | PASS |
| Generation, manifest and exact ACK delivery evidence | generation participant and delivery ledger | `SyncGenerationServiceTest` | PASS |
| Repeated bidirectional exchange and restart | production generation implementations | `SyncDeploymentConversationTest` (24 conversations) | PASS |
| Existing transport, deletion and archive behavior | unchanged production suites | canonical Meson suite, 85/85 | PASS |
| Android migrations and backup | SQLiteOpenHelper v22 and backup verifier | Android unit suite, 221 tests, 0 failures, 5 skips | PASS |
| Deep links, persisted browser creation and embedded assets | production HTTP server and React route | `test_web_sessions_browser.py`, `web_server`, stored-value inspection | PASS |
| Catppuccin responsive UI and embedded assets | React Sessions route and existing tokens | typecheck, Vitest 82/82, Vite/Meson builds; Chromium `[390, 844, DPR 1]` captures | PASS |
Physical USB delivery, the installed APK and daily data migration are
intentionally pending the single controlled-rollout consent gate. Isolated
real-Firefox functional captures and exact 390 CSS-pixel Chromium captures are
retained under `docs/reviews/evidence/`.

View file

@ -24,7 +24,7 @@ distinction between measured values and estimates.
## Current baseline
Desktop schema v23, Android schema v22, Notcurses, direct
Desktop schema v22, Android schema v21, 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
@ -33,7 +33,7 @@ exchange and APP_SHELL_V1 retain their explicit manual validation/review gates.
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.
visualizations are implemented; the other four 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.
@ -53,8 +53,7 @@ transactional consumption, durable correlated ACK and restart/replay entry
points. The orchestrator, Web API and Web control are complete behind an
explicit trusted opt-in. The authorized private direct-MTP rollout passed on
real hardware; automatic V3 selection remains unchanged without that opt-in.
The next separately contracted Web surface is Exercises. Sessions V1 is
complete in software and is not reopened by that future contract.
The next work returns to separately contracted Web surfaces.
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS`
defines the complete-sync target and its bounded dependency order.

View file

@ -1,16 +1,5 @@
# Synchronization exchange
## Manual session preparations V1
`trainlog-session-preparations` V1 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
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.
## Causal deletion safety gate
Android and desktop expose staged producers and consumers for the independent
@ -29,7 +18,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 v22 and Android schema v21 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
@ -817,14 +806,6 @@ for its ACK). Objects retained from an interrupted conversation are ignored
until atomically replaced by matching objects. After both peer ACKs, Android
reports completion directly; it does not enter the legacy receipt wait state.
The peer publication precedes one fresh `trainlog-sync-request-v1` signal on
every explicit Android attempt. This wakes the existing user-session daemon;
it is not an ACK and does not alter generation identity. A retry may resume an
exact `captured`, `published`, or `waiting_acknowledgement` generation. It may
also finish the inbound half of an acknowledged outbound generation when no
terminal consumed/rejected inbound ledger row exists for the run. It never
recaptures state under that run identity.
The 2026-09-17 private rollout exercised this path with the production libmtp
adapter on one paired Samsung phone. Three complete bidirectional conversations
passed, including service/application restart and an idempotent replay whose

View file

@ -1,21 +1,5 @@
# Tests and validation
`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.
`SessionPreparationExchangeTest` covers Android import replay, occupied
singleton refusal, explicit start, reserved execution identity and absence of
performed facts. `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;
the coordinator's production resume predicate preserves an interrupted
generation identity until the counterpart generation has a durable terminal
consumption row.
`SyncDeploymentConversationTest` executes both production generation services
and reopens Android storage. `test_web_sync_browser.py` adds a real Firefox click
through the C adapter, observes the desktop commit at an Android test barrier,

View file

@ -1,21 +0,0 @@
# Web Sessions V1
The Sessions route implements three distinct views:
- Preparation: manual preparations and immutable AI proposals;
- Resume: lifecycle-backed execution drafts, continued only on Android;
- History: completed sessions, including explicit unknown end times.
Manual preparation supports a title, session type, optional calendar date,
note, catalogue selection, duplicate occurrences, keyboard reordering,
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.
Opening or deriving from an AI proposal never accepts, starts, deletes or
rewrites the source. Derivation records the exact proposal identity and imported
payload fingerprint. The Android screen labels manual preparations separately
and refuses to overwrite an active draft.
This V1 does not provide live Web capture, historical correction, automatic
training generation, Programs, Analysis or the standalone Exercises route.

View file

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

View file

@ -23,9 +23,7 @@ CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEX
CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,manifest_sha256 TEXT,result TEXT,durability TEXT,created_at TEXT,diagnostic TEXT,payload_sha256 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 session_preparation_deliveries(delivery_id TEXT PRIMARY KEY,preparation_id TEXT,revision_id TEXT,execution_session_id TEXT,state TEXT,created_at TEXT,generation_id TEXT,acknowledged_at TEXT);
CREATE TABLE session_preparations(preparation_id TEXT PRIMARY KEY,current_revision_id TEXT,delivery_state TEXT);
PRAGMA user_version=23;
PRAGMA user_version=22;
"""
class GenerationTest(unittest.TestCase):

View file

@ -1,111 +0,0 @@
#!/usr/bin/env python3
"""Exercise Web Sessions through embedded production assets in real Firefox."""
import os
import signal
import socket
import sqlite3
import subprocess
import tempfile
import time
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BUILD = ROOT / os.environ.get("TRAINLOG_WEB_E2E_BUILD", "build") / "tui"
GECKO = Path(
os.environ.get(
"TRAINLOG_GECKODRIVER", "/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver"
)
)
EVIDENCE = ROOT / "docs" / "reviews" / "evidence"
class BrowserSessionsTest(unittest.TestCase):
def test_create_reload_detail_and_mobile_editor(self):
if not GECKO.is_file():
self.skipTest("pinned geckodriver unavailable")
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.service import Service
from selenium.webdriver.support.ui import WebDriverWait
with tempfile.TemporaryDirectory(prefix="trainlog-web-sessions-") as directory:
root = Path(directory)
xdg = root / "data"
xdg.mkdir()
with socket.socket() as reserved:
reserved.bind(("127.0.0.1", 0))
port = reserved.getsockname()[1]
environment = os.environ.copy()
environment.update(
{
"XDG_DATA_HOME": str(xdg),
"XDG_CONFIG_HOME": str(root / "config"),
"XDG_CACHE_HOME": str(root / "cache"),
}
)
server = subprocess.Popen(
[str(BUILD / "trainlog"), "--web", "--port", str(port)],
env=environment,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
driver = None
try:
for _ in range(200):
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1", port)) == 0:
break
time.sleep(0.025)
options = webdriver.FirefoxOptions()
options.add_argument("-headless")
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
wait = WebDriverWait(driver, 15)
driver.set_window_size(1440, 1000)
driver.get(f"http://127.0.0.1:{port}/seances")
wait.until(lambda current: "Aucun résultat" in current.page_source)
EVIDENCE.mkdir(parents=True, exist_ok=True)
driver.save_screenshot(str(EVIDENCE / "web-sessions-list-desktop.png"))
driver.find_element(By.XPATH, "//button[normalize-space()='Nouvelle séance']").click()
wait.until(lambda current: "Nouvelle séance" in current.page_source)
title = driver.find_element(By.XPATH, "//label[normalize-space()='Titre']/input")
title.send_keys("Séance navigateur éphémère")
driver.set_window_size(390, 844)
driver.save_screenshot(str(EVIDENCE / "web-sessions-editor-mobile.png"))
save = driver.find_element(By.XPATH, "//button[normalize-space()='Enregistrer']")
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", save)
save.click()
wait.until(lambda current: "PRÉPARATION MANUELLE" in current.page_source)
driver.refresh()
wait.until(lambda current: "Séance navigateur éphémère" in current.page_source)
driver.set_window_size(1440, 1000)
driver.save_screenshot(str(EVIDENCE / "web-sessions-detail-desktop.png"))
database_path = xdg / "trainlog" / "trainlog.db"
with sqlite3.connect(database_path) as database:
row = database.execute(
"SELECT r.title, p.editing_state "
"FROM session_preparations p "
"JOIN session_preparation_revisions r "
"ON r.revision_id = p.current_revision_id"
).fetchone()
entry_count = database.execute(
"SELECT COUNT(*) FROM session_preparation_entries"
).fetchone()[0]
self.assertEqual(row, ("Séance navigateur éphémère", "draft"))
self.assertEqual(entry_count, 0)
finally:
if driver is not None:
driver.quit()
os.killpg(server.pid, signal.SIGTERM)
server.wait(timeout=10)
if __name__ == "__main__":
unittest.main()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23):
if args.version == 4 and schema_version not in (19, 20, 21, 22):
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) and not (args.version == 2 and schema_version == 10):
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():

View file

@ -1,100 +0,0 @@
#!/usr/bin/env python3
"""Export immutable ready preparation deliveries for Android."""
import argparse
import datetime as dt
import json
import os
import sqlite3
import tempfile
from pathlib import Path
from trainlog_sqlite import connect_database
MAX_DELIVERIES = 128
MAX_ENTRIES = 64
def build_export(connection: sqlite3.Connection) -> dict:
if connection.execute("PRAGMA user_version").fetchone()[0] != 23:
raise ValueError("desktop schema v23 required")
deliveries = []
rows = connection.execute(
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
"r.title,r.session_type,r.planned_for,r.notes,p.source_proposal_id,p.source_payload_sha256 "
"FROM session_preparation_deliveries d "
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
"JOIN session_preparation_revisions r ON r.revision_id=d.revision_id "
"WHERE d.state IN('pending','remote_unknown') "
"ORDER BY d.created_at,d.delivery_id LIMIT ?",
(MAX_DELIVERIES + 1,),
).fetchall()
if len(rows) > MAX_DELIVERIES:
raise ValueError("preparation delivery capacity exceeded")
for row in rows:
entries = connection.execute(
"SELECT entry_id,position,exercise_id,equipment_id,recording_mode,tracking_mode,"
"data_fields,load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds,"
"target_weight_kg,notes FROM session_preparation_entries "
"WHERE revision_id=? ORDER BY position,entry_id LIMIT ?",
(row[2], MAX_ENTRIES + 1),
).fetchall()
if not entries or len(entries) > MAX_ENTRIES:
raise ValueError("invalid preparation occurrence count")
deliveries.append({
"delivery_id": row[0], "preparation_id": row[1], "revision_id": row[2],
"execution_session_id": row[3], "state": row[4], "title": row[5],
"session_type": row[6], "planned_for": row[7], "notes": row[8],
"source_proposal_id": row[9], "source_payload_sha256": row[10],
"occurrences": [{
"entry_id": entry[0], "position": entry[1], "exercise_id": entry[2],
"equipment_id": entry[3], "recording_mode": entry[4],
"tracking_mode": entry[5], "data_fields": entry[6],
"load_mode": entry[7], "rest_seconds": entry[8],
"target_sets": entry[9], "target_reps": entry[10],
"target_duration_seconds": entry[11], "target_weight_kg": entry[12],
"notes": entry[13],
} for entry in entries],
})
return {
"format": "trainlog-session-preparations", "version": 1,
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"deliveries": deliveries,
}
def write_atomic(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
json.dump(payload, stream, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
except Exception:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
raise
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", required=True, type=Path)
arguments = parser.parse_args()
try:
with connect_database(arguments.database) as connection:
payload = build_export(connection)
write_atomic(arguments.output, payload)
print(f"SESSION_PREPARATION_EXPORT=PASS deliveries={len(payload['deliveries'])}")
return 0
except (OSError, sqlite3.Error, ValueError) as error:
print(f"SESSION_PREPARATION_EXPORT=FAIL {error}")
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -70,8 +70,6 @@ ARTIFACTS = (
"causal_delete_exchange.py", ("export",)),
("ai-proposals", "trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", False,
"export_ai_session_drafts.py", ()),
("session-preparations", "trainlog-session-preparations", 1,
"session-preparations-v1.json", False, "export_session_preparations.py", ()),
)
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS}
@ -201,8 +199,8 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None:
if db.execute("PRAGMA user_version").fetchone()[0] != 23:
raise GenerationError("desktop schema v23 required")
if db.execute("PRAGMA user_version").fetchone()[0] != 22:
raise GenerationError("desktop schema v22 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
@ -405,22 +403,6 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
[(generation, value["logical_name"], value["format"], value["version"],
value["filename"], value["size"], value["sha256"], int(value["required"]))
for value in descriptors])
# CONTRACT: the exact generation relation is recorded only after
# its complete immutable artifact has been captured successfully.
preparation_path = stage / "session-preparations-v1.json"
if preparation_path.is_file():
captured_preparations = strict_json(
preparation_path.read_bytes(), MAX_ARTIFACT
)["deliveries"]
for delivery in captured_preparations:
cursor = db.execute(
"UPDATE session_preparation_deliveries SET generation_id=? "
"WHERE delivery_id=? AND revision_id=? AND "
"state IN('pending','remote_unknown') AND generation_id IS NULL",
(generation, delivery["delivery_id"], delivery["revision_id"]),
)
if cursor.rowcount != 1:
raise GenerationError("preparation delivery changed during capture")
causal_path = stage / "causal-deletions-v1.json"
if causal_path.is_file():
for operation in json.loads(causal_path.read_text())["operations"]:
@ -679,20 +661,6 @@ def accept_ack(database: Path, path: Path) -> str:
raise GenerationError("generation is not awaiting ACK")
db.execute("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?",
(status, ack["consumed_at"], ack["generation_id"]))
if status == "acknowledged":
db.execute(
"UPDATE session_preparation_deliveries SET state='acknowledged',acknowledged_at=? "
"WHERE generation_id=? AND state IN('pending','remote_unknown')",
(ack["consumed_at"], ack["generation_id"]),
)
db.execute(
"UPDATE session_preparations SET delivery_state='acknowledged' WHERE EXISTS("
"SELECT 1 FROM session_preparation_deliveries d WHERE "
"d.preparation_id=session_preparations.preparation_id AND "
"d.revision_id=session_preparations.current_revision_id AND "
"d.generation_id=? AND d.state='acknowledged')",
(ack["generation_id"],),
)
db.commit(); return status

View file

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

View file

@ -1,8 +1,7 @@
/*
* Trainlog id interface.
*
* Declares the module boundary and ownership contract; implementation and persistence remain in
* their owning modules.
* Declares the module boundary and ownership contract; implementation and persistence remain in their owning modules.
*/
#ifndef TRAINLOG_ID_H
#define TRAINLOG_ID_H
@ -26,7 +25,8 @@
* CONTRACT: occurrence identities use the longer `sxe` prefix while the
* frozen v1 object identities retain their existing two-character prefixes.
*/
#define TRAINLOG_GENERATED_ID_CAPACITY (3U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U)
#define TRAINLOG_GENERATED_ID_CAPACITY \
(3U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U)
/**
* @brief Generate an official Trainlog identifier using UUID version 4.
@ -39,9 +39,7 @@
* - `se` for sessions;
* - `bo` for body observations.
* - `sy` for synchronization runs;
* - `sxe` for persisted session-exercise occurrences;
* - `sp`, `spr`, `spe`, and `spd` for preparation, revision, occurrence, and
* delivery identities.
* - `sxe` for persisted session-exercise occurrences.
*
* Imported v1 documents may contain other schema-valid opaque identifiers;
* this API defines creation policy, not import validation.
@ -56,6 +54,10 @@
* Existing two-character prefixes still require 40 bytes; `sxe`
* requires TRAINLOG_GENERATED_ID_CAPACITY bytes.
*/
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
);
#endif

View file

@ -1,82 +0,0 @@
/* Trainlog Web Sessions V1 application service. */
#ifndef TRAINLOG_WEB_SESSIONS_H
#define TRAINLOG_WEB_SESSIONS_H
#include <stddef.h>
#include "trainlog/database.h"
#include "trainlog/status.h"
#define TRAINLOG_WEB_SESSIONS_PAGE_MAX 64U
#define TRAINLOG_WEB_SESSIONS_JSON_MAX (512U * 1024U)
typedef enum TrainlogWebSessionCollection {
TRAINLOG_WEB_SESSION_PREPARATIONS = 0,
TRAINLOG_WEB_SESSION_PROPOSALS,
TRAINLOG_WEB_SESSION_DRAFTS,
TRAINLOG_WEB_SESSION_HISTORY
} TrainlogWebSessionCollection;
typedef struct TrainlogWebSessionsPageQuery {
TrainlogWebSessionCollection collection;
size_t offset;
size_t limit;
const char *search;
} TrainlogWebSessionsPageQuery;
/*
* CONTRACT: every successful JSON-producing function below transfers one
* heap allocation through output_json; the caller owns it and releases it
* with free(). Borrowed input strings need only remain valid for the call.
*/
/*
* CONTRACT: database and query strings are borrowed for the call. On success,
* output_json is heap-owned by the caller and must be released with free().
* A page contains at most TRAINLOG_WEB_SESSIONS_PAGE_MAX rows plus a `more`
* flag derived from one additional row observed in the same read snapshot.
*/
TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
const TrainlogWebSessionsPageQuery *query,
char **output_json,
size_t *output_size);
/* CONTRACT: kind is one of preparation, proposal, draft, or history. The
* returned detail is a factual snapshot and performs no lifecycle transition. */
TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database,
const char *kind,
const char *identity,
char **output_json,
size_t *output_size);
/* CONTRACT: the catalogue contains only currently selectable exercises. */
TrainlogStatus trainlog_web_sessions_catalog_json(TrainlogDatabase *database,
const char *search,
size_t offset,
size_t limit,
char **output_json,
size_t *output_size);
/*
* CONTRACT: save_body is a complete revision document and request_id is a
* durable idempotency key. expected_revision is empty only for creation.
* Validation and the head comparison occur in one immediate transaction.
*/
TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
const char *preparation_id,
const char *expected_revision,
const char *request_id,
const char *save_body,
size_t save_body_size,
char **output_json,
size_t *output_size);
/* Creates one explicit immutable delivery for a ready current revision. */
TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database,
const char *preparation_id,
const char *expected_revision,
const char *request_id,
char **output_json,
size_t *output_size);
#endif

View file

@ -67,8 +67,6 @@ if web_assets_enabled
'../web/src/dashboard/dashboardLayout.ts',
'../web/src/routes/DashboardPage.tsx',
'../web/src/routes/PlaceholderPage.tsx',
'../web/src/routes/SessionsPage.tsx',
'../web/src/api/sessions.ts',
'../web/src/theme/app.css',
'../web/src/theme/tokens.css',
)
@ -196,7 +194,6 @@ trainlog_core_sources = files(
'src/web_dashboard_json.c',
'src/web_prepared_items.c',
'src/web_prepared_items_json.c',
'src/web_sessions.c',
'src/duration.c',
'src/id.c',
'src/timeutil.c',
@ -349,15 +346,6 @@ test_web_prepared_items = executable(
)
test('web_prepared_items', test_web_prepared_items)
test_web_sessions = executable(
'test_web_sessions',
'tests/test_web_sessions.c',
dependencies: trainlog_core_dep,
include_directories: trainlog_private_include,
c_args: strict_c_args,
)
test('web_sessions', test_web_sessions)
test_dashboard_header = executable(
'test_dashboard_header',
'tests/test_dashboard_header.c',

View file

@ -884,45 +884,6 @@ static const char *const MIGRATE_V21_TO_V22_SQL =
"audit_json TEXT NOT NULL);"
"PRAGMA user_version=22;COMMIT;";
/* WHY: a Web-authored preparation is neither an imported AI proposal nor an
* execution draft. CONTRACT: v23 stores immutable preparation revisions,
* stable ordered occurrences, durable request replay, and delivery identity
* without changing completed history or frozen exchange formats. INVARIANT:
* migration invents no preparation, execution, delivery, or acknowledgement. */
static const char *const MIGRATE_V22_TO_V23_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE session_preparations("
"preparation_id TEXT PRIMARY KEY,current_revision_id TEXT NOT NULL,created_at TEXT NOT NULL,"
"updated_at TEXT NOT NULL,editing_state TEXT NOT NULL CHECK(editing_state IN('draft','ready')),"
"delivery_state TEXT NOT NULL DEFAULT 'local' CHECK(delivery_state IN('local','pending','acknowledged','remote_unknown','cancelled')),"
"source_proposal_id TEXT,source_payload_sha256 TEXT);"
"CREATE TABLE session_preparation_revisions("
"revision_id TEXT PRIMARY KEY,preparation_id TEXT NOT NULL REFERENCES session_preparations(preparation_id) ON DELETE RESTRICT,"
"parent_revision_id TEXT,title TEXT NOT NULL,session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),"
"planned_for TEXT,notes TEXT,created_at TEXT NOT NULL,"
"CHECK(length(CAST(title AS BLOB))<=200),CHECK(notes IS NULL OR length(CAST(notes AS BLOB))<=4000),"
"UNIQUE(preparation_id,revision_id));"
"CREATE TABLE session_preparation_entries("
"revision_id TEXT NOT NULL REFERENCES session_preparation_revisions(revision_id) ON DELETE RESTRICT,"
"entry_id TEXT NOT NULL,position INTEGER NOT NULL CHECK(position>=0),exercise_id TEXT NOT NULL,"
"equipment_id TEXT,recording_mode TEXT NOT NULL CHECK(recording_mode IN('sets','continuous')),"
"tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration')),data_fields INTEGER NOT NULL,"
"load_mode TEXT NOT NULL CHECK(load_mode IN('none','external','assistance')),rest_seconds INTEGER NOT NULL CHECK(rest_seconds BETWEEN 0 AND 86400),"
"target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,notes TEXT,"
"PRIMARY KEY(revision_id,entry_id),UNIQUE(revision_id,position),"
"CHECK(notes IS NULL OR length(CAST(notes AS BLOB))<=4000));"
"CREATE INDEX session_preparation_entries_exercise ON session_preparation_entries(exercise_id);"
"CREATE TABLE session_preparation_requests("
"request_id TEXT PRIMARY KEY,command TEXT NOT NULL,preparation_id TEXT NOT NULL,revision_id TEXT NOT NULL,"
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
"CREATE TABLE session_preparation_deliveries("
"delivery_id TEXT PRIMARY KEY,preparation_id TEXT NOT NULL REFERENCES session_preparations(preparation_id),"
"revision_id TEXT NOT NULL REFERENCES session_preparation_revisions(revision_id),execution_session_id TEXT NOT NULL UNIQUE,"
"state TEXT NOT NULL CHECK(state IN('pending','acknowledged','remote_unknown','cancelled')),"
"created_at TEXT NOT NULL,generation_id TEXT,acknowledged_at TEXT,"
"UNIQUE(preparation_id,revision_id,delivery_id));"
"PRAGMA user_version=23;COMMIT;";
/* CONTRACT: v18 was already deployed before bounded draft publication was
* added. Keep its version number and add only the nullable lifecycle cursor;
* NULL deliberately means retryable/not yet published. */
@ -1618,8 +1579,7 @@ static TrainlogStatus initialize_or_validate_schema(
status = TRAINLOG_STATUS_OK;
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
version == 15 || version == 16 || version == 17 || version == 18 ||
version == 19 || version == 20 || version == 21 || version == 22 ||
version == 23) {
version == 19 || version == 20 || version == 21 || version == 22) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -1795,9 +1755,6 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK && version < 22) {
status = execute_sql(database, MIGRATE_V21_TO_V22_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 23) {
status = execute_sql(database, MIGRATE_V22_TO_V23_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -1813,7 +1770,7 @@ static TrainlogStatus initialize_or_validate_schema(
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v23" : "migrate database to schema v23",
version == 0 ? "create schema v22" : "migrate database to schema v22",
database->connection,
SQLITE_ERROR
);

View file

@ -11,18 +11,26 @@
#include <uuid/uuid.h>
static bool prefix_is_supported(const char *prefix) {
static bool prefix_is_supported(const char *prefix)
{
/*
* Keep official creator prefixes deliberately small and explicit.
* The exchange parser remains more permissive because imported v1 IDs are
* opaque values governed by the frozen wire-schema syntax.
*/
return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 ||
strcmp(prefix, "sy") == 0 || strcmp(prefix, "sxe") == 0 || strcmp(prefix, "sp") == 0 ||
strcmp(prefix, "spr") == 0 || strcmp(prefix, "spe") == 0 || strcmp(prefix, "spd") == 0;
return strcmp(prefix, "ex") == 0 ||
strcmp(prefix, "se") == 0 ||
strcmp(prefix, "bo") == 0 ||
strcmp(prefix, "sy") == 0 ||
strcmp(prefix, "sxe") == 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
)
{
uuid_t value;
char uuid_text[TRAINLOG_UUID_TEXT_LENGTH + 1U];
int written;
@ -43,9 +51,16 @@ TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t out
uuid_generate_random(value);
uuid_unparse_lower(value, uuid_text);
written = snprintf(output, output_size, "%s_%s", prefix, uuid_text);
written = snprintf(
output,
output_size,
"%s_%s",
prefix,
uuid_text
);
if (written < 0 || (size_t)written >= output_size) {
if (written < 0 ||
(size_t)written >= output_size) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}

View file

@ -22,7 +22,6 @@
#include "trainlog/web_dashboard.h"
#include "trainlog/web_prepared_items.h"
#include "trainlog/web_sessions.h"
#include "trainlog/dashboard_layout.h"
#include "web_assets.h"
@ -30,8 +29,7 @@
#define TRAINLOG_HTTP_PER_IP_LIMIT 32U
#define TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT (16U * 1024U)
#define TRAINLOG_HTTP_HEADER_LIMIT (8U * 1024U)
#define TRAINLOG_HTTP_BODY_LIMIT (128U * 1024U)
#define TRAINLOG_HTTP_LEGACY_BODY_LIMIT (4U * 1024U)
#define TRAINLOG_HTTP_BODY_LIMIT (4U * 1024U)
#define TRAINLOG_HTTP_TIMEOUT_SECONDS 10U
#define TRAINLOG_HTTP_BACKLOG 32U
#define TRAINLOG_HTTP_POLL_MAX_MS 250ULL
@ -429,12 +427,6 @@ static bool is_ui_route(const char *url) {
return true;
}
}
/* CONTRACT: Sessions detail identities are client-side path segments. A
* direct load must receive the SPA, while the /api guard below remains
* authoritative for every adapter route. */
if (strncmp(url, "/seances/", strlen("/seances/")) == 0) {
return true;
}
return false;
}
@ -521,47 +513,6 @@ static bool parse_if_match(const char *value, uint64_t *revision) {
return true;
}
static bool
parse_page_number(const char *value, size_t default_value, size_t maximum, size_t *out) {
char *end = NULL;
unsigned long long parsed;
if (value == NULL) {
*out = default_value;
return true;
}
errno = 0;
parsed = strtoull(value, &end, 10);
if (errno != 0 || end == value || *end != '\0' || parsed > maximum) {
return false;
}
*out = (size_t)parsed;
return true;
}
static enum MHD_Result queue_owned_sessions_json(struct MHD_Connection *connection,
TrainlogStatus status,
char *json,
size_t json_size) {
enum MHD_Result result;
if (status == TRAINLOG_STATUS_NOT_FOUND) {
free(json);
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
}
if (status != TRAINLOG_STATUS_OK || json == NULL || json_size == 0U) {
free(json);
return queue_json(connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"sessions_unavailable\"}\n",
NULL);
}
(void)json_size;
result = queue_json(connection, MHD_HTTP_OK, json, NULL);
free(json);
return result;
}
static enum MHD_Result handle_request(void *closure,
struct MHD_Connection *connection,
const char *url,
@ -610,9 +561,7 @@ static enum MHD_Result handle_request(void *closure,
"{\"error\":\"headers_too_large\"}\n",
NULL);
}
if (state->body_too_large ||
(strncmp(url, "/api/v1/sessions/", strlen("/api/v1/sessions/")) != 0 &&
state->body_size > TRAINLOG_HTTP_LEGACY_BODY_LIMIT)) {
if (state->body_too_large) {
return queue_json(
connection, MHD_HTTP_CONTENT_TOO_LARGE, "{\"error\":\"body_too_large\"}\n", NULL);
}
@ -780,188 +729,6 @@ static enum MHD_Result handle_request(void *closure,
}
return queue_json(connection, MHD_HTTP_OK, json, NULL);
}
if (strncmp(url, "/api/v1/sessions", strlen("/api/v1/sessions")) == 0) {
static const struct {
const char *path;
TrainlogWebSessionCollection collection;
} collections[] = {
{"/api/v1/sessions/preparations", TRAINLOG_WEB_SESSION_PREPARATIONS},
{"/api/v1/sessions/proposals", TRAINLOG_WEB_SESSION_PROPOSALS},
{"/api/v1/sessions/drafts", TRAINLOG_WEB_SESSION_DRAFTS},
{"/api/v1/sessions/history", TRAINLOG_WEB_SESSION_HISTORY},
};
const char *offset_value;
const char *limit_value;
const char *search;
char *json = NULL;
size_t json_size = 0U;
size_t offset;
size_t limit;
size_t index;
if (!is_get && (strcmp(url, "/api/v1/sessions/preparations") == 0 ||
strncmp(url,
"/api/v1/sessions/preparation/",
strlen("/api/v1/sessions/preparation/")) == 0)) {
const char *origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin");
const char *csrf =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token");
const char *content_type = MHD_lookup_connection_value(
connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE);
const char *request_id =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
const char *if_match =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
const char *identity = NULL;
const char *expected = "";
char expected_buffer[TRAINLOG_ID_MAX + 1U];
char identity_buffer[TRAINLOG_ID_MAX + 1U];
bool delivery = false;
TrainlogStatus status;
if ((strcmp(url, "/api/v1/sessions/preparations") == 0 &&
strcmp(method, MHD_HTTP_METHOD_POST) != 0) ||
(strcmp(url, "/api/v1/sessions/preparations") != 0 &&
strcmp(method, MHD_HTTP_METHOD_PUT) != 0 &&
strcmp(method, MHD_HTTP_METHOD_POST) != 0)) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"POST, PUT");
}
if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
if (content_type == NULL || strcmp(content_type, "application/json") != 0 ||
request_id == NULL || request_id[0] == '\0' || strlen(request_id) > 128U) {
return queue_json(connection,
MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_mutation_headers\"}\n",
NULL);
}
if (strcmp(url, "/api/v1/sessions/preparations") != 0) {
size_t length;
identity = url + strlen("/api/v1/sessions/preparation/");
length = strlen(identity);
if (length > strlen("/deliver") &&
strcmp(identity + length - strlen("/deliver"), "/deliver") == 0) {
size_t identity_length = length - strlen("/deliver");
if (identity_length == 0U || identity_length > TRAINLOG_ID_MAX) {
return queue_json(connection,
MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_identity\"}\n",
NULL);
}
(void)memcpy(identity_buffer, identity, identity_length);
identity_buffer[identity_length] = '\0';
identity = identity_buffer;
delivery = true;
}
if ((delivery && strcmp(method, MHD_HTTP_METHOD_POST) != 0) ||
(!delivery && strcmp(method, MHD_HTTP_METHOD_PUT) != 0)) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
delivery ? "POST" : "PUT");
}
if (if_match == NULL || if_match[0] != '"') {
return queue_json(connection,
MHD_HTTP_PRECONDITION_REQUIRED,
"{\"error\":\"precondition_required\"}\n",
NULL);
}
length = strlen(if_match);
if (length < 3U || length - 2U > TRAINLOG_ID_MAX || if_match[length - 1U] != '"') {
return queue_json(connection,
MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_revision\"}\n",
NULL);
}
(void)memcpy(expected_buffer, if_match + 1, length - 2U);
expected_buffer[length - 2U] = '\0';
expected = expected_buffer;
}
if (delivery) {
status = trainlog_web_sessions_deliver_json(
context->database, identity, expected, request_id, &json, &json_size);
} else {
status = trainlog_web_sessions_save_json(context->database,
identity,
expected,
request_id,
state->body,
state->body_size,
&json,
&json_size);
}
if (status == TRAINLOG_STATUS_CONFLICT) {
free(json);
return queue_json(
connection, MHD_HTTP_CONFLICT, "{\"error\":\"revision_conflict\"}\n", NULL);
}
if (status == TRAINLOG_STATUS_NOT_FOUND) {
free(json);
return queue_json(
connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
}
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
free(json);
return queue_json(connection,
MHD_HTTP_UNPROCESSABLE_ENTITY,
"{\"error\":\"invalid_preparation\"}\n",
NULL);
}
return queue_owned_sessions_json(connection, status, json, json_size);
}
if (!is_get) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"GET");
}
offset_value = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "offset");
limit_value = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "limit");
search = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "search");
if (!parse_page_number(offset_value, 0U, 1000000U, &offset) ||
!parse_page_number(limit_value, 24U, TRAINLOG_WEB_SESSIONS_PAGE_MAX, &limit) ||
limit == 0U) {
return queue_json(
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_page\"}\n", NULL);
}
if (strcmp(url, "/api/v1/sessions/catalog") == 0) {
TrainlogStatus status = trainlog_web_sessions_catalog_json(
context->database, search, offset, limit, &json, &json_size);
return queue_owned_sessions_json(connection, status, json, json_size);
}
for (index = 0U; index < sizeof(collections) / sizeof(collections[0]); ++index) {
if (strcmp(url, collections[index].path) == 0) {
TrainlogWebSessionsPageQuery query = {
collections[index].collection, offset, limit, search};
TrainlogStatus status =
trainlog_web_sessions_list_json(context->database, &query, &json, &json_size);
return queue_owned_sessions_json(connection, status, json, json_size);
}
}
{
static const char DETAIL_PREFIX[] = "/api/v1/sessions/";
const char *kind = url + strlen(DETAIL_PREFIX);
const char *separator = strchr(kind, '/');
char kind_buffer[32];
if (separator != NULL && separator[1] != '\0' &&
(size_t)(separator - kind) < sizeof(kind_buffer)) {
TrainlogStatus status;
(void)memcpy(kind_buffer, kind, (size_t)(separator - kind));
kind_buffer[separator - kind] = '\0';
status = trainlog_web_sessions_detail_json(
context->database, kind_buffer, separator + 1, &json, &json_size);
return queue_owned_sessions_json(connection, status, json, json_size);
}
}
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
}
if (strcmp(url, "/api/v1/dashboard-layout") == 0) {
TrainlogDashboardLayout layout;
TrainlogDashboardLayout saved;

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -382,13 +382,6 @@ static bool test_http_contract(TrainlogDatabase *database) {
exchange(
port, "GET /analyse HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", response, sizeof(response)) &&
strstr(response, "<title>Trainlog</title>") != NULL);
CHECK(exchange(port,
"GET /seances/preparation/sp_11111111-1111-4111-8111-111111111111 "
"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, "<title>Trainlog</title>") != NULL);
CHECK(exchange(port,
"GET /api/v1/unknown HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response,

View file

@ -1,183 +0,0 @@
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sqlite3.h>
#include "database_internal.h"
#include "trainlog/database.h"
#include "trainlog/web_sessions.h"
#define CHECK(expression) \
do { \
if (!(expression)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \
return false; \
} \
} while (0)
static int scalar(TrainlogDatabase *database, const char *sql) {
sqlite3_stmt *statement = NULL;
int result = -1;
if (sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) {
result = sqlite3_column_int(statement, 0);
}
(void)sqlite3_finalize(statement);
return result;
}
static bool creation_replay_revision_and_pagination(void) {
static const char CREATE_BODY[] =
"{\"title\":\"Séance échappée \\\"A\\\"\",\"session_type\":\"training\","
"\"planned_for\":\"2026-09-20\",\"notes\":\"Dos & épaules\","
"\"editing_state\":\"ready\",\"occurrences\":["
"{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
"\"load_mode\":\"external\",\"rest_seconds\":90,\"target_sets\":3,"
"\"target_reps\":8,\"target_duration_seconds\":null,\"target_weight_kg\":42.5},"
"{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
"\"load_mode\":\"none\",\"rest_seconds\":0,\"target_sets\":1,"
"\"target_reps\":12,\"target_duration_seconds\":null,\"target_weight_kg\":null}]}";
static const char UPDATE_BODY[] =
"{\"title\":\"Réordonnée\",\"session_type\":\"training\","
"\"planned_for\":null,\"notes\":null,\"editing_state\":\"draft\","
"\"occurrences\":[]}";
static const char DERIVED_BODY[] =
"{\"title\":\"Derived\",\"session_type\":\"training\","
"\"planned_for\":\"2026-09-17\",\"notes\":null,\"editing_state\":\"ready\","
"\"occurrences\":[{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
"\"equipment_id\":null,\"load_mode\":\"external\",\"rest_seconds\":90,"
"\"target_sets\":4,\"target_reps\":10,\"target_duration_seconds\":null,"
"\"target_weight_kg\":39,\"notes\":null}],\"source_proposal_id\":"
"\"aid_11111111-1111-4111-8111-111111111111\","
"\"source_payload_sha256\":"
"\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}";
TrainlogDatabase *database = NULL;
TrainlogWebSessionsPageQuery query = {TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 1U, "échappée"};
char *created = NULL;
char *replayed = NULL;
char *detail = NULL;
char *page = NULL;
char *updated = NULL;
size_t created_size = 0U;
size_t replayed_size = 0U;
size_t detail_size = 0U;
size_t page_size = 0U;
size_t updated_size = 0U;
char preparation_id[128];
char revision_id[128];
const char *preparation_start;
const char *revision_start;
const char *end;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"Développé test",
"developpe test",
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','Source');"
"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-create-1",
CREATE_BODY,
strlen(CREATE_BODY),
&created,
&created_size) == TRAINLOG_STATUS_OK);
CHECK(created_size > 0U);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 1);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_entries") == 2);
CHECK(trainlog_web_sessions_save_json(database,
NULL,
"",
"request-create-1",
CREATE_BODY,
strlen(CREATE_BODY),
&replayed,
&replayed_size) == TRAINLOG_STATUS_OK);
CHECK(created_size == replayed_size && strcmp(created, replayed) == 0);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 1);
preparation_start = strstr(created, "\"preparation_id\":\"");
revision_start = strstr(created, "\"revision_id\":\"");
CHECK(preparation_start != NULL && revision_start != NULL);
preparation_start += strlen("\"preparation_id\":\"");
end = strchr(preparation_start, '"');
CHECK(end != NULL && (size_t)(end - preparation_start) < sizeof(preparation_id));
(void)memcpy(preparation_id, preparation_start, (size_t)(end - preparation_start));
preparation_id[end - preparation_start] = '\0';
revision_start += strlen("\"revision_id\":\"");
end = strchr(revision_start, '"');
CHECK(end != NULL && (size_t)(end - revision_start) < sizeof(revision_id));
(void)memcpy(revision_id, revision_start, (size_t)(end - revision_start));
revision_id[end - revision_start] = '\0';
CHECK(trainlog_web_sessions_detail_json(
database, "preparation", preparation_id, &detail, &detail_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(detail, "Séance échappée") != NULL);
CHECK(strstr(detail, "Développé test") != NULL);
CHECK(trainlog_web_sessions_list_json(database, &query, &page, &page_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(page, preparation_id) != NULL);
CHECK(trainlog_web_sessions_save_json(database,
preparation_id,
revision_id,
"request-update-1",
UPDATE_BODY,
strlen(UPDATE_BODY),
&updated,
&updated_size) == TRAINLOG_STATUS_OK);
CHECK(updated_size > 0U);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 2);
free(detail);
detail = NULL;
detail_size = 0U;
CHECK(trainlog_web_sessions_save_json(database,
NULL,
"",
"request-derived-1",
DERIVED_BODY,
strlen(DERIVED_BODY),
&detail,
&detail_size) == TRAINLOG_STATUS_OK);
CHECK(scalar(database,
"SELECT COUNT(*) FROM session_preparations WHERE source_proposal_id="
"'aid_11111111-1111-4111-8111-111111111111'") == 1);
free(detail);
detail = NULL;
detail_size = 0U;
CHECK(trainlog_web_sessions_save_json(database,
preparation_id,
revision_id,
"request-stale",
UPDATE_BODY,
strlen(UPDATE_BODY),
&detail,
&detail_size) == TRAINLOG_STATUS_CONFLICT);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 3);
free(created);
free(replayed);
free(detail);
free(page);
free(updated);
trainlog_database_close(database);
return true;
}
int main(void) {
return creation_replay_revision_and_pagination() ? 0 : 1;
}

View file

@ -1,39 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { fetchAllSessionPages } from './sessions'
function item(identity: string) {
return {
identity,
title: identity,
date: '2026-09-17',
state: 'draft',
occurrence_count: 1,
sort_timestamp: '2026-09-17T12:00:00Z',
}
}
describe('Sessions pagination', () => {
it('loads every page without losing an item at the boundary', async () => {
const first = Array.from({ length: 24 }, (_, index) => item(`sp_${index}`))
const second = Array.from({ length: 17 }, (_, index) => item(`sp_${index + 24}`))
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({
api_version: 1, kind: 'preparations', offset: 0, more: true, next_offset: 24, items: first,
}) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({
api_version: 1, kind: 'preparations', offset: 24, more: false, next_offset: 41, items: second,
}) }))
const result = await fetchAllSessionPages('preparations')
expect(result).toHaveLength(41)
expect(result.at(-1)?.identity).toBe('sp_40')
expect(fetch).toHaveBeenCalledTimes(2)
})
it('rejects a stale or looping page instead of merging it', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({
api_version: 1, kind: 'preparations', offset: 0, more: true, next_offset: 0, items: [item('sp_one')],
}) }))
await expect(fetchAllSessionPages('preparations')).rejects.toThrow('curseur')
})
})

View file

@ -1,223 +0,0 @@
import { mutationCsrfToken } from './sync'
export type SessionCollection = 'preparations' | 'proposals' | 'drafts' | 'history'
export interface SessionListItem {
identity: string
title: string
date: string | null
state: string
occurrence_count: number
sort_timestamp: string
}
export interface SessionPage {
api_version: 1
kind: string
offset: number
more: boolean
next_offset: number
items: SessionListItem[]
}
export interface PreparationOccurrence {
entry_id?: string
exercise_id: string
exercise_name?: string
equipment_id: string | null
recording_mode?: string
tracking_mode?: string
load_mode: 'none' | 'external' | 'assistance'
rest_seconds: number
target_sets: number | null
target_reps: number | null
target_duration_seconds: number | null
target_weight_kg: number | null
notes: string | null
}
export interface SessionDetail {
api_version: 1
kind: 'preparation' | 'proposal'
identity: string
revision_id: string
title: string
planned_for: string | null
session_type: 'training' | 'max_test'
notes: string | null
source_fingerprint: string | null
state: string
occurrences: PreparationOccurrence[]
}
export interface CatalogChoice {
exercise_id: string
name: string
recording_mode: 'sets' | 'continuous'
tracking_mode: 'reps' | 'duration'
data_fields: number
}
export interface PreparationInput {
title: string
session_type: 'training' | 'max_test'
planned_for: string | null
notes: string | null
editing_state: 'draft' | 'ready'
occurrences: PreparationOccurrence[]
source_proposal_id?: string
source_payload_sha256?: string
}
function object(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function validListItem(value: unknown): value is SessionListItem {
if (!object(value)) return false
return typeof value.identity === 'string' && typeof value.title === 'string' &&
(value.date === null || typeof value.date === 'string') && typeof value.state === 'string' &&
Number.isSafeInteger(value.occurrence_count) && Number(value.occurrence_count) >= 0 &&
typeof value.sort_timestamp === 'string'
}
export async function fetchSessionPage(
collection: SessionCollection,
offset = 0,
search = '',
signal?: AbortSignal,
): Promise<SessionPage> {
const query = new URLSearchParams({ offset: String(offset), limit: '24' })
if (search.trim()) query.set('search', search.trim())
const response = await fetch(`/api/v1/sessions/${collection}?${query}`, {
headers: { Accept: 'application/json' }, signal,
})
if (!response.ok) throw new Error(`liste HTTP ${response.status}`)
const value: unknown = await response.json()
if (!object(value) || value.api_version !== 1 || !Array.isArray(value.items) ||
!value.items.every(validListItem) || typeof value.more !== 'boolean' ||
!Number.isSafeInteger(value.next_offset)) throw new TypeError('liste de séances invalide')
return value as unknown as SessionPage
}
export async function fetchAllSessionPages(
collection: SessionCollection,
search = '',
signal?: AbortSignal,
): Promise<SessionListItem[]> {
const items: SessionListItem[] = []
const identities = new Set<string>()
let offset = 0
for (;;) {
const page = await fetchSessionPage(collection, offset, search, signal)
if (page.offset !== offset) throw new TypeError('curseur de séances obsolète')
for (const item of page.items) {
if (identities.has(item.identity)) throw new TypeError('pagination de séances incohérente')
identities.add(item.identity)
items.push(item)
}
if (!page.more) return items
if (page.next_offset <= offset || page.items.length === 0) {
throw new TypeError('curseur de séances obsolète')
}
offset = page.next_offset
}
}
export async function fetchSessionDetail(
kind: 'preparation' | 'proposal', identity: string, signal?: AbortSignal,
): Promise<SessionDetail> {
const response = await fetch(`/api/v1/sessions/${kind}/${encodeURIComponent(identity)}`, {
headers: { Accept: 'application/json' }, signal,
})
if (!response.ok) throw new Error(`fiche HTTP ${response.status}`)
const value: unknown = await response.json()
if (!object(value) || value.api_version !== 1 || value.kind !== kind ||
value.identity !== identity || typeof value.revision_id !== 'string' ||
typeof value.title !== 'string' || !Array.isArray(value.occurrences)) {
throw new TypeError('fiche de séance invalide')
}
return value as unknown as SessionDetail
}
export async function fetchReadonlySessionDetail(
kind: 'draft' | 'history', identity: string, signal?: AbortSignal,
): Promise<Record<string, unknown>> {
const response = await fetch(`/api/v1/sessions/${kind}/${encodeURIComponent(identity)}`, {
headers: { Accept: 'application/json' }, signal,
})
if (!response.ok) throw new Error(`fiche HTTP ${response.status}`)
const value: unknown = await response.json()
if (!object(value) || value.api_version !== 1 || value.kind !== kind || value.identity !== identity) {
throw new TypeError('fiche de séance invalide')
}
return value
}
export async function fetchCatalog(search = '', signal?: AbortSignal): Promise<CatalogChoice[]> {
const query = new URLSearchParams({ limit: '64', search })
const response = await fetch(`/api/v1/sessions/catalog?${query}`, {
headers: { Accept: 'application/json' }, signal,
})
if (!response.ok) throw new Error(`catalogue HTTP ${response.status}`)
const value: unknown = await response.json()
if (!object(value) || !Array.isArray(value.items)) throw new TypeError('catalogue invalide')
return value.items as CatalogChoice[]
}
function requestId(): string {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
return `web-${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`
}
export async function savePreparation(
input: PreparationInput,
identity?: string,
revision?: string,
): Promise<{ preparation_id: string; revision_id: string }> {
const csrf = await mutationCsrfToken()
const response = await fetch(identity
? `/api/v1/sessions/preparation/${encodeURIComponent(identity)}`
: '/api/v1/sessions/preparations', {
method: identity ? 'PUT' : 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf,
'X-Trainlog-Request-ID': requestId(),
...(identity && revision ? { 'If-Match': `"${revision}"` } : {}),
},
body: JSON.stringify(input),
})
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) || typeof value.preparation_id !== 'string' ||
typeof value.revision_id !== 'string') throw new TypeError('réponse de sauvegarde invalide')
return { preparation_id: value.preparation_id, revision_id: value.revision_id }
}
export async function prepareForAndroid(identity: string, revision: string): Promise<void> {
const csrf = await mutationCsrfToken()
const response = await fetch(
`/api/v1/sessions/preparation/${encodeURIComponent(identity)}/deliver`, {
method: 'POST',
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)
}
}

View file

@ -40,12 +40,6 @@ export interface SyncStatus {
}
let csrfToken = ''
export async function mutationCsrfToken(): Promise<string> {
if (csrfToken.length !== 64) await fetchSyncStatus()
if (csrfToken.length !== 64) throw new Error('jeton CSRF absent')
return csrfToken
}
const phases = new Set<SyncPhase>([
'idle',
'requested',

View file

@ -6,7 +6,6 @@ import { Footer } from '../components/Footer'
import { Header } from '../components/Header'
import { DashboardPage } from '../routes/DashboardPage'
import { PlaceholderPage } from '../routes/PlaceholderPage'
import { SessionsPage } from '../routes/SessionsPage'
import type { RouteId } from './routes'
import { useRoute } from './useRoute'
@ -73,7 +72,7 @@ export function App() {
preparedItemsPending={preparedItemsPending}
preparedItemsFailed={preparedItemsFailed}
/>
) : route.id === 'sessions' ? <SessionsPage /> :
) :
<PlaceholderPage title={placeholderContent[route.id][0]}
eyebrow={placeholderContent[route.id][1]}
description={placeholderContent[route.id][2]} />

View file

@ -15,6 +15,5 @@ export const routes: readonly AppRoute[] = [
]
export function routeFromPath(pathname: string): AppRoute {
if (pathname === '/seances' || pathname.startsWith('/seances/')) return routes[3]
return routes.find((route) => route.path === pathname) ?? routes[0]
}

View file

@ -1,55 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { commandOccurrence, SessionsPage } from './SessionsPage'
function page(kind: string, items: unknown[]) {
return { api_version: 1, kind, offset: 0, more: false, next_offset: items.length, items }
}
function response(value: unknown) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(value) })
}
describe('Sessions page', () => {
it('removes presentation-only fields from a preparation command', () => {
const command = commandOccurrence({
exercise_id: 'ex_one', exercise_name: 'Visible name', equipment_id: null,
recording_mode: 'sets', tracking_mode: 'reps', load_mode: 'external', rest_seconds: 90,
target_sets: 3, target_reps: 8, target_duration_seconds: null, target_weight_kg: 42,
notes: null,
})
expect(command).not.toHaveProperty('exercise_name')
expect(command).not.toHaveProperty('recording_mode')
expect(command).not.toHaveProperty('tracking_mode')
expect(command).toMatchObject({ exercise_id: 'ex_one', target_sets: 3, rest_seconds: 90 })
})
it('keeps proposals distinct and opens a stable deep link', async () => {
window.history.replaceState(null, '', '/seances')
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/preparations?')) return response(page('preparations', []))
if (url.includes('/proposals?')) return response(page('proposals', [{
identity: 'aid_target', title: 'Proposition cible', date: '2026-09-17', state: 'published',
occurrence_count: 6, sort_timestamp: '2026-09-17T12:00:00Z',
}]))
return response({ api_version: 1, kind: 'proposal', identity: 'aid_target', revision_id: 'rev',
title: 'Proposition cible', planned_for: null, session_type: 'training', notes: null,
source_fingerprint: 'abc', state: 'published', occurrences: [] })
}))
render(<SessionsPage />)
await waitFor(() => expect(screen.getByText('Proposition cible')).toBeInTheDocument())
expect(screen.getByText('Proposition IA')).toBeInTheDocument()
fireEvent.click(screen.getByText('Proposition cible'))
expect(window.location.pathname).toBe('/seances/proposal/aid_target')
await waitFor(() => expect(screen.getByRole('heading', { name: 'Proposition cible' })).toBeInTheDocument())
})
it('reports an API failure instead of presenting an empty list', async () => {
window.history.replaceState(null, '', '/seances')
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network')))
render(<SessionsPage />)
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('ne sont pas disponibles'))
expect(screen.queryByText('Aucun résultat pour cette vue.')).not.toBeInTheDocument()
})
})

View file

@ -1,317 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import {
fetchCatalog,
fetchSessionDetail,
fetchReadonlySessionDetail,
prepareForAndroid,
fetchAllSessionPages,
savePreparation,
type CatalogChoice,
type PreparationInput,
type PreparationOccurrence,
type SessionCollection,
type SessionDetail,
type SessionListItem,
} from '../api/sessions'
type View = 'preparation' | 'resume' | 'history'
const collections: Record<View, SessionCollection[]> = {
preparation: ['preparations', 'proposals'], resume: ['drafts'], history: ['history'],
}
function parseDecimal(value: string): number | null {
if (!value.trim()) return null
const normalized = value.replace(',', '.')
const number = Number(normalized)
return Number.isFinite(number) ? number : null
}
function emptyInput(): PreparationInput {
return { title: '', session_type: 'training', planned_for: null, notes: null,
editing_state: 'draft', occurrences: [] }
}
function editableOccurrence(
value: PreparationOccurrence,
preserveIdentity: boolean,
): PreparationOccurrence {
return {
...(preserveIdentity ? { entry_id: value.entry_id } : {}),
exercise_id: value.exercise_id,
exercise_name: value.exercise_name,
equipment_id: value.equipment_id,
recording_mode: value.recording_mode,
tracking_mode: value.tracking_mode,
load_mode: value.load_mode,
rest_seconds: value.rest_seconds,
target_sets: value.target_sets,
target_reps: value.target_reps,
target_duration_seconds: value.target_duration_seconds,
target_weight_kg: value.target_weight_kg,
notes: value.notes,
}
}
export function commandOccurrence(value: PreparationOccurrence): PreparationOccurrence {
return {
...(value.entry_id ? { entry_id: value.entry_id } : {}),
exercise_id: value.exercise_id,
equipment_id: value.equipment_id,
load_mode: value.load_mode,
rest_seconds: value.rest_seconds,
target_sets: value.target_sets,
target_reps: value.target_reps,
target_duration_seconds: value.target_duration_seconds,
target_weight_kg: value.target_weight_kg,
notes: value.notes,
}
}
function Editor({ initial, onSaved, onCancel }: {
initial?: SessionDetail
onSaved: (identity: string) => void
onCancel: () => void
}) {
const [input, setInput] = useState<PreparationInput>(() => initial ? {
title: initial.title, session_type: initial.session_type, planned_for: initial.planned_for,
notes: initial.notes, editing_state: 'draft', occurrences: initial.occurrences.map((value) =>
editableOccurrence(value, initial.kind === 'preparation')),
...(initial.kind === 'proposal' ? {
source_proposal_id: initial.identity,
source_payload_sha256: initial.source_fingerprint ?? undefined,
} : {}),
} : emptyInput())
const [catalog, setCatalog] = useState<CatalogChoice[]>([])
const [catalogSearch, setCatalogSearch] = useState('')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
const controller = new AbortController()
fetchCatalog(catalogSearch, controller.signal).then(setCatalog).catch(() => setCatalog([]))
return () => controller.abort()
}, [catalogSearch])
const updateOccurrence = (index: number, next: PreparationOccurrence) => {
setInput((current) => ({ ...current,
occurrences: current.occurrences.map((value, position) => position === index ? next : value) }))
}
const add = (choice: CatalogChoice) => setInput((current) => ({ ...current, occurrences: [
...current.occurrences,
{ exercise_id: choice.exercise_id, exercise_name: choice.name, equipment_id: null,
recording_mode: choice.recording_mode, tracking_mode: choice.tracking_mode,
load_mode: 'none', rest_seconds: 0, target_sets: choice.recording_mode === 'sets' ? 1 : null,
target_reps: choice.tracking_mode === 'reps' ? 1 : null,
target_duration_seconds: choice.tracking_mode === 'duration' ? 60 : null,
target_weight_kg: null, notes: null },
] }))
const move = (index: number, delta: number) => setInput((current) => {
const target = index + delta
if (target < 0 || target >= current.occurrences.length) return current
const occurrences = [...current.occurrences]
;[occurrences[index], occurrences[target]] = [occurrences[target], occurrences[index]]
return { ...current, occurrences }
})
const submit = async (ready: boolean) => {
setSaving(true); setError('')
try {
const result = await savePreparation({ ...input, editing_state: ready ? 'ready' : 'draft',
occurrences: input.occurrences.map(commandOccurrence) },
initial?.kind === 'preparation' ? initial.identity : undefined,
initial?.kind === 'preparation' ? initial.revision_id : undefined)
if (ready) await prepareForAndroid(result.preparation_id, result.revision_id)
onSaved(result.preparation_id)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Sauvegarde impossible')
} finally { setSaving(false) }
}
return <section className="sessions-editor" aria-labelledby="editor-title">
<div className="sessions-toolbar"><div><p className="eyebrow">PRÉPARATION</p>
<h2 id="editor-title">{initial ? 'Modifier la séance' : 'Nouvelle séance'}</h2></div>
<button type="button" className="quiet-action" onClick={onCancel}>Annuler les modifications</button></div>
{error && <p role="alert" className="form-error">{error}</p>}
<div className="form-grid">
<label>Titre<input value={input.title} maxLength={200}
onChange={(event) => setInput({ ...input, title: event.target.value })} /></label>
<label>Type<select value={input.session_type}
onChange={(event) => setInput({ ...input, session_type: event.target.value as 'training' | 'max_test' })}>
<option value="training">Entraînement</option><option value="max_test">Test MAX</option></select></label>
<label>Date planifiée<input type="date" value={input.planned_for ?? ''}
onChange={(event) => setInput({ ...input, planned_for: event.target.value || null })} /></label>
<label className="wide-field">Note<textarea value={input.notes ?? ''} maxLength={4000}
onChange={(event) => setInput({ ...input, notes: event.target.value || null })} /></label>
</div>
<div className="catalog-picker"><label>Rechercher un exercice<input value={catalogSearch}
onChange={(event) => setCatalogSearch(event.target.value)} /></label>
<div className="catalog-results">{catalog.map((choice) => <button type="button"
key={choice.exercise_id} onClick={() => add(choice)}>+ {choice.name}</button>)}</div></div>
<ol className="occurrence-editor-list">{input.occurrences.map((occurrence, index) =>
<li key={occurrence.entry_id ?? `${occurrence.exercise_id}-${index}`}>
<div className="occurrence-heading"><span className="drag-handle" aria-hidden="true">⋮⋮</span>
<strong>{occurrence.exercise_name ?? occurrence.exercise_id}</strong>
<span className="occurrence-actions"><button type="button" disabled={index === 0}
aria-label="Monter" onClick={() => move(index, -1)}>↑</button>
<button type="button" disabled={index + 1 === input.occurrences.length}
aria-label="Descendre" onClick={() => move(index, 1)}>↓</button>
<button type="button" onClick={() => setInput({ ...input,
occurrences: input.occurrences.filter((_, position) => position !== index) })}>Retirer</button></span></div>
<div className="occurrence-fields"><label>Charge<select value={occurrence.load_mode}
onChange={(event) => updateOccurrence(index, { ...occurrence,
load_mode: event.target.value as PreparationOccurrence['load_mode'],
target_weight_kg: event.target.value === 'none' ? null : occurrence.target_weight_kg })}>
<option value="none">Aucune</option><option value="external">Externe</option>
<option value="assistance">Assistance</option></select></label>
{occurrence.recording_mode === 'sets' && <label>Séries<input inputMode="numeric"
value={occurrence.target_sets ?? ''} onChange={(event) => updateOccurrence(index,
{ ...occurrence, target_sets: parseDecimal(event.target.value) })} /></label>}
{occurrence.tracking_mode === 'reps' ? <label>Répétitions<input inputMode="numeric"
value={occurrence.target_reps ?? ''} onChange={(event) => updateOccurrence(index,
{ ...occurrence, target_reps: parseDecimal(event.target.value) })} /></label> :
<label>Durée (s)<input inputMode="numeric" value={occurrence.target_duration_seconds ?? ''}
onChange={(event) => updateOccurrence(index,
{ ...occurrence, target_duration_seconds: parseDecimal(event.target.value) })} /></label>}
{occurrence.load_mode !== 'none' && <label>Charge (kg)<input inputMode="decimal"
value={occurrence.target_weight_kg ?? ''} onChange={(event) => updateOccurrence(index,
{ ...occurrence, target_weight_kg: parseDecimal(event.target.value) })} /></label>}
<label>Repos (s)<input inputMode="numeric" value={occurrence.rest_seconds}
onChange={(event) => updateOccurrence(index,
{ ...occurrence, rest_seconds: parseDecimal(event.target.value) ?? 0 })} /></label></div>
</li>)}</ol>
{input.occurrences.length === 0 && <p className="empty-inline">Ajoutez des exercices pour rendre la préparation exécutable.</p>}
<div className="editor-actions"><button type="button" disabled={saving}
onClick={() => void submit(false)}>Enregistrer</button>
<button type="button" className="primary-action" disabled={saving || !input.title || input.occurrences.length === 0}
onClick={() => void submit(true)}>Marquer prête / Préparer pour Android</button></div>
</section>
}
function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'proposal'; identity: string;
onBack: () => void; onEdit: (detail: SessionDetail) => void }) {
const [detail, setDetail] = useState<SessionDetail | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => { const controller = new AbortController(); setFailed(false)
fetchSessionDetail(kind, identity, controller.signal).then(setDetail).catch(() => setFailed(true))
return () => controller.abort() }, [kind, identity])
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
return <article className="session-detail"><div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button>
<button type="button" className="primary-action" onClick={() => onEdit(detail)}>
{kind === 'preparation' ? 'Modifier' : 'Préparer à partir de cette proposition'}</button></div>
<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>
{detail.notes && <section className="detail-tile"><h3>Note</h3><p>{detail.notes}</p></section>}
<section className="detail-tile"><h3>Exercices ordonnés</h3><ol className="detail-occurrences">
{detail.occurrences.map((occurrence) => <li key={occurrence.entry_id}>
<strong>{occurrence.exercise_name ?? occurrence.exercise_id}</strong>
<span>Prévu : {occurrence.target_sets ?? '—'} séries · {occurrence.target_reps ?? occurrence.target_duration_seconds ?? '—'}
{occurrence.tracking_mode === 'duration' ? ' s' : ' répétitions'}</span>
<small>{occurrence.load_mode === 'none' ? 'Sans charge' : `${occurrence.load_mode} · ${occurrence.target_weight_kg ?? '—'} kg`}</small>
</li>)}</ol></section>
{detail.source_fingerprint && <section className="detail-tile"><h3>Provenance</h3>
<code>{detail.source_fingerprint}</code></section>}
</article>
}
function ReadonlyDetail({ kind, identity, onBack }: {
kind: 'draft' | 'history'; identity: string; onBack: () => void
}) {
const [detail, setDetail] = useState<Record<string, unknown> | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => { const controller = new AbortController(); setFailed(false)
fetchReadonlySessionDetail(kind, identity, controller.signal).then(setDetail).catch(() => setFailed(true))
return () => controller.abort() }, [kind, identity])
if (failed) return <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
const occurrences = Array.isArray(detail.occurrences) ? detail.occurrences as Array<Record<string, unknown>> : []
const payload = typeof detail.payload === 'object' && detail.payload !== null
? detail.payload as Record<string, unknown> : null
return <article className="session-detail"><div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button></div>
<header className="detail-summary"><p className="eyebrow">{kind === 'draft' ? 'BROUILLON D’EXÉCUTION' : 'SÉANCE RÉALISÉE'}</p>
<h2>{String(detail.session_type ?? 'Séance')}</h2>
<p>{String(detail.started_at ?? 'Début inconnu')} · {kind === 'history'
? detail.ended_at ? `fin ${String(detail.ended_at)}` : 'heure de fin inconnue'
: `à poursuivre sur Android · ${String(detail.state ?? '')}`}</p></header>
{typeof detail.notes === 'string' && detail.notes && <section className="detail-tile"><h3>Notes</h3><p>{detail.notes}</p></section>}
{kind === 'history' && <section className="detail-tile"><h3>Prévu / Réalisé</h3><ol className="detail-occurrences">
{occurrences.map((occurrence) => <li key={String(occurrence.entry_id)}>
<strong>{String(occurrence.exercise_name ?? occurrence.exercise_id)}</strong>
<span>Objectifs : {String(occurrence.target_sets ?? '—')} séries · {String(occurrence.target_reps ?? occurrence.target_duration_seconds ?? '—')}</span>
<span>Réalisé : {Array.isArray(occurrence.performed_sets) && occurrence.performed_sets.length > 0
? occurrence.performed_sets.map((set) => { const values = set as Record<string, unknown>
return `${String(values.reps ?? values.duration_seconds ?? '—')}${values.weight_kg ? ` × ${String(values.weight_kg)} kg` : ''}` }).join(' · ')
: occurrence.max_weight_kg ? `MAX mesuré ${String(occurrence.max_weight_kg)} kg` : 'aucune mesure persistée'}</span>
</li>)}</ol></section>}
{kind === 'draft' && payload && <section className="detail-tile"><h3>État confirmé</h3>
<pre className="draft-payload">{JSON.stringify(payload, null, 2)}</pre></section>}
</article>
}
export function SessionsPage() {
const [view, setView] = useState<View>('preparation')
const [items, setItems] = useState<Array<SessionListItem & { collection: SessionCollection }>>([])
const [search, setSearch] = useState('')
const [stateFilter, setStateFilter] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [pending, setPending] = useState(true)
const [failed, setFailed] = useState(false)
const [editor, setEditor] = useState<SessionDetail | 'new' | null>(null)
const [detailPath, setDetailPath] = useState(() => window.location.pathname)
const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath])
const load = () => { const controller = new AbortController(); setPending(true); setFailed(false)
Promise.all(collections[view].map((collection) => fetchAllSessionPages(collection, search, controller.signal)
.then((pageItems) => pageItems.map((item) => ({ ...item, collection })))))
.then((pages) => setItems(pages.flat())).catch(() => setFailed(true)).finally(() => setPending(false))
return controller }
useEffect(() => { const controller = load(); return () => controller.abort() }, [view, search])
useEffect(() => { const pop = () => setDetailPath(window.location.pathname)
window.addEventListener('popstate', pop); return () => window.removeEventListener('popstate', pop) }, [])
const open = (collection: SessionCollection, identity: string) => {
const kind = collection === 'preparations' ? 'preparation' : collection === 'proposals' ? 'proposal'
: collection === 'drafts' ? 'draft' : 'history'
const path = `/seances/${kind}/${encodeURIComponent(identity)}`
window.history.pushState(null, '', path); setDetailPath(path)
}
const back = () => { window.history.pushState(null, '', '/seances'); setDetailPath('/seances'); setEditor(null) }
const visibleItems = useMemo(() => items.filter((item) => {
if (stateFilter && item.state !== stateFilter) return false
if (dateFrom && (!item.date || item.date < dateFrom)) return false
if (dateTo && (!item.date || item.date > dateTo)) return false
return true
}), [items, stateFilter, dateFrom, dateTo])
const states = useMemo(() => [...new Set(items.map((item) => item.state))].sort(), [items])
if (editor) return <section className="page"><Editor initial={editor === 'new' ? undefined : editor}
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
return <section className="page"><Detail kind={detailMatch[1]}
identity={decodeURIComponent(detailMatch[2])} onBack={back} onEdit={setEditor} /></section>
}
if (detailMatch) return <section className="page"><ReadonlyDetail
kind={detailMatch[1] as 'draft' | 'history'} identity={decodeURIComponent(detailMatch[2])}
onBack={back} /></section>
return <section className="page sessions-page" aria-labelledby="page-title"><div className="page-heading">
<div><p className="eyebrow">ORGANISER</p><h1 id="page-title">Séances</h1></div>
<p className="page-intro">Préparez sans confondre proposition, brouillon d’exécution et séance réalisée.</p></div>
<div className="sessions-toolbar"><div className="session-tabs" role="tablist">
{(['preparation', 'resume', 'history'] as const).map((candidate) => <button type="button" role="tab"
aria-selected={view === candidate} key={candidate} onClick={() => setView(candidate)}>
{candidate === 'preparation' ? 'Préparation' : candidate === 'resume' ? 'À reprendre' : 'Historique'}</button>)}</div>
{view === 'preparation' && <button type="button" className="primary-action" onClick={() => setEditor('new')}>Nouvelle séance</button>}</div>
<label className="session-search"><span>Rechercher par titre ou exercice</span><input type="search" value={search}
onChange={(event) => setSearch(event.target.value)} /></label>
<div className="session-filters" aria-label="Filtres de séances">
<label>État<select value={stateFilter} onChange={(event) => setStateFilter(event.target.value)}>
<option value="">Tous</option>{states.map((state) => <option value={state} key={state}>{state}</option>)}
</select></label>
<label>Du<input type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} /></label>
<label>Au<input type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} /></label>
</div>
{pending && <p aria-live="polite">Chargement…</p>}{failed && <p className="error-panel" role="alert">Les données ne sont pas disponibles.</p>}
{!pending && !failed && visibleItems.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
<div className="session-list">{visibleItems.map((item) => <button type="button" className="session-row"
key={`${item.collection}-${item.identity}`} onClick={() => open(item.collection, item.identity)}>
<span><small>{item.collection === 'proposals' ? 'Proposition IA' : item.collection === 'preparations' ? 'Préparation manuelle' : item.collection === 'drafts' ? 'Brouillon d’exécution' : 'Séance réalisée'}</small>
<strong>{item.title || 'Sans titre'}</strong></span><span>{item.date ?? 'Date inconnue'}</span>
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span><span>{item.state}</span></button>)}</div>
</section>
}

View file

@ -58,49 +58,6 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
.page-intro { max-width: 34rem; margin-bottom: .15rem; color: var(--subtext-0); font-size: clamp(.95rem, 1.4vw, 1.08rem); line-height: 1.65; }
.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; }
.session-tabs { display: flex; gap: .4rem; padding: .3rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); }
.session-tabs button, .quiet-action, .primary-action, .editor-actions button { min-height: 2.6rem; padding: .6rem .9rem; border: 1px solid transparent; border-radius: .55rem; color: var(--text); background: transparent; cursor: pointer; }
.session-tabs button[aria-selected="true"] { color: var(--crust); background: var(--lavender); font-weight: 700; }
.primary-action, .editor-actions .primary-action { color: var(--crust); background: var(--lavender); font-weight: 700; }
.quiet-action { border-color: var(--surface-2); }
.session-search { display: grid; gap: .45rem; max-width: 34rem; margin-bottom: 1.5rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
.session-filters { display: flex; flex-wrap: wrap; gap: .8rem; margin: -.35rem 0 1.25rem; }
.session-filters label { display: grid; min-width: 10rem; gap: .35rem; color: var(--subtext-0); font-size: var(--font-size-xs); }
.session-filters select, .session-filters input { min-height: 2.5rem; padding: .55rem .7rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .5rem; }
.session-search input, .form-grid input, .form-grid select, .form-grid textarea, .catalog-picker input, .occurrence-fields input, .occurrence-fields select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; border: 1px solid var(--surface-2); border-radius: .5rem; color: var(--text); background: var(--base); font-size: var(--font-size-md); }
.session-list { display: grid; gap: .6rem; }
.session-row { width: 100%; padding: 1rem 1.1rem; display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(7rem, 1fr)); gap: 1rem; align-items: center; text-align: left; color: var(--text); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); cursor: pointer; }
.session-row:hover { border-color: var(--lavender); background: var(--base); }
.session-row span:first-child { display: grid; gap: .2rem; }
.session-row small { color: var(--lavender); font: 700 var(--font-size-xs) var(--font-mono); text-transform: uppercase; }
.sessions-editor, .session-detail { padding: clamp(1rem, 3vw, 2.25rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }
.form-grid { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 1rem; }
.form-grid label, .catalog-picker label, .occurrence-fields label { display: grid; gap: .4rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
.wide-field { grid-column: 1 / -1; }
.form-grid textarea { min-height: 7rem; resize: vertical; }
.catalog-picker { margin: 1.5rem 0; }
.catalog-results { margin-top: .6rem; display: flex; gap: .45rem; flex-wrap: wrap; }
.catalog-results button, .occurrence-actions button { padding: .45rem .65rem; border: 1px solid var(--surface-2); border-radius: .45rem; color: var(--text); background: var(--base); cursor: pointer; }
.occurrence-editor-list, .detail-occurrences { padding: 0; display: grid; gap: .75rem; list-style: none; }
.occurrence-editor-list > li, .detail-occurrences > li { padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; background: var(--base); }
.occurrence-heading { display: flex; align-items: center; gap: .65rem; }
.drag-handle { color: var(--overlay-1); }
.occurrence-actions { margin-left: auto; display: flex; gap: .35rem; }
.occurrence-fields { margin-top: 1rem; display: grid; grid-template-columns: repeat(5, minmax(7rem, 1fr)); gap: .75rem; }
.editor-actions { margin-top: 1.5rem; display: flex; justify-content: flex-end; gap: .75rem; }
.editor-actions button { border-color: var(--surface-2); }
.editor-actions button:disabled { cursor: not-allowed; opacity: .55; }
.empty-inline, .error-panel, .form-error { padding: 1rem; border-radius: .65rem; background: var(--base); color: var(--subtext-0); }
.error-panel, .form-error { border: 1px solid var(--red); color: var(--red); }
.detail-summary, .detail-tile { margin-bottom: 1rem; padding: 1.1rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--base); }
.detail-occurrences li { display: grid; gap: .35rem; }
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }
.detail-tile code { overflow-wrap: anywhere; color: var(--lavender); }
.draft-payload { max-height: 32rem; overflow: auto; color: var(--subtext-0); font: var(--font-size-sm)/1.6 var(--font-mono); white-space: pre-wrap; }
@media (max-width: 52rem) { .session-row { grid-template-columns: 1fr 1fr; } .form-grid { grid-template-columns: 1fr 1fr; } .occurrence-fields { grid-template-columns: 1fr 1fr; } }
@media (max-width: 30rem) { .session-row, .form-grid, .occurrence-fields { grid-template-columns: 1fr; } .session-tabs { width: 100%; overflow-x: auto; } .session-tabs button { white-space: nowrap; } .editor-actions { align-items: stretch; flex-direction: column; } }
.dashboard-layout { min-width: 0; }
.layout-toolbar { min-height: 2.7rem; margin: -1rem 0 1rem; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: .65rem; }
.layout-action { min-height: 2.45rem; padding: .58rem .82rem; color: var(--subtext-0); background: var(--surface-0); border: 1px solid rgb(108 112 134 / 48%); border-radius: .55rem; font: 650 var(--font-size-xs) var(--font-mono); cursor: pointer; transition: color 140ms ease, border-color 140ms ease, background 140ms ease; }