Compare commits
5 commits
a5cdbdf2b9
...
c99493fa54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c99493fa54 | ||
|
|
bbcb487491 | ||
|
|
e0a0397d2a | ||
|
|
47385c7a86 | ||
|
|
ec17fca45d |
82 changed files with 3221 additions and 110 deletions
|
|
@ -2,6 +2,11 @@
|
|||
|
||||
## 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.
|
||||
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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 implemented local Dashboard and persist its private layout through typed Trainlog Core/API boundaries. Analyse, Programmes, Sessions, and Exercises remain placeholders. |
|
||||
| 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. |
|
||||
|
||||
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 v22 and Android SQLite schema v21;
|
||||
- desktop SQLite schema v23 and Android SQLite schema v22;
|
||||
- Notcurses is the only active desktop terminal backend;
|
||||
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
||||
all-files access setting;
|
||||
|
|
@ -330,7 +330,8 @@ 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`; the other four Web routes remain placeholders;
|
||||
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
|
||||
Analyse, Programmes, and Exercises 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
|
||||
|
|
|
|||
|
|
@ -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..21
|
||||
manifest.getInt("schema") in 17..22
|
||||
) {
|
||||
"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..21) { "Unsupported Android schema ${db.version}." }
|
||||
require(db.version in 17..22) { "Unsupported Android schema ${db.version}." }
|
||||
return db.version
|
||||
} finally {
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -69,7 +69,8 @@ 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-ai-session-drafts", 1, "ai-proposals-v1.json", false) +
|
||||
Kind("trainlog-session-preparations", 1, "session-preparations-v1.json", false))
|
||||
.map { it.format to it.version }
|
||||
.toSet()
|
||||
|
||||
|
|
@ -835,6 +836,12 @@ 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()
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ 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
|
||||
|
|
@ -1874,6 +1875,147 @@ 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()
|
||||
|
|
@ -7934,7 +8076,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
21,
|
||||
22,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -7980,6 +8122,7 @@ private class TrainlogDatabaseHelper(
|
|||
createCausalDeleteTables(db)
|
||||
createSyncGenerationTables(db)
|
||||
createSyncGenerationArchiveTable(db)
|
||||
createSessionPreparationTables(db)
|
||||
seedEquipment(db)
|
||||
}
|
||||
|
||||
|
|
@ -8155,6 +8298,12 @@ 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(
|
||||
|
|
@ -8262,6 +8411,18 @@ 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(" +
|
||||
|
|
|
|||
|
|
@ -130,6 +130,18 @@ 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,
|
||||
|
|
|
|||
|
|
@ -83,9 +83,36 @@ 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()) TrainlogInfo(strings.getString(R.string.draft_none))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drafts.forEach { draft ->
|
||||
TrainlogFrame(draft.title ?: strings.getString(R.string.session_proposal), active = true) {
|
||||
TrainlogInfo(listOfNotNull(
|
||||
|
|
|
|||
|
|
@ -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.listAiSessionDrafts().size + repository.listPreparedSessions().size
|
||||
}
|
||||
|
||||
fun open(route: AppRoute) { navigationController.open(route) }
|
||||
|
|
|
|||
|
|
@ -295,6 +295,8 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -295,6 +295,8 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(21, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(22, scalar(db, "PRAGMA user_version"))
|
||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(21, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(22, scalar(db, "PRAGMA user_version"))
|
||||
/* v13 intentionally adds the frozen machine identities and
|
||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(21, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(22, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(before, listOf(
|
||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
|||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(21, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(22, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
|
|||
null,
|
||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||
)
|
||||
.use { assertEquals(21, it.version) }
|
||||
.use { assertEquals(22, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ class SyncGenerationServiceTest {
|
|||
.use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
assertEquals(21, it.getInt(0))
|
||||
assertEquals(22, it.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(21, cursor.getInt(0))
|
||||
assertEquals(22, 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(21, cursor.getInt(0))
|
||||
assertEquals(22, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
# 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.
|
||||
|
|
|
|||
|
|
@ -493,7 +493,7 @@ silently rendered as unclassified.
|
|||
|
||||
### Desktop
|
||||
|
||||
Desktop SQLite schema v21 is canonical long-term history. Its additive v18 ->
|
||||
Desktop SQLite schema v23 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
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ parallel implementation of its rules.
|
|||
| Boundary | Current state |
|
||||
|---|---|
|
||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
| Desktop SQLite | schema v22 |
|
||||
| Android SQLite | schema v21 |
|
||||
| Desktop SQLite | schema v23 |
|
||||
| Android SQLite | schema v22 |
|
||||
| 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,6 +72,7 @@ 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` (isolated software validation; controlled deployment pending) |
|
||||
| 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` |
|
||||
|
|
@ -216,8 +217,16 @@ 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
|
||||
business mutation or automatic browser launch. Analyse, Programmes, Sessions,
|
||||
and Exercises remain explicit placeholders.
|
||||
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 Next Session tile no longer treats the frozen Dashboard
|
||||
`next_session.available` placeholder as a durable inventory. A separate bounded
|
||||
|
|
@ -378,9 +387,8 @@ 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, Séances and Exercices
|
||||
remain shell placeholders until their Core/API ownership and implementation
|
||||
order are explicitly contracted.
|
||||
- `WEB_NEXT_MODULE_SELECTION_V1`: Analyse, Programmes and Exercices remain
|
||||
shell placeholders. Exercises is the next proposed Web module.
|
||||
- `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.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
# 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
|
||||
|
|
@ -16,8 +26,8 @@ is a later, separately versioned migration.
|
|||
## 1. Status
|
||||
|
||||
```text
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=21
|
||||
DATABASE_SCHEMA_V21=IMPLEMENTED
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=23
|
||||
DATABASE_SCHEMA_V23=IMPLEMENTED
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
```
|
||||
|
||||
|
|
@ -36,7 +46,7 @@ PRAGMA user_version;
|
|||
Current value:
|
||||
|
||||
```text
|
||||
21
|
||||
23
|
||||
```
|
||||
|
||||
The independent actual-set loads documented in the current desktop, Android
|
||||
|
|
|
|||
83
docs/design/web_sessions_v1.md
Normal file
83
docs/design/web_sessions_v1.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# 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.
|
||||
|
|
@ -1,5 +1,15 @@
|
|||
# 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,
|
||||
|
|
|
|||
BIN
docs/reviews/evidence/web-sessions-detail-desktop.png
Normal file
BIN
docs/reviews/evidence/web-sessions-detail-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
docs/reviews/evidence/web-sessions-detail-mobile-390.png
Normal file
BIN
docs/reviews/evidence/web-sessions-detail-mobile-390.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/reviews/evidence/web-sessions-editor-mobile-390.png
Normal file
BIN
docs/reviews/evidence/web-sessions-editor-mobile-390.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/reviews/evidence/web-sessions-editor-mobile.png
Normal file
BIN
docs/reviews/evidence/web-sessions-editor-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/reviews/evidence/web-sessions-list-desktop.png
Normal file
BIN
docs/reviews/evidence/web-sessions-list-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
docs/reviews/evidence/web-sessions-list-mobile-390.png
Normal file
BIN
docs/reviews/evidence/web-sessions-list-mobile-390.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
21
docs/reviews/web_sessions_v1_evidence.md
Normal file
21
docs/reviews/web_sessions_v1_evidence.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# 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/`.
|
||||
|
|
@ -24,7 +24,7 @@ distinction between measured values and estimates.
|
|||
|
||||
## Current baseline
|
||||
|
||||
Desktop schema v22, Android schema v21, Notcurses, direct
|
||||
Desktop schema v23, Android schema v22, 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 are implemented; the other four Web routes remain placeholders.
|
||||
visualizations and Sessions V1 are implemented; the other three Web routes remain placeholders.
|
||||
The corrective prepared-item projection and bounded phase-owned MTP outbox are
|
||||
implemented without opening the Sessions route, changing schemas, or changing
|
||||
the frozen Dashboard and exchange contracts.
|
||||
|
|
@ -53,7 +53,8 @@ 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 work returns to separately contracted Web surfaces.
|
||||
The next separately contracted Web surface is Exercises. Sessions V1 is
|
||||
complete in software and is not reopened by that future contract.
|
||||
|
||||
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS`
|
||||
defines the complete-sync target and its bounded dependency order.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
# 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
|
||||
|
|
@ -18,7 +29,7 @@ available because availability is independently guarded by causal state.
|
|||
|
||||
## Machine-exercise Phase 1 compatibility
|
||||
|
||||
Desktop schema v22 and Android schema v21 retain mobile export V3, readable V1/V2 imports,
|
||||
Desktop schema v23 and Android schema v22 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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
# 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.
|
||||
|
||||
`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,
|
||||
|
|
|
|||
21
docs/web_sessions.md
Normal file
21
docs/web_sessions.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# 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.
|
||||
|
|
@ -23,7 +23,9 @@ 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));
|
||||
PRAGMA user_version=22;
|
||||
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;
|
||||
"""
|
||||
|
||||
class GenerationTest(unittest.TestCase):
|
||||
|
|
|
|||
111
tests/test_web_sessions_browser.py
Normal file
111
tests/test_web_sessions_browser.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
#!/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()
|
||||
|
|
@ -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):
|
||||
raise CausalError(f"desktop schema v20-v22 required, found v{version}")
|
||||
if version not in (20, 21, 22, 23):
|
||||
raise CausalError(f"desktop schema v20-v23 required, found v{version}")
|
||||
|
||||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
|
|
|
|||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
|||
|
||||
def require_schema(connection):
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (19, 20, 21, 22):
|
||||
raise LifecycleError(f"desktop schema v19-v22 required, found v{version}")
|
||||
if version not in (19, 20, 21, 22, 23):
|
||||
raise LifecycleError(f"desktop schema v19-v23 required, found v{version}")
|
||||
|
||||
|
||||
def import_document(connection, document, own_transaction=True):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
|||
from trainlog_sqlite import configure_connection
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23)
|
||||
MEASUREMENT_FIELDS = (
|
||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ ID_PREFIX = "aid_"
|
|||
def ensure_publication_state(connection):
|
||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||
owned_transaction = not connection.in_transaction
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22):
|
||||
raise ValueError("schema desktop v18-v22 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23):
|
||||
raise ValueError("schema desktop v18-v23 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
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):
|
||||
raise ValueError("schema desktop v8 à v16 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
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):
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ 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):
|
||||
raise ValueError("schema desktop v12-v16 requis")
|
||||
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")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
||||
raise ValueError("schema desktop v11-v16 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (17, 18, 19, 20, 21, 22):
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23):
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||
rows = con.execute("""
|
||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ def main():
|
|||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22):
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23):
|
||||
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) and not (args.version == 2 and schema_version == 10):
|
||||
if schema_version not in (11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23) 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():
|
||||
|
|
|
|||
100
tools/export_session_preparations.py
Normal file
100
tools/export_session_preparations.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/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())
|
||||
|
|
@ -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): raise ValueError("schema desktop v15-v22 requis")
|
||||
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 not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
|
||||
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
||||
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
||||
|
|
|
|||
|
|
@ -193,8 +193,8 @@ def import_payload(connection, payload, imported_at=None):
|
|||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||
any failure leaves no draft, child, or import-identity row behind.
|
||||
"""
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22):
|
||||
raise ImportFailure("schema desktop v18-v22 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (18, 19, 20, 21, 22, 23):
|
||||
raise ImportFailure("schema desktop v18-v23 requis")
|
||||
draft, entries = validate(payload, connection)
|
||||
digest = canonical_digest(payload)
|
||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ def main():
|
|||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
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):
|
||||
fail("schema desktop v8 à v21 requis")
|
||||
proof_path = args.mobile_export
|
||||
if proof_path is None:
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ def validate(payload, reserved):
|
|||
|
||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||
"""Apply validated definitions without owning the surrounding transaction."""
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
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):
|
||||
fail("schema desktop v8 à v21 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||
fail("causal equipment protection requires the staged artifact")
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ def load(path):
|
|||
|
||||
def apply_aliases(con, aliases):
|
||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
||||
fail("schema desktop v12-v21 requis")
|
||||
for source, canonical in aliases:
|
||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ def replace(connection, row_id, primary, secondary):
|
|||
|
||||
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||
"""Apply validated zone rows without owning transaction boundaries."""
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11,12,13,14,15,16,17,18,19,20,21,22): raise ImportFailure("schema desktop v11-v22 requis")
|
||||
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 not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
|
||||
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ def validate(item):
|
|||
def apply_profile_state(con, root, allow_pending=False):
|
||||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22): fail("schema desktop v17-v22 requis")
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (17,18,19,20,21,22,23): fail("schema desktop v17-v23 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set()
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
|
|
|
|||
|
|
@ -846,9 +846,9 @@ def require_supported_schema(connection):
|
|||
|
||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||
# readable for legacy artifacts and are never made to fake that table.
|
||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22):
|
||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v22 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v23 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):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v22")
|
||||
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 schema_version >= 20:
|
||||
protected = {(row[0], row[1]) for row in connection.execute(
|
||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
|||
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17,18,19,20,21,22):fail("schema desktop v15-v22 requis")
|
||||
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 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"]):
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ RUNTIME_TOOLS = [
|
|||
"export_exercise_body_zones.py",
|
||||
"export_training_feedback.py",
|
||||
"export_ai_session_drafts.py",
|
||||
"export_session_preparations.py",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -95,8 +96,8 @@ def main() -> int:
|
|||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||
).strip(),
|
||||
"product_version": "0.1.2",
|
||||
"desktop_schema": 22,
|
||||
"android_schema": 21,
|
||||
"desktop_schema": 23,
|
||||
"android_schema": 22,
|
||||
"protocols": [
|
||||
"mobile-export-v3",
|
||||
"mobile-history-v4",
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ ARTIFACTS = (
|
|||
"causal_delete_exchange.py", ("export",)),
|
||||
("ai-proposals", "trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", False,
|
||||
"export_ai_session_drafts.py", ()),
|
||||
("session-preparations", "trainlog-session-preparations", 1,
|
||||
"session-preparations-v1.json", False, "export_session_preparations.py", ()),
|
||||
)
|
||||
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS}
|
||||
|
||||
|
|
@ -199,8 +201,8 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
|
|||
|
||||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 22:
|
||||
raise GenerationError("desktop schema v22 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 23:
|
||||
raise GenerationError("desktop schema v23 required")
|
||||
|
||||
|
||||
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||
|
|
@ -403,6 +405,22 @@ 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"]:
|
||||
|
|
@ -661,6 +679,20 @@ 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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 22
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 23
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
/*
|
||||
* 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
|
||||
|
|
@ -25,8 +26,7 @@
|
|||
* 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,7 +39,9 @@
|
|||
* - `se` for sessions;
|
||||
* - `bo` for body observations.
|
||||
* - `sy` for synchronization runs;
|
||||
* - `sxe` for persisted session-exercise occurrences.
|
||||
* - `sxe` for persisted session-exercise occurrences;
|
||||
* - `sp`, `spr`, `spe`, and `spd` for preparation, revision, occurrence, and
|
||||
* delivery identities.
|
||||
*
|
||||
* Imported v1 documents may contain other schema-valid opaque identifiers;
|
||||
* this API defines creation policy, not import validation.
|
||||
|
|
@ -54,10 +56,6 @@
|
|||
* 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
|
||||
|
|
|
|||
76
tui/include/trainlog/web_sessions.h
Normal file
76
tui/include/trainlog/web_sessions.h
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/* 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: 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
|
||||
|
|
@ -194,6 +194,7 @@ 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',
|
||||
|
|
@ -346,6 +347,15 @@ 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',
|
||||
|
|
|
|||
|
|
@ -884,6 +884,45 @@ 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. */
|
||||
|
|
@ -1579,7 +1618,8 @@ 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 == 19 || version == 20 || version == 21 || version == 22 ||
|
||||
version == 23) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -1755,6 +1795,9 @@ 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);
|
||||
}
|
||||
|
|
@ -1770,7 +1813,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
set_open_diagnostic(
|
||||
output_diagnostic,
|
||||
output_diagnostic_capacity,
|
||||
version == 0 ? "create schema v22" : "migrate database to schema v22",
|
||||
version == 0 ? "create schema v23" : "migrate database to schema v23",
|
||||
database->connection,
|
||||
SQLITE_ERROR
|
||||
);
|
||||
|
|
|
|||
29
tui/src/id.c
29
tui/src/id.c
|
|
@ -11,26 +11,18 @@
|
|||
|
||||
#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;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -51,16 +43,9 @@ TrainlogStatus trainlog_id_generate(
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
|
||||
#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"
|
||||
|
||||
|
|
@ -29,7 +30,8 @@
|
|||
#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 (4U * 1024U)
|
||||
#define TRAINLOG_HTTP_BODY_LIMIT (128U * 1024U)
|
||||
#define TRAINLOG_HTTP_LEGACY_BODY_LIMIT (4U * 1024U)
|
||||
#define TRAINLOG_HTTP_TIMEOUT_SECONDS 10U
|
||||
#define TRAINLOG_HTTP_BACKLOG 32U
|
||||
#define TRAINLOG_HTTP_POLL_MAX_MS 250ULL
|
||||
|
|
@ -427,6 +429,12 @@ 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;
|
||||
}
|
||||
|
||||
|
|
@ -513,6 +521,47 @@ 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,
|
||||
|
|
@ -561,7 +610,9 @@ static enum MHD_Result handle_request(void *closure,
|
|||
"{\"error\":\"headers_too_large\"}\n",
|
||||
NULL);
|
||||
}
|
||||
if (state->body_too_large) {
|
||||
if (state->body_too_large ||
|
||||
(strncmp(url, "/api/v1/sessions/", strlen("/api/v1/sessions/")) != 0 &&
|
||||
state->body_size > TRAINLOG_HTTP_LEGACY_BODY_LIMIT)) {
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_CONTENT_TOO_LARGE, "{\"error\":\"body_too_large\"}\n", NULL);
|
||||
}
|
||||
|
|
@ -729,6 +780,188 @@ 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;
|
||||
|
|
|
|||
1213
tui/src/web_sessions.c
Normal file
1213
tui/src/web_sessions.c
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 sync_generation_archives;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw,"PRAGMA foreign_keys=OFF;DROP TABLE session_preparation_deliveries;DROP TABLE session_preparation_requests;DROP TABLE session_preparation_entries;DROP TABLE session_preparation_revisions;DROP TABLE session_preparations;DROP TABLE sync_generation_archives;DROP TABLE sync_causal_publications;DROP TABLE sync_acknowledgements;DROP TABLE sync_consumed_generations;DROP TABLE sync_generation_artifacts;DROP TABLE sync_generations;DROP TABLE sync_peer_identity;PRAGMA user_version=20;",NULL,NULL,NULL)==SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw)==SQLITE_OK);
|
||||
CHECK(trainlog_database_open(path,&database)==TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_exercise_count(database,&exercises)==TRAINLOG_STATUS_OK);CHECK(exercises>0U);
|
||||
trainlog_database_close(database);database=NULL;
|
||||
CHECK(sqlite3_open(path,&raw)==SQLITE_OK);
|
||||
CHECK(scalar(raw,"PRAGMA user_version")==22);
|
||||
CHECK(scalar(raw,"PRAGMA user_version")==23);
|
||||
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_generations")==0);
|
||||
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_consumed_generations")==0);
|
||||
CHECK(scalar(raw,"SELECT COUNT(*) FROM sync_acknowledgements")==0);
|
||||
|
|
|
|||
|
|
@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strncmp(diagnostic, "migrate database to schema v22: SQLite rc=",
|
||||
strlen("migrate database to schema v22: SQLite rc=")) == 0);
|
||||
CHECK(strncmp(diagnostic, "migrate database to schema v23: SQLite rc=",
|
||||
strlen("migrate database to schema v23: 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=23;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=24;", 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 23 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 24 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v22") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v23") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
|
|
@ -382,6 +382,13 @@ 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,
|
||||
|
|
|
|||
148
tui/tests/test_web_sessions.c
Normal file
148
tui/tests/test_web_sessions.c
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
#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\":[]}";
|
||||
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(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,
|
||||
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") == 2);
|
||||
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;
|
||||
}
|
||||
39
web/src/api/sessions.test.ts
Normal file
39
web/src/api/sessions.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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')
|
||||
})
|
||||
})
|
||||
223
web/src/api/sessions.ts
Normal file
223
web/src/api/sessions.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,12 @@ 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',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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'
|
||||
|
||||
|
|
@ -72,7 +73,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]} />
|
||||
|
|
|
|||
|
|
@ -15,5 +15,6 @@ 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]
|
||||
}
|
||||
|
|
|
|||
42
web/src/routes/SessionsPage.test.tsx
Normal file
42
web/src/routes/SessionsPage.test.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { 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('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()
|
||||
})
|
||||
})
|
||||
281
web/src/routes/SessionsPage.tsx
Normal file
281
web/src/routes/SessionsPage.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
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 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) => ({
|
||||
...value, entry_id: initial.kind === 'proposal' ? undefined : value.entry_id,
|
||||
})),
|
||||
...(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' },
|
||||
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>
|
||||
}
|
||||
|
|
@ -58,6 +58,49 @@ 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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue